Skip to content

P1: Close eight run-lifecycle races (double fork, verify window, checkpoint vs next turn, unguarded worktree IPC) #1430

Closed

Description

@currentbits

Found in the T3 Orchestrator V2 deep dive (report: reports/T3 Code orchestrator deep dive.md on branch coder/i-want-you-to-research-t3-codes-orchestr-fae77a). Everything below comes from reading the code at fbba4e10. None of it was reproduced live. A scan of the live store found no sign of R1 yet.

A run-long per-thread lock is the wrong fix. It would deadlock whenever a thread calls thread_stop on itself or thread_merge onto its own tree. A lock wouldn't help startRun either: there are no awaits between the active.has check (runner.js:8229) and the preparing entry (:8644). Fix the races directly instead.

Phase A: targeted fixes, no lock (~80–120 lines)

ID Race Where Fix
R1 An orchestrator thread's first-prompt fork hop holds no active entry while it waits on the pool. A second send forks again, Stop does nothing during the hop, and a worker that fails synchronously has its wake notice forked as if it were a task. runner.js:8318-8394, 7766-7799 Register the same preparing entry the main path uses, and delete it in finally only if it's still ours. Call abortIfCancelled between workers. Run the hop only when pendingFork && !input.fromNotice.
R2 The verify gate sets the thread to working without registering it as active. A user turn, notice or quota timer can start a run mid-verify, verify then overwrites that run's status, and Stop can't cancel verify. runner.js:1496-1516, 1707-1711 Make verify an active entry {kind:"verify", runId, stopping, handle:{kill}}. Write status only while active.get(id)?.runId still matches.
R3 The checkpoint on the non-verify path is fire-and-forget, so it can race the next queued turn and commit that turn's edits under the previous message id. runner.js:1526-1532 Await the checkpoint before finishSuccessfulTurn. #1427 needs this too.
R4 Rewind and restore check thread state, await, then reset, leaving a gap. services.js:4140-4210, worktrees.js:5450-5510 Phase B lock on the IPC entry points.
R5 A synchronous throw after the preparing entry is registered leaks it, and the thread reports "already active" forever. runner.js:8644-8704 try/catch that deletes the entry if it's still ours.
R6 The Codex session-writer check ignores preparing entries, which reopens the #1265 failure mode. runner.js:1221-1232 codexSessionHeld counts a preparing Codex entry with the same sessionId as holding the session. This invariant spans threads, so a per-thread lock can't fix it.
R7 The git:mergeWorktree and git:removeWorktree IPC handlers have no running guard. ipc.js:1714-1720 Refuse while the thread is running, reusing the requireLandableWorker wording.
R8 steerRun appends its row with a runId it read before awaiting. runner.js:8805-8851 Re-read the active entry after the await.

Phase B: a lock held only during transitions (~25 lines plus wiring)

// ponytail: one promise chain per thread; never held across a run or verify.
const tails = new Map();
function withThreadLock(id, fn) {
  const prev = tails.get(id) || Promise.resolve();
  const run = prev.then(fn, fn);
  const tail = run.catch(() => {});
  tails.set(id, tail);
  tail.then(() => { if (tails.get(id) === tail) tails.delete(id); });
  return run;
}
  • Wrap: startRun (rename the body to startRunInner and rebind it; internal callers are at runner.js 1405, 1730, 2103, 3235, 3297, 3345, 7779 and 8351), the public stopRun (the recursion in stopCrew calls the inner version), and the rewind / undo-rewind / restoreCheckpoint IPC entry points.
  • Never wrap: notice flush, enqueue, drainQueued, maybeCreateCheckpoint, verify, stopAll, steerRun, thread_merge.
  • Stop: sets stopping on a preparing entry before waiting for the lock (the P1: Honor Stop while a run is preparing to launch #1228 fast path).
  • Ordering: never nest the same key. Cross-thread work takes locks one after another, lead before worker.

Tests

  • stop-during-prepare: Stop during the fork hop; a synchronous throw leaves no entry behind.
  • orchestrator-threads: two concurrent sends create one worker; a notice is never forked.
  • verify-gate: a start during verify stays queued; Stop cancels verify; verify never overwrites a newer run.
  • checkpoints: a queued follow-up starts only after the checkpoint commit.
  • codex-writer-lock: the preparing case.
  • A unit test for withThreadLock.

Phase A first. It should land before #1427 (which needs R3) and before any new lifecycle entry points such as lead steering or PR-watch wakes. #1428's replay harness makes R1, R2 and R3 testable without sleeps.

Activity

  1. currentbits commented on Oct 4, 2026

    @currentbits
    OwnerAuthor

    Repro results at 615b38a0, in a CI-comparable env against the real runner, store and git. The repro tests assert the correct behaviour, so they fail on current code. They're in research_notes/T3 orchestrator ACP UX and repros/repros/ on branch coder/i-want-you-to-research-t3-codes-orchestr-fae77a.

    ID Verdict Correction to the fix in this issue
    R1 3 of 4 reproduced: a double fork from two sends; a peer notice forked as a worker's task (any notice arriving mid-hop is affected, not only fromNotice turns); Stop during /committee still starting the second worker. Not reproduced: Stop during the single-worker fork hop, where the crew cascade stops the preparing worker and only the lead's transcript is misleading Broaden the notice guard to every notice arriving mid-hop. Drop the single-hop Stop sub-claim
    R2 Reproduced. A notice starts a run while verify is running, and verify then overwrites that run's status with done. Stop has no effect on verify, and verify's success then starts the queued follow-up The verify entry must also cancel or hold the follow-up drain when stopped
    R3 Reproduced 5/5. The turn-1 checkpoint contains turn 2's file, and its message-id trailer names turn 2's prompt Awaiting the checkpoint isn't enough. notifyRunTerminal drains the queue a second time (runner.js:2067-2078). Also add status !== "done" && to that condition. With both parts, R3 passes 3/3 and the neighbouring suites stay green (69/69)
    R5 Reproduced (the CLI binary vanishing during first-turn prefetch). It's not "forever": Stop clears it, and the stall checker only warns after 10 minutes The catch must also reset the thread's status to failed. Deleting the leaked entry alone leaves it showing working
    R7 Reproduced at the IPC layer: merge and remove delete a live run's working directory. The UI already disables those buttons while a thread is working, so exposure is via IPC, web and pairing callers Unchanged

    Q1, answered: Stop already holds a queued follow-up (queued-drain tests, 6/6). The exception is Stop during verify (R2).

    Not driven: the git:prMerge path, the web bridge, and eject (Stop without the crew cascade).

    Suggested order: land these repro files with #1428's harness, then fix Phase A against them.

  2. currentbits commented on Oct 9, 2026

    @currentbits
    OwnerAuthor

    One more race for this list, re-verified on main @24d3c36b: a send during rollback can undo it. electron/worktrees-checkpoints.js:445 checks isRunning once, then awaits listCheckpoints (:463), maybeCreateCheckpoint (:476) and reset --hard (:478) before clearing sessionId (:495). There is no per-thread lock across those awaits. A message sent in that window resumes the old session, and its session id can overwrite the cleared one, so the agent continues from the pre-rollback conversation. Fix: take the same per-thread run lock that sends take, for the whole rollback.

  3. added and removed on Oct 12, 2026
  4. currentbits commented on Oct 12, 2026

    @currentbits
    OwnerAuthor

    Phase A (R1, R2, R3, R5, R6, R7, R8) is committed as 3f6c35c3 on branch coder/p1-close-eight-run-lifecycle-races-doubl-fc27bb. It is not pushed yet.

    • R1: the orchestrator hop (pendingFork and /handoff dispatch) holds a preparing entry via holdForHop. Notices skip the fork path. A Stop during the hop stops any worker that slipped past stopCrew, and dispatch starts no further workers.
    • R2: verify is now an active entry {kind:"verify"}. Stop kills the command through an AbortSignal, and releaseVerify gates every status write.
    • R3: the checkpoint is awaited before finishSuccessfulTurn. The duplicate drain in notifyRunTerminal is removed.
    • R5: a throw after the preparing entry is registered now clears it, but only while it's still ours and still preparing.
    • R6: codexSessionHeld now counts a preparing entry on a Codex thread.
    • R7: git:mergeWorktree and git:removeWorktree refuse while runner.isRunning.
    • R8: steerRun throws instead of filing the row when the run changed during the send.

    Tests: each race has one regression test, and all 10 fail on the pre-fix source. The full electron suite has 4668 passing and 2 failing. Both failures also happen on clean HEAD: store.test.js round-trip and automation-create-mcp GitHub trigger.

    Still open: R4 and the Phase B withThreadLock.

  5. currentbits commented on Oct 12, 2026

    @currentbits
    OwnerAuthor

    Phase A is up for review as PR #1609 (rebased onto main at 3b650a5, commit c07e696). R4 and Phase B are still open.

  6. added a commit that references this issue on Oct 12, 2026
  7. added and removed on Oct 12, 2026
  8. currentbits commented on Oct 12, 2026

    @currentbits
    OwnerAuthor

    Landed from Solenta thread "P1: Close eight run-lifecycle races (double fork, verify win". Closed on merge.

  9. added a commit that references this issue on Oct 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions