From b70869ecfd52d2770a86b847d5c069c981c9fa83 Mon Sep 17 00:00:00 2001 From: Fuksss Date: Wed, 21 Oct 2026 18:08:28 +0200 Subject: [PATCH 1/6] fix(server): keep newer Claude wakes active during replay An older buffered result can otherwise settle a continuation whose newer wake is still running. Let the last root init own replay settlement. Keep earlier billed usage and error notices once, while leaving text to the assistant frames of each wake. Empty zero-turn task successes retain base billing, and the settling result keeps the existing text fallback. Cover the incident frame order, queued prompt, several wakes, buffered successors, empty-result fallback, lone results and live empty success. Refs #16808. --- .../Adapters/ClaudeAdapterV2.test.ts | 366 ++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 105 +++-- 2 files changed, 440 insertions(+), 31 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index aeb128bf6febf..a1f35eac9895f 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -7752,6 +7752,372 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + // Oracle: #16808 frame order and replay contract. Each row gives the exact + // visible texts, settlement and billed usage; empty successes retain base billing. + const replayWake = ( + id: string, + text: string, + result: Omit[0], "uuid">, + ) => [ + claudeSdkFrame({ ...wakeTurnInit, uuid: `${id}-init` }), + makeAssistantTextFrame({ uuid: `${id}-assistant`, text }), + makeResultFrame({ ...result, uuid: `${id}-result` }), + ]; + const earlierWake = replayWake("earlier-a", "Earlier A.", { + result: "Earlier A.", + origin: { kind: "task-notification" }, + usage: { + input_tokens: 10, + output_tokens: 20, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }); + const earlierErrorWake = replayWake("earlier-error", "Earlier A.", { + result: "Earlier A.", + origin: { kind: "task-notification" }, + isError: true, + terminalReason: "api_error", + errors: ["Tool execution failed: EACCES"], + usage: { + input_tokens: 10, + output_tokens: 20, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }); + const secondEarlierWake = replayWake("earlier-b", "Earlier B.", { + result: "Earlier B.", + origin: { kind: "task-notification" }, + usage: { + input_tokens: 100, + output_tokens: 200, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }); + const emptyWakeSuccess = makeResultFrame({ + uuid: "empty-wake-result", + result: "", + numTurns: 0, + origin: { kind: "task-notification" }, + terminalReason: null, + usage: { + input_tokens: 7, + output_tokens: 7, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }); + it.effect.each([ + { + label: "older assistant and success keep the newer wake open", + before: earlierWake, + after: [wakeTurnInit], + beforeCheck: [wakeAssistant], + live: [wakeResult], + texts: ["Earlier A.", WAKE_ASSISTANT_TEXT], + status: "completed", + inputTokens: 11, + outputTokens: 21, + notices: [], + openOnAttach: true, + terminalCount: 2, + usageStatus: "complete", + }, + { + label: "older assistant and error keep the newer wake open with a notice", + before: earlierErrorWake, + after: [wakeTurnInit], + beforeCheck: [wakeAssistant], + live: [wakeResult], + texts: ["Earlier A.", WAKE_ASSISTANT_TEXT], + status: "completed", + inputTokens: 11, + outputTokens: 21, + notices: ["Tool execution failed: EACCES"], + openOnAttach: true, + terminalCount: 2, + usageStatus: "complete", + }, + { + label: "two older assistant-result wakes keep the third wake open", + before: [...earlierWake, ...secondEarlierWake], + after: [wakeTurnInit], + beforeCheck: [wakeAssistant], + live: [wakeResult], + texts: ["Earlier A.", "Earlier B.", WAKE_ASSISTANT_TEXT], + status: "completed", + inputTokens: 111, + outputTokens: 221, + notices: [], + openOnAttach: true, + terminalCount: 2, + usageStatus: "complete", + }, + { + label: "fully buffered successor without assistant text uses its own result", + before: replayWake("silent-older", "", { + result: "Earlier A.", + origin: { kind: "task-notification" }, + usage: { + input_tokens: 10, + output_tokens: 20, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }), + after: [ + wakeTurnInit, + makeResultFrame({ + uuid: "successor-result", + result: "Successor text.", + origin: { kind: "task-notification" }, + }), + ], + beforeCheck: [], + live: [], + texts: ["Successor text."], + status: "completed", + inputTokens: 11, + outputTokens: 21, + notices: [], + openOnAttach: false, + terminalCount: 3, + usageStatus: "complete", + }, + { + label: "two older results and latest empty successes keep base text fallback", + before: [ + ...replayWake("fallback-x", "", { + result: "Earlier X.", + origin: { kind: "task-notification" }, + usage: { + input_tokens: 10, + output_tokens: 20, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }), + ...replayWake("fallback-a", "", { + result: "Earlier A.", + origin: { kind: "task-notification" }, + usage: { + input_tokens: 100, + output_tokens: 200, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }), + ], + after: [ + wakeTurnInit, + makeAssistantTextFrame({ uuid: "empty-assistant", text: "" }), + emptyWakeSuccess, + claudeSdkFrame({ ...emptyWakeSuccess, uuid: "second-empty-result" }), + ], + beforeCheck: [], + live: [], + texts: ["Earlier A."], + status: "completed", + inputTokens: 110, + outputTokens: 220, + notices: [], + openOnAttach: false, + terminalCount: 3, + usageStatus: "complete", + }, + { + label: "lone buffered success without later init keeps base settlement", + before: earlierWake, + after: [], + beforeCheck: [], + live: [], + texts: ["Earlier A."], + status: "completed", + inputTokens: 10, + outputTokens: 20, + notices: [], + openOnAttach: false, + terminalCount: 3, + usageStatus: "complete", + }, + { + label: "lone buffered error without later init keeps base settlement", + before: earlierErrorWake, + after: [], + beforeCheck: [], + live: [], + texts: ["Earlier A."], + status: "failed", + inputTokens: 10, + outputTokens: 20, + notices: [], + openOnAttach: false, + terminalCount: 3, + usageStatus: "partial", + }, + { + label: "live empty success after buffered rate-limit assistant error completes", + before: [ + wakeTurnInit, + makeAssistantErrorFrame({ uuid: "buffered-rate-limit", error: "rate_limit" }), + ], + after: [], + beforeCheck: [], + live: [emptyWakeSuccess], + texts: ["Claude could not complete this request."], + status: "completed", + inputTokens: 7, + outputTokens: 7, + notices: [], + openOnAttach: true, + terminalCount: 2, + usageStatus: "complete", + }, + { + label: "older empty zero-turn success keeps base billing", + before: [ + wakeTurnInit, + makeAssistantTextFrame({ uuid: "older-empty-assistant", text: "" }), + emptyWakeSuccess, + ], + after: [claudeSdkFrame({ ...wakeTurnInit, uuid: "newer-init" })], + beforeCheck: [wakeAssistant], + live: [wakeResult], + texts: [WAKE_ASSISTANT_TEXT], + status: "completed", + inputTokens: 1, + outputTokens: 1, + notices: [], + openOnAttach: true, + terminalCount: 2, + usageStatus: "complete", + }, + ])("replays wakes: $label", (scenario) => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const wakeAttempt = RunAttemptId.make("attempt-claude-replay-3"); + const wakeRun = RunId.make(`run-${wakeAttempt}`); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-replay-1"), + text: "Run the build in the background.", + attachments: [], + }), + ); + yield* harness.offerAndWait(turnOneResult); + yield* harness.awaitTerminal(1); + for (const frame of scenario.before) yield* harness.offerAndWait(frame); + yield* Queue.take(harness.continuationReceipts); + // The queued user prompt runs before the buffered continuation attaches. + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-replay-2"), + text: "Check the build.", + attachments: [], + providerTurnOrdinal: 2, + }), + ); + yield* harness.offerAndWait(claudeSdkFrame({ ...wakeTurnInit, uuid: "prompt-init" })); + yield* harness.offerAndWait( + makeAssistantTextFrame({ uuid: "prompt-assistant", text: "Checked the build." }), + ); + yield* harness.offerAndWait( + claudeSdkFrame({ + ...makeResultFrame({ uuid: "prompt-result", result: "Checked the build." }), + user_message_uuid: harness.promptUuid(1), + }), + ); + yield* harness.awaitTerminal(2); + for (const frame of scenario.after) yield* harness.offerAndWait(frame); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: wakeAttempt, + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 3, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + for (const frame of scenario.beforeCheck) { + yield* harness.offerAndWait(frame); + yield* harness.until( + () => + harness.terminalEvents().length >= 3 || + harness.events.some( + (event) => + event.type === "message.updated" && + event.message.runId === wakeRun && + event.message.text === WAKE_ASSISTANT_TEXT, + ), + ); + } + if (!scenario.openOnAttach) yield* harness.awaitTerminal(3); + const terminalCountOnAttach = harness.terminalEvents().length; + for (const frame of scenario.live) yield* harness.offerAndWait(frame); + yield* harness.awaitTerminal(3); + const completed = harness.events.findLast( + (event) => event.type === "provider_turn.updated", + ); + assert.deepEqual( + { + terminalCountOnAttach, + texts: harness.events.flatMap((event) => + event.type === "message.updated" && event.message.runId === wakeRun + ? [event.message.text] + : [], + ), + status: harness.terminalEvents()[2]?.status, + usage: + completed?.type === "provider_turn.updated" + ? completed.providerTurn.turnTokenUsage + : undefined, + notices: harness.events.flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.runId === wakeRun && + event.turnItem.type === "system_notice" + ? [event.turnItem.message] + : [], + ), + }, + { + terminalCountOnAttach: scenario.terminalCount, + texts: scenario.texts, + status: scenario.status, + usage: { + usageScope: "main_agent", + usageStatus: scenario.usageStatus, + hasSubagents: false, + inputTokens: scenario.inputTokens, + outputTokens: scenario.outputTokens, + cachedInputTokens: 0, + cacheCreationTokens: 0, + }, + notices: scenario.notices, + }, + ); + assert.lengthOf(harness.offeredMessages, 2); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + it.effect("ignores a zero-turn task-notification origin result during a normal user turn", () => 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 91e926cc85e91..1a5f83a855541 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -3085,6 +3085,17 @@ function isClaudeTaskNotificationOriginResult(message: SDKMessage): message is S return message.type === "result" && message.origin?.kind === "task-notification"; } +function isClaudeEmptyTaskNotificationSuccess(message: SDKMessage): boolean { + return ( + isClaudeTaskNotificationOriginResult(message) && + message.subtype === "success" && + !message.is_error && + message.num_turns === 0 && + message.result.length === 0 && + terminalStatusFromResult(message) === "completed" + ); +} + // The CLI throttles unchanged rate_limit_event frames, but repeats this // undeclared payload on each blocked assistant response. const isClaudeBlockedAssistantRateLimit = Schema.is( @@ -3385,9 +3396,9 @@ interface ActiveClaudeTurnContext { // Set while this turn's latest compaction status reports a failure, with // Claude's error text when it sent one. compactionFailure: { readonly message: string | undefined } | null; - // Usage of the results that steers ended, oldest first. The final result's + // Usage of earlier steered segments and buffered wakes, oldest first. The final result's // usage covers only its own segment, so the turn bills these as well. - readonly steeredSegmentUsage: Array; + readonly earlierSegmentUsage: Array; readonly subagentsByTaskId: Map; readonly subagentsByToolUseId: Map; readonly subagentNodesByTaskId: Map; @@ -6743,7 +6754,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( input.context.subagentsByTaskId.size > 0 || input.context.subagentsByToolUseId.size > 0, input.status, - input.context.steeredSegmentUsage, + input.context.earlierSegmentUsage, ), }, }), @@ -6882,6 +6893,49 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ); }); + // A later root init owns settlement. This per-frame helper keeps only + // billing and errors; assistant frames already carry the wake's text. + const recordEarlierWakeResult = Effect.fnUntraced(function* ( + context: ActiveClaudeTurnContext, + result: SDKResultMessage, + ) { + if (isClaudeEmptyTaskNotificationSuccess(result)) return; + context.earlierSegmentUsage.push(result.usage); + const now = yield* DateTime.now; + const failure = providerFailureFromResult(result, now); + if (failure !== null) { + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: CLAUDE_PROVIDER, + turnItem: { + id: idAllocator.derive.turnItemFromProviderItem({ + driver: CLAUDE_PROVIDER, + nativeItemId: result.uuid, + }), + threadId: context.input.threadId, + runId: context.input.runId, + nodeId: context.input.rootNodeId, + providerThreadId: context.input.providerThread.id, + providerTurnId: context.providerTurnId, + nativeItemRef: { + driver: CLAUDE_PROVIDER, + nativeId: result.uuid, + strength: "strong", + }, + parentItemId: null, + ordinal: yield* resolveItemOrdinal(context, result.uuid), + type: "system_notice", + status: "completed", + title: failure.message, + message: failure.message, + startedAt: now, + completedAt: now, + updatedAt: now, + }, + }); + } + }); + const finalizeActiveTurnAfterQueryExit = Effect.fnUntraced(function* ( closedQuery: ClaudeLiveQueryContext, cause?: Cause.Cause, @@ -8468,7 +8522,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const interrupted = (yield* Ref.get(interruptedTurns)).has(context.providerTurnId); const wasSteered = (yield* Ref.get(steeredTurns)).has(context.providerTurnId); if (!interrupted && wasSteered && isClaudeActiveSteeringAbortResult(message)) { - context.steeredSegmentUsage.push(message.usage); + context.earlierSegmentUsage.push(message.usage); return; } yield* Ref.update(steeredTurns, (current) => { @@ -9438,7 +9492,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( rateLimitResetTimes: new Map(), latestAssistantRateLimit: null, compactionFailure: null, - steeredSegmentUsage: [], + earlierSegmentUsage: [], subagentsByTaskId: new Map(), subagentsByToolUseId: new Map(), subagentNodesByTaskId: new Map(), @@ -9540,34 +9594,21 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } // Replay any result message last: a result finalizes the turn, and // replaying it before the rest would drop them back into the wake - // buffer and request another continuation. When Claude dequeues - // several notifications at once it can end an empty turn and open - // the next within a millisecond; that next turn is still running - // and owns this run, so the empty turn's result must not settle it. + // buffer and request another continuation. Results before the + // last root init must not settle a successor still running: + // keep their error notices and usage instead. + const lastTurnStartIndex = drained.findLastIndex(isClaudeTurnStartMessage); const lastResultIndex = drained.findLastIndex((entry) => entry.type === "result"); - const tailResult = drained[lastResultIndex]; - const emptyTurnBeforeNextTurn = - tailResult?.type === "result" && - tailResult.num_turns === 0 && - !tailResult.is_error && - terminalStatusFromResult(tailResult) === "completed" && - drained - .slice(lastResultIndex + 1) - .some((entry) => isClaudeTurnStartMessage(entry)); - // The CLI can append empty task successes after a failure. Filter - // those for replay without changing the raw-tail successor guard. - const isEmptyTaskNotificationSuccess = (entry: SDKMessage) => - isClaudeTaskNotificationOriginResult(entry) && - entry.subtype === "success" && - !entry.is_error && - entry.num_turns === 0 && - entry.result.length === 0 && - terminalStatusFromResult(entry) === "completed"; + // Preserve the base fallback when the latest wake has only empty + // task successes: an earlier non-empty result still owns failure. const replayResultIndex = drained.findLastIndex( - (entry) => entry.type === "result" && !isEmptyTaskNotificationSuccess(entry), + (entry) => + entry.type === "result" && !isClaudeEmptyTaskNotificationSuccess(entry), ); const lastResult = - drained[replayResultIndex === -1 ? lastResultIndex : replayResultIndex]; + lastResultIndex > lastTurnStartIndex + ? drained[replayResultIndex === -1 ? lastResultIndex : replayResultIndex] + : undefined; const opaqueReplayTombstones = taskIdSetForNativeThread( yield* Ref.get(opaqueBackgroundTaskReplayTombstonesByNativeThread), nativeThreadId, @@ -9578,7 +9619,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( entry.subtype === "task_notification" && opaqueReplayTombstones.has(entry.task_id), ); - for (const entry of drained) { + for (const [index, entry] of drained.entries()) { if ( !(yield* Ref.get(wakeBuffers)).get(nativeThreadId)?.messages.includes(entry) ) { @@ -9586,6 +9627,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } if (entry.type !== "result") { yield* handleSdkMessage({ query: querySession.query, message: entry }); + } else if (index < lastTurnStartIndex && entry !== lastResult) { + yield* recordEarlierWakeResult(context, entry); } yield* Ref.update(wakeBuffers, (current) => { const buffered = current.get(nativeThreadId); @@ -9602,7 +9645,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return next; }); } - if (lastResult !== undefined && !emptyTurnBeforeNextTurn) { + if (lastResult !== undefined) { yield* handleSdkMessage({ query: querySession.query, message: lastResult }); return; } From 57fd74a1215ac35a26b235dcaa516d971fa5b903 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 16:51:07 +0000 Subject: [PATCH 2/6] test(server): simplify wake replay coverage and verify approvals --- .../Adapters/ClaudeAdapterV2.test.ts | 156 +++++------------- .../Adapters/ClaudeAdapterV2.ts | 9 +- 2 files changed, 44 insertions(+), 121 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 1c26f7a87beea..0ff5d075d99e1 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -9033,8 +9033,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - // Oracle: #16808 frame order and replay contract. Each row gives the exact - // visible texts, settlement and billed usage; empty successes retain base billing. const replayWake = ( id: string, text: string, @@ -9067,16 +9065,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { cache_read_input_tokens: 0, }, }); - const secondEarlierWake = replayWake("earlier-b", "Earlier B.", { - result: "Earlier B.", - origin: { kind: "task-notification" }, - usage: { - input_tokens: 100, - output_tokens: 200, - cache_creation_input_tokens: 0, - cache_read_input_tokens: 0, - }, - }); const emptyWakeSuccess = makeResultFrame({ uuid: "empty-wake-result", result: "", @@ -9092,50 +9080,20 @@ describe("ClaudeAdapterV2 background wake turns", () => { }); it.effect.each([ { - label: "older assistant and success keep the newer wake open", - before: earlierWake, + label: "older results preserve the active successor and its approval", + before: [wakeTurnInit, emptyWakeSuccess, ...earlierWake, ...earlierErrorWake], after: [wakeTurnInit], beforeCheck: [wakeAssistant], live: [wakeResult], - texts: ["Earlier A.", WAKE_ASSISTANT_TEXT], + texts: ["Earlier A.", "Earlier A.", WAKE_ASSISTANT_TEXT], status: "completed", - inputTokens: 11, - outputTokens: 21, - notices: [], - openOnAttach: true, - terminalCount: 2, - usageStatus: "complete", - }, - { - label: "older assistant and error keep the newer wake open with a notice", - before: earlierErrorWake, - after: [wakeTurnInit], - beforeCheck: [wakeAssistant], - live: [wakeResult], - texts: ["Earlier A.", WAKE_ASSISTANT_TEXT], - status: "completed", - inputTokens: 11, - outputTokens: 21, + inputTokens: 21, + outputTokens: 41, notices: ["Tool execution failed: EACCES"], openOnAttach: true, terminalCount: 2, usageStatus: "complete", }, - { - label: "two older assistant-result wakes keep the third wake open", - before: [...earlierWake, ...secondEarlierWake], - after: [wakeTurnInit], - beforeCheck: [wakeAssistant], - live: [wakeResult], - texts: ["Earlier A.", "Earlier B.", WAKE_ASSISTANT_TEXT], - status: "completed", - inputTokens: 111, - outputTokens: 221, - notices: [], - openOnAttach: true, - terminalCount: 2, - usageStatus: "complete", - }, { label: "fully buffered successor without assistant text uses its own result", before: replayWake("silent-older", "", { @@ -9208,78 +9166,16 @@ describe("ClaudeAdapterV2 background wake turns", () => { terminalCount: 3, usageStatus: "complete", }, - { - label: "lone buffered success without later init keeps base settlement", - before: earlierWake, - after: [], - beforeCheck: [], - live: [], - texts: ["Earlier A."], - status: "completed", - inputTokens: 10, - outputTokens: 20, - notices: [], - openOnAttach: false, - terminalCount: 3, - usageStatus: "complete", - }, - { - label: "lone buffered error without later init keeps base settlement", - before: earlierErrorWake, - after: [], - beforeCheck: [], - live: [], - texts: ["Earlier A."], - status: "failed", - inputTokens: 10, - outputTokens: 20, - notices: [], - openOnAttach: false, - terminalCount: 3, - usageStatus: "partial", - }, - { - label: "live empty success after buffered rate-limit assistant error completes", - before: [ - wakeTurnInit, - makeAssistantErrorFrame({ uuid: "buffered-rate-limit", error: "rate_limit" }), - ], - after: [], - beforeCheck: [], - live: [emptyWakeSuccess], - texts: ["Claude could not complete this request."], - status: "completed", - inputTokens: 7, - outputTokens: 7, - notices: [], - openOnAttach: true, - terminalCount: 2, - usageStatus: "complete", - }, - { - label: "older empty zero-turn success keeps base billing", - before: [ - wakeTurnInit, - makeAssistantTextFrame({ uuid: "older-empty-assistant", text: "" }), - emptyWakeSuccess, - ], - after: [claudeSdkFrame({ ...wakeTurnInit, uuid: "newer-init" })], - beforeCheck: [wakeAssistant], - live: [wakeResult], - texts: [WAKE_ASSISTANT_TEXT], - status: "completed", - inputTokens: 1, - outputTokens: 1, - notices: [], - openOnAttach: true, - terminalCount: 2, - usageStatus: "complete", - }, ])("replays wakes: $label", (scenario) => Effect.scoped( Effect.gen(function* () { const harness = yield* makeWakeHarness; const now = yield* DateTime.now; + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "approval-required", + interactionMode: "default", + cwd: "/workspace", + }); const wakeAttempt = RunAttemptId.make("attempt-claude-replay-3"); const wakeRun = RunId.make(`run-${wakeAttempt}`); yield* harness.runtime.startTurn( @@ -9287,6 +9183,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { threadId: harness.threadId, providerThread: harness.providerThread, now, + runtimePolicy, attemptId: RunAttemptId.make("attempt-claude-replay-1"), text: "Run the build in the background.", attachments: [], @@ -9302,6 +9199,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { threadId: harness.threadId, providerThread: harness.providerThread, now, + runtimePolicy, attemptId: RunAttemptId.make("attempt-claude-replay-2"), text: "Check the build.", attachments: [], @@ -9325,6 +9223,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { threadId: harness.threadId, providerThread: harness.providerThread, now, + runtimePolicy, attemptId: wakeAttempt, text: "Background task completed.", attachments: [], @@ -9348,6 +9247,35 @@ describe("ClaudeAdapterV2 background wake turns", () => { } if (!scenario.openOnAttach) yield* harness.awaitTerminal(3); const terminalCountOnAttach = harness.terminalEvents().length; + if (scenario.openOnAttach) { + assert.equal(terminalCountOnAttach, 2); + const permission = yield* Effect.promise(() => + harness.getOpenedOptions()!.canUseTool!( + "Bash", + { command: "git status" }, + { + signal: new AbortController().signal, + toolUseID: "toolu_replayed_wake_approval", + requestId: "request-replayed-wake", + }, + ), + ).pipe(Effect.forkScoped); + const request = yield* Queue.take(harness.runtimeRequestReceipts); + const wakeTurn = harness.events.findLast( + (event) => + event.type === "provider_turn.updated" && + event.providerTurn.runAttemptId === wakeAttempt, + ); + assert.equal( + request.runtimeRequest.providerTurnId, + wakeTurn?.type === "provider_turn.updated" ? wakeTurn.providerTurn.id : undefined, + ); + yield* harness.runtime.respondToRuntimeRequest({ + requestId: request.runtimeRequest.id, + decision: "accept", + }); + assert.equal((yield* Fiber.join(permission))?.behavior, "allow"); + } for (const frame of scenario.live) yield* harness.offerAndWait(frame); yield* harness.awaitTerminal(3); const completed = harness.events.findLast( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 0b9e67aac12e2..a7de0fa4a26a6 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -3801,8 +3801,7 @@ interface ActiveClaudeTurnContext { // Set while this turn's latest compaction status reports a failure, with // Claude's error text when it sent one. compactionFailure: { readonly message: string | undefined } | null; - // Usage of earlier steered segments and buffered wakes, oldest first. The final result's - // usage covers only its own segment, so the turn bills these as well. + // The final result excludes earlier steered segments and buffered wakes; bill them too. readonly earlierSegmentUsage: Array; readonly subagentsByTaskId: Map; readonly subagentsByToolUseId: Map; @@ -10147,11 +10146,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( yield* finalizeActiveTurn({ context, status: "completed", completedAt }); return; } - // Replay any result message last: a result finalizes the turn, and - // replaying it before the rest would drop them back into the wake - // buffer and request another continuation. Results before the - // last root init must not settle a successor still running: - // keep their error notices and usage instead. + // Replay results last to avoid rebuffering later frames; a newer root init owns settlement. const lastTurnStartIndex = drained.findLastIndex(isClaudeTurnStartMessage); const lastResultIndex = drained.findLastIndex((entry) => entry.type === "result"); // Preserve the base fallback when the latest wake has only empty From 4902e4a160b09657241bfd11b019a2c1259fa02d Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 30 Oct 2026 06:08:06 +0000 Subject: [PATCH 3/6] fix(server): retain earlier wake usage when the successor stops --- .../Adapters/ClaudeAdapterV2.test.ts | 41 +++++++--- .../Adapters/ClaudeAdapterV2.ts | 80 +++++++++---------- .../src/provider/ClaudeTurnTokenUsage.ts | 11 ++- .../src/provider/TurnTokenUsage.test.ts | 6 ++ 4 files changed, 82 insertions(+), 56 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index b80f88b2b6f3a..cb62878c5bb00 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -9733,6 +9733,19 @@ describe("ClaudeAdapterV2 background wake turns", () => { }, }); it.effect.each([ + { + label: "stopping the successor preserves earlier billed usage", + before: earlierWake, + after: [wakeTurnInit], + beforeCheck: [wakeAssistant], + live: [], + texts: ["Earlier A.", WAKE_ASSISTANT_TEXT], + inputTokens: 10, + outputTokens: 20, + notices: [], + openOnAttach: true, + stop: true, + }, { label: "older results preserve the active successor and its approval", before: [wakeTurnInit, emptyWakeSuccess, ...earlierWake, ...earlierErrorWake], @@ -9740,13 +9753,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { beforeCheck: [wakeAssistant], live: [wakeResult], texts: ["Earlier A.", "Earlier A.", WAKE_ASSISTANT_TEXT], - status: "completed", inputTokens: 21, outputTokens: 41, notices: ["Tool execution failed: EACCES"], openOnAttach: true, - terminalCount: 2, - usageStatus: "complete", }, { label: "fully buffered successor without assistant text uses its own result", @@ -9771,13 +9781,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { beforeCheck: [], live: [], texts: ["Successor text."], - status: "completed", inputTokens: 11, outputTokens: 21, notices: [], openOnAttach: false, - terminalCount: 3, - usageStatus: "complete", }, { label: "two older results and latest empty successes keep base text fallback", @@ -9812,18 +9819,17 @@ describe("ClaudeAdapterV2 background wake turns", () => { beforeCheck: [], live: [], texts: ["Earlier A."], - status: "completed", inputTokens: 110, outputTokens: 220, notices: [], openOnAttach: false, - terminalCount: 3, - usageStatus: "complete", }, ])("replays wakes: $label", (scenario) => Effect.scoped( Effect.gen(function* () { - const harness = yield* makeWakeHarness; + const harness = yield* makeWakeHarnessWithOptions({ + close: (sdkMessages) => Queue.shutdown(sdkMessages), + }); const now = yield* DateTime.now; const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "approval-required", @@ -9931,6 +9937,15 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal((yield* Fiber.join(permission))?.behavior, "allow"); } for (const frame of scenario.live) yield* harness.offerAndWait(frame); + if ("stop" in scenario && scenario.stop) { + yield* harness.runtime.interruptTurn({ + providerThread: harness.providerThread, + providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ + driver: ClaudeAdapterV2.CLAUDE_PROVIDER, + nativeTurnId: `turn:${wakeAttempt}`, + }), + }); + } yield* harness.awaitTerminal(3); const completed = harness.events.findLast( (event) => event.type === "provider_turn.updated", @@ -9957,12 +9972,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), }, { - terminalCountOnAttach: scenario.terminalCount, + terminalCountOnAttach: scenario.openOnAttach ? 2 : 3, texts: scenario.texts, - status: scenario.status, + status: "stop" in scenario ? "interrupted" : "completed", usage: { usageScope: "main_agent", - usageStatus: scenario.usageStatus, + usageStatus: "stop" in scenario ? "partial" : "complete", hasSubagents: false, inputTokens: scenario.inputTokens, outputTokens: scenario.outputTokens, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 8f9e18d88c3bb..19a6ebe592882 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -7574,48 +7574,46 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ); }); - // A later root init owns settlement. This per-frame helper keeps only - // billing and errors; assistant frames already carry the wake's text. - const recordEarlierWakeResult = Effect.fnUntraced(function* ( - context: ActiveClaudeTurnContext, - result: SDKResultMessage, - ) { - if (isClaudeEmptyTaskNotificationSuccess(result)) return; - context.earlierSegmentUsage.push(result.usage); - const now = yield* DateTime.now; - const failure = providerFailureFromResult(result, now); - if (failure !== null) { - yield* emitProviderEvent({ - type: "turn_item.updated", - driver: CLAUDE_PROVIDER, - turnItem: { - id: idAllocator.derive.turnItemFromProviderItem({ - driver: CLAUDE_PROVIDER, - nativeItemId: result.uuid, - }), - threadId: context.input.threadId, - runId: context.input.runId, - nodeId: context.input.rootNodeId, - providerThreadId: context.input.providerThread.id, - providerTurnId: context.providerTurnId, - nativeItemRef: { - driver: CLAUDE_PROVIDER, - nativeId: result.uuid, - strength: "strong", + // Assistant frames carry the earlier wake's text; its result only adds usage and errors. + const recordEarlierWakeResult = Effect.fn("ClaudeAdapter.recordEarlierWakeResult")( + function* (context: ActiveClaudeTurnContext, result: SDKResultMessage) { + if (isClaudeEmptyTaskNotificationSuccess(result)) return; + context.earlierSegmentUsage.push(result.usage); + const now = yield* DateTime.now; + const failure = providerFailureFromResult(result, now); + if (failure !== null) { + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: CLAUDE_PROVIDER, + turnItem: { + id: idAllocator.derive.turnItemFromProviderItem({ + driver: CLAUDE_PROVIDER, + nativeItemId: result.uuid, + }), + threadId: context.input.threadId, + runId: context.input.runId, + nodeId: context.input.rootNodeId, + providerThreadId: context.input.providerThread.id, + providerTurnId: context.providerTurnId, + nativeItemRef: { + driver: CLAUDE_PROVIDER, + nativeId: result.uuid, + strength: "strong", + }, + parentItemId: null, + ordinal: yield* resolveItemOrdinal(context, result.uuid), + type: "system_notice", + status: "completed", + title: failure.message, + message: failure.message, + startedAt: now, + completedAt: now, + updatedAt: now, }, - parentItemId: null, - ordinal: yield* resolveItemOrdinal(context, result.uuid), - type: "system_notice", - status: "completed", - title: failure.message, - message: failure.message, - startedAt: now, - completedAt: now, - updatedAt: now, - }, - }); - } - }); + }); + } + }, + ); const finalizeActiveTurnAfterQueryExit = Effect.fnUntraced(function* ( closedQuery: ClaudeLiveQueryContext, diff --git a/apps/server/src/provider/ClaudeTurnTokenUsage.ts b/apps/server/src/provider/ClaudeTurnTokenUsage.ts index d401fde52c2d7..985bd91d979bd 100644 --- a/apps/server/src/provider/ClaudeTurnTokenUsage.ts +++ b/apps/server/src/provider/ClaudeTurnTokenUsage.ts @@ -59,8 +59,14 @@ export function normalizeClaudeTurnTokenUsage( terminalStatus: RuntimeTurnState, earlierSegmentUsage: ReadonlyArray = [], ): TurnTokenUsage { - const finalUsage = result?.usage as Record | undefined; - const usage = finalUsage && withEarlierSegmentUsage(finalUsage, earlierSegmentUsage); + const [firstEarlierUsage, ...remainingEarlierUsage] = earlierSegmentUsage; + const finalUsage = (result?.usage ?? firstEarlierUsage) as Record | undefined; + const usage = + finalUsage && + withEarlierSegmentUsage( + finalUsage, + result?.usage == null ? remainingEarlierUsage : earlierSegmentUsage, + ); if (!usage) { return { usageStatus: "unavailable", @@ -115,6 +121,7 @@ export function normalizeClaudeTurnTokenUsage( } as const; if ( terminalStatus === "completed" && + result?.usage != null && result?.subtype === "success" && inputTokens !== undefined && rawOutputTokens !== undefined diff --git a/apps/server/src/provider/TurnTokenUsage.test.ts b/apps/server/src/provider/TurnTokenUsage.test.ts index 25db70f6411a6..d4adeea33401c 100644 --- a/apps/server/src/provider/TurnTokenUsage.test.ts +++ b/apps/server/src/provider/TurnTokenUsage.test.ts @@ -103,6 +103,12 @@ describe("billable native turn usage", () => { }); it("does not turn a zeroed Claude crash result into a measured zero-cost turn", () => { + expect( + normalizeClaudeTurnTokenUsage(undefined, false, "interrupted", [ + { input_tokens: 10, output_tokens: 20 }, + { input_tokens: 3, output_tokens: 4 }, + ]), + ).toMatchObject({ usageStatus: "partial", inputTokens: 13, outputTokens: 24 }); expect( normalizeClaudeTurnTokenUsage( { From 83c33f5915e8c6f1327178b535b0147b5332449c Mon Sep 17 00:00:00 2001 From: Fuksss Date: Thu, 12 Nov 2026 15:19:05 +0100 Subject: [PATCH 4/6] test(server): check that an earlier Claude wake's ledger growth is billed Main now subtracts the root usage from the process-wide model ledger to report other usage. A replayed earlier wake adds its root usage to the successor turn, so it must add its ledger growth too. This replay case gives every result a running ledger total and expects other usage of 10/12; it fails without that ledger line. --- .../Adapters/ClaudeAdapterV2.test.ts | 64 ++++++++++++++++++- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 89300f4fa1e47..ff53462d8f42e 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -15455,6 +15455,19 @@ describe("ClaudeAdapterV2 background wake turns", () => { cache_read_input_tokens: 0, }, }); + // Claude's modelUsage is a running total for the whole CLI process. + const ledgerTotal = (inputTokens: number, outputTokens: number) => ({ + "claude-sonnet-4-6": { + inputTokens, + outputTokens, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + webSearchRequests: 0, + costUSD: 0, + contextWindow: 0, + maxOutputTokens: 32_000, + }, + }); it.effect.each([ { label: "stopping the successor preserves earlier billed usage", @@ -15509,6 +15522,43 @@ describe("ClaudeAdapterV2 background wake turns", () => { notices: [], openOnAttach: false, }, + { + label: "the earlier wake's ledger growth stays in the successor's other usage", + // Totals 100/200, 115/225, 120/230, 126/238: the earlier wake grew 15/25 and the + // successor 6/8, against root usage 10/20 and 1/1, so other work is 10/12. + ledger: { + turnOne: ledgerTotal(100, 200), + prompt: ledgerTotal(120, 230), + otherUsage: { inputTokens: 10, outputTokens: 12 }, + }, + before: replayWake("ledger-older", "", { + result: "Earlier A.", + origin: { kind: "task-notification" }, + usage: { + input_tokens: 10, + output_tokens: 20, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + modelUsage: ledgerTotal(115, 225), + }), + after: [ + wakeTurnInit, + makeResultFrame({ + uuid: "ledger-successor-result", + result: "Successor text.", + origin: { kind: "task-notification" }, + modelUsage: ledgerTotal(126, 238), + }), + ], + beforeCheck: [], + live: [], + texts: ["Successor text."], + inputTokens: 11, + outputTokens: 21, + notices: [], + openOnAttach: false, + }, { label: "two older results and latest empty successes keep base text fallback", before: [ @@ -15561,6 +15611,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { }); const wakeAttempt = RunAttemptId.make("attempt-claude-replay-3"); const wakeRun = RunId.make(`run-${wakeAttempt}`); + const ledger = "ledger" in scenario ? scenario.ledger : undefined; yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, @@ -15572,7 +15623,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { attachments: [], }), ); - yield* harness.offerAndWait(turnOneResult); + yield* harness.offerAndWait( + ledger === undefined + ? turnOneResult + : claudeSdkFrame({ ...turnOneResult, modelUsage: ledger.turnOne }), + ); yield* harness.awaitTerminal(1); for (const frame of scenario.before) yield* harness.offerAndWait(frame); yield* Queue.take(harness.continuationReceipts); @@ -15595,7 +15650,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); yield* harness.offerAndWait( claudeSdkFrame({ - ...makeResultFrame({ uuid: "prompt-result", result: "Checked the build." }), + ...makeResultFrame({ + uuid: "prompt-result", + result: "Checked the build.", + ...(ledger === undefined ? {} : { modelUsage: ledger.prompt }), + }), user_message_uuid: harness.promptUuid(1), }), ); @@ -15707,6 +15766,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { cachedInputTokens: 0, cacheCreationTokens: 0, models: ["claude-sonnet-4-6"], + ...(ledger === undefined ? {} : { otherUsage: ledger.otherUsage }), }, notices: scenario.notices, }, From 03106d2b1a0b2896ca9d49d7a345ecc52d0dc838 Mon Sep 17 00:00:00 2001 From: Fuksss Date: Thu, 12 Nov 2026 16:53:39 +0100 Subject: [PATCH 5/6] fix(server): give an earlier Claude wake's failure notice its own sign-in advice A replayed earlier wake's failure notice now gets the same hint as a live result: the result text for a 403, and sign-in advice only when that wake itself reported authentication_failed. Before, it said that Claude gave up after repeated API errors. Tests: a replay row with three earlier wakes (signed out, plain API error, 403) checks each notice, and a replay row checks that an earlier result without a ledger total leaves other usage unknown. --- .../Adapters/ClaudeAdapterV2.test.ts | 92 ++++++++++++++++++- .../Adapters/ClaudeAdapterV2.ts | 38 +++++++- 2 files changed, 126 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index ff53462d8f42e..7cada28db3b9a 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -96,6 +96,7 @@ import { WorktreeToolkit } from "../../mcp/toolkits/worktree/tools.ts"; import { ThreadToolkit } from "../../mcp/toolkits/thread/tools.ts"; import { OrchestratorToolkit } from "../../mcp/toolkits/orchestrator/tools.ts"; import { ClaudeExecutableFileCheck } from "../../provider/Drivers/ClaudeExecutable.ts"; +import { claudeSignedOutMessage } from "../../provider/Drivers/ClaudeHome.ts"; import { BUNDLED_CLAUDE_MODEL_CATALOG, type ClaudeModelCatalog, @@ -15559,6 +15560,95 @@ describe("ClaudeAdapterV2 background wake turns", () => { notices: [], openOnAttach: false, }, + { + label: "an earlier wake without a ledger total leaves other usage unknown", + // The earlier result has no modelUsage, so its growth is unknown; the prompt + // re-anchors the ledger and the successor grows 30/40. As zero, other work would be 19/19. + ledger: { turnOne: ledgerTotal(100, 200), prompt: ledgerTotal(120, 230) }, + before: replayWake("unknown-ledger-older", "", { + result: "Earlier A.", + origin: { kind: "task-notification" }, + usage: { + input_tokens: 10, + output_tokens: 20, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }), + after: [ + wakeTurnInit, + makeResultFrame({ + uuid: "unknown-ledger-successor-result", + result: "Successor text.", + origin: { kind: "task-notification" }, + modelUsage: ledgerTotal(150, 270), + }), + ], + beforeCheck: [], + live: [], + texts: ["Successor text."], + inputTokens: 11, + outputTokens: 21, + notices: [], + openOnAttach: false, + }, + { + label: "each earlier wake's failure notice follows that wake's own sign-in state", + before: [ + claudeSdkFrame({ ...wakeTurnInit, uuid: "signed-out-init" }), + makeAssistantErrorFrame({ uuid: "signed-out-assistant", error: "authentication_failed" }), + makeResultFrame({ + uuid: "signed-out-result", + result: "", + origin: { kind: "task-notification" }, + isError: true, + terminalReason: "api_error", + }), + claudeSdkFrame({ ...wakeTurnInit, uuid: "api-error-init" }), + makeResultFrame({ + uuid: "api-error-result", + result: "", + origin: { kind: "task-notification" }, + isError: true, + terminalReason: "api_error", + }), + claudeSdkFrame({ ...wakeTurnInit, uuid: "denied-init" }), + makeAssistantErrorFrame({ uuid: "denied-assistant", error: "authentication_failed" }), + makeResultFrame({ + uuid: "denied-result", + result: + "Failed to authenticate. API Error: 403 Your organization does not have access to this model.", + origin: { kind: "task-notification" }, + isError: true, + apiErrorStatus: 403, + terminalReason: "api_error", + }), + ], + after: [ + wakeTurnInit, + makeAssistantTextFrame({ uuid: "signed-in-successor-assistant", text: "Successor text." }), + makeResultFrame({ + uuid: "signed-in-successor-result", + result: "Successor text.", + origin: { kind: "task-notification" }, + }), + ], + beforeCheck: [], + live: [], + texts: [ + "Claude could not complete this request.", + "Claude could not complete this request.", + "Successor text.", + ], + inputTokens: 4, + outputTokens: 4, + notices: [ + claudeSignedOutMessage({ configDir: undefined, cwd: "/workspace" }), + "Claude gave up after repeated API errors.", + "Failed to authenticate. API Error: 403 Your organization does not have access to this model.", + ], + openOnAttach: false, + }, { label: "two older results and latest empty successes keep base text fallback", before: [ @@ -15766,7 +15856,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { cachedInputTokens: 0, cacheCreationTokens: 0, models: ["claude-sonnet-4-6"], - ...(ledger === undefined ? {} : { otherUsage: ledger.otherUsage }), + ...(ledger?.otherUsage === undefined ? {} : { otherUsage: ledger.otherUsage }), }, notices: scenario.notices, }, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index d505320486fe4..d3c5724165887 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -4360,6 +4360,19 @@ function isClaudeEmptyTaskNotificationSuccess(message: SDKMessage): boolean { ); } +// Whether the wake that ends with the result at `resultIndex` reported a sign-in failure. +function claudeWakeSignedOut(frames: ReadonlyArray, resultIndex: number): boolean { + const wakeStart = frames.slice(0, resultIndex).findLastIndex(isClaudeTurnStartMessage); + return frames + .slice(wakeStart + 1, resultIndex) + .some( + (frame) => + frame.type === "assistant" && + frame.parent_tool_use_id === null && + frame.error === "authentication_failed", + ); +} + // The CLI throttles unchanged rate_limit_event frames, but repeats this // undeclared payload on each blocked assistant response. const isClaudeBlockedAssistantRateLimit = Schema.is( @@ -9061,7 +9074,11 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( // Assistant frames carry the earlier wake's text; its result only adds usage and errors. const recordEarlierWakeResult = Effect.fn("ClaudeAdapter.recordEarlierWakeResult")( - function* (context: ActiveClaudeTurnContext, result: SDKResultMessage) { + function* ( + context: ActiveClaudeTurnContext, + result: SDKResultMessage, + signedOut: boolean, + ) { if (isClaudeEmptyTaskNotificationSuccess(result)) return; context.earlierSegmentUsage.push(result.usage); context.earlierLedgerUsage = addClaudeLedgerUsage( @@ -9069,7 +9086,18 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( resultLedgerUsage.get(result), ); const now = yield* DateTime.now; - const failure = providerFailureFromResult(result, now); + // As for a live result: a 403 shows its text, and this wake's sign-in failure gives sign-in advice. + const permissionDenied = + result.subtype === "success" && result.api_error_status === 403; + const failure = providerFailureFromResult( + result, + now, + permissionDenied + ? result.result + : signedOut + ? context.authenticationFailureMessage + : undefined, + ); if (failure !== null) { yield* emitProviderEvent({ type: "turn_item.updated", @@ -12588,7 +12616,11 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( if (entry.type !== "result") { yield* handleSdkMessage({ query: querySession.query, message: entry }); } else if (index < lastTurnStartIndex && entry !== lastResult) { - yield* recordEarlierWakeResult(context, entry); + yield* recordEarlierWakeResult( + context, + entry, + claudeWakeSignedOut(drained, index), + ); } yield* Ref.update(wakeBuffers, (current) => { const buffered = current.get(nativeThreadId); From e7b32a77e310ddcd666594736106ef647fdc2e33 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 21:34:08 +0000 Subject: [PATCH 6/6] test(server): drop wake replay checks that repeat existing coverage Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 32 ------------------- .../src/provider/TurnTokenUsage.test.ts | 6 ---- 2 files changed, 38 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 470a9b76f9e89..eabec272c3be3 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -16787,38 +16787,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { notices: [], openOnAttach: false, }, - { - label: "an earlier wake without a ledger total leaves other usage unknown", - // The earlier result has no modelUsage, so its growth is unknown; the prompt - // re-anchors the ledger and the successor grows 30/40. As zero, other work would be 19/19. - ledger: { turnOne: ledgerTotal(100, 200), prompt: ledgerTotal(120, 230) }, - before: replayWake("unknown-ledger-older", "", { - result: "Earlier A.", - origin: { kind: "task-notification" }, - usage: { - input_tokens: 10, - output_tokens: 20, - cache_creation_input_tokens: 0, - cache_read_input_tokens: 0, - }, - }), - after: [ - wakeTurnInit, - makeResultFrame({ - uuid: "unknown-ledger-successor-result", - result: "Successor text.", - origin: { kind: "task-notification" }, - modelUsage: ledgerTotal(150, 270), - }), - ], - beforeCheck: [], - live: [], - texts: ["Successor text."], - inputTokens: 11, - outputTokens: 21, - notices: [], - openOnAttach: false, - }, { label: "each earlier wake's failure notice follows that wake's own sign-in state", before: [ diff --git a/apps/server/src/provider/TurnTokenUsage.test.ts b/apps/server/src/provider/TurnTokenUsage.test.ts index 3006e3e66779e..fb14511211e36 100644 --- a/apps/server/src/provider/TurnTokenUsage.test.ts +++ b/apps/server/src/provider/TurnTokenUsage.test.ts @@ -107,12 +107,6 @@ describe("billable native turn usage", () => { }); it("does not turn a zeroed Claude crash result into a measured zero-cost turn", () => { - expect( - normalizeClaudeTurnTokenUsage(undefined, false, "interrupted", [ - { input_tokens: 10, output_tokens: 20 }, - { input_tokens: 3, output_tokens: 4 }, - ]), - ).toMatchObject({ usageStatus: "partial", inputTokens: 13, outputTokens: 24 }); expect( normalizeClaudeTurnTokenUsage( {