Repository navigation
fix(server): tell the provider when a restart killed its background work - #13895
juliusmarminge merged 8 commits into
Conversation
Startup recovery cancels background subagents, shells and monitors that outlived a settled turn, but the provider session resumes from its own transcript and still expects them to report back. Recovery now records the cancelled work on the thread's latest started run (run.restartCancelledBackgroundWork, written through its run.updated event). The next run that reaches the provider prepends a short note to its input, through the same prefix the context handoff uses. The note stays pending until a later prompted provider turn exists, so it is derived from runs and provider turns rather than cleared, and is delivered once. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
| records: message.context?.records ?? [], | ||
| }); | ||
| // Delivered once: this run's provider turn marks the work as told. | ||
| const restartCancelledWork = pendingRestartCancelledBackgroundWork({ |
There was a problem hiding this comment.
🟠 High orchestration-v2/ProviderTurnStartService.ts:905
A run on a different provider thread can consume the restart note, so cancelled background work on the current provider thread is never reported when the user switches back. pendingRestartCancelledBackgroundWork receives all projection.runs and projection.providerTurns; scope both inputs to providerThread.id before calculating the note.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/orchestration-v2/ProviderTurnStartService.ts around line 905:
A run on a different provider thread can consume the restart note, so cancelled background work on the current provider thread is never reported when the user switches back. `pendingRestartCancelledBackgroundWork` receives all `projection.runs` and `projection.providerTurns`; scope both inputs to `providerThread.id` before calculating the note.
Thread transfer impact✅ Thread transfer remains within every enforced ceiling.
Baseline: unavailable · PR result: Scenario and decoded snapshot size10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.
Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed. |
ApprovabilityVerdict: Not approved Macroscope's review found this PR not approvable — This is a substantial cross-cutting runtime change that records restart-cancelled work, persists new state, and injects provider-facing recovery context on later turns; it is more than a narrowly scoped bug fix. An unresolved high-severity finding also concerns provider-thread delivery behavior, so the recovery and delivery paths need human review. Not approved because:
Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more. |
…ross providers Record restart-cancelled background work with its own run.background-work-cancelled event, which the projectors merge onto the run's optional field, instead of a full run.updated snapshot that could overwrite a lifecycle change made after recovery read it. Scope the pending note to the provider thread that lost the work, so a turn on another provider after a switch neither receives nor consumes it. Cap the note at ten entries and count it in the context handoff budget. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…d roster labels A run.updated snapshot without restartCancelledBackgroundWork replaced the run's payload wholesale, so a stale or concurrent update erased the note before it was delivered. The SQL upsert and the in-memory projector now keep the recorded field, the same way they keep delegatedCompletion. Roster task labels appended the raw task id after the compacted description, so a long id could exceed the label cap. The whole label is now compacted. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…t lost it Recovery recorded all cancelled background work on the thread's latest started run. After a provider switch that run belongs to the new provider, so the note was stored where no turn on the losing provider thread reads it and never delivered. Group the work by provider thread and record each group on that provider thread's latest started run. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…v2/restart-cancelled-background-note
…pat tests run.background-work-cancelled is now a known event, so it no longer stands in for a newer server's type. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Entries now carry the turn item or provider task id, and merging dedupes by it. Rows recorded before ids existed still fall back to kind and label. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A steer restarts the run on a new attempt; if an earlier attempt already reached the provider with the note, the replacement skips it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
When the server restarts after a turn has settled while its background work was still running (Claude/Codex background subagents, background shells, monitors), startup recovery marks that work cancelled. The provider session then resumes from its own transcript, which has no record of the death, so the model keeps waiting for work that will never report back. Only T3 knows it happened.
What changed
subagent/command_execution/dynamic_toolitems, plus the provider-thread roster (the Claudelocal_bashpath), deduped by native id. It records a short{ kind, label }list on the thread's latest started run with a small new event,run.background-work-cancelled({ runId, restartCancelledBackgroundWork }). The projectors merge only that optional run field (SQL:json_seton the row's payload). A run snapshot read before the commit therefore can't regress a lifecycle change made since, such as a checkpoint completing the run. Work on a running run is not recorded, because it dies with its turn and the existing "Continue" path covers that. Shutdown reconciliation records too, since a graceful restart cancels the work there first.ProviderTurnStartServiceprependsNote: the T3 server restarted, and this background work was cancelled before it finished. It will not report back:plus the list to the provider input. It uses the samestartWithHandoffsprefix (…\n\nUser message:\n<text>) as context handoffs. "Delivered" is derived, not cleared: the note is pending until a later run's attempt has a provider turn. Compactions and restart continuations don't count as delivery (Codex resumes those without a prompt). Rolled-back runs don't count either. The note belongs to the provider thread that lost the work: after a provider switch, turns on the other provider neither receive it nor consume it. It is bounded (≤10 entries plus "and K more", each label ≤160 chars) and counted in the handoff budget, since it travels with the user text.v2/grok-bg-restartmakes them trackable. The ACP adapter is untouched.runOrchestratorV2ProviderReplayScenariogets opt-inrecoverOnStartup(runsProviderRuntimeRecoveryService.recoverbefore the effect worker starts, asserverRuntimeStartupdoes) andcontinueThreadsAfterServerUpdate.Verification
OrchestratorReplayRestartBackgroundNote.integration.test.ts. It replays the recordedclaude_background_subagent_after_rootsession across two runtimes and one DB, cut by a restart right after the root turn's result, with the subagent still running. The fresh runtime runs startup recovery, then two user turns. The replay runner pins each outboundprompt.offer: the first carries the note exactly once, the second doesn't. The subagent endscancelled, and the timeline's user messages are unchanged.ProviderTurnStartServicechange (ClaudeReplayFrameMismatchErroratprompt.offer:1).prompt.offer:2carries the note again).ProviderRuntimeRecoveryService.test.ts: the waiting-run case now asserts that norun.updatedis emitted and the work is recorded throughrun.background-work-cancelled(roster and item deduped).ProjectionStore.test.ts: a run that completed after recovery's read stayscompletedonce the event applies (fails with a full-snapshotrun.updated).RestartBackgroundNote.test.ts: on a switch Claude→Codex→Claude, the note is not sent to or consumed by Codex and is delivered on return to Claude (fails without the provider-thread scoping). The note is capped at 10 entries plus "and 15 more", and labels at ≤160 chars.vp test runover ProviderRuntimeRecoveryService{,.regression}, RestartContinuation, ProviderTurnStartService, OrchestratorReplayRecovery, OrchestratorReplayRestartBackgroundNote, and OrchestratorReplayFixtures (full replay suite): all pass.vp test runon client-runtimeorchestrationV2Projection.test.ts.tsc --noEmitfor apps/server, packages/contracts, packages/client-runtime, apps/web and apps/mobile: clean.knip(files/deps, plus--exportsfor server, contracts and client-runtime): clean.vp lint/vp fmton touched files: clean.client-runtimeonly (the projection reducer; the awareness relay ignores it), with no UI change.Model: Claude Opus 5.5 (Claude Code)
🤖 Generated with Claude Code