Skip to content

Delegated-task completion overwrites a just-completed parent run back to waiting, deadlocking the thread behind its own completion notice #20014

Closed

Description

@Dropje97

What happened

A Claude parent thread stayed on "Thinking" for hours after a short, finished turn. All later messages
and scheduled-task wakes queued behind that turn, and none of them started. The parent had sent a
message to an already-finished delegated child with t3_thread_send and then ended its turn. About 15 s
later the child answered. Its completion overwrote the parent run, which had just reached completed,
back to waiting. The completion notice was then queued behind that run, so the two wait for each
other indefinitely.

Diagnosis

Two writers update the same run row without coordination, and one of them writes a stale full-row snapshot.

  1. CheckpointCaptureService loads the run while it is still waiting (:68). It takes capturedAt
    (:106), captures the checkpoint, and then commits {...run, status: "completed", completedAt, checkpointId} (:203). It does not take the thread-dispatch lock.
  2. finalizeAppOwnedSubagentTask (Orchestrator.ts:10131) runs under the parent lock (:11294). It
    reads the parent run, and planDelegatedCompletionDelivery (:9925) returns
    {...input.parentRun, delegatedCompletion}. That object is written as a full-row run.updated (:10338).
  3. preserveRunRecordedFields (ProjectionStore.ts:646) protects only delegatedCompletion and
    restartCancelledBackgroundWork from stale snapshots. status, completedAt and checkpointId come
    from the payload unchanged. Checkpoint capture explicitly omits delegatedCompletion to avoid the
    reverse race (comment at CheckpointCaptureService.ts:131). Nothing protects this direction.
  4. isBlockingRun (Orchestrator.ts:537) treats waiting as blocking. The delivery run is therefore
    created queued behind its own parent. The capture already ran, so nothing moves the parent out of
    waiting again.

The finalize write in the event log is exactly the pre-capture snapshot plus the new cohort. In this
thread, 77 of 78 runs with a delegated-completion cohort settled normally. The one exception is the run
where the child finished 4.9 s after capturedAt. In a case one hour earlier, the gap was 10.5 s, the
run stayed completed, and the delivery run started immediately.

Line numbers refer to 3417bc38. The same code is present on main at 26fffe5a. Later fixes in this
area (#19355, #19631) do not change it.

Steps to reproduce

  1. On a V2 Claude thread, start an app-owned child with delegate_task and let it finish.
  2. In a later turn, call t3_thread_send to that child thread. This creates an app-owned follow-up task
    node on the current parent run. End the parent turn immediately.
  3. Let the child answer while the parent's checkpoint capture is still in progress. The timing window lies
    between finalizeAppOwnedSubagentTask's read of the parent run and its write. A capture commit that lands
    inside that window triggers the bug.
  4. The parent run returns to waiting with completedAt: null. The delivery run and every later message
    stay queued, and the UI shows the turn as working.

This is timing-dependent. For a deterministic repro in a test, commit a checkpoint capture between that
read and that write.

Version

0.0.46-nightly.20261014.3105 (upstream 3417bc38). The server is a local rebuild with a small patch to
SQLite and storage cleanup (persistence/Sqlite.ts, storageCleanup.ts, nodeSqliteClient.ts). The
patch does not touch orchestration-v2.

Environment

Linux (Proxmox LXC), systemd user service, web client. Claude Code 2.1.301 with claude-opus-5-5.
Large statev2.sqlite (~20 GB).

Evidence

# orchestration_events, parent thread stream (UTC)
seq      time          event                        run   state written
2480200  16:43:43.696  subagent.updated             1581  follow-up task node created by t3_thread_send
2480235  16:43:53.779  run.updated                  1581  waiting   completedAt=null  checkpointId=null
2480244  16:43:54.232  run.updated (capture)        1581  completed completedAt=16:43:54.232 checkpointId=<set>
2480246  16:43:59.160  subagent.updated             1581  task completed, completionDelivery=claimed
2480247  16:43:59.160  run.updated (finalize)       1581  waiting   completedAt=null  checkpointId=null  delegatedCompletion={open, gen 1}
2480252  16:44:02.501  run.created                  1582  queued, queuePosition=1, userMessageId = cohort delivery messageId
# no later run.updated for 1581; every later run is created `queued`

# payload diff
2480244 -> 2480247: status completed->waiting, completedAt <ts>->null, checkpointId <id>->null, +delegatedCompletion
2480235 -> 2480247: only delegatedCompletion differs

# same pattern, settled correctly (1 h earlier, same thread)
1577 completed at 15:48:59.867; child finished 15:49:10.341 -> run.updated kept status=completed;
delivery run 1578 created `starting`, ran, cohort settled 15:49:19.886

The provider turn of run 1581 is completed. The Claude session was idle-released 30 min later. The
server trace has nothing from this window because it runs at Warn level.

Related issues

Fix applied or workaround

Nothing has been applied; the thread is left wedged for inspection. Two expected workarounds, both not yet
verified here: Stop on the run, which should take the session-gone interrupt path because the provider turn
is completed, or a server restart.

Possible fixes: write only the delegatedCompletion change instead of a full-row snapshot. Alternatively,
do not let a run regress from completed with a checkpointId to a non-terminal status, for example with
a compare-and-set write like writeIfProviderThreadUnchanged from #19355.

Filed by

Claude Code (claude-opus-5-5). The investigation followed .github/triage/PLAYBOOK.md but was not started with t3 triage.

Activity

  1. juliusmarminge commented on Oct 15, 2026

    @juliusmarminge
    Member

    Note

    Grok responding on behalf of Julius.

    Thanks for the detailed event-log trace. I checked the cited paths on current main, and the race is real as described:

    • CheckpointCaptureService commits a full-row run.updated built from the run it loaded before capture ({...runWithoutDelegatedCompletion, status: "completed", completedAt, checkpointId}).
    • planDelegatedCompletionDelivery returns {...input.parentRun, delegatedCompletion}. finalizeAppOwnedSubagentTask writes that as a full-row run.updated too.
    • preserveRunRecordedFields (ProjectionStore.ts) only carries forward delegatedCompletion and restartCancelledBackgroundWork. A stale snapshot can still move status, completedAt and checkpointId back.
    • isBlockingRun treats waiting as blocking, so the delivery run queues behind the reverted parent.

    This is a different failure from #15124 (capture stalls), #15173 (idle release while waiting) and #19002 (follow-up node never settles). #19355 and #19631 don't touch this write path. I'm keeping it open as a bug.

    A likely direction: stop the finalize write from regressing a terminal run, for example by writing only the delegatedCompletion change or by refusing completed/checkpointed → non-terminal transitions in the projection. For now, Stop on the stuck run or a server restart should get the thread moving again. We haven't verified either.

  2. added
    bugSomething is broken or behaving incorrectly.
    via-triageFiled through npx t3 triage
    on Oct 15, 2026
  3. Dropje97 commented on Oct 15, 2026

    @Dropje97
    Author

    Correction to "Fix applied or workaround": Stop does not recover this state. A server restart does.

    Stop. Three Stop attempts on the wedged run were rejected with
    OrchestrationV2DispatchCommandError: Run …:1581 is not interruptible. dispatchRunInterrupt finds no
    provider turn to interrupt: the run is waiting, its provider turn is completed, and there is no
    background work. The branch that handles a missing turn only covers preparing, starting and
    running (Orchestrator.ts:9365), so a waiting run falls through to the error at :9485. The line
    numbers are the same on 3417bc38 and main. The web client does not surface the error, so Stop
    appears to do nothing. t3_thread_interrupt sends the same command and has the same limitation.

    Restart. The thread recovered without losing any queued messages:

    1. The shutdown reconcile cancelled run 1581 and held the four queued runs (queueHeld: true).
    2. After queue.resume, the delivery run 1582 started, completed and got its checkpoint.
    3. The cohort on 1581 settled to {disposition: "open", delivery: null}. That is the same state as the
      run that did not race.
    4. The remaining queued messages were all delivered.

    The only lasting effect is that run 1581 ends as cancelled, with checkpointId still null, instead
    of completed.

    A small addition to the possible fixes: let Stop settle a waiting run whose provider turn is already
    terminal, so that this state can be recovered without a restart.

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

    bugSomething is broken or behaving incorrectly.via-triageFiled through npx t3 triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions