From 2987fd6279b28efa43cc4741702c534ec49fe1dc Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Thu, 23 Jul 2026 12:53:42 -0400 Subject: [PATCH] feat(orchestrator): Surface waiting background work --- .../Adapters/AcpAdapterV2.test.ts | 159 ++ .../orchestration-v2/Adapters/AcpAdapterV2.ts | 14 + .../Adapters/ClaudeAdapterV2.test.ts | 1649 ++++++++++++++++- .../Adapters/ClaudeAdapterV2.ts | 826 ++++++++- .../src/orchestration-v2/ProjectionStore.ts | 80 +- .../src/orchestration-v2/ProviderAdapter.ts | 9 + .../ProviderRuntimeRecoveryService.test.ts | 213 +++ .../ProviderRuntimeRecoveryService.ts | 93 +- .../RunExecutionService.test.ts | 362 ++++ .../orchestration-v2/RunExecutionService.ts | 28 +- apps/web/src/components/ChatView.tsx | 21 + apps/web/src/components/Sidebar.logic.test.ts | 80 + apps/web/src/components/Sidebar.logic.ts | 12 + .../chat/MessagesTimeline.logic.test.ts | 63 + .../components/chat/MessagesTimeline.logic.ts | 39 +- .../src/components/chat/MessagesTimeline.tsx | 27 + .../client-runtime/src/state/entities.test.ts | 93 + packages/client-runtime/src/state/models.ts | 13 +- .../contracts/src/orchestrationV2.test.ts | 91 + packages/contracts/src/orchestrationV2.ts | 21 + packages/shared/package.json | 4 + ...chestrationV2PendingBackgroundWork.test.ts | 172 ++ .../orchestrationV2PendingBackgroundWork.ts | 189 ++ 23 files changed, 4169 insertions(+), 89 deletions(-) create mode 100644 packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts create mode 100644 packages/shared/src/orchestrationV2PendingBackgroundWork.ts diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 18b538898d3..f54f208ca83 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -2942,6 +2942,165 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(testLayer), Effect.scoped), ); + it.effect( + "pins hasPendingBackgroundWork while carryover holds a live subagent after root settle", + () => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocatorV2; + const path = yield* Path.Path; + const serverConfig = yield* ServerConfig; + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + const protocolEvents = yield* Queue.bounded(256); + const instanceId = ProviderInstanceId.make("acp-test"); + let subagentPhase: "spawn" | "complete" = "spawn"; + const adapter = makeAcpAdapterV2({ + crypto: yield* Crypto.Crypto, + instanceId, + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + deferFinalizeForBackgroundWork: true, + enablePostSettleContinuation: true, + extractSubagentUpdate: (toolCall) => + toolCall.toolCallId !== "tool-call-generic-1" + ? undefined + : subagentPhase === "spawn" + ? { + nativeTaskId: "task-generic-1", + prompt: "background subagent", + title: "background subagent", + model: null, + status: "running", + childSessionId: null, + result: null, + } + : { + nativeTaskId: "task-generic-1", + prompt: "", + title: null, + model: null, + status: "completed", + childSessionId: null, + result: "SUB_DONE", + }, + makeRuntime: makeMockRuntime({ + childProcessSpawner, + mockAgentPath, + environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, + protocolEvents, + }), + }, + fileSystem, + idAllocator, + serverConfig, + continuationRequests: { offer: () => Effect.void }, + }); + const threadId = ThreadId.make("thread-acp-carryover-pending-pin"); + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const modelSelection = { instanceId, model: "default" } as const; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-acp-carryover-pending-pin"), + modelSelection, + runtimePolicy, + }); + if (runtime.hasPendingBackgroundWork === undefined) { + return yield* Effect.die( + "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.", + ); + } + const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; + const events = yield* Queue.unbounded(); + yield* runtime.events.pipe( + Stream.runForEach((event) => Queue.offer(events, event)), + Effect.forkScoped, + ); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const now = yield* DateTime.now; + yield* runtime.startTurn( + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), + ); + yield* Stream.fromQueue(protocolEvents).pipe( + Stream.filter( + (event) => + event.direction === "incoming" && + event.stage === "raw" && + typeof event.payload === "string" && + event.payload.includes('"stopReason"'), + ), + Stream.runHead, + ); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + + const firstProviderTurnId = idAllocator.derive.providerTurn({ + driver: ACP_TEST_DRIVER, + nativeTurnId: "mock-session-1:turn:1", + }); + const interruptFiber = yield* runtime + .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) + .pipe(Effect.forkScoped); + yield* TestClock.adjust("10 seconds"); + yield* Fiber.join(interruptFiber); + + let firstTerminalStatus: string | null = null; + while (firstTerminalStatus === null) { + const event = yield* Queue.take(events); + if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) { + firstTerminalStatus = event.status; + } + } + assert.equal(firstTerminalStatus, "interrupted"); + // Root settled with a live projected subagent in carryover: pin idle release. + assert.isTrue( + yield* hasPendingBackgroundWork, + "carryover live subagent must pin hasPendingBackgroundWork after root settle", + ); + + subagentPhase = "complete"; + const secondNow = yield* DateTime.now; + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now: secondNow, + ordinal: 2, + }), + ); + const secondProviderTurnId = idAllocator.derive.providerTurn({ + driver: ACP_TEST_DRIVER, + nativeTurnId: "mock-session-1:turn:2", + }); + let secondTerminalStatus: string | null = null; + while (secondTerminalStatus === null) { + const event = yield* Queue.take(events); + if (event.type === "turn.terminal" && event.providerTurnId === secondProviderTurnId) { + secondTerminalStatus = event.status; + } + } + assert.equal(secondTerminalStatus, "completed"); + // Carryover is consumed into the next turn and terminalized; pin clears. + assert.isFalse( + yield* hasPendingBackgroundWork, + "hasPendingBackgroundWork must clear after carryover subagent terminals", + ); + }).pipe(Effect.provide(testLayer), Effect.scoped), + ); + it.effect( "preserveRuntimeOnSettledInterrupt keeps the process alive and carries subagents through a settled steering interrupt", () => diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts index da059b3862f..9e503d0d6b8 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts @@ -4640,6 +4640,20 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV if ((yield* Ref.get(wakeBuffer)).length > 0) return true; if (yield* Ref.get(continuationRequested)) return true; if ((yield* Ref.get(runningBackgroundTaskIds)).size > 0) return true; + // Projected post-settle Grok subagents can outlive the root + // turn via carryover; keep the ACP process pinned until they + // terminalize or teardown clears the carryover. + const carryover = yield* Ref.get(carryoverSubagents); + if (carryover !== null) { + for (const subagent of carryover.subagents) { + if ( + subagent.task.status === "running" || + subagent.task.status === "pending" + ) { + return true; + } + } + } return false; }), } diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 8e22379ae8d..2022c28baea 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -54,6 +54,7 @@ import { CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS, CLAUDE_T3_MCP_TOOL_WILDCARD, ClaudeProviderCapabilitiesV2, + ClaudeAgentSdkQueryRunnerError, claudeEffectiveQueryPolicyKey, claudeMcpQueryOverrides, claudeQueryMessages, @@ -122,6 +123,8 @@ function makeClaudeTestTurnInput(input: { readonly providerTurnOrdinal?: number; readonly messageCreatedBy?: ProviderAdapterV2TurnInput["message"]["createdBy"]; readonly messageCreationSource?: ProviderAdapterV2TurnInput["message"]["creationSource"]; + readonly modelSelection?: ModelSelection; + readonly runtimePolicy?: ProviderAdapterV2RuntimePolicy; }): ProviderAdapterV2TurnInput { return { appThread: makeClaudeTestAppThread(input), @@ -139,8 +142,8 @@ function makeClaudeTestTurnInput(input: { text: input.text, attachments: input.attachments, }, - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + modelSelection: input.modelSelection ?? CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: input.runtimePolicy ?? CLAUDE_TEST_RUNTIME_POLICY, }; } @@ -1119,6 +1122,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { const WAKE_NATIVE_SESSION = "native-thread-claude-wake"; const WAKE_TASK_ID = "task-wake-build"; const WAKE_SUMMARY = "Background build completed successfully"; + const WAKE_ASSISTANT_TEXT = "The background build has finished."; const WAKE_RESULT_TEXT = "The background build finished; everything passed."; function claudeSdkFrame(frame: unknown): SDKMessage { @@ -1183,6 +1187,16 @@ describe("ClaudeAdapterV2 background wake turns", () => { uuid: "00000000-0000-4000-8000-000000000103", session_id: WAKE_NATIVE_SESSION, }); + const wakeAssistant = claudeSdkFrame({ + type: "assistant", + message: { + role: "assistant", + content: [{ type: "text", text: WAKE_ASSISTANT_TEXT }], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000107", + session_id: WAKE_NATIVE_SESSION, + }); const wakeResult = makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000104", result: WAKE_RESULT_TEXT, @@ -1290,6 +1304,528 @@ describe("ClaudeAdapterV2 background wake turns", () => { }; }); + const providerThreadRosterEvents = (events: ReadonlyArray) => + events.filter( + (event): event is Extract => + event.type === "provider_thread.updated", + ); + + it.effect( + "projects an authoritative background_tasks_changed roster on the provider thread", + () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const rosterSnapshot = claudeSdkFrame({ + type: "system", + subtype: "background_tasks_changed", + tasks: [ + { + task_id: WAKE_TASK_ID, + description: "npm run build", + task_type: "local_bash", + }, + ], + uuid: "00000000-0000-4000-8000-000000000201", + session_id: WAKE_NATIVE_SESSION, + }); + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-roster-snapshot"), + text: "Run the build in the background.", + attachments: [], + }), + ); + yield* Queue.offer(harness.sdkMessages, rosterSnapshot); + yield* Queue.offer(harness.sdkMessages, turnOneResult); + yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); + + const rosterEvents = providerThreadRosterEvents(harness.events).filter( + (event) => (event.providerThread.pendingBackgroundTasks?.length ?? 0) > 0, + ); + assert.isAtLeast(rosterEvents.length, 1); + assert.deepEqual(rosterEvents.at(-1)?.providerThread.pendingBackgroundTasks ?? [], [ + { + taskId: WAKE_TASK_ID, + description: "npm run build", + taskType: "local_bash", + }, + ]); + assert.isTrue(yield* harness.hasPendingBackgroundWork); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), + ); + + it.effect( + "uses task_started as an incremental roster fallback and clears on empty snapshot", + () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const emptyRoster = claudeSdkFrame({ + type: "system", + subtype: "background_tasks_changed", + tasks: [], + uuid: "00000000-0000-4000-8000-000000000202", + session_id: WAKE_NATIVE_SESSION, + }); + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-roster-fallback"), + text: "Run the build in the background.", + attachments: [], + }), + ); + yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); + yield* Queue.offer(harness.sdkMessages, turnOneResult); + yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); + + const afterStart = providerThreadRosterEvents(harness.events).filter( + (event) => (event.providerThread.pendingBackgroundTasks?.length ?? 0) > 0, + ); + assert.isAtLeast(afterStart.length, 1); + assert.equal( + (afterStart.at(-1)?.providerThread.pendingBackgroundTasks ?? [])[0]?.taskId, + WAKE_TASK_ID, + ); + + yield* Queue.offer(harness.sdkMessages, emptyRoster); + yield* awaitUntil( + () => + providerThreadRosterEvents(harness.events).some( + (event) => + event.providerThread.status === "idle" && + (event.providerThread.pendingBackgroundTasks?.length ?? 0) === 0, + ), + "empty roster clear", + ); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), + ); + + it.effect("clears the roster when a turn fails", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const failedResult = claudeSdkFrame({ + type: "result", + subtype: "error_during_execution", + duration_ms: 10, + duration_api_ms: 10, + is_error: true, + num_turns: 1, + result: "boom", + stop_reason: "end_turn", + total_cost_usd: 0, + usage: { + input_tokens: 1, + output_tokens: 1, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + modelUsage: {}, + permission_denials: [], + errors: ["boom"], + uuid: "00000000-0000-4000-8000-000000000203", + session_id: WAKE_NATIVE_SESSION, + }); + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-roster-fail"), + text: "Run the build in the background.", + attachments: [], + }), + ); + yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); + yield* awaitUntil( + () => + providerThreadRosterEvents(harness.events).some( + (event) => (event.providerThread.pendingBackgroundTasks?.length ?? 0) > 0, + ), + "roster after task_started", + ); + yield* Queue.offer(harness.sdkMessages, failedResult); + yield* awaitUntil(() => harness.terminalEvents().length === 1, "failed terminal"); + assert.equal(harness.terminalEvents()[0]?.status, "failed"); + + const afterFailure = providerThreadRosterEvents(harness.events).at(-1); + assert.deepEqual(afterFailure?.providerThread.pendingBackgroundTasks ?? [], []); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), + ); + + it.effect("clears the native-thread roster when a turn is interrupted", () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocatorV2; + const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-v2-roster-interrupt-", + }); + const sdkMessages = yield* Queue.unbounded(); + const events: Array = []; + const adapter = makeClaudeAdapterV2({ + instanceId: CLAUDE_DEFAULT_INSTANCE_ID, + settings: DEFAULT_CLAUDE_SETTINGS, + environment: {}, + attachmentsDir, + fileSystem, + idAllocator, + continuationRequests: { offer: () => Effect.void }, + queryRunner: { + allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), + open: () => + Effect.succeed({ + messages: Stream.fromQueue(sdkMessages), + offer: () => Effect.void, + setModel: () => Effect.void, + interrupt: Effect.void, + // End the message stream so interruptTurn's closed wait resolves + // via stream exit finalize (interrupted status clears roster). + close: Queue.shutdown(sdkMessages), + }), + forkSession: () => Effect.die("unused forkSession"), + assertComplete: Effect.void, + }, + }); + const threadId = ThreadId.make("thread-claude-roster-interrupt"); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-claude-roster-interrupt"), + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + yield* runtime.events.pipe( + Stream.runForEach((event) => + Effect.sync(() => { + events.push(event); + }), + ), + Effect.forkScoped, + ); + if (runtime.hasPendingBackgroundWork === undefined) { + return yield* Effect.die("Claude adapter runtime must expose hasPendingBackgroundWork."); + } + const now = yield* DateTime.now; + + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-roster-interrupt"), + text: "Run the build in the background.", + attachments: [], + }), + ); + yield* Queue.offer(sdkMessages, wakeTaskStarted); + yield* awaitUntil( + () => + providerThreadRosterEvents(events).some( + (event) => (event.providerThread.pendingBackgroundTasks?.length ?? 0) > 0, + ), + "roster after task_started", + ); + + const providerTurnId = events.find( + (event): event is Extract => + event.type === "provider_turn.updated", + )?.providerTurn.id; + assert.isDefined(providerTurnId); + yield* runtime.interruptTurn({ + providerThread, + providerTurnId: providerTurnId!, + }); + yield* awaitUntil( + () => + events.some( + (event) => event.type === "turn.terminal" && event.status === "interrupted", + ), + "interrupted terminal", + ); + + const afterInterrupt = providerThreadRosterEvents(events).at(-1); + assert.deepEqual(afterInterrupt?.providerThread.pendingBackgroundTasks ?? [], []); + assert.isFalse(yield* runtime.hasPendingBackgroundWork); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), + ); + + it.effect( + "clears the replaced sibling native thread roster when openQuery switches processes", + () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocatorV2; + const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-v2-sibling-replace-", + }); + const nativeIds = ["native-thread-roster-a", "native-thread-roster-b"] as const; + let allocateIndex = 0; + // Real two-process model: each openQuery owns its own message queue. + // A shared queue would mask sibling process death on replacement. + const processQueues: Array<{ + readonly nativeThreadId: string; + readonly queue: Queue.Queue; + }> = []; + const events: Array = []; + const adapter = makeClaudeAdapterV2({ + instanceId: CLAUDE_DEFAULT_INSTANCE_ID, + settings: DEFAULT_CLAUDE_SETTINGS, + environment: {}, + attachmentsDir, + fileSystem, + idAllocator, + continuationRequests: { + offer: () => Effect.void, + }, + queryRunner: { + allocateSessionId: Effect.sync(() => { + const next = + nativeIds[allocateIndex] ?? `native-thread-roster-extra-${allocateIndex}`; + allocateIndex += 1; + return next; + }), + open: (openInput) => + Effect.gen(function* () { + const nativeThreadId = openInput.options.sessionId ?? openInput.options.resume; + if (typeof nativeThreadId !== "string" || nativeThreadId.length === 0) { + return yield* Effect.die("openQuery must supply a native session id"); + } + const queue = yield* Queue.unbounded(); + processQueues.push({ nativeThreadId, queue }); + return { + messages: Stream.fromQueue(queue), + offer: () => Effect.void, + setModel: () => Effect.void, + interrupt: Effect.void, + close: Queue.shutdown(queue), + }; + }), + forkSession: () => Effect.die("unused forkSession"), + assertComplete: Effect.void, + }, + }); + const appThreadA = ThreadId.make("thread-claude-roster-a"); + const appThreadB = ThreadId.make("thread-claude-roster-b"); + const runtime = yield* adapter.openSession({ + threadId: appThreadA, + providerSessionId: ProviderSessionId.make("provider-session-claude-sibling-replace"), + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const providerThreadA = yield* runtime.ensureThread({ + threadId: appThreadA, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const providerThreadB = yield* runtime.ensureThread({ + threadId: appThreadB, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + assert.notEqual( + providerThreadA.nativeThreadRef?.nativeId, + providerThreadB.nativeThreadRef?.nativeId, + ); + yield* runtime.events.pipe( + Stream.runForEach((event) => + Effect.sync(() => { + events.push(event); + }), + ), + Effect.forkScoped, + ); + if (runtime.hasPendingBackgroundWork === undefined) { + return yield* Effect.die( + "Claude adapter runtime must expose hasPendingBackgroundWork.", + ); + } + if (runtime.hasPendingBackgroundWorkForThread === undefined) { + return yield* Effect.die( + "Claude adapter runtime must expose hasPendingBackgroundWorkForThread.", + ); + } + const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; + const hasPendingBackgroundWorkForThread = runtime.hasPendingBackgroundWorkForThread; + const now = yield* DateTime.now; + const taskA = "task-roster-a"; + const taskB = "task-roster-b"; + + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: appThreadA, + providerThread: providerThreadA, + now, + attemptId: RunAttemptId.make("attempt-roster-iso-a"), + text: "Background work on A.", + attachments: [], + }), + ); + assert.equal(processQueues.length, 1); + const processA = processQueues[0]!; + yield* Queue.offer( + processA.queue, + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: taskA, + description: "work on A", + task_type: "local_bash", + uuid: "00000000-0000-4000-8000-000000000301", + session_id: nativeIds[0], + }), + ); + yield* Queue.offer( + processA.queue, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000302", + result: "A settled with background work.", + }), + ); + yield* awaitUntil( + () => + events.some( + (event) => + event.type === "turn.terminal" && + event.providerThreadId === providerThreadA.id && + event.status === "completed", + ), + "thread A terminal", + ); + const rosterAAfterSettle = providerThreadRosterEvents(events) + .filter((event) => event.providerThread.id === providerThreadA.id) + .at(-1)?.providerThread.pendingBackgroundTasks; + assert.deepEqual(rosterAAfterSettle ?? [], [ + { taskId: taskA, description: "work on A", taskType: "local_bash" }, + ]); + assert.isTrue(yield* hasPendingBackgroundWork); + assert.isTrue(yield* hasPendingBackgroundWorkForThread(providerThreadA)); + assert.isFalse(yield* hasPendingBackgroundWorkForThread(providerThreadB)); + + // Starting B closes A's only live query. A can never emit a roster + // clear from a dead process, so openQuery must idle-clear A. + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: appThreadB, + providerThread: { ...providerThreadB, status: "active" }, + now, + attemptId: RunAttemptId.make("attempt-roster-iso-b"), + text: "Background work on B.", + attachments: [], + }), + ); + assert.equal(processQueues.length, 2); + yield* awaitUntil( + () => + providerThreadRosterEvents(events).some( + (event) => + event.providerThread.id === providerThreadA.id && + event.providerThread.status === "idle" && + (event.providerThread.pendingBackgroundTasks?.length ?? 0) === 0, + ), + "sibling A roster cleared idle on process replacement", + ); + assert.isFalse(yield* hasPendingBackgroundWorkForThread(providerThreadA)); + + const processB = processQueues[1]!; + yield* Queue.offer( + processB.queue, + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: taskB, + description: "work on B", + task_type: "local_bash", + uuid: "00000000-0000-4000-8000-000000000303", + session_id: nativeIds[1], + }), + ); + yield* awaitUntil( + () => + providerThreadRosterEvents(events).some( + (event) => + event.providerThread.id === providerThreadB.id && + (event.providerThread.pendingBackgroundTasks?.length ?? 0) > 0, + ), + "thread B roster populated", + ); + assert.isTrue(yield* hasPendingBackgroundWorkForThread(providerThreadB)); + assert.isTrue(yield* hasPendingBackgroundWork); + // Starting B's process clears only B's process-scoped level; A stays + // empty from the sibling replacement clear above. + assert.isFalse(yield* hasPendingBackgroundWorkForThread(providerThreadA)); + + yield* Queue.offer( + processB.queue, + claudeSdkFrame({ + type: "result", + subtype: "error_during_execution", + duration_ms: 10, + duration_api_ms: 10, + is_error: true, + num_turns: 1, + result: "B failed", + stop_reason: "end_turn", + total_cost_usd: 0, + usage: { + input_tokens: 1, + output_tokens: 1, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + modelUsage: {}, + permission_denials: [], + errors: ["B failed"], + uuid: "00000000-0000-4000-8000-000000000304", + session_id: nativeIds[1], + }), + ); + yield* awaitUntil( + () => + events.some( + (event) => + event.type === "turn.terminal" && + event.providerThreadId === providerThreadB.id && + event.status === "failed", + ), + "thread B failed terminal", + ); + + const rosterBAfterFail = providerThreadRosterEvents(events) + .filter((event) => event.providerThread.id === providerThreadB.id) + .at(-1)?.providerThread.pendingBackgroundTasks; + assert.deepEqual(rosterBAfterFail ?? [], []); + assert.isFalse(yield* hasPendingBackgroundWorkForThread(providerThreadA)); + assert.isFalse(yield* hasPendingBackgroundWorkForThread(providerThreadB)); + assert.isFalse(yield* hasPendingBackgroundWork); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), + ); + it.effect("buffers wake output and requests a single continuation run", () => Effect.scoped( Effect.gen(function* () { @@ -1314,6 +1850,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.lengthOf(harness.continuationRequests, 0); yield* Queue.offer(harness.sdkMessages, wakeNotification); + let quietYields = 0; + yield* awaitUntil(() => quietYields++ >= 50, "notification-only quiet window"); + assert.lengthOf(harness.continuationRequests, 0); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + + yield* Queue.offer(harness.sdkMessages, wakeAssistant); yield* awaitUntil(() => harness.continuationRequests.length === 1, "continuation request"); assert.equal(harness.continuationRequests[0]?.threadId, harness.threadId); assert.equal(harness.continuationRequests[0]?.providerThreadId, harness.providerThread.id); @@ -1330,6 +1872,57 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("does not offer a continuation for notification-only opaque work", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-notification-only"), + text: "Start opaque background work.", + attachments: [], + }), + ); + yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); + yield* Queue.offer(harness.sdkMessages, turnOneResult); + yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); + assert.isTrue(yield* harness.hasPendingBackgroundWork); + + yield* Queue.offer(harness.sdkMessages, wakeNotification); + let quietYields = 0; + yield* awaitUntil(() => quietYields++ >= 100, "notification-only quiet window"); + assert.lengthOf(harness.continuationRequests, 0); + assert.lengthOf(harness.terminalEvents(), 1); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-notification-only-continuation"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + yield* awaitUntil( + () => harness.terminalEvents().length === 2, + "notification-only continuation terminal", + ); + assert.equal(harness.terminalEvents()[1]?.status, "completed"); + assert.lengthOf(harness.offeredMessages, 1); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), + ); + it.effect("drains buffered wake messages into a continuation turn", () => Effect.scoped( Effect.gen(function* () { @@ -1381,7 +1974,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); // The background task never renders as a subagent node. assert.isFalse( - harness.events.some((event) => JSON.stringify(event).includes(WAKE_TASK_ID)), + harness.events.some( + (event) => + event.type !== "provider_thread.updated" && + JSON.stringify(event).includes(WAKE_TASK_ID), + ), ); assert.isFalse(yield* harness.hasPendingBackgroundWork); }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), @@ -1468,7 +2065,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); assert.isFalse( - harness.events.some((event) => JSON.stringify(event).includes(WAKE_TASK_ID)), + harness.events.some( + (event) => + event.type !== "provider_thread.updated" && + JSON.stringify(event).includes(WAKE_TASK_ID), + ), ); assert.isFalse(yield* harness.hasPendingBackgroundWork); }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), @@ -1652,10 +2253,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { session_id: WAKE_NATIVE_SESSION, }), ); + yield* Queue.offer(harness.sdkMessages, wakeResult); yield* awaitUntil(() => harness.continuationRequests.length === 1, "continuation request"); assert.isNull(harness.continuationRequests[0]?.detail); - yield* Queue.offer(harness.sdkMessages, wakeResult); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, @@ -2448,6 +3049,1044 @@ describe("ClaudeAdapterV2 background wake turns", () => { }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), ), ); + + it.effect( + "orders nonempty level, empty level, notification, and continuation drain without subagent projection", + () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const nonemptyRoster = claudeSdkFrame({ + type: "system", + subtype: "background_tasks_changed", + tasks: [ + { + task_id: WAKE_TASK_ID, + description: "npm run build", + task_type: "local_bash", + }, + ], + uuid: "00000000-0000-4000-8000-000000000600", + session_id: WAKE_NATIVE_SESSION, + }); + const emptyRoster = claudeSdkFrame({ + type: "system", + subtype: "background_tasks_changed", + tasks: [], + uuid: "00000000-0000-4000-8000-000000000601", + session_id: WAKE_NATIVE_SESSION, + }); + const duplicateNotification = claudeSdkFrame({ + type: "system", + subtype: "task_notification", + task_id: WAKE_TASK_ID, + status: "completed", + output_file: "/tmp/task-wake-build-dup.log", + summary: "duplicate should not re-buffer", + uuid: "00000000-0000-4000-8000-000000000605", + session_id: WAKE_NATIVE_SESSION, + }); + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-level-before-edge-a"), + text: "Run the build in the background.", + attachments: [], + }), + ); + // 1) Nonempty authoritative level admits local_bash to Waiting + + // wake eligibility. + yield* Queue.offer(harness.sdkMessages, nonemptyRoster); + yield* awaitUntil( + () => + providerThreadRosterEvents(harness.events).some( + (event) => (event.providerThread.pendingBackgroundTasks?.length ?? 0) > 0, + ), + "nonempty level populated Waiting roster", + ); + assert.deepEqual( + providerThreadRosterEvents(harness.events).at(-1)?.providerThread + .pendingBackgroundTasks ?? [], + [ + { + taskId: WAKE_TASK_ID, + description: "npm run build", + taskType: "local_bash", + }, + ], + ); + yield* Queue.offer(harness.sdkMessages, turnOneResult); + yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); + assert.isTrue(yield* harness.hasPendingBackgroundWork); + + // 2) Empty level clears Waiting but keeps wake eligibility so the + // later notification can still offer exactly one continuation. + yield* Queue.offer(harness.sdkMessages, emptyRoster); + yield* awaitUntil( + () => + providerThreadRosterEvents(harness.events).some( + (event) => + event.providerThread.status === "idle" && + (event.providerThread.pendingBackgroundTasks?.length ?? 0) === 0, + ), + "empty level cleared Waiting roster", + ); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + assert.lengthOf(harness.continuationRequests, 0); + + // 3) First idle notification buffers and consumes eligibility. The + // following native assistant frame proves Claude began a wake turn. + yield* Queue.offer(harness.sdkMessages, wakeNotification); + yield* Queue.offer(harness.sdkMessages, wakeAssistant); + yield* awaitUntil( + () => harness.continuationRequests.length === 1, + "continuation after level-before-edge", + ); + assert.equal(harness.continuationRequests[0]?.detail, WAKE_SUMMARY); + + // A duplicate notification must not re-buffer or re-offer. + yield* Queue.offer(harness.sdkMessages, duplicateNotification); + let settleYields = 0; + yield* awaitUntil(() => settleYields++ >= 50, "duplicate notification settle"); + assert.lengthOf(harness.continuationRequests, 1); + + // 4) Continuation drain classifies the buffered notification as + // local_bash (replay tombstone) and never fabricates a subagent. + yield* Queue.offer(harness.sdkMessages, wakeResult); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-level-before-edge-b"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal"); + assert.equal(harness.terminalEvents()[1]?.status, "completed"); + assert.lengthOf(harness.continuationRequests, 1); + assert.isTrue( + harness.events.some( + (event) => + event.type === "message.updated" && event.message.text === WAKE_ASSISTANT_TEXT, + ), + ); + assert.isFalse( + harness.events.some( + (event) => + event.type === "subagent.updated" || + (event.type === "node.updated" && event.node.kind === "subagent"), + ), + ); + assert.isFalse( + harness.events.some( + (event) => + event.type !== "provider_thread.updated" && + JSON.stringify(event).includes(WAKE_TASK_ID), + ), + ); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), + ); + + it.effect("resets Waiting roster and wake eligibility when the CLI process is replaced", () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocatorV2; + const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-v2-process-reset-", + }); + const processQueues: Array> = []; + const events: Array = []; + const continuationRequests: Array = []; + const adapter = makeClaudeAdapterV2({ + instanceId: CLAUDE_DEFAULT_INSTANCE_ID, + settings: DEFAULT_CLAUDE_SETTINGS, + environment: {}, + attachmentsDir, + fileSystem, + idAllocator, + continuationRequests: { + offer: (request) => + Effect.sync(() => { + continuationRequests.push(request); + }), + }, + queryRunner: { + allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), + open: () => + Effect.gen(function* () { + const sdkMessages = yield* Queue.unbounded(); + processQueues.push(sdkMessages); + return { + messages: Stream.fromQueue(sdkMessages), + offer: () => Effect.void, + setModel: () => Effect.void, + interrupt: Effect.void, + // End this process stream so openQuery can replace it. + close: Queue.shutdown(sdkMessages), + }; + }), + forkSession: () => Effect.die("unused forkSession"), + assertComplete: Effect.void, + }, + }); + const threadId = ThreadId.make("thread-claude-process-reset"); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-claude-process-reset"), + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + yield* runtime.events.pipe( + Stream.runForEach((event) => + Effect.sync(() => { + events.push(event); + }), + ), + Effect.forkScoped, + ); + if (runtime.hasPendingBackgroundWork === undefined) { + return yield* Effect.die("Claude adapter runtime must expose hasPendingBackgroundWork."); + } + const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; + const now = yield* DateTime.now; + + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-process-reset-a"), + text: "Run the build in the background.", + attachments: [], + }), + ); + assert.equal(processQueues.length, 1); + const firstProcess = processQueues[0]!; + yield* Queue.offer(firstProcess, wakeTaskStarted); + yield* Queue.offer(firstProcess, turnOneResult); + yield* awaitUntil( + () => events.some((event) => event.type === "turn.terminal"), + "first turn terminal", + ); + assert.isTrue(yield* hasPendingBackgroundWork); + + const alternateModel = { + ...CLAUDE_TEST_MODEL_SELECTION, + model: "claude-haiku-4-5-20251001", + } satisfies ModelSelection; + // ProviderTurnStartService marks the thread active before startTurn; + // the process-reset clear must preserve that status. + const activeProviderThread = { + ...providerThread, + status: "active" as const, + } satisfies OrchestrationV2ProviderThread; + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread: activeProviderThread, + now, + attemptId: RunAttemptId.make("attempt-claude-process-reset-b"), + text: "Continue after process restart.", + attachments: [], + providerTurnOrdinal: 2, + modelSelection: alternateModel, + }), + ); + assert.equal(processQueues.length, 2); + + // Process-scoped level resets to empty on CLI (re)start while the + // starting turn's provider thread remains active (not idle). + yield* awaitUntil( + () => + providerThreadRosterEvents(events).some( + (event) => + event.providerThread.status === "active" && + (event.providerThread.pendingBackgroundTasks?.length ?? 0) === 0 && + // Prefer the post-replace clear over the initial empty thread. + event.providerThread.updatedAt !== undefined, + ), + "roster cleared on process replace while remaining active", + ); + // After replace, the in-memory Waiting probe must be false even if a + // late empty-level event was already present before background work. + assert.isFalse(yield* hasPendingBackgroundWork); + const emptyActiveRosterEvents = providerThreadRosterEvents(events).filter( + (event) => + event.providerThread.status === "active" && + (event.providerThread.pendingBackgroundTasks?.length ?? 0) === 0, + ); + assert.isAtLeast(emptyActiveRosterEvents.length, 1); + assert.deepEqual( + emptyActiveRosterEvents.at(-1)?.providerThread.pendingBackgroundTasks ?? [], + [], + ); + assert.equal(emptyActiveRosterEvents.at(-1)?.providerThread.status, "active"); + + // A late notification from the previous process must not wake after + // eligibility was reset with the process. Offer on the new process + // stream (the old queue is shut down). + const secondProcess = processQueues[1]!; + yield* Queue.offer(secondProcess, wakeNotification); + let settleYields = 0; + yield* awaitUntil(() => settleYields++ >= 50, "stale notification settle"); + assert.lengthOf(continuationRequests, 0); + + yield* Queue.offer( + secondProcess, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000602", + result: "Process restart turn finished.", + }), + ); + yield* awaitUntil( + () => events.filter((event) => event.type === "turn.terminal").length === 2, + "second turn terminal", + ); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), + ); + + it.effect("admits only local_bash from a mixed background_tasks_changed snapshot", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const SUBAGENT_TASK_ID = "task-mixed-snapshot-subagent"; + const SUBAGENT_TOOL_USE_ID = "toolu-mixed-snapshot-subagent"; + const mixedSnapshot = claudeSdkFrame({ + type: "system", + subtype: "background_tasks_changed", + tasks: [ + { + task_id: WAKE_TASK_ID, + description: "npm run build", + task_type: "local_bash", + }, + { + task_id: SUBAGENT_TASK_ID, + description: "Agent review", + task_type: "local_agent", + }, + { + task_id: "task-mixed-foreground-agent", + description: "Backgrounded foreground agent", + task_type: "local_agent", + }, + ], + uuid: "00000000-0000-4000-8000-000000000603", + session_id: WAKE_NATIVE_SESSION, + }); + const subagentTaskStarted = claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: SUBAGENT_TASK_ID, + tool_use_id: SUBAGENT_TOOL_USE_ID, + description: "Agent review", + subagent_type: "general-purpose", + task_type: "local_agent", + prompt: "Review the change.", + uuid: "00000000-0000-4000-8000-000000000604", + session_id: WAKE_NATIVE_SESSION, + }); + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-mixed-snapshot"), + text: "Background a bash task and a subagent.", + attachments: [], + }), + ); + yield* Queue.offer(harness.sdkMessages, subagentTaskStarted); + yield* awaitUntil( + () => + harness.events.some( + (event) => + event.type === "subagent.updated" && + event.subagent.nativeTaskRef?.nativeId === SUBAGENT_TASK_ID, + ), + "subagent projected normally", + ); + yield* Queue.offer(harness.sdkMessages, mixedSnapshot); + yield* awaitUntil( + () => + providerThreadRosterEvents(harness.events).some( + (event) => (event.providerThread.pendingBackgroundTasks?.length ?? 0) > 0, + ), + "roster after mixed snapshot", + ); + + const roster = providerThreadRosterEvents(harness.events).at(-1)?.providerThread + .pendingBackgroundTasks; + assert.deepEqual(roster ?? [], [ + { + taskId: WAKE_TASK_ID, + description: "npm run build", + taskType: "local_bash", + }, + ]); + // Subagent lifecycle stays on the subagent path, not the Waiting roster. + assert.isTrue( + harness.events.some( + (event) => + event.type === "subagent.updated" && + event.subagent.nativeTaskRef?.nativeId === SUBAGENT_TASK_ID && + event.subagent.status === "running", + ), + ); + assert.isFalse((roster ?? []).some((task) => task.taskId === SUBAGENT_TASK_ID)); + + yield* Queue.offer(harness.sdkMessages, turnOneResult); + yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal"); + assert.isTrue(yield* harness.hasPendingBackgroundWork); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), + ); + + it.effect( + "preserves buffered local_bash notification classification across model/policy query replacement", + () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocatorV2; + const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-v2-buffer-replace-", + }); + const processQueues: Array> = []; + const events: Array = []; + const continuationRequests: Array = []; + const adapter = makeClaudeAdapterV2({ + instanceId: CLAUDE_DEFAULT_INSTANCE_ID, + settings: DEFAULT_CLAUDE_SETTINGS, + environment: {}, + attachmentsDir, + fileSystem, + idAllocator, + continuationRequests: { + offer: (request) => + Effect.sync(() => { + continuationRequests.push(request); + }), + }, + queryRunner: { + allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), + open: () => + Effect.gen(function* () { + const sdkMessages = yield* Queue.unbounded(); + processQueues.push(sdkMessages); + return { + messages: Stream.fromQueue(sdkMessages), + offer: () => Effect.void, + setModel: () => Effect.void, + interrupt: Effect.void, + close: Queue.shutdown(sdkMessages), + }; + }), + forkSession: () => Effect.die("unused forkSession"), + assertComplete: Effect.void, + }, + }); + const threadId = ThreadId.make("thread-claude-buffer-replace"); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-claude-buffer-replace"), + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + yield* runtime.events.pipe( + Stream.runForEach((event) => + Effect.sync(() => { + events.push(event); + }), + ), + Effect.forkScoped, + ); + if (runtime.hasPendingBackgroundWork === undefined) { + return yield* Effect.die( + "Claude adapter runtime must expose hasPendingBackgroundWork.", + ); + } + const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; + const now = yield* DateTime.now; + + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-buffer-replace-a"), + text: "Run the build in the background.", + attachments: [], + }), + ); + assert.equal(processQueues.length, 1); + const firstProcess = processQueues[0]!; + yield* Queue.offer(firstProcess, wakeTaskStarted); + yield* Queue.offer(firstProcess, turnOneResult); + yield* awaitUntil( + () => events.some((event) => event.type === "turn.terminal"), + "first turn terminal", + ); + assert.isTrue(yield* hasPendingBackgroundWork); + + // Idle completion notification buffers before any continuation runs. + yield* Queue.offer(firstProcess, wakeNotification); + let quietYields = 0; + yield* awaitUntil(() => quietYields++ >= 50, "notification-only quiet window"); + assert.lengthOf(continuationRequests, 0); + + // User turn changes model, replacing the query while the wake buffer + // stays queued for the later provider continuation. + const alternateModel = { + ...CLAUDE_TEST_MODEL_SELECTION, + model: "claude-haiku-4-5-20251001", + } satisfies ModelSelection; + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread: { ...providerThread, status: "active" }, + now, + attemptId: RunAttemptId.make("attempt-claude-buffer-replace-user"), + text: "Switch model while background work completes.", + attachments: [], + providerTurnOrdinal: 2, + modelSelection: alternateModel, + }), + ); + assert.equal(processQueues.length, 2); + const secondProcess = processQueues[1]!; + yield* Queue.offer( + secondProcess, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000701", + result: "User turn finished after model switch.", + }), + ); + yield* awaitUntil( + () => events.filter((event) => event.type === "turn.terminal").length === 2, + "user turn terminal after replace", + ); + // The terminal notification remains buffered for classification, but + // notification-only traffic no longer pins pending work. + assert.isFalse(yield* hasPendingBackgroundWork); + assert.lengthOf(continuationRequests, 0); + + // Continuation drains the buffered local_bash notification with no + // fabricated subagent/node and attributes the wake result text. + yield* Queue.offer(secondProcess, wakeResult); + yield* awaitUntil(() => continuationRequests.length === 1, "continuation after result"); + assert.equal(continuationRequests[0]?.detail, WAKE_SUMMARY); + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread: { ...providerThread, status: "active" }, + now, + attemptId: RunAttemptId.make("attempt-claude-buffer-replace-cont"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 3, + modelSelection: alternateModel, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + yield* awaitUntil( + () => events.filter((event) => event.type === "turn.terminal").length === 3, + "continuation terminal after buffered drain", + ); + assert.isTrue( + events.some( + (event) => + event.type === "message.updated" && event.message.text === WAKE_RESULT_TEXT, + ), + ); + assert.isFalse( + events.some( + (event) => + event.type === "subagent.updated" || + (event.type === "node.updated" && event.node.kind === "subagent"), + ), + ); + // Must not re-project the opaque task id as anything but roster history. + assert.isFalse( + events.some( + (event) => + event.type !== "provider_thread.updated" && + JSON.stringify(event).includes(WAKE_TASK_ID), + ), + ); + assert.isFalse(yield* hasPendingBackgroundWork); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), + ); + + it.effect( + "does not opaque-misclassify a buffered subagent notification across model/policy query replacement", + () => + Effect.scoped( + Effect.gen(function* () { + const SUBAGENT_TASK_ID = "task-buffer-replace-subagent"; + const SUBAGENT_TOOL_USE_ID = "toolu-buffer-replace-subagent"; + const SUBAGENT_SUMMARY = "SUB_BUFFER_REPLACE_DONE"; + const subagentTaskStarted = claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: SUBAGENT_TASK_ID, + tool_use_id: SUBAGENT_TOOL_USE_ID, + description: "Background research", + subagent_type: "general-purpose", + task_type: "local_agent", + prompt: "Research then return SUB_BUFFER_REPLACE_DONE.", + uuid: "00000000-0000-4000-8000-000000000801", + session_id: WAKE_NATIVE_SESSION, + }); + const subagentNotification = claudeSdkFrame({ + type: "system", + subtype: "task_notification", + task_id: SUBAGENT_TASK_ID, + tool_use_id: SUBAGENT_TOOL_USE_ID, + status: "completed", + output_file: "/tmp/task-buffer-replace-subagent.output", + summary: SUBAGENT_SUMMARY, + uuid: "00000000-0000-4000-8000-000000000802", + session_id: WAKE_NATIVE_SESSION, + }); + const subagentAsyncAck = claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: SUBAGENT_TOOL_USE_ID, + content: [{ type: "text", text: "Async agent launched successfully." }], + }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000803", + session_id: WAKE_NATIVE_SESSION, + tool_use_result: { + isAsync: true, + status: "async_launched", + agentId: SUBAGENT_TASK_ID, + prompt: "Research then return SUB_BUFFER_REPLACE_DONE.", + }, + }); + + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocatorV2; + const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-v2-subagent-buffer-replace-", + }); + const processQueues: Array> = []; + const events: Array = []; + const continuationRequests: Array = []; + const adapter = makeClaudeAdapterV2({ + instanceId: CLAUDE_DEFAULT_INSTANCE_ID, + settings: DEFAULT_CLAUDE_SETTINGS, + environment: {}, + attachmentsDir, + fileSystem, + idAllocator, + continuationRequests: { + offer: (request) => + Effect.sync(() => { + continuationRequests.push(request); + }), + }, + queryRunner: { + allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), + open: () => + Effect.gen(function* () { + const sdkMessages = yield* Queue.unbounded(); + processQueues.push(sdkMessages); + return { + messages: Stream.fromQueue(sdkMessages), + offer: () => Effect.void, + setModel: () => Effect.void, + interrupt: Effect.void, + close: Queue.shutdown(sdkMessages), + }; + }), + forkSession: () => Effect.die("unused forkSession"), + assertComplete: Effect.void, + }, + }); + const threadId = ThreadId.make("thread-claude-subagent-buffer-replace"); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make( + "provider-session-claude-subagent-buffer-replace", + ), + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + yield* runtime.events.pipe( + Stream.runForEach((event) => + Effect.sync(() => { + events.push(event); + }), + ), + Effect.forkScoped, + ); + if (runtime.hasPendingBackgroundWork === undefined) { + return yield* Effect.die( + "Claude adapter runtime must expose hasPendingBackgroundWork.", + ); + } + const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; + const subagentEvents = () => + events.filter( + (event): event is Extract => + event.type === "subagent.updated", + ); + const now = yield* DateTime.now; + + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-subagent-buffer-replace-a"), + text: "Spawn a background subagent and stop.", + attachments: [], + }), + ); + assert.equal(processQueues.length, 1); + const firstProcess = processQueues[0]!; + yield* Queue.offer(firstProcess, subagentTaskStarted); + yield* awaitUntil(() => subagentEvents().length >= 1, "subagent node created"); + assert.equal(subagentEvents()[0]?.subagent.status, "running"); + yield* Queue.offer(firstProcess, subagentAsyncAck); + yield* Queue.offer( + firstProcess, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000804", + result: "Spawned the subagent in the background.", + }), + ); + yield* awaitUntil( + () => events.some((event) => event.type === "turn.terminal"), + "first turn terminal", + ); + assert.isTrue(yield* hasPendingBackgroundWork); + + // Session-registered subagent completion buffers; no opaque tombstone. + yield* Queue.offer(firstProcess, subagentNotification); + yield* awaitUntil(() => continuationRequests.length === 1, "continuation after notify"); + assert.equal(continuationRequests[0]?.detail, SUBAGENT_SUMMARY); + + // Model-changing user turn replaces the query while continuation stays + // queued. Process reset must not invent opaque classification for the + // buffered subagent notification. + const alternateModel = { + ...CLAUDE_TEST_MODEL_SELECTION, + model: "claude-haiku-4-5-20251001", + } satisfies ModelSelection; + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread: { ...providerThread, status: "active" }, + now, + attemptId: RunAttemptId.make("attempt-claude-subagent-buffer-replace-user"), + text: "Switch model while the subagent completes.", + attachments: [], + providerTurnOrdinal: 2, + modelSelection: alternateModel, + }), + ); + assert.equal(processQueues.length, 2); + const secondProcess = processQueues[1]!; + yield* Queue.offer( + secondProcess, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000805", + result: "User turn finished after model switch.", + }), + ); + yield* awaitUntil( + () => events.filter((event) => event.type === "turn.terminal").length === 2, + "user turn terminal after replace", + ); + assert.isTrue(yield* hasPendingBackgroundWork); + assert.lengthOf(continuationRequests, 1); + + yield* Queue.offer( + secondProcess, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000806", + result: "The subagent finished with SUB_BUFFER_REPLACE_DONE.", + }), + ); + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread: { ...providerThread, status: "active" }, + now, + attemptId: RunAttemptId.make("attempt-claude-subagent-buffer-replace-cont"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 3, + modelSelection: alternateModel, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + yield* awaitUntil( + () => events.filter((event) => event.type === "turn.terminal").length === 3, + "continuation terminal after buffered subagent drain", + ); + + const finalSubagent = subagentEvents().at(-1)?.subagent; + assert.equal(finalSubagent?.status, "completed"); + assert.equal(finalSubagent?.result, SUBAGENT_SUMMARY); + assert.equal(finalSubagent?.runId, subagentEvents()[0]?.subagent.runId); + const subagentNodeEvents = events.filter( + (event): event is Extract => + event.type === "node.updated" && + event.node.kind === "subagent" && + event.node.nativeItemRef?.nativeId === SUBAGENT_TASK_ID, + ); + assert.equal(subagentNodeEvents.at(-1)?.node.status, "completed"); + assert.isFalse(yield* hasPendingBackgroundWork); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), + ); + + it.effect( + "clears process-scoped roster when same-native-thread replacement open fails after close", + () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocatorV2; + const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-v2-replace-open-fail-", + }); + let openCount = 0; + const processQueues: Array> = []; + const events: Array = []; + const adapter = makeClaudeAdapterV2({ + instanceId: CLAUDE_DEFAULT_INSTANCE_ID, + settings: DEFAULT_CLAUDE_SETTINGS, + environment: {}, + attachmentsDir, + fileSystem, + idAllocator, + continuationRequests: { + offer: () => Effect.void, + }, + queryRunner: { + allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), + open: () => { + openCount += 1; + if (openCount === 2) { + return Effect.fail( + new ClaudeAgentSdkQueryRunnerError({ + method: "open", + cause: "forced replacement open failure", + }), + ); + } + return Effect.gen(function* () { + const sdkMessages = yield* Queue.unbounded(); + processQueues.push(sdkMessages); + return { + messages: Stream.fromQueue(sdkMessages), + offer: () => Effect.void, + setModel: () => Effect.void, + interrupt: Effect.void, + close: Queue.shutdown(sdkMessages), + }; + }); + }, + forkSession: () => Effect.die("unused forkSession"), + assertComplete: Effect.void, + }, + }); + const threadId = ThreadId.make("thread-claude-replace-open-fail"); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-claude-replace-open-fail"), + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + yield* runtime.events.pipe( + Stream.runForEach((event) => + Effect.sync(() => { + events.push(event); + }), + ), + Effect.forkScoped, + ); + if (runtime.hasPendingBackgroundWork === undefined) { + return yield* Effect.die( + "Claude adapter runtime must expose hasPendingBackgroundWork.", + ); + } + const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; + const now = yield* DateTime.now; + + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-replace-open-fail-a"), + text: "Run the build in the background.", + attachments: [], + }), + ); + assert.equal(processQueues.length, 1); + yield* Queue.offer(processQueues[0]!, wakeTaskStarted); + yield* Queue.offer(processQueues[0]!, turnOneResult); + yield* awaitUntil( + () => events.some((event) => event.type === "turn.terminal"), + "first turn terminal", + ); + assert.isTrue(yield* hasPendingBackgroundWork); + + const alternateModel = { + ...CLAUDE_TEST_MODEL_SELECTION, + model: "claude-haiku-4-5-20251001", + } satisfies ModelSelection; + const failedStart = yield* runtime + .startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread: { ...providerThread, status: "active" }, + now, + attemptId: RunAttemptId.make("attempt-claude-replace-open-fail-b"), + text: "Replace process but fail open.", + attachments: [], + providerTurnOrdinal: 2, + modelSelection: alternateModel, + }), + ) + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(failedStart)); + // Old process was closed before the failed open: roster must not stick. + yield* awaitUntil( + () => + providerThreadRosterEvents(events).some( + (event) => + event.providerThread.status === "idle" && + (event.providerThread.pendingBackgroundTasks?.length ?? 0) === 0, + ), + "roster cleared after failed same-thread replacement open", + ); + assert.isFalse(yield* hasPendingBackgroundWork); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), + ); + + it.effect("does not invent process reset state on a first-ever failed open", () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocatorV2; + const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-v2-first-open-fail-", + }); + const events: Array = []; + const adapter = makeClaudeAdapterV2({ + instanceId: CLAUDE_DEFAULT_INSTANCE_ID, + settings: DEFAULT_CLAUDE_SETTINGS, + environment: {}, + attachmentsDir, + fileSystem, + idAllocator, + continuationRequests: { + offer: () => Effect.void, + }, + queryRunner: { + allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), + open: () => + Effect.fail( + new ClaudeAgentSdkQueryRunnerError({ + method: "open", + cause: "forced first open failure", + }), + ), + forkSession: () => Effect.die("unused forkSession"), + assertComplete: Effect.void, + }, + }); + const threadId = ThreadId.make("thread-claude-first-open-fail"); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-claude-first-open-fail"), + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + yield* runtime.events.pipe( + Stream.runForEach((event) => + Effect.sync(() => { + events.push(event); + }), + ), + Effect.forkScoped, + ); + const now = yield* DateTime.now; + const failedStart = yield* runtime + .startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-first-open-fail"), + text: "First open fails.", + attachments: [], + }), + ) + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(failedStart)); + // No live process ever existed: do not emit a fabricated empty roster. + assert.lengthOf(providerThreadRosterEvents(events), 0); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), + ); }); describe("ClaudeAdapterV2 query message stream", () => { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index c681d89ab7a..f8e9c3d5421 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -27,6 +27,7 @@ import { type OrchestrationV2ExecutionNode, type OrchestrationV2ProviderCapabilities, type OrchestrationV2ProviderFailure, + type OrchestrationV2PendingBackgroundTask, type OrchestrationV2ProviderSession, type OrchestrationV2ProviderThread, type OrchestrationV2ProviderTurn, @@ -1393,6 +1394,15 @@ function commandInputFromClaudeTool(toolName: string, input: ClaudeNativeToolInp ); } +// Opaque non-subagent background work admitted onto the Waiting roster. +// Subagents project through the normal subagent lifecycle and must not be +// double-counted when background_tasks_changed includes them. +const CLAUDE_OPAQUE_BACKGROUND_TASK_TYPES = new Set(["local_bash"]); + +function isClaudeOpaqueBackgroundTaskType(taskType: string | null | undefined): boolean { + return typeof taskType === "string" && CLAUDE_OPAQUE_BACKGROUND_TASK_TYPES.has(taskType); +} + function claudeTaskTypeFromSdkMessage(message: SDKMessage): string | null { if (typeof message !== "object" || message === null) { return null; @@ -1402,7 +1412,45 @@ function claudeTaskTypeFromSdkMessage(message: SDKMessage): string | null { } function isClaudeNonSubagentTask(message: SDKMessage): boolean { - return claudeTaskTypeFromSdkMessage(message) === "local_bash"; + return isClaudeOpaqueBackgroundTaskType(claudeTaskTypeFromSdkMessage(message)); +} + +function isClaudeBackgroundTasksChangedMessage(message: SDKMessage): boolean { + return ( + message.type === "system" && + // Undeclared SDK subtype: full roster snapshot of live background tasks. + (message.subtype as string) === "background_tasks_changed" + ); +} + +function claudePendingBackgroundTasksFromRoster( + roster: ReadonlyMap, +): ReadonlyArray { + return Array.from(roster.values()); +} + +function parseClaudeBackgroundTaskEntry( + entry: unknown, +): OrchestrationV2PendingBackgroundTask | null { + if (entry === null || typeof entry !== "object") { + return null; + } + const taskId = Reflect.get(entry, "task_id"); + if (typeof taskId !== "string" || taskId.length === 0) { + return null; + } + const taskType = Reflect.get(entry, "task_type"); + // Mirror the incremental path: only opaque non-subagent types currently + // supported for Waiting. Subagent/agent entries stay on the subagent path. + if (!isClaudeOpaqueBackgroundTaskType(typeof taskType === "string" ? taskType : null)) { + return null; + } + const description = Reflect.get(entry, "description"); + return { + taskId, + ...(typeof description === "string" && description.trim().length > 0 ? { description } : {}), + taskType, + }; } function fileNameFromClaudeTool(toolName: string, input: ClaudeNativeToolInput): string { @@ -2008,7 +2056,32 @@ export function makeClaudeAdapterV2( { readonly threadId: ThreadId; readonly providerThreadId: ProviderThreadId } >(), ); - const pendingBackgroundTaskIds = yield* Ref.make(new Set()); + // Authoritative + incremental background-task roster for post-settle + // Waiting UI. Outer key is native Claude session id so concurrent + // provider threads on one runtime cannot share or clear each other. + const pendingBackgroundTasksByNativeThread = yield* Ref.make( + new Map>(), + ); + // Wake eligibility is separate from the Waiting roster. It survives + // empty background_tasks_changed levels (SDK: empty level can precede + // task_notification) and is consumed when the first idle notification + // is buffered/offered so duplicates cannot re-buffer. A short-lived + // replay tombstone then covers continuation drain classification so + // local_bash is never projected as a subagent; the tombstone is + // cleared after that drained notification is processed. Both sets + // clear on CLI process open/replacement and failed/interrupted turns. + const wakeEligibleBackgroundTasksByNativeThread = yield* Ref.make( + new Map>(), + ); + const opaqueBackgroundTaskReplayTombstonesByNativeThread = yield* Ref.make( + new Map>(), + ); + // Last known provider-thread payload per native session, used to emit + // roster-only provider_thread.updated events between turns without + // resurrecting an active status after root settlement. + const lastProviderThreadByNativeThread = yield* Ref.make( + new Map(), + ); // Subagent registry that survives turn settle: a background subagent // (Agent with run_in_background) can complete after the root turn // ended, and its task_notification must both count as wake evidence @@ -2028,6 +2101,359 @@ export function makeClaudeAdapterV2( const emitProviderEvent = (event: ProviderAdapterV2Event) => Queue.offer(events, event).pipe(Effect.asVoid); + const rememberProviderThread = (providerThread: OrchestrationV2ProviderThread) => + Effect.gen(function* () { + const nativeThreadId = providerThread.nativeThreadRef?.nativeId; + if (nativeThreadId === undefined || nativeThreadId === null) { + return; + } + yield* Ref.update(lastProviderThreadByNativeThread, (current) => + new Map(current).set(nativeThreadId, providerThread), + ); + }); + + const rosterForNativeThread = ( + all: ReadonlyMap>, + nativeThreadId: string, + ): Map => + all.get(nativeThreadId) ?? new Map(); + + const hasPendingBackgroundTaskOnNativeThread = (nativeThreadId: string, taskId: string) => + Ref.get(pendingBackgroundTasksByNativeThread).pipe( + Effect.map((all) => rosterForNativeThread(all, nativeThreadId).has(taskId)), + ); + + const taskIdSetForNativeThread = ( + all: ReadonlyMap>, + nativeThreadId: string, + ): Set => all.get(nativeThreadId) ?? new Set(); + + const addTaskIdsToNativeThreadSet = ( + ref: Ref.Ref>>, + nativeThreadId: string, + taskIds: ReadonlyArray, + ) => + Ref.update(ref, (current) => { + if (taskIds.length === 0) { + return current; + } + const next = new Set(taskIdSetForNativeThread(current, nativeThreadId)); + let changed = false; + for (const taskId of taskIds) { + if (!next.has(taskId)) { + next.add(taskId); + changed = true; + } + } + return changed ? new Map(current).set(nativeThreadId, next) : current; + }); + + const clearTaskIdFromNativeThreadSet = ( + ref: Ref.Ref>>, + nativeThreadId: string, + taskId: string, + ) => + Ref.update(ref, (current) => { + const existing = taskIdSetForNativeThread(current, nativeThreadId); + if (!existing.has(taskId)) { + return current; + } + const next = new Set(existing); + next.delete(taskId); + const updated = new Map(current); + if (next.size === 0) { + updated.delete(nativeThreadId); + } else { + updated.set(nativeThreadId, next); + } + return updated; + }); + + const clearNativeThreadTaskIdSet = ( + ref: Ref.Ref>>, + nativeThreadId: string, + ) => + Ref.update(ref, (current) => { + if (!current.has(nativeThreadId)) { + return current; + } + const updated = new Map(current); + updated.delete(nativeThreadId); + return updated; + }); + + // First-notification wake offering only: not the Waiting roster and + // not the post-buffer replay tombstone. + const isWakeEligibleOpaqueBackgroundTaskOnNativeThread = ( + nativeThreadId: string, + taskId: string, + ) => + Ref.get(wakeEligibleBackgroundTasksByNativeThread).pipe( + Effect.map((all) => taskIdSetForNativeThread(all, nativeThreadId).has(taskId)), + ); + + const hasOpaqueBackgroundTaskReplayTombstoneOnNativeThread = ( + nativeThreadId: string, + taskId: string, + ) => + Ref.get(opaqueBackgroundTaskReplayTombstonesByNativeThread).pipe( + Effect.map((all) => taskIdSetForNativeThread(all, nativeThreadId).has(taskId)), + ); + + // Classify a task_notification as opaque local_bash (not a subagent): + // live roster, still-eligible first notification, or short-lived + // replay tombstone left after the idle notification was buffered. + const isKnownOpaqueBackgroundTaskOnNativeThread = ( + nativeThreadId: string, + taskId: string, + ) => + Effect.gen(function* () { + if (yield* hasPendingBackgroundTaskOnNativeThread(nativeThreadId, taskId)) { + return true; + } + if (yield* isWakeEligibleOpaqueBackgroundTaskOnNativeThread(nativeThreadId, taskId)) { + return true; + } + return yield* hasOpaqueBackgroundTaskReplayTombstoneOnNativeThread( + nativeThreadId, + taskId, + ); + }); + + // Admit onto wake eligibility only. Replay tombstones are created when + // the first idle notification is buffered, not at task start. + const markWakeEligibleOpaqueBackgroundTasks = ( + nativeThreadId: string, + taskIds: ReadonlyArray, + ) => + addTaskIdsToNativeThreadSet( + wakeEligibleBackgroundTasksByNativeThread, + nativeThreadId, + taskIds, + ); + + // After the first idle opaque notification is buffered/offered: stop + // further wake buffering for this task id, but keep a replay tombstone + // until the continuation drain classifies the buffered notification. + const consumeWakeEligibilityForBufferedNotification = ( + nativeThreadId: string, + taskId: string, + ) => + Effect.gen(function* () { + yield* clearTaskIdFromNativeThreadSet( + wakeEligibleBackgroundTasksByNativeThread, + nativeThreadId, + taskId, + ); + yield* addTaskIdsToNativeThreadSet( + opaqueBackgroundTaskReplayTombstonesByNativeThread, + nativeThreadId, + [taskId], + ); + }); + + const clearOpaqueBackgroundTaskReplayTombstone = (nativeThreadId: string, taskId: string) => + clearTaskIdFromNativeThreadSet( + opaqueBackgroundTaskReplayTombstonesByNativeThread, + nativeThreadId, + taskId, + ); + + const emitProviderThreadRoster = Effect.fnUntraced(function* (input: { + readonly nativeThreadId: string; + readonly providerThread: OrchestrationV2ProviderThread; + readonly status?: OrchestrationV2ProviderThread["status"]; + }) { + const roster = rosterForNativeThread( + yield* Ref.get(pendingBackgroundTasksByNativeThread), + input.nativeThreadId, + ); + const now = yield* DateTime.now; + const providerThread: OrchestrationV2ProviderThread = { + ...input.providerThread, + providerSessionId: session.id, + ...(input.status === undefined ? {} : { status: input.status }), + pendingBackgroundTasks: claudePendingBackgroundTasksFromRoster(roster), + updatedAt: now, + }; + yield* rememberProviderThread(providerThread); + yield* emitProviderEvent({ + type: "provider_thread.updated", + driver: CLAUDE_PROVIDER, + providerThread, + }); + }); + + const replacePendingBackgroundTasks = ( + nativeThreadId: string, + tasks: ReadonlyArray, + ) => + Effect.gen(function* () { + yield* Ref.update(pendingBackgroundTasksByNativeThread, (current) => { + const updated = new Map(current); + if (tasks.length === 0) { + updated.delete(nativeThreadId); + } else { + updated.set( + nativeThreadId, + new Map(tasks.map((task) => [task.taskId, task] as const)), + ); + } + return updated; + }); + // Empty level must not drop wake eligibility: notification may + // still be in flight. Non-empty level admits new task ids to + // wake eligibility only (replay tombstones are edge-created). + if (tasks.length > 0) { + yield* markWakeEligibleOpaqueBackgroundTasks( + nativeThreadId, + tasks.map((task) => task.taskId), + ); + } + }); + + const upsertPendingBackgroundTask = ( + nativeThreadId: string, + task: OrchestrationV2PendingBackgroundTask, + ) => + Effect.gen(function* () { + yield* Ref.update(pendingBackgroundTasksByNativeThread, (current) => { + const roster = new Map(rosterForNativeThread(current, nativeThreadId)); + roster.set(task.taskId, task); + return new Map(current).set(nativeThreadId, roster); + }); + yield* markWakeEligibleOpaqueBackgroundTasks(nativeThreadId, [task.taskId]); + }); + + const clearPendingBackgroundTask = (nativeThreadId: string, taskId: string) => + Ref.modify(pendingBackgroundTasksByNativeThread, (current) => { + const roster = rosterForNativeThread(current, nativeThreadId); + if (!roster.has(taskId)) { + return [false, current] as const; + } + const nextRoster = new Map(roster); + nextRoster.delete(taskId); + const updated = new Map(current); + if (nextRoster.size === 0) { + updated.delete(nativeThreadId); + } else { + updated.set(nativeThreadId, nextRoster); + } + return [true, updated] as const; + }); + + const clearPendingBackgroundTasksForNativeThread = (nativeThreadId: string) => + Ref.update(pendingBackgroundTasksByNativeThread, (current) => { + if (!current.has(nativeThreadId)) { + return current; + } + const updated = new Map(current); + updated.delete(nativeThreadId); + return updated; + }); + + // Drop idle wake traffic for a dead native process so it cannot pin + // session-wide pending work after sibling query replacement. + const clearWakeStateForNativeThread = (nativeThreadId: string) => + Effect.gen(function* () { + yield* Ref.update(wakeBuffers, (current) => { + if (!current.has(nativeThreadId)) { + return current; + } + const updated = new Map(current); + updated.delete(nativeThreadId); + return updated; + }); + yield* Ref.update(requestedContinuations, (current) => { + if (!current.has(nativeThreadId)) { + return current; + } + const updated = new Set(current); + updated.delete(nativeThreadId); + return updated; + }); + }); + + // Process-scoped level: SDK emits nothing at CLI start, so both the + // Waiting roster and wake eligibility reset when a live query opens + // or is replaced for this native thread. Opaque replay tombstones that + // already covered buffered task_notification frames are restored so a + // model/policy query replacement still classifies those local_bash + // completions on continuation drain. Buffer membership alone must not + // invent opaque classification: session-registered subagent + // notifications share the same buffer. + const resetBackgroundTaskStateForNativeThreadProcess = Effect.fnUntraced(function* ( + nativeThreadId: string, + options?: { + // openQuery during startTurn: activeTurn is not installed yet, but + // ProviderTurnStartService already marked the provider thread active. + readonly status?: OrchestrationV2ProviderThread["status"]; + }, + ) { + const hadRoster = + rosterForNativeThread( + yield* Ref.get(pendingBackgroundTasksByNativeThread), + nativeThreadId, + ).size > 0; + const remembered = (yield* Ref.get(lastProviderThreadByNativeThread)).get(nativeThreadId); + const hadPersistedRoster = (remembered?.pendingBackgroundTasks?.length ?? 0) > 0; + const priorOpaqueTombstones = taskIdSetForNativeThread( + yield* Ref.get(opaqueBackgroundTaskReplayTombstonesByNativeThread), + nativeThreadId, + ); + const bufferedTaskNotificationIds = new Set(); + const buffered = (yield* Ref.get(wakeBuffers)).get(nativeThreadId); + if (buffered !== undefined) { + for (const message of buffered.messages) { + if (message.type === "system" && message.subtype === "task_notification") { + bufferedTaskNotificationIds.add(message.task_id); + } + } + } + const preservedOpaqueTombstones = [...priorOpaqueTombstones].filter((taskId) => + bufferedTaskNotificationIds.has(taskId), + ); + yield* clearPendingBackgroundTasksForNativeThread(nativeThreadId); + yield* clearNativeThreadTaskIdSet( + wakeEligibleBackgroundTasksByNativeThread, + nativeThreadId, + ); + yield* clearNativeThreadTaskIdSet( + opaqueBackgroundTaskReplayTombstonesByNativeThread, + nativeThreadId, + ); + if (preservedOpaqueTombstones.length > 0) { + yield* addTaskIdsToNativeThreadSet( + opaqueBackgroundTaskReplayTombstonesByNativeThread, + nativeThreadId, + preservedOpaqueTombstones, + ); + } + if (!hadRoster && !hadPersistedRoster) { + return; + } + if (remembered === undefined) { + return; + } + // Prefer an explicit starting-turn status so a successful openQuery + // replacement clear cannot emit idle over an already-active thread. + // Otherwise: between turns never resurrect active from process reset; + // with a live activeTurn context, upgrade idle → active. + const activeContext = yield* Ref.get(activeTurn); + const status = + options?.status ?? + (activeContext === null + ? ("idle" as const) + : remembered.status === "idle" + ? ("active" as const) + : remembered.status); + yield* emitProviderThreadRoster({ + nativeThreadId, + providerThread: remembered, + status, + }); + }); + const resolveItemOrdinal = Effect.fnUntraced(function* ( context: ActiveClaudeTurnContext, nativeItemId: string, @@ -2878,26 +3304,55 @@ export function makeClaudeAdapterV2( completedAt: input.completedAt, }), }), - ...(input.status === "completed" && - input.context.input.providerThread.nativeConversationHeadRef !== null - ? [ - emitProviderEvent({ - type: "provider_thread.updated" as const, - driver: CLAUDE_PROVIDER, - providerThread: { - ...input.context.input.providerThread, - providerSessionId: session.id, - nativeConversationHeadRef: null, - status: "active" as const, - firstRunOrdinal: - input.context.input.providerThread.firstRunOrdinal ?? - input.context.input.runOrdinal, - lastRunOrdinal: input.context.input.runOrdinal, - updatedAt: input.completedAt, - }, - }), - ] - : []), + // Surface this native thread's roster before the root turn + // terminals so writeFinalRunEvents preserves it. Failed or + // interrupted turns drop only this thread's roster so sibling + // native threads keep their Waiting state. + Effect.gen(function* () { + const nativeThreadId = + input.context.input.providerThread.nativeThreadRef?.nativeId ?? null; + if (nativeThreadId !== null) { + if (input.status !== "completed") { + yield* clearPendingBackgroundTasksForNativeThread(nativeThreadId); + yield* clearNativeThreadTaskIdSet( + wakeEligibleBackgroundTasksByNativeThread, + nativeThreadId, + ); + yield* clearNativeThreadTaskIdSet( + opaqueBackgroundTaskReplayTombstonesByNativeThread, + nativeThreadId, + ); + } + } + const roster = + nativeThreadId === null + ? new Map() + : rosterForNativeThread( + yield* Ref.get(pendingBackgroundTasksByNativeThread), + nativeThreadId, + ); + const clearConversationHead = + input.status === "completed" && + input.context.input.providerThread.nativeConversationHeadRef !== null; + const providerThread: OrchestrationV2ProviderThread = { + ...input.context.input.providerThread, + providerSessionId: session.id, + ...(clearConversationHead ? { nativeConversationHeadRef: null } : {}), + firstRunOrdinal: + input.context.input.providerThread.firstRunOrdinal ?? + input.context.input.runOrdinal, + lastRunOrdinal: input.context.input.runOrdinal, + pendingBackgroundTasks: claudePendingBackgroundTasksFromRoster(roster), + status: input.status === "completed" ? "active" : "idle", + updatedAt: input.completedAt, + }; + yield* rememberProviderThread(providerThread); + yield* emitProviderEvent({ + type: "provider_thread.updated" as const, + driver: CLAUDE_PROVIDER, + providerThread, + }); + }), emitProviderEvent(terminalEvent), ], { concurrency: 1 }, @@ -2992,16 +3447,6 @@ export function makeClaudeAdapterV2( } }); - const clearPendingBackgroundTask = (taskId: string) => - Ref.modify(pendingBackgroundTaskIds, (current) => { - if (!current.has(taskId)) { - return [false, current] as const; - } - const updated = new Set(current); - updated.delete(taskId); - return [true, updated] as const; - }); - const bufferWakeMessage = Effect.fnUntraced(function* (wakeInput: { readonly nativeThreadId: string; readonly message: SDKMessage; @@ -3010,13 +3455,17 @@ export function makeClaudeAdapterV2( const isNotification = message.type === "system" && message.subtype === "task_notification"; // Only notifications for tracked tasks count as wake evidence: a - // pending local_bash background task, or a session-registered - // subagent that is still running (Agent with run_in_background - // settling after the root turn). A stray notification for an - // unknown task is dropped as before instead of triggering a - // spurious continuation. + // wake-eligible local_bash task (eligibility set, not the Waiting + // roster), or a session-registered subagent that is still running + // (Agent with run_in_background settling after the root turn). A + // stray notification for an unknown task is dropped as before + // instead of triggering a spurious continuation. const isPendingTaskNotification = - isNotification && (yield* Ref.get(pendingBackgroundTaskIds)).has(message.task_id); + isNotification && + (yield* isWakeEligibleOpaqueBackgroundTaskOnNativeThread( + wakeInput.nativeThreadId, + message.task_id, + )); const isPendingSubagentNotification = isNotification && !isPendingTaskNotification && @@ -3080,12 +3529,31 @@ export function makeClaudeAdapterV2( }); return updated; }); - // Request a continuation run once per wake, when the wake turn has - // either announced the finished task or fully settled. Earlier - // messages only buffer; the continuation turn drains them. + // First idle opaque notification: consume wake eligibility so a + // duplicate cannot re-buffer, and leave a short-lived replay + // tombstone for continuation-drain classification. + if (isPendingTaskNotification) { + yield* consumeWakeEligibilityForBufferedNotification( + wakeInput.nativeThreadId, + message.task_id, + ); + } + // A terminal task notification can clear the Waiting roster without + // Claude dequeuing it into a native model turn. Buffer it for replay, + // but do not open an opaque-task continuation until native user, + // assistant, or result output proves that Claude actually began the + // wake turn. Subagent notifications retain their existing immediate + // offer because their projected lifecycle owns the continuation. + const buffered = (yield* Ref.get(wakeBuffers)).get(wakeInput.nativeThreadId); + const hasBufferedNotification = + buffered?.messages.some( + (entry) => entry.type === "system" && entry.subtype === "task_notification", + ) ?? false; + const isNativeOpaqueWakeFrame = + hasBufferedNotification && (message.type === "assistant" || message.type === "user"); if ( - !isPendingTaskNotification && !isPendingSubagentNotification && + !isNativeOpaqueWakeFrame && message.type !== "result" ) { return; @@ -3124,6 +3592,90 @@ export function makeClaudeAdapterV2( }); }); + const applyBackgroundTaskRosterMessage = Effect.fnUntraced(function* (input: { + readonly nativeThreadId: string; + readonly message: SDKMessage; + readonly activeContext: ActiveClaudeTurnContext | null; + }) { + const message = input.message; + let rosterChanged = false; + + if (isClaudeBackgroundTasksChangedMessage(message)) { + const roster = Reflect.get(message, "tasks"); + if (!Array.isArray(roster)) { + return false; + } + const nextTasks: OrchestrationV2PendingBackgroundTask[] = []; + for (const entry of roster) { + const task = parseClaudeBackgroundTaskEntry(entry); + if (task !== null) { + nextTasks.push(task); + } + } + yield* replacePendingBackgroundTasks(input.nativeThreadId, nextTasks); + rosterChanged = true; + } else if (message.type === "system" && message.subtype === "task_started") { + // Incremental fallback when background_tasks_changed is absent. + // Subagent tasks project as subagent turn items; only non-subagent + // background work (e.g. local_bash) lives on the provider-thread roster. + if (!isClaudeNonSubagentTask(message)) { + return false; + } + const description = + typeof message.description === "string" && message.description.trim().length > 0 + ? message.description + : undefined; + const taskType = claudeTaskTypeFromSdkMessage(message) ?? undefined; + yield* upsertPendingBackgroundTask(input.nativeThreadId, { + taskId: message.task_id, + ...(description === undefined ? {} : { description }), + ...(taskType === undefined ? {} : { taskType }), + }); + rosterChanged = true; + } else if (message.type === "system" && message.subtype === "task_notification") { + const removed = yield* clearPendingBackgroundTask( + input.nativeThreadId, + message.task_id, + ); + // Waiting roster clears on the notification edge. Wake eligibility + // is consumed when the first idle notification is buffered; clear + // here too for same-turn active notifications that never entered + // the idle buffer path. Replay tombstones are not cleared here. + yield* clearTaskIdFromNativeThreadSet( + wakeEligibleBackgroundTasksByNativeThread, + input.nativeThreadId, + message.task_id, + ); + rosterChanged = removed; + } + + if (!rosterChanged) { + return false; + } + + const baseThread = + input.activeContext?.input.providerThread ?? + (yield* Ref.get(lastProviderThreadByNativeThread)).get(input.nativeThreadId); + if (baseThread === undefined) { + return true; + } + + // Between turns, never resurrect active status from a late empty + // roster update. During an active turn, preserve the thread status. + const status = + input.activeContext === null + ? ("idle" as const) + : baseThread.status === "idle" + ? ("active" as const) + : baseThread.status; + yield* emitProviderThreadRoster({ + nativeThreadId: input.nativeThreadId, + providerThread: baseThread, + status, + }); + return true; + }); + const handleSdkMessage = Effect.fnUntraced(function* (input: { readonly query: ClaudeAgentSdkQuerySession; readonly message: SDKMessage; @@ -3136,7 +3688,23 @@ export function makeClaudeAdapterV2( const message = input.message; const context = yield* Ref.get(activeTurn); if (context === null) { - yield* bufferWakeMessage({ nativeThreadId: liveQuery.nativeThreadId, message }); + // task_notification must buffer wake evidence while still tracked + // on the roster; clearing first would drop the wake pin. + if (message.type === "system" && message.subtype === "task_notification") { + yield* bufferWakeMessage({ nativeThreadId: liveQuery.nativeThreadId, message }); + yield* applyBackgroundTaskRosterMessage({ + nativeThreadId: liveQuery.nativeThreadId, + message, + activeContext: null, + }); + } else { + yield* applyBackgroundTaskRosterMessage({ + nativeThreadId: liveQuery.nativeThreadId, + message, + activeContext: null, + }); + yield* bufferWakeMessage({ nativeThreadId: liveQuery.nativeThreadId, message }); + } return; } @@ -3144,12 +3712,23 @@ export function makeClaudeAdapterV2( context.nativeMessageCursor = message.uuid; } + if (isClaudeBackgroundTasksChangedMessage(message)) { + yield* applyBackgroundTaskRosterMessage({ + nativeThreadId: liveQuery.nativeThreadId, + message, + activeContext: context, + }); + return; + } + if (message.type === "system" && message.subtype === "task_started") { if (isClaudeNonSubagentTask(message)) { context.ignoredTaskIds.add(message.task_id); - yield* Ref.update(pendingBackgroundTaskIds, (current) => - new Set(current).add(message.task_id), - ); + yield* applyBackgroundTaskRosterMessage({ + nativeThreadId: liveQuery.nativeThreadId, + message, + activeContext: context, + }); } else { yield* updateClaudeSubagentNode({ context, @@ -3165,7 +3744,8 @@ export function makeClaudeAdapterV2( if (message.type === "system" && message.subtype === "task_progress") { const progress = message.description.trim(); - const isBackgroundTask = (yield* Ref.get(pendingBackgroundTaskIds)).has( + const isBackgroundTask = yield* hasPendingBackgroundTaskOnNativeThread( + liveQuery.nativeThreadId, message.task_id, ); if ( @@ -3184,9 +3764,19 @@ export function makeClaudeAdapterV2( } if (message.type === "system" && message.subtype === "task_notification") { - // A wake-replay turn has empty ignoredTaskIds, so the session-level - // background registry is the durable ignore signal across turns. - const wasBackgroundTask = yield* clearPendingBackgroundTask(message.task_id); + // A wake-replay turn has empty ignoredTaskIds, so opaque-task + // tracking (live roster, wake eligibility, or the short-lived + // post-buffer replay tombstone) classifies local_bash before any + // subagent handling. + const wasBackgroundTask = yield* isKnownOpaqueBackgroundTaskOnNativeThread( + liveQuery.nativeThreadId, + message.task_id, + ); + yield* applyBackgroundTaskRosterMessage({ + nativeThreadId: liveQuery.nativeThreadId, + message, + activeContext: context, + }); if (!wasBackgroundTask && !context.ignoredTaskIds.has(message.task_id)) { yield* updateClaudeSubagentNode({ context, @@ -3201,6 +3791,14 @@ export function makeClaudeAdapterV2( : "failed", }); } + // Replay tombstone only needs to outlive buffering until this + // drained/live classification runs; drop it so it cannot leak. + if (wasBackgroundTask) { + yield* clearOpaqueBackgroundTaskReplayTombstone( + liveQuery.nativeThreadId, + message.task_id, + ); + } } for (const toolUse of claudeToolUseBlocksFromAssistantMessage(message)) { @@ -3455,8 +4053,20 @@ export function makeClaudeAdapterV2( return existing; } + // openQuery owns one live process. Closing it for another native + // thread kills that sibling's CLI; it can never emit a roster clear, + // so drop its process-scoped Waiting/wake state immediately. Closing + // for the same native thread leaves a non-authoritative roster until + // the replacement open succeeds or fails below. + const closedExistingNativeThreadId = existing !== null ? existing.nativeThreadId : null; if (existing !== null) { yield* existing.query.close.pipe(Effect.ignore); + if (existing.nativeThreadId !== nativeThreadId) { + yield* clearWakeStateForNativeThread(existing.nativeThreadId); + yield* resetBackgroundTaskStateForNativeThreadProcess(existing.nativeThreadId, { + status: "idle", + }); + } } const openedWithResume = (yield* Ref.get(openedNativeThreads)).has(nativeThreadId); @@ -3468,26 +4078,42 @@ export function makeClaudeAdapterV2( const hasPersistedProviderTurn = turnInput.providerTurnOrdinal > 1; const shouldResume = resumeSessionAt !== undefined || openedWithResume || hasPersistedProviderTurn; - const querySession = yield* queryRunner.open({ - threadId: turnInput.threadId, - providerSessionId: input.providerSessionId, - options: makeClaudeQueryOptions({ - modelSelection: turnInput.modelSelection, - nativeThreadId, - resume: shouldResume, - ...(resumeSessionAt === undefined ? {} : { resumeSessionAt }), - cwd: turnInput.runtimePolicy.cwd, - settings: adapterOptions.settings, - environment: adapterOptions.environment, - tools: queryPolicy.tools ?? CLAUDE_CODE_PRESET_TOOLS, - ...mcpOverrides, - permissionMode: queryPolicy.permissionMode, - ...(queryPolicy.allowDangerouslySkipPermissions === undefined - ? {} - : { allowDangerouslySkipPermissions: queryPolicy.allowDangerouslySkipPermissions }), - ...(shouldInstallClaudePermissionCallback(queryPolicy) ? { canUseTool } : {}), - }), - }); + const querySession = yield* queryRunner + .open({ + threadId: turnInput.threadId, + providerSessionId: input.providerSessionId, + options: makeClaudeQueryOptions({ + modelSelection: turnInput.modelSelection, + nativeThreadId, + resume: shouldResume, + ...(resumeSessionAt === undefined ? {} : { resumeSessionAt }), + cwd: turnInput.runtimePolicy.cwd, + settings: adapterOptions.settings, + environment: adapterOptions.environment, + tools: queryPolicy.tools ?? CLAUDE_CODE_PRESET_TOOLS, + ...mcpOverrides, + permissionMode: queryPolicy.permissionMode, + ...(queryPolicy.allowDangerouslySkipPermissions === undefined + ? {} + : { + allowDangerouslySkipPermissions: queryPolicy.allowDangerouslySkipPermissions, + }), + ...(shouldInstallClaudePermissionCallback(queryPolicy) ? { canUseTool } : {}), + }), + }) + .pipe( + Effect.tapError(() => + // Same-native-thread replacement: the old process is already + // dead, so its process-scoped roster is not authoritative. + // First-ever failed open (no prior live query) must not invent + // native-session reset events. + closedExistingNativeThreadId === nativeThreadId + ? resetBackgroundTaskStateForNativeThreadProcess(nativeThreadId, { + status: "idle", + }) + : Effect.void, + ), + ); // Marked only after a successful open: a failed create must not // leave the runtime believing the native session exists, or the // retry would resume a session that was never created. @@ -3499,6 +4125,16 @@ export function makeClaudeAdapterV2( updated.add(nativeThreadId); return updated; }); + // Level is per CLI process: reset Waiting roster and wake + // eligibility whenever this native thread's process starts or is + // replaced. Membership repopulates on the next snapshot/edge. + // openQuery only runs from startTurn after ProviderTurnStartService + // marked the provider thread active, and before activeTurn is set. + // Buffered local_bash task_notification classification is preserved + // across this reset (see resetBackgroundTaskStateForNativeThreadProcess). + yield* resetBackgroundTaskStateForNativeThreadProcess(nativeThreadId, { + status: "active", + }); const closed = yield* Deferred.make(); const context: ClaudeLiveQueryContext = { nativeThreadId, @@ -3554,6 +4190,7 @@ export function makeClaudeAdapterV2( }); return updated; }); + yield* rememberProviderThread(turnInput.providerThread); const context: ActiveClaudeTurnContext = { input: turnInput, nativeTurnId, @@ -3632,6 +4269,16 @@ export function makeClaudeAdapterV2( // replaying it before the rest would drop them back into the wake // buffer and request another continuation. const resultMessages = drained.filter((entry) => entry.type === "result"); + const opaqueReplayTombstones = taskIdSetForNativeThread( + yield* Ref.get(opaqueBackgroundTaskReplayTombstonesByNativeThread), + nativeThreadId, + ); + const hasOpaqueTaskNotification = drained.some( + (entry) => + entry.type === "system" && + entry.subtype === "task_notification" && + opaqueReplayTombstones.has(entry.task_id), + ); for (const entry of drained) { if (entry.type !== "result") { yield* handleSdkMessage({ query: querySession.query, message: entry }); @@ -3640,6 +4287,14 @@ export function makeClaudeAdapterV2( const lastResult = resultMessages.at(-1); if (lastResult !== undefined) { yield* handleSdkMessage({ query: querySession.query, message: lastResult }); + return; + } + const hasNativeWakeFrame = drained.some( + (entry) => entry.type === "user" || entry.type === "assistant", + ); + if (hasOpaqueTaskNotification && !hasNativeWakeFrame) { + const completedAt = yield* DateTime.now; + yield* finalizeActiveTurn({ context, status: "completed", completedAt }); } }, (effect, turnInput) => @@ -3812,8 +4467,11 @@ export function makeClaudeAdapterV2( providerSession: session, events: Stream.fromEffectRepeat(Queue.take(events)), hasPendingBackgroundWork: Effect.gen(function* () { - if ((yield* Ref.get(pendingBackgroundTaskIds)).size > 0) { - return true; + // Session capability: any native thread with pending work pins idle. + for (const roster of (yield* Ref.get(pendingBackgroundTasksByNativeThread)).values()) { + if (roster.size > 0) { + return true; + } } for (const subagent of (yield* Ref.get(sessionSubagentsByTaskId)).values()) { if (subagent.task.status === "running") { @@ -3822,12 +4480,34 @@ export function makeClaudeAdapterV2( } const buffers = yield* Ref.get(wakeBuffers); for (const entry of buffers.values()) { - if (entry.messages.length > 0) { + if ( + entry.messages.some( + (message) => + message.type === "user" || + message.type === "assistant" || + message.type === "result", + ) + ) { return true; } } return false; }), + hasPendingBackgroundWorkForThread: (providerThread) => + Effect.gen(function* () { + const nativeThreadId = providerThread.nativeThreadRef?.nativeId; + if (nativeThreadId === undefined || nativeThreadId === null) { + return false; + } + // Root-run stop gate: only this native thread's roster. Session + // subagents and wake buffers stay on the session-wide probe. + return ( + rosterForNativeThread( + yield* Ref.get(pendingBackgroundTasksByNativeThread), + nativeThreadId, + ).size > 0 + ); + }), ensureThread: Effect.fn("ClaudeAdapterV2.ensureThread")( function* (threadInput: ProviderAdapterV2EnsureThreadInput) { const createdAt = yield* DateTime.now; diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index c6a446039e1..d0e271db72a 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -34,6 +34,7 @@ import { isOrchestrationV2SupersededInterrupt, isOrchestrationV2TurnItemVisible, } from "@t3tools/shared/orchestrationV2Timeline"; +import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -753,6 +754,12 @@ export function threadShellFromProjection( (left, right) => DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt), )[0] ?? null; + const pendingBackgroundTasks = derivePendingBackgroundWork({ + latestRun, + providerThreads: projection.providerThreads, + turnItems: projection.turnItems, + activeProviderThreadId: projection.thread.activeProviderThreadId, + }); return { createdBy: projection.thread.createdBy, creationSource: projection.thread.creationSource, @@ -792,6 +799,7 @@ export function threadShellFromProjection( hasActionableProposedPlan: projection.plans.some( (plan) => plan.kind === "proposed_plan" && plan.status === "active", ), + pendingBackgroundTasks: [...pendingBackgroundTasks], itemCount: activeLocalTurnItems(projection).length, visibleItemCount: projection.visibleTurnItems.length, createdAt: projection.thread.createdAt, @@ -823,6 +831,7 @@ type ShellThreadState = { readonly latestVisibleMessage: OrchestrationV2ConversationMessage | null; readonly latestUserMessageAt: DateTime.Utc | null; readonly hasActionableProposedPlan: boolean; + readonly pendingBackgroundTasks: OrchestrationV2ThreadShell["pendingBackgroundTasks"]; readonly itemCount: number; readonly updatedAt: OrchestrationV2ThreadProjection["updatedAt"]; readonly runOrdinalById: ReadonlyMap; @@ -952,6 +961,7 @@ function shellFromState(input: { }, latestUserMessageAt: input.state.latestUserMessageAt, hasActionableProposedPlan: input.state.hasActionableProposedPlan, + pendingBackgroundTasks: input.state.pendingBackgroundTasks, itemCount: input.state.itemCount, visibleItemCount: input.visibleItemCount, createdAt: input.state.thread.createdAt, @@ -2052,7 +2062,14 @@ export const layer: Layer.Layer = sql .withTransaction( Effect.gen(function* () { - const [threadRows, runRows, itemCountRows, sequenceRows] = yield* Effect.all([ + const [ + threadRows, + runRows, + itemCountRows, + sequenceRows, + providerThreadRows, + pendingTurnItemRows, + ] = yield* Effect.all([ sql` SELECT t.thread_id, @@ -2136,6 +2153,17 @@ export const layer: Layer.Layer = FROM orchestration_events WHERE application_event_version = 2 AND aggregate_kind = 'thread' + `, + sql` + SELECT thread_id, payload_json + FROM orchestration_v2_projection_provider_threads + WHERE thread_id IS NOT NULL + `, + sql` + SELECT thread_id, payload_json + FROM orchestration_v2_projection_turn_items + WHERE type IN ('command_execution', 'dynamic_tool', 'subagent') + AND status NOT IN ('completed', 'interrupted', 'failed', 'cancelled') `, ]); @@ -2157,6 +2185,33 @@ export const layer: Layer.Layer = itemCountsByThreadId.set(threadId, existing); } + const providerThreadsByThreadId = new Map< + ThreadId, + Array + >(); + for (const row of providerThreadRows) { + const providerThread = yield* decodeProviderThreadPayload(row.payload_json); + const threadId = + row.thread_id.length > 0 + ? ThreadId.make(row.thread_id) + : providerThread.appThreadId; + if (threadId === null) { + continue; + } + const existing = providerThreadsByThreadId.get(threadId) ?? []; + existing.push(providerThread); + providerThreadsByThreadId.set(threadId, existing); + } + + const pendingTurnItemsByThreadId = new Map>(); + for (const row of pendingTurnItemRows) { + const turnItem = yield* decodeTurnItemPayload(row.payload_json); + const threadId = ThreadId.make(row.thread_id); + const existing = pendingTurnItemsByThreadId.get(threadId) ?? []; + existing.push(turnItem); + pendingTurnItemsByThreadId.set(threadId, existing); + } + const states = yield* Effect.forEach(threadRows, (row) => Effect.gen(function* () { const thread = yield* decodeThreadPayload(row.payload_json); @@ -2168,10 +2223,28 @@ export const layer: Layer.Layer = row.latest_message_payload_json === null ? null : yield* decodeMessagePayload(row.latest_message_payload_json); + const latestRunId = + row.latest_run_id === null ? null : RunId.make(row.latest_run_id); + const latestRunStatus = shellStatusFromStoredRunStatus(row.latest_run_status); + const pendingBackgroundTasks = [ + ...derivePendingBackgroundWork({ + latestRun: + latestRunId === null || latestRunStatus === "idle" + ? null + : { + id: latestRunId, + ordinal: 0, + status: latestRunStatus, + }, + providerThreads: providerThreadsByThreadId.get(thread.id) ?? [], + turnItems: pendingTurnItemsByThreadId.get(thread.id) ?? [], + activeProviderThreadId: thread.activeProviderThreadId, + }), + ]; return { thread, - latestRunId: row.latest_run_id === null ? null : RunId.make(row.latest_run_id), - latestRunStatus: shellStatusFromStoredRunStatus(row.latest_run_status), + latestRunId, + latestRunStatus, activeRunId: row.active_run_id === null ? null : RunId.make(row.active_run_id), pendingRuntimeRequest, latestVisibleMessage, @@ -2180,6 +2253,7 @@ export const layer: Layer.Layer = ? null : DateTime.makeUnsafe(row.latest_user_message_at), hasActionableProposedPlan: row.has_actionable_proposed_plan === 1, + pendingBackgroundTasks, itemCount: row.item_count, updatedAt: thread.updatedAt, runOrdinalById: diff --git a/apps/server/src/orchestration-v2/ProviderAdapter.ts b/apps/server/src/orchestration-v2/ProviderAdapter.ts index d8f3595bcf3..dadfacc7ec6 100644 --- a/apps/server/src/orchestration-v2/ProviderAdapter.ts +++ b/apps/server/src/orchestration-v2/ProviderAdapter.ts @@ -482,6 +482,15 @@ export interface ProviderAdapterV2SessionRuntime { * here so the session manager defers idle release while it is pending. */ readonly hasPendingBackgroundWork?: Effect.Effect; + /** + * Per-provider-thread pending work for root-run ingestion stop gates. When + * present, RunExecutionService uses only this probe (never the session-wide + * hasPendingBackgroundWork) so sibling native threads cannot pin an + * unrelated root subscription open. + */ + readonly hasPendingBackgroundWorkForThread?: ( + providerThread: OrchestrationV2ProviderThread, + ) => Effect.Effect; readonly ensureThread: ( input: ProviderAdapterV2EnsureThreadInput, ) => Effect.Effect; diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts index 932beec8995..1f7868bf46e 100644 --- a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts @@ -524,3 +524,216 @@ it.effect( }).pipe(Effect.provide(layer)); }, ); + +it.effect( + "clears persisted pendingBackgroundTasks and terminalizes stale background items on settled runs", + () => { + const threadId = ThreadId.make("thread_recovery_background"); + const settledRunId = RunId.make("run_recovery_background_settled"); + const activeRunId = RunId.make("run_recovery_background_active"); + const activeAttemptId = RunAttemptId.make("attempt_recovery_background_active"); + const activeRootNodeId = NodeId.make("node_recovery_background_active"); + const idleProviderThreadId = ProviderThreadId.make("provider_thread_recovery_background_idle"); + const activeProviderThreadId = ProviderThreadId.make( + "provider_thread_recovery_background_active", + ); + const secondaryProviderThreadId = ProviderThreadId.make( + "provider_thread_recovery_background_secondary", + ); + const providerSessionId = ProviderSessionId.make("provider_session_recovery_background"); + const settledStaleItemId = TurnItemId.make("turn_item_recovery_background_stale"); + const activeRunItemId = TurnItemId.make("turn_item_recovery_background_active"); + const nullRunCommandItemId = TurnItemId.make("turn_item_recovery_background_null_run"); + const nullRunSubagentItemId = TurnItemId.make("turn_item_recovery_background_null_subagent"); + const claudeInstanceId = ProviderInstanceId.make("claude"); + const secondaryInstanceId = ProviderInstanceId.make("claude-secondary"); + const subagentInstanceId = ProviderInstanceId.make("claude-subagent"); + let committedInput: Parameters[0] | null = + null; + const projection = { + thread: { id: threadId }, + runtimeRequests: [], + providerSessions: [ + { + id: providerSessionId, + driver: ProviderDriverKind.make("claude"), + providerInstanceId: claudeInstanceId, + status: "ready", + }, + ], + providerThreads: [ + { + id: idleProviderThreadId, + driver: ProviderDriverKind.make("claude"), + // Index-0 is intentionally a different instance so misattribution + // to providerThreads[0] fails the assertions below. + providerInstanceId: claudeInstanceId, + status: "idle", + pendingBackgroundTasks: [{ taskId: "bg-settled", description: "sleep 30" }], + }, + { + id: activeProviderThreadId, + driver: ProviderDriverKind.make("claude"), + providerInstanceId: claudeInstanceId, + status: "active", + pendingBackgroundTasks: [{ taskId: "bg-active", description: "npm test" }], + }, + { + id: secondaryProviderThreadId, + driver: ProviderDriverKind.make("claude"), + providerInstanceId: secondaryInstanceId, + status: "idle", + pendingBackgroundTasks: [], + }, + ], + providerTurns: [], + runs: [ + { + id: settledRunId, + status: "completed", + providerInstanceId: claudeInstanceId, + }, + { + id: activeRunId, + status: "running", + providerInstanceId: claudeInstanceId, + }, + ], + attempts: [ + { + id: activeAttemptId, + runId: activeRunId, + rootNodeId: activeRootNodeId, + status: "running", + }, + ], + nodes: [{ id: activeRootNodeId, runId: activeRunId, status: "running" }], + subagents: [], + messages: [], + turnItems: [ + { + id: settledStaleItemId, + runId: settledRunId, + nodeId: null, + providerThreadId: idleProviderThreadId, + type: "command_execution", + status: "running", + }, + { + id: activeRunItemId, + runId: activeRunId, + nodeId: activeRootNodeId, + providerThreadId: activeProviderThreadId, + type: "dynamic_tool", + status: "running", + }, + { + // Missing run: must attribute via providerThreadId, not index 0. + id: nullRunCommandItemId, + runId: null, + nodeId: null, + providerThreadId: secondaryProviderThreadId, + type: "command_execution", + status: "running", + }, + { + // Missing run with a real matching provider thread whose instance + // differs from the subagent's own: own providerInstanceId must win. + id: nullRunSubagentItemId, + runId: null, + nodeId: null, + providerThreadId: secondaryProviderThreadId, + type: "subagent", + status: "running", + providerInstanceId: subagentInstanceId, + }, + ], + } as unknown as OrchestrationV2ThreadProjection; + const layer = ProviderRuntimeRecovery.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getShellSnapshot: () => + Effect.succeed({ + schemaVersion: 2, + snapshotSequence: 0, + threads: [{ id: threadId }], + archivedThreads: [], + } as never), + getThreadProjection: () => Effect.succeed(projection), + }), + Layer.mock(EventSink.EventSinkV2)({ + commitCommand: (input) => { + committedInput = input; + return Effect.succeed({ committed: true, cancelledEffectCount: 0 } as never); + }, + }), + IdAllocator.layer, + Layer.mock(EffectWorker.OrchestrationEffectWorkerV2)({ runOnce: Effect.succeed(false) }), + Layer.mock(EffectOutbox.EffectOutboxV2)({ + listByCommandId: () => Effect.succeed([]), + reconcileAfterProcessLoss: Effect.succeed({ requeued: 0, cancelled: 0 }), + }), + ), + ), + ); + + return Effect.gen(function* () { + const summary = + yield* (yield* ProviderRuntimeRecovery.ProviderRuntimeRecoveryService).reconcile("startup"); + assert.equal(summary.terminalizedRuns, 1); + const events = committedInput?.events ?? []; + + const turnItemCancels = events.filter( + (event) => event.type === "turn-item.updated" && event.payload.status === "cancelled", + ); + // Active-run item + settled-run stale + null-run command + null-run subagent. + assert.equal(turnItemCancels.length, 4); + assert.deepEqual( + turnItemCancels + .map((event) => event.type === "turn-item.updated" && event.payload.id) + .sort(), + [activeRunItemId, nullRunCommandItemId, nullRunSubagentItemId, settledStaleItemId].sort(), + ); + + const cancelById = (id: TurnItemId) => + turnItemCancels.find( + (event) => event.type === "turn-item.updated" && event.payload.id === id, + ); + assert.equal(cancelById(nullRunCommandItemId)?.providerInstanceId, secondaryInstanceId); + // Subagent own instance wins over the matching thread's secondary instance. + assert.notEqual(subagentInstanceId, secondaryInstanceId); + assert.equal(cancelById(nullRunSubagentItemId)?.providerInstanceId, subagentInstanceId); + // Settled-run item still prefers the run's provider instance when present. + assert.equal(cancelById(settledStaleItemId)?.providerInstanceId, claudeInstanceId); + + const providerThreadEvents = events.filter( + (event) => event.type === "provider-thread.updated", + ); + // Only threads with active status or nonempty rosters are rewritten. + assert.equal(providerThreadEvents.length, 2); + for (const event of providerThreadEvents) { + if (event.type !== "provider-thread.updated") continue; + assert.deepEqual(event.payload.pendingBackgroundTasks ?? [], []); + } + const idleThreadEvent = providerThreadEvents.find( + (event) => + event.type === "provider-thread.updated" && event.payload.id === idleProviderThreadId, + ); + assert.equal( + idleThreadEvent?.type === "provider-thread.updated" ? idleThreadEvent.payload.status : null, + "idle", + ); + const activeThreadEvent = providerThreadEvents.find( + (event) => + event.type === "provider-thread.updated" && event.payload.id === activeProviderThreadId, + ); + assert.equal( + activeThreadEvent?.type === "provider-thread.updated" + ? activeThreadEvent.payload.status + : null, + "idle", + ); + }).pipe(Effect.provide(layer)); + }, +); diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts index 5dadaa7b01e..c2a04b98475 100644 --- a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts @@ -69,6 +69,50 @@ function nonterminalRuns(projection: OrchestrationV2ThreadProjection) { }); } +function isBackgroundCapableTurnItemType(type: string): boolean { + return type === "command_execution" || type === "dynamic_tool" || type === "subagent"; +} + +function isNonterminalTurnItemStatus(status: string): boolean { + return status === "pending" || status === "running" || status === "waiting"; +} + +function providerThreadHasPendingBackgroundTasks( + providerThread: OrchestrationV2ThreadProjection["providerThreads"][number], +): boolean { + return (providerThread.pendingBackgroundTasks?.length ?? 0) > 0; +} + +/** + * Resolve providerInstanceId for a stale background-capable turn item whose + * run is missing/null (or not found). Prefer an existing run, then a subagent + * item's own instance id, then the item's provider thread, then a last-resort + * first provider thread. + */ +function resolveStaleBackgroundItemProviderInstanceId( + item: OrchestrationV2ThreadProjection["turnItems"][number], + projection: OrchestrationV2ThreadProjection, +): OrchestrationV2ThreadProjection["providerThreads"][number]["providerInstanceId"] | undefined { + if (item.runId !== null) { + const run = projection.runs.find((candidate) => candidate.id === item.runId); + if (run !== undefined) { + return run.providerInstanceId; + } + } + if (item.type === "subagent") { + return item.providerInstanceId; + } + if (item.providerThreadId !== null && item.providerThreadId !== undefined) { + const providerThread = projection.providerThreads.find( + (candidate) => candidate.id === item.providerThreadId, + ); + if (providerThread !== undefined) { + return providerThread.providerInstanceId; + } + } + return projection.providerThreads[0]?.providerInstanceId; +} + export const make = Effect.gen(function* () { const projections = yield* ProjectionStore.ProjectionStoreV2; const eventSink = yield* EventSink.EventSinkV2; @@ -253,9 +297,45 @@ export const make = Effect.gen(function* () { }); } } - for (const providerThread of projection.providerThreads.filter( - (candidate) => candidate.status === "active", - )) { + // Process loss also orphans background-capable turn items on already- + // settled runs (e.g. post-settle Waiting work). Skip items already + // cancelled above for recovered nonterminal runs to avoid duplicate + // cancellation events. + const recoveredNonterminalRunIds = new Set(runs.map((run) => run.id)); + for (const item of projection.turnItems ?? []) { + if (item.runId !== null && recoveredNonterminalRunIds.has(item.runId)) { + continue; + } + if (!isBackgroundCapableTurnItemType(item.type)) { + continue; + } + if (!isNonterminalTurnItemStatus(item.status)) { + continue; + } + const providerInstanceId = resolveStaleBackgroundItemProviderInstanceId(item, projection); + if (providerInstanceId === undefined) { + continue; + } + events.push({ + id: yield* allocateEventId(), + type: "turn-item.updated", + threadId: projection.thread.id, + ...(item.runId === null ? {} : { runId: item.runId }), + ...(item.nodeId === null || item.nodeId === undefined ? {} : { nodeId: item.nodeId }), + providerInstanceId, + occurredAt: now, + payload: { ...item, status: "cancelled", completedAt: now, updatedAt: now }, + }); + } + // All provider processes are gone on startup/shutdown: clear any + // persisted Waiting roster (including idle threads from settled roots) + // and idle active threads without resurrecting active status. + for (const providerThread of projection.providerThreads ?? []) { + const needsIdle = providerThread.status === "active"; + const needsRosterClear = providerThreadHasPendingBackgroundTasks(providerThread); + if (!needsIdle && !needsRosterClear) { + continue; + } events.push({ id: yield* allocateEventId(), type: "provider-thread.updated", @@ -263,7 +343,12 @@ export const make = Effect.gen(function* () { driver: providerThread.driver, providerInstanceId: providerThread.providerInstanceId, occurredAt: now, - payload: { ...providerThread, status: "idle", updatedAt: now }, + payload: { + ...providerThread, + status: needsIdle ? "idle" : providerThread.status, + pendingBackgroundTasks: [], + updatedAt: now, + }, }); } for (const session of projection.providerSessions.filter( diff --git a/apps/server/src/orchestration-v2/RunExecutionService.test.ts b/apps/server/src/orchestration-v2/RunExecutionService.test.ts index 28f2120e599..79ee7b57989 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.test.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.test.ts @@ -594,6 +594,368 @@ it.effect("does not pin ingestion on background items when the root turn is inte }), ); +it.effect( + "keeps ingesting a late empty provider-thread roster while thread-scoped pending work is true", + () => + Effect.gen(function* () { + const key = "bg-roster-pending-work"; + const ids = backgroundScenarioIds(key); + const providerInstanceId = ProviderInstanceId.make("codex"); + const now = yield* DateTime.now; + const observed = yield* Ref.make>([]); + const pendingByProviderThreadId = yield* Ref.make(new Map([[ids.providerThreadId, true]])); + const scopedProbeArgs = yield* Ref.make>([]); + const ingestCalls = yield* Ref.make< + ReadonlyArray<{ + readonly eventType: string; + readonly hasWriteIfRunCurrent: boolean; + readonly rosterLength: number | null; + }> + >([]); + const ingestionDone = yield* Deferred.make(); + const testLayer = runExecutionServiceLayer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(CheckpointServiceV2)({ captureBaseline: () => Effect.void }), + Layer.mock(EventSinkV2)({ + write: () => Effect.succeed([]), + writeWithEffects: (input) => + Effect.gen(function* () { + if ( + input.events.some( + (event) => event.type === "run.updated" && event.runId === ids.runId, + ) + ) { + yield* Ref.update(observed, (current) => [...current, "root-finalized"]); + } + return []; + }), + writeIfRunCurrent: () => Effect.succeed({ committed: true, storedEvents: [] }), + }), + idAllocatorLayer, + Layer.mock(ProviderEventIngestorV2)({ + ingestNormalized: (input) => + Effect.gen(function* () { + const event = input.event; + const rosterLength = + event.type === "provider_thread.updated" + ? (event.providerThread.pendingBackgroundTasks?.length ?? 0) + : null; + yield* Ref.update(ingestCalls, (current) => [ + ...current, + { + eventType: event.type, + hasWriteIfRunCurrent: input.writeIfRunCurrent !== undefined, + rosterLength, + }, + ]); + if (event.type === "provider_thread.updated" && rosterLength === 0) { + yield* Ref.update(pendingByProviderThreadId, (current) => { + const next = new Map(current); + next.set(event.providerThread.id, false); + return next; + }); + yield* Ref.update(observed, (current) => [...current, "roster-cleared"]); + } + if (event.type === "turn.terminal") { + yield* Ref.update(observed, (current) => [...current, "terminal"]); + } + return []; + }), + }), + ServerSettingsService.layerTest(), + ), + ), + ); + + const providerThreadBase = { + id: ids.providerThreadId, + driver, + providerInstanceId, + providerSessionId: ProviderSessionId.make(`session:${key}`), + appThreadId: ids.threadId, + ownerNodeId: null, + nativeThreadRef: null, + nativeConversationHeadRef: null, + status: "idle" as const, + firstRunOrdinal: 1, + lastRunOrdinal: 1, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }; + + yield* Effect.gen(function* () { + const runExecution = yield* RunExecutionServiceV2; + yield* runExecution.startRootRun({ + commandId: CommandId.make(`command:${key}`), + appThread: { id: ids.threadId } as OrchestrationV2AppThread, + providerSessionId: ProviderSessionId.make(`session:${key}`), + session: { + events: Stream.empty, + // Session-wide stays true forever; the root must consult the + // thread-scoped probe instead of being pinned by siblings. + hasPendingBackgroundWork: Effect.succeed(true), + hasPendingBackgroundWorkForThread: (providerThread: OrchestrationV2ProviderThread) => + Effect.gen(function* () { + yield* Ref.update(scopedProbeArgs, (current) => [...current, providerThread.id]); + return (yield* Ref.get(pendingByProviderThreadId)).get(providerThread.id) === true; + }), + subscribeEvents: Effect.succeed({ + events: Stream.fromIterable([ + { + type: "provider_thread.updated", + driver, + providerThread: { + ...providerThreadBase, + status: "active" as const, + pendingBackgroundTasks: [ + { taskId: "bg-1", description: "sleep 20", taskType: "local_bash" }, + ], + updatedAt: now, + }, + } as ProviderAdapterV2Event, + rootTerminalEvent(ids, "completed"), + { + type: "provider_thread.updated", + driver, + providerThread: { + ...providerThreadBase, + status: "idle" as const, + pendingBackgroundTasks: [], + updatedAt: now, + }, + } as ProviderAdapterV2Event, + ]), + close: Deferred.succeed(ingestionDone, undefined), + }), + startTurn: () => Effect.void, + } as unknown as ProviderAdapterV2SessionRuntime, + run: { + id: ids.runId, + threadId: ids.threadId, + ordinal: 1, + providerInstanceId, + } as OrchestrationV2Run, + rootNode: { id: ids.rootNodeId } as OrchestrationV2ExecutionNode, + checkpointScope: { + id: CheckpointScopeId.make(`checkpoint-scope:${key}`), + } as OrchestrationV2CheckpointScope, + providerThread: providerThreadBase as OrchestrationV2ProviderThread, + attempt: { + id: ids.attemptId, + providerTurnId: ids.rootProviderTurnId, + } as OrchestrationV2RunAttempt, + attemptId: ids.attemptId, + providerTurnOrdinal: 1, + message: { + messageId: MessageId.make(`message:${key}:user`), + text: "Start background work and settle.", + attachments: [], + createdBy: "user", + creationSource: "web", + }, + modelSelection: { instanceId: providerInstanceId, model: "gpt-5.4" }, + runtimePolicy: { + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + approvalPolicy: "never", + sandboxPolicy: { + type: "readOnly", + access: { type: "fullAccess" }, + networkAccess: false, + }, + }, + }); + }).pipe(Effect.provide(testLayer)); + + const closed = yield* Deferred.await(ingestionDone).pipe(Effect.timeoutOption("2 seconds")); + assert.isTrue(Option.isSome(closed), "event subscription did not release"); + assert.deepEqual(yield* Ref.get(observed), ["terminal", "root-finalized", "roster-cleared"]); + assert.isTrue((yield* Ref.get(scopedProbeArgs)).includes(ids.providerThreadId)); + + const calls = yield* Ref.get(ingestCalls); + const preTerminalRoster = calls.find( + (call) => call.eventType === "provider_thread.updated" && call.rosterLength === 1, + ); + const lateClear = calls.find( + (call) => call.eventType === "provider_thread.updated" && call.rosterLength === 0, + ); + assert.isDefined(preTerminalRoster); + assert.isTrue(preTerminalRoster?.hasWriteIfRunCurrent); + assert.isDefined(lateClear); + assert.isFalse( + lateClear?.hasWriteIfRunCurrent, + "late empty roster must not use stale writeIfRunCurrent running-gate", + ); + }), +); + +it.effect( + "does not pin ingestion on a sibling session-wide pending state when this thread has no roster", + () => + Effect.gen(function* () { + const key = "bg-roster-sibling-not-pin"; + const ids = backgroundScenarioIds(key); + const providerInstanceId = ProviderInstanceId.make("codex"); + const now = yield* DateTime.now; + const observed = yield* Ref.make>([]); + const ingestionDone = yield* Deferred.make(); + const scopedProbeArgs = yield* Ref.make>([]); + const testLayer = runExecutionServiceLayer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(CheckpointServiceV2)({ captureBaseline: () => Effect.void }), + Layer.mock(EventSinkV2)({ + write: () => Effect.succeed([]), + writeWithEffects: (input) => + Effect.gen(function* () { + if ( + input.events.some( + (event) => event.type === "run.updated" && event.runId === ids.runId, + ) + ) { + yield* Ref.update(observed, (current) => [...current, "root-finalized"]); + } + return []; + }), + writeIfRunCurrent: () => Effect.succeed({ committed: true, storedEvents: [] }), + }), + idAllocatorLayer, + Layer.mock(ProviderEventIngestorV2)({ + ingestNormalized: (input) => + Effect.gen(function* () { + if (input.event.type === "turn.terminal") { + yield* Ref.update(observed, (current) => [...current, "terminal"]); + } + return []; + }), + }), + ServerSettingsService.layerTest(), + ), + ), + ); + + const providerThreadBase = { + id: ids.providerThreadId, + driver, + providerInstanceId, + providerSessionId: ProviderSessionId.make(`session:${key}`), + appThreadId: ids.threadId, + ownerNodeId: null, + nativeThreadRef: { + driver, + nativeId: "native-self", + strength: "strong" as const, + }, + nativeConversationHeadRef: null, + status: "idle" as const, + firstRunOrdinal: 1, + lastRunOrdinal: 1, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }; + const siblingProviderThreadId = ProviderThreadId.make(`provider-thread:${key}:sibling`); + + yield* Effect.gen(function* () { + const runExecution = yield* RunExecutionServiceV2; + yield* runExecution.startRootRun({ + commandId: CommandId.make(`command:${key}`), + appThread: { id: ids.threadId } as OrchestrationV2AppThread, + providerSessionId: ProviderSessionId.make(`session:${key}`), + session: { + events: Stream.empty, + // Session-wide stays true (sibling has work). Stop must use only + // the scoped probe for this root's provider thread. + hasPendingBackgroundWork: Effect.succeed(true), + hasPendingBackgroundWorkForThread: (providerThread: OrchestrationV2ProviderThread) => + Effect.gen(function* () { + yield* Ref.update(scopedProbeArgs, (current) => [...current, providerThread.id]); + // Own thread has no roster; sibling would report true if probed. + return providerThread.id !== ids.providerThreadId; + }), + subscribeEvents: Effect.succeed({ + events: Stream.fromIterable([ + rootTerminalEvent(ids, "completed"), + // Sibling thread update after terminal. Own-thread scoped + // pending is false, so this root must release without waiting + // for sibling-driven session-wide pending work. + { + type: "provider_thread.updated", + driver, + providerThread: { + ...providerThreadBase, + id: siblingProviderThreadId, + appThreadId: ThreadId.make(`thread:${key}:sibling`), + nativeThreadRef: { + driver, + nativeId: "native-sibling", + strength: "strong" as const, + }, + pendingBackgroundTasks: [{ taskId: "sibling-bg", description: "other thread" }], + updatedAt: now, + }, + } as ProviderAdapterV2Event, + ]), + close: Deferred.succeed(ingestionDone, undefined), + }), + startTurn: () => Effect.void, + } as unknown as ProviderAdapterV2SessionRuntime, + run: { + id: ids.runId, + threadId: ids.threadId, + ordinal: 1, + providerInstanceId, + } as OrchestrationV2Run, + rootNode: { id: ids.rootNodeId } as OrchestrationV2ExecutionNode, + checkpointScope: { + id: CheckpointScopeId.make(`checkpoint-scope:${key}`), + } as OrchestrationV2CheckpointScope, + providerThread: providerThreadBase as OrchestrationV2ProviderThread, + attempt: { + id: ids.attemptId, + providerTurnId: ids.rootProviderTurnId, + } as OrchestrationV2RunAttempt, + attemptId: ids.attemptId, + providerTurnOrdinal: 1, + message: { + messageId: MessageId.make(`message:${key}:user`), + text: "Settle without local pending work.", + attachments: [], + createdBy: "user", + creationSource: "web", + }, + modelSelection: { instanceId: providerInstanceId, model: "gpt-5.4" }, + runtimePolicy: { + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + approvalPolicy: "never", + sandboxPolicy: { + type: "readOnly", + access: { type: "fullAccess" }, + networkAccess: false, + }, + }, + }); + }).pipe(Effect.provide(testLayer)); + + const closed = yield* Deferred.await(ingestionDone).pipe(Effect.timeoutOption("2 seconds")); + assert.isTrue(Option.isSome(closed), "event subscription did not release"); + // Critical: release while session-wide hasPendingBackgroundWork stays true + // and the scoped probe reports false for this root's provider thread. + const observedEvents = [...(yield* Ref.get(observed))]; + assert.includeMembers(observedEvents, ["terminal", "root-finalized"]); + const probedIds = yield* Ref.get(scopedProbeArgs); + assert.isTrue(probedIds.includes(ids.providerThreadId)); + assert.isFalse(probedIds.includes(siblingProviderThreadId)); + }), +); + it.effect( "cascade-terminalizes run-owned subagent rows on interrupt before root finalization", () => diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index a3d72b065b7..3bd085af3db 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -954,7 +954,26 @@ export const layer: Layer.Layer< // item's non-terminal event before the root terminal; an item // first seen after the terminal is not pinned. const backgroundItems = yield* Ref.get(activeBackgroundTurnItems); - return backgroundItems.size === 0; + if (backgroundItems.size > 0) { + return false; + } + // Claude background Bash has no turn-item projection. Keep the + // stream open while this root's provider thread still reports + // pending roster work so late empty updates can clear Waiting. + // Use only the thread-scoped probe: session-wide pending work + // (siblings, wake buffers, session subagents) must not pin this + // root subscription. Session idle release still uses + // hasPendingBackgroundWork via ProviderSessionManager. + const latestProviderThreadSnapshot = yield* Ref.get(latestProviderThread); + if (input.session.hasPendingBackgroundWorkForThread !== undefined) { + const hasPendingWork = yield* input.session + .hasPendingBackgroundWorkForThread(latestProviderThreadSnapshot) + .pipe(Effect.catchCause(() => Effect.succeed(false))); + if (hasPendingWork) { + return false; + } + } + return true; }); const eventSubscription = input.session.subscribeEvents === undefined @@ -969,6 +988,10 @@ export const layer: Layer.Layer< let storedEventCount = 0; const shouldDeliver = shouldDeliverProviderEvent(event, assistantStreamingEnabled); if (shouldDeliver) { + // After the root turn terminals the run leaves "running", so + // writeIfRunCurrent would drop late provider_thread.updated + // roster clears. Only gate pre-terminal root-thread updates. + const rootTerminalAlreadySeen = yield* Ref.get(rootTerminalSeen); const storedEvents = yield* providerEventIngestor.ingestNormalized({ providerSessionId: input.providerSessionId, providerInstanceId: input.run.providerInstanceId, @@ -977,7 +1000,8 @@ export const layer: Layer.Layer< nodeId: input.rootNode.id, event, ...(event.type === "provider_thread.updated" && - event.providerThread.id === input.providerThread.id + event.providerThread.id === input.providerThread.id && + !rootTerminalAlreadySeen ? { writeIfRunCurrent: { runId: input.run.id, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6a06e654f46..1e348a1dba7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -44,6 +44,7 @@ import { resolvePromptInjectedEffort, } from "@t3tools/shared/model"; import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList"; +import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; @@ -2125,6 +2126,25 @@ function ChatViewContent(props: ChatViewProps) { threadError, }); const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; + const pendingBackgroundTasks = useMemo(() => { + if (serverProjection === null || serverProjection === undefined) { + return []; + } + const latestRun = + serverProjection.runs.length === 0 + ? null + : serverProjection.runs.reduce((latest, candidate) => + candidate.ordinal > latest.ordinal ? candidate : latest, + ); + return [ + ...derivePendingBackgroundWork({ + latestRun, + providerThreads: serverProjection.providerThreads, + turnItems: serverProjection.turnItems, + activeProviderThreadId: serverProjection.thread.activeProviderThreadId, + }), + ]; + }, [serverProjection]); const activeWorkStartedAt = deriveActiveWorkStartedAt( activeLatestRun, activeRuntime, @@ -5978,6 +5998,7 @@ function ChatViewContent(props: ChatViewProps) { isWorking={isWorking} activeTurnInProgress={isWorking || !latestRunSettled} activeTurnStartedAt={activeWorkStartedAt} + pendingBackgroundTasks={pendingBackgroundTasks} listRef={legendListRef} timelineEntries={timelineEntries} latestRun={activeLatestRun} diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 8e92a5bc7ab..94744d88563 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -896,6 +896,54 @@ describe("resolveThreadStatusPill", () => { ).toMatchObject({ label: "Working", pulse: true }); }); + it("shows waiting for an idle thread with pending background tasks", () => { + expect( + resolveThreadStatusPill({ + thread: { + ...baseThread, + pendingBackgroundTasks: [{ taskId: "bg-1", description: "sleep 20" }], + runtime: { + ...baseThread.runtime, + status: "idle", + activeRunId: null, + }, + }, + }), + ).toMatchObject({ + label: "Waiting", + colorClass: "text-sidebar-muted-foreground", + dotClass: "bg-sidebar-muted-foreground", + pulse: false, + }); + }); + + it("keeps an active turn working when background tasks are also present", () => { + expect( + resolveThreadStatusPill({ + thread: { + ...baseThread, + pendingBackgroundTasks: [{ taskId: "bg-1", description: "sleep 20" }], + }, + }), + ).toMatchObject({ label: "Working", pulse: true }); + }); + + it("does not show waiting after the background task roster clears", () => { + expect( + resolveThreadStatusPill({ + thread: { + ...baseThread, + pendingBackgroundTasks: [], + runtime: { + ...baseThread.runtime, + status: "idle", + activeRunId: null, + }, + }, + }), + ).toBeNull(); + }); + it("shows plan ready when a settled plan turn has a proposed plan ready for follow-up", () => { expect( resolveThreadStatusPill({ @@ -1018,6 +1066,38 @@ describe("resolveProjectStatusIndicator", () => { ]), ).toMatchObject({ label: "Plan Ready", dotClass: "bg-violet-500" }); }); + + it("ranks waiting below active work and above plan-ready", () => { + const waiting = { + label: "Waiting" as const, + colorClass: "text-sidebar-muted-foreground", + dotClass: "bg-sidebar-muted-foreground", + pulse: false, + }; + + expect( + resolveProjectStatusIndicator([ + waiting, + { + label: "Working", + colorClass: "text-sky-600", + dotClass: "bg-sky-500", + pulse: true, + }, + ]), + ).toMatchObject({ label: "Working" }); + expect( + resolveProjectStatusIndicator([ + { + label: "Plan Ready", + colorClass: "text-violet-600", + dotClass: "bg-violet-500", + pulse: false, + }, + waiting, + ]), + ).toMatchObject({ label: "Waiting" }); + }); }); describe("getVisibleThreadsForProject", () => { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index a917631d1a4..fd391dd6715 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -114,6 +114,7 @@ export interface ThreadStatusPill { | "Completed" | "Pending Approval" | "Awaiting Input" + | "Waiting" | "Plan Ready"; colorClass: string; dotClass: string; @@ -125,6 +126,7 @@ const THREAD_STATUS_PRIORITY: Record = { "Awaiting Input": 4, Working: 3, Connecting: 3, + Waiting: 2.5, "Plan Ready": 2, Completed: 1, }; @@ -139,6 +141,7 @@ type ThreadStatusInput = Pick< | "runtime" > & { lastVisitedAt?: string | undefined; + pendingBackgroundTasks?: SidebarThreadSummary["pendingBackgroundTasks"] | undefined; }; export interface ThreadJumpHintVisibilityController { @@ -599,6 +602,15 @@ export function resolveThreadStatusPill(input: { }; } + if ((thread.pendingBackgroundTasks?.length ?? 0) > 0) { + return { + label: "Waiting", + colorClass: "text-sidebar-muted-foreground", + dotClass: "bg-sidebar-muted-foreground", + pulse: false, + }; + } + const hasPlanReadyPrompt = !thread.hasPendingUserInput && thread.interactionMode === "plan" && diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 47a157f9c5f..b89cb077bb9 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1436,3 +1436,66 @@ describe("computeStableMessagesTimelineRows", () => { expect(reordered.result).toEqual([initial.result[1], initial.result[0]]); }); }); + +describe("deriveMessagesTimelineRows waiting-background", () => { + it("renders a waiting row when settled with pending background tasks", () => { + const rows = deriveMessagesTimelineRows({ + timelineEntries: [], + isWorking: false, + activeTurnStartedAt: null, + pendingBackgroundTasks: [{ taskId: "bg-1", description: "Run Codex review" }], + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); + + expect(rows).toEqual([ + { + kind: "waiting-background", + id: "waiting-background-row", + createdAt: null, + description: "Run Codex review", + taskCount: 1, + label: "Waiting on background task: Run Codex review", + }, + ]); + }); + + it("suppresses waiting while working", () => { + const rows = deriveMessagesTimelineRows({ + timelineEntries: [], + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + pendingBackgroundTasks: [{ taskId: "bg-1", description: "Run Codex review" }], + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); + + expect(rows.some((row) => row.kind === "waiting-background")).toBe(false); + expect(rows.some((row) => row.kind === "working")).toBe(true); + }); + + it("includes task count for multiple background tasks", () => { + const rows = deriveMessagesTimelineRows({ + timelineEntries: [], + isWorking: false, + activeTurnStartedAt: null, + pendingBackgroundTasks: [ + { taskId: "bg-1", description: "first" }, + { taskId: "bg-2", description: "second" }, + ], + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); + + expect(rows).toEqual([ + { + kind: "waiting-background", + id: "waiting-background-row", + createdAt: null, + description: "first", + taskCount: 2, + label: "Waiting on 2 background tasks: first, …", + }, + ]); + }); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index a57c402671a..f41f3036c0e 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -13,6 +13,7 @@ import { type RunId, } from "@t3tools/contracts"; import type { ThreadRunSummary } from "@t3tools/client-runtime/state/shell"; +import { formatPendingBackgroundWorkLabel } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { resolveT3McpToolPresentation, type T3McpToolPresentation, @@ -191,7 +192,15 @@ export type MessagesTimelineRow = createdAt: string; proposedPlan: ProposedPlan; } - | { kind: "working"; id: string; createdAt: string | null }; + | { kind: "working"; id: string; createdAt: string | null } + | { + kind: "waiting-background"; + id: string; + createdAt: string | null; + description: string | null; + taskCount: number; + label: string; + }; export interface StableMessagesTimelineRowsState { byId: Map; @@ -489,6 +498,10 @@ export function deriveMessagesTimelineRows(input: { expandedAttemptIds?: ReadonlySet; isWorking: boolean; activeTurnStartedAt: string | null; + pendingBackgroundTasks?: ReadonlyArray<{ + readonly taskId: string; + readonly description?: string | undefined; + }> | null; turnDiffSummaryByAssistantMessageId: ReadonlyMap; revertTurnCountByUserMessageId: ReadonlyMap; }): MessagesTimelineRow[] { @@ -655,6 +668,20 @@ export function deriveMessagesTimelineRows(input: { id: "working-indicator-row", createdAt: input.activeTurnStartedAt, }); + } else if (input.pendingBackgroundTasks && input.pendingBackgroundTasks.length > 0) { + // Root run settled but finite provider background work remains. Show Waiting + // instead of looking fully idle until the roster drains. + const firstTask = input.pendingBackgroundTasks[0]; + nextRows.push({ + kind: "waiting-background", + id: "waiting-background-row", + createdAt: input.activeTurnStartedAt, + description: firstTask?.description ?? null, + taskCount: input.pendingBackgroundTasks.length, + label: + formatPendingBackgroundWorkLabel(input.pendingBackgroundTasks) ?? + "Waiting on a background task", + }); } return nextRows; @@ -688,6 +715,16 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean case "working": return a.createdAt === (b as typeof a).createdAt; + case "waiting-background": { + const bw = b as typeof a; + return ( + a.createdAt === bw.createdAt && + a.description === bw.description && + a.taskCount === bw.taskCount && + a.label === bw.label + ); + } + case "turn-fold": { const bf = b as typeof a; return a.createdAt === bf.createdAt && a.label === bf.label && a.expanded === bf.expanded; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index c3b1e14eeaf..2bc50e41852 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -180,6 +180,10 @@ interface MessagesTimelineProps { isWorking: boolean; activeTurnInProgress: boolean; activeTurnStartedAt: string | null; + pendingBackgroundTasks?: ReadonlyArray<{ + readonly taskId: string; + readonly description?: string | undefined; + }> | null; listRef: React.RefObject; timelineEntries: ReadonlyArray; latestRun: TimelineLatestRun | null; @@ -227,6 +231,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ isWorking, activeTurnInProgress, activeTurnStartedAt, + pendingBackgroundTasks = null, listRef, timelineEntries, latestRun, @@ -323,6 +328,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ expandedAttemptIds, isWorking, activeTurnStartedAt, + pendingBackgroundTasks, turnDiffSummaryByAssistantMessageId, revertTurnCountByUserMessageId, }), @@ -333,6 +339,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ expandedAttemptIds, isWorking, activeTurnStartedAt, + pendingBackgroundTasks, turnDiffSummaryByAssistantMessageId, revertTurnCountByUserMessageId, ], @@ -910,6 +917,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time {row.kind === "proposed-plan" ? : null} {row.kind === "event" ? : null} {row.kind === "working" ? : null} + {row.kind === "waiting-background" ? : null} ); }); @@ -1478,6 +1486,25 @@ function WorkingTimelineRow({ row }: { row: Extract; +}) { + return ( +
+
+ + + + + + {row.label} +
+
+ ); +} + // --------------------------------------------------------------------------- // Self-ticking labels — update their own text nodes so elapsed-time display // does not create a React commit every second while a response is streaming. diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts index f187665acc7..4fcae705867 100644 --- a/packages/client-runtime/src/state/entities.test.ts +++ b/packages/client-runtime/src/state/entities.test.ts @@ -68,6 +68,99 @@ describe("V2 client presentation", () => { expect(shell.source).toBe(v2ThreadShell); }); + it("parks presented runtime at idle when a settled shell has pending background tasks", () => { + const runId = RunId.make("run-completed"); + const shell = presentThreadShell(environmentId, { + ...v2ThreadShell, + latestRunId: runId, + activeRunId: null, + status: "completed", + pendingBackgroundTasks: [{ taskId: "bg-1", description: "sleep 20" }], + }); + + expect(shell.latestRun).toMatchObject({ runId, status: "completed" }); + expect(shell.runtime).toMatchObject({ + status: "idle", + activeRunId: null, + }); + expect(shell.pendingBackgroundTasks).toEqual([{ taskId: "bg-1", description: "sleep 20" }]); + }); + + it("keeps terminal runtime completed when there are no pending background tasks", () => { + const runId = RunId.make("run-completed"); + const shell = presentThreadShell(environmentId, { + ...v2ThreadShell, + latestRunId: runId, + activeRunId: null, + status: "completed", + pendingBackgroundTasks: [], + }); + + expect(shell.latestRun).toMatchObject({ runId, status: "completed" }); + expect(shell.runtime).toMatchObject({ + status: "completed", + activeRunId: null, + }); + }); + + it("keeps runtime running when there is no background roster", () => { + const runId = RunId.make("run-running"); + const shell = presentThreadShell(environmentId, { + ...v2ThreadShell, + latestRunId: runId, + activeRunId: runId, + status: "running", + pendingBackgroundTasks: [], + }); + + expect(shell.latestRun).toMatchObject({ runId, status: "running" }); + expect(shell.runtime).toMatchObject({ + status: "running", + activeRunId: runId, + }); + }); + + it("parks runtime idle over stale shell running when the roster is nonempty", () => { + const runId = RunId.make("run-stale-running"); + const shell = presentThreadShell(environmentId, { + ...v2ThreadShell, + latestRunId: runId, + activeRunId: runId, + // Stale: server already projected a post-settlement roster, but shell + // status still says running (packaged orchestrator-v2 bug). + status: "running", + pendingBackgroundTasks: [{ taskId: "bg-1", description: "sleep 20" }], + }); + + expect(shell.latestRun).toMatchObject({ runId, status: "running" }); + expect(shell.runtime).toMatchObject({ + status: "idle", + activeRunId: runId, + }); + expect(shell.pendingBackgroundTasks).toEqual([{ taskId: "bg-1", description: "sleep 20" }]); + }); + + it("parks runtime idle over stale checkpoint waiting when the roster is nonempty", () => { + const runId = RunId.make("run-stale-waiting"); + const shell = presentThreadShell(environmentId, { + ...v2ThreadShell, + latestRunId: runId, + activeRunId: runId, + // Stale: checkpoint-oriented waiting masks post-settlement background work. + status: "waiting", + pendingBackgroundTasks: [{ taskId: "bg-2", description: "background bash" }], + }); + + expect(shell.latestRun).toMatchObject({ runId, status: "waiting" }); + expect(shell.runtime).toMatchObject({ + status: "idle", + activeRunId: runId, + }); + expect(shell.pendingBackgroundTasks).toEqual([ + { taskId: "bg-2", description: "background bash" }, + ]); + }); + it("derives execution summaries without wrapping or copying the projection", () => { const runId = RunId.make("run-1"); const now = DateTime.makeUnsafe("2026-06-20T01:00:00.000Z"); diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index d467e310bba..390c4027fa8 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -86,6 +86,9 @@ export interface EnvironmentThreadShell { readonly hasPendingApprovals: boolean; readonly hasPendingUserInput: boolean; readonly hasActionableProposedPlan: boolean; + readonly pendingBackgroundTasks: ReadonlyArray< + NonNullable[number] + >; readonly itemCount: number; readonly visibleItemCount: number; readonly createdAt: string; @@ -117,10 +120,17 @@ function terminalRunStatus(status: OrchestrationV2RunStatus): boolean { ); } +// Park runtime at idle when the post-settlement background roster is nonempty +// so #4415 Waiting (session.idle) can consume CTM runtime. The server only +// derives a nonempty roster when the latest root run is terminal, so a roster +// is the stronger presentation signal even if cached shell status is still +// running or checkpoint-oriented waiting. latestRun keeps the shell status. function shellRuntime(thread: OrchestrationV2ThreadShell): ThreadRuntimeSummary | null { if (thread.latestRunId === null && thread.activeProviderThreadId === null) return null; + const hasPendingBackgroundTasks = (thread.pendingBackgroundTasks?.length ?? 0) > 0; + const status = hasPendingBackgroundTasks ? "idle" : thread.status; return { - status: thread.status, + status, activeRunId: thread.activeRunId, providerInstanceId: thread.providerInstanceId, providerName: null, @@ -176,6 +186,7 @@ export function presentThreadShell( thread.pendingRuntimeRequest.kind !== "auth_refresh", hasPendingUserInput: thread.pendingRuntimeRequest?.kind === "user_input", hasActionableProposedPlan: thread.hasActionableProposedPlan, + pendingBackgroundTasks: thread.pendingBackgroundTasks ?? [], itemCount: thread.itemCount, visibleItemCount: thread.visibleItemCount, createdAt: iso(thread.createdAt), diff --git a/packages/contracts/src/orchestrationV2.test.ts b/packages/contracts/src/orchestrationV2.test.ts index 8c4ed1d6495..11106164611 100644 --- a/packages/contracts/src/orchestrationV2.test.ts +++ b/packages/contracts/src/orchestrationV2.test.ts @@ -24,9 +24,12 @@ import { OrchestrationV2CheckpointScope, OrchestrationV2Command, OrchestrationV2DomainEvent, + OrchestrationV2ProviderThread, + OrchestrationV2ProviderThreadJson, OrchestrationV2ShellSnapshot, OrchestrationV2Subagent, OrchestrationV2ThreadProjection, + OrchestrationV2ThreadShell, OrchestrationV2TurnItem, } from "./orchestrationV2.ts"; @@ -593,4 +596,92 @@ describe("orchestration V2 contracts", () => { expect(CheckpointRef.make("git-ref-1")).toBe("git-ref-1"); expect(ContextTransferId.make("context-transfer-1")).toBe("context-transfer-1"); }); + + it("decodes historical provider-thread JSON without pendingBackgroundTasks as empty roster", () => { + const providerThread = Schema.decodeUnknownSync(OrchestrationV2ProviderThreadJson)({ + id: "provider-thread-1", + driver: "claude", + providerInstanceId: "claudeAgent", + providerSessionId: "provider-session-1", + appThreadId: "thread-1", + ownerNodeId: null, + nativeThreadRef: { + driver: "claude", + nativeId: "native-session-1", + strength: "strong", + }, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: 1, + lastRunOrdinal: 1, + handoffIds: [], + forkedFrom: null, + createdAt: "2026-04-20T00:00:00.000Z", + updatedAt: "2026-04-20T00:00:00.000Z", + }); + + expect(providerThread.pendingBackgroundTasks).toEqual([]); + + const runtimeThread = Schema.decodeUnknownSync(OrchestrationV2ProviderThread)({ + id: "provider-thread-2", + driver: "claude", + providerInstanceId: "claudeAgent", + providerSessionId: null, + appThreadId: "thread-2", + ownerNodeId: null, + nativeThreadRef: null, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }); + expect(runtimeThread.pendingBackgroundTasks).toEqual([]); + }); + + it("decodes historical thread shell JSON without pendingBackgroundTasks as empty roster", () => { + const shell = Schema.decodeUnknownSync(OrchestrationV2ThreadShell)({ + createdBy: "user", + creationSource: "web", + id: "thread-1", + projectId: "project-1", + title: "Thread", + providerInstanceId: "claudeAgent", + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-sonnet", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + lineage: { + parentThreadId: null, + relationshipToParent: null, + rootThreadId: "thread-1", + }, + forkedFrom: null, + activeProviderThreadId: "provider-thread-1", + latestRunId: "run-1", + activeRunId: null, + status: "completed", + pendingRuntimeRequest: null, + latestVisibleMessage: null, + latestUserMessageAt: null, + hasActionableProposedPlan: false, + itemCount: 0, + visibleItemCount: 0, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + deletedAt: null, + }); + + expect(shell.pendingBackgroundTasks).toEqual([]); + }); }); diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 186e0b36fb0..6311c4a775f 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -496,6 +496,18 @@ export const OrchestrationV2ProviderSessionDetached = Schema.Struct({ export type OrchestrationV2ProviderSessionDetached = typeof OrchestrationV2ProviderSessionDetached.Type; +/** + * Provider-owned background work that can outlive the root turn (for example a + * Claude background Bash task). Associated with the provider thread so shared + * runtimes cannot make an unrelated app thread look busy. + */ +export const OrchestrationV2PendingBackgroundTask = Schema.Struct({ + taskId: TrimmedNonEmptyString, + description: Schema.optional(TrimmedNonEmptyString), + taskType: Schema.optional(TrimmedNonEmptyString), +}); +export type OrchestrationV2PendingBackgroundTask = typeof OrchestrationV2PendingBackgroundTask.Type; + export const OrchestrationV2ProviderThread = Schema.Struct({ id: ProviderThreadId, driver: ProviderDriverKind, @@ -516,6 +528,10 @@ export const OrchestrationV2ProviderThread = Schema.Struct({ checkpointId: Schema.optional(CheckpointId), }), ), + // Optional Type so adapters can omit empty rosters; historical JSON decodes to []. + pendingBackgroundTasks: Schema.optional(Schema.Array(OrchestrationV2PendingBackgroundTask)).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + ), createdAt: Schema.DateTimeUtc, updatedAt: Schema.DateTimeUtc, }); @@ -1171,6 +1187,11 @@ export const OrchestrationV2ThreadShell = Schema.Struct({ latestVisibleMessage: Schema.NullOr(OrchestrationV2LatestVisibleMessageSummary), latestUserMessageAt: Schema.NullOr(Schema.DateTimeUtc), hasActionableProposedPlan: Schema.Boolean, + // Normalized post-settlement background work for sidebar Waiting pills. + // Empty when the latest root run is still active or no pending work remains. + pendingBackgroundTasks: Schema.optional(Schema.Array(OrchestrationV2PendingBackgroundTask)).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + ), itemCount: NonNegativeInt, visibleItemCount: NonNegativeInt, createdAt: Schema.DateTimeUtc, diff --git a/packages/shared/package.json b/packages/shared/package.json index 06c9681fcf8..9408fc626ee 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -107,6 +107,10 @@ "types": "./src/orchestrationV2Timeline.ts", "import": "./src/orchestrationV2Timeline.ts" }, + "./orchestrationV2PendingBackgroundWork": { + "types": "./src/orchestrationV2PendingBackgroundWork.ts", + "import": "./src/orchestrationV2PendingBackgroundWork.ts" + }, "./remote": { "types": "./src/remote.ts", "import": "./src/remote.ts" diff --git a/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts b/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts new file mode 100644 index 00000000000..e9af3ec522d --- /dev/null +++ b/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + derivePendingBackgroundWork, + formatPendingBackgroundWorkLabel, +} from "./orchestrationV2PendingBackgroundWork.ts"; + +describe("derivePendingBackgroundWork", () => { + it("returns empty while the latest run is not settled", () => { + const tasks = derivePendingBackgroundWork({ + latestRun: { id: "run-1" as never, ordinal: 1, status: "running" }, + providerThreads: [ + { + id: "pt-1" as never, + pendingBackgroundTasks: [{ taskId: "bg-1", description: "sleep 20" }], + }, + ], + turnItems: [ + { + id: "item-1" as never, + type: "command_execution", + status: "running", + title: "npm test", + nativeItemRef: null, + input: "npm test", + }, + ], + }); + expect(tasks).toEqual([]); + }); + + it("returns the provider-thread roster after settlement", () => { + const tasks = derivePendingBackgroundWork({ + latestRun: { id: "run-1" as never, ordinal: 1, status: "completed" }, + providerThreads: [ + { + id: "pt-1" as never, + pendingBackgroundTasks: [ + { taskId: "bg-1", description: "Run Codex review", taskType: "local_bash" }, + ], + }, + ], + turnItems: [], + activeProviderThreadId: "pt-1", + }); + expect(tasks).toEqual([ + { taskId: "bg-1", description: "Run Codex review", taskType: "local_bash" }, + ]); + }); + + it("includes nonterminal turn items and excludes completed ones", () => { + const tasks = derivePendingBackgroundWork({ + latestRun: { id: "run-1" as never, ordinal: 1, status: "completed" }, + providerThreads: [{ id: "pt-1" as never }], + turnItems: [ + { + id: "item-1" as never, + type: "command_execution", + status: "running", + title: "npm test", + nativeItemRef: { nativeId: "cmd-1" }, + input: "npm test", + }, + { + id: "item-2" as never, + type: "command_execution", + status: "completed", + title: "done", + nativeItemRef: { nativeId: "cmd-2" }, + input: "echo done", + }, + ], + }); + expect(tasks).toEqual([ + { taskId: "cmd-1", description: "npm test", taskType: "command_execution" }, + ]); + }); + + it("dedupes roster entries against turn items by native task id", () => { + const tasks = derivePendingBackgroundWork({ + latestRun: { id: "run-1" as never, ordinal: 1, status: "completed" }, + providerThreads: [ + { + id: "pt-1" as never, + pendingBackgroundTasks: [{ taskId: "task-9", description: "Agent review" }], + }, + ], + turnItems: [ + { + id: "item-sub" as never, + type: "subagent", + status: "running", + title: "Agent review", + nativeItemRef: { nativeId: "task-9" }, + prompt: "review the plan", + }, + ], + }); + expect(tasks).toEqual([{ taskId: "task-9", description: "Agent review" }]); + }); + + it("excludes Grok persistent monitors", () => { + const tasks = derivePendingBackgroundWork({ + latestRun: { id: "run-1" as never, ordinal: 1, status: "completed" }, + providerThreads: [{ id: "pt-1" as never }], + turnItems: [ + { + id: "item-1" as never, + type: "dynamic_tool", + status: "running", + title: "monitor logs", + nativeItemRef: { nativeId: "mon-1" }, + input: { persistent: true, command: "tail -f" }, + }, + { + id: "item-2" as never, + type: "dynamic_tool", + status: "running", + title: "finite monitor", + nativeItemRef: { nativeId: "mon-2" }, + input: { persistent: false, command: "sleep 5" }, + }, + ], + }); + expect(tasks).toEqual([ + { taskId: "mon-2", description: "finite monitor", taskType: "dynamic_tool" }, + ]); + }); + + it("returns multiple tasks with stable ordering from insertion", () => { + const tasks = derivePendingBackgroundWork({ + latestRun: { id: "run-1" as never, ordinal: 1, status: "completed" }, + providerThreads: [ + { + id: "pt-1" as never, + pendingBackgroundTasks: [ + { taskId: "bg-1", description: "first" }, + { taskId: "bg-2", description: "second" }, + ], + }, + ], + turnItems: [ + { + id: "item-1" as never, + type: "command_execution", + status: "running", + title: "third", + nativeItemRef: { nativeId: "cmd-3" }, + input: "third", + }, + ], + }); + expect(tasks.map((task) => task.taskId)).toEqual(["bg-1", "bg-2", "cmd-3"]); + }); +}); + +describe("formatPendingBackgroundWorkLabel", () => { + it("formats single and multi-task labels", () => { + expect(formatPendingBackgroundWorkLabel([])).toBeNull(); + expect(formatPendingBackgroundWorkLabel([{ taskId: "a" }])).toBe( + "Waiting on a background task", + ); + expect( + formatPendingBackgroundWorkLabel([{ taskId: "a", description: "Run Codex review" }]), + ).toBe("Waiting on background task: Run Codex review"); + expect( + formatPendingBackgroundWorkLabel([ + { taskId: "a", description: "first" }, + { taskId: "b", description: "second" }, + ]), + ).toBe("Waiting on 2 background tasks: first, …"); + }); +}); diff --git a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts new file mode 100644 index 00000000000..1fac0714e58 --- /dev/null +++ b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts @@ -0,0 +1,189 @@ +import type { + OrchestrationV2PendingBackgroundTask, + OrchestrationV2ProviderThread, + OrchestrationV2Run, + OrchestrationV2TurnItem, +} from "@t3tools/contracts"; + +const BACKGROUND_TURN_ITEM_TYPES = new Set([ + "command_execution", + "dynamic_tool", + "subagent", +]); + +const TERMINAL_TURN_ITEM_STATUSES = new Set([ + "completed", + "interrupted", + "failed", + "cancelled", +]); + +const TERMINAL_RUN_STATUSES = new Set([ + "completed", + "interrupted", + "failed", + "cancelled", + "rolled_back", +]); + +export type PendingBackgroundWorkTask = OrchestrationV2PendingBackgroundTask; + +type PendingBackgroundWorkRun = Pick; + +type PendingBackgroundWorkProviderThread = Pick< + OrchestrationV2ProviderThread, + "id" | "pendingBackgroundTasks" +>; + +type PendingBackgroundWorkTurnItem = { + readonly id: OrchestrationV2TurnItem["id"] | string; + readonly type: OrchestrationV2TurnItem["type"]; + readonly status: OrchestrationV2TurnItem["status"]; + readonly title: string | null; + readonly nativeItemRef?: { + readonly nativeId: string | null; + } | null; + readonly input?: unknown; + readonly prompt?: string | undefined; +}; + +function isTerminalTurnItemStatus(status: OrchestrationV2TurnItem["status"]): boolean { + return TERMINAL_TURN_ITEM_STATUSES.has(status); +} + +function isLatestRunSettledForBackgroundWait( + latestRun: PendingBackgroundWorkRun | null | undefined, +): boolean { + if (latestRun === undefined || latestRun === null) { + return false; + } + return TERMINAL_RUN_STATUSES.has(latestRun.status); +} + +function isPersistentDynamicToolInput(input: unknown): boolean { + if (input === null || typeof input !== "object" || Array.isArray(input)) { + return false; + } + return Reflect.get(input, "persistent") === true; +} + +function descriptionFromTurnItem(item: PendingBackgroundWorkTurnItem): string | undefined { + if (typeof item.title === "string" && item.title.trim().length > 0) { + return item.title; + } + if (item.type === "command_execution" && typeof item.input === "string") { + const command = item.input.trim(); + return command.length > 0 ? command : undefined; + } + if (item.type === "dynamic_tool") { + const toolName = Reflect.get(item, "toolName"); + if (typeof toolName === "string" && toolName.trim().length > 0) { + return toolName; + } + } + if (item.type === "subagent" && typeof item.prompt === "string") { + const prompt = item.prompt.trim(); + return prompt.length > 0 ? prompt : undefined; + } + return undefined; +} + +function nativeTaskIdFromTurnItem(item: PendingBackgroundWorkTurnItem): string { + const nativeId = item.nativeItemRef?.nativeId; + if (typeof nativeId === "string" && nativeId.length > 0) { + return nativeId; + } + return String(item.id); +} + +/** + * Derive one normalized pending-background-work list for post-settlement UI. + * + * Sources: + * - Provider-thread roster (Claude SDK background tasks) + * - Nonterminal command_execution / dynamic_tool / subagent turn items + * + * Gated on latest root run settlement. Dedupes by native task ID. Excludes + * Grok persistent monitors (`dynamic_tool` input with `persistent: true`). + * Does not consult subagent entities (those double-count turn items). + */ +export function derivePendingBackgroundWork(input: { + readonly latestRun: PendingBackgroundWorkRun | null | undefined; + readonly providerThreads: ReadonlyArray; + readonly turnItems: ReadonlyArray; + readonly activeProviderThreadId?: string | null; +}): ReadonlyArray { + if (!isLatestRunSettledForBackgroundWait(input.latestRun)) { + return []; + } + + const byTaskId = new Map(); + + const providerThreads = + input.activeProviderThreadId === undefined || input.activeProviderThreadId === null + ? input.providerThreads + : input.providerThreads.filter((thread) => thread.id === input.activeProviderThreadId); + + for (const providerThread of providerThreads) { + for (const task of providerThread.pendingBackgroundTasks ?? []) { + if (task.taskId.length === 0 || byTaskId.has(task.taskId)) { + continue; + } + byTaskId.set(task.taskId, { + taskId: task.taskId, + ...(task.description === undefined ? {} : { description: task.description }), + ...(task.taskType === undefined ? {} : { taskType: task.taskType }), + }); + } + } + + for (const item of input.turnItems) { + if (!BACKGROUND_TURN_ITEM_TYPES.has(item.type)) { + continue; + } + if (isTerminalTurnItemStatus(item.status)) { + continue; + } + if (item.type === "dynamic_tool" && isPersistentDynamicToolInput(item.input)) { + continue; + } + + const taskId = nativeTaskIdFromTurnItem(item); + if (byTaskId.has(taskId)) { + continue; + } + + const description = descriptionFromTurnItem(item); + byTaskId.set(taskId, { + taskId, + ...(description === undefined ? {} : { description }), + ...(item.type === "subagent" + ? { taskType: "subagent" } + : item.type === "command_execution" + ? { taskType: "command_execution" } + : item.type === "dynamic_tool" + ? { taskType: "dynamic_tool" } + : {}), + }); + } + + return Array.from(byTaskId.values()); +} + +export function formatPendingBackgroundWorkLabel( + tasks: ReadonlyArray, +): string | null { + if (tasks.length === 0) { + return null; + } + const firstDescription = tasks[0]?.description?.trim(); + if (tasks.length === 1) { + return firstDescription && firstDescription.length > 0 + ? `Waiting on background task: ${firstDescription}` + : "Waiting on a background task"; + } + if (firstDescription && firstDescription.length > 0) { + return `Waiting on ${tasks.length} background tasks: ${firstDescription}, …`; + } + return `Waiting on ${tasks.length} background tasks`; +}