authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-02 23:06:09-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-02 23:06:09-07:00
log5bf19d12b0fd54fc9999579869bcf264bc555874
treec7a054a280de3a14c39f5356cca677629b31ce65
parentd1034d62b28e19c19240085566a39a92c5f9b466
signature Signed by PGP key B5690EEEBB952194

fix(server): threads stay working while Claude starts a wake turn (#15055)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

2 files changed, 88 insertions(+), 7 deletions(-)

apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts+64
......@@ -2004,6 +2004,13 @@ describe("ClaudeAdapterV2 background wake turns", () => {
20042004 uuid: "00000000-0000-4000-8000-000000000107",
20052005 text: WAKE_ASSISTANT_TEXT,
20062006 });
2007 // The CLI opens the wake turn with `init`, seconds before its first output.
2008 const wakeTurnInit = claudeSdkFrame({
2009 type: "system",
2010 subtype: "init",
2011 uuid: "00000000-0000-4000-8000-000000000110",
2012 session_id: WAKE_NATIVE_SESSION,
2013 });
20072014 const wakeResult = makeResultFrame({
20082015 uuid: "00000000-0000-4000-8000-000000000104",
20092016 result: WAKE_RESULT_TEXT,
......@@ -4641,6 +4648,63 @@ describe("ClaudeAdapterV2 background wake turns", () => {
46414648 ),
46424649 );
46434650
4651 it.effect("starts the wake run when Claude opens the wake turn, before its output", () =>
4652 Effect.scoped(
4653 Effect.gen(function* () {
4654 const harness = yield* makeWakeHarness;
4655 const now = yield* DateTime.now;
4656
4657 yield* harness.runtime.startTurn(
4658 makeClaudeTestTurnInput({
4659 threadId: harness.threadId,
4660 providerThread: harness.providerThread,
4661 now,
4662 attemptId: RunAttemptId.make("attempt-claude-wake-init-1"),
4663 text: "Run the build in the background.",
4664 attachments: [],
4665 }),
4666 );
4667 yield* Queue.offer(harness.sdkMessages, wakeTaskStarted);
4668 yield* Queue.offer(harness.sdkMessages, turnOneResult);
4669 yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal");
4670
4671 yield* harness.offerAndWait(wakeNotification);
4672 assert.lengthOf(harness.continuationRequests, 0);
4673 yield* harness.offerAndWait(wakeTurnInit);
4674 assert.lengthOf(harness.continuationRequests, 1);
4675 assert.equal(harness.continuationRequests[0]?.detail, WAKE_SUMMARY);
4676
4677 // The run attaches while Claude still thinks: only the notification
4678 // and `init` are buffered, so the run waits for the turn's output.
4679 yield* harness.runtime.startTurn(
4680 makeClaudeTestTurnInput({
4681 threadId: harness.threadId,
4682 providerThread: harness.providerThread,
4683 now,
4684 attemptId: RunAttemptId.make("attempt-claude-wake-init-2"),
4685 text: "Background task completed.",
4686 attachments: [],
4687 providerTurnOrdinal: 2,
4688 messageCreatedBy: "agent",
4689 messageCreationSource: "provider",
4690 }),
4691 );
4692 assert.lengthOf(harness.terminalEvents(), 1);
4693
4694 yield* harness.offerAndWait(wakeAssistant);
4695 yield* harness.offerAndWait(wakeResult);
4696 yield* awaitUntil(() => harness.terminalEvents().length === 2, "wake run terminal");
4697 assert.equal(harness.terminalEvents()[1]?.status, "completed");
4698 assert.lengthOf(harness.continuationRequests, 1);
4699 assert.isTrue(
4700 harness.events.some(
4701 (event) => event.type === "message.updated" && event.message.text === WAKE_RESULT_TEXT,
4702 ),
4703 );
4704 }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
4705 ),
4706 );
4707
46444708 it.effect("does not offer a continuation for notification-only opaque work", () =>
46454709 Effect.scoped(
46464710 Effect.gen(function* () {
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+24-7
......@@ -1739,6 +1739,13 @@ function isClaudeBackgroundTasksChangedMessage(message: SDKMessage): boolean {
17391739 );
17401740}
17411741
1742// Claude opens every turn it runs with a root `init` frame. Outside a T3 turn
1743// that turn is a wake, and `init` comes 20-110 ms after the notification that
1744// caused it but seconds before its first output (model thinking time).
1745function isClaudeTurnStartMessage(message: SDKMessage): boolean {
1746 return message.type === "system" && message.subtype === "init";
1747}
1748
17421749function claudePendingBackgroundTasksFromRoster(
17431750 roster: ReadonlyMap<string, OrchestrationV2PendingBackgroundTask>,
17441751): ReadonlyArray<OrchestrationV2PendingBackgroundTask> {
......@@ -5095,12 +5102,14 @@ export function makeClaudeAdapterV2(
50955102 // replay them to the turn that was still starting when they
50965103 // arrived; the offer gate below keeps them from requesting a
50975104 // continuation on their own.
5105 const isWakeTurnStart = isClaudeTurnStartMessage(message);
50985106 const isWakeEvidence =
50995107 isPendingTaskNotification ||
51005108 isPendingSubagentNotification ||
51015109 isNestedSubagentNotification ||
51025110 isKnownSubagentTaskStarted ||
51035111 isNewSubagentTaskStarted ||
5112 isWakeTurnStart ||
51045113 message.type === "assistant" ||
51055114 message.type === "user" ||
51065115 message.type === "result" ||
......@@ -5157,12 +5166,14 @@ export function makeClaudeAdapterV2(
51575166 }
51585167 // A terminal task notification can clear the Waiting roster without
51595168 // Claude dequeuing it into a native model turn. Buffer it for replay,
5160 // but do not open an opaque-task continuation until native user,
5161 // assistant, or result output proves that Claude actually began the
5162 // wake turn. Subagent notifications retain their existing immediate
5163 // offer because their projected lifecycle owns the continuation.
5164 // Only root output proves it: a background subagent keeps streaming
5165 // its own frames while the root is idle.
5169 // but do not open an opaque-task continuation until the turn's `init`,
5170 // or native user, assistant, or result output, proves that Claude
5171 // actually began the wake turn. `init` comes first, so the thread
5172 // shows working while Claude thinks instead of looking finished.
5173 // Subagent notifications retain their existing immediate offer
5174 // because their projected lifecycle owns the continuation. Only
5175 // root frames prove it: a background subagent keeps streaming its
5176 // own frames while the root is idle.
51665177 const buffered = (yield* Ref.get(wakeBuffers)).get(wakeInput.nativeThreadId);
51675178 const hasBufferedNotification =
51685179 buffered?.messages.some(
......@@ -5175,6 +5186,7 @@ export function makeClaudeAdapterV2(
51755186 if (
51765187 !isPendingSubagentNotification &&
51775188 !isNativeOpaqueWakeFrame &&
5189 !isWakeTurnStart &&
51785190 message.type !== "result"
51795191 ) {
51805192 return;
......@@ -7135,8 +7147,13 @@ export function makeClaudeAdapterV2(
71357147 yield* handleSdkMessage({ query: querySession.query, message: lastResult });
71367148 return;
71377149 }
7150 // A drained `init` means Claude began the wake turn, so its output
7151 // may still be on the way: stay open for it.
71387152 const hasNativeWakeFrame = drained.some(
7139 (entry) => entry.type === "user" || entry.type === "assistant",
7153 (entry) =>
7154 entry.type === "user" ||
7155 entry.type === "assistant" ||
7156 isClaudeTurnStartMessage(entry),
71407157 );
71417158 if (hasOpaqueTaskNotification && !hasNativeWakeFrame) {
71427159 const completedAt = yield* DateTime.now;