From 0fc9ff10e8235dfe9538df9d73d5be7d962da09e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 07:54:05 +0000 Subject: [PATCH 1/4] test(server): Claude turns blocked by CLI permission denials report completed Failing test for #18386: an Auto-mode result carrying permission_denials from the CLI ends the turn as completed, so the thread never needs attention. AI-assisted; verified with the tests and tools listed above. --- .../Adapters/ClaudeAdapterV2.test.ts | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index c7f7d04bfe077..a96dccc5ca2b9 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -3257,6 +3257,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { readonly cache_read_input_tokens: number; }; readonly modelUsage?: SDKResultMessage["modelUsage"]; + readonly permissionDenials?: SDKResultMessage["permission_denials"]; }) => claudeSdkFrame({ type: "result", @@ -3275,7 +3276,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { cache_read_input_tokens: 0, }, modelUsage: input.modelUsage ?? {}, - permission_denials: [], + permission_denials: input.permissionDenials ?? [], uuid: input.uuid, session_id: WAKE_NATIVE_SESSION, ...(input.origin === undefined ? {} : { origin: input.origin }), @@ -7224,6 +7225,51 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("does not report a turn the CLI's permission check blocked as completed", () => + 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-cli-permission-denial"), + text: "Run printf T3_DENIAL_PROBE_ok.", + attachments: [], + }), + ); + // Auto mode denies inside the CLI, so T3 never sees a permission request; + // the denial only shows up on the result. + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000697", + result: "The Bash command was denied, so I stopped.", + permissionDenials: [ + { + tool_name: "Bash", + tool_use_id: "toolu_cli_denied_bash", + tool_input: { command: "printf T3_DENIAL_PROBE_ok" }, + }, + ], + }), + ); + + const terminal = yield* Queue.take(harness.terminalReceipts); + assert.notEqual(terminal.status, "completed"); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + it.effect.each([ { name: "an unflagged result with surrounding whitespace", From dbbcccf222d965ff4529da06343a1a8b1a11f96f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 08:02:01 +0000 Subject: [PATCH 2/4] fix(server): Claude turns blocked by Auto-mode denials need attention In Auto mode the Claude CLI refuses tool calls with its classifier or deny rules before T3's canUseTool runs, so no request is created and the only record is the result's permission_denials. The adapter ignored that field and settled the turn as completed. A successful result whose permission_denials include calls T3 did not decide itself now ends the turn as failed with a permission_error naming each blocked call. Denials the user gave through T3's own prompt keep the turn completed. AI-assisted; verified with the tests and tools listed above. --- .../Adapters/ClaudeAdapterV2.test.ts | 62 +++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 52 +++++++++++++--- 2 files changed, 107 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 a96dccc5ca2b9..88b4622053d7d 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -7258,6 +7258,68 @@ describe("ClaudeAdapterV2 background wake turns", () => { const terminal = yield* Queue.take(harness.terminalReceipts); assert.notEqual(terminal.status, "completed"); + assert.equal(terminal.failure?.class, "permission_error"); + assert.include(terminal.failure?.message, "Bash: printf T3_DENIAL_PROBE_ok"); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + it.effect("completes a turn whose only denial the user declined in T3", () => + 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-host-declined"), + text: "Run git status.", + attachments: [], + }), + ); + const decision = yield* Effect.promise(() => + harness.getOpenedOptions()!.canUseTool!( + "Bash", + { command: "git status" }, + { + signal: new AbortController().signal, + toolUseID: "toolu_host_declined_bash", + requestId: "request-host-declined-bash", + }, + ), + ).pipe(Effect.forkScoped); + const request = yield* Queue.take(harness.runtimeRequestReceipts); + yield* harness.runtime.respondToRuntimeRequest({ + requestId: request.runtimeRequest.id, + decision: "decline", + }); + assert.equal((yield* Fiber.join(decision))?.behavior, "deny"); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000698", + result: "You declined git status, so I stopped.", + permissionDenials: [ + { + tool_name: "Bash", + tool_use_id: "toolu_host_declined_bash", + tool_input: { command: "git status" }, + }, + ], + }), + ); + + const terminal = yield* Queue.take(harness.terminalReceipts); + assert.equal(terminal.status, "completed"); }).pipe( Effect.provide( Layer.mergeAll( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 92a13b2b3785e..2d9f5244d3e5d 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -4374,6 +4374,34 @@ function claudeRewrittenVersion( : undefined; } +// Auto mode's classifier and deny rules refuse tool calls inside the CLI, so +// T3 never sees a request; the result's permission_denials is the only record. +function cliPermissionDenialFailure( + message: SDKResultMessage, + hostDecidedToolUseIds: ReadonlySet, +): OrchestrationV2ProviderFailure | null { + const denied = message.permission_denials.filter( + (denial) => !hostDecidedToolUseIds.has(denial.tool_use_id), + ); + if (denied.length === 0) { + return null; + } + return makeProviderFailure({ + class: "permission_error", + code: "permission_denied", + retryable: false, + message: [ + "Claude's permission settings blocked:", + ...denied.map((denial) => + summarizeClaudeToolRequest( + denial.tool_name, + claudeNativeToolInputFromRecord(denial.tool_input), + ), + ), + ].join("\n"), + }); +} + function providerFailureFromResult( message: SDKResultMessage, receivedAt: DateTime.Utc, @@ -4691,6 +4719,8 @@ interface ActiveClaudeTurnContext { >; // Claude may settle a turn with a result that echoes only a steer's uuid. readonly steerUuids: Set; + // Tool calls T3's canUseTool decided; the result's permission_denials also lists those. + readonly hostDecidedToolUseIds: Set; // Claude can run another model than requested, such as when an organization disallows it. executedModel: string | undefined; } @@ -10897,17 +10927,23 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( failureHint, usageLimited, ); + const resultStatus = interrupted + ? "interrupted" + : limitedCompaction !== null + ? "failed" + : terminalStatusFromResult(message, failureHint); + const permissionDenialFailure = + resultStatus === "completed" + ? cliPermissionDenialFailure(message, context.hostDecidedToolUseIds) + : null; const terminalFailure = - resultFailure?.class === "usage_limit" + permissionDenialFailure ?? + (resultFailure?.class === "usage_limit" ? { ...resultFailure, resetAt: resetAt ?? resultFailure.resetAt ?? null } - : resultFailure; + : resultFailure); yield* finalizeActiveTurn({ context, - status: interrupted - ? "interrupted" - : limitedCompaction !== null - ? "failed" - : terminalStatusFromResult(message, failureHint), + status: permissionDenialFailure === null ? resultStatus : "failed", completedAt, result: message, ...(terminalFailure === null ? {} : { failure: terminalFailure }), @@ -11146,6 +11182,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } const nativeRequestId = callbackOptions.toolUseID; + context.hostDecidedToolUseIds.add(nativeRequestId); const nativeToolInput = claudeNativeToolInputFromRecord(toolInput); const heldForEcho = context.heldRootFrames.length > 0; const startToolCall = toolName !== "Agent" && options?.startToolCall !== false; @@ -12137,6 +12174,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( heldRootFrames: [], pendingSteers: new Map(), steerUuids: new Set(), + hostDecidedToolUseIds: new Set(), executedModel: undefined, }; const attachTurn = Effect.gen(function* () { From dc7231747f304e85c5a6b52441f4bc2b4c428711 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 07:30:57 +0000 Subject: [PATCH 3/4] fix(server): Auto-mode denial failures keep the turn's background work running Reporting a CLI permission denial as a failed turn ran the cleanup for a stopped turn: it stopped running workflows, ended background subagent calls, cancelled steers and cleared the background roster while Claude's process kept running. The turn now finalizes with its real status and only its terminal and provider turn report the permission failure. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 3 +++ .../Adapters/ClaudeAdapterV2.ts | 26 ++++++++++++------- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index bea87e0f4d45d..1213b4705f776 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -7383,6 +7383,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { attachments: [], }), ); + yield* harness.offerAndWait(wakeTaskStarted); // Auto mode denies inside the CLI, so T3 never sees a permission request; // the denial only shows up on the result. yield* Queue.offer( @@ -7404,6 +7405,8 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.notEqual(terminal.status, "completed"); assert.equal(terminal.failure?.class, "permission_error"); assert.include(terminal.failure?.message, "Bash: printf T3_DENIAL_PROBE_ok"); + // The process still runs its background shell, so the turn keeps tracking it. + assert.isTrue(yield* harness.hasPendingBackgroundWork); }).pipe( Effect.provide( Layer.mergeAll( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index d9ca8e4fcb154..743203828040f 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -8664,9 +8664,12 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( >; readonly completedAt: DateTime.Utc; readonly failure?: OrchestrationV2ProviderFailure; + // Reports the turn as failed without the cleanup a stopped turn gets: the process and its background work live on. + readonly reportedFailure?: OrchestrationV2ProviderFailure; readonly threadDisposition?: "reusable" | "broken"; readonly result?: SDKResultMessage; }) { + const reportedStatus = input.reportedFailure === undefined ? input.status : "failed"; input.context.steersClosed = true; yield* reasoningDeltas.flushTurn(input.context.nativeTurnId); if (input.status === "completed") { @@ -8866,7 +8869,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const threadDisposition = input.threadDisposition ?? "reusable"; const terminalEvent: ProviderAdapter.ProviderAdapterV2Event = - input.status === "failed" + reportedStatus === "failed" ? { type: "turn.terminal", driver: CLAUDE_PROVIDER, @@ -8877,8 +8880,11 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( input.context, `terminal-failure:${input.context.providerTurnId}`, ), - status: input.status, - failure: input.failure ?? makeProviderFailure({ class: "provider_error" }), + status: reportedStatus, + failure: + input.reportedFailure ?? + input.failure ?? + makeProviderFailure({ class: "provider_error" }), ...(providerRetry === undefined ? {} : { @@ -8893,7 +8899,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( providerThreadId: input.context.input.providerThread.id, providerTurnId: input.context.providerTurnId, runOrdinal: input.context.input.runOrdinal, - status: input.status, + status: reportedStatus, failure: null, threadDisposition, }; @@ -8905,7 +8911,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( providerTurn: { ...providerTurnPayload({ context: input.context, - status: input.status, + status: reportedStatus, completedAt: input.completedAt, }), turnTokenUsage: normalizeClaudeTurnTokenUsage( @@ -11266,16 +11272,18 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ? cliPermissionDenialFailure(message, context.hostDecidedToolUseIds) : null; const terminalFailure = - permissionDenialFailure ?? - (resultFailure?.class === "usage_limit" + resultFailure?.class === "usage_limit" ? { ...resultFailure, resetAt: resetAt ?? resultFailure.resetAt ?? null } - : resultFailure); + : resultFailure; yield* finalizeActiveTurn({ context, - status: permissionDenialFailure === null ? resultStatus : "failed", + status: resultStatus, completedAt, result: message, ...(terminalFailure === null ? {} : { failure: terminalFailure }), + ...(permissionDenialFailure === null + ? {} + : { reportedFailure: permissionDenialFailure }), }); } }); From f54f0bc8da05682e4666c18aa16e8ab3298259ee Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 07:41:07 +0000 Subject: [PATCH 4/4] fix(server): Auto-mode denial failures let the run keep ingesting background work A completed Claude turn reported as failed for a CLI permission denial omitted childTurnsContinue, so the run service cascade-failed open subagents and stopped event ingestion while Claude's background work was still running. The terminal now sets childTurnsContinue, as Codex does for a failed root whose children keep working. Co-Authored-By: Claude Opus 5.5 --- .../src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts | 3 ++- apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 1213b4705f776..c1e64efb878dd 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -7405,7 +7405,8 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.notEqual(terminal.status, "completed"); assert.equal(terminal.failure?.class, "permission_error"); assert.include(terminal.failure?.message, "Bash: printf T3_DENIAL_PROBE_ok"); - // The process still runs its background shell, so the turn keeps tracking it. + // The process still runs its background shell, so the run and the turn keep tracking it. + assert.isTrue(terminal.status === "failed" && terminal.childTurnsContinue); assert.isTrue(yield* harness.hasPendingBackgroundWork); }).pipe( Effect.provide( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 743203828040f..2a52bb6c1b19f 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -8891,6 +8891,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( retry: providerRetry.retry, retryStartedAt: providerRetry.startedAt, }), + ...(input.reportedFailure === undefined ? {} : { childTurnsContinue: true }), threadDisposition, } : {