Skip to content
Source
Sign inSign up

fix(server): Stop waits for the turn to settle instead of polling #35948

Mergedjuliusmarmingemerged 1 commit intomain fromrefs/pull/35948/headmerged by juliusmarminge View on GitHub

juliusmarmingecommented

Problem. On Stop, ProviderTurnControlService.interrupt sent the interrupt, then polled the projection every 10 ms on the forced real clock, for up to 2 s, until the provider turn stopped running. The poll never checked the run attempt, so Stop could return before the provider's turn.terminal was ingested and RunExecutionService.finalizeRootRun committed run-attempt.updated. The thread.background-work.settle follow-up then repaired the run first. When a usage limit lands just as Stop arrives (TaskCancelUsageLimit), the repair wins and the run never becomes failed.

Fix. Stop reads the latest thread sequence, checks the projection, and follows EventSink.stream({ threadId, afterSequence }). It re-reads on provider-turn.updated / run-attempt.updated until both the turn and its attempt stop running. The wait is bounded with Effect.timeoutOption("2 seconds") on the normal Clock, with no polling and no forced wall clock. EventSink is wired into runtimeLayer.ts and the replay harness.

Two SteeringCompletion fakes needed to match real adapters:

  • The nextTurnSelectionHarness fake now ends an interrupted turn with turn.terminal, as every real adapter does (audit below). Before, the attempt only settled in the follow-up, so Stop waited out its bound.
  • startFirstTurn raced RunExecution's root-turn ownership check: the test rewrote the run to preparing before the check ran. On main that check's "started after Stop" cleanup interrupts the turn, which was harmless only because the old fake never terminated it. startFirstTurn now takes the thread command lock once, so it continues only after the check. The replay harness exposes ThreadCommandExecutor for this.

Adapter audit. After T3 interrupts a running turn, does the adapter emit the turn.terminal that leads finalizeRootRun to settle the attempt?

AdapterRunning turnAlready finished turnEvidence
Codexyes: turn/interrupt, then turn/completed (interrupted) → emitOrDeferRootTerminalnot active → returns (restart) or a protocol error; projection already settled, so Stop does not waitCodexAdapterV2.ts ~6746, interruptTurn ~7847; fixtures turn_interrupt, turn_interrupt_mid_tool
Claudeyes: query.interrupt + close; query close emits terminal interruptedno active turn → early returnClaudeAdapterV2.ts ~9104, ~12687; fixtures turn_interrupt, turn_interrupt_mid_tool, turn_interrupt_restart, claude_background_task_interrupt
ACP generic (Grok, Kiro, Antigravity, ACP registry)yes: finalizeTurn(context, "interrupted") on every branch (session close, hard restart, settled soft), or on prompt return after cancel; then awaits context.completedno active turn → "already interrupted" early returnprovider-acp/src/server/adapter.ts finalizeTurn ~7756 (emits at ~7903), interrupt ~9045/9102/9284; fixtures turn_interrupt (grok/kiro/registry), kiro_turn_interrupt_mid_tool, kiro_subagent_interrupt
Cursoryes: finalizeTurn({ status: "interrupted" })no context → returnprovider-cursor/src/server/adapter.ts ~2835, terminal ~2190; fixture turn_interrupt_mid_tool
OpenCode v1yes: session.abort, then the session idle/error path emits terminalno turn → returnprovider-opencode/src/server/adapter.ts ~3505, terminal ~2115; fixture turn_interrupt
OpenCode v2yes: finishTurn(state, { status: "interrupted" })not active → returnprovider-opencode/src/server/v2/adapter.ts ~4911, terminal ~2276; fixture opencode2_interrupt
Piyes: abort → terminalnot active → return / protocol errorprovider-pi/src/server/adapter.ts ~3084, terminal ~1816; fixture turn_interrupt_mid_tool

All 10 *ReplayFixtures.integration.test.ts files pass with this change: 172 tests. They run on TestClock, so a Stop that never saw a terminal would have hung at the bound instead of passing.

Before/after

  • SteeringCompletion.integration.test.ts: with the Stop change alone, 13 tests hung to the 120 s test timeout. With the fixed fakes, all 60 pass in 45 s.
  • ProjectionControlReads.test.ts: the new case fails (hangs) on main's polling code and passes with the fix.

Verification (server tests run sandboxed in a PID namespace)

  • Replay fixtures: Codex/Claude/Cursor/Pi 83 ✓; AcpRegistry/Grok/Kiro/OpenCode/Fx/Muse 89 ✓
  • SelectionRestart, ThreadStop, BackgroundWorkStop, ProjectionControlReads, ProviderTurnControlService: 69 ✓
  • TaskCancelUsageLimit, ProviderSwitch, OrchestratorMcpService (+activity): 139 ✓
  • DelegatedCompletionDelivery, Orchestrator.control-reads, RestartContinuation, ThreadFork.execution, runtimeLayer: 315 ✓
  • EffectWorker, RunExecutionService, ProviderTurnStartService, ThreadSettlementService, ThreadManagementService: 199 ✓
  • ProviderSessionManager, ProviderRuntimeRecoveryService, ProviderCommitBatch, LegacyV1Cutover: 106 ✓
  • vp run check:changed: format, lint, knip, typecheck, 7 changed test files (242 tests) ✓

Done by Claude Opus 5.5 in Claude Code.

🤖 Generated with Claude Code

    1. github-actions[bot](GitHub profile, opens in a new tab) added labelsvouch:trusted

    2. Thread transfer impact

      ✅ Thread transfer remains within every enforced ceiling.

      ℹ️ The exact PR base did not have a successful artifact. Baseline uses the latest successful main measurement shown below.

      ProviderMetricMain baselineThis PRImpactPR ceiling
      CodexTotal thread wire5.0 KiB5.0 KiB0 B (0.0%)6.8 KiB✅
      CodexThread snapshot wire3.8 KiB3.8 KiB0 B (0.0%)4.9 KiB✅
      CodexLive turn WebSocket wire1.2 KiB1.2 KiB0 B (0.0%)2.0 KiB✅
      CodexLive turn WebSocket decoded21.5 KiB21.5 KiB0 B (0.0%)24.4 KiB✅
      CodexLive turn messages110 (0.0%)8✅
      ClaudeTotal thread wire5.0 KiB5.0 KiB0 B (0.0%)6.8 KiB✅
      ClaudeThread snapshot wire3.8 KiB3.8 KiB0 B (0.0%)4.9 KiB✅
      ClaudeLive turn WebSocket wire1.2 KiB1.2 KiB0 B (0.0%)2.0 KiB✅
      ClaudeLive turn WebSocket decoded21.9 KiB21.9 KiB0 B (0.0%)24.4 KiB✅
      ClaudeLive turn messages110 (0.0%)8✅

      Other scenarios

      All 64 scenario measurements match main.

      All 64 scenario measurements
      ProviderScenarioMetricMain baselineThis PRImpactPR ceiling
      Codexshort turn resumeWebSocket decoded10.5 KiB10.5 KiB0 B (0.0%)12.7 KiB✅
      Codexshort turn resumeWebSocket messages220 (0.0%)2✅
      Codexlong turn running, cold openHTTP decoded281.7 KiB281.7 KiB0 B (0.0%)302.7 KiB✅
      Codexlong turn running, cold openrows75750 (0.0%)75✅
      Codexlong turn running, history pageHTTP decoded92.4 KiB92.4 KiB0 B (0.0%)107.4 KiB✅
      Codexlong turn running, history pagerows75750 (0.0%)75✅
      Codexlong turn finished, cold openHTTP decoded381.5 KiB381.5 KiB0 B (0.0%)410.2 KiB✅
      Codexlong turn finished, cold openrows1511510 (0.0%)151✅
      Codexlong turn finished, socket cold openWebSocket decoded381.6 KiB381.6 KiB0 B (0.0%)410.2 KiB✅
      Codexlong turn finished, socket cold openWebSocket messages220 (0.0%)2✅
      Codexlong turn finished, socket cold openrows1511510 (0.0%)151✅
      Codexlong turn finished, work pageHTTP decoded186.2 KiB186.2 KiB0 B (0.0%)200.2 KiB✅
      Codexlong turn finished, work pagerows1501500 (0.0%)150✅
      Codexidle thread resumeWebSocket decoded65 B65 B0 B (0.0%)200 B✅
      Codexidle thread resumeWebSocket messages110 (0.0%)1✅
      Codexfleet shell snapshotHTTP decoded42.4 KiB42.4 KiB0 B (0.0%)53.7 KiB✅
      Codexfleet shell snapshotrows27270 (0.0%)27✅
      Codexfleet shell subscribeWebSocket decoded65 B65 B0 B (0.0%)200 B✅
      Codexfleet shell subscribeWebSocket messages110 (0.0%)1✅
      Codexfleet shell resume checkHTTP decoded18 B18 B0 B (0.0%)100 B✅
      Codexfleet shell resume checkrows0000✅
      Codexstale shell reloadHTTP decoded42.4 KiB42.4 KiB0 B (0.0%)53.7 KiB✅
      Codexstale shell reloadrows27270 (0.0%)27✅
      Codexstale shell resumeWebSocket decoded140 B140 B0 B (0.0%)400 B✅
      Codexstale shell resumeWebSocket messages220 (0.0%)2✅
      Codexstale todo list cold openHTTP decoded72.4 KiB72.4 KiB0 B (0.0%)83.0 KiB✅
      Codexstale todo list cold openrows75750 (0.0%)75✅
      Codexstale todo list cold opennodes220 (0.0%)4✅
      Codexstale todo list cold opensubagents220 (0.0%)4✅
      Codexstale todo list cold openplans110 (0.0%)2✅
      Codexstale todo list cold openproviderTurns110 (0.0%)2✅
      Codexstale todo list cold opencheckpoints110 (0.0%)2✅
      Claudeshort turn resumeWebSocket decoded10.6 KiB10.6 KiB0 B (0.0%)12.7 KiB✅
      Claudeshort turn resumeWebSocket messages220 (0.0%)2✅
      Claudelong turn running, cold openHTTP decoded283.3 KiB283.3 KiB0 B (0.0%)302.7 KiB✅
      Claudelong turn running, cold openrows75750 (0.0%)75✅
      Claudelong turn running, history pageHTTP decoded93.4 KiB93.4 KiB0 B (0.0%)107.4 KiB✅
      Claudelong turn running, history pagerows75750 (0.0%)75✅
      Claudelong turn finished, cold openHTTP decoded384.2 KiB384.2 KiB0 B (0.0%)410.2 KiB✅
      Claudelong turn finished, cold openrows1511510 (0.0%)151✅
      Claudelong turn finished, socket cold openWebSocket decoded384.3 KiB384.3 KiB0 B (0.0%)410.2 KiB✅
      Claudelong turn finished, socket cold openWebSocket messages220 (0.0%)2✅
      Claudelong turn finished, socket cold openrows1511510 (0.0%)151✅
      Claudelong turn finished, work pageHTTP decoded188.1 KiB188.1 KiB0 B (0.0%)200.2 KiB✅
      Claudelong turn finished, work pagerows1501500 (0.0%)150✅
      Claudeidle thread resumeWebSocket decoded66 B66 B0 B (0.0%)200 B✅
      Claudeidle thread resumeWebSocket messages110 (0.0%)1✅
      Claudefleet shell snapshotHTTP decoded43.0 KiB43.0 KiB0 B (0.0%)53.7 KiB✅
      Claudefleet shell snapshotrows27270 (0.0%)27✅
      Claudefleet shell subscribeWebSocket decoded66 B66 B0 B (0.0%)200 B✅
      Claudefleet shell subscribeWebSocket messages110 (0.0%)1✅
      Claudefleet shell resume checkHTTP decoded18 B18 B0 B (0.0%)100 B✅
      Claudefleet shell resume checkrows0000✅
      Claudestale shell reloadHTTP decoded43.0 KiB43.0 KiB0 B (0.0%)53.7 KiB✅
      Claudestale shell reloadrows27270 (0.0%)27✅
      Claudestale shell resumeWebSocket decoded140 B140 B0 B (0.0%)400 B✅
      Claudestale shell resumeWebSocket messages220 (0.0%)2✅
      Claudestale todo list cold openHTTP decoded73.4 KiB73.4 KiB0 B (0.0%)83.0 KiB✅
      Claudestale todo list cold openrows75750 (0.0%)75✅
      Claudestale todo list cold opennodes220 (0.0%)4✅
      Claudestale todo list cold opensubagents220 (0.0%)4✅
      Claudestale todo list cold openplans110 (0.0%)2✅
      Claudestale todo list cold openproviderTurns110 (0.0%)2✅
      Claudestale todo list cold opencheckpoints110 (0.0%)2✅

      Baseline: ee64555 · PR result: e97f2e5 · Source CI: success

      Scenario and decoded snapshot size

      10 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.

      • Codex decoded thread snapshot: 108.5 KiB
      • Claude decoded thread snapshot: 108.8 KiB

      Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

    3. Approvability

      Verdict: Approved at e97f2e5

      Macroscope's review found this PR approvable — Stop now waits on thread events for the provider turn and its attempt to settle instead of polling. The existing two-second bound remains, keeping this coordination change limited in scope.

      Notes:

      • All code in this push has already been reviewed. Approvability was decided on eligibility alone.
      You can add or adjust custom eligibility rules. [Learn more](https://docs.macroscope.com/approvability#example-custom-eligibility-rules).
    4. Explain Change →

      No actionable comments were generated in the recent review.

      Walkthrough

      ProviderTurnControlService now waits for provider-turn and run-attempt updates after interrupting a running turn. The wait ends when both are settled or after two seconds. Runtime layers provide EventSinkV2, and tests cover timeout and settlement paths.

      Changes: Provider turn stop settlement

      Layer / File(s)Summary
      Event-driven settlement
      apps/server/src/orchestration-v2/ProviderTurnControlService.ts, apps/server/src/orchestration-v2/runtimeLayer.ts, apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts
      ProviderTurnControlService waits for provider-turn and run-attempt updates to confirm settlement, with a two-second timeout. Runtime and replay layers provide EventSinkV2. The replay runtime also provides ThreadCommandExecutor.
      Settlement and interruption tests
      apps/server/src/orchestration-v2/ProjectionControlReads.test.ts, apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts, apps/server/src/orchestration-v2/BackgroundWorkStop.integration.test.ts, apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts
      Tests cover timeout and confirmed settlement. Integration tests synchronize with provider interrupts, emit terminal interruption events, and wait for the thread command lock.

      Estimated code review effort: 3 (Moderate) | ~20 minutes

      Change: Bug fix

      Sequence Diagram(s)

      sequenceDiagram
        participant ProviderTurnControlService
        participant ProviderSessionManager
        participant ProjectionStore
        participant EventSinkV2
        ProviderTurnControlService->>ProviderSessionManager: interrupt running turn
        ProviderTurnControlService->>ProjectionStore: check turn and attempt state
        ProviderTurnControlService->>EventSinkV2: watch turn and attempt updates
        EventSinkV2-->>ProviderTurnControlService: deliver update events
        ProviderTurnControlService->>ProjectionStore: check for settlement

      Possibly related issues

      • pingdotgg/t3code#30985 — The issue describes Stop completing after an acknowledged interrupt that does not end the turn. This PR changes Stop to wait for settlement and bounds that wait.

      Suggested labels: bugfix

      Suggested reviewers: maria-rcks, yash-singh1, fireboltdude1357

      Merge Risk: Minimal · up to e97f2

      Stop now waits for the provider turn and its run attempt to settle through events instead of polling, with a two-second fallback. No concrete defect was established in the changed code. The PR author says the real-app Claude delegated-child Stop race could not be run, but that is a normal validation gap and not an identified risk.

      Pre-merge checks: Passed 4

      Check nameStatusExplanation
      Title checkPassedThe title names the main change: Stop now waits for the turn to settle, replacing the 10 ms polling loop. It follows the conventional commit format and matches the objectives and the changed ProviderT…
      Description checkPassedThe description follows the template's Problem, Change, Scope, and Verification sections. The Problem section explains the race and the failing run-attempt repair. The Change section explains the Even…
      Linked Issues checkPassedCheck skipped because no linked issues were found for this pull request.
      Out of Scope Changes checkPassedCheck skipped because no linked issues were found for this pull request.
      Full details: Title check

      Explanation

      The title names the main change: Stop now waits for the turn to settle, replacing the 10 ms polling loop. It follows the conventional commit format and matches the objectives and the changed ProviderTurnControlService.ts. It is specific enough for a teammate scanning history.

      Full details: Description check

      Explanation

      The description follows the template's Problem, Change, Scope, and Verification sections. The Problem section explains the race and the failing run-attempt repair. The Change section explains the EventSink-based settle wait and the wiring changes. The Verification section lists focused test groups, observed counts, and an adapter audit. Scope is weak: it does not link an issue or say why no issue is needed, and it does not say whether a maintainer requested the PR. The objectives also say the delegated-child Stop race and the real-app behavior remain unverified, and the description does not state that limitation. These gaps are non-critical, so the description is mostly complete.

      Finishing Touches

      Generate unit tests (beta)

      • Commit to this branch
      • Create a new PR
      • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
      Recent review info

      Run configuration

      Commits

      Reviewing files that changed from the base of the PR and between 6b60a1d270521682826a3560c7791b284b3df885 and e97f2e5932c7592a7b9e1afe171ceba484aac027.

      Files selected for processing: 2

      • apps/server/src/orchestration-v2/BackgroundWorkStop.integration.test.ts
      • apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts

      Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 0 remain after this review.

    5. juliusmarminge added labelstakeover

    6. juliusmarmingecommented

      The automated pipeline stopped on this pull request and will not merge it:

      the fix is written and reviewed, but proving it needs something no worker has:

      • Permanent blocker: Claude’s native usage-limit flow requires first-party Claude OAuth access, explicitly unavailable on every VM. Could not run the delegated-child Stop race on either commit.
      • Preservation of the failed outcome, captured-attempt settlement, and the two-second fallback bound remain unverified in the real app. No captures were produced.

      It stays open for a person to finish or close.

    7. juliusmarminge added labelstakeover:in-progress

    8. juliusmarminge merged this15d1ef5

    9. juliusmarminge closed this

Sign in to join this conversation.