fix(server): Stop always ends the background work a thread shows - #14636
Conversation
The Waiting strip's Stop was accepted but could be dropped: the control service skipped any settled turn whose adapter has no per-thread pending-work probe, the Codex probe missed work its process no longer tracked, and with no live session the Stop was rejected. The strip kept showing the work. A settled Stop now always reaches the adapter, which stops what it still runs or reports nothing is left. Afterwards a settle command marks interrupted whatever the thread still shows on that provider thread, or on provider threads with no live session; with no live session the Stop settles directly. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ApprovabilityVerdict: Not approved Macroscope's review found this PR not approvable — This PR substantially changes Stop behavior across multiple production providers, including aborting descendants, terminating background processes, and durably settling orphaned work through a new orchestration path. The cross-provider runtime and process-lifecycle impact is broader than a small self-contained fix. You can add or adjust custom eligibility rules. Learn more. |
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. |
4d058d4 to
813da49
Compare
…ss left A settled-turn Stop on a Claude session whose CLI process had already exited failed with "has no live query", so the settle follow-up never ran and the Waiting strip kept the work. A settled-turn Stop with nothing live is now a no-op success in the Claude adapter too, as in Codex, Cursor, Pi, OpenCode and ACP; it also drops the gone process's roster. The settle follow-up now carries the stopped provider turn and ends only that run's and older runs' background work, so a later run's work survives an earlier Stop. A settle that ends nothing records its receipt, so a replayed Stop effect hits it instead of settling work that appeared since. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
When the latest run was interrupted before its provider turn started, it has no provider turn, so Stop found nothing to interrupt and failed with "Run ... is not interruptible". The orchestrator now falls back to the provider thread's latest turn when background work is pending. Upstream pingdotgg#14636 already reads background work thread-wide and lets a settled Stop reach the adapter, so only this fallback is carried. The integration test covers Claude and Codex scenarios from one harness. The "Use T3 subagents only" setting notes that it applies to Claude sessions started after the change. Squashes fork commits 99b07da and 85d5723. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the latest run was interrupted before its provider turn started, it has no provider turn, so Stop found nothing to interrupt and failed with "Run ... is not interruptible". The orchestrator now falls back to the provider thread's latest turn when background work is pending. Upstream pingdotgg#14636 already reads background work thread-wide and lets a settled Stop reach the adapter, so only this fallback is carried. The integration test covers Claude and Codex scenarios from one harness. The "Use T3 subagents only" setting notes that it applies to Claude sessions started after the change. Squashes fork commits 99b07da and 85d5723. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the latest run was interrupted before its provider turn started, it has no provider turn, so Stop found nothing to interrupt and failed with "Run ... is not interruptible". The orchestrator now falls back to the provider thread's latest turn when background work is pending. Upstream pingdotgg#14636 already reads background work thread-wide and lets a settled Stop reach the adapter, so only this fallback is carried. The integration test covers Claude and Codex scenarios from one harness. The "Use T3 subagents only" setting notes that it applies to Claude sessions started after the change. Squashes fork commits 99b07da and 85d5723. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the latest run was interrupted before its provider turn started, it has no provider turn, so Stop found nothing to interrupt and failed with "Run ... is not interruptible". The orchestrator now falls back to the provider thread's latest turn when background work is pending. Upstream pingdotgg#14636 already reads background work thread-wide and lets a settled Stop reach the adapter, so only this fallback is carried. The integration test covers Claude and Codex scenarios from one harness. The "Use T3 subagents only" setting notes that it applies to Claude sessions started after the change. Squashes fork commits 99b07da and 85d5723. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pressing Stop on the composer's "Waiting on …" strip could do nothing. The orchestrator accepted the Stop, but
ProviderTurnControlServicedropped it for any settled turn whose adapter has no per-thread pending-work probe (every adapter except Claude and Codex). Codex's probe also missed work its process no longer tracked, and with no live provider session the Stop was rejected outright. The strip kept showing work nothing would ever end.Fix
ProviderTurnControlService.interruptnow forwards a settled turn's Stop withrequestRuntimeRestart: trueinstead of returning early. The adapter stops whatever it still runs, or reports there's nothing left. It still returns early in one case: no live session, so no adapter to call.taskchild can outlive its turn.thread.background-work.settle. It marksinterruptedany background item (command_execution,dynamic_tool,subagent) the thread still shows on that provider thread, or on provider threads with no live session. It also clears the pending-work roster of a dead process. It does nothing if a new run has started since the Stop.pendingBackgroundTurnItems(shared) is the selection the strip and the settle now both use, so they can't disagree.ProjectionStoregains aturnItemStatusesrecord filter so Stop reads only active items, not a thread's whole history.Provider specifics stay in the adapters. The orchestrator only settles projected items that no live process will report on.
What Stop now does, per provider
item/completed) is settledinterruptedrunning, so the regular Stop appliesinterruptedtaskchild sessionsCodex: is the stale item an adapter bug?
I checked against Codex 0.156.1 source. Codex always sends
item/completedfor a background terminal, with the original turnId, including afterturn/completed:core/src/unified_exec/async_watcher.rs:165-247.failed): terminate, clean, prune and timeout, inprocess_manager.rs:1725,1798,1835and:649-657.core/src/tasks/mod.rs:782.resolveItemEventContext/settledTurns.So there is no adapter bug to fix with a fixture. A projected item outlives its command only when the notification never reaches T3:
effect-codex-app-server/src/client.ts:165-170).The maintainer's thread probably ran on a Mac, so I couldn't inspect it here.
Verification
All server tests ran inside
unshare -U --map-current-user -p -f --mount-proc. The branch contains the pid-1 group-kill guard (6562235).CodexAdapterV2.test.ts, "Stop ends a background command no Codex process tracks any more". Through the orchestrator: a Codex background command is still running when the app-server exits. On base the command staysrunning.stop_background_work_after_failed_turn/acpRegistryreplay fixture, for an adapter without the probe. An ACP prompt fails while anexecutetool is in progress; the frames are hand-ported from the registry tool frames plus the recordedgrok_prompt_errorerror frame. On base the strip still listsdev-serverafter Stop.stop_background_work_after_release/acpRegistryreplay fixture. The same thread after the idle timeout released its session. On base Stop is rejected with "Provider session … is not active".ClaudeAdapterV2.test.ts, "a settled Stop with no CLI process left succeeds and clears the roster". The CLI exits after the turn settled with a background task on the roster. On 813da49 Stop fails with "has no live query".Orchestrator.control-reads.test.ts, "settles only the stopped run's background work, once". Two settled runs each have a running background command. The settle for run 1's Stop leaves run 2's command running. A settle that ended nothing replays as a no-op after new work appears. On 813da49 run 2's command is interrupted.claude_background_task_interrupt;OrchestratorReplayFixtures.integration.test.ts,CodexReplayFixtures.integration.test.ts,ProviderTurnControlService.test.ts,EffectWorker.test.ts,ProjectionControlReads.test.ts, and the Codex, Claude, Cursor, Pi and OpenCode adapter tests. 470 passed.ProjectionControlReads.test.tsasserted the old drop and was updated.packages/sharedorchestrationV2PendingBackgroundWork.test.ts: 31 passed.Orchestrator.control-reads.test.ts,EffectWorker.test.ts,ProjectionControlReads.test.ts,ProviderTurnControlService.test.ts,OrchestratorReplayFixtures.integration.test.tsand.contract.test.ts,ClaudeReplayFixtures.integration.test.ts,CodexReplayFixtures.integration.test.ts,CodexAdapterV2.test.ts,ClaudeAdapterV2.test.ts. 10 files, 395 passed.AcpAdapterV2.test.ts, outside the namespace (its process-tree tests need real process groups), 5 runs: 5 of 5 runs, 114/114 each (the 5 failures the first run reported were from running it inside the namespace). CI's "carries a live subagent lineage across an interrupt" failure on 813da49 is a timing flake, not this change. This PR touches no ACP adapter, runtime, test or mock-agent code, and that test callsinterruptTurnwithoutrequestRuntimeRestart, directly on the adapter, so the always-forward change inProviderTurnControlServiceis not on its path.tsc --noEmitexits 0 with 0 errors for apps/server and packages/contracts (re-run on 4743813); packages/shared unchanged since.vp linton the touched files shows only pre-existing warnings.knipreports nothing in the files this PR touches.Model: Claude Opus 5.5 (Claude Code)
🤖 Generated with Claude Code