From 95583c604530b86ad04dd421fec9fd61cf5ed5e0 Mon Sep 17 00:00:00 2001 From: Mnigos Date: Mon, 12 Oct 2026 01:57:02 +0200 Subject: [PATCH 1/2] fix(server): Claude auto-mode denials need attention instead of completing In Auto mode, Claude Code's classifier denies tool calls before T3's permission callback runs, so no pending request is created and a turn that gave up on a blocked action ended as Completed and could auto-settle. The Claude adapter now records every tool call T3's own callback denied (captured ExitPlanMode plans, declined approvals, cancelled questions) and, when a result's permission_denials lists calls outside that set, emits one warning notice for the turn with the reason taken from the matching tool_result error text. The notice sets needsAttention, a new optional field on system_notice. Settlement skips a thread while its latest started run carries such a notice; queued, held and cancelled-before-start runs keep the hold, and any run that starts releases it. --- .../Adapters/ClaudeAdapterV2.test.ts | 239 +++++++++++++++++- .../Adapters/ClaudeAdapterV2.ts | 71 +++++- .../Adapters/claudePermissionDenials.ts | 42 +++ .../ProjectionSettlement.test.ts | 92 ++++++- .../src/orchestration-v2/ProjectionStore.ts | 50 ++++ .../tool_call_denied_write/claude_output.ts | 5 + docs/user/thread-sidebar.md | 3 +- packages/contracts/src/orchestrationV2.ts | 10 + 8 files changed, 507 insertions(+), 5 deletions(-) create mode 100644 apps/server/src/orchestration-v2/Adapters/claudePermissionDenials.ts diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 4bf007065a739..19b3b716a76d4 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -2233,6 +2233,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { readonly apiErrorStatus?: number; // null omits the field, as the CLI does on a zero-turn result. readonly terminalReason?: SDKResultMessage["terminal_reason"] | null; + readonly permissionDenials?: SDKResultMessage["permission_denials"]; }) => claudeSdkFrame({ type: "result", @@ -2251,7 +2252,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { cache_read_input_tokens: 0, }, modelUsage: {}, - permission_denials: [], + permission_denials: input.permissionDenials ?? [], uuid: input.uuid, session_id: WAKE_NATIVE_SESSION, ...(input.origin === undefined ? {} : { origin: input.origin }), @@ -2363,6 +2364,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* Queue.unbounded< Extract >(); + const runtimeRequestReceipts = + yield* Queue.unbounded< + Extract + >(); let openedOptions: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions | undefined; const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, @@ -2464,6 +2469,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { if (event.type === "turn_item.updated" && event.turnItem.type === "system_notice") { yield* Queue.offer(systemNoticeReceipts, event); } + if (event.type === "runtime_request.updated") { + yield* Queue.offer(runtimeRequestReceipts, event); + } }), ), Effect.forkScoped, @@ -2500,6 +2508,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { terminalReceipts, subagentReceipts, systemNoticeReceipts, + runtimeRequestReceipts, getOpenedOptions: () => openedOptions, terminalEvents, hasPendingBackgroundWork, @@ -4177,6 +4186,234 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + describe("permission denials Claude Code made on its own", () => { + const AUTO_RUNTIME_POLICY = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "auto", + interactionMode: "default", + cwd: "/workspace", + }); + const APPROVAL_RUNTIME_POLICY = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "approval-required", + interactionMode: "default", + cwd: "/workspace", + }); + const CLASSIFIER_REASON = + "Permission for this action has been denied. Reason: cannot assess the action due to earlier conversation content."; + + const makeDenialHarness = Effect.gen(function* () { + const harness = yield* makeWakeHarness; + let sequence = 900; + let turn = 0; + const nextUuid = () => `00000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}`; + const startTurn = Effect.fnUntraced(function* ( + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, + ) { + turn++; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make(`attempt-claude-denials-${turn}`), + providerTurnOrdinal: turn, + text: "Run the checks.", + attachments: [], + runtimePolicy, + }), + ); + }); + // A tool call Claude made and the failed tool_result it got back. + const failedToolCall = Effect.fnUntraced(function* (input: { + readonly toolUseId: string; + readonly toolName: string; + readonly error: string; + }) { + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "assistant", + message: { + id: `msg_${nextUuid()}`, + model: "claude-sonnet-4-6", + type: "message", + role: "assistant", + content: [{ type: "tool_use", id: input.toolUseId, name: input.toolName, input: {} }], + stop_reason: "tool_use", + stop_sequence: null, + usage: { + input_tokens: 1, + output_tokens: 1, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, + parent_tool_use_id: null, + uuid: nextUuid(), + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: input.toolUseId, + content: input.error, + is_error: true, + }, + ], + }, + parent_tool_use_id: null, + uuid: nextUuid(), + session_id: WAKE_NATIVE_SESSION, + }), + ); + }); + const finishTurn = Effect.fnUntraced(function* ( + denials: SDKResultMessage["permission_denials"], + ) { + yield* harness.offerAndWait( + makeResultFrame({ + uuid: nextUuid(), + result: "I could not run the checks.", + permissionDenials: denials, + }), + ); + return yield* Queue.take(harness.terminalReceipts); + }); + const notices = () => + harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "system_notice" + ? [event.turnItem] + : [], + ); + const canUseTool = ( + toolName: string, + toolInput: Record, + toolUseID: string, + signal = new AbortController().signal, + ) => + Effect.promise(() => + harness.getOpenedOptions()!.canUseTool!(toolName, toolInput, { + signal, + toolUseID, + requestId: `request-${toolUseID}`, + }), + ); + return { harness, startTurn, failedToolCall, finishTurn, notices, canUseTool }; + }); + + it.effect( + "flags classifier denials with their reason, and only on the turn that had them", + () => + Effect.gen(function* () { + const { startTurn, failedToolCall, finishTurn, notices } = yield* makeDenialHarness; + yield* startTurn(AUTO_RUNTIME_POLICY); + for (const toolUseId of ["toolu-classifier-1", "toolu-classifier-2"]) { + yield* failedToolCall({ toolUseId, toolName: "Bash", error: CLASSIFIER_REASON }); + } + const terminal = yield* finishTurn([ + { tool_name: "Bash", tool_use_id: "toolu-classifier-1", tool_input: { command: "ls" } }, + { + tool_name: "Bash", + tool_use_id: "toolu-classifier-2", + tool_input: { command: "pwd" }, + }, + ]); + + assert.equal(terminal.status, "completed"); + const [notice, ...rest] = notices(); + assert.lengthOf(rest, 0); + assert.equal(notice?.type, "system_notice"); + if (notice?.type !== "system_notice") return; + assert.equal(notice.message, `Auto mode denied 2 actions (Bash): ${CLASSIFIER_REASON}`); + assert.isTrue(notice.needsAttention); + assert.equal(notice.providerTurnId, terminal.providerTurnId); + + // The next turn has no denials, so it adds nothing. + yield* startTurn(AUTO_RUNTIME_POLICY); + assert.equal((yield* finishTurn([])).status, "completed"); + assert.lengthOf(notices(), 1); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + + it.effect("leaves out the calls T3 denied itself", () => + Effect.gen(function* () { + const { harness, startTurn, failedToolCall, finishTurn, notices, canUseTool } = + yield* makeDenialHarness; + yield* startTurn(APPROVAL_RUNTIME_POLICY); + + // ExitPlanMode: T3 captures the plan and denies the tool. + assert.equal( + (yield* canUseTool("ExitPlanMode", { plan: "# Plan" }, "toolu-plan"))?.behavior, + "deny", + ); + // A question the CLI cancelled before the user answered. + const cancelled = new AbortController(); + cancelled.abort(); + assert.equal( + (yield* canUseTool( + "AskUserQuestion", + { questions: [] }, + "toolu-question", + cancelled.signal, + ))?.behavior, + "deny", + ); + // An approval the user declined. + const decline = yield* canUseTool("Write", { file_path: "/tmp/x" }, "toolu-write").pipe( + Effect.forkScoped, + ); + // The cancelled question raised a request first; skip to the Write one. + let approval = yield* Queue.take(harness.runtimeRequestReceipts); + while (approval.runtimeRequest.nativeRequestRef?.nativeId !== "toolu-write") { + approval = yield* Queue.take(harness.runtimeRequestReceipts); + } + yield* harness.runtime.respondToRuntimeRequest({ + requestId: approval.runtimeRequest.id, + decision: "decline", + }); + assert.equal((yield* Fiber.join(decline))?.behavior, "deny"); + yield* failedToolCall({ + toolUseId: "toolu-write", + toolName: "Write", + error: "User declined tool execution.", + }); + + // A deny rule Claude Code applied on its own, in the same turn. + yield* failedToolCall({ + toolUseId: "toolu-rule", + toolName: "Bash", + error: "Denied by rule.", + }); + + const terminal = yield* finishTurn([ + { tool_name: "ExitPlanMode", tool_use_id: "toolu-plan", tool_input: {} }, + { tool_name: "AskUserQuestion", tool_use_id: "toolu-question", tool_input: {} }, + { tool_name: "Write", tool_use_id: "toolu-write", tool_input: {} }, + { tool_name: "Bash", tool_use_id: "toolu-rule", tool_input: {} }, + ]); + assert.equal(terminal.status, "completed"); + assert.deepEqual( + notices().map((notice) => (notice.type === "system_notice" ? notice.message : null)), + ["Claude denied 1 action (Bash): Denied by rule."], + ); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + }); + it.effect("removes deleted tasks without resurrecting them on later updates or turns", () => Effect.gen(function* () { const harness = yield* makeTaskToolHarness; diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 7199f73040ef9..8b3a647a3e27d 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -6,6 +6,7 @@ import { } from "@t3tools/shared/toolActivity"; import { field, text } from "../unknownField.ts"; import { readWorkflowAgentAnswers } from "../workflowAgentAnswers.ts"; +import { claudePermissionDenialNotice } from "./claudePermissionDenials.ts"; import { CLAUDE_WORKFLOW_TASK_TYPE, hasClaudeWorkflowSnapshot, @@ -2927,6 +2928,9 @@ interface ActiveClaudeTurnContext { >; }; readonly toolCalls: Map; + // Error text of this turn's failed tool calls, by tool_use id: the only + // record of why Claude Code denied a call on its own. + readonly toolErrorTexts: Map; readonly ignoredTaskIds: Set; readonly announcedUsageLimits: Set; authenticationFailureMessage: string | undefined; @@ -3673,6 +3677,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( // tool_use frame is handled, in whichever run that frame is routed // to (the prompt's turn, or the continuation that drains a wake). const heldProposedPlansByToolUseId = new Map(); + // Tool calls T3's permission callback denied, so a result's + // permission_denials can tell them from Claude Code's own denials. + const deniedByT3ToolUseIds = new Set(); const runtimeContext = yield* Effect.context(); const runPromise = Effect.runPromiseWith(runtimeContext); @@ -7301,6 +7308,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } for (const { toolResult, output } of claudeToolResultEntriesFromMessage(message)) { + if (isClaudeToolResultError(toolResult)) { + context.toolErrorTexts.set(toolResult.tool_use_id, claudeSubagentResultText(output)); + } const subagent = yield* resolveSubagentByToolUseId(context, toolResult.tool_use_id); const runHandles = parseClaudeWorkflowRunHandles(claudeNativeToolOutputValue(output)); // Independent of the branches below: unlike the Agent tool, a @@ -7615,6 +7625,54 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( resultFailure?.class === "usage_limit" ? { ...resultFailure, resetAt: resetAt ?? resultFailure.resetAt ?? null } : resultFailure; + const denialNotice = interrupted + ? null + : claudePermissionDenialNotice({ + denials: message.permission_denials ?? [], + deniedByT3: deniedByT3ToolUseIds, + toolErrorTexts: context.toolErrorTexts, + permissionMode: claudeRuntimeQueryPolicyForRuntimePolicy( + context.input.runtimePolicy, + ).permissionMode, + }); + for (const denial of message.permission_denials ?? []) { + deniedByT3ToolUseIds.delete(denial.tool_use_id); + } + if (denialNotice !== null) { + // The turn still completes. The notice keeps the thread out of + // auto-settlement until another run starts. + const nativeItemId = `permission-denials:${context.providerTurnId}`; + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: CLAUDE_PROVIDER, + turnItem: { + id: idAllocator.derive.turnItemFromProviderItem({ + driver: CLAUDE_PROVIDER, + nativeItemId, + }), + 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: nativeItemId, + strength: "weak", + }, + parentItemId: null, + ordinal: yield* resolveItemOrdinal(context, nativeItemId), + type: "system_notice", + status: "completed", + title: denialNotice, + message: denialNotice, + needsAttention: true, + startedAt: completedAt, + completedAt, + updatedAt: completedAt, + }, + }); + } yield* finalizeActiveTurn({ context, status: interrupted ? "interrupted" : terminalStatusFromResult(message, failureHint), @@ -8081,7 +8139,17 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) => - runPromise(canUseToolEffect(toolName, toolInput, callbackOptions)); + runPromise( + canUseToolEffect(toolName, toolInput, callbackOptions).pipe( + Effect.tap((result) => + Effect.sync(() => { + if (result.behavior === "deny") { + deniedByT3ToolUseIds.add(callbackOptions.toolUseID); + } + }), + ), + ), + ); const onUserDialog: NonNullable = ( request, @@ -8480,6 +8548,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( blocks: new Map(), }, toolCalls: new Map(), + toolErrorTexts: new Map(), ignoredTaskIds: new Set(), announcedUsageLimits: new Set(), authenticationFailureMessage: undefined, diff --git a/apps/server/src/orchestration-v2/Adapters/claudePermissionDenials.ts b/apps/server/src/orchestration-v2/Adapters/claudePermissionDenials.ts new file mode 100644 index 0000000000000..129e0c199c048 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/claudePermissionDenials.ts @@ -0,0 +1,42 @@ +import type { PermissionMode, SDKPermissionDenial } from "@anthropic-ai/claude-agent-sdk"; + +const MAX_REASON_LENGTH = 300; + +function oneLine(text: string): string { + const collapsed = text.replace(/\s+/gu, " ").trim(); + return collapsed.length > MAX_REASON_LENGTH + ? `${collapsed.slice(0, MAX_REASON_LENGTH - 1)}…` + : collapsed; +} + +/** + * The notice for tool calls Claude Code denied on its own, such as Auto + * mode's classifier, which decides before T3's permission callback runs. + * T3's own denials (a captured ExitPlanMode plan, a declined approval, a + * cancelled question) are already visible to the user and are left out. + * The SDK's denial entries carry no reason, so it comes from the tool_result + * error text Claude received for that call. Null when nothing is left. + */ +export function claudePermissionDenialNotice(input: { + readonly denials: ReadonlyArray; + readonly deniedByT3: ReadonlySet; + readonly toolErrorTexts: ReadonlyMap; + readonly permissionMode: PermissionMode; +}): string | null { + const denials = input.denials.filter((denial) => !input.deniedByT3.has(denial.tool_use_id)); + if (denials.length === 0) return null; + const count = `${denials.length} action${denials.length === 1 ? "" : "s"}`; + const title = `${input.permissionMode === "auto" ? "Auto mode" : "Claude"} denied ${count}`; + const tools = [...new Set(denials.map((denial) => denial.tool_name))].join(", "); + const reasons = [ + ...new Set( + denials.flatMap((denial) => { + const text = input.toolErrorTexts.get(denial.tool_use_id); + return text === undefined || text.trim().length === 0 ? [] : [oneLine(text)]; + }), + ), + ]; + return reasons.length === 0 + ? `${title} (${tools}).` + : `${title} (${tools}): ${reasons.join(" ")}`; +} diff --git a/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts b/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts index f626d0c385834..8f1ac6bbeb77c 100644 --- a/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts @@ -76,11 +76,12 @@ const createRun = Effect.fn(function* ( threadId: ThreadId, status: OrchestrationV2Run["status"] = "completed", ordinal = 1, + overrides: Partial = {}, ) { const store = yield* ProjectionStore.ProjectionStoreV2; const runId = RunId.make(`run:${threadId}:${ordinal}`); yield* store.apply({ - id: EventId.make(`event:${runId}`), + id: EventId.make(`event:${runId}:${status}`), type: "run.created", threadId, occurredAt: old, @@ -100,6 +101,7 @@ const createRun = Effect.fn(function* ( completedAt: ["completed", "rolled_back", "failed"].includes(status) ? old : null, checkpointId: null, contextHandoffId: null, + ...overrides, }, }); return runId; @@ -140,6 +142,80 @@ const createItem = Effect.fn(function* ( }); }); +const createNotice = Effect.fn(function* ( + threadId: ThreadId, + runId: RunId, + needsAttention: boolean, +) { + const store = yield* ProjectionStore.ProjectionStoreV2; + yield* store.apply({ + id: EventId.make(`event:notice:${runId}`), + type: "turn-item.updated", + threadId, + occurredAt: old, + payload: { + id: TurnItemId.make(`notice:${runId}`), + threadId, + runId, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 2, + type: "system_notice", + status: "completed", + title: null, + message: "Auto mode denied 1 action (Bash): blocked by the classifier.", + ...(needsAttention ? { needsAttention: true } : {}), + startedAt: old, + completedAt: old, + updatedAt: old, + }, + }); +}); + +it.effect.each([ + ["sql", layerSql], + ["memory", ProjectionStore.layerMemory], +] as const)( + "%s: a run that ended on denied actions holds settlement until another run starts", + ([, testLayer]) => + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const denied = yield* createThread("denied"); + yield* createNotice(denied, yield* createRun(denied), true); + const notice = yield* createThread("plain-notice"); + yield* createNotice(notice, yield* createRun(notice), false); + const retried = yield* createThread("retried"); + yield* createNotice(retried, yield* createRun(retried), true); + yield* createRun(retried, "completed", 2); + + // Follow-ups that never started keep the hold: a held queue, an + // ordinary queued message, and one cancelled before it started. + const waiting = yield* createThread("waiting-follow-ups"); + yield* createNotice(waiting, yield* createRun(waiting), true); + yield* createRun(waiting, "queued", 2, { queueHeld: true }); + yield* createRun(waiting, "queued", 3); + yield* createRun(waiting, "cancelled", 4, { startedAt: null }); + + const candidates = yield* store.getSettlementCandidates(); + assert.deepEqual( + candidates.map((thread) => thread.id).toSorted(), + [notice, retried].toSorted(), + ); + assert.deepEqual(yield* store.getSettlementCandidates(denied), []); + assert.deepEqual(yield* store.getSettlementCandidates(waiting), []); + + // The held follow-up starts, so the user has moved on. + yield* createRun(waiting, "completed", 2, { startedAt: now, completedAt: now }); + assert.deepEqual( + (yield* store.getSettlementCandidates(waiting)).map((thread) => thread.id), + [waiting], + ); + }).pipe(Effect.provide(testLayer)), +); + it.effect.each([ ["sql", layerSql], ["memory", ProjectionStore.layerMemory], @@ -459,8 +535,20 @@ it.effect( ); assert.isTrue(queries.every(([query]) => !query.includes("COUNT("))); const pendingQuery = queries.find(([query]) => - query.includes("FROM orchestration_v2_projection_turn_items"), + query.includes("FROM orchestration_v2_projection_turn_items i"), + ); + const candidateQuery = queries.find(([query]) => + query.includes("FROM orchestration_v2_projection_turn_items notice"), + ); + assert.isDefined(candidateQuery); + const candidatePlan = yield* sql.unsafe<{ readonly detail: string }>( + `EXPLAIN QUERY PLAN ${candidateQuery![0]}`, + candidateQuery![1], ); + // The latest run's notices are an index lookup, not a history scan. + const noticeLookups = candidatePlan.filter((row) => row.detail.includes(" notice ")); + assert.isNotEmpty(noticeLookups); + assert.isTrue(noticeLookups.every((row) => row.detail.startsWith("SEARCH notice USING"))); assert.isDefined(pendingQuery); const metadata = yield* store.getThread(threadId); assert.equal(metadata.id, threadId); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 4f10a0512fd53..c8ef58573d9b1 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1374,6 +1374,31 @@ function buildVisibleTurnItems(input: { ]); } +/** + * The run whose needs-attention notices hold a thread out of auto-settlement: + * the one that started last. Queued runs, held or not, and runs cancelled + * before they started never release the hold; any run that starts does, + * including an automatic continuation. The SQL settlement query mirrors this. + */ +function attentionAnchorRunId( + runs: ReadonlyArray, +): RunId | null { + let anchor: { readonly id: RunId; readonly ordinal: number; readonly at: number } | null = null; + for (const run of runs) { + if (run.startedAt === null) continue; + const at = DateTime.toEpochMillis(run.startedAt); + if ( + anchor === null || + at > anchor.at || + (at === anchor.at && + (run.ordinal > anchor.ordinal || (run.ordinal === anchor.ordinal && run.id > anchor.id))) + ) { + anchor = { id: run.id, ordinal: run.ordinal, at }; + } + } + return anchor?.id ?? null; +} + /** * An agent waiting on a secret is waiting on the user just like a question, * so the shell reports it as pending user input. Secret requests have no @@ -5519,6 +5544,22 @@ export const layer: Layer.Layer = SELECT 1 FROM orchestration_v2_projection_runtime_requests request WHERE request.thread_id = t.thread_id AND request.status = 'pending' ) + -- A needs-attention notice on the latest started run holds the + -- thread like a pending request; see attentionAnchorRunId. + AND NOT EXISTS ( + SELECT 1 FROM orchestration_v2_projection_turn_items notice + WHERE notice.thread_id = t.thread_id + AND notice.run_id = ( + SELECT anchor.run_id FROM orchestration_v2_projection_runs anchor + WHERE anchor.thread_id = t.thread_id + AND json_extract(anchor.payload_json, '$.startedAt') IS NOT NULL + ORDER BY json_extract(anchor.payload_json, '$.startedAt') DESC, + anchor.ordinal DESC, anchor.run_id DESC + LIMIT 1 + ) + AND notice.type = 'system_notice' + AND json_extract(notice.payload_json, '$.needsAttention') = 1 + ) ORDER BY t.updated_at ASC, t.thread_id ASC `; if (rows.length === 0) return []; @@ -6080,6 +6121,15 @@ export const layerMemory: Layer.Layer = Layer.effect( !runs.some(isActivityRunForShell) && !runtimeRequests.some((request) => request.status === "pending"), ) + .filter(({ runs, turnItems }) => { + const anchorRunId = attentionAnchorRunId(runs); + return !turnItems.some( + (item) => + item.runId === anchorRunId && + item.type === "system_notice" && + item.needsAttention === true, + ); + }) .map((projection) => { const shell = threadShellFromProjection(projection); return { diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_denied_write/claude_output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_denied_write/claude_output.ts index 558dc7167cd54..f63beaf7f3d2a 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_denied_write/claude_output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_denied_write/claude_output.ts @@ -41,4 +41,9 @@ export function assertToolCallDeniedWriteClaudeOutput( const writes = projection.turnItems.filter((item) => item.type === "file_change"); assert.lengthOf(writes, 1); assert.equal(writes[0]?.status, "failed"); + // The result lists this decline in permission_denials, but the user made it. + assert.deepEqual( + projection.turnItems.filter((item) => item.type === "system_notice"), + [], + ); } diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 80af3a815b676..3f076d5f4e91d 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -148,7 +148,8 @@ Press `Escape` while dragging to cancel. By default, environments settle inactive threads after three days and settle threads whose pull request merged. A closed pull request can also settle an idle -thread. Work in progress, pending questions or approvals, and live background work +thread. Work in progress, pending questions or approvals, live background work, and a +last turn where Claude blocked actions without asking you, for example in Auto mode, prevent automatic settlement. An open pull request does not prevent inactivity settlement, but an old closed or merged pull request does not settle work you resumed after it closed. Only your own messages count as resuming. A turn that diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 4822493765a88..7b7f93ca8cd35 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1579,6 +1579,15 @@ export const OrchestrationV2TurnItem = Schema.Union([ ...OrchestrationV2TurnItemBaseFields, type: Schema.Literal("system_notice"), message: Schema.String, + /** + * The notice reports something the agent could not get past without the + * user, such as tool calls Claude blocked without asking. While its run is + * the thread's latest started run, the thread is held out of + * auto-settlement. Queued runs (held or not) and runs cancelled before + * starting keep the hold; any run that starts, including an automatic + * continuation, releases it. + */ + needsAttention: Schema.optional(Schema.Boolean), }), Schema.Struct({ ...OrchestrationV2TurnItemBaseFields, @@ -2358,6 +2367,7 @@ export const OrchestrationV2TurnItemJson = Schema.Union([ ...OrchestrationV2TurnItemJsonBaseFields, type: Schema.Literal("system_notice"), message: Schema.String, + needsAttention: Schema.optional(Schema.Boolean), }), Schema.Struct({ ...OrchestrationV2TurnItemJsonBaseFields, From 5853b33f1e2b450cd6b7ddce0600370ad0c36549 Mon Sep 17 00:00:00 2001 From: Mnigos Date: Mon, 12 Oct 2026 02:19:48 +0200 Subject: [PATCH 2/2] fix(server): T3-denied tool ids live with the turn, not the session The set of tool calls T3's permission callback denied was session-scoped and only pruned by ids a later result listed, so a denial that never reached a result (an interrupted turn, a query exit) stayed for the rest of the session. Each turn context now owns its set and drops it with the turn. A denial whose tool_use frame is still held for a prompt echo waits beside the held plans and joins the turn that frame is routed to, so a wake turn's result still recognizes an ExitPlanMode capture. A callback with no active turn records nothing, since no result will list that call. --- .../Adapters/ClaudeAdapterV2.test.ts | 23 ++++++++++++++- .../Adapters/ClaudeAdapterV2.ts | 29 ++++++++++++------- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 19b3b716a76d4..6fd396696909b 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -5708,7 +5708,24 @@ describe("ClaudeAdapterV2 background wake turns", () => { "The client captured your proposed plan", ); yield* Queue.offer(harness.sdkMessages, planToolResult); - yield* Queue.offer(harness.sdkMessages, wakeResult); + // The wake result lists the captured plan among its denials. T3 made + // that denial while the frame was held, so the turn it lands in + // must still recognize it. + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000745", + result: WAKE_RESULT_TEXT, + origin: { kind: "task-notification" }, + permissionDenials: [ + { + tool_name: "ExitPlanMode", + tool_use_id: planToolUseId, + tool_input: { plan: planMarkdown }, + }, + ], + }), + ); yield* awaitUntil( () => harness.continuationRequests.length === 1, "continuation request", @@ -5797,6 +5814,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { userRunItems.map((item) => item.type), ["assistant_message"], ); + assert.notInclude( + [...latestItems.values()].map((item) => item.type), + "system_notice", + ); }).pipe( Effect.provide( Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 8b3a647a3e27d..95d42263ee582 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -2931,6 +2931,9 @@ interface ActiveClaudeTurnContext { // Error text of this turn's failed tool calls, by tool_use id: the only // record of why Claude Code denied a call on its own. readonly toolErrorTexts: Map; + // Tool calls T3's permission callback denied in this turn, so its result's + // permission_denials can tell them from Claude Code's own denials. + readonly deniedByT3ToolUseIds: Set; readonly ignoredTaskIds: Set; readonly announcedUsageLimits: Set; authenticationFailureMessage: string | undefined; @@ -3677,9 +3680,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( // tool_use frame is handled, in whichever run that frame is routed // to (the prompt's turn, or the continuation that drains a wake). const heldProposedPlansByToolUseId = new Map(); - // Tool calls T3's permission callback denied, so a result's - // permission_denials can tell them from Claude Code's own denials. - const deniedByT3ToolUseIds = new Set(); + // T3 denials whose tool_use frame is held for a prompt echo, like + // held plans: each joins the turn its frame is routed to. + const heldDeniedByT3ToolUseIds = new Set(); const runtimeContext = yield* Effect.context(); const runPromise = Effect.runPromiseWith(runtimeContext); @@ -7295,6 +7298,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( toolName: toolUse.name.replace(/^mcp__claude_ai_/u, "mcp__"), }), }); + if (heldDeniedByT3ToolUseIds.delete(toolUse.id)) { + context.deniedByT3ToolUseIds.add(toolUse.id); + } const heldPlan = heldProposedPlansByToolUseId.get(toolUse.id); if (heldPlan !== undefined) { heldProposedPlansByToolUseId.delete(toolUse.id); @@ -7629,15 +7635,12 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ? null : claudePermissionDenialNotice({ denials: message.permission_denials ?? [], - deniedByT3: deniedByT3ToolUseIds, + deniedByT3: context.deniedByT3ToolUseIds, toolErrorTexts: context.toolErrorTexts, permissionMode: claudeRuntimeQueryPolicyForRuntimePolicy( context.input.runtimePolicy, ).permissionMode, }); - for (const denial of message.permission_denials ?? []) { - deniedByT3ToolUseIds.delete(denial.tool_use_id); - } if (denialNotice !== null) { // The turn still completes. The notice keeps the thread out of // auto-settlement until another run starts. @@ -8142,9 +8145,14 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( runPromise( canUseToolEffect(toolName, toolInput, callbackOptions).pipe( Effect.tap((result) => - Effect.sync(() => { - if (result.behavior === "deny") { - deniedByT3ToolUseIds.add(callbackOptions.toolUseID); + Effect.gen(function* () { + const context = yield* Ref.get(activeTurn); + // With no active turn, no result will list this call. + if (result.behavior !== "deny" || context === null) return; + if (heldToolUseIds(context).has(callbackOptions.toolUseID)) { + heldDeniedByT3ToolUseIds.add(callbackOptions.toolUseID); + } else { + context.deniedByT3ToolUseIds.add(callbackOptions.toolUseID); } }), ), @@ -8549,6 +8557,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }, toolCalls: new Map(), toolErrorTexts: new Map(), + deniedByT3ToolUseIds: new Set(), ignoredTaskIds: new Set(), announcedUsageLimits: new Set(), authenticationFailureMessage: undefined,