From 5bf19d12b0fd54fc9999579869bcf264bc555874 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 2 Oct 2026 23:06:09 -0700 Subject: [PATCH] fix(server): threads stay working while Claude starts a wake turn (#15055) Co-authored-by: Claude Opus 5.5 (1M context) --- .../Adapters/ClaudeAdapterV2.test.ts | 64 +++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 31 +++++++-- 2 files changed, 88 insertions(+), 7 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index f14cbd3e87240a1480fb6d6c1f8549bf2bc128b9..e5839c8242199abc3857c75fac430dd8ffa30ebd 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -2004,6 +2004,13 @@ describe("ClaudeAdapterV2 background wake turns", () => { uuid: "00000000-0000-4000-8000-000000000107", text: WAKE_ASSISTANT_TEXT, }); + // The CLI opens the wake turn with `init`, seconds before its first output. + const wakeTurnInit = claudeSdkFrame({ + type: "system", + subtype: "init", + uuid: "00000000-0000-4000-8000-000000000110", + session_id: WAKE_NATIVE_SESSION, + }); const wakeResult = makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000104", result: WAKE_RESULT_TEXT, @@ -4641,6 +4648,63 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("starts the wake run when Claude opens the wake turn, before its output", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-init-1"), + text: "Run the build in the background.", + attachments: [], + }), + ); + yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); + yield* Queue.offer(harness.sdkMessages, turnOneResult); + yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); + + yield* harness.offerAndWait(wakeNotification); + assert.lengthOf(harness.continuationRequests, 0); + yield* harness.offerAndWait(wakeTurnInit); + assert.lengthOf(harness.continuationRequests, 1); + assert.equal(harness.continuationRequests[0]?.detail, WAKE_SUMMARY); + + // The run attaches while Claude still thinks: only the notification + // and `init` are buffered, so the run waits for the turn's output. + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-init-2"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + assert.lengthOf(harness.terminalEvents(), 1); + + yield* harness.offerAndWait(wakeAssistant); + yield* harness.offerAndWait(wakeResult); + yield* awaitUntil(() => harness.terminalEvents().length === 2, "wake run terminal"); + assert.equal(harness.terminalEvents()[1]?.status, "completed"); + assert.lengthOf(harness.continuationRequests, 1); + assert.isTrue( + harness.events.some( + (event) => event.type === "message.updated" && event.message.text === WAKE_RESULT_TEXT, + ), + ); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), + ); + it.effect("does not offer a continuation for notification-only opaque work", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 6e61738ec1f059ac757a97bcee6902a776aa5dac..1aa14ae6fdd65e70b54d6800c656da3a1258c6f9 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1739,6 +1739,13 @@ function isClaudeBackgroundTasksChangedMessage(message: SDKMessage): boolean { ); } +// Claude opens every turn it runs with a root `init` frame. Outside a T3 turn +// that turn is a wake, and `init` comes 20-110 ms after the notification that +// caused it but seconds before its first output (model thinking time). +function isClaudeTurnStartMessage(message: SDKMessage): boolean { + return message.type === "system" && message.subtype === "init"; +} + function claudePendingBackgroundTasksFromRoster( roster: ReadonlyMap, ): ReadonlyArray { @@ -5095,12 +5102,14 @@ export function makeClaudeAdapterV2( // replay them to the turn that was still starting when they // arrived; the offer gate below keeps them from requesting a // continuation on their own. + const isWakeTurnStart = isClaudeTurnStartMessage(message); const isWakeEvidence = isPendingTaskNotification || isPendingSubagentNotification || isNestedSubagentNotification || isKnownSubagentTaskStarted || isNewSubagentTaskStarted || + isWakeTurnStart || message.type === "assistant" || message.type === "user" || message.type === "result" || @@ -5157,12 +5166,14 @@ export function makeClaudeAdapterV2( } // A terminal task notification can clear the Waiting roster without // Claude dequeuing it into a native model turn. Buffer it for replay, - // but do not open an opaque-task continuation until native user, - // assistant, or result output proves that Claude actually began the - // wake turn. Subagent notifications retain their existing immediate - // offer because their projected lifecycle owns the continuation. - // Only root output proves it: a background subagent keeps streaming - // its own frames while the root is idle. + // but do not open an opaque-task continuation until the turn's `init`, + // or native user, assistant, or result output, proves that Claude + // actually began the wake turn. `init` comes first, so the thread + // shows working while Claude thinks instead of looking finished. + // Subagent notifications retain their existing immediate offer + // because their projected lifecycle owns the continuation. Only + // root frames prove it: a background subagent keeps streaming its + // own frames while the root is idle. const buffered = (yield* Ref.get(wakeBuffers)).get(wakeInput.nativeThreadId); const hasBufferedNotification = buffered?.messages.some( @@ -5175,6 +5186,7 @@ export function makeClaudeAdapterV2( if ( !isPendingSubagentNotification && !isNativeOpaqueWakeFrame && + !isWakeTurnStart && message.type !== "result" ) { return; @@ -7135,8 +7147,13 @@ export function makeClaudeAdapterV2( yield* handleSdkMessage({ query: querySession.query, message: lastResult }); return; } + // A drained `init` means Claude began the wake turn, so its output + // may still be on the way: stay open for it. const hasNativeWakeFrame = drained.some( - (entry) => entry.type === "user" || entry.type === "assistant", + (entry) => + entry.type === "user" || + entry.type === "assistant" || + isClaudeTurnStartMessage(entry), ); if (hasOpaqueTaskNotification && !hasNativeWakeFrame) { const completedAt = yield* DateTime.now; -- 2.54.0