Claude wake latch: a wake queued behind a held run swallows later wakes, so idle-launched subagents stay Running#22033

opened4d agobyRroyalaid

A Claude native subagent launched while the root is idle can be discovered from task_progress during a later user turn, yet stay Running after its final task_notification arrives.

Observed in the macOS desktop nightly 0.1.1-nightly.20261018.3331. The deterministic replay runs on Windows, which has the same nightly installed, and fails against both that nightly's adapter source and main at cb0553ea39.

Minimal SDK replay, using the production Claude adapter:

  1. Start and finish an initial root user turn.
  2. Deliver system/task_started for one background local_agent while the root is idle. Leave its buffered continuation undrained.
  3. Start an ordinary user turn. Deliver system/task_progress for that task. The adapter creates the subagent from this progress event, with a fallback title and a late start time.
  4. Finish that user turn.
  5. Deliver the task's final system/task_notification {status: completed, summary: "Audit finished."} while the root is idle.
  6. Read the latest subagent.updated: it remains running, with no completion timestamp/result.

Expected: the authoritative final notification settles the registered subagent, even when its older launch frame remains in the wake buffer.

No SendMessage resume, nested agent, provider switch, process replacement, or background shell is needed. The external SDK stream is stubbed in this deterministic replay; this is not a live authenticated Claude CLI reproduction. The matching production incident has both raw task_updated {status: completed} and task_notification completed in the provider log while the persisted row stays running.

The failing gate is in ClaudeAdapterV2.ts at cb0553ea39: hasBufferedStart prevents the idle outcome from being applied, even though progress already registered that same launch during the later user turn. The normal user-turn path does not drain the wake buffer. The guard came from 265fb7000f (#18778), which already exists in the affected nightly. A raw completion signal reaches T3; the SDK is not dropping it in this case.

Related: #17809, #18910 and #20430. The stuck continuation gate in #20430 can leave such a start undrained, but this replay needs no held queued run. #16610 concerns interim completed notifications followed by self-resume; that is a separate observation. #21724/#21725 reconcile unregistered tasks after process replacement and do not remove this guard for registered tasks.

Reproduction added to the existing ClaudeAdapterV2.test.ts suite:

sh
1vp test run apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts -t 'settles an idle-launched subagent discovered'
2# expected 'running' to equal 'completed'
3# 1 failed, 299 skipped

The same assertion failure occurs with only the adapter source replaced by the exact installed nightly tag, and with current-main adapter source. All task names, IDs, prompts and output in the test are synthetic. No user transcripts, database, screenshots or private project data are attached.

Implemented/investigated with GPT-6.1 Sol through Codex in T3 Code.

Repro correction: the first short test waited for SDK processing but did not wait for the separate event collector. The test-only patch below adds an ordered provider-thread roster receipt after the final notification. That receipt arrives on both broken and fixed adapters, so the status assertion is independent of whether a completion event was emitted. This strengthened test still fails on cb0553ea39 with actual running, expected completed; the candidate fix passes it, the SQLite persistence case, and the existing genuine-resume case. Broader focused checks and review are still running.

Test-only reproduction patch against cb0553ea39

Apply this patch, then run the command above. It changes only the existing test file.

diff
1diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
2index 3b09094aa1..98c6018f5d 100644
3--- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
4+++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
5@@ -2538,6 +2538,10 @@ describe("ClaudeAdapterV2 background wake turns", () => {
6 yield* Queue.unbounded<
7 Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "subagent.updated" }>
8 >();
9+ const providerThreadReceipts =
10+ yield* Queue.unbounded<
11+ Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "provider_thread.updated" }>
12+ >();
13 const systemNoticeReceipts =
14 yield* Queue.unbounded<
15 Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "turn_item.updated" }>
16@@ -2646,6 +2650,9 @@ describe("ClaudeAdapterV2 background wake turns", () => {
17 if (event.type === "subagent.updated") {
18 yield* Queue.offer(subagentReceipts, event);
19 }
20+ if (event.type === "provider_thread.updated") {
21+ yield* Queue.offer(providerThreadReceipts, event);
22+ }
23 if (event.type === "turn.terminal") {
24 yield* Queue.offer(terminalReceipts, event);
25 }
26@@ -2687,6 +2694,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
27 events,
28 terminalReceipts,
29 subagentReceipts,
30+ providerThreadReceipts,
31 systemNoticeReceipts,
32 getOpenedOptions: () => openedOptions,
33 terminalEvents,
34@@ -10092,6 +10100,109 @@ describe("ClaudeAdapterV2 background wake turns", () => {
35 ),
36 );
37
38+ it.effect("settles an idle-launched subagent discovered during a later user turn", () =>
39+ Effect.scoped(
40+ Effect.gen(function* () {
41+ const harness = yield* makeWakeHarness;
42+ const taskId = "task-idle-launch-late-discovery";
43+ const toolUseId = "toolu-idle-launch-late-discovery";
44+ const startTurn = (ordinal: number) =>
45+ harness.runtime.startTurn(
46+ makeClaudeTestTurnInput({
47+ threadId: harness.threadId,
48+ providerThread: harness.providerThread,
49+ now: DateTime.makeUnsafe(0),
50+ attemptId: RunAttemptId.make(`attempt-idle-discovery-${ordinal}`),
51+ providerTurnOrdinal: ordinal,
52+ text: "Continue work.",
53+ attachments: [],
54+ }),
55+ );
56+ yield* startTurn(1);
57+ yield* harness.offerAndWait(
58+ makeResultFrame({
59+ uuid: "00000000-0000-4000-8000-000000004101",
60+ result: "Initial turn finished.",
61+ }),
62+ );
63+ yield* Queue.take(harness.terminalReceipts);
64+ yield* harness.offerAndWait(
65+ makeSubagentTaskStartedFrame({
66+ taskId,
67+ toolUseId,
68+ uuid: "00000000-0000-4000-8000-000000004102",
69+ }),
70+ );
71+ yield* startTurn(2);
72+ yield* harness.offerAndWait(
73+ claudeSdkFrame({
74+ type: "system",
75+ subtype: "task_progress",
76+ task_id: taskId,
77+ tool_use_id: toolUseId,
78+ description: "Checking the result.",
79+ uuid: "00000000-0000-4000-8000-000000004103",
80+ session_id: WAKE_NATIVE_SESSION,
81+ }),
82+ );
83+ yield* harness.offerAndWait(
84+ makeResultFrame({
85+ uuid: "00000000-0000-4000-8000-000000004104",
86+ result: "User turn finished.",
87+ }),
88+ );
89+ yield* Queue.take(harness.terminalReceipts);
90+ yield* harness.offerAndWait(
91+ makeSubagentNotificationFrame({
92+ taskId,
93+ toolUseId,
94+ summary: "Audit finished.",
95+ uuid: "00000000-0000-4000-8000-000000004105",
96+ }),
97+ );
98+ const markerId = "task-idle-discovery-collector-barrier";
99+ yield* harness.offerAndWait(
100+ claudeSdkFrame({
101+ type: "system",
102+ subtype: "task_started",
103+ task_id: markerId,
104+ tool_use_id: `toolu-${markerId}`,
105+ description: "Outcome collector barrier",
106+ is_backgrounded: true,
107+ task_type: "local_bash",
108+ uuid: "00000000-0000-4000-8000-000000004106",
109+ session_id: WAKE_NATIVE_SESSION,
110+ }),
111+ );
112+ // A later roster receipt also proves the collector has seen the outcome.
113+ yield* Stream.fromQueue(harness.providerThreadReceipts).pipe(
114+ Stream.filter(
115+ (event) =>
116+ event.providerThread.pendingBackgroundTasks?.some(
117+ (task) => task.taskId === markerId,
118+ ) === true,
119+ ),
120+ Stream.take(1),
121+ Stream.runDrain,
122+ );
123+ const latest = harness.events.findLast(
124+ (event) =>
125+ event.type === "subagent.updated" && event.subagent.nativeTaskRef?.nativeId === taskId,
126+ );
127+ assert.equal(latest?.type, "subagent.updated");
128+ if (latest?.type !== "subagent.updated") return;
129+ assert.equal(latest.subagent.status, "completed");
130+ assert.equal(latest.subagent.result, "Audit finished.");
131+ assert.equal(latest.subagent.title, "Audit recent commits");
132+ assert.equal(latest.subagent.prompt, "Audit the last five commits.");
133+ }).pipe(
134+ Effect.provide(
135+ Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
136+ ),
137+ ),
138+ ),
139+ );
140+
141 it.effect("holds subagent frames that precede task_started and shows its result once", () =>
142 Effect.scoped(
143 Effect.gen(function* () {

Note

Grok responding on behalf of Julius.

Thanks for the deterministic replay. The guard does what you describe on current main. When a task_notification arrives while the root is idle, the outcome is applied directly only if no task_started for that task is still in the wake buffer, or if a stop outcome is already known (apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts:7112-7125). Otherwise it waits for the continuation drain. #18778 added that deferral so an old buffered start replaying later couldn't flip a finished subagent back to running.

The replay depends on step 2, though: it leaves the idle launch's continuation undrained. A subagent task_started that arrives while the root is idle is buffered specifically so that a continuation drains it (lines 6669-6675). In the normal flow that drain processes both the start and the later notification and settles the task. So the open question is what kept that start undrained in your real incident. A held or jammed continuation would make this the same root cause as #20430, or the wake that never opens in #19196, rather than a separate problem with the guard.

To narrow it down, could you check the provider log and run history from the production incident:

  1. Did a continuation or wake run start between the idle task_started and your next user turn?
  2. Was any queued or held run sitting on the thread at that point?
  3. Did any run start after the final task_notification arrived?
Synced from GitHub

I checked the production run/event history and reproduced this with a fresh native Claude Agent task on the installed macOS nightly 0.1.1-nightly.20261018.3331.

Fresh synthetic probe, relative to its idle launch:

EventTime
Native background Agent launch, no T3 run active+0 s
Next T3 turn starts from an external message+22.9 s
T3 discovers the agent from progress+24.2 s
That T3 turn ends+29.7 s
SDK final task_notification completed arrives while idle+97.1 s
Persisted agent after the notificationrunning, completed_at = null

There was no T3 continuation/wake run between the idle start and that later message turn, and no queued or held run was found in the durable history. The probe used sequential foreground echo calls, with no SendMessage resume, own background shell work, or interim completion notification. Its last persisted update precedes the final notification by about 68 seconds.

For the three original agents:

  1. Two launched while idle. Neither had a continuation run before the later turn that discovered it. No queued or held run was found. Both had later T3 runs after their final notifications, and those did not repair their status.
  2. The third launched during an active turn, then had an idle resume/start between turns. Progress during the next T3 turn was recorded; its final idle notification was not. Later T3 runs did not repair it either.
  3. One of the two idle launches had no resumes and no interim notifications, so neither is necessary for this failure.

The store does not expose the adapter's in-memory requestedContinuations set, so I cannot rule out a stuck latch solely from that history. I can confirm that the native Claude wake activity itself occurred without a corresponding T3 run.

I agree that removing the guard is wrong: that candidate broke the existing genuine-resume regression and was rejected. The revised candidate keeps the guard and consumes the matching buffered plain-agent start through normal registration before later live progress creates a fallback entry. The ordered adapter replay, real SQLite persistence case, and existing genuine-resume case pass. The broader focused run has 304 passes and one unchanged steer-test timeout, now being checked against baseline. Independent review is running; I will link the patch and installed after-evidence when verified.

Synced from GitHub

The fix is offered in #22049 (through 1e0716cadf). Native fork artifacts are published in preview 20261020.2, commit 2ef9c7940e14.

The single replay runner runs the same idle-start regression on two prepared worktrees. Apply the test-only patch in this issue to the baseline worktree, install dependencies in both, and run:

text
1python verify-lineage-replay.py --baseline ./baseline --fixed ./fixed --vp <path-to-vp> --output ./results

It temporarily selects the installed nightly adapter source in the baseline worktree and restores it in finally. Observed before: exit 1, expected 'running' to equal 'completed'. Observed after: exit 0, the same test passes. This is SDK replay evidence, not a live provider test.

Separately, a native synthetic after-probe on the initial installed preview exercised idle self-resume → later-turn discovery → final idle completion:

UTCNative or projected event
00:57:17.741Native self-resume while parent idle
00:57:19.601Projection running after discovery in later turn
00:57:24.198Parent turn ends
00:58:49.954Projection completed, completed_at populated
00:58:49.990Final native completion notification recorded

The old nightly's native probe instead remained running with completed_at null after its final notification. The after-probe used an idle self-resume because notification-woken turns now opened T3 runs after restart; the reason for that wake difference is unproven. Historical stale rows were separately cancelled by normal startup recovery, not retroactively confirmed completed.

The follow-up also covers both active-turn pending frames and idle wake-buffer child frames. Its regression proves idle/live output order, tool completion before the terminal update, sibling isolation and no duplicate output on a later continuation drain. 53 focused checks, server typecheck and native Windows/macOS package validation pass. Independent review found no material defects. The unrelated early interim-completion behavior (#16610) remains.

Synced from GitHub

You were right to ask about held runs. Our earlier answer to (2) only looked at runs 14 to 28, and it was wrong. I've now reproduced the whole route on current main through the app, with no injected frames.

Repro on main 3b95e7831b, Windows, web dev client, Claude Sonnet 5.5

Every message after the first Stop goes through the MCP / t3 thread send path, so none of them is a client message that would release the hold (#21473).

  1. Start a long turn, for example 60 sequential echo calls. While it runs, send a follow-up so it queues. Press Stop in the composer. Run 1 is interrupted and the queued run 2 becomes queued, queueHeld: true.
  2. Send (via t3 thread send): start node -e "setTimeout(()=>{},30000)" with run_in_background: true, then run a 26 s foreground node -e "setTimeout(()=>{},26000)", then write a ~300-word reply and end the turn. When the background command's notification arrives, call Agent with run_in_background: true (60 sequential echo probe3-step-n calls), then end that turn. The point of the timing is that the background command finishes while the run is still settling.
  3. About 50 s after the launch, send a one-word ping via t3 thread send.
  4. Wait for the agent to finish.
UTCEvent
05:42:12.676run 6 starts (step 2)
05:43:00background command completes; Claude dequeues the notification as a native wake turn
05:43:03.222claude-wake-turn-detected; the continuation is dispatched as run 7, created queued, queueHeld: true, queuePosition: 2, while run 6 is still waiting
05:43:06the wake turn calls Agent ("lineage probe 3"). No T3 run is open for it, and run 7 never starts
05:43:59.829run 8 (the ping) starts; the agent is first recorded at 05:44:04.451 from progress
05:44:08.236run 8 completes
05:46:16the SDK delivers Agent "lineage probe 3" finished with the parent idle
05:57:43the row is still running, completed_at null, updated_at 05:44:08

Two controls from the same thread:

  • With the queue held but no run active, a background wake opened continuation run 5 immediately. The Agent launched inside it, and it completed normally. A hold alone doesn't jam anything. The wake has to be dispatched while a run is active, so that it queues and inherits the hold.
  • When the background command finished mid-turn, Claude handled the notification inside the tracked run, and that agent completed normally.

The original incident, on 0.1.1-nightly.20261018.3331

The same shape occurred in normal use.

  1. 13:12:32Z: the thread's runtime mode was changed during an active turn. thread.runtime-mode.set detached the session, run 1 failed, and the already queued run 2 (a delegated-task wake) became queueHeld: true.
  2. Run 8 and run 12 were later created held. Run 12 was the Claude background-task continuation at 13:43:02Z, dispatched 4 s after run 11 completed, at queuePosition: 4. None of them started.
  3. From 13:43Z until the next restart at 00:54Z, no native wake on the thread opened a T3 run. All three stale agents were launched in those untracked native turns:
    • the probe: launched in a turn woken by an earlier probe's notification;
    • one original agent: launched in a turn woken by a background Bash command that finished 12.5 s after its run ended;
    • the other original agent: launched in a turn woken by a Stop hook.
  4. Later human messages did not release the hold, because #21473 is not in 3331. After the restart, the next background wake opened a run immediately.

Conclusion

The stale row follows from the open latch half of #20430. The held continuation keeps requestedContinuations set, so later wake turns, and the Agent launches inside them, never get a run. The start stays buffered, and the final notification waits behind it. Theo asked on #20430 for a separate issue covering only the latch, so I've retitled this one to it. With the latch fixed, wake turns would drain the start normally. #22049 then only matters if a start can be stranded some other way, and we're fine closing it if you'd rather not carry it as defense in depth.

Sanitized excerpt of the original probe window (3331)

Ids, hosts, paths and names are removed, and external message bodies are summarised. "T3 run" means a row in orchestration_v2_projection_runs whose window contains the turn start. All times are UTC on 20 Oct.

Turn startTriggerT3 run
00:17:02.347external message delivered as a T3 user message, asking for a probe; parent launches probe A (run_in_background: true, three sleep 40; echo steps)run 27
00:17:23.712<task-notification>: probe A "is waiting on its own background work" (marked completed early, #16610)none
00:18:03.758, 00:18:45.129further probe A interim notificationsnone
00:19:25.142probe A "finished"; parent checks the store, sees run 27 completed, and launches probe Bnone
00:20:33.487probe B "finished" (a foreground sleep was blocked); parent launches probe B2 at 00:20:44.599none
00:21:07.896unrelated external message delivered as a T3 user message; B2 recorded running 00:21:08.911 to 00:21:13.639run 28 (ends 00:21:14.381)
00:21:35.277Stop hook ("Stop hook feedback")none
00:22:21.804Agent "lineage probe B2" finished, <result>PROBE_B_OK</result>none

B2's Agent input:

json
1{"description": "lineage probe B2", "subagent_type": "<custom haiku agent>", "run_in_background": true, "prompt": "Synthetic test task with no real work. Make 60 separate Bash tool calls, strictly one after another and never in parallel, numbered n = 1 to 60. Call n runs exactly `echo probe-step-n` with the number filled in. Do not use run_in_background, sleep, Monitor or any loop: one echo per tool call. After all 60, reply with exactly: PROBE_B_OK"}

The projection stayed running with completed_at null until startup reconcile cancelled it at 00:54:29.589. Runs 2, 8 and 12 were queued with queueHeld: true throughout.

Synced from GitHub

Follow-up: I reran the same route against both open fixes, on main 3b95e7831b with the same Stop, MCP-only messages and timing as above.

#22049 alone does not cover it. The wake continuation (run 4) was again created queued, queueHeld: true while run 3 was settling, and never started. The Agent launched in the untracked wake turn at 07:51:50Z. The later ping (run 5, 07:52:54 to 07:53:00Z) happened to fall between two of the agent's task_progress frames (07:52:53.8Z and 07:53:00.2Z), so the buffered-start replay never fired. The agent finished at 07:55:29Z with the parent idle, and T3 never recorded it at all: no subagent row, run 4 still held. #22049 only helps when a progress frame happens to land inside some later run.

#17885 does cover it. With its Orchestrator and ProjectionStore changes applied on the same base (only conflict was the Codex test assertion, since that test is gone on main):

UTCEvent
07:59:33run 3 waiting; background command completes, wake dispatched
07:59:35run 3 completes; the wake run 4 starts with no hold. Run 2 (held by Stop) stays held
07:59:39the Agent launch is recorded inside run 4, subagent running
08:01:28.506the agent finishes with the parent idle; its notification opens run 5 and the subagent is completed
08:01:34run 5 completes

Focused suites pass on that build: runtimeLayer, ProjectionStore, ThreadLaunchService, BackgroundWorkStop, ThreadStop and CodexAdapterV2 (286 tests). That build also carried #22049, but it wasn't exercised: the start arrived inside run 4.

So for this route the fix is #17885, which needs a rebase onto main. What it leaves open, by its own description, is a wake dispatched after Stop reaches the active run: that one is still held, and the latch stays set until the CLI restarts. #17109's release-on-cancel/fail would close that. I'm happy to close #22049 in favour of #17885.