From 996087f1f56f7fb9934050457b446f3439b22564 Mon Sep 17 00:00:00 2001 From: pandec Date: Sat, 26 Sep 2026 08:42:02 +0200 Subject: [PATCH 1/4] feat: snooze until done waits for subagents The until-done snooze woke when the main agent's turn ended, even while its background subagents kept working. It now holds while the awaited turn runs or its subagents work, and moves onto the follow-up turn the agent starts for their results. Claude opens that turn on the task notification, so the thread never looks idle in between. --- README.md | 2 +- .../Layers/OrchestrationEngine.ts | 8 +- .../ThreadSettlementPolicy.test.ts | 22 +++ .../orchestration/ThreadSettlementPolicy.ts | 31 +++-- .../src/orchestration/decider.snoozed.test.ts | 89 ++++++++++++ apps/server/src/orchestration/decider.ts | 67 +++++++-- .../src/provider/Layers/ClaudeAdapter.test.ts | 97 +++++++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 128 +++++++++++------- docs/user/cli-automation.md | 7 +- docs/user/thread-sidebar.md | 8 +- .../src/state/threadCommands.ts | 4 +- .../client-runtime/src/state/threadSettled.ts | 56 +++++--- .../src/state/threadSnoozed.test.ts | 34 ++++- 13 files changed, 458 insertions(+), 95 deletions(-) diff --git a/README.md b/README.md index 97a52bdcacfa..1251db1a929f 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ This is a personal fork of [pingdotgg/t3code](https://github.com/pingdotgg/t3cod - **Removed worktree recovery**. Follow-up messages continue the same conversation in the main project checkout when a worktree is gone. A T3 notice appears as a user message and tells the agent where it is running. Settings → Extras → **Skip recreating removed worktrees** is on by default. Turn it off to try recreation first, with the main checkout as a fallback if recreation fails. Mobile exposes the same shared setting under Settings → Thread behavior. -- **Snooze until it's done** — an "Until it's done" preset in the thread snooze menus, offered only while the agent is mid-turn, hides the thread until that turn ends: it returns when the agent finishes, is interrupted, fails, or asks you something, with the usual Woke marker. These rows sit at the top of the Snoozed section with an "until done" label; the T3 CLI reports them as `Snoozed: until done`. Available on web, desktop, and mobile against servers that carry the feature. +- **Snooze until it's done** — an "Until it's done" preset in the thread snooze menus, offered while the agent is mid-turn or its subagents are still working, hides the thread until that work ends, including the agent's follow-up on the subagents' results. Watch loops such as a dev server don't hold it. The thread returns when the agent finishes, is interrupted, fails, or asks you something, with the usual Woke marker. These rows sit at the top of the Snoozed section with an "until done" label; the T3 CLI reports them as `Snoozed: until done`. Available on web, desktop, and mobile against servers that carry the feature. - **Indefinite snooze** — an "Until I wake it" preset in the thread sidebar snooze menus parks a thread on the snoozed shelf with no timer: it comes back when explicitly woken, when you send it a message, or when the thread needs you (a finished or interrupted turn, a failure, or a pending request). Indefinite rows sort after every timed wake and carry a "parked" label; the T3 CLI reports them as `Snoozed: until woken`. - **Conversation forking** — fork a Codex or Claude thread mid-conversation into a new thread, including immediately after importing an external session. Claude copies the full native history using the same bundled helper as rewind, preserving the selected provider instance and matching conversation turns even when native system records change; forked threads are titled with a 🔱 prefix. - **Two-pane split view** — web and desktop can show a second thread beside the routed one, including a thread from a different environment. Open it from the chat header button (between the git actions and the terminal toggle) or the ⌘K "Open thread in split view..." action. While split, hovering the divider reveals its controls near the top: swap the two threads (also ⌘K "Swap split threads", rebindable, default mod+shift+\), switch the right pane's thread, and close the split. Sidebar clicks and ⌘K thread picks open into the active pane — picking a thread already on screen just focuses its pane, so neither path can accidentally fold the split — and the sidebar marks both open threads with left/right pane glyphs. A ring marks the active pane, a rebindable shortcut (default mod+\) jumps between panes, and window shortcuts — including permission-prompt number keys — act only on the active pane. Each pane's right panel opens as an overlay sheet, terminals stay with the pane that owns their thread, the divider drags and remembers its position, and actions that create a new thread (new thread, implement plan) stay in the main pane. diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index e3f6d20a9e65..60d51cb0bf86 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -433,10 +433,10 @@ const makeOrchestrationEngine = Effect.gen(function* () { : Option.none(); const eventBase = yield* decideOrchestrationCommand({ command: envelope.command, - hasLiveBackgroundWork: - "threadId" in envelope.command && - threadBackgroundLiveness.getThreadBackgroundLiveness(envelope.command.threadId) !== - null, + backgroundLiveness: + "threadId" in envelope.command + ? threadBackgroundLiveness.getThreadBackgroundLiveness(envelope.command.threadId) + : null, readModel: Option.isSome(completionCheckpoints) ? { ...commandReadModel, diff --git a/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts b/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts index 9ef9321b1b00..580ffe84a786 100644 --- a/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts @@ -302,6 +302,28 @@ describe("resolveAutoSettlementAt", () => { ), ).toBe(false); expect(isThreadSnoozed(makeThread({ ...untilDone, latestTurn: null }), NOW)).toBe(false); + // An ended turn whose subagents still work stays snoozed; watch loops + // alone don't hold it. + const endedTurn = { + turnId: TurnId.make("turn-done"), + state: "completed" as const, + requestedAt: "2026-08-18T00:00:00.000Z", + startedAt: "2026-08-18T00:01:00.000Z", + completedAt: "2026-08-20T00:00:00.000Z", + assistantMessageId: null, + }; + expect( + isThreadSnoozed( + makeThread({ ...untilDone, latestTurn: endedTurn, backgroundLiveness: "working" }), + NOW, + ), + ).toBe(true); + expect( + isThreadSnoozed( + makeThread({ ...untilDone, latestTurn: endedTurn, backgroundLiveness: "monitoring" }), + NOW, + ), + ).toBe(false); }); it("allows a fresh completion to wake snooze before settlement", () => { diff --git a/apps/server/src/orchestration/ThreadSettlementPolicy.ts b/apps/server/src/orchestration/ThreadSettlementPolicy.ts index b547ac033f50..4326e8836647 100644 --- a/apps/server/src/orchestration/ThreadSettlementPolicy.ts +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.ts @@ -132,6 +132,24 @@ export function isAutoSettlementCandidate(thread: OrchestrationThreadShell, now: return !isThreadSnoozed(thread, now); } +/** + * Server twin of client-runtime's untilDoneWorkContinues: the awaited turn + * is running, or it ended while its subagents work on. Watch loops alone + * ("monitoring") don't count. Keep the two in step. + */ +export function untilDoneWorkContinues( + thread: Pick< + OrchestrationThreadShell, + "snoozedUntilTurnId" | "latestTurn" | "backgroundLiveness" + >, +): boolean { + return ( + thread.snoozedUntilTurnId != null && + thread.latestTurn?.turnId === thread.snoozedUntilTurnId && + (thread.latestTurn.state === "running" || thread.backgroundLiveness === "working") + ); +} + /** * Server twin of client-runtime's effectiveSnoozed: hidden while the wake * condition holds and the thread has not raised its hand. A raised hand is @@ -148,6 +166,7 @@ export function isThreadSnoozed( | "hasPendingUserInput" | "session" | "latestTurn" + | "backgroundLiveness" >, now: string, ): boolean { @@ -159,6 +178,10 @@ export function isThreadSnoozed( ) { return false; } + // "Until it's done": snoozed only while the awaited work continues. An + // ended turn is not news while its subagents still work; any other shape + // (ended and quiet, replaced, dropped) wakes. + if (thread.snoozedUntilTurnId != null) return untilDoneWorkContinues(thread); if ( thread.snoozedAt != null && thread.latestTurn != null && @@ -168,14 +191,6 @@ export function isThreadSnoozed( ) { return false; } - // "Until it's done": snoozed only while the awaited turn is still the - // running latest turn. Any other shape (ended, replaced, dropped) wakes. - if (thread.snoozedUntilTurnId != null) { - return ( - thread.latestTurn?.turnId === thread.snoozedUntilTurnId && - thread.latestTurn.state === "running" - ); - } if (thread.snoozedUntil == null) return thread.snoozedAt != null; return Date.parse(thread.snoozedUntil) > Date.parse(now); } diff --git a/apps/server/src/orchestration/decider.snoozed.test.ts b/apps/server/src/orchestration/decider.snoozed.test.ts index 9c08166109e5..8309beb1fa67 100644 --- a/apps/server/src/orchestration/decider.snoozed.test.ts +++ b/apps/server/src/orchestration/decider.snoozed.test.ts @@ -30,6 +30,12 @@ const RUNNING_TURN: NonNullable = { completedAt: null, assistantMessageId: null, }; +// Settled after the snooze was set, with subagents possibly still working. +const SETTLED_TURN: NonNullable = { + ...RUNNING_TURN, + state: "completed", + completedAt: "1969-12-31T00:00:00.000Z", +}; function makeReadModel(input: { readonly snoozedUntil?: string | null; @@ -444,6 +450,89 @@ it.layer(NodeServices.layer)("snoozed thread decider", (it) => { }), ); + it.effect("snoozes until done on a settled turn while its subagents work, not watch loops", () => + Effect.gen(function* () { + const command = { + type: "thread.snooze", + commandId: CommandId.make("cmd-snooze-until-done-background"), + threadId: ThreadId.make("thread-1"), + snoozedUntil: null, + untilDone: true, + } as const; + const readModel = makeReadModel({ latestTurn: SETTLED_TURN }); + const event = yield* decideOrchestrationCommand({ + command, + readModel, + backgroundLiveness: "working", + }); + const events = Array.isArray(event) ? event : [event]; + expect(events[0]?.type === "thread.snoozed" && events[0].payload.snoozedUntilTurnId).toBe( + SETTLED_TURN.turnId, + ); + const error = yield* decideOrchestrationCommand({ + command, + readModel, + backgroundLiveness: "monitoring", + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect( + "moves an until-done snooze onto the agent's follow-up turn only while work is live", + () => + Effect.gen(function* () { + const followUpTurnId = TurnId.make("turn-2"); + const command = { + type: "thread.session.set", + commandId: CommandId.make("cmd-session-follow-up"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: followUpTurnId, + lastError: null, + updatedAt: NOW_EPOCH, + }, + createdAt: NOW_EPOCH, + } as const; + const readModel = makeReadModel({ + snoozedAt: SNOOZED_AT, + snoozedUntilTurnId: "turn-1", + latestTurn: SETTLED_TURN, + }); + + const live = yield* decideOrchestrationCommand({ + command, + readModel, + backgroundLiveness: "working", + }); + const liveEvents = Array.isArray(live) ? live : [live]; + expect(liveEvents.map((event) => event.type)).toEqual([ + "thread.session-set", + "thread.snoozed", + ]); + const moved = liveEvents[1]; + if (moved?.type !== "thread.snoozed") throw new Error("expected thread.snoozed"); + expect(moved.payload.snoozedUntilTurnId).toBe(followUpTurnId); + expect(moved.payload.snoozedAt).toBe(SNOOZED_AT); + + // The work went quiet before this turn: the thread already woke. + for (const backgroundLiveness of [null, "monitoring"] as const) { + const quiet = yield* decideOrchestrationCommand({ + command, + readModel, + backgroundLiveness, + }); + expect((Array.isArray(quiet) ? quiet : [quiet]).map((event) => event.type)).toEqual([ + "thread.session-set", + ]); + } + }), + ); + it.effect("rejects until-done combined with a wake time", () => Effect.gen(function* () { const error = yield* decideOrchestrationCommand({ diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index ac2290dbc1b3..4ff64d282ca7 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -10,6 +10,7 @@ import { type OrchestrationEvent, type OrchestrationReadModel, type OrchestrationThread, + type OrchestrationThreadShell, type ThreadPullRequestKey, type ThreadPullRequestLink, type OrchestrationThreadActivity, @@ -238,12 +239,13 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, readModel, userInputActivity, - hasLiveBackgroundWork = false, + backgroundLiveness = null, }: { readonly command: OrchestrationCommand; readonly readModel: OrchestrationReadModel; readonly userInputActivity?: OrchestrationThreadActivity; - readonly hasLiveBackgroundWork?: boolean; + /** The command thread's live background work (engine state, not read model). */ + readonly backgroundLiveness?: OrchestrationThreadShell["backgroundLiveness"]; }): Effect.fn.Return< DecideOrchestrationCommandResult, OrchestrationCommandRejection | PlatformError.PlatformError, @@ -797,7 +799,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" requestedAt: occurredAt, status: "pending", }); - if (turnId !== null || hasLiveBackgroundWork) return request; + if (turnId !== null || backgroundLiveness != null) return request; return [ request, { @@ -1156,13 +1158,15 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" detail: `thread ${command.threadId} has a queued turn start and cannot be snoozed`, }); } - // "Until it's done" waits on the running turn. Without one there is - // nothing to finish and the snooze would never wake, so reject rather - // than silently parking the thread indefinitely. Background work that - // outlives a settled turn does not count: the wake rule reads - // latestTurn, and that turn has already ended. + // "Until it's done" waits on the running turn, or on the subagents a + // settled turn left working. With neither there is nothing to finish + // and the snooze would never wake, so reject rather than silently + // parking the thread indefinitely. Watch loops alone ("monitoring") + // don't count: a dev server can outlive the session. const untilDoneTurnId = - command.untilDone === true && thread.latestTurn?.state === "running" + command.untilDone === true && + thread.latestTurn !== null && + (thread.latestTurn.state === "running" || backgroundLiveness === "working") ? thread.latestTurn.turnId : null; if (command.untilDone === true && command.snoozedUntil !== null) { @@ -1174,7 +1178,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" if (command.untilDone === true && untilDoneTurnId === null) { return yield* new OrchestrationCommandInvariantError({ commandType: command.type, - detail: `thread ${command.threadId} has no running turn to wait for`, + detail: `thread ${command.threadId} has no running turn or working subagents to wait for`, }); } // Re-snoozing an already-snoozed thread to the SAME wake condition is @@ -1195,6 +1199,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" snoozedUntilTurnId: thread.snoozedUntilTurnId, session: thread.session, latestTurn: thread.latestTurn, + backgroundLiveness, // Open requests were already rejected above. hasPendingApprovals: false, hasPendingUserInput: false, @@ -2643,6 +2648,48 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }, }; const sessionEvents = [sessionSetEvent]; + // A turn the agent starts on its own (its answer to finished subagents) + // continues the work an "until it's done" snooze waits on: move the + // snooze onto it while that work is still live. A user's turn start + // unsnoozes first, and a turn arriving after the work went quiet finds + // the thread already awake, so both stay out. + const followUpTurnId = command.session.activeTurnId; + if ( + followUpTurnId !== null && + thread.snoozedUntilTurnId != null && + thread.snoozedAt != null && + followUpTurnId !== thread.snoozedUntilTurnId && + isThreadSnoozed( + { + snoozedUntil: thread.snoozedUntil, + snoozedAt: thread.snoozedAt, + snoozedUntilTurnId: thread.snoozedUntilTurnId, + session: thread.session, + latestTurn: thread.latestTurn, + backgroundLiveness, + hasPendingApprovals: openRequests(thread).size > 0, + hasPendingUserInput: false, + }, + command.createdAt, + ) + ) { + sessionEvents.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.snoozed", + payload: { + threadId: command.threadId, + snoozedUntil: null, + snoozedAt: thread.snoozedAt, + snoozedUntilTurnId: followUpTurnId, + updatedAt: command.createdAt, + }, + }); + } // An idle archive has no selected turn to compare at execution. A new // provider turn must cancel it even when no user turn-start was sent. if ( diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index bb73ca427cb3..95ec973b93a5 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -5031,6 +5031,103 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("opens the follow-up turn when an idle background task notifies the main agent", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const lifecycleFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.type === "turn.started" || + event.type === "turn.completed" || + event.type === "task.completed", + ), + Stream.take(7), + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "spawn a background agent", + attachments: [], + }); + const emitResult = (uuid: string) => + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session", + uuid, + } as unknown as SDKMessage); + const emitNotification = (taskId: string, extra: Record = {}) => + harness.query.emit({ + type: "system", + subtype: "task_notification", + task_id: taskId, + status: "completed", + output_file: "", + summary: "done", + uuid: `${taskId}-done`, + session_id: "sdk-session", + ...extra, + } as unknown as SDKMessage); + const emitAssistant = (uuid: string) => + harness.query.emit({ + type: "assistant", + session_id: "sdk-session", + uuid, + parent_tool_use_id: null, + message: { id: `${uuid}-message`, content: [{ type: "text", text: "Results in" }] }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "system", + subtype: "task_started", + task_id: "task-bg", + description: "Background agent", + task_type: "local_agent", + uuid: "task-bg-uuid", + session_id: "sdk-session", + } as unknown as SDKMessage); + emitResult("result-user-turn"); + // The main agent answers the finished agent: one turn, opened by the + // notification before the task completes, and kept by the answer. + emitNotification("task-bg"); + emitAssistant("assistant-follow-up"); + emitResult("result-follow-up"); + // Ambient work never reaches the main agent: no turn until output. + emitNotification("task-ambient", { ambient: true }); + emitAssistant("assistant-unprompted"); + + const events = Array.from(yield* Fiber.join(lifecycleFiber)); + assert.deepEqual( + events.map((event) => event.type), + [ + "turn.started", + "turn.completed", + "turn.started", + "task.completed", + "turn.completed", + "task.completed", + "turn.started", + ], + ); + const followUp = events[2]; + assert.equal(followUp?.raw?.method, "claude/synthetic-turn-start"); + assert.equal(events[4]?.turnId, followUp?.turnId); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("keeps the session available when process close fails", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index a9f9d226d405..754d4b7994de 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -313,6 +313,9 @@ interface ClaudeTurnState { * steered instead (the queued message continues the same turn). */ readonly synthetic?: boolean; + /** Synthetic turn opened before its first assistant message, whose + * rollback boundary is still unknown. */ + syntheticBoundaryPending?: boolean; readonly assistantTextBlocks: Map; readonly assistantTextBlockOrder: Array; readonly capturedProposedPlanKeys: Set; @@ -3766,6 +3769,64 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } }); + /** + * Opens a synthetic turn for main-agent work that no user prompt started. + * A null boundary is filled from the turn's first assistant message. + */ + const startSyntheticTurn = Effect.fn("startSyntheticTurn")(function* ( + context: ClaudeSessionContext, + boundaryMessageId: string | null, + ) { + context.pendingWorkState.hasPendingWork = undefined; + const turnId = TurnId.make(yield* randomUUIDv4); + const startedAt = yield* nowIso; + context.turnStartMessageIds.push(boundaryMessageId); + context.turnState = { + turnId, + startedAt, + synthetic: true, + syntheticBoundaryPending: boundaryMessageId === null, + assistantTextBlocks: new Map(), + assistantTextBlockOrder: [], + capturedProposedPlanKeys: new Set(), + latestAssistantUsage: undefined, + compactedSinceLatestAssistantUsage: false, + hasSubagents: false, + nextSyntheticAssistantBlockIndex: -1, + authenticationFailureMessage: undefined, + rejectedRateLimitTypes: new Set(), + latestAssistantRateLimited: false, + emittedThinkingText: false, + thinkingSnapshotIds: new Set(), + }; + context.session = { + ...context.session, + status: "running", + activeTurnId: turnId, + updatedAt: startedAt, + }; + yield* updateResumeCursor(context); + const turnStartedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "turn.started", + eventId: turnStartedStamp.eventId, + provider: PROVIDER, + createdAt: turnStartedStamp.createdAt, + threadId: context.session.threadId, + turnId, + payload: {}, + providerRefs: { + ...nativeProviderRefs(context), + providerTurnId: turnId, + }, + raw: { + source: "claude.sdk.message", + method: "claude/synthetic-turn-start", + payload: {}, + }, + }); + }); + const handleAssistantMessage = Effect.fn("handleAssistantMessage")(function* ( context: ClaudeSessionContext, message: SDKMessage, @@ -3808,53 +3869,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( // Auto-start a synthetic turn for assistant messages that arrive without // an active turn (e.g., background agent/subagent responses between user prompts). if (!context.turnState) { - context.pendingWorkState.hasPendingWork = undefined; - const turnId = TurnId.make(yield* randomUUIDv4); - const startedAt = yield* nowIso; - context.turnStartMessageIds.push(message.uuid); - context.turnState = { - turnId, - startedAt, - synthetic: true, - assistantTextBlocks: new Map(), - assistantTextBlockOrder: [], - capturedProposedPlanKeys: new Set(), - latestAssistantUsage: undefined, - compactedSinceLatestAssistantUsage: false, - hasSubagents: false, - nextSyntheticAssistantBlockIndex: -1, - authenticationFailureMessage: undefined, - rejectedRateLimitTypes: new Set(), - latestAssistantRateLimited: false, - emittedThinkingText: false, - thinkingSnapshotIds: new Set(), - }; - context.session = { - ...context.session, - status: "running", - activeTurnId: turnId, - updatedAt: startedAt, - }; + yield* startSyntheticTurn(context, message.uuid); + } else if (context.turnState.syntheticBoundaryPending === true) { + // A turn opened by a task notification takes its rollback boundary + // from the first assistant message, like any other synthetic turn. + context.turnState.syntheticBoundaryPending = false; + context.turnStartMessageIds[context.turnStartMessageIds.length - 1] = message.uuid; yield* updateResumeCursor(context); - const turnStartedStamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "turn.started", - eventId: turnStartedStamp.eventId, - provider: PROVIDER, - createdAt: turnStartedStamp.createdAt, - threadId: context.session.threadId, - turnId, - payload: {}, - providerRefs: { - ...nativeProviderRefs(context), - providerTurnId: turnId, - }, - raw: { - source: "claude.sdk.message", - method: "claude/synthetic-turn-start", - payload: {}, - }, - }); } const content = message.message?.content; @@ -4295,6 +4316,21 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( return; } case "task_notification": { + // Between prompts, the CLI feeds a finished background task back to + // the main agent, which then answers. Open that turn now rather than + // at its first assistant message, and before task.completed, so the + // thread never looks idle between the work ending and the answer: + // an "until it's done" snooze follows the work across that edge. + // Transcript-skipped (ambient) tasks and tasks owned by a subagent + // never reach the main agent. + if ( + !context.turnState && + message.skip_transcript !== true && + message.ambient !== true && + context.taskAgents.get(message.task_id)?.owningAgentId === undefined + ) { + yield* startSyntheticTurn(context, null); + } context.liveTaskIds.delete(message.task_id); yield* emitThreadTokenUsage( context, diff --git a/docs/user/cli-automation.md b/docs/user/cli-automation.md index 99802e818741..8080c521f63e 100644 --- a/docs/user/cli-automation.md +++ b/docs/user/cli-automation.md @@ -152,9 +152,10 @@ the provider supports steering. It does not resolve a user-input request. Use `t for that request. Thread list and status JSON summaries include the stored snooze request: `snoozedUntil`, `snoozedAt`, and `snoozedUntilTurnId`, all `null` when nothing was requested. An indefinite snooze ("until I wake it") carries a `snoozedAt` with a `null` `snoozedUntil`, and a -snooze until the turn ends ("until it's done") also carries the awaited turn in -`snoozedUntilTurnId`. These fields stay set after a snooze wakes on its own (the timer passed or -the turn ended); the `thread status` text line reports whether the thread is currently snoozed. +snooze until the work finishes ("until it's done") also carries the awaited turn in +`snoozedUntilTurnId`; when the agent starts a follow-up turn for its subagents' results, the field +moves to that turn. These fields stay set after a snooze wakes on its own (the timer passed or the +work finished); the `thread status` text line reports whether the thread is currently snoozed. Snooze is an inbox overlay and does not change the thread's turn `state`. Passing `--title` records a manual title, so automatic title generation does not replace it. Without diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 26f63a5d1a03..b462a988d937 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -153,9 +153,11 @@ Mobile shows the same groups. Use **Arrange threads** to drag between groups or ## Snooze active work Choose **Snooze** to hide a thread until a preset time. **Until it's done** appears while the agent -is mid-turn and hides the thread until that turn ends. **Until I wake it** snoozes without a timer. -Both need a server that supports them. A snoozed thread returns when its timer expires, its turn -ends, you wake it, or it needs attention. Threads snoozed until done sit at the top of the Snoozed +is mid-turn or its subagents are still working, and hides the thread until that work finishes, +including the agent's follow-up on the subagents' results. Watch loops such as a running dev server +don't hold it. **Until I wake it** snoozes without a timer. Both need a server that supports them. +A snoozed thread returns when its timer expires, its work finishes, you wake it, or it needs +attention. Threads snoozed until done sit at the top of the Snoozed section. ## Settle finished work diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index 21770922cfc9..64990aa4e752 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -7,7 +7,7 @@ import { } from "@t3tools/contracts"; import { createOptimisticThreadLifecycle } from "./threadLifecycle.ts"; -import { canSnooze, effectiveSnoozed } from "./threadSettled.ts"; +import { canSnooze, canSnoozeUntilDone, effectiveSnoozed } from "./threadSettled.ts"; import { createAtomCommandScheduler, @@ -346,7 +346,7 @@ export function createThreadEnvironmentAtoms( })), snooze: optimistic.wrap(commands.snooze, (thread, input, now, accepted) => { const untilDoneTurnId = - input.untilDone === true && thread.latestTurn?.state === "running" + input.untilDone === true && thread.latestTurn !== null && canSnoozeUntilDone(thread) ? thread.latestTurn.turnId : null; if ( diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index 254be55542e9..6e7543d45f87 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -59,8 +59,27 @@ export type ThreadSnoozeShell = Pick< | "hasPendingUserInput" | "session" | "latestTurn" + | "backgroundLiveness" >; +/** + * Whether the work an "until it's done" snooze waits on is still going: the + * awaited turn is running, or it ended while its subagents work on. The + * server moves the snooze onto a follow-up turn the agent starts for them, + * so the awaited turn stays the latest one until the work goes quiet. + * Watch loops alone ("monitoring") don't count: a dev server can outlive + * the session. Mirrored by the server's untilDoneWorkContinues. + */ +export function untilDoneWorkContinues( + shell: Pick, +): boolean { + return ( + shell.snoozedUntilTurnId != null && + shell.latestTurn?.turnId === shell.snoozedUntilTurnId && + (shell.latestTurn.state === "running" || shell.backgroundLiveness === "working") + ); +} + /** * A snoozed thread "raises its hand" when something happens that outranks * the user's snooze: the agent is blocked on them (approval / user input), @@ -83,12 +102,15 @@ export function threadRaisedHandWhileSnoozed(shell: ThreadSnoozeShell): boolean ) { return true; } + // An ended turn is not news while an "until it's done" snooze still sees + // its subagents working. if ( shell.snoozedAt != null && shell.latestTurn != null && shell.latestTurn.state !== "running" && shell.latestTurn.completedAt != null && - Date.parse(shell.latestTurn.completedAt) > Date.parse(shell.snoozedAt) + Date.parse(shell.latestTurn.completedAt) > Date.parse(shell.snoozedAt) && + !untilDoneWorkContinues(shell) ) { return true; } @@ -97,10 +119,16 @@ export function threadRaisedHandWhileSnoozed(shell: ThreadSnoozeShell): boolean /** * Whether an "until it's done" snooze may be offered: the server rejects it - * unless a turn is running, so the client hides the preset on quiet threads. + * unless a turn is running or its subagents are still working, so the + * client hides the preset on quiet threads. */ -export function canSnoozeUntilDone(shell: Pick): boolean { - return shell.latestTurn?.state === "running"; +export function canSnoozeUntilDone( + shell: Pick, +): boolean { + return ( + shell.latestTurn?.state === "running" || + (shell.latestTurn != null && shell.backgroundLiveness === "working") + ); } /** @@ -139,15 +167,9 @@ export function effectiveSnoozed( // fields clear together on wake, so a lone snoozedAt is never stale — // but malformed data never hides a thread, same as the timed branch. if (shell.snoozedAt == null || Number.isNaN(Date.parse(shell.snoozedAt))) return false; - // "Until it's done": snoozed only while the awaited turn is still the - // running latest turn. Any other shape (ended, replaced, dropped) - // wakes — the raised-hand rule below reports the same for an ended - // turn, but a replaced or missing turn needs this check. - if ( - shell.snoozedUntilTurnId != null && - (shell.latestTurn?.turnId !== shell.snoozedUntilTurnId || - shell.latestTurn.state !== "running") - ) { + // "Until it's done": snoozed only while the awaited work continues. + // Any other shape (ended and quiet, replaced, dropped) wakes. + if (shell.snoozedUntilTurnId != null && !untilDoneWorkContinues(shell)) { return false; } return !threadRaisedHandWhileSnoozed(shell); @@ -206,10 +228,10 @@ export function threadWokeAt( // placeholder stamp from before the snooze). Either way the thread is // awake and the Woke pill needs a time. if (shell.snoozedUntilTurnId != null) { + if (untilDoneWorkContinues(shell)) return null; if (shell.latestTurn?.turnId !== shell.snoozedUntilTurnId) { return shell.latestTurn?.requestedAt ?? shell.snoozedAt ?? null; } - if (shell.latestTurn.state === "running") return null; return shell.session?.updatedAt ?? shell.snoozedAt ?? null; } // No raised hand: an indefinite snooze is simply still snoozed; a timed @@ -238,20 +260,20 @@ export interface SnoozePreset { readonly whenLabel: string; /** ISO wake time, or null for a condition-based preset. */ readonly snoozedUntil: string | null; - /** "Until it's done": wake when the running turn ends. */ + /** "Until it's done": wake when the running turn and its subagents finish. */ readonly untilDone?: true; } /** * The "until it's done" preset: hides a working thread until its running - * turn ends. Listed first because it is the one choice that is about the + * turn and the subagents it started finish. Listed first because it is the one choice that is about the * thread rather than the clock. Callers gate it on canSnoozeUntilDone and * the threadSnoozeUntilDone capability. */ export const SNOOZE_UNTIL_DONE_PRESET: SnoozePreset = { id: "until-done", label: "Until it's done", - whenLabel: "when the turn ends", + whenLabel: "when the work ends", snoozedUntil: null, untilDone: true, }; diff --git a/packages/client-runtime/src/state/threadSnoozed.test.ts b/packages/client-runtime/src/state/threadSnoozed.test.ts index cbaa0e683a78..4d0f1dd46e9b 100644 --- a/packages/client-runtime/src/state/threadSnoozed.test.ts +++ b/packages/client-runtime/src/state/threadSnoozed.test.ts @@ -34,6 +34,7 @@ function makeShell(input: { readonly turnCompletedAt?: string | null; readonly turnId?: string; readonly turnState?: "running" | "interrupted" | "completed" | "error"; + readonly backgroundLiveness?: "working" | "monitoring"; }): ThreadSnoozeShell { const threadId = ThreadId.make("thread-1"); return { @@ -66,6 +67,7 @@ function makeShell(input: { completedAt: input.turnCompletedAt ?? null, assistantMessageId: null, }, + backgroundLiveness: input.backgroundLiveness ?? null, }; } @@ -220,6 +222,30 @@ describe("effectiveSnoozed", () => { expect(effectiveSnoozed(makeShell({ ...UNTIL_DONE }), { now: NOW })).toBe(false); }); + it("keeps an until-done snooze hidden while subagents outlive the ended turn", () => { + const ended = { ...UNTIL_DONE, turnCompletedAt: "2026-04-10T10:30:00.000Z" } as const; + const working = makeShell({ ...ended, backgroundLiveness: "working" }); + expect(effectiveSnoozed(working, { now: NOW })).toBe(true); + expect(threadRaisedHandWhileSnoozed(working)).toBe(false); + expect(threadWokeAt(working, { now: NOW })).toBeNull(); + + // Watch loops alone (a dev server) don't hold it; quiet work wakes it. + for (const backgroundLiveness of ["monitoring", undefined] as const) { + const quiet = makeShell({ ...ended, ...(backgroundLiveness ? { backgroundLiveness } : {}) }); + expect(effectiveSnoozed(quiet, { now: NOW })).toBe(false); + expect(threadWokeAt(quiet, { now: NOW })).toBe("2026-04-10T10:30:00.000Z"); + } + // Blocked-on-you work still wakes it. + expect( + effectiveSnoozed( + makeShell({ ...ended, backgroundLiveness: "working", pending: "approval" }), + { + now: NOW, + }, + ), + ).toBe(false); + }); + it("ignores runs that completed before the snooze — the user saw that result", () => { expect( effectiveSnoozed( @@ -292,10 +318,16 @@ describe("canSnooze", () => { }); describe("canSnoozeUntilDone", () => { - it("requires a running turn", () => { + it("requires a running turn or working subagents", () => { expect(canSnoozeUntilDone(makeShell({ turnState: "running" }))).toBe(true); expect(canSnoozeUntilDone(makeShell({}))).toBe(false); expect(canSnoozeUntilDone(makeShell({ turnCompletedAt: NOW }))).toBe(false); + expect( + canSnoozeUntilDone(makeShell({ turnCompletedAt: NOW, backgroundLiveness: "working" })), + ).toBe(true); + expect( + canSnoozeUntilDone(makeShell({ turnCompletedAt: NOW, backgroundLiveness: "monitoring" })), + ).toBe(false); }); }); From 8bbcab20ad3f1956a4d58d1bc6bedbc02ad053b8 Mon Sep 17 00:00:00 2001 From: pandec Date: Sat, 26 Sep 2026 09:08:06 +0200 Subject: [PATCH 2/4] fix(server): keep subagent liveness until its notification opens the follow-up turn Claude sends a terminal task_updated right before task_notification, which cleared liveness before the snooze could move. Restart-orphan notifications no longer open a turn. --- .../src/provider/Layers/ClaudeAdapter.test.ts | 38 ++++++++++++++++--- .../src/provider/Layers/ClaudeAdapter.ts | 14 ++++--- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 95ec973b93a5..e5696a5326df 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -5040,9 +5040,10 @@ describe("ClaudeAdapterLive", () => { (event) => event.type === "turn.started" || event.type === "turn.completed" || + event.type === "task.updated" || event.type === "task.completed", ), - Stream.take(7), + Stream.take(9), Stream.runCollect, Effect.forkChild, ); @@ -5097,13 +5098,24 @@ describe("ClaudeAdapterLive", () => { session_id: "sdk-session", } as unknown as SDKMessage); emitResult("result-user-turn"); - // The main agent answers the finished agent: one turn, opened by the - // notification before the task completes, and kept by the answer. + // The CLI sends a terminal patch before its notification. It must not + // clear liveness before the follow-up turn can inherit the snooze. + harness.query.emit({ + type: "system", + subtype: "task_updated", + task_id: "task-bg", + patch: { status: "completed", end_time: 1234 }, + uuid: "task-bg-updated", + session_id: "sdk-session", + } as unknown as SDKMessage); emitNotification("task-bg"); emitAssistant("assistant-follow-up"); emitResult("result-follow-up"); // Ambient work never reaches the main agent: no turn until output. emitNotification("task-ambient", { ambient: true }); + // Restart orphan notifications report old work; they do not run the + // main agent. Opening a turn here would leave it running without output. + emitNotification("task-orphan", { status: "stopped", reason: "worker_restart" }); emitAssistant("assistant-unprompted"); const events = Array.from(yield* Fiber.join(lifecycleFiber)); @@ -5112,16 +5124,32 @@ describe("ClaudeAdapterLive", () => { [ "turn.started", "turn.completed", + "task.updated", "turn.started", "task.completed", "turn.completed", "task.completed", + "task.completed", "turn.started", ], ); - const followUp = events[2]; + const terminalPatch = events[2]; + assert.equal(terminalPatch?.type, "task.updated"); + if (terminalPatch?.type === "task.updated") { + assert.equal(terminalPatch.payload.status, undefined); + assert.equal(terminalPatch.payload.endedAt, "1970-01-01T00:00:01.234Z"); + } + const followUp = events[3]; assert.equal(followUp?.raw?.method, "claude/synthetic-turn-start"); - assert.equal(events[4]?.turnId, followUp?.turnId); + assert.equal(events[5]?.turnId, followUp?.turnId); + const sessions = yield* adapter.listSessions(); + assert.deepEqual(sessions[0]?.resumeCursor, { + threadId: THREAD_ID, + resume: "sdk-session", + resumeSessionAt: "assistant-unprompted", + turnCount: 3, + turnStartMessageIds: [events[0]?.turnId, "assistant-follow-up", "assistant-unprompted"], + }); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 754d4b7994de..097f4d4e26fe 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -4291,9 +4291,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const patch = message.patch; const status = patch.status !== undefined ? CLAUDE_TASK_PATCH_STATUS[patch.status] : undefined; - if (status === "completed" || status === "failed" || status === "cancelled") { - context.liveTaskIds.delete(message.task_id); - } + // The CLI emits terminal patches immediately before task_notification. + // Let the notification own terminal status so background liveness stays + // live until that handler opens the main agent's follow-up turn. + const terminal = status === "completed" || status === "failed" || status === "cancelled"; const endedAt = typeof patch.end_time === "number" && Number.isFinite(patch.end_time) ? DateTime.formatIso(DateTime.makeUnsafe(patch.end_time)) @@ -4303,7 +4304,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( type: "task.updated", payload: { taskId: RuntimeTaskId.make(message.task_id), - ...(status ? { status } : {}), + ...(status && !terminal ? { status } : {}), ...(patch.description ? { description: patch.description } : {}), ...(patch.error ? { error: patch.error } : {}), ...(endedAt ? { endedAt } : {}), @@ -4321,10 +4322,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( // at its first assistant message, and before task.completed, so the // thread never looks idle between the work ending and the answer: // an "until it's done" snooze follows the work across that edge. - // Transcript-skipped (ambient) tasks and tasks owned by a subagent - // never reach the main agent. + // Ambient tasks, subagent-owned tasks, and restart orphan reports + // do not trigger a main-agent turn. if ( !context.turnState && + message.reason !== "worker_restart" && message.skip_transcript !== true && message.ambient !== true && context.taskAgents.get(message.task_id)?.owningAgentId === undefined From fdef362b4eedf1d0aeb3b2ec91108f601480bb15 Mon Sep 17 00:00:00 2001 From: pandec Date: Sat, 26 Sep 2026 09:28:48 +0200 Subject: [PATCH 3/4] fix(server): release a held terminal task status when no notification follows Only idle-time main-agent patches are held; in-turn patches settle at once as before. A task_notification claims the held status, otherwise it is emitted after two seconds. --- .../src/provider/Layers/ClaudeAdapter.test.ts | 62 ++++++++++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 74 +++++++++++++++++-- 2 files changed, 131 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index e5696a5326df..14b5403f0dba 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -5156,6 +5156,68 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("releases a held terminal task status when no notification follows", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const taskUpdates = () => + adapter.streamEvents.pipe( + Stream.filter((event) => event.type === "task.updated"), + Stream.take(1), + Stream.runCollect, + Effect.forkChild, + ); + const heldFiber = yield* taskUpdates(); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId: session.threadId, input: "go", attachments: [] }); + harness.query.emit({ + type: "system", + subtype: "task_started", + task_id: "task-bg", + description: "Background agent", + task_type: "local_agent", + uuid: "task-bg-uuid", + session_id: "sdk-session", + } as unknown as SDKMessage); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session", + uuid: "result-user-turn", + } as unknown as SDKMessage); + harness.query.emit({ + type: "system", + subtype: "task_updated", + task_id: "task-bg", + patch: { status: "failed" }, + uuid: "task-bg-updated", + session_id: "sdk-session", + } as unknown as SDKMessage); + + const held = Array.from(yield* Fiber.join(heldFiber))[0]; + assert.equal(held?.type === "task.updated" ? held.payload.status : "missing", undefined); + + const releasedFiber = yield* taskUpdates(); + yield* Effect.yieldNow; + yield* TestClock.adjust("2 seconds"); + const released = Array.from(yield* Fiber.join(releasedFiber))[0]; + assert.equal( + released?.type === "task.updated" ? released.payload.status : "missing", + "failed", + ); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("keeps the session available when process close fails", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 097f4d4e26fe..eaab1e26386f 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -86,6 +86,7 @@ import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; @@ -499,6 +500,11 @@ interface ClaudeSessionContext { readonly workflowMemberFingerprints: Map; /** Task ids that have started and not yet reached a terminal state. */ readonly liveTaskIds: Set; + /** Terminal statuses of idle-time main-agent tasks, held until their + * task_notification (or a short fallback) settles them. */ + readonly heldTerminalTaskStatuses: Map; + /** Runs a detached effect in the session's runtime. */ + readonly forkDetached: (effect: Effect.Effect) => void; turnState: ClaudeTurnState | undefined; lastKnownContextWindow: number | undefined; lastKnownTokenUsage: ThreadTokenUsageSnapshot | undefined; @@ -1494,6 +1500,9 @@ function taskLinkageFor( }; } +/** How long an idle-time terminal task patch waits for its task_notification. */ +const HELD_TERMINAL_TASK_GRACE = Duration.seconds(2); + const WORKFLOW_PHASE_CAP = 64; const WORKFLOW_AGENT_CAP = 100; @@ -3769,6 +3778,34 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } }); + /** Emits a held terminal task status whose task_notification never came. */ + const releaseHeldTerminalTask = Effect.fn("releaseHeldTerminalTask")(function* ( + context: ClaudeSessionContext, + taskId: string, + ) { + const status = context.heldTerminalTaskStatuses.get(taskId); + if (context.stopped || status === undefined) { + return; + } + context.heldTerminalTaskStatuses.delete(taskId); + context.liveTaskIds.delete(taskId); + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "task.updated", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + providerRefs: nativeProviderRefs(context), + payload: { + taskId: RuntimeTaskId.make(taskId), + status, + ...taskLinkageFor(context.taskAgents, taskId), + }, + }); + }); + /** * Opens a synthetic turn for main-agent work that no user prompt started. * A null boundary is filled from the turn's first assistant message. @@ -4291,10 +4328,32 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const patch = message.patch; const status = patch.status !== undefined ? CLAUDE_TASK_PATCH_STATUS[patch.status] : undefined; - // The CLI emits terminal patches immediately before task_notification. - // Let the notification own terminal status so background liveness stays - // live until that handler opens the main agent's follow-up turn. - const terminal = status === "completed" || status === "failed" || status === "cancelled"; + const terminalStatus = + status === "completed" || status === "failed" || status === "cancelled" + ? status + : undefined; + // Between prompts, the CLI sends a main-agent task's terminal patch + // right before the task_notification that opens the follow-up turn. + // Hold the terminal status back so liveness stays live across that + // edge. The notification settles it; a patch that never gets one is + // released after a short grace. + const holdTerminal = + terminalStatus !== undefined && + !context.turnState && + context.taskAgents.get(message.task_id)?.owningAgentId === undefined; + if (holdTerminal) { + context.heldTerminalTaskStatuses.set(message.task_id, terminalStatus); + context.forkDetached( + Effect.sleep(HELD_TERMINAL_TASK_GRACE).pipe( + Effect.andThen(releaseHeldTerminalTask(context, message.task_id)), + Effect.catch((cause) => + Effect.logWarning("Failed to release a held Claude task status.", { cause }), + ), + ), + ); + } else if (terminalStatus !== undefined) { + context.liveTaskIds.delete(message.task_id); + } const endedAt = typeof patch.end_time === "number" && Number.isFinite(patch.end_time) ? DateTime.formatIso(DateTime.makeUnsafe(patch.end_time)) @@ -4304,7 +4363,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( type: "task.updated", payload: { taskId: RuntimeTaskId.make(message.task_id), - ...(status && !terminal ? { status } : {}), + ...(status && !holdTerminal ? { status } : {}), ...(patch.description ? { description: patch.description } : {}), ...(patch.error ? { error: patch.error } : {}), ...(endedAt ? { endedAt } : {}), @@ -4333,6 +4392,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ) { yield* startSyntheticTurn(context, null); } + context.heldTerminalTaskStatuses.delete(message.task_id); context.liveTaskIds.delete(message.task_id); yield* emitThreadTokenUsage( context, @@ -5600,6 +5660,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( pendingTaskModels, workflowMemberFingerprints, liveTaskIds, + heldTerminalTaskStatuses: new Map(), + forkDetached: (effect) => { + runFork(effect); + }, turnState: undefined, lastKnownContextWindow: initialContextWindow, lastKnownTokenUsage: undefined, From e35c7ba1651339ea99a460eca918d1fcbf6bd6b2 Mon Sep 17 00:00:00 2001 From: pandec Date: Sat, 26 Sep 2026 10:19:06 +0200 Subject: [PATCH 4/4] fix(server): keep the provider end time on a released task status --- .../src/provider/Layers/ClaudeAdapter.test.ts | 7 +++++- .../src/provider/Layers/ClaudeAdapter.ts | 25 ++++++++++++------- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 14b5403f0dba..5519b23d6253 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -5196,7 +5196,7 @@ describe("ClaudeAdapterLive", () => { type: "system", subtype: "task_updated", task_id: "task-bg", - patch: { status: "failed" }, + patch: { status: "failed", end_time: 1234 }, uuid: "task-bg-updated", session_id: "sdk-session", } as unknown as SDKMessage); @@ -5212,6 +5212,11 @@ describe("ClaudeAdapterLive", () => { released?.type === "task.updated" ? released.payload.status : "missing", "failed", ); + // The provider's end time travels with the released status. + assert.equal( + released?.type === "task.updated" ? released.payload.endedAt : "missing", + "1970-01-01T00:00:01.234Z", + ); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index eaab1e26386f..da1af3957c72 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -502,7 +502,10 @@ interface ClaudeSessionContext { readonly liveTaskIds: Set; /** Terminal statuses of idle-time main-agent tasks, held until their * task_notification (or a short fallback) settles them. */ - readonly heldTerminalTaskStatuses: Map; + readonly heldTerminalTaskStatuses: Map< + string, + { readonly status: "completed" | "failed" | "cancelled"; readonly endedAt?: string } + >; /** Runs a detached effect in the session's runtime. */ readonly forkDetached: (effect: Effect.Effect) => void; turnState: ClaudeTurnState | undefined; @@ -3783,8 +3786,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( context: ClaudeSessionContext, taskId: string, ) { - const status = context.heldTerminalTaskStatuses.get(taskId); - if (context.stopped || status === undefined) { + const held = context.heldTerminalTaskStatuses.get(taskId); + if (context.stopped || held === undefined) { return; } context.heldTerminalTaskStatuses.delete(taskId); @@ -3800,7 +3803,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( providerRefs: nativeProviderRefs(context), payload: { taskId: RuntimeTaskId.make(taskId), - status, + status: held.status, + ...(held.endedAt ? { endedAt: held.endedAt } : {}), ...taskLinkageFor(context.taskAgents, taskId), }, }); @@ -4337,12 +4341,19 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( // Hold the terminal status back so liveness stays live across that // edge. The notification settles it; a patch that never gets one is // released after a short grace. + const endedAt = + typeof patch.end_time === "number" && Number.isFinite(patch.end_time) + ? DateTime.formatIso(DateTime.makeUnsafe(patch.end_time)) + : undefined; const holdTerminal = terminalStatus !== undefined && !context.turnState && context.taskAgents.get(message.task_id)?.owningAgentId === undefined; if (holdTerminal) { - context.heldTerminalTaskStatuses.set(message.task_id, terminalStatus); + context.heldTerminalTaskStatuses.set(message.task_id, { + status: terminalStatus, + ...(endedAt ? { endedAt } : {}), + }); context.forkDetached( Effect.sleep(HELD_TERMINAL_TASK_GRACE).pipe( Effect.andThen(releaseHeldTerminalTask(context, message.task_id)), @@ -4354,10 +4365,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } else if (terminalStatus !== undefined) { context.liveTaskIds.delete(message.task_id); } - const endedAt = - typeof patch.end_time === "number" && Number.isFinite(patch.end_time) - ? DateTime.formatIso(DateTime.makeUnsafe(patch.end_time)) - : undefined; yield* offerRuntimeEvent({ ...base, type: "task.updated",