From ddd1569e45e0863340811b1f6d64edf40c3bf402 Mon Sep 17 00:00:00 2001 From: Adam Firestone Date: Tue, 11 Aug 2026 11:31:20 -0500 Subject: [PATCH 01/18] feat(web,server): single-Esc retracts a just-sent message before assistant output Pressing Esc between send and first assistant output interrupts the turn, reverts to the pre-turn checkpoint, and restores the message into the composer. Claude provider rollback was previously cosmetic: resumeSessionAt was never passed to the SDK and live sessions kept rolled-back history. Rollback now maintains per-turn assistant-uuid watermarks, clears the resume cursor at turn zero, and recycles the session before the next turn. Built with Claude Fable 5 via Claude Code, implementation by Codex (GPT-5.6-Sol). --- .../Layers/CheckpointReactor.test.ts | 81 +++++ .../orchestration/Layers/CheckpointReactor.ts | 10 +- .../src/provider/Layers/ClaudeAdapter.test.ts | 257 ++++++++++----- .../src/provider/Layers/ClaudeAdapter.ts | 26 +- apps/web/src/components/ChatView.tsx | 294 +++++++++++++++--- .../src/components/ComposerPromptEditor.tsx | 13 +- apps/web/src/components/chat/ChatComposer.tsx | 19 +- .../chat/lastUserMessagePop.test.ts | 244 +++++++++++++++ .../src/components/chat/lastUserMessagePop.ts | 130 ++++++++ docs/user/keybindings.md | 5 + .../src/state/threadReducer.test.ts | 67 ++++ .../client-runtime/src/state/threadReducer.ts | 64 +++- 12 files changed, 1083 insertions(+), 127 deletions(-) create mode 100644 apps/web/src/components/chat/lastUserMessagePop.test.ts create mode 100644 apps/web/src/components/chat/lastUserMessagePop.ts diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 08ea1437bb29..5431e78fc8d9 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -148,12 +148,14 @@ async function waitForThread( readonly latestTurn: { readonly turnId: string } | null; readonly checkpoints: ReadonlyArray<{ readonly checkpointTurnCount: number }>; readonly activities: ReadonlyArray<{ readonly kind: string }>; + readonly messages: ReadonlyArray; }>; }>, predicate: (thread: { latestTurn: { turnId: string } | null; checkpoints: ReadonlyArray<{ checkpointTurnCount: number }>; activities: ReadonlyArray<{ kind: string }>; + messages: ReadonlyArray; }) => boolean, timeoutMs = 15_000, ) { @@ -162,6 +164,7 @@ async function waitForThread( latestTurn: { turnId: string } | null; checkpoints: ReadonlyArray<{ checkpointTurnCount: number }>; activities: ReadonlyArray<{ kind: string }>; + messages: ReadonlyArray; }> => { const snapshot = await readModel(); const thread = snapshot.threads.find((entry) => entry.id === ThreadId.make("thread-1")); @@ -1151,6 +1154,84 @@ describe("CheckpointReactor", () => { }); }); + it("rolls back an interrupted first turn before its completion checkpoint lands", async () => { + const harness = await createHarness(); + const createdAt = "2026-01-01T00:00:00.000Z"; + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-start-uncheckpointed-turn"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: MessageId.make("message-uncheckpointed-turn"), + role: "user", + text: "Let me edit this", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt, + }), + ); + await harness.drain(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-running-uncheckpointed-turn"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: asTurnId("turn-uncheckpointed"), + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }), + ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-settled-uncheckpointed-turn"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "ready", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.checkpoint.revert", + commandId: CommandId.make("cmd-revert-uncheckpointed-turn"), + threadId: ThreadId.make("thread-1"), + turnCount: 0, + createdAt, + }), + ); + + await waitForEvent(harness.engine, (event) => event.type === "thread.reverted"); + const thread = await waitForThread(harness.readModel, (entry) => entry.messages.length === 0); + + expect(thread.checkpoints).toHaveLength(0); + expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1); + expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({ + threadId: ThreadId.make("thread-1"), + numTurns: 1, + }); + }); + it("processes consecutive revert requests with deterministic rollback sequencing", async () => { const harness = await createHarness(); const createdAt = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 95adee0cf7f8..b3fc56f18f68 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -774,7 +774,15 @@ const make = Effect.gen(function* () { // reflects the reverted filesystem state. yield* workspaceEntries.refresh(sessionRuntime.value.cwd); - const rolledBackTurns = Math.max(0, currentTurnCount - event.payload.turnCount); + // A just-interrupted turn is already present in Claude/Codex provider + // history, but its completion checkpoint can still be in flight. + const hasSettledUncheckpointedLatestTurn = + thread.latestTurn !== null && + thread.latestTurn.state !== "running" && + !thread.checkpoints.some((checkpoint) => checkpoint.turnId === thread.latestTurn?.turnId); + const currentConversationTurnCount = + currentTurnCount + (hasSettledUncheckpointedLatestTurn ? 1 : 0); + const rolledBackTurns = Math.max(0, currentConversationTurnCount - event.payload.turnCount); if (rolledBackTurns > 0) { yield* providerService.rollbackConversation({ threadId: sessionRuntime.value.threadId, diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 711b0f6f6aa3..39742f761f3a 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -161,8 +161,14 @@ function makeHarness(config?: { readonly baseDir?: string; readonly claudeConfig?: Partial; readonly instanceId?: ProviderInstanceId; + readonly queryFactory?: () => FakeClaudeQuery; }) { const query = new FakeClaudeQuery(); + const queries: Array = []; + const createInputs: Array<{ + readonly prompt: AsyncIterable; + readonly options: ClaudeQueryOptions; + }> = []; let createInput: | { readonly prompt: AsyncIterable; @@ -174,7 +180,10 @@ function makeHarness(config?: { ...(config?.instanceId ? { instanceId: config.instanceId } : {}), createQuery: (input) => { createInput = input; - return query; + createInputs.push(input); + const nextQuery = config?.queryFactory?.() ?? query; + queries.push(nextQuery); + return nextQuery; }, ...(config?.nativeEventLogger ? { @@ -206,7 +215,9 @@ function makeHarness(config?: { Layer.provideMerge(NodeServices.layer), ), query, + queries, getLastCreateQueryInput: () => createInput, + getCreateQueryInputs: () => createInputs, }; } @@ -3362,7 +3373,7 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("passes Claude resume ids without pinning a stale assistant checkpoint", () => { + it.effect("passes Claude resume ids and assistant checkpoint to the SDK", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; @@ -3390,7 +3401,7 @@ describe("ClaudeAdapterLive", () => { const createInput = harness.getLastCreateQueryInput(); assert.equal(createInput?.options.resume, "550e8400-e29b-41d4-a716-446655440000"); assert.equal(createInput?.options.sessionId, undefined); - assert.equal(createInput?.options.resumeSessionAt, undefined); + assert.equal(createInput?.options.resumeSessionAt, "assistant-99"); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), @@ -3519,87 +3530,189 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect( - "supports rollbackThread by trimming in-memory turns and preserving earlier turns", - () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const adapter = yield* ClaudeAdapter; + it.effect("rolls the resume cursor back to the retained turn watermark", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; - const session = yield* adapter.startSession({ - threadId: THREAD_ID, - provider: ProviderDriverKind.make("claudeAgent"), - runtimeMode: "full-access", - }); + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); - const firstTurn = yield* adapter.sendTurn({ - threadId: session.threadId, - input: "first", - attachments: [], - }); + const firstTurn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "first", + attachments: [], + }); - const firstCompletedFiber = yield* Stream.filter( - adapter.streamEvents, - (event) => event.type === "turn.completed", - ).pipe(Stream.runHead, Effect.forkChild); + const firstCompletedFiber = yield* Stream.filter( + adapter.streamEvents, + (event) => event.type === "turn.completed", + ).pipe(Stream.runHead, Effect.forkChild); - harness.query.emit({ - type: "result", - subtype: "success", - is_error: false, - errors: [], - session_id: "sdk-session-rollback", - uuid: "result-first", - } as unknown as SDKMessage); + harness.query.emit({ + type: "assistant", + session_id: "550e8400-e29b-41d4-a716-446655440000", + uuid: "assistant-first", + parent_tool_use_id: null, + message: { + id: "assistant-message-first", + content: [{ type: "text", text: "first response" }], + }, + } as unknown as SDKMessage); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "550e8400-e29b-41d4-a716-446655440000", + uuid: "result-first", + } as unknown as SDKMessage); - const firstCompleted = yield* Fiber.join(firstCompletedFiber); - assert.equal(firstCompleted._tag, "Some"); - if (firstCompleted._tag === "Some" && firstCompleted.value.type === "turn.completed") { - assert.equal(String(firstCompleted.value.turnId), String(firstTurn.turnId)); - } + const firstCompleted = yield* Fiber.join(firstCompletedFiber); + assert.equal(firstCompleted._tag, "Some"); + if (firstCompleted._tag === "Some" && firstCompleted.value.type === "turn.completed") { + assert.equal(String(firstCompleted.value.turnId), String(firstTurn.turnId)); + } - const secondTurn = yield* adapter.sendTurn({ - threadId: session.threadId, - input: "second", - attachments: [], - }); + const secondTurn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "second", + attachments: [], + }); - const secondCompletedFiber = yield* Stream.filter( - adapter.streamEvents, - (event) => event.type === "turn.completed", - ).pipe(Stream.runHead, Effect.forkChild); + const secondCompletedFiber = yield* Stream.filter( + adapter.streamEvents, + (event) => event.type === "turn.completed", + ).pipe(Stream.runHead, Effect.forkChild); - harness.query.emit({ - type: "result", - subtype: "success", - is_error: false, - errors: [], - session_id: "sdk-session-rollback", - uuid: "result-second", - } as unknown as SDKMessage); + harness.query.emit({ + type: "assistant", + session_id: "550e8400-e29b-41d4-a716-446655440000", + uuid: "assistant-second", + parent_tool_use_id: null, + message: { + id: "assistant-message-second", + content: [{ type: "text", text: "second response" }], + }, + } as unknown as SDKMessage); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "550e8400-e29b-41d4-a716-446655440000", + uuid: "result-second", + } as unknown as SDKMessage); - const secondCompleted = yield* Fiber.join(secondCompletedFiber); - assert.equal(secondCompleted._tag, "Some"); - if (secondCompleted._tag === "Some" && secondCompleted.value.type === "turn.completed") { - assert.equal(String(secondCompleted.value.turnId), String(secondTurn.turnId)); - } + const secondCompleted = yield* Fiber.join(secondCompletedFiber); + assert.equal(secondCompleted._tag, "Some"); + if (secondCompleted._tag === "Some" && secondCompleted.value.type === "turn.completed") { + assert.equal(String(secondCompleted.value.turnId), String(secondTurn.turnId)); + } - const threadBeforeRollback = yield* adapter.readThread(session.threadId); - assert.equal(threadBeforeRollback.turns.length, 2); + const threadBeforeRollback = yield* adapter.readThread(session.threadId); + assert.equal(threadBeforeRollback.turns.length, 2); - const rolledBack = yield* adapter.rollbackThread(session.threadId, 1); - assert.equal(rolledBack.turns.length, 1); - assert.equal(rolledBack.turns[0]?.id, firstTurn.turnId); + const rolledBack = yield* adapter.rollbackThread(session.threadId, 1); + assert.equal(rolledBack.turns.length, 1); + assert.equal(rolledBack.turns[0]?.id, firstTurn.turnId); - const threadAfterRollback = yield* adapter.readThread(session.threadId); - assert.equal(threadAfterRollback.turns.length, 1); - assert.equal(threadAfterRollback.turns[0]?.id, firstTurn.turnId); - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }, - ); + const activeSessions = yield* adapter.listSessions(); + assert.deepEqual(activeSessions[0]?.resumeCursor, { + threadId: THREAD_ID, + resume: "550e8400-e29b-41d4-a716-446655440000", + resumeSessionAt: "assistant-first", + turnCount: 1, + }); + + const threadAfterRollback = yield* adapter.readThread(session.threadId); + assert.equal(threadAfterRollback.turns.length, 1); + assert.equal(threadAfterRollback.turns[0]?.id, firstTurn.turnId); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("clears resume state at turn zero and recycles before the next prompt", () => { + const harness = makeHarness({ + queryFactory: () => new FakeClaudeQuery(), + }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + const firstQuery = harness.queries[0]; + assert.isDefined(firstQuery); + if (!firstQuery) { + return; + } + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "discard me", + attachments: [], + }); + const completedFiber = yield* Stream.filter( + adapter.streamEvents, + (event) => event.type === "turn.completed", + ).pipe(Stream.runHead, Effect.forkChild); + + firstQuery.emit({ + type: "assistant", + session_id: "550e8400-e29b-41d4-a716-446655440000", + uuid: "assistant-discarded", + parent_tool_use_id: null, + message: { + id: "assistant-message-discarded", + content: [{ type: "text", text: "discarded response" }], + }, + } as unknown as SDKMessage); + firstQuery.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "550e8400-e29b-41d4-a716-446655440000", + uuid: "result-discarded", + } as unknown as SDKMessage); + yield* Fiber.join(completedFiber); + + yield* adapter.rollbackThread(session.threadId, 1); + const rolledBackSessions = yield* adapter.listSessions(); + assert.deepEqual(rolledBackSessions[0]?.resumeCursor, { + threadId: THREAD_ID, + turnCount: 0, + }); + assert.equal(yield* adapter.hasSession(THREAD_ID), true); + assert.equal(firstQuery.closeCalls, 0); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "replacement", + attachments: [], + }); + + assert.equal(harness.queries.length, 2); + assert.equal(firstQuery.closeCalls, 1); + assert.equal(yield* adapter.hasSession(THREAD_ID), true); + const restartInput = harness.getCreateQueryInputs()[1]; + assert.equal(restartInput?.options.resume, undefined); + assert.equal(restartInput?.options.resumeSessionAt, undefined); + assert.equal(typeof restartInput?.options.sessionId, "string"); + const replacementPrompt = yield* Effect.promise(() => readFirstPromptText(restartInput)); + assert.equal(replacementPrompt, "replacement"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); it.effect("updates model on sendTurn when model override is provided", () => { const harness = makeHarness(); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index b1fa74e81bda..bf0049772a17 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -36,6 +36,7 @@ import { type ProviderRuntimeTurnStatus, type ProviderSendTurnInput, type ProviderSession, + type ProviderSessionStartInput, type ThreadTokenUsageSnapshot, type ProviderUserInputAnswers, type RuntimeContentStreamKind, @@ -225,6 +226,7 @@ interface ClaudeSessionContext { readonly turns: Array<{ id: TurnId; items: Array; + lastAssistantUuid: string | undefined; }>; readonly inFlightTools: Map; readonly claudeTasks: Map; @@ -245,6 +247,8 @@ interface ClaudeSessionContext { lastKnownTotalProcessedTokens: number | undefined; lastAssistantUuid: string | undefined; lastThreadStartedId: string | undefined; + restartInput: ProviderSessionStartInput; + recycleBeforeNextTurn: boolean; stopped: boolean; } @@ -2315,6 +2319,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( context.turns.push({ id: turnState.turnId, items: [...turnState.items], + lastAssistantUuid: context.lastAssistantUuid, }); yield* emitThreadTokenUsage(context, usageSnapshot, { @@ -4117,6 +4122,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( : {}), ...(Object.keys(settings).length > 0 ? { settings } : {}), ...(existingResumeSessionId ? { resume: existingResumeSessionId } : {}), + ...(existingResumeSessionId && resumeState?.resumeSessionAt + ? { resumeSessionAt: resumeState.resumeSessionAt } + : {}), ...(newSessionId ? { sessionId: newSessionId } : {}), includePartialMessages: true, canUseTool, @@ -4221,6 +4229,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( lastKnownTotalProcessedTokens: undefined, lastAssistantUuid: resumeState?.resumeSessionAt, lastThreadStartedId: undefined, + restartInput: input, + recycleBeforeNextTurn: false, stopped: false, }; yield* Ref.set(contextRef, context); @@ -4301,7 +4311,15 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ); const sendTurn: ClaudeAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) { - const context = yield* requireSession(input.threadId); + let context = yield* requireSession(input.threadId); + if (context.recycleBeforeNextTurn) { + yield* startSession({ + ...context.restartInput, + ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}), + resumeCursor: context.session.resumeCursor, + }); + context = yield* requireSession(input.threadId); + } const modelSelection = input.modelSelection !== undefined && input.modelSelection.instanceId === boundInstanceId ? input.modelSelection @@ -4484,6 +4502,12 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const context = yield* requireSession(threadId); const nextLength = Math.max(0, context.turns.length - numTurns); context.turns.splice(nextLength); + const retainedTurn = context.turns.at(-1); + context.lastAssistantUuid = retainedTurn?.lastAssistantUuid; + if (!retainedTurn) { + context.resumeSessionId = undefined; + } + context.recycleBeforeNextTurn = true; yield* updateResumeCursor(context); return yield* snapshotThread(context); }, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 762dac559f21..e15ca85baf28 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -19,6 +19,7 @@ import { OrchestrationThreadActivity, ProviderInteractionMode, ProviderDriverKind, + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, RuntimeMode, TerminalOpenInput, } from "@t3tools/contracts"; @@ -244,6 +245,15 @@ import { } from "../state/entities"; import { environmentShell } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; +import { + captureLastUserMessageImages, + deriveLastUserMessageRestoredText, + findLastUserMessagePopCandidate, + IMAGE_ONLY_MESSAGE_PLACEHOLDER, + isLastUserMessagePopWindowOpen, + LAST_USER_MESSAGE_POP_SETTLE_TIMEOUT_MS, + mergePoppedPrompt, +} from "./chat/lastUserMessagePop"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; @@ -338,8 +348,6 @@ import { } from "../versionSkew"; import { useAssetUrls } from "../assets/assetUrls"; -const IMAGE_ONLY_BOOTSTRAP_PROMPT = - "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; @@ -1332,6 +1340,7 @@ function ChatViewContent(props: ChatViewProps) { >({}); const [isConnecting, _setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); + const [isPoppingLastUserMessage, setIsPoppingLastUserMessage] = useState(false); const [maximizedRightPanelThreadKey, setMaximizedRightPanelThreadKey] = useState( null, ); @@ -2151,6 +2160,48 @@ function ChatViewContent(props: ChatViewProps) { ); const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; const phase = derivePhase(activeThread?.session ?? null); + const runningTurnStateRef = useRef({ threadId: activeThread?.id ?? null, phase }); + runningTurnStateRef.current = { threadId: activeThread?.id ?? null, phase }; + const runningTurnSettlementChecksRef = useRef(new Set<(cancel?: boolean) => void>()); + useEffect(() => { + for (const check of runningTurnSettlementChecksRef.current) { + check(); + } + }, [activeThread?.id, phase]); + useEffect( + () => () => { + for (const check of runningTurnSettlementChecksRef.current) { + check(true); + } + runningTurnSettlementChecksRef.current.clear(); + }, + [], + ); + const waitForRunningTurnToSettle = useCallback((threadId: ThreadId): Promise => { + const current = runningTurnStateRef.current; + if (current.threadId !== threadId) return Promise.resolve(false); + if (current.phase !== "running") return Promise.resolve(true); + + return new Promise((resolve) => { + let timeoutId: number | null = null; + const finish = (settled: boolean) => { + runningTurnSettlementChecksRef.current.delete(check); + if (timeoutId !== null) window.clearTimeout(timeoutId); + resolve(settled); + }; + const check = (cancel = false) => { + const next = runningTurnStateRef.current; + if (cancel || next.threadId !== threadId) { + finish(false); + } else if (next.phase !== "running") { + finish(true); + } + }; + runningTurnSettlementChecksRef.current.add(check); + timeoutId = window.setTimeout(() => finish(false), LAST_USER_MESSAGE_POP_SETTLE_TIMEOUT_MS); + check(); + }); + }, []); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]); const turnPlans = useMemo(() => deriveTurnPlans(threadActivities), [threadActivities]); @@ -2563,6 +2614,26 @@ function ChatViewContent(props: ChatViewProps) { return byUserMessageId; }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]); + const latestCheckpoint = activeThread?.checkpoints.at(-1) ?? null; + const checkpointTurnCount = activeThread?.checkpoints.reduce( + (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount), + 0, + ); + const activeRunningTurnId = activeThread?.session?.activeTurnId ?? null; + const lastUserMessagePopWindowOpen = + latestCheckpoint?.turnId !== activeRunningTurnId && + isLastUserMessagePopWindowOpen({ + phase, + activeTurnId: activeRunningTurnId, + timelineEntries, + }); + const lastUserMessagePopCandidate = lastUserMessagePopWindowOpen + ? findLastUserMessagePopCandidate({ + messages: timelineMessages, + turnCount: checkpointTurnCount ?? 0, + latestCheckpointCompletedAt: latestCheckpoint?.completedAt ?? null, + }) + : null; const gitCwd = activeProject ? projectScriptCwd({ @@ -4770,50 +4841,59 @@ function ChatViewContent(props: ChatViewProps) { ]); const onRevertToTurnCount = useCallback( - async (turnCount: number) => { + async (turnCount: number, options?: { skipConfirm?: boolean }): Promise => { const localApi = readLocalApi(); - if (!localApi || !activeThread || isRevertingCheckpoint) return; + if (!localApi || !activeThread || isRevertingCheckpoint) return false; if (activeEnvironmentUnavailable && activeEnvironmentUnavailableLabel) { setThreadError( activeThread.id, `Reconnect ${activeEnvironmentUnavailableLabel} before reverting checkpoints.`, ); - return; + return false; } if (phase === "running" || isSendBusy || isConnecting) { setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); - return; + return false; } - const confirmed = await localApi.dialogs.confirm( - [ - `Revert this thread to checkpoint ${turnCount}?`, - "This will discard newer messages and turn diffs in this thread.", - "This action cannot be undone.", - ].join("\n"), - { variant: "destructive" }, - ); - if (!confirmed) { - return; + if (!options?.skipConfirm) { + const confirmed = await localApi.dialogs.confirm( + [ + `Revert this thread to checkpoint ${turnCount}?`, + "This will discard newer messages and turn diffs in this thread.", + "This action cannot be undone.", + ].join("\n"), + { variant: "destructive" }, + ); + if (!confirmed) { + return false; + } } setIsRevertingCheckpoint(true); setThreadError(activeThread.id, null); - const result = await revertThreadCheckpoint({ - environmentId, - input: { - threadId: activeThread.id, - turnCount, - }, - }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - setThreadError( - activeThread.id, - error instanceof Error ? error.message : "Failed to revert thread state.", - ); + try { + const result = await revertThreadCheckpoint({ + environmentId, + input: { + threadId: activeThread.id, + turnCount, + }, + }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + setThreadError( + activeThread.id, + error instanceof Error ? error.message : "Failed to revert thread state.", + ); + } + return false; + } + return true; + } finally { + setIsRevertingCheckpoint(false); } - setIsRevertingCheckpoint(false); }, [ activeThread, @@ -5043,7 +5123,7 @@ function ChatViewContent(props: ChatViewProps) { model: ctxSelectedModel, models: ctxSelectedProviderModels, effort: ctxSelectedPromptEffort, - text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, + text: messageTextForSend || IMAGE_ONLY_MESSAGE_PLACEHOLDER, }); const turnAttachmentsPromise = Promise.all( composerImagesSnapshot.map(async (image) => ({ @@ -5282,20 +5362,27 @@ function ChatViewContent(props: ChatViewProps) { } }; - const onInterrupt = async () => { - if (!activeThread) return; + const interruptActiveTurn = useCallback(async (): Promise => { + if (!activeThread) return false; const result = await interruptThreadTurn({ environmentId, input: buildThreadTurnInterruptInput(activeThread), }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - setThreadError( - activeThread.id, - error instanceof Error ? error.message : "Failed to interrupt the current turn.", - ); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + setThreadError( + activeThread.id, + error instanceof Error ? error.message : "Failed to interrupt the current turn.", + ); + } + return false; } - }; + return true; + }, [activeThread, environmentId, interruptThreadTurn, setThreadError]); + const onInterrupt = useCallback(() => { + void interruptActiveTurn(); + }, [interruptActiveTurn]); const onRespondToApproval = useCallback( async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => { @@ -5946,6 +6033,128 @@ function ChatViewContent(props: ChatViewProps) { } void onRevertToTurnCountRef.current(targetTurnCount); }, []); + const onPopLastUserMessage = useCallback(async () => { + if (!lastUserMessagePopCandidate || !activeThread || isPoppingLastUserMessage) return; + + setIsPoppingLastUserMessage(true); + const poppedThreadId = activeThread.id; + const poppedDraftTarget = composerDraftTarget; + const poppedMessageId = lastUserMessagePopCandidate.message.id; + const restoredText = deriveLastUserMessageRestoredText( + lastUserMessagePopCandidate.message.text, + ); + const imageCapture = captureLastUserMessageImages(lastUserMessagePopCandidate.message); + let failureDescription: string | null = null; + + try { + const interrupted = await interruptActiveTurn(); + if (!interrupted) { + failureDescription = "The turn could not be interrupted."; + } else { + const settled = await waitForRunningTurnToSettle(poppedThreadId); + if (!settled) { + failureDescription = "The turn did not settle within 15 seconds."; + } else { + const reverted = await onRevertToTurnCountRef.current( + lastUserMessagePopCandidate.turnCount, + { skipConfirm: true }, + ); + if (!reverted) { + failureDescription = "The sent message could not be removed from thread history."; + } else { + setOptimisticUserMessages((existing) => { + const removed = existing.filter((message) => message.id === poppedMessageId); + for (const message of removed) { + revokeUserMessagePreviewUrls(message); + } + return existing.filter((message) => message.id !== poppedMessageId); + }); + clearAttachmentPreviewHandoff(poppedMessageId); + } + } + } + } catch (error) { + failureDescription = chatActionErrorMessage(error); + } + + const { images, failedNames } = await imageCapture.catch(() => ({ + images: [], + failedNames: (lastUserMessagePopCandidate.message.attachments ?? []).map( + (attachment) => attachment.name, + ), + })); + const currentDraft = useComposerDraftStore.getState().getComposerDraft(poppedDraftTarget); + const currentPrompt = currentDraft?.prompt ?? ""; + const nextPrompt = mergePoppedPrompt(currentPrompt, restoredText); + setComposerDraftPrompt(poppedDraftTarget, nextPrompt); + + const existingImages = currentDraft?.images ?? []; + const existingIds = new Set(existingImages.map((image) => image.id)); + const existingKeys = new Set( + existingImages.map((image) => JSON.stringify([image.mimeType, image.sizeBytes, image.name])), + ); + const acceptedImages: ComposerImageAttachment[] = []; + const overflowNames: string[] = []; + for (const image of images) { + const dedupKey = JSON.stringify([image.mimeType, image.sizeBytes, image.name]); + if (existingIds.has(image.id) || existingKeys.has(dedupKey)) { + revokeBlobPreviewUrl(image.previewUrl); + continue; + } + if (existingImages.length + acceptedImages.length >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { + overflowNames.push(image.name); + revokeBlobPreviewUrl(image.previewUrl); + continue; + } + existingIds.add(image.id); + existingKeys.add(dedupKey); + acceptedImages.push(image); + } + addComposerDraftImages(poppedDraftTarget, acceptedImages); + + if (runningTurnStateRef.current.threadId === poppedThreadId) { + promptRef.current = nextPrompt; + composerImagesRef.current = [...existingImages, ...acceptedImages]; + composerRef.current?.resetCursorState({ + cursor: collapseExpandedComposerCursor(nextPrompt, nextPrompt.length), + prompt: nextPrompt, + detectTrigger: true, + }); + window.requestAnimationFrame(() => { + composerRef.current?.focusAtEnd(); + }); + } + + const unrestoredImageNames = [...failedNames, ...overflowNames]; + if (unrestoredImageNames.length > 0) { + toastManager.add({ + type: "warning", + title: "Some images could not be restored", + description: `${unrestoredImageNames.join(", ")} could not be restored to the composer.`, + }); + } + if (failureDescription !== null) { + toastManager.add({ + type: "error", + title: "Message restored, but the turn could not be rewound", + description: failureDescription, + }); + } + setIsPoppingLastUserMessage(false); + }, [ + activeThread, + addComposerDraftImages, + clearAttachmentPreviewHandoff, + composerDraftTarget, + composerImagesRef, + composerRef, + interruptActiveTurn, + isPoppingLastUserMessage, + lastUserMessagePopCandidate, + promptRef, + setComposerDraftPrompt, + waitForRunningTurnToSettle, + ]); // Empty state: no active thread if (!activeThread) { @@ -6329,6 +6538,11 @@ function ChatViewContent(props: ChatViewProps) { composerTerminalContextsRef={composerTerminalContextsRef} composerElementContextsRef={composerElementContextsRef} onSend={onSend} + onPopLastUserMessage={ + lastUserMessagePopCandidate !== null && !isPoppingLastUserMessage + ? onPopLastUserMessage + : null + } onInterrupt={onInterrupt} onImplementPlanInNewThread={onImplementPlanInNewThread} onRespondToApproval={onRespondToApproval} diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 0489e8c79cdf..fe1c9bd38a69 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -26,6 +26,7 @@ import { KEY_ARROW_UP_COMMAND, KEY_DOWN_COMMAND, KEY_ENTER_COMMAND, + KEY_ESCAPE_COMMAND, KEY_TAB_COMMAND, COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_LOW, @@ -893,7 +894,7 @@ interface ComposerPromptEditorProps { terminalContextIds: string[], ) => void; onCommandKeyDown?: ( - key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", + key: "ArrowDown" | "ArrowUp" | "Enter" | "Escape" | "Tab", event: KeyboardEvent, ) => boolean; onPaste: React.ClipboardEventHandler; @@ -902,7 +903,7 @@ interface ComposerPromptEditorProps { function ComposerCommandKeyPlugin(props: { onCommandKeyDown?: ( - key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", + key: "ArrowDown" | "ArrowUp" | "Enter" | "Escape" | "Tab", event: KeyboardEvent, ) => boolean; }) { @@ -910,7 +911,7 @@ function ComposerCommandKeyPlugin(props: { useEffect(() => { const handleCommand = ( - key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", + key: "ArrowDown" | "ArrowUp" | "Enter" | "Escape" | "Tab", event: KeyboardEvent | null, ): boolean => { if (!props.onCommandKeyDown || !event) { @@ -945,6 +946,11 @@ function ComposerCommandKeyPlugin(props: { (event) => handleCommand("Enter", event), COMMAND_PRIORITY_HIGH, ); + const unregisterEscape = editor.registerCommand( + KEY_ESCAPE_COMMAND, + (event) => handleCommand("Escape", event), + COMMAND_PRIORITY_HIGH, + ); const unregisterTab = editor.registerCommand( KEY_TAB_COMMAND, (event) => handleCommand("Tab", event), @@ -955,6 +961,7 @@ function ComposerCommandKeyPlugin(props: { unregisterArrowDown(); unregisterArrowUp(); unregisterEnter(); + unregisterEscape(); unregisterTab(); }; }, [editor, props]); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 38490aeaa7e4..a880aedfa015 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -567,6 +567,7 @@ export interface ChatComposerProps { // Callbacks onSend: (e?: { preventDefault: () => void }) => void; + onPopLastUserMessage: (() => Promise) | null; onInterrupt: () => void; onImplementPlanInNewThread: () => void; onRespondToApproval: ( @@ -649,6 +650,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerTerminalContextsRef, composerElementContextsRef, onSend, + onPopLastUserMessage, onInterrupt, onImplementPlanInNewThread, onRespondToApproval, @@ -1865,9 +1867,24 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Callbacks: command key // ------------------------------------------------------------------ const onComposerCommandKey = ( - key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", + key: "ArrowDown" | "ArrowUp" | "Enter" | "Escape" | "Tab", event: KeyboardEvent, ) => { + if (key === "Escape") { + const { trigger } = resolveActiveComposerTrigger(); + const composerMenuOpen = + composerMenuOpenRef.current || + trigger !== null || + isStashMenuOpen || + isComposerModelPickerOpen || + isCommandPaletteOpen() || + document.querySelector(COMPOSER_FLOATING_LAYER_SELECTOR) !== null; + if (composerMenuOpen || onPopLastUserMessage === null) { + return false; + } + void onPopLastUserMessage(); + return true; + } if (key === "Tab" && event.shiftKey) { if (!planModeUiEnabled) return false; toggleInteractionMode(); diff --git a/apps/web/src/components/chat/lastUserMessagePop.test.ts b/apps/web/src/components/chat/lastUserMessagePop.test.ts new file mode 100644 index 000000000000..de3d14a65140 --- /dev/null +++ b/apps/web/src/components/chat/lastUserMessagePop.test.ts @@ -0,0 +1,244 @@ +import { MessageId, TurnId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import type { TimelineEntry, WorkLogEntry } from "../../session-logic"; +import type { ChatMessage } from "../../types"; +import { + deriveLastUserMessageRestoredText, + findLastUserMessagePopCandidate, + IMAGE_ONLY_MESSAGE_PLACEHOLDER, + isLastUserMessagePopWindowOpen, + mergePoppedPrompt, +} from "./lastUserMessagePop"; + +function message(input: { + id: string; + role: ChatMessage["role"]; + text?: string; + turnId?: TurnId | null; + createdAt?: string; +}): ChatMessage { + const createdAt = input.createdAt ?? "2026-08-10T12:00:00.000Z"; + return { + id: MessageId.make(input.id), + role: input.role, + text: input.text ?? input.id, + turnId: input.turnId ?? null, + streaming: false, + createdAt, + updatedAt: createdAt, + }; +} + +function messageEntry(value: ChatMessage): TimelineEntry { + return { id: value.id, kind: "message", createdAt: value.createdAt, message: value }; +} + +function workEntry(input: { + id: string; + turnId: TurnId; + tone: WorkLogEntry["tone"]; + label: string; +}): TimelineEntry { + const entry: WorkLogEntry = { + id: input.id, + createdAt: "2026-08-10T12:00:01.000Z", + turnId: input.turnId, + tone: input.tone, + label: input.label, + }; + return { id: input.id, kind: "work", createdAt: entry.createdAt, entry }; +} + +describe("last user message pop window", () => { + const codexTurnId = TurnId.make("codex-turn"); + const claudeTurnId = TurnId.make("claude-turn"); + + it("opens for a running Codex turn with no output and ignores reasoning-only state", () => { + const user = message({ id: "user-codex", role: "user" }); + expect( + isLastUserMessagePopWindowOpen({ + phase: "running", + activeTurnId: codexTurnId, + timelineEntries: [messageEntry(user)], + }), + ).toBe(true); + expect( + isLastUserMessagePopWindowOpen({ + phase: "running", + activeTurnId: codexTurnId, + timelineEntries: [ + messageEntry(user), + workEntry({ + id: "codex-reasoning", + turnId: codexTurnId, + tone: "thinking", + label: "Reasoning", + }), + ], + }), + ).toBe(true); + }); + + it("closes when Codex tool output or assistant text reaches the timeline", () => { + const user = message({ id: "user-codex", role: "user" }); + expect( + isLastUserMessagePopWindowOpen({ + phase: "running", + activeTurnId: codexTurnId, + timelineEntries: [ + messageEntry(user), + workEntry({ + id: "codex-command", + turnId: codexTurnId, + tone: "tool", + label: "Ran command", + }), + ], + }), + ).toBe(false); + expect( + isLastUserMessagePopWindowOpen({ + phase: "running", + activeTurnId: codexTurnId, + timelineEntries: [ + messageEntry(user), + messageEntry( + message({ + id: "assistant-codex", + role: "assistant", + text: "Starting now", + turnId: codexTurnId, + }), + ), + ], + }), + ).toBe(false); + }); + + it("keeps Claude thinking eligible but closes on a Claude tool activity", () => { + const user = message({ id: "user-claude", role: "user" }); + expect( + isLastUserMessagePopWindowOpen({ + phase: "running", + activeTurnId: claudeTurnId, + timelineEntries: [ + messageEntry(user), + workEntry({ + id: "claude-thinking", + turnId: claudeTurnId, + tone: "thinking", + label: "Thinking", + }), + ], + }), + ).toBe(true); + expect( + isLastUserMessagePopWindowOpen({ + phase: "running", + activeTurnId: claudeTurnId, + timelineEntries: [ + messageEntry(user), + workEntry({ + id: "claude-tool", + turnId: claudeTurnId, + tone: "tool", + label: "Read file", + }), + ], + }), + ).toBe(false); + }); + + it("is closed for an idle thread", () => { + expect( + isLastUserMessagePopWindowOpen({ + phase: "ready", + activeTurnId: codexTurnId, + timelineEntries: [], + }), + ).toBe(false); + }); +}); + +describe("last user message selection", () => { + it("selects an optimistic first message for turn zero", () => { + const optimistic = message({ id: "optimistic-first", role: "user" }); + expect( + findLastUserMessagePopCandidate({ + messages: [optimistic], + turnCount: 0, + latestCheckpointCompletedAt: null, + }), + ).toEqual({ message: optimistic, turnCount: 0 }); + }); + + it("selects the optimistic follow-up after server messages", () => { + const first = message({ + id: "user-1", + role: "user", + createdAt: "2026-08-10T12:00:00.000Z", + }); + const assistant = message({ + id: "assistant-1", + role: "assistant", + turnId: TurnId.make("turn-1"), + createdAt: "2026-08-10T12:00:01.000Z", + }); + const optimistic = message({ + id: "optimistic-2", + role: "user", + createdAt: "2026-08-10T12:00:03.000Z", + }); + expect( + findLastUserMessagePopCandidate({ + messages: [first, assistant, optimistic], + turnCount: 1, + latestCheckpointCompletedAt: "2026-08-10T12:00:02.000Z", + }), + ).toEqual({ message: optimistic, turnCount: 1 }); + }); + + it("does not select a user message that predates the latest checkpoint", () => { + expect( + findLastUserMessagePopCandidate({ + messages: [message({ id: "completed-user", role: "user" })], + turnCount: 1, + latestCheckpointCompletedAt: "2026-08-10T12:00:01.000Z", + }), + ).toBeNull(); + }); +}); + +describe("last user message restored text", () => { + it("removes the injected effort prefix and terminal and element decorations", () => { + const decorated = [ + "Ultrathink:", + "Fix the save flow", + "", + "", + "- Terminal 1 line 12:", + " 12 | pnpm test", + "", + "", + "", + "- :", + " selector: button.save", + "", + ].join("\n"); + + expect(deriveLastUserMessageRestoredText(decorated)).toBe("Fix the save flow"); + }); + + it("turns the image-only placeholder back into an empty prompt", () => { + expect(deriveLastUserMessageRestoredText(IMAGE_ONLY_MESSAGE_PLACEHOLDER)).toBe(""); + }); + + it("merges with an in-progress draft using stash restore semantics", () => { + expect(mergePoppedPrompt("new follow-up \n", "original message")).toBe( + "new follow-up\n\noriginal message", + ); + expect(mergePoppedPrompt("", "original message")).toBe("original message"); + expect(mergePoppedPrompt("new follow-up", "")).toBe("new follow-up"); + }); +}); diff --git a/apps/web/src/components/chat/lastUserMessagePop.ts b/apps/web/src/components/chat/lastUserMessagePop.ts new file mode 100644 index 000000000000..da24d04cdabc --- /dev/null +++ b/apps/web/src/components/chat/lastUserMessagePop.ts @@ -0,0 +1,130 @@ +import type { TurnId } from "@t3tools/contracts"; + +import { extractTrailingElementContexts } from "../../lib/elementContext"; +import { extractTrailingPreviewAnnotation } from "../../lib/previewAnnotation"; +import { deriveDisplayedUserMessageState } from "../../lib/terminalContext"; +import { parseReviewCommentMessageSegments } from "../../reviewCommentContext"; +import type { TimelineEntry } from "../../session-logic"; +import type { ChatMessage, SessionPhase } from "../../types"; +import type { ComposerImageAttachment } from "../../composerDraftStore"; + +export const LAST_USER_MESSAGE_POP_SETTLE_TIMEOUT_MS = 15_000; +export const IMAGE_ONLY_MESSAGE_PLACEHOLDER = + "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; + +export interface LastUserMessagePopCandidate { + message: ChatMessage; + turnCount: number; +} + +export function findLastUserMessagePopCandidate(input: { + messages: ReadonlyArray; + turnCount: number; + latestCheckpointCompletedAt: string | null; +}): LastUserMessagePopCandidate | null { + const message = input.messages.findLast((entry) => entry.role === "user"); + if (!message) return null; + if ( + input.latestCheckpointCompletedAt !== null && + message.createdAt < input.latestCheckpointCompletedAt + ) { + return null; + } + return { message, turnCount: input.turnCount }; +} + +export function isLastUserMessagePopWindowOpen(input: { + phase: SessionPhase; + activeTurnId: TurnId | null; + timelineEntries: ReadonlyArray; +}): boolean { + if (input.phase !== "running" || input.activeTurnId === null) { + return false; + } + + return !input.timelineEntries.some((entry) => { + switch (entry.kind) { + case "message": + return ( + entry.message.role === "assistant" && + entry.message.turnId === input.activeTurnId && + entry.message.text.length > 0 + ); + case "work": + return entry.entry.turnId === input.activeTurnId && entry.entry.tone !== "thinking"; + case "proposed-plan": + return entry.proposedPlan.turnId === input.activeTurnId; + case "turn-plan": + return entry.turnPlan.turnId === input.activeTurnId; + } + }); +} + +export function mergePoppedPrompt(currentPrompt: string, poppedPrompt: string): string { + if (poppedPrompt.length === 0) return currentPrompt; + return currentPrompt.trim().length + ? `${currentPrompt.replace(/\s+$/, "")}\n\n${poppedPrompt}` + : poppedPrompt; +} + +function stripDisplayedReviewComments(prompt: string): string { + const segments = parseReviewCommentMessageSegments(prompt); + if (!segments.some((segment) => segment.kind === "review-comment")) { + return prompt; + } + return segments + .flatMap((segment) => (segment.kind === "text" ? [segment.text] : [])) + .join("") + .trimEnd(); +} + +export function deriveLastUserMessageRestoredText(messageText: string): string { + let visibleText = stripDisplayedReviewComments(messageText); + while (true) { + const extracted = extractTrailingPreviewAnnotation(visibleText); + if (!extracted.annotation) break; + visibleText = extracted.promptText; + } + + visibleText = deriveDisplayedUserMessageState(visibleText).visibleText; + visibleText = extractTrailingElementContexts(visibleText).promptText; + if (visibleText === IMAGE_ONLY_MESSAGE_PLACEHOLDER) return ""; + return visibleText.startsWith("Ultrathink:\n") + ? visibleText.slice("Ultrathink:\n".length) + : visibleText; +} + +export async function captureLastUserMessageImages( + message: ChatMessage, +): Promise<{ images: ComposerImageAttachment[]; failedNames: string[] }> { + const results = await Promise.all( + (message.attachments ?? []).map(async (attachment) => { + if (!attachment.previewUrl) return { name: attachment.name, image: null }; + try { + const response = await fetch(attachment.previewUrl); + if (!response.ok) return { name: attachment.name, image: null }; + const blob = await response.blob(); + const file = new File([blob], attachment.name, { type: attachment.mimeType }); + const previewUrl = + typeof URL === "undefined" || typeof URL.createObjectURL !== "function" + ? attachment.previewUrl + : URL.createObjectURL(file); + return { + name: attachment.name, + image: { + ...attachment, + sizeBytes: file.size, + previewUrl, + file, + } satisfies ComposerImageAttachment, + }; + } catch { + return { name: attachment.name, image: null }; + } + }), + ); + return { + images: results.flatMap((result) => (result.image ? [result.image] : [])), + failedNames: results.flatMap((result) => (result.image ? [] : [result.name])), + }; +} diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index f7f6facbe594..344f60f16c18 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -55,6 +55,11 @@ characters and uses SQLite's ASCII case-insensitive matching. The full command list and the current defaults are shown in **Settings** → **Keybindings**, which always matches the build you are running. Use that rather than a copied list. +When the message composer is focused, press `Escape` to interrupt a running turn and move the +message you just sent back into the composer for editing. This only works before the agent has +shown assistant text, a tool call, a command, or another activity for that turn. Agent thinking does +not close this short window. The shortcut is built into the composer and is not configurable. + Note that `chat.new` and `chat.newLocal` both create a thread through the same path. A new thread inherits the project you were in, along with model and mode selections. Branch, worktree, and environment mode always come from your configured defaults, not from the thread you were looking diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 8b2479c7a349..fa8e716db37d 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -880,6 +880,73 @@ describe("applyThreadDetailEvent", () => { expect(result.thread.latestTurn?.turnId).toBe("turn-1"); } }); + + it("removes an unbound user message beyond the target turn count", () => { + const threadWithRunningUser: OrchestrationThread = { + ...baseThread, + messages: [ + { + id: MessageId.make("msg-user-1"), + role: "user", + text: "First", + turnId: null, + streaming: false, + createdAt: "2026-04-01T01:00:00.000Z", + updatedAt: "2026-04-01T01:00:00.000Z", + }, + { + id: MessageId.make("msg-assistant-1"), + role: "assistant", + text: "Response 1", + turnId: TurnId.make("turn-1"), + streaming: false, + createdAt: "2026-04-01T02:00:00.000Z", + updatedAt: "2026-04-01T02:00:00.000Z", + }, + { + id: MessageId.make("msg-user-running"), + role: "user", + text: "Pop me", + turnId: null, + streaming: false, + createdAt: "2026-04-01T03:00:00.000Z", + updatedAt: "2026-04-01T03:00:00.000Z", + }, + ], + checkpoints: [ + { + turnId: TurnId.make("turn-1"), + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.make("ref-1"), + status: "ready", + files: [], + assistantMessageId: MessageId.make("msg-assistant-1"), + completedAt: "2026-04-01T02:00:00.000Z", + }, + ], + }; + + const result = applyThreadDetailEvent(threadWithRunningUser, { + ...baseEventFields, + sequence: 15, + occurredAt: "2026-04-01T04:00:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.reverted", + payload: { + threadId: ThreadId.make("thread-1"), + turnCount: 1, + }, + }); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.messages.map((message) => message.id)).toEqual([ + "msg-user-1", + "msg-assistant-1", + ]); + } + }); }); describe("no-op events", () => { diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 970fd94b1a16..1e7f4dfdbd75 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -522,7 +522,11 @@ export function applyThreadDetailEvent( ); const retainedTurnIds = new Set(Arr.map(checkpoints, (entry) => entry.turnId)); - const messages = retainMessagesAfterRevert(thread.messages, retainedTurnIds); + const messages = retainMessagesAfterRevert( + thread.messages, + retainedTurnIds, + event.payload.turnCount, + ); const proposedPlans = pipe( thread.proposedPlans, Arr.filter((plan) => plan.turnId === null || retainedTurnIds.has(plan.turnId)), @@ -654,16 +658,58 @@ function rebindCheckpointAssistantMessage( function retainMessagesAfterRevert( messages: ReadonlyArray, retainedTurnIds: ReadonlySet, + turnCount: number, ): OrchestrationMessage[] { - // Keep messages that belong to a retained turn, plus system messages and - // messages without a turn binding (pre-turn-0 user messages). - return Arr.filter(messages, (message) => { + const retainedMessageIds = new Set(); + for (const message of messages) { if (message.role === "system") { - return true; + retainedMessageIds.add(message.id); + continue; } - if (message.turnId === null) { - return true; + if (message.turnId !== null && retainedTurnIds.has(message.turnId)) { + retainedMessageIds.add(message.id); } - return retainedTurnIds.has(message.turnId); - }); + } + + const retainedUserCount = messages.filter( + (message) => message.role === "user" && retainedMessageIds.has(message.id), + ).length; + const missingUserCount = Math.max(0, turnCount - retainedUserCount); + const fallbackUserMessages = messages + .filter( + (message) => + message.role === "user" && + !retainedMessageIds.has(message.id) && + (message.turnId === null || retainedTurnIds.has(message.turnId)), + ) + .toSorted( + (left, right) => + left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), + ) + .slice(0, missingUserCount); + for (const message of fallbackUserMessages) { + retainedMessageIds.add(message.id); + } + + const retainedAssistantCount = messages.filter( + (message) => message.role === "assistant" && retainedMessageIds.has(message.id), + ).length; + const missingAssistantCount = Math.max(0, turnCount - retainedAssistantCount); + const fallbackAssistantMessages = messages + .filter( + (message) => + message.role === "assistant" && + !retainedMessageIds.has(message.id) && + (message.turnId === null || retainedTurnIds.has(message.turnId)), + ) + .toSorted( + (left, right) => + left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), + ) + .slice(0, missingAssistantCount); + for (const message of fallbackAssistantMessages) { + retainedMessageIds.add(message.id); + } + + return messages.filter((message) => retainedMessageIds.has(message.id)); } From 5b262d2c9166ce93fdfc407494798ac6ad74edc9 Mon Sep 17 00:00:00 2001 From: Adam Firestone Date: Tue, 11 Aug 2026 11:31:30 -0500 Subject: [PATCH 02/18] feat(server,contracts): thread.turn.retract protocol and durable retraction intent Adds the client-dispatchable retract command, threadTurnRetraction capability, additive retraction metadata on turn-interrupt-requested/reverted payloads, a durable pending-retraction projection (migration 041) with startup scan, and authoritative decider eligibility incl. the no-assistant-output rule. Nothing consumes the intent yet; the retraction process manager follows. Built with Claude Fable 5 via Claude Code, implementation by Codex (GPT-5.6-Sol). --- .../src/environment/ServerEnvironment.test.ts | 1 + .../src/environment/ServerEnvironment.ts | 1 + .../Layers/ProjectionPipeline.test.ts | 142 ++++++++ .../Layers/ProjectionPipeline.ts | 70 ++++ .../Layers/ProjectionSnapshotQuery.test.ts | 49 +++ .../Layers/ProjectionSnapshotQuery.ts | 268 +++++++++++++- apps/server/src/orchestration/decider.ts | 106 ++++++ .../decider.turnRetraction.test.ts | 328 ++++++++++++++++++ apps/server/src/orchestration/projector.ts | 82 +++++ .../Layers/ProjectionTurnRetractions.ts | 165 +++++++++ apps/server/src/persistence/Migrations.ts | 2 + .../041_ProjectionTurnRetractions.test.ts | 54 +++ .../041_ProjectionTurnRetractions.ts | 34 ++ .../Services/ProjectionTurnRetractions.ts | 78 +++++ packages/contracts/src/environment.test.ts | 10 + packages/contracts/src/environment.ts | 2 + packages/contracts/src/orchestration.test.ts | 112 ++++++ packages/contracts/src/orchestration.ts | 48 +++ 18 files changed, 1548 insertions(+), 4 deletions(-) create mode 100644 apps/server/src/orchestration/decider.turnRetraction.test.ts create mode 100644 apps/server/src/persistence/Layers/ProjectionTurnRetractions.ts create mode 100644 apps/server/src/persistence/Migrations/041_ProjectionTurnRetractions.test.ts create mode 100644 apps/server/src/persistence/Migrations/041_ProjectionTurnRetractions.ts create mode 100644 apps/server/src/persistence/Services/ProjectionTurnRetractions.ts diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 84269c381ceb..e48f75546a50 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -71,6 +71,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(second.capabilities.connectionProbe).toBe(true); expect(second.capabilities.pullRequests).toBe(true); expect(second.capabilities.threadTitleRegeneration).toBe(true); + expect(second.capabilities.threadTurnRetraction).toBe(true); }), ); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index e1e9020eb27d..5dee09334ffd 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -149,6 +149,7 @@ export const make = Effect.gen(function* () { threadPinning: true, threadPinReorder: true, threadTitleRegeneration: true, + threadTurnRetraction: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}), }, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index e3b18d74a9a7..b284533c27dd 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -24,6 +24,7 @@ import { SqlitePersistenceMemory, } from "../../persistence/Layers/Sqlite.ts"; import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; +import { ProjectionTurnRetractionRepository } from "../../persistence/Services/ProjectionTurnRetractions.ts"; import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { @@ -241,6 +242,147 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { ); }); +it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-retraction-projection-test-")))( + "turn retraction projection", + (it) => { + it.effect( + "replays pending, failed, and completed tombstones and exposes pending startup scan", + () => + Effect.gen(function* () { + const pipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const retractions = yield* ProjectionTurnRetractionRepository; + const threadId = ThreadId.make("thread-retraction"); + const requestedAt = "2026-01-01T00:00:00.000Z"; + + yield* eventStore.append({ + type: "thread.turn-interrupt-requested", + eventId: EventId.make("evt-retract-requested-1"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: requestedAt, + commandId: CommandId.make("cmd-retract-1"), + causationEventId: null, + correlationId: CommandId.make("cmd-retract-1"), + metadata: {}, + payload: { + threadId, + turnId: TurnId.make("turn-3"), + createdAt: requestedAt, + retraction: { + requestId: CommandId.make("cmd-retract-1"), + messageId: MessageId.make("message-3"), + targetTurnId: TurnId.make("turn-3"), + baselineTurnCount: 2, + firstUserMessage: false, + }, + }, + }); + yield* pipeline.bootstrap; + + const pending = yield* retractions.listPending(); + assert.equal(pending.length, 1); + assert.equal(pending[0]?.requestId, CommandId.make("cmd-retract-1")); + assert.equal(pending[0]?.providerSendClaimed, false); + assert.equal(pending[0]?.status, "requested"); + assert.equal(pending[0]?.baselineTurnCount, 2); + assert.match(pending[0]?.baselineCheckpointRef ?? "", /\/turn\/2$/); + + yield* eventStore.append({ + type: "thread.activity-appended", + eventId: EventId.make("evt-retract-failed-1"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: "2026-01-01T00:00:01.000Z", + commandId: CommandId.make("cmd-retract-failed-1"), + causationEventId: null, + correlationId: CommandId.make("cmd-retract-1"), + metadata: {}, + payload: { + threadId, + activity: { + id: EventId.make("activity-retract-failed-1"), + tone: "error", + kind: "turn.retract.failed", + summary: "Could not retract turn", + payload: { requestId: CommandId.make("cmd-retract-1") }, + turnId: TurnId.make("turn-3"), + createdAt: "2026-01-01T00:00:01.000Z", + }, + }, + }); + yield* pipeline.bootstrap; + + const failed = yield* retractions.getByRequestId({ + requestId: CommandId.make("cmd-retract-1"), + }); + assert.equal(failed._tag, "Some"); + if (failed._tag === "Some") { + assert.equal(failed.value.status, "failed"); + assert.equal(failed.value.failedAt, "2026-01-01T00:00:01.000Z"); + } + assert.equal((yield* retractions.listPending()).length, 0); + + yield* eventStore.append({ + type: "thread.turn-interrupt-requested", + eventId: EventId.make("evt-retract-requested-2"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: "2026-01-01T00:00:02.000Z", + commandId: CommandId.make("cmd-retract-2"), + causationEventId: null, + correlationId: CommandId.make("cmd-retract-2"), + metadata: {}, + payload: { + threadId, + createdAt: "2026-01-01T00:00:02.000Z", + retraction: { + requestId: CommandId.make("cmd-retract-2"), + messageId: MessageId.make("message-4"), + targetTurnId: null, + baselineTurnCount: 0, + firstUserMessage: true, + }, + }, + }); + yield* eventStore.append({ + type: "thread.reverted", + eventId: EventId.make("evt-retract-completed-2"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: "2026-01-01T00:00:03.000Z", + commandId: CommandId.make("cmd-retract-complete-2"), + causationEventId: null, + correlationId: CommandId.make("cmd-retract-2"), + metadata: {}, + payload: { + threadId, + turnCount: 0, + retraction: { + requestId: CommandId.make("cmd-retract-2"), + messageId: MessageId.make("message-4"), + turnId: null, + firstUserMessage: true, + completedAt: "2026-01-01T00:00:03.000Z", + }, + }, + }); + yield* pipeline.bootstrap; + + const completed = yield* retractions.getByRequestId({ + requestId: CommandId.make("cmd-retract-2"), + }); + assert.equal(completed._tag, "Some"); + if (completed._tag === "Some") { + assert.equal(completed.value.status, "completed"); + assert.equal(completed.value.completedAt, "2026-01-01T00:00:03.000Z"); + } + assert.equal((yield* retractions.listPending()).length, 0); + }), + ); + }, +); + it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-base-")))( "OrchestrationProjectionPipeline", (it) => { diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index e9a625dd91cf..4b54b7dcaf71 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1,5 +1,6 @@ import { ApprovalRequestId, + CommandId, type ChatAttachment, type OrchestrationEvent, type OrchestrationSessionStatus, @@ -10,6 +11,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -42,7 +44,9 @@ import { ProjectionThreadMessageRepositoryLive } from "../../persistence/Layers/ import { ProjectionThreadProposedPlanRepositoryLive } from "../../persistence/Layers/ProjectionThreadProposedPlans.ts"; import { ProjectionThreadSessionRepositoryLive } from "../../persistence/Layers/ProjectionThreadSessions.ts"; import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; +import { ProjectionTurnRetractionRepositoryLive } from "../../persistence/Layers/ProjectionTurnRetractions.ts"; import { ProjectionThreadRepositoryLive } from "../../persistence/Layers/ProjectionThreads.ts"; +import { ProjectionTurnRetractionRepository } from "../../persistence/Services/ProjectionTurnRetractions.ts"; import { ServerConfig } from "../../config.ts"; import { OrchestrationProjectionPipeline, @@ -54,6 +58,7 @@ import { parseThreadSegmentFromAttachmentId, toSafeThreadAttachmentSegment, } from "../../attachmentStore.ts"; +import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts"; export const ORCHESTRATION_PROJECTOR_NAMES = { projects: "projection.projects", @@ -65,11 +70,16 @@ export const ORCHESTRATION_PROJECTOR_NAMES = { threadTurns: "projection.thread-turns", checkpoints: "projection.checkpoints", pendingApprovals: "projection.pending-approvals", + turnRetractions: "projection.turn-retractions", } as const; type ProjectorName = (typeof ORCHESTRATION_PROJECTOR_NAMES)[keyof typeof ORCHESTRATION_PROJECTOR_NAMES]; +const decodeTurnRetractionFailurePayload = Schema.decodeUnknownOption( + Schema.Struct({ requestId: CommandId }), +); + /** * Turn state to settle still-running turns with when their session leaves the * "running" status, or null while the session is (re)starting or running and @@ -480,6 +490,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const projectionThreadSessionRepository = yield* ProjectionThreadSessionRepository; const projectionTurnRepository = yield* ProjectionTurnRepository; const projectionPendingApprovalRepository = yield* ProjectionPendingApprovalRepository; + const projectionTurnRetractionRepository = yield* ProjectionTurnRetractionRepository; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -1606,6 +1617,60 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti } }); + const applyTurnRetractionsProjection: ProjectorDefinition["apply"] = Effect.fn( + "applyTurnRetractionsProjection", + )(function* (event, _attachmentSideEffects) { + switch (event.type) { + case "thread.turn-interrupt-requested": { + const retraction = event.payload.retraction; + if (retraction === undefined) return; + yield* projectionTurnRetractionRepository.upsertPending({ + requestId: retraction.requestId, + threadId: event.payload.threadId, + messageId: retraction.messageId, + baselineTurnCount: retraction.baselineTurnCount, + baselineCheckpointRef: checkpointRefForThreadTurn( + event.payload.threadId, + retraction.baselineTurnCount, + ), + targetTurnId: retraction.targetTurnId, + providerSendClaimed: false, + firstUserMessage: retraction.firstUserMessage, + requestedAt: event.payload.createdAt, + status: "requested", + completedAt: null, + failedAt: null, + }); + return; + } + + case "thread.reverted": { + const retraction = event.payload.retraction; + if (retraction === undefined) return; + yield* projectionTurnRetractionRepository.markCompleted({ + requestId: retraction.requestId, + completedAt: retraction.completedAt, + targetTurnId: retraction.turnId, + }); + return; + } + + case "thread.activity-appended": { + if (event.payload.activity.kind !== "turn.retract.failed") return; + const payload = decodeTurnRetractionFailurePayload(event.payload.activity.payload); + if (Option.isNone(payload)) return; + yield* projectionTurnRetractionRepository.markFailed({ + requestId: payload.value.requestId, + failedAt: event.payload.activity.createdAt, + }); + return; + } + + default: + return; + } + }); + const projectors: ReadonlyArray = [ { name: ORCHESTRATION_PROJECTOR_NAMES.projects, @@ -1639,6 +1704,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti name: ORCHESTRATION_PROJECTOR_NAMES.pendingApprovals, apply: applyPendingApprovalsProjection, }, + { + name: ORCHESTRATION_PROJECTOR_NAMES.turnRetractions, + apply: applyTurnRetractionsProjection, + }, { name: ORCHESTRATION_PROJECTOR_NAMES.threads, apply: applyThreadsProjection, @@ -1745,5 +1814,6 @@ export const OrchestrationProjectionPipelineLive = Layer.effect( Layer.provideMerge(ProjectionThreadSessionRepositoryLive), Layer.provideMerge(ProjectionTurnRepositoryLive), Layer.provideMerge(ProjectionPendingApprovalRepositoryLive), + Layer.provideMerge(ProjectionTurnRetractionRepositoryLive), Layer.provideMerge(ProjectionStateRepositoryLive), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index be596b36b850..36e1171ace13 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -1,5 +1,6 @@ import { CheckpointRef, + CommandId, EventId, MessageId, ProjectId, @@ -244,6 +245,36 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { ) `; + yield* sql` + INSERT INTO projection_turn_retractions ( + request_id, + thread_id, + message_id, + baseline_turn_count, + baseline_checkpoint_ref, + target_turn_id, + provider_send_claimed, + first_user_message, + requested_at, + status, + completed_at, + failed_at + ) VALUES ( + 'cmd-retract-1', + 'thread-1', + 'message-user-1', + 1, + 'checkpoint-1', + 'turn-1', + 1, + 0, + '2026-02-24T00:00:08.500Z', + 'requested', + NULL, + NULL + ) + `; + let sequence = 5; for (const projector of Object.values(ORCHESTRATION_PROJECTOR_NAMES)) { yield* sql` @@ -326,6 +357,19 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { pinnedAt: "2026-02-24T00:00:01.000Z", pinOrderKey: "gm", titleRegeneration: null, + turnRetraction: { + requestId: CommandId.make("cmd-retract-1"), + messageId: MessageId.make("message-user-1"), + baselineTurnCount: 1, + baselineCheckpointRef: CheckpointRef.make("checkpoint-1"), + targetTurnId: TurnId.make("turn-1"), + providerSendClaimed: true, + firstUserMessage: false, + requestedAt: "2026-02-24T00:00:08.500Z", + status: "requested", + completedAt: null, + failedAt: null, + }, deletedAt: null, messages: [ { @@ -383,6 +427,11 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { }, ]); + const commandReadModel = yield* snapshotQuery.getCommandReadModel(); + assert.equal(commandReadModel.threads[0]?.turnRetraction?.requestId, "cmd-retract-1"); + assert.equal(commandReadModel.threads[0]?.messages[0]?.text, "hello from projection"); + assert.equal(commandReadModel.threads[0]?.activities[0]?.kind, "runtime.note"); + const shellSnapshot = yield* snapshotQuery.getShellSnapshot(); assert.equal(shellSnapshot.snapshotSequence, 5); assert.deepEqual(shellSnapshot.projects, [ diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 3e77f9cf875a..6a182e398810 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -21,6 +21,7 @@ import { type OrchestrationProject, type OrchestrationSession, type OrchestrationThreadActivity, + type OrchestrationThreadTurnRetraction, type OrchestrationThreadShell, ModelSelection, ProjectId, @@ -52,6 +53,7 @@ import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionTh import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts"; import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts"; +import { ProjectionTurnRetraction } from "../../persistence/Services/ProjectionTurnRetractions.ts"; import { decodeThreadDetailPageCursor, encodeThreadDetailPageCursor, @@ -99,6 +101,12 @@ const ProjectionCheckpointDbRowSchema = ProjectionCheckpoint.mapFields( files: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)), }), ); +const ProjectionTurnRetractionDbRowSchema = ProjectionTurnRetraction.mapFields( + Struct.assign({ + providerSendClaimed: Schema.Number, + firstUserMessage: Schema.Number, + }), +); const ProjectionLatestTurnDbRowSchema = Schema.Struct({ threadId: ProjectionThread.fields.threadId, turnId: TurnId, @@ -196,6 +204,7 @@ const REQUIRED_SNAPSHOT_PROJECTORS = [ ORCHESTRATION_PROJECTOR_NAMES.threadActivities, ORCHESTRATION_PROJECTOR_NAMES.threadSessions, ORCHESTRATION_PROJECTOR_NAMES.checkpoints, + ORCHESTRATION_PROJECTOR_NAMES.turnRetractions, ] as const; function maxIso(left: string | null, right: string): string { @@ -291,6 +300,24 @@ function mapTitleRegeneration(row: Schema.Schema.Type, +): OrchestrationThreadTurnRetraction { + return { + requestId: row.requestId, + messageId: row.messageId, + baselineTurnCount: row.baselineTurnCount, + baselineCheckpointRef: row.baselineCheckpointRef, + targetTurnId: row.targetTurnId, + providerSendClaimed: row.providerSendClaimed !== 0, + firstUserMessage: row.firstUserMessage !== 0, + requestedAt: row.requestedAt, + status: row.status, + completedAt: row.completedAt, + failedAt: row.failedAt, + }; +} + function mapSessionRow( row: Schema.Schema.Type, ): OrchestrationSession { @@ -536,6 +563,34 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const listCommandThreadMessageRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionThreadMessageDbRowSchema, + execute: () => + sql` + SELECT + messages.message_id AS "messageId", + messages.thread_id AS "threadId", + messages.turn_id AS "turnId", + messages.role, + messages.text, + messages.attachments_json AS "attachments", + messages.is_streaming AS "isStreaming", + messages.created_at AS "createdAt", + messages.updated_at AS "updatedAt" + FROM projection_thread_messages AS messages + LEFT JOIN projection_thread_sessions AS sessions + ON sessions.thread_id = messages.thread_id + WHERE messages.role = 'user' + OR ( + messages.role = 'assistant' + AND LENGTH(messages.text) > 0 + AND messages.turn_id = sessions.active_turn_id + ) + ORDER BY messages.thread_id ASC, messages.created_at ASC, messages.message_id ASC + `, + }); + const listThreadProposedPlanRows = SqlSchema.findAll({ Request: Schema.Void, Result: ProjectionThreadProposedPlanDbRowSchema, @@ -579,6 +634,84 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const listCommandThreadActivityRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionThreadActivityDbRowSchema, + execute: () => + sql` + SELECT + activities.activity_id AS "activityId", + activities.thread_id AS "threadId", + activities.turn_id AS "turnId", + activities.tone, + activities.kind, + activities.summary, + activities.payload_json AS "payload", + activities.sequence, + activities.created_at AS "createdAt" + FROM projection_thread_activities AS activities + INNER JOIN projection_thread_sessions AS sessions + ON sessions.thread_id = activities.thread_id + AND sessions.active_turn_id = activities.turn_id + ORDER BY activities.thread_id ASC, activities.created_at ASC, activities.activity_id ASC + `, + }); + + const listTurnRetractionRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionTurnRetractionDbRowSchema, + execute: () => + sql` + SELECT + request_id AS "requestId", + thread_id AS "threadId", + message_id AS "messageId", + baseline_turn_count AS "baselineTurnCount", + baseline_checkpoint_ref AS "baselineCheckpointRef", + target_turn_id AS "targetTurnId", + provider_send_claimed AS "providerSendClaimed", + first_user_message AS "firstUserMessage", + requested_at AS "requestedAt", + status, + completed_at AS "completedAt", + failed_at AS "failedAt" + FROM projection_turn_retractions + ORDER BY + thread_id ASC, + CASE WHEN status = 'requested' THEN 0 ELSE 1 END ASC, + requested_at DESC, + request_id DESC + `, + }); + + const getLatestTurnRetractionRowByThread = SqlSchema.findOneOption({ + Request: ThreadIdLookupInput, + Result: ProjectionTurnRetractionDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + request_id AS "requestId", + thread_id AS "threadId", + message_id AS "messageId", + baseline_turn_count AS "baselineTurnCount", + baseline_checkpoint_ref AS "baselineCheckpointRef", + target_turn_id AS "targetTurnId", + provider_send_claimed AS "providerSendClaimed", + first_user_message AS "firstUserMessage", + requested_at AS "requestedAt", + status, + completed_at AS "completedAt", + failed_at AS "failedAt" + FROM projection_turn_retractions + WHERE thread_id = ${threadId} + ORDER BY + CASE WHEN status = 'requested' THEN 0 ELSE 1 END ASC, + requested_at DESC, + request_id DESC + LIMIT 1 + `, + }); + const listThreadSessionRows = SqlSchema.findAll({ Request: Schema.Void, Result: ProjectionThreadSessionDbRowSchema, @@ -1382,6 +1515,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), + listTurnRetractionRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listTurnRetractions:query", + "ProjectionSnapshotQuery.getSnapshot:listTurnRetractions:decodeRows", + ), + ), + ), listProjectionStateRows(undefined).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -1403,6 +1544,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { sessionRows, checkpointRows, latestTurnRows, + retractionRows, stateRows, ]) => Effect.gen(function* () { @@ -1412,6 +1554,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { const checkpointsByThread = new Map>(); const sessionsByThread = new Map(); const latestTurnByThread = new Map(); + const turnRetractionByThread = new Map(); let updatedAt: string | null = null; @@ -1523,6 +1666,12 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { }); } + for (const row of retractionRows) { + if (!turnRetractionByThread.has(row.threadId)) { + turnRetractionByThread.set(row.threadId, mapTurnRetraction(row)); + } + } + for (const row of sessionRows) { updatedAt = maxIso(updatedAt, row.updatedAt); sessionsByThread.set(row.threadId, { @@ -1578,6 +1727,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pinnedAt: row.pinnedAt, pinOrderKey: row.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), + turnRetraction: turnRetractionByThread.get(row.threadId) ?? null, deletedAt: row.deletedAt, messages: messagesByThread.get(row.threadId) ?? [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], @@ -1628,6 +1778,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), + listCommandThreadMessageRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getCommandReadModel:listThreadMessages:query", + "ProjectionSnapshotQuery.getCommandReadModel:listThreadMessages:decodeRows", + ), + ), + ), listThreadProposedPlanRows(undefined).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -1636,6 +1794,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), + listCommandThreadActivityRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getCommandReadModel:listThreadActivities:query", + "ProjectionSnapshotQuery.getCommandReadModel:listThreadActivities:decodeRows", + ), + ), + ), listThreadSessionRows(undefined).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -1644,6 +1810,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), + listCheckpointRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getCommandReadModel:listCheckpoints:query", + "ProjectionSnapshotQuery.getCommandReadModel:listCheckpoints:decodeRows", + ), + ), + ), listLatestTurnRows(undefined).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -1652,6 +1826,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), + listTurnRetractionRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getCommandReadModel:listTurnRetractions:query", + "ProjectionSnapshotQuery.getCommandReadModel:listTurnRetractions:decodeRows", + ), + ), + ), listProjectionStateRows(undefined).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -1664,7 +1846,18 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ) .pipe( Effect.flatMap( - ([projectRows, threadRows, proposedPlanRows, sessionRows, latestTurnRows, stateRows]) => + ([ + projectRows, + threadRows, + messageRows, + proposedPlanRows, + activityRows, + sessionRows, + checkpointRows, + latestTurnRows, + retractionRows, + stateRows, + ]) => Effect.sync(() => { let updatedAt: string | null = null; const projects: OrchestrationProject[] = []; @@ -1732,6 +1925,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { } const latestTurnByThread = new Map(); + const messagesByThread = new Map>(); + const activitiesByThread = new Map>(); + const checkpointsByThread = new Map>(); + const turnRetractionByThread = new Map(); for (let index = 0; index < latestTurnRows.length; index += 1) { const row = latestTurnRows[index]; if (!row) { @@ -1742,6 +1939,56 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { const proposedPlansByThread = new Map>(); const sessionByThread = new Map(); + for (const row of messageRows) { + const messages = messagesByThread.get(row.threadId) ?? []; + messages.push({ + id: row.messageId, + role: row.role, + text: row.text, + ...(row.attachments !== null ? { attachments: row.attachments } : {}), + turnId: row.turnId, + streaming: row.isStreaming === 1, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }); + messagesByThread.set(row.threadId, messages); + } + + for (const row of activityRows) { + const activities = activitiesByThread.get(row.threadId) ?? []; + activities.push({ + id: row.activityId, + tone: row.tone, + kind: row.kind, + summary: row.summary, + payload: row.payload, + turnId: row.turnId, + ...(row.sequence !== null ? { sequence: row.sequence } : {}), + createdAt: row.createdAt, + }); + activitiesByThread.set(row.threadId, activities); + } + + for (const row of checkpointRows) { + const checkpoints = checkpointsByThread.get(row.threadId) ?? []; + checkpoints.push({ + turnId: row.turnId, + checkpointTurnCount: row.checkpointTurnCount, + checkpointRef: row.checkpointRef, + status: row.status, + files: row.files, + assistantMessageId: row.assistantMessageId, + completedAt: row.completedAt, + }); + checkpointsByThread.set(row.threadId, checkpoints); + } + + for (const row of retractionRows) { + if (!turnRetractionByThread.has(row.threadId)) { + turnRetractionByThread.set(row.threadId, mapTurnRetraction(row)); + } + } + for (let index = 0; index < sessionRows.length; index += 1) { const row = sessionRows[index]; if (!row) { @@ -1785,11 +2032,12 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pinnedAt: row.pinnedAt, pinOrderKey: row.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), + turnRetraction: turnRetractionByThread.get(row.threadId) ?? null, deletedAt: row.deletedAt, - messages: [], + messages: messagesByThread.get(row.threadId) ?? [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], - activities: [], - checkpoints: [], + activities: activitiesByThread.get(row.threadId) ?? [], + checkpoints: checkpointsByThread.get(row.threadId) ?? [], session: sessionByThread.get(row.threadId) ?? null, }); } @@ -2377,6 +2625,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { checkpointRows, latestTurnRow, sessionRow, + retractionRow, ] = yield* Effect.all([ getActiveThreadRowById({ threadId }).pipe( Effect.mapError( @@ -2440,6 +2689,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), + getLatestTurnRetractionRowByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:getTurnRetraction:query", + "ProjectionSnapshotQuery.getThreadDetailById:getTurnRetraction:decodeRow", + ), + ), + ), ]); if (Option.isNone(threadRow)) { @@ -2466,6 +2723,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pinnedAt: threadRow.value.pinnedAt, pinOrderKey: threadRow.value.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(threadRow.value), + turnRetraction: Option.isSome(retractionRow) + ? mapTurnRetraction(retractionRow.value) + : null, deletedAt: null, messages: messageRows.map((row) => { const message = { diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index a48bb29e154b..fc9525ee6e4a 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -142,6 +142,43 @@ function threadHasQueuedTurnStart( ); } +function newestUserMessage(thread: OrchestrationReadModel["threads"][number]) { + return thread.messages + .filter((message) => message.role === "user") + .toSorted( + (left, right) => + right.createdAt.localeCompare(left.createdAt) || right.id.localeCompare(left.id), + )[0]; +} + +function turnHasVisibleAssistantOutput( + thread: OrchestrationReadModel["threads"][number], + turnId: string, +): boolean { + if ( + thread.messages.some( + (message) => + message.role === "assistant" && message.turnId === turnId && message.text.length > 0, + ) + ) { + return true; + } + if ( + thread.activities.some( + (activity) => + activity.turnId === turnId && + activity.kind !== "task.progress" && + activity.kind !== "task.started", + ) + ) { + return true; + } + if (thread.proposedPlans.some((plan) => plan.turnId === turnId)) { + return true; + } + return thread.checkpoints.some((checkpoint) => checkpoint.turnId === turnId); +} + function withEventBase( input: Pick & { readonly aggregateKind: OrchestrationEvent["aggregateKind"]; @@ -1046,6 +1083,75 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.turn.retract": { + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + const latestUserMessage = newestUserMessage(thread); + if (latestUserMessage?.id !== command.messageId) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Message '${command.messageId}' is not the newest user message on thread '${command.threadId}'.`, + }); + } + if (thread.turnRetraction?.status === "requested") { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Thread '${command.threadId}' already has pending retraction '${thread.turnRetraction.requestId}'.`, + }); + } + + const queued = threadHasQueuedTurnStart(thread, command.createdAt); + const starting = thread.session?.status === "starting" && queued; + const targetTurnId = + thread.session?.status === "running" && + thread.session.activeTurnId !== null && + thread.latestTurn?.turnId === thread.session.activeTurnId + ? thread.session.activeTurnId + : null; + if (!queued && !starting && targetTurnId === null) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Thread '${command.threadId}' has no queued, starting, or matching running turn to retract.`, + }); + } + if (targetTurnId !== null && turnHasVisibleAssistantOutput(thread, targetTurnId)) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Turn '${targetTurnId}' already has assistant-visible output and can no longer be retracted.`, + }); + } + + const baselineTurnCount = thread.checkpoints.reduce( + (latest, checkpoint) => Math.max(latest, checkpoint.checkpointTurnCount), + 0, + ); + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.turn-interrupt-requested", + payload: { + threadId: command.threadId, + ...(targetTurnId !== null ? { turnId: targetTurnId } : {}), + createdAt: command.createdAt, + retraction: { + requestId: command.commandId, + messageId: command.messageId, + targetTurnId, + baselineTurnCount, + firstUserMessage: + thread.messages.filter((message) => message.role === "user").length === 1, + }, + }, + }; + } + case "thread.approval.respond": { yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/decider.turnRetraction.test.ts b/apps/server/src/orchestration/decider.turnRetraction.test.ts new file mode 100644 index 000000000000..3ce2c44369d8 --- /dev/null +++ b/apps/server/src/orchestration/decider.turnRetraction.test.ts @@ -0,0 +1,328 @@ +import { + CheckpointRef, + CommandId, + EventId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, + TurnId, + type OrchestrationReadModel, + type OrchestrationEvent, + type OrchestrationThread, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; + +const NOW = "2026-01-01T00:00:01.000Z"; +const MESSAGE_AT = "2026-01-01T00:00:00.000Z"; +const THREAD_ID = ThreadId.make("thread-retract"); +const MESSAGE_ID = MessageId.make("message-latest"); +const TURN_ID = TurnId.make("turn-current"); + +function makeMessage( + id: MessageId, + role: "user" | "assistant", + text: string, + turnId: TurnId | null = null, + createdAt = MESSAGE_AT, +): OrchestrationThread["messages"][number] { + return { id, role, text, turnId, streaming: false, createdAt, updatedAt: createdAt }; +} + +function makeThread(patch: Partial = {}): OrchestrationThread { + return { + id: THREAD_ID, + projectId: ProjectId.make("project-1"), + title: "Retract", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: MESSAGE_AT, + updatedAt: NOW, + archivedAt: null, + settledOverride: null, + settledAt: null, + deletedAt: null, + messages: [makeMessage(MESSAGE_ID, "user", "undo this")], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + ...patch, + }; +} + +function runningThread(patch: Partial = {}) { + return makeThread({ + latestTurn: { + turnId: TURN_ID, + state: "running", + requestedAt: MESSAGE_AT, + startedAt: MESSAGE_AT, + completedAt: null, + assistantMessageId: null, + }, + session: { + threadId: THREAD_ID, + status: "running", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: TURN_ID, + lastError: null, + updatedAt: MESSAGE_AT, + }, + ...patch, + }); +} + +function readModel(thread: OrchestrationThread): OrchestrationReadModel { + return { snapshotSequence: 0, projects: [], threads: [thread], updatedAt: NOW }; +} + +function retract(thread: OrchestrationThread, commandId = "cmd-retract") { + return decideOrchestrationCommand({ + command: { + type: "thread.turn.retract", + commandId: CommandId.make(commandId), + threadId: THREAD_ID, + messageId: MESSAGE_ID, + createdAt: NOW, + }, + readModel: readModel(thread), + }); +} + +function firstEvent( + result: + | Omit + | ReadonlyArray>, +) { + return Array.isArray(result) ? result[0] : (result as Omit); +} + +function invariantDetail(error: unknown): string { + expect(error).toHaveProperty("detail"); + return (error as { readonly detail: string }).detail; +} + +it.layer(NodeServices.layer)("thread.turn.retract decider", (it) => { + it.effect("accepts queued, starting, and matching running lifecycle states", () => + Effect.gen(function* () { + const queued = makeThread(); + const starting = makeThread({ + session: { + threadId: THREAD_ID, + status: "starting", + providerName: "Claude", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: NOW, + }, + }); + + for (const [index, thread] of [queued, starting, runningThread()].entries()) { + const event = firstEvent(yield* retract(thread, `cmd-accept-${index}`)); + if (event?.type !== "thread.turn-interrupt-requested") continue; + expect(event.payload.retraction).toMatchObject({ + requestId: CommandId.make(`cmd-accept-${index}`), + messageId: MESSAGE_ID, + baselineTurnCount: 0, + firstUserMessage: true, + }); + expect(event.payload.retraction?.targetTurnId).toBe(index === 2 ? TURN_ID : null); + } + }), + ); + + it.effect("keeps thinking-only and empty assistant state retractable", () => + Effect.gen(function* () { + const thread = runningThread({ + messages: [ + makeMessage(MESSAGE_ID, "user", "undo this"), + makeMessage(MessageId.make("assistant-empty"), "assistant", "", TURN_ID), + ], + activities: [ + { + id: EventId.make("activity-thinking"), + tone: "info", + kind: "task.progress", + summary: "Reasoning update", + payload: {}, + turnId: TURN_ID, + createdAt: NOW, + }, + ], + }); + expect(firstEvent(yield* retract(thread))?.type).toBe("thread.turn-interrupt-requested"); + }), + ); + + it.effect("rejects every assistant-visible output form", () => + Effect.gen(function* () { + const variants: OrchestrationThread[] = [ + runningThread({ + messages: [ + makeMessage(MESSAGE_ID, "user", "undo this"), + makeMessage(MessageId.make("assistant-text"), "assistant", "hello", TURN_ID), + ], + }), + runningThread({ + activities: [ + { + id: EventId.make("activity-tool"), + tone: "tool", + kind: "tool.started", + summary: "Running command", + payload: {}, + turnId: TURN_ID, + createdAt: NOW, + }, + ], + }), + runningThread({ + activities: [ + { + id: EventId.make("activity-plan"), + tone: "info", + kind: "turn.plan.updated", + summary: "Plan updated", + payload: { plan: [] }, + turnId: TURN_ID, + createdAt: NOW, + }, + ], + }), + runningThread({ + proposedPlans: [ + { + id: "plan-1", + turnId: TURN_ID, + planMarkdown: "# Plan", + implementedAt: null, + implementationThreadId: null, + createdAt: NOW, + updatedAt: NOW, + }, + ], + }), + ]; + + for (const [index, thread] of variants.entries()) { + const error = yield* Effect.flip(retract(thread, `cmd-output-${index}`)); + expect(invariantDetail(error)).toContain("assistant-visible output"); + } + }), + ); + + it.effect("rejects idle, mismatched running, stale-message, and duplicate-pending requests", () => + Effect.gen(function* () { + const idleError = yield* Effect.flip( + retract( + makeThread({ + latestTurn: { + turnId: TurnId.make("turn-old"), + state: "completed", + requestedAt: NOW, + startedAt: NOW, + completedAt: NOW, + assistantMessageId: null, + }, + }), + ), + ); + expect(invariantDetail(idleError)).toContain("no queued, starting, or matching running turn"); + + const mismatch = runningThread({ + latestTurn: { + turnId: TurnId.make("turn-other"), + state: "running", + requestedAt: MESSAGE_AT, + startedAt: MESSAGE_AT, + completedAt: null, + assistantMessageId: null, + }, + }); + expect(invariantDetail(yield* Effect.flip(retract(mismatch)))).toContain("no queued"); + + const stale = runningThread({ + messages: [ + makeMessage(MESSAGE_ID, "user", "old", null, "2025-12-31T23:59:59.000Z"), + makeMessage(MessageId.make("message-new"), "user", "new", null, MESSAGE_AT), + ], + }); + expect(invariantDetail(yield* Effect.flip(retract(stale)))).toContain( + "not the newest user message", + ); + + const pending = runningThread({ + turnRetraction: { + requestId: CommandId.make("cmd-existing"), + messageId: MESSAGE_ID, + baselineTurnCount: 0, + baselineCheckpointRef: CheckpointRef.make("refs/t3/checkpoints/thread/turn/0"), + targetTurnId: TURN_ID, + providerSendClaimed: false, + firstUserMessage: true, + requestedAt: NOW, + status: "requested", + completedAt: null, + failedAt: null, + }, + }); + expect(invariantDetail(yield* Effect.flip(retract(pending, "cmd-different")))).toContain( + "already has pending retraction", + ); + }), + ); + + it.effect("records the absolute baseline turn count in accepted intent", () => + Effect.gen(function* () { + const thread = runningThread({ + messages: [ + makeMessage( + MessageId.make("message-first"), + "user", + "first", + null, + "2025-12-30T00:00:00.000Z", + ), + makeMessage(MESSAGE_ID, "user", "undo this"), + ], + checkpoints: [ + { + turnId: TurnId.make("turn-1"), + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.make("ref-1"), + status: "ready", + files: [], + assistantMessageId: null, + completedAt: "2025-12-30T00:01:00.000Z", + }, + { + turnId: TurnId.make("turn-2"), + checkpointTurnCount: 2, + checkpointRef: CheckpointRef.make("ref-2"), + status: "ready", + files: [], + assistantMessageId: null, + completedAt: "2025-12-31T00:01:00.000Z", + }, + ], + }); + const event = firstEvent(yield* retract(thread)); + if (event?.type !== "thread.turn-interrupt-requested") return; + expect(event.payload.retraction).toMatchObject({ + baselineTurnCount: 2, + firstUserMessage: false, + }); + }), + ); +}); diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index f486dcb2bcbc..58f8289cdcb3 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -8,6 +8,8 @@ import { import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +import { checkpointRefForThreadTurn } from "../checkpointing/Utils.ts"; + import { toProjectorDecodeError, type OrchestrationProjectorDecodeError } from "./Errors.ts"; import { MessageSentPayloadSchema, @@ -32,6 +34,7 @@ import { ThreadUnsnoozedPayload, ThreadRevertedPayload, ThreadSessionSetPayload, + ThreadTurnInterruptRequestedPayload, ThreadTurnDiffCompletedPayload, } from "./Schemas.ts"; @@ -612,6 +615,42 @@ export function projectEvent( }; }); + case "thread.turn-interrupt-requested": + return decodeForEvent( + ThreadTurnInterruptRequestedPayload, + event.payload, + event.type, + "payload", + ).pipe( + Effect.map((payload) => { + if (payload.retraction === undefined) return nextBase; + const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + if (!thread) return nextBase; + return { + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + turnRetraction: { + requestId: payload.retraction.requestId, + messageId: payload.retraction.messageId, + baselineTurnCount: payload.retraction.baselineTurnCount, + baselineCheckpointRef: checkpointRefForThreadTurn( + payload.threadId, + payload.retraction.baselineTurnCount, + ), + targetTurnId: payload.retraction.targetTurnId, + providerSendClaimed: false, + firstUserMessage: payload.retraction.firstUserMessage, + requestedAt: payload.createdAt, + status: "requested", + completedAt: null, + failedAt: null, + }, + updatedAt: event.occurredAt, + }), + }; + }), + ); + case "thread.proposed-plan-upserted": return Effect.gen(function* () { const payload = yield* decodeForEvent( @@ -764,6 +803,29 @@ export function projectEvent( proposedPlans, activities, latestTurn, + ...(payload.retraction !== undefined + ? { + turnRetraction: { + requestId: payload.retraction.requestId, + messageId: payload.retraction.messageId, + baselineTurnCount: payload.turnCount, + baselineCheckpointRef: checkpointRefForThreadTurn( + payload.threadId, + payload.turnCount, + ), + targetTurnId: payload.retraction.turnId, + providerSendClaimed: false, + firstUserMessage: payload.retraction.firstUserMessage, + requestedAt: + thread.turnRetraction?.requestId === payload.retraction.requestId + ? thread.turnRetraction.requestedAt + : payload.retraction.completedAt, + status: "completed" as const, + completedAt: payload.retraction.completedAt, + failedAt: null, + }, + } + : {}), updatedAt: event.occurredAt, }), }; @@ -790,10 +852,30 @@ export function projectEvent( .toSorted(compareThreadActivities) .slice(-500); + const failedRetractionRequestId = + payload.activity.kind === "turn.retract.failed" && + typeof payload.activity.payload === "object" && + payload.activity.payload !== null && + "requestId" in payload.activity.payload && + typeof payload.activity.payload.requestId === "string" + ? payload.activity.payload.requestId + : null; + return { ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { activities, + ...(failedRetractionRequestId !== null && + thread.turnRetraction?.requestId === failedRetractionRequestId + ? { + turnRetraction: { + ...thread.turnRetraction, + status: "failed" as const, + completedAt: null, + failedAt: payload.activity.createdAt, + }, + } + : {}), updatedAt: event.occurredAt, }), }; diff --git a/apps/server/src/persistence/Layers/ProjectionTurnRetractions.ts b/apps/server/src/persistence/Layers/ProjectionTurnRetractions.ts new file mode 100644 index 000000000000..0900987627e9 --- /dev/null +++ b/apps/server/src/persistence/Layers/ProjectionTurnRetractions.ts @@ -0,0 +1,165 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Struct from "effect/Struct"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts"; +import { + MarkProjectionTurnRetractionCompleted, + MarkProjectionTurnRetractionFailed, + ProjectionTurnRetraction, + ProjectionTurnRetractionRepository, + ProjectionTurnRetractionRequest, + ProjectionTurnRetractionThread, + type ProjectionTurnRetractionRepositoryShape, +} from "../Services/ProjectionTurnRetractions.ts"; + +const ProjectionTurnRetractionDbRow = ProjectionTurnRetraction.mapFields( + Struct.assign({ + providerSendClaimed: Schema.Number, + firstUserMessage: Schema.Number, + }), +); + +function mapRow(row: typeof ProjectionTurnRetractionDbRow.Type): ProjectionTurnRetraction { + return { + ...row, + providerSendClaimed: row.providerSendClaimed !== 0, + firstUserMessage: row.firstUserMessage !== 0, + }; +} + +function sqlOrDecode(sqlOperation: string, decodeOperation: string) { + return (cause: unknown) => + Schema.isSchemaError(cause) + ? toPersistenceDecodeError(decodeOperation)(cause) + : toPersistenceSqlError(sqlOperation)(cause); +} + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertPendingRow = SqlSchema.void({ + Request: ProjectionTurnRetractionDbRow, + execute: (row) => sql` + INSERT INTO projection_turn_retractions ( + request_id, thread_id, message_id, baseline_turn_count, baseline_checkpoint_ref, + target_turn_id, provider_send_claimed, first_user_message, requested_at, + status, completed_at, failed_at + ) VALUES ( + ${row.requestId}, ${row.threadId}, ${row.messageId}, ${row.baselineTurnCount}, + ${row.baselineCheckpointRef}, ${row.targetTurnId}, ${row.providerSendClaimed}, + ${row.firstUserMessage}, ${row.requestedAt}, ${row.status}, ${row.completedAt}, ${row.failedAt} + ) + ON CONFLICT (request_id) DO UPDATE SET + thread_id = excluded.thread_id, + message_id = excluded.message_id, + baseline_turn_count = excluded.baseline_turn_count, + baseline_checkpoint_ref = excluded.baseline_checkpoint_ref, + target_turn_id = excluded.target_turn_id, + provider_send_claimed = excluded.provider_send_claimed, + first_user_message = excluded.first_user_message, + requested_at = excluded.requested_at, + status = excluded.status, + completed_at = excluded.completed_at, + failed_at = excluded.failed_at + `, + }); + + const markCompletedRow = SqlSchema.void({ + Request: MarkProjectionTurnRetractionCompleted, + execute: (input) => sql` + UPDATE projection_turn_retractions + SET status = 'completed', completed_at = ${input.completedAt}, + target_turn_id = COALESCE(${input.targetTurnId}, target_turn_id), failed_at = NULL + WHERE request_id = ${input.requestId} + `, + }); + + const markFailedRow = SqlSchema.void({ + Request: MarkProjectionTurnRetractionFailed, + execute: (input) => sql` + UPDATE projection_turn_retractions + SET status = 'failed', failed_at = ${input.failedAt}, completed_at = NULL + WHERE request_id = ${input.requestId} + `, + }); + + const getByRequestIdRow = SqlSchema.findOneOption({ + Request: ProjectionTurnRetractionRequest, + Result: ProjectionTurnRetractionDbRow, + execute: ({ requestId }) => sql` + SELECT request_id AS "requestId", thread_id AS "threadId", message_id AS "messageId", + baseline_turn_count AS "baselineTurnCount", baseline_checkpoint_ref AS "baselineCheckpointRef", + target_turn_id AS "targetTurnId", provider_send_claimed AS "providerSendClaimed", + first_user_message AS "firstUserMessage", requested_at AS "requestedAt", status, + completed_at AS "completedAt", failed_at AS "failedAt" + FROM projection_turn_retractions WHERE request_id = ${requestId} LIMIT 1 + `, + }); + + const getLatestByThreadIdRow = SqlSchema.findOneOption({ + Request: ProjectionTurnRetractionThread, + Result: ProjectionTurnRetractionDbRow, + execute: ({ threadId }) => sql` + SELECT request_id AS "requestId", thread_id AS "threadId", message_id AS "messageId", + baseline_turn_count AS "baselineTurnCount", baseline_checkpoint_ref AS "baselineCheckpointRef", + target_turn_id AS "targetTurnId", provider_send_claimed AS "providerSendClaimed", + first_user_message AS "firstUserMessage", requested_at AS "requestedAt", status, + completed_at AS "completedAt", failed_at AS "failedAt" + FROM projection_turn_retractions WHERE thread_id = ${threadId} + ORDER BY + CASE WHEN status = 'requested' THEN 0 ELSE 1 END ASC, + requested_at DESC, + request_id DESC + LIMIT 1 + `, + }); + + const listPendingRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionTurnRetractionDbRow, + execute: () => sql` + SELECT request_id AS "requestId", thread_id AS "threadId", message_id AS "messageId", + baseline_turn_count AS "baselineTurnCount", baseline_checkpoint_ref AS "baselineCheckpointRef", + target_turn_id AS "targetTurnId", provider_send_claimed AS "providerSendClaimed", + first_user_message AS "firstUserMessage", requested_at AS "requestedAt", status, + completed_at AS "completedAt", failed_at AS "failedAt" + FROM projection_turn_retractions WHERE status = 'requested' + ORDER BY requested_at ASC, request_id ASC + `, + }); + + const mapError = sqlOrDecode( + "ProjectionTurnRetractionRepository:query", + "ProjectionTurnRetractionRepository:decode", + ); + + return ProjectionTurnRetractionRepository.of({ + upsertPending: (row) => + upsertPendingRow({ + ...row, + providerSendClaimed: row.providerSendClaimed ? 1 : 0, + firstUserMessage: row.firstUserMessage ? 1 : 0, + }).pipe(Effect.mapError(mapError)), + markCompleted: (input) => markCompletedRow(input).pipe(Effect.mapError(mapError)), + markFailed: (input) => markFailedRow(input).pipe(Effect.mapError(mapError)), + getByRequestId: (input) => + getByRequestIdRow(input).pipe(Effect.map(Option.map(mapRow)), Effect.mapError(mapError)), + getLatestByThreadId: (input) => + getLatestByThreadIdRow(input).pipe(Effect.map(Option.map(mapRow)), Effect.mapError(mapError)), + listPending: () => + listPendingRows(undefined).pipe( + Effect.map((rows) => rows.map(mapRow)), + Effect.mapError(mapError), + ), + } satisfies ProjectionTurnRetractionRepositoryShape); +}); + +export const ProjectionTurnRetractionRepositoryLive = Layer.effect( + ProjectionTurnRetractionRepository, + make, +); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index b137cedfbedd..407f510af622 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -53,6 +53,7 @@ import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts"; import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts"; import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts"; import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts"; +import Migration0041 from "./Migrations/041_ProjectionTurnRetractions.ts"; /** * Migration loader with all migrations defined inline. @@ -105,6 +106,7 @@ export const migrationEntries = [ [38, "ProjectionThreadsPinOrderKey", Migration0038], [39, "ProjectionProjectsDefaultThreadEnvMode", Migration0039], [40, "ProjectionProjectFaviconPath", Migration0040], + [41, "ProjectionTurnRetractions", Migration0041], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/041_ProjectionTurnRetractions.test.ts b/apps/server/src/persistence/Migrations/041_ProjectionTurnRetractions.test.ts new file mode 100644 index 000000000000..fe6da66ec8c0 --- /dev/null +++ b/apps/server/src/persistence/Migrations/041_ProjectionTurnRetractions.test.ts @@ -0,0 +1,54 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("041_ProjectionTurnRetractions", (it) => { + it.effect("upgrades an existing schema with durable, startup-indexed retraction rows", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 40 }); + const existingTables = yield* sql<{ readonly name: string }>` + SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'projection_threads' + `; + assert.equal(existingTables.length, 1); + + yield* runMigrations({ toMigrationInclusive: 41 }); + + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_turn_retractions) + `; + assert.deepEqual( + columns.map((column) => column.name), + [ + "request_id", + "thread_id", + "message_id", + "baseline_turn_count", + "baseline_checkpoint_ref", + "target_turn_id", + "provider_send_claimed", + "first_user_message", + "requested_at", + "status", + "completed_at", + "failed_at", + ], + ); + + const indexes = yield* sql<{ readonly name: string }>` + SELECT name FROM sqlite_master + WHERE type = 'index' AND tbl_name = 'projection_turn_retractions' + `; + const names = new Set(indexes.map((index) => index.name)); + assert.ok(names.has("idx_projection_turn_retractions_pending_thread")); + assert.ok(names.has("idx_projection_turn_retractions_status_requested")); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/041_ProjectionTurnRetractions.ts b/apps/server/src/persistence/Migrations/041_ProjectionTurnRetractions.ts new file mode 100644 index 000000000000..c2ab1aac14bf --- /dev/null +++ b/apps/server/src/persistence/Migrations/041_ProjectionTurnRetractions.ts @@ -0,0 +1,34 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS projection_turn_retractions ( + request_id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL, + message_id TEXT NOT NULL, + baseline_turn_count INTEGER NOT NULL, + baseline_checkpoint_ref TEXT NOT NULL, + target_turn_id TEXT, + provider_send_claimed INTEGER NOT NULL DEFAULT 0, + first_user_message INTEGER NOT NULL, + requested_at TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('requested', 'completed', 'failed')), + completed_at TEXT, + failed_at TEXT + ) + `; + + yield* sql` + CREATE UNIQUE INDEX IF NOT EXISTS idx_projection_turn_retractions_pending_thread + ON projection_turn_retractions(thread_id) + WHERE status = 'requested' + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_turn_retractions_status_requested + ON projection_turn_retractions(status, requested_at, request_id) + `; +}); diff --git a/apps/server/src/persistence/Services/ProjectionTurnRetractions.ts b/apps/server/src/persistence/Services/ProjectionTurnRetractions.ts new file mode 100644 index 000000000000..a541f3402056 --- /dev/null +++ b/apps/server/src/persistence/Services/ProjectionTurnRetractions.ts @@ -0,0 +1,78 @@ +import { + CheckpointRef, + CommandId, + IsoDateTime, + MessageId, + NonNegativeInt, + ThreadId, + ThreadTurnRetractionStatus, + TurnId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import type { ProjectionRepositoryError } from "../Errors.ts"; + +export const ProjectionTurnRetraction = Schema.Struct({ + requestId: CommandId, + threadId: ThreadId, + messageId: MessageId, + baselineTurnCount: NonNegativeInt, + baselineCheckpointRef: CheckpointRef, + targetTurnId: Schema.NullOr(TurnId), + providerSendClaimed: Schema.Boolean, + firstUserMessage: Schema.Boolean, + requestedAt: IsoDateTime, + status: ThreadTurnRetractionStatus, + completedAt: Schema.NullOr(IsoDateTime), + failedAt: Schema.NullOr(IsoDateTime), +}); +export type ProjectionTurnRetraction = typeof ProjectionTurnRetraction.Type; + +const ProjectionTurnRetractionRequest = Schema.Struct({ requestId: CommandId }); +const ProjectionTurnRetractionThread = Schema.Struct({ threadId: ThreadId }); +const MarkProjectionTurnRetractionCompleted = Schema.Struct({ + requestId: CommandId, + completedAt: IsoDateTime, + targetTurnId: Schema.NullOr(TurnId), +}); +const MarkProjectionTurnRetractionFailed = Schema.Struct({ + requestId: CommandId, + failedAt: IsoDateTime, +}); + +export interface ProjectionTurnRetractionRepositoryShape { + readonly upsertPending: ( + row: ProjectionTurnRetraction, + ) => Effect.Effect; + readonly markCompleted: ( + input: typeof MarkProjectionTurnRetractionCompleted.Type, + ) => Effect.Effect; + readonly markFailed: ( + input: typeof MarkProjectionTurnRetractionFailed.Type, + ) => Effect.Effect; + readonly getByRequestId: ( + input: typeof ProjectionTurnRetractionRequest.Type, + ) => Effect.Effect, ProjectionRepositoryError>; + readonly getLatestByThreadId: ( + input: typeof ProjectionTurnRetractionThread.Type, + ) => Effect.Effect, ProjectionRepositoryError>; + readonly listPending: () => Effect.Effect< + ReadonlyArray, + ProjectionRepositoryError + >; +} + +export class ProjectionTurnRetractionRepository extends Context.Service< + ProjectionTurnRetractionRepository, + ProjectionTurnRetractionRepositoryShape +>()("t3/persistence/Services/ProjectionTurnRetractions/ProjectionTurnRetractionRepository") {} + +export { + MarkProjectionTurnRetractionCompleted, + MarkProjectionTurnRetractionFailed, + ProjectionTurnRetractionRequest, + ProjectionTurnRetractionThread, +}; diff --git a/packages/contracts/src/environment.test.ts b/packages/contracts/src/environment.test.ts index 3a4324625a00..215a5170f436 100644 --- a/packages/contracts/src/environment.test.ts +++ b/packages/contracts/src/environment.test.ts @@ -26,4 +26,14 @@ describe("ExecutionEnvironmentDescriptor", () => { }).capabilities.pullRequests, ).toBe(true); }); + + it("version-gates durable turn retraction", () => { + expect(decodeDescriptor(descriptor).capabilities.threadTurnRetraction).toBeUndefined(); + expect( + decodeDescriptor({ + ...descriptor, + capabilities: { ...descriptor.capabilities, threadTurnRetraction: true }, + }).capabilities.threadTurnRetraction, + ).toBe(true); + }); }); diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 8173ad12b4cf..40d62c570efc 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -67,6 +67,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server understands regenerateTitle on thread.meta.update. Absent on older servers, so clients hide the action instead of sending it. */ threadTitleRegeneration: Schema.optionalKey(Schema.Boolean), + /** Server understands thread.turn.retract and durably coordinates its lifecycle. */ + threadTurnRetraction: Schema.optionalKey(Schema.Boolean), /** The update path clients should offer for this server. Absent on servers that must be relaunched manually (dev checkouts, Windows foreground runs, pre-update servers). */ diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index eba1b4648b25..874dcc95a5a2 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -5,6 +5,7 @@ import * as Schema from "effect/Schema"; import { DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, + ClientOrchestrationCommand, ModelSelection, OrchestrationCommand, OrchestrationEvent, @@ -23,6 +24,9 @@ import { ThreadCreatedPayload, ThreadTurnDiff, ThreadTurnStartRequestedPayload, + ThreadTurnRetractCommand, + ThreadTurnInterruptRequestedPayload, + ThreadRevertedPayload, } from "./orchestration.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; @@ -51,8 +55,116 @@ function getOptionValue( } const decodeThreadCreatedPayload = Schema.decodeUnknownEffect(ThreadCreatedPayload); const decodeOrchestrationCommand = Schema.decodeUnknownEffect(OrchestrationCommand); +const decodeClientOrchestrationCommand = Schema.decodeUnknownEffect(ClientOrchestrationCommand); const decodeOrchestrationEvent = Schema.decodeUnknownEffect(OrchestrationEvent); const decodeThreadMetaUpdatedPayload = Schema.decodeUnknownEffect(ThreadMetaUpdatedPayload); +const decodeThreadTurnRetractCommand = Schema.decodeUnknownEffect(ThreadTurnRetractCommand); +const decodeThreadTurnInterruptRequestedPayload = Schema.decodeUnknownEffect( + ThreadTurnInterruptRequestedPayload, +); +const decodeThreadRevertedPayload = Schema.decodeUnknownEffect(ThreadRevertedPayload); +const encodeThreadTurnInterruptRequestedPayload = Schema.encodeUnknownEffect( + ThreadTurnInterruptRequestedPayload, +); +const encodeThreadRevertedPayload = Schema.encodeUnknownEffect(ThreadRevertedPayload); + +it.effect("decodes thread.turn.retract in the client-dispatchable command union", () => + Effect.gen(function* () { + const command = yield* decodeThreadTurnRetractCommand({ + type: "thread.turn.retract", + commandId: "cmd-retract", + threadId: "thread-1", + messageId: "message-1", + createdAt: "2026-01-01T00:00:00.000Z", + }); + const unionCommand = yield* decodeClientOrchestrationCommand(command); + assert.strictEqual(unionCommand.type, "thread.turn.retract"); + }), +); + +it.effect("decodes historical interrupt and reverted payloads without retraction metadata", () => + Effect.gen(function* () { + const interrupt = yield* decodeThreadTurnInterruptRequestedPayload({ + threadId: "thread-1", + turnId: "turn-1", + createdAt: "2026-01-01T00:00:00.000Z", + }); + const reverted = yield* decodeThreadRevertedPayload({ threadId: "thread-1", turnCount: 0 }); + assert.strictEqual(interrupt.retraction, undefined); + assert.strictEqual(reverted.retraction, undefined); + + const storedInterrupt = yield* decodeOrchestrationEvent({ + sequence: 1, + eventId: "event-interrupt-old", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: "2026-01-01T00:00:00.000Z", + commandId: "cmd-interrupt-old", + causationEventId: null, + correlationId: "cmd-interrupt-old", + metadata: {}, + type: "thread.turn-interrupt-requested", + payload: { + threadId: "thread-1", + turnId: "turn-1", + createdAt: "2026-01-01T00:00:00.000Z", + }, + }); + assert.strictEqual(storedInterrupt.type, "thread.turn-interrupt-requested"); + }), +); + +it.effect("roundtrips additive retraction metadata on interrupt and reverted payloads", () => + Effect.gen(function* () { + const interrupt = yield* decodeThreadTurnInterruptRequestedPayload({ + threadId: "thread-1", + turnId: "turn-1", + createdAt: "2026-01-01T00:00:00.000Z", + retraction: { + requestId: "cmd-retract", + messageId: "message-1", + targetTurnId: "turn-1", + baselineTurnCount: 2, + firstUserMessage: false, + }, + }); + assert.deepStrictEqual(yield* encodeThreadTurnInterruptRequestedPayload(interrupt), { + threadId: "thread-1", + turnId: "turn-1", + createdAt: "2026-01-01T00:00:00.000Z", + retraction: { + requestId: "cmd-retract", + messageId: "message-1", + targetTurnId: "turn-1", + baselineTurnCount: 2, + firstUserMessage: false, + }, + }); + + const reverted = yield* decodeThreadRevertedPayload({ + threadId: "thread-1", + turnCount: 2, + retraction: { + requestId: "cmd-retract", + messageId: "message-1", + turnId: "turn-1", + firstUserMessage: false, + completedAt: "2026-01-01T00:00:05.000Z", + }, + }); + assert.deepStrictEqual(yield* encodeThreadRevertedPayload(reverted), { + threadId: "thread-1", + turnCount: 2, + retraction: { + requestId: "cmd-retract", + messageId: "message-1", + turnId: "turn-1", + firstUserMessage: false, + completedAt: "2026-01-01T00:00:05.000Z", + }, + }); + }), +); it.effect("parses turn diff input when fromTurnCount <= toTurnCount", () => Effect.gen(function* () { diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 35fef721efa7..78a150923339 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -361,6 +361,24 @@ export const ThreadTitleRegeneration = Schema.Struct({ }); export type ThreadTitleRegeneration = typeof ThreadTitleRegeneration.Type; +export const ThreadTurnRetractionStatus = Schema.Literals(["requested", "completed", "failed"]); +export type ThreadTurnRetractionStatus = typeof ThreadTurnRetractionStatus.Type; + +export const OrchestrationThreadTurnRetraction = Schema.Struct({ + requestId: CommandId, + messageId: MessageId, + baselineTurnCount: NonNegativeInt, + baselineCheckpointRef: CheckpointRef, + targetTurnId: Schema.NullOr(TurnId), + providerSendClaimed: Schema.Boolean, + firstUserMessage: Schema.Boolean, + requestedAt: IsoDateTime, + status: ThreadTurnRetractionStatus, + completedAt: Schema.NullOr(IsoDateTime), + failedAt: Schema.NullOr(IsoDateTime), +}); +export type OrchestrationThreadTurnRetraction = typeof OrchestrationThreadTurnRetraction.Type; + export const OrchestrationThread = Schema.Struct({ id: ThreadId, projectId: ProjectId, @@ -397,6 +415,8 @@ export const OrchestrationThread = Schema.Struct({ pinOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), // Pending-only state. Optional so older servers remain compatible. titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), + // Durable retract intent. Optional so snapshots from older servers remain compatible. + turnRetraction: Schema.optional(Schema.NullOr(OrchestrationThreadTurnRetraction)), deletedAt: Schema.NullOr(IsoDateTime), messages: Schema.Array(OrchestrationMessage), proposedPlans: Schema.Array(OrchestrationProposedPlan).pipe( @@ -856,6 +876,14 @@ const ThreadTurnInterruptCommand = Schema.Struct({ createdAt: IsoDateTime, }); +export const ThreadTurnRetractCommand = Schema.Struct({ + type: Schema.Literal("thread.turn.retract"), + commandId: CommandId, + threadId: ThreadId, + messageId: MessageId, + createdAt: IsoDateTime, +}); + const ThreadApprovalRespondCommand = Schema.Struct({ type: Schema.Literal("thread.approval.respond"), commandId: CommandId, @@ -915,6 +943,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadInteractionModeSetCommand, ThreadTurnStartCommand, ThreadTurnInterruptCommand, + ThreadTurnRetractCommand, ThreadApprovalRespondCommand, ThreadUserInputRespondCommand, ThreadCheckpointRevertCommand, @@ -943,6 +972,7 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadInteractionModeSetCommand, ClientThreadTurnStartCommand, ThreadTurnInterruptCommand, + ThreadTurnRetractCommand, ThreadApprovalRespondCommand, ThreadUserInputRespondCommand, ThreadCheckpointRevertCommand, @@ -1247,6 +1277,15 @@ export const ThreadTurnInterruptRequestedPayload = Schema.Struct({ threadId: ThreadId, turnId: Schema.optional(TurnId), createdAt: IsoDateTime, + retraction: Schema.optional( + Schema.Struct({ + requestId: CommandId, + messageId: MessageId, + targetTurnId: Schema.NullOr(TurnId), + baselineTurnCount: NonNegativeInt, + firstUserMessage: Schema.Boolean, + }), + ), }); export const ThreadApprovalResponseRequestedPayload = Schema.Struct({ @@ -1272,6 +1311,15 @@ export const ThreadCheckpointRevertRequestedPayload = Schema.Struct({ export const ThreadRevertedPayload = Schema.Struct({ threadId: ThreadId, turnCount: NonNegativeInt, + retraction: Schema.optional( + Schema.Struct({ + requestId: CommandId, + messageId: MessageId, + turnId: Schema.NullOr(TurnId), + firstUserMessage: Schema.Boolean, + completedAt: IsoDateTime, + }), + ), }); export const ThreadSessionStopRequestedPayload = Schema.Struct({ From 44c7c4ee12b4c44f277f24b2da1a20b312d35444 Mon Sep 17 00:00:00 2001 From: Adam Firestone Date: Tue, 11 Aug 2026 11:47:34 -0500 Subject: [PATCH 03/18] feat(server): durable provider-send ownership and baseline-before-send gating Retract can now cancel an unclaimed provider send before session spawn, and sends are atomically claimed after the pre-turn baseline checkpoint exists. Adds the dispatch-ownership ledger (migration 042) and restart classification. Built with Claude Fable 5 via Claude Code, implementation by Codex (GPT-5.6-Sol). --- .../Layers/CheckpointReactor.test.ts | 42 ++++ .../orchestration/Layers/CheckpointReactor.ts | 141 ++++++------ .../Layers/OrchestrationReactor.test.ts | 1 + .../Layers/ProjectionPipeline.test.ts | 91 ++++++++ .../Layers/ProjectionPipeline.ts | 1 + .../Layers/ProjectionSnapshotQuery.test.ts | 1 + .../Layers/ProjectionSnapshotQuery.ts | 3 + .../Layers/ProviderCommandReactor.test.ts | 205 +++++++++++++++++- .../Layers/ProviderCommandReactor.ts | 65 +++++- .../Services/CheckpointReactor.ts | 14 ++ apps/server/src/orchestration/projector.ts | 4 +- .../Layers/ProjectionTurnRetractions.ts | 98 ++++++++- apps/server/src/persistence/Migrations.ts | 2 + ...42_ProjectionTurnDispatchOwnership.test.ts | 50 +++++ .../042_ProjectionTurnDispatchOwnership.ts | 27 +++ .../Services/ProjectionTurnRetractions.ts | 23 ++ packages/contracts/src/orchestration.ts | 5 + 17 files changed, 689 insertions(+), 84 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/042_ProjectionTurnDispatchOwnership.test.ts create mode 100644 apps/server/src/persistence/Migrations/042_ProjectionTurnDispatchOwnership.ts diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 5431e78fc8d9..99c96825dd8d 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -449,6 +449,7 @@ describe("CheckpointReactor", () => { return { engine, + reactor, readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()), provider, cwd, @@ -456,6 +457,47 @@ describe("CheckpointReactor", () => { }; } + it("ensures the pre-turn baseline directly and skips an existing ref", async () => { + const harness = await createHarness({ seedFilesystemCheckpoints: false }); + const checkpointRef = checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0); + + expect(gitRefExists(harness.cwd, checkpointRef)).toBe(false); + await Effect.runPromise( + harness.reactor.ensurePreTurnBaseline({ + threadId: ThreadId.make("thread-1"), + createdAt: "2026-01-01T00:00:00.000Z", + }), + ); + expect(gitRefExists(harness.cwd, checkpointRef)).toBe(true); + + const firstOid = runGit(harness.cwd, ["rev-parse", checkpointRef]).trim(); + await Effect.runPromise( + harness.reactor.ensurePreTurnBaseline({ + threadId: ThreadId.make("thread-1"), + createdAt: "2026-01-01T00:00:01.000Z", + }), + ); + expect(runGit(harness.cwd, ["rev-parse", checkpointRef]).trim()).toBe(firstOid); + }); + + it("allows non-Git workspaces through without a baseline ref", async () => { + const nonGitCwd = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-non-git-")); + tempDirs.push(nonGitCwd); + const harness = await createHarness({ + seedFilesystemCheckpoints: false, + projectWorkspaceRoot: nonGitCwd, + threadWorktreePath: nonGitCwd, + }); + + const result = await Effect.runPromise( + harness.reactor.ensurePreTurnBaseline({ + threadId: ThreadId.make("thread-1"), + createdAt: "2026-01-01T00:00:00.000Z", + }), + ); + expect(result).toBeNull(); + }); + it("captures pre-turn baseline on turn.started and post-turn checkpoint on turn.completed", async () => { const harness = await createHarness({ seedFilesystemCheckpoints: false }); const createdAt = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index b3fc56f18f68..4ac89ee853fa 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -11,11 +11,14 @@ import { type VcsStatusLocalResult, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; +import * as Cache from "effect/Cache"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Semaphore from "effect/Semaphore"; import type * as PlatformError from "effect/PlatformError"; import * as Stream from "effect/Stream"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; @@ -88,6 +91,11 @@ const make = Effect.gen(function* () { const receiptBus = yield* RuntimeReceiptBus; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; + const baselineCaptureLocks = yield* Cache.make({ + capacity: 10_000, + timeToLive: Duration.minutes(30), + lookup: () => Semaphore.make(1), + }); const appendRevertFailureActivity = (input: { readonly threadId: ThreadId; @@ -215,6 +223,57 @@ const make = Effect.gen(function* () { return cwd; }); + const ensurePreTurnBaseline: CheckpointReactorShape["ensurePreTurnBaseline"] = Effect.fn( + "ensurePreTurnBaseline", + )(function* (input) { + const lock = yield* Cache.get(baselineCaptureLocks, input.threadId); + return yield* lock.withPermit( + Effect.gen(function* () { + const thread = yield* resolveThreadDetail(input.threadId); + if (!thread) { + return null; + } + + const projects = yield* resolveThreadProjects(thread.projectId); + const checkpointCwd = yield* resolveCheckpointCwd({ + threadId: input.threadId, + thread, + projects, + preferSessionRuntime: false, + }); + // Non-Git workspaces have no hidden checkpoint ref. Provider work can + // proceed, but a later retract can only restore files best-effort. + if (!checkpointCwd) { + return null; + } + + const currentTurnCount = thread.checkpoints.reduce( + (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount), + 0, + ); + const baselineCheckpointRef = checkpointRefForThreadTurn(input.threadId, currentTurnCount); + const baselineExists = yield* checkpointStore.hasCheckpointRef({ + cwd: checkpointCwd, + checkpointRef: baselineCheckpointRef, + }); + if (!baselineExists) { + yield* checkpointStore.captureCheckpoint({ + cwd: checkpointCwd, + checkpointRef: baselineCheckpointRef, + }); + yield* receiptBus.publish({ + type: "checkpoint.baseline.captured", + threadId: input.threadId, + checkpointTurnCount: currentTurnCount, + checkpointRef: baselineCheckpointRef, + createdAt: input.createdAt, + }); + } + return baselineCheckpointRef; + }), + ); + }); + // Shared tail for both capture paths: creates the git checkpoint ref, diffs // it against the previous turn, then dispatches the domain events to update // the orchestration read model. @@ -486,44 +545,8 @@ const make = Effect.gen(function* () { return; } - const thread = yield* resolveThreadDetail(event.threadId); - if (!thread) { - return; - } - - const projects = yield* resolveThreadProjects(thread.projectId); - const checkpointCwd = yield* resolveCheckpointCwd({ - threadId: thread.id, - thread, - projects, - preferSessionRuntime: false, - }); - if (!checkpointCwd) { - return; - } - - const currentTurnCount = thread.checkpoints.reduce( - (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount), - 0, - ); - const baselineCheckpointRef = checkpointRefForThreadTurn(thread.id, currentTurnCount); - const baselineExists = yield* checkpointStore.hasCheckpointRef({ - cwd: checkpointCwd, - checkpointRef: baselineCheckpointRef, - }); - if (baselineExists) { - return; - } - - yield* checkpointStore.captureCheckpoint({ - cwd: checkpointCwd, - checkpointRef: baselineCheckpointRef, - }); - yield* receiptBus.publish({ - type: "checkpoint.baseline.captured", - threadId: thread.id, - checkpointTurnCount: currentTurnCount, - checkpointRef: baselineCheckpointRef, + yield* ensurePreTurnBaseline({ + threadId: event.threadId, createdAt: event.createdAt, }); }, @@ -644,45 +667,8 @@ const make = Effect.gen(function* () { } } - const threadId = event.payload.threadId; - const thread = yield* resolveThreadDetail(threadId); - if (!thread) { - return; - } - - const projects = yield* resolveThreadProjects(thread.projectId); - const checkpointCwd = yield* resolveCheckpointCwd({ - threadId, - thread, - projects, - preferSessionRuntime: false, - }); - if (!checkpointCwd) { - return; - } - - const currentTurnCount = thread.checkpoints.reduce( - (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount), - 0, - ); - const baselineCheckpointRef = checkpointRefForThreadTurn(threadId, currentTurnCount); - const baselineExists = yield* checkpointStore.hasCheckpointRef({ - cwd: checkpointCwd, - checkpointRef: baselineCheckpointRef, - }); - if (baselineExists) { - return; - } - - yield* checkpointStore.captureCheckpoint({ - cwd: checkpointCwd, - checkpointRef: baselineCheckpointRef, - }); - yield* receiptBus.publish({ - type: "checkpoint.baseline.captured", - threadId, - checkpointTurnCount: currentTurnCount, - checkpointRef: baselineCheckpointRef, + yield* ensurePreTurnBaseline({ + threadId: event.payload.threadId, createdAt: event.occurredAt, }); }); @@ -946,6 +932,7 @@ const make = Effect.gen(function* () { }); return { + ensurePreTurnBaseline, start, drain: worker.drain, } satisfies CheckpointReactorShape; diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts index 300d1526bb9a..d2737b98f0e0 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts @@ -48,6 +48,7 @@ describe("OrchestrationReactor", () => { ), Layer.provideMerge( Layer.succeed(CheckpointReactor, { + ensurePreTurnBaseline: () => Effect.succeed(null), start: () => { started.push("checkpoint-reactor"); return Effect.void; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index b284533c27dd..22fe30f76239 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -19,6 +19,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; +import { ProjectionTurnRetractionRepositoryLive } from "../../persistence/Layers/ProjectionTurnRetractions.ts"; import { makeSqlitePersistenceLive, SqlitePersistenceMemory, @@ -284,10 +285,18 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-retraction-proj assert.equal(pending.length, 1); assert.equal(pending[0]?.requestId, CommandId.make("cmd-retract-1")); assert.equal(pending[0]?.providerSendClaimed, false); + assert.equal(pending[0]?.providerSendState, "unclaimed"); assert.equal(pending[0]?.status, "requested"); assert.equal(pending[0]?.baselineTurnCount, 2); assert.match(pending[0]?.baselineCheckpointRef ?? "", /\/turn\/2$/); + const sendClaim = yield* retractions.claimProviderSend({ + threadId, + messageId: MessageId.make("message-4"), + claimedAt: "2026-01-01T00:00:01.500Z", + }); + assert.equal(sendClaim, "claimed"); + yield* eventStore.append({ type: "thread.activity-appended", eventId: EventId.make("evt-retract-failed-1"), @@ -345,6 +354,17 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-retraction-proj }, }, }); + yield* pipeline.bootstrap; + + const claimedPending = yield* retractions.getByRequestId({ + requestId: CommandId.make("cmd-retract-2"), + }); + assert.equal(claimedPending._tag, "Some"); + if (claimedPending._tag === "Some") { + assert.equal(claimedPending.value.providerSendClaimed, true); + assert.equal(claimedPending.value.providerSendState, "claimed"); + } + yield* eventStore.append({ type: "thread.reverted", eventId: EventId.make("evt-retract-completed-2"), @@ -2812,6 +2832,77 @@ it.effect("restores pending turn-start metadata across projection pipeline resta ), ); +it.effect("retains provider-send claim classification across repository restart", () => + Effect.gen(function* () { + const { dbPath } = yield* ServerConfig; + const threadId = ThreadId.make("thread-retraction-claim-restart"); + const messageId = MessageId.make("message-retraction-claim-restart"); + const requestId = CommandId.make("request-retraction-claim-restart"); + + yield* Effect.gen(function* () { + const retractions = yield* ProjectionTurnRetractionRepository; + assert.equal( + yield* retractions.claimProviderSend({ + threadId, + messageId, + claimedAt: "2026-01-01T00:00:00.000Z", + }), + "claimed", + ); + yield* retractions.upsertPending({ + requestId, + threadId, + messageId, + baselineTurnCount: 0, + baselineCheckpointRef: CheckpointRef.make( + "refs/t3/threads/thread-retraction-claim-restart/turn/0", + ), + targetTurnId: null, + providerSendClaimed: false, + providerSendState: "unclaimed", + firstUserMessage: true, + requestedAt: "2026-01-01T00:00:01.000Z", + status: "requested", + completedAt: null, + failedAt: null, + }); + }).pipe( + Effect.provide( + ProjectionTurnRetractionRepositoryLive.pipe( + Layer.provide(makeSqlitePersistenceLive(dbPath)), + ), + ), + ); + + const restarted = yield* Effect.gen(function* () { + const retractions = yield* ProjectionTurnRetractionRepository; + return yield* retractions.getByRequestId({ requestId }); + }).pipe( + Effect.provide( + ProjectionTurnRetractionRepositoryLive.pipe( + Layer.provide(makeSqlitePersistenceLive(dbPath)), + ), + ), + ); + + assert.equal(restarted._tag, "Some"); + if (restarted._tag === "Some") { + assert.equal(restarted.value.providerSendClaimed, true); + assert.equal(restarted.value.providerSendState, "claimed"); + assert.equal(restarted.value.status, "requested"); + } + }).pipe( + Effect.provide( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-retraction-claim-restart-", + }), + NodeServices.layer, + ), + ), + ), +); + const engineLayer = it.layer( OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionSnapshotQueryLive), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 4b54b7dcaf71..c34b4918745c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1635,6 +1635,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ), targetTurnId: retraction.targetTurnId, providerSendClaimed: false, + providerSendState: "unclaimed", firstUserMessage: retraction.firstUserMessage, requestedAt: event.payload.createdAt, status: "requested", diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 36e1171ace13..3fe81c023d6c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -364,6 +364,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { baselineCheckpointRef: CheckpointRef.make("checkpoint-1"), targetTurnId: TurnId.make("turn-1"), providerSendClaimed: true, + providerSendState: "claimed", firstUserMessage: false, requestedAt: "2026-02-24T00:00:08.500Z", status: "requested", diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 6a182e398810..f016e8534fb5 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -310,6 +310,7 @@ function mapTurnRetraction( baselineCheckpointRef: row.baselineCheckpointRef, targetTurnId: row.targetTurnId, providerSendClaimed: row.providerSendClaimed !== 0, + providerSendState: row.providerSendClaimed !== 0 ? "claimed" : row.providerSendState, firstUserMessage: row.firstUserMessage !== 0, requestedAt: row.requestedAt, status: row.status, @@ -670,6 +671,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { baseline_checkpoint_ref AS "baselineCheckpointRef", target_turn_id AS "targetTurnId", provider_send_claimed AS "providerSendClaimed", + provider_send_state AS "providerSendState", first_user_message AS "firstUserMessage", requested_at AS "requestedAt", status, @@ -697,6 +699,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { baseline_checkpoint_ref AS "baselineCheckpointRef", target_turn_id AS "targetTurnId", provider_send_claimed AS "providerSendClaimed", + provider_send_state AS "providerSendState", first_user_message AS "firstUserMessage", requested_at AS "requestedAt", status, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 2b4d3771605a..a8b22d07ebc4 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -13,6 +13,7 @@ import { import { createModelSelection } from "@t3tools/shared/model"; import { ApprovalRequestId, + CheckpointRef, CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, EventId, @@ -58,6 +59,7 @@ import { import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; +import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Clock from "effect/Clock"; import { ServerSettingsService } from "../../serverSettings.ts"; @@ -153,6 +155,8 @@ describe("ProviderCommandReactor", () => { readonly startSessionEffect?: ( session: ProviderSession, ) => Effect.Effect; + readonly beforeThreadDetailRead?: (callIndex: number) => Effect.Effect; + readonly ensurePreTurnBaselineEffect?: () => Effect.Effect; }) { const now = "2026-01-01T00:00:00.000Z"; const baseDir = @@ -235,6 +239,9 @@ describe("ProviderCommandReactor", () => { turnId: asTurnId("turn-1"), }), ); + const ensurePreTurnBaseline = vi.fn( + () => input?.ensurePreTurnBaselineEffect?.() ?? Effect.succeed(null), + ); const interruptTurn = vi.fn((_: unknown) => Effect.void); const respondToRequest = vi.fn(() => Effect.void); const respondToUserInput = vi.fn(() => Effect.void); @@ -361,6 +368,23 @@ describe("ProviderCommandReactor", () => { Layer.provide(RepositoryIdentityResolver.layer), Layer.provide(SqlitePersistenceMemory), ); + let threadDetailReadCount = 0; + const reactorProjectionSnapshotLayer = Layer.effect( + ProjectionSnapshotQuery, + Effect.gen(function* () { + const query = yield* ProjectionSnapshotQuery; + return { + ...query, + getThreadDetailById: (threadId) => { + threadDetailReadCount += 1; + const beforeRead = input?.beforeThreadDetailRead?.(threadDetailReadCount); + return beforeRead === undefined + ? query.getThreadDetailById(threadId) + : beforeRead.pipe(Effect.andThen(query.getThreadDetailById(threadId))); + }, + } satisfies ProjectionSnapshotQuery["Service"]; + }), + ).pipe(Layer.provide(projectionSnapshotLayer)); let titleRegenerationCompletionDispatchAttempts = 0; const reactorOrchestrationLayer = Layer.effect( OrchestrationEngineService, @@ -389,8 +413,15 @@ describe("ProviderCommandReactor", () => { ).pipe(Layer.provide(orchestrationLayer)); const layer = ProviderCommandReactorLive.pipe( Layer.provideMerge(reactorOrchestrationLayer), - Layer.provideMerge(projectionSnapshotLayer), + Layer.provideMerge(reactorProjectionSnapshotLayer), Layer.provideMerge(Layer.succeed(ProviderService, service)), + Layer.provideMerge( + Layer.succeed(CheckpointReactor, { + ensurePreTurnBaseline, + start: () => Effect.void, + drain: Effect.void, + }), + ), Layer.provideMerge(makeProviderRegistryLayer(providerSnapshots as never)), Layer.provideMerge( Layer.mock(GitWorkflowService.GitWorkflowService)({ @@ -414,6 +445,7 @@ describe("ProviderCommandReactor", () => { ), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), + Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge(NodeServices.layer), ); runtime = ManagedRuntime.make(layer); @@ -503,6 +535,7 @@ describe("ProviderCommandReactor", () => { generateBranchName, generateThreadTitle, runtimeSessions, + ensurePreTurnBaseline, stateDir, drain, runEffect, @@ -552,6 +585,176 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.runtimeMode).toBe("approval-required"); }); + effectIt.effect("cancels an unclaimed retraction before provider session creation", () => + Effect.gen(function* () { + const readEntered = yield* Deferred.make(); + const releaseRead = yield* Deferred.make(); + const harness = yield* Effect.promise(() => + createHarness({ + beforeThreadDetailRead: (callIndex) => + callIndex === 1 + ? Deferred.succeed(readEntered, undefined).pipe( + Effect.andThen(Deferred.await(releaseRead)), + ) + : Effect.void, + }), + ); + const messageId = asMessageId("user-message-cancel-before-spawn"); + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-cancel-before-spawn"), + threadId: ThreadId.make("thread-1"), + message: { + messageId, + role: "user", + text: "cancel before spawn", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }); + yield* Deferred.await(readEntered); + yield* harness.engine.dispatch({ + type: "thread.turn.retract", + commandId: CommandId.make("cmd-retract-cancel-before-spawn"), + threadId: ThreadId.make("thread-1"), + messageId, + createdAt: "2026-01-01T00:00:00.100Z", + }); + yield* Deferred.succeed(releaseRead, undefined); + yield* Effect.promise(() => harness.drain()); + + expect(harness.startSession).not.toHaveBeenCalled(); + expect(harness.sendTurn).not.toHaveBeenCalled(); + expect(harness.interruptTurn).not.toHaveBeenCalled(); + const readModel = yield* Effect.promise(() => harness.readModel()); + expect(readModel.threads[0]?.turnRetraction).toMatchObject({ + status: "requested", + providerSendClaimed: false, + providerSendState: "cancelled", + }); + }), + ); + + effectIt.effect("cancels retract while the provider session is starting", () => + Effect.gen(function* () { + const releaseStart = yield* Deferred.make(); + const harness = yield* Effect.promise(() => + createHarness({ + startSessionEffect: (session) => Deferred.await(releaseStart).pipe(Effect.as(session)), + }), + ); + const messageId = asMessageId("user-message-retract-while-starting"); + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-retract-while-starting"), + threadId: ThreadId.make("thread-1"), + message: { + messageId, + role: "user", + text: "retract while starting", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }); + yield* Effect.promise(() => waitFor(() => harness.startSession.mock.calls.length === 1)); + yield* harness.engine.dispatch({ + type: "thread.turn.retract", + commandId: CommandId.make("cmd-retract-while-starting"), + threadId: ThreadId.make("thread-1"), + messageId, + createdAt: "2026-01-01T00:00:00.100Z", + }); + yield* Deferred.succeed(releaseStart, undefined); + yield* Effect.promise(() => harness.drain()); + + expect(harness.sendTurn).not.toHaveBeenCalled(); + expect(harness.interruptTurn).not.toHaveBeenCalled(); + const readModel = yield* Effect.promise(() => harness.readModel()); + expect(readModel.threads[0]?.turnRetraction?.providerSendState).toBe("cancelled"); + }), + ); + + effectIt.effect("waits for baseline capture before claiming and sending", () => + Effect.gen(function* () { + const releaseBaseline = yield* Deferred.make(); + const harness = yield* Effect.promise(() => + createHarness({ + ensurePreTurnBaselineEffect: () => + Deferred.await(releaseBaseline).pipe( + Effect.as(CheckpointRef.make("refs/t3/threads/thread-1/turn/0")), + ), + }), + ); + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-baseline-gated"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-baseline-gated"), + role: "user", + text: "wait for baseline", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }); + yield* Effect.promise(() => + waitFor(() => harness.ensurePreTurnBaseline.mock.calls.length === 1), + ); + expect(harness.sendTurn).not.toHaveBeenCalled(); + + yield* Deferred.succeed(releaseBaseline, undefined); + yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 1)); + }), + ); + + effectIt.effect("classifies a claimed send before any provider runtime event", () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => createHarness()); + const messageId = asMessageId("user-message-claimed-no-runtime"); + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-claimed-no-runtime"), + threadId: ThreadId.make("thread-1"), + message: { + messageId, + role: "user", + text: "claim before runtime", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }); + yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 1)); + yield* harness.engine.dispatch({ + type: "thread.turn.retract", + commandId: CommandId.make("cmd-retract-claimed-no-runtime"), + threadId: ThreadId.make("thread-1"), + messageId, + createdAt: "2026-01-01T00:00:00.100Z", + }); + yield* Effect.promise(() => harness.drain()); + + const readModel = yield* Effect.promise(() => harness.readModel()); + expect(readModel.threads[0]?.turnRetraction).toMatchObject({ + status: "requested", + providerSendClaimed: true, + providerSendState: "claimed", + }); + expect(harness.interruptTurn).toHaveBeenCalledTimes(1); + }), + ); + effectIt.effect("projects starting before a slow provider session finishes", () => Effect.gen(function* () { const releaseStart = yield* Deferred.make(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index ff639797179f..927adb8a85d0 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -34,6 +34,9 @@ import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { ProviderRegistry } from "../../provider/Services/ProviderRegistry.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; +import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; +import { ProjectionTurnRetractionRepository } from "../../persistence/Services/ProjectionTurnRetractions.ts"; +import { ProjectionTurnRetractionRepositoryLive } from "../../persistence/Layers/ProjectionTurnRetractions.ts"; import { ProviderCommandReactor, type ProviderCommandReactorShape, @@ -314,6 +317,8 @@ const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const orchestrationEngine = yield* OrchestrationEngineService; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + const checkpointReactor = yield* CheckpointReactor; + const turnRetractions = yield* ProjectionTurnRetractionRepository; const providerService = yield* ProviderService; const providerRegistry = yield* ProviderRegistry; const gitWorkflow = yield* GitWorkflowService; @@ -1094,6 +1099,18 @@ const make = Effect.gen(function* () { return; } + // First gate: when retract won before provider startup, persist the + // send-cancelled handoff and do not create a provider session. WO3 owns + // completion for rows in { status: requested, providerSendState: cancelled }. + if ( + yield* turnRetractions.cancelPendingProviderSend({ + threadId: event.payload.threadId, + messageId: event.payload.messageId, + }) + ) { + return; + } + const isFirstUserMessageTurn = thread.messages.filter((entry) => entry.role === "user").length === 1; if (isFirstUserMessageTurn) { @@ -1179,6 +1196,34 @@ const make = Effect.gen(function* () { return; } + // Provider dispatch cannot cross this boundary until the pre-turn Git ref + // exists. Non-Git workspaces return null and proceed with best-effort file + // restoration semantics. + const baselineReady = yield* checkpointReactor + .ensurePreTurnBaseline({ + threadId: event.payload.threadId, + createdAt: event.payload.createdAt, + }) + .pipe( + Effect.as(true), + Effect.catchCause((cause) => handleTurnStartFailure(cause).pipe(Effect.as(false))), + ); + if (!baselineReady) { + return; + } + + // Second gate and linearization point: atomically claim before calling the + // provider. If retract committed while startup/baseline work was in flight, + // this transitions the row to send-cancelled instead. + const providerSendState = yield* turnRetractions.claimProviderSend({ + threadId: event.payload.threadId, + messageId: event.payload.messageId, + claimedAt: event.payload.createdAt, + }); + if (providerSendState === "cancelled") { + return; + } + yield* providerService .sendTurn(sendTurnRequest.value) .pipe(Effect.catchCause(recoverTurnStartFailure), Effect.forkScoped); @@ -1191,6 +1236,22 @@ const make = Effect.gen(function* () { if (!thread) { return; } + if (event.payload.retraction !== undefined) { + const sendCancelled = yield* turnRetractions.cancelPendingProviderSend({ + threadId: event.payload.threadId, + messageId: event.payload.retraction.messageId, + }); + if (sendCancelled) { + return; + } + + const retraction = yield* turnRetractions.getByRequestId({ + requestId: event.payload.retraction.requestId, + }); + if (Option.isSome(retraction) && retraction.value.providerSendState !== "claimed") { + return; + } + } const hasSession = thread.session && thread.session.status !== "stopped"; if (!hasSession) { return yield* appendProviderFailureActivity({ @@ -1450,4 +1511,6 @@ const make = Effect.gen(function* () { } satisfies ProviderCommandReactorShape; }); -export const ProviderCommandReactorLive = Layer.effect(ProviderCommandReactor, make); +export const ProviderCommandReactorLive = Layer.effect(ProviderCommandReactor, make).pipe( + Layer.provide(ProjectionTurnRetractionRepositoryLive), +); diff --git a/apps/server/src/orchestration/Services/CheckpointReactor.ts b/apps/server/src/orchestration/Services/CheckpointReactor.ts index bd3ee3e88f91..8070f0aa2f84 100644 --- a/apps/server/src/orchestration/Services/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Services/CheckpointReactor.ts @@ -6,14 +6,28 @@ * * @module CheckpointReactor */ +import { type CheckpointRef, type ThreadId } from "@t3tools/contracts"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; import type * as Scope from "effect/Scope"; +import type { CheckpointStoreError } from "../../checkpointing/Errors.ts"; +import type { ProjectionRepositoryError } from "../../persistence/Errors.ts"; + /** * CheckpointReactorShape - Service API for checkpoint reactor lifecycle. */ export interface CheckpointReactorShape { + /** + * Ensures the current pre-turn Git checkpoint exists before provider dispatch. + * Returns null when the workspace is unavailable or non-Git; file restoration + * is necessarily best-effort for those workspaces and provider send may proceed. + */ + readonly ensurePreTurnBaseline: (input: { + readonly threadId: ThreadId; + readonly createdAt: string; + }) => Effect.Effect; + /** * Start the checkpoint reactor. * diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 58f8289cdcb3..6a48da69a227 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -639,6 +639,7 @@ export function projectEvent( ), targetTurnId: payload.retraction.targetTurnId, providerSendClaimed: false, + providerSendState: "unclaimed", firstUserMessage: payload.retraction.firstUserMessage, requestedAt: payload.createdAt, status: "requested", @@ -814,7 +815,8 @@ export function projectEvent( payload.turnCount, ), targetTurnId: payload.retraction.turnId, - providerSendClaimed: false, + providerSendClaimed: thread.turnRetraction?.providerSendClaimed ?? false, + providerSendState: thread.turnRetraction?.providerSendState ?? "unclaimed", firstUserMessage: payload.retraction.firstUserMessage, requestedAt: thread.turnRetraction?.requestId === payload.retraction.requestId diff --git a/apps/server/src/persistence/Layers/ProjectionTurnRetractions.ts b/apps/server/src/persistence/Layers/ProjectionTurnRetractions.ts index 0900987627e9..dc6d13394902 100644 --- a/apps/server/src/persistence/Layers/ProjectionTurnRetractions.ts +++ b/apps/server/src/persistence/Layers/ProjectionTurnRetractions.ts @@ -10,10 +10,12 @@ import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts"; import { MarkProjectionTurnRetractionCompleted, MarkProjectionTurnRetractionFailed, + CancelProjectionTurnProviderSend, ProjectionTurnRetraction, ProjectionTurnRetractionRepository, ProjectionTurnRetractionRequest, ProjectionTurnRetractionThread, + ProjectionTurnProviderSend, type ProjectionTurnRetractionRepositoryShape, } from "../Services/ProjectionTurnRetractions.ts"; @@ -25,9 +27,11 @@ const ProjectionTurnRetractionDbRow = ProjectionTurnRetraction.mapFields( ); function mapRow(row: typeof ProjectionTurnRetractionDbRow.Type): ProjectionTurnRetraction { + const providerSendState = row.providerSendClaimed !== 0 ? "claimed" : row.providerSendState; return { ...row, - providerSendClaimed: row.providerSendClaimed !== 0, + providerSendClaimed: providerSendState === "claimed", + providerSendState, firstUserMessage: row.firstUserMessage !== 0, }; } @@ -47,11 +51,19 @@ const make = Effect.gen(function* () { execute: (row) => sql` INSERT INTO projection_turn_retractions ( request_id, thread_id, message_id, baseline_turn_count, baseline_checkpoint_ref, - target_turn_id, provider_send_claimed, first_user_message, requested_at, + target_turn_id, provider_send_claimed, provider_send_state, first_user_message, requested_at, status, completed_at, failed_at ) VALUES ( ${row.requestId}, ${row.threadId}, ${row.messageId}, ${row.baselineTurnCount}, - ${row.baselineCheckpointRef}, ${row.targetTurnId}, ${row.providerSendClaimed}, + ${row.baselineCheckpointRef}, ${row.targetTurnId}, + CASE WHEN EXISTS ( + SELECT 1 FROM provider_turn_send_claims + WHERE thread_id = ${row.threadId} AND message_id = ${row.messageId} + ) THEN 1 ELSE ${row.providerSendClaimed} END, + CASE WHEN EXISTS ( + SELECT 1 FROM provider_turn_send_claims + WHERE thread_id = ${row.threadId} AND message_id = ${row.messageId} + ) THEN 'claimed' ELSE ${row.providerSendState} END, ${row.firstUserMessage}, ${row.requestedAt}, ${row.status}, ${row.completedAt}, ${row.failedAt} ) ON CONFLICT (request_id) DO UPDATE SET @@ -60,7 +72,8 @@ const make = Effect.gen(function* () { baseline_turn_count = excluded.baseline_turn_count, baseline_checkpoint_ref = excluded.baseline_checkpoint_ref, target_turn_id = excluded.target_turn_id, - provider_send_claimed = excluded.provider_send_claimed, + provider_send_claimed = projection_turn_retractions.provider_send_claimed, + provider_send_state = projection_turn_retractions.provider_send_state, first_user_message = excluded.first_user_message, requested_at = excluded.requested_at, status = excluded.status, @@ -88,6 +101,40 @@ const make = Effect.gen(function* () { `, }); + const getPendingByMessageRow = SqlSchema.findOneOption({ + Request: CancelProjectionTurnProviderSend, + Result: ProjectionTurnRetractionDbRow, + execute: ({ threadId, messageId }) => sql` + SELECT request_id AS "requestId", thread_id AS "threadId", message_id AS "messageId", + baseline_turn_count AS "baselineTurnCount", baseline_checkpoint_ref AS "baselineCheckpointRef", + target_turn_id AS "targetTurnId", provider_send_claimed AS "providerSendClaimed", + provider_send_state AS "providerSendState", first_user_message AS "firstUserMessage", + requested_at AS "requestedAt", status, completed_at AS "completedAt", failed_at AS "failedAt" + FROM projection_turn_retractions + WHERE thread_id = ${threadId} AND message_id = ${messageId} AND status = 'requested' + LIMIT 1 + `, + }); + + const markProviderSendCancelledRow = SqlSchema.void({ + Request: ProjectionTurnRetractionRequest, + execute: ({ requestId }) => sql` + UPDATE projection_turn_retractions + SET provider_send_claimed = 0, provider_send_state = 'cancelled' + WHERE request_id = ${requestId} AND status = 'requested' + AND provider_send_state = 'unclaimed' + `, + }); + + const upsertProviderSendClaimRow = SqlSchema.void({ + Request: ProjectionTurnProviderSend, + execute: ({ threadId, messageId, claimedAt }) => sql` + INSERT INTO provider_turn_send_claims (thread_id, message_id, claimed_at) + VALUES (${threadId}, ${messageId}, ${claimedAt}) + ON CONFLICT (thread_id, message_id) DO NOTHING + `, + }); + const getByRequestIdRow = SqlSchema.findOneOption({ Request: ProjectionTurnRetractionRequest, Result: ProjectionTurnRetractionDbRow, @@ -95,6 +142,7 @@ const make = Effect.gen(function* () { SELECT request_id AS "requestId", thread_id AS "threadId", message_id AS "messageId", baseline_turn_count AS "baselineTurnCount", baseline_checkpoint_ref AS "baselineCheckpointRef", target_turn_id AS "targetTurnId", provider_send_claimed AS "providerSendClaimed", + provider_send_state AS "providerSendState", first_user_message AS "firstUserMessage", requested_at AS "requestedAt", status, completed_at AS "completedAt", failed_at AS "failedAt" FROM projection_turn_retractions WHERE request_id = ${requestId} LIMIT 1 @@ -108,6 +156,7 @@ const make = Effect.gen(function* () { SELECT request_id AS "requestId", thread_id AS "threadId", message_id AS "messageId", baseline_turn_count AS "baselineTurnCount", baseline_checkpoint_ref AS "baselineCheckpointRef", target_turn_id AS "targetTurnId", provider_send_claimed AS "providerSendClaimed", + provider_send_state AS "providerSendState", first_user_message AS "firstUserMessage", requested_at AS "requestedAt", status, completed_at AS "completedAt", failed_at AS "failedAt" FROM projection_turn_retractions WHERE thread_id = ${threadId} @@ -126,6 +175,7 @@ const make = Effect.gen(function* () { SELECT request_id AS "requestId", thread_id AS "threadId", message_id AS "messageId", baseline_turn_count AS "baselineTurnCount", baseline_checkpoint_ref AS "baselineCheckpointRef", target_turn_id AS "targetTurnId", provider_send_claimed AS "providerSendClaimed", + provider_send_state AS "providerSendState", first_user_message AS "firstUserMessage", requested_at AS "requestedAt", status, completed_at AS "completedAt", failed_at AS "failedAt" FROM projection_turn_retractions WHERE status = 'requested' @@ -147,6 +197,46 @@ const make = Effect.gen(function* () { }).pipe(Effect.mapError(mapError)), markCompleted: (input) => markCompletedRow(input).pipe(Effect.mapError(mapError)), markFailed: (input) => markFailedRow(input).pipe(Effect.mapError(mapError)), + claimProviderSend: (input) => + sql + .withTransaction( + Effect.gen(function* () { + const pending = yield* getPendingByMessageRow(input); + if (Option.isSome(pending)) { + const row = mapRow(pending.value); + if (row.providerSendState === "cancelled") { + return "cancelled" as const; + } + if (row.providerSendState === "unclaimed") { + yield* markProviderSendCancelledRow({ requestId: row.requestId }); + return "cancelled" as const; + } + } + + yield* upsertProviderSendClaimRow(input); + return "claimed" as const; + }), + ) + .pipe(Effect.mapError(mapError)), + cancelPendingProviderSend: (input) => + sql + .withTransaction( + Effect.gen(function* () { + const pending = yield* getPendingByMessageRow(input); + if (Option.isNone(pending)) { + return false; + } + const row = mapRow(pending.value); + if (row.providerSendState === "claimed") { + return false; + } + if (row.providerSendState === "unclaimed") { + yield* markProviderSendCancelledRow({ requestId: row.requestId }); + } + return true; + }), + ) + .pipe(Effect.mapError(mapError)), getByRequestId: (input) => getByRequestIdRow(input).pipe(Effect.map(Option.map(mapRow)), Effect.mapError(mapError)), getLatestByThreadId: (input) => diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 407f510af622..b4d0eb156b77 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -54,6 +54,7 @@ import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts"; import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts"; import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts"; import Migration0041 from "./Migrations/041_ProjectionTurnRetractions.ts"; +import Migration0042 from "./Migrations/042_ProjectionTurnDispatchOwnership.ts"; /** * Migration loader with all migrations defined inline. @@ -107,6 +108,7 @@ export const migrationEntries = [ [39, "ProjectionProjectsDefaultThreadEnvMode", Migration0039], [40, "ProjectionProjectFaviconPath", Migration0040], [41, "ProjectionTurnRetractions", Migration0041], + [42, "ProjectionTurnDispatchOwnership", Migration0042], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/042_ProjectionTurnDispatchOwnership.test.ts b/apps/server/src/persistence/Migrations/042_ProjectionTurnDispatchOwnership.test.ts new file mode 100644 index 000000000000..261ade38b311 --- /dev/null +++ b/apps/server/src/persistence/Migrations/042_ProjectionTurnDispatchOwnership.test.ts @@ -0,0 +1,50 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("042_ProjectionTurnDispatchOwnership", (it) => { + it.effect("adds durable provider-send ownership and claim storage", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 41 }); + yield* sql` + INSERT INTO projection_turn_retractions ( + request_id, thread_id, message_id, baseline_turn_count, + baseline_checkpoint_ref, target_turn_id, provider_send_claimed, + first_user_message, requested_at, status, completed_at, failed_at + ) VALUES ( + 'request-claimed', 'thread-1', 'message-1', 0, + 'refs/t3/thread/thread-1/turn/0', NULL, 1, + 1, '2026-01-01T00:00:00.000Z', 'requested', NULL, NULL + ) + `; + + yield* runMigrations({ toMigrationInclusive: 42 }); + + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_turn_retractions) + `; + assert.equal(columns.at(-1)?.name, "provider_send_state"); + + const rows = yield* sql<{ readonly providerSendState: string }>` + SELECT provider_send_state AS "providerSendState" + FROM projection_turn_retractions + WHERE request_id = 'request-claimed' + `; + assert.deepEqual(rows, [{ providerSendState: "claimed" }]); + + const claimTables = yield* sql<{ readonly name: string }>` + SELECT name FROM sqlite_master + WHERE type = 'table' AND name = 'provider_turn_send_claims' + `; + assert.equal(claimTables.length, 1); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/042_ProjectionTurnDispatchOwnership.ts b/apps/server/src/persistence/Migrations/042_ProjectionTurnDispatchOwnership.ts new file mode 100644 index 000000000000..f6aa27342108 --- /dev/null +++ b/apps/server/src/persistence/Migrations/042_ProjectionTurnDispatchOwnership.ts @@ -0,0 +1,27 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + ALTER TABLE projection_turn_retractions + ADD COLUMN provider_send_state TEXT NOT NULL DEFAULT 'unclaimed' + CHECK (provider_send_state IN ('unclaimed', 'claimed', 'cancelled')) + `; + + yield* sql` + UPDATE projection_turn_retractions + SET provider_send_state = 'claimed' + WHERE provider_send_claimed <> 0 + `; + + yield* sql` + CREATE TABLE IF NOT EXISTS provider_turn_send_claims ( + thread_id TEXT NOT NULL, + message_id TEXT NOT NULL, + claimed_at TEXT NOT NULL, + PRIMARY KEY (thread_id, message_id) + ) + `; +}); diff --git a/apps/server/src/persistence/Services/ProjectionTurnRetractions.ts b/apps/server/src/persistence/Services/ProjectionTurnRetractions.ts index a541f3402056..4e84458c2913 100644 --- a/apps/server/src/persistence/Services/ProjectionTurnRetractions.ts +++ b/apps/server/src/persistence/Services/ProjectionTurnRetractions.ts @@ -5,6 +5,7 @@ import { MessageId, NonNegativeInt, ThreadId, + ThreadTurnProviderSendState, ThreadTurnRetractionStatus, TurnId, } from "@t3tools/contracts"; @@ -23,6 +24,7 @@ export const ProjectionTurnRetraction = Schema.Struct({ baselineCheckpointRef: CheckpointRef, targetTurnId: Schema.NullOr(TurnId), providerSendClaimed: Schema.Boolean, + providerSendState: ThreadTurnProviderSendState, firstUserMessage: Schema.Boolean, requestedAt: IsoDateTime, status: ThreadTurnRetractionStatus, @@ -33,6 +35,15 @@ export type ProjectionTurnRetraction = typeof ProjectionTurnRetraction.Type; const ProjectionTurnRetractionRequest = Schema.Struct({ requestId: CommandId }); const ProjectionTurnRetractionThread = Schema.Struct({ threadId: ThreadId }); +const ProjectionTurnProviderSend = Schema.Struct({ + threadId: ThreadId, + messageId: MessageId, + claimedAt: IsoDateTime, +}); +const CancelProjectionTurnProviderSend = Schema.Struct({ + threadId: ThreadId, + messageId: MessageId, +}); const MarkProjectionTurnRetractionCompleted = Schema.Struct({ requestId: CommandId, completedAt: IsoDateTime, @@ -53,6 +64,16 @@ export interface ProjectionTurnRetractionRepositoryShape { readonly markFailed: ( input: typeof MarkProjectionTurnRetractionFailed.Type, ) => Effect.Effect; + /** + * Atomically claims provider send ownership unless an unclaimed retraction is pending. + * A cancelled result is the durable cancel-before-spawn handoff to WO3. + */ + readonly claimProviderSend: ( + input: typeof ProjectionTurnProviderSend.Type, + ) => Effect.Effect<"claimed" | "cancelled", ProjectionRepositoryError>; + readonly cancelPendingProviderSend: ( + input: typeof CancelProjectionTurnProviderSend.Type, + ) => Effect.Effect; readonly getByRequestId: ( input: typeof ProjectionTurnRetractionRequest.Type, ) => Effect.Effect, ProjectionRepositoryError>; @@ -73,6 +94,8 @@ export class ProjectionTurnRetractionRepository extends Context.Service< export { MarkProjectionTurnRetractionCompleted, MarkProjectionTurnRetractionFailed, + ProjectionTurnProviderSend, + CancelProjectionTurnProviderSend, ProjectionTurnRetractionRequest, ProjectionTurnRetractionThread, }; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 78a150923339..e77f68a7bf3e 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -364,6 +364,9 @@ export type ThreadTitleRegeneration = typeof ThreadTitleRegeneration.Type; export const ThreadTurnRetractionStatus = Schema.Literals(["requested", "completed", "failed"]); export type ThreadTurnRetractionStatus = typeof ThreadTurnRetractionStatus.Type; +export const ThreadTurnProviderSendState = Schema.Literals(["unclaimed", "claimed", "cancelled"]); +export type ThreadTurnProviderSendState = typeof ThreadTurnProviderSendState.Type; + export const OrchestrationThreadTurnRetraction = Schema.Struct({ requestId: CommandId, messageId: MessageId, @@ -371,6 +374,8 @@ export const OrchestrationThreadTurnRetraction = Schema.Struct({ baselineCheckpointRef: CheckpointRef, targetTurnId: Schema.NullOr(TurnId), providerSendClaimed: Schema.Boolean, + // Optional so snapshots written before durable provider-send ownership still decode. + providerSendState: Schema.optional(ThreadTurnProviderSendState), firstUserMessage: Schema.Boolean, requestedAt: IsoDateTime, status: ThreadTurnRetractionStatus, From cd7b3bc4a04714fdbdb26ab8eb63121e63467cfc Mon Sep 17 00:00:00 2001 From: Adam Firestone Date: Tue, 11 Aug 2026 11:47:36 -0500 Subject: [PATCH 04/18] feat(server): managed-worktree provenance and retraction completion command Bootstrap-created worktrees record provenance; thread.turn.retract.complete atomically emits reverted (+deleted for first-message retractions), and deletion cleanup removes only T3-owned, unshared worktrees. Built with Claude Fable 5 via Claude Code, implementation by Codex (GPT-5.6-Sol). --- .../Layers/OrchestrationEngine.test.ts | 13 +- .../Layers/ProjectionPipeline.ts | 4 + .../Layers/ProjectionSnapshotQuery.ts | 9 ++ .../Layers/ThreadDeletionReactor.test.ts | 132 +++++++++++++++++- .../Layers/ThreadDeletionReactor.ts | 54 +++++++ apps/server/src/orchestration/decider.ts | 95 +++++++++++++ .../decider.turnRetraction.test.ts | 91 ++++++++++++ apps/server/src/orchestration/projector.ts | 4 + .../Layers/ProjectionRepositories.test.ts | 2 + .../persistence/Layers/ProjectionThreads.ts | 34 ++++- apps/server/src/persistence/Migrations.ts | 2 + .../042_ProjectionManagedWorktrees.test.ts | 25 ++++ .../042_ProjectionManagedWorktrees.ts | 16 +++ .../persistence/Services/ProjectionThreads.ts | 13 ++ apps/server/src/server.test.ts | 86 +++++++++++- apps/server/src/ws.ts | 29 +++- packages/contracts/src/orchestration.test.ts | 16 +++ packages/contracts/src/orchestration.ts | 37 +++++ 18 files changed, 645 insertions(+), 17 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/042_ProjectionManagedWorktrees.test.ts create mode 100644 apps/server/src/persistence/Migrations/042_ProjectionManagedWorktrees.ts diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 19290d6ec40e..04b4bf0309f6 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -606,17 +606,26 @@ describe("OrchestrationEngine", () => { ); await system.run( engine.dispatch({ - type: "thread.meta.update", + type: "thread.managed-worktree.record", commandId: CommandId.make("cmd-authoritative-worktree-bootstrap"), threadId: ThreadId.make("thread-worktree-bootstrap"), branch: "t3code/1234abcd", - worktreePath: "/tmp/project-worktree-bootstrap-worktree", + managedWorktree: { + projectCwd: "/tmp/project-worktree-bootstrap", + path: "/tmp/project-worktree-bootstrap-worktree", + createdForCommandId: CommandId.make("cmd-worktree-bootstrap-first-send"), + }, }), ); const snapshot = await system.readModel(); expect(snapshot.threads[0]?.branch).toBe("t3code/1234abcd"); expect(snapshot.threads[0]?.worktreePath).toBe("/tmp/project-worktree-bootstrap-worktree"); + expect(snapshot.threads[0]?.managedWorktree).toEqual({ + projectCwd: "/tmp/project-worktree-bootstrap", + path: "/tmp/project-worktree-bootstrap-worktree", + createdForCommandId: CommandId.make("cmd-worktree-bootstrap-first-send"), + }); await system.dispose(); }); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 4b54b7dcaf71..a2365d5ab2e3 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -622,6 +622,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti interactionMode: event.payload.interactionMode, branch: event.payload.branch, worktreePath: event.payload.worktreePath, + managedWorktree: null, latestTurnId: null, createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, @@ -810,6 +811,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...(event.payload.worktreePath !== undefined ? { worktreePath: event.payload.worktreePath } : {}), + ...(event.payload.managedWorktree !== undefined + ? { managedWorktree: event.payload.managedWorktree } + : {}), updatedAt: event.payload.updatedAt, }); return; diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 6a182e398810..615f6c3035f8 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -2,6 +2,7 @@ import { ChatAttachment, CheckpointRef, IsoDateTime, + ManagedWorktreeProvenance, MessageId, NonNegativeInt, OrchestrationCheckpointFile, @@ -87,6 +88,7 @@ const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), + managedWorktree: Schema.NullOr(Schema.fromJsonString(ManagedWorktreeProvenance)), }), ); const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( @@ -445,6 +447,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + managed_worktree_json AS "managedWorktree", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -481,6 +484,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + managed_worktree_json AS "managedWorktree", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -519,6 +523,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + managed_worktree_json AS "managedWorktree", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -1067,6 +1072,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + managed_worktree_json AS "managedWorktree", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -1716,6 +1722,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + managedWorktree: row.managedWorktree, latestTurn: latestTurnByThread.get(row.threadId) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -2021,6 +2028,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + managedWorktree: row.managedWorktree, latestTurn: latestTurnByThread.get(row.threadId) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -2712,6 +2720,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: threadRow.value.interactionMode, branch: threadRow.value.branch, worktreePath: threadRow.value.worktreePath, + managedWorktree: threadRow.value.managedWorktree, latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null, createdAt: threadRow.value.createdAt, updatedAt: threadRow.value.updatedAt, diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts index 34b1b995a3ad..6623cef15d57 100644 --- a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts @@ -1,14 +1,93 @@ -import { ThreadId } from "@t3tools/contracts"; +import { + CommandId, + EventId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationEvent, +} from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import { describe, expect, it } from "vite-plus/test"; -import { logCleanupCauseUnlessInterrupted } from "./ThreadDeletionReactor.ts"; +import type { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts"; +import { + logCleanupCauseUnlessInterrupted, + managedWorktreeCleanupTarget, +} from "./ThreadDeletionReactor.ts"; -describe("logCleanupCauseUnlessInterrupted", () => { - const threadId = ThreadId.make("thread-deletion-reactor-test"); +const threadId = ThreadId.make("thread-deletion-reactor-test"); +const firstSendCommandId = CommandId.make("cmd-first-send"); +const managedWorktree = { + projectCwd: "/tmp/project", + path: "/tmp/project-worktree", + createdForCommandId: firstSendCommandId, +} as const; + +function deletedEvent(retraction = true) { + return { + sequence: 1, + eventId: EventId.make("event-thread-deleted"), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.deleted", + occurredAt: "2026-01-01T00:00:00.000Z", + commandId: CommandId.make("cmd-retract-complete"), + causationEventId: null, + correlationId: CommandId.make("cmd-retract-complete"), + metadata: {}, + payload: { + threadId, + deletedAt: "2026-01-01T00:00:00.000Z", + ...(retraction + ? { + retraction: { + requestId: CommandId.make("cmd-retract"), + messageId: MessageId.make("message-first"), + firstUserMessage: true as const, + managedWorktreeCreatedForCommandId: firstSendCommandId, + }, + } + : {}), + }, + } satisfies Extract; +} +function projectedThread(patch: Partial = {}): ProjectionThread { + return { + threadId, + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feature/thread", + worktreePath: managedWorktree.path, + managedWorktree, + latestTurnId: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, + pinnedAt: null, + pinOrderKey: null, + titleRegenerationRequestId: null, + titleRegenerationStartedAt: null, + latestUserMessageAt: null, + pendingApprovalCount: 0, + pendingUserInputCount: 0, + hasActionableProposedPlan: 0, + deletedAt: "2026-01-01T00:00:00.000Z", + ...patch, + }; +} + +describe("logCleanupCauseUnlessInterrupted", () => { it("swallows ordinary cleanup failures", async () => { const exit = await Effect.runPromiseExit( logCleanupCauseUnlessInterrupted({ @@ -36,3 +115,48 @@ describe("logCleanupCauseUnlessInterrupted", () => { } }); }); + +describe("managedWorktreeCleanupTarget", () => { + it("selects only an exclusively referenced worktree owned by the retracted first send", () => { + expect( + managedWorktreeCleanupTarget({ + event: deletedEvent(), + thread: projectedThread(), + hasOtherLiveReference: false, + }), + ).toEqual(managedWorktree); + }); + + it("rejects regular deletion, pre-existing, mismatched, and shared worktrees", () => { + const cases = [ + { event: deletedEvent(false), thread: projectedThread(), hasOtherLiveReference: false }, + { + event: deletedEvent(), + thread: projectedThread({ managedWorktree: null }), + hasOtherLiveReference: false, + }, + { + event: deletedEvent(), + thread: projectedThread({ worktreePath: "/tmp/other-worktree" }), + hasOtherLiveReference: false, + }, + { event: deletedEvent(), thread: projectedThread(), hasOtherLiveReference: true }, + ]; + for (const input of cases) { + expect(managedWorktreeCleanupTarget(input)).toBeNull(); + } + + const mismatch = deletedEvent(); + if (mismatch.payload.retraction !== undefined) { + mismatch.payload.retraction.managedWorktreeCreatedForCommandId = + CommandId.make("cmd-other-send"); + } + expect( + managedWorktreeCleanupTarget({ + event: mismatch, + thread: projectedThread(), + hasOtherLiveReference: false, + }), + ).toBeNull(); + }); +}); diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts index a026f5ad81bd..9a687be3afa4 100644 --- a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts @@ -3,8 +3,14 @@ import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; +import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; +import { + ProjectionThreadRepository, + type ProjectionThread, +} from "../../persistence/Services/ProjectionThreads.ts"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; import * as TerminalManager from "../../terminal/Manager.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; @@ -16,6 +22,26 @@ import { forkParked } from "../../serverActivation.ts"; type ThreadDeletedEvent = Extract; +export function managedWorktreeCleanupTarget(input: { + readonly event: ThreadDeletedEvent; + readonly thread: ProjectionThread; + readonly hasOtherLiveReference: boolean; +}): ProjectionThread["managedWorktree"] { + const { event, thread } = input; + const managedWorktree = thread.managedWorktree; + if ( + event.payload.retraction === undefined || + managedWorktree === null || + event.payload.retraction.managedWorktreeCreatedForCommandId !== + managedWorktree.createdForCommandId || + thread.worktreePath !== managedWorktree.path || + input.hasOtherLiveReference + ) { + return null; + } + return managedWorktree; +} + export const logCleanupCauseUnlessInterrupted = ({ effect, message, @@ -41,6 +67,8 @@ const make = Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngineService; const providerService = yield* ProviderService; const terminalManager = yield* TerminalManager.TerminalManager; + const projectionThreadRepository = yield* ProjectionThreadRepository; + const gitWorkflow = yield* GitWorkflowService; const stopProviderSession = (threadId: ThreadDeletedEvent["payload"]["threadId"]) => logCleanupCauseUnlessInterrupted({ @@ -56,12 +84,38 @@ const make = Effect.gen(function* () { threadId, }); + const removeRetractedManagedWorktree = Effect.fn("removeRetractedManagedWorktree")(function* ( + event: ThreadDeletedEvent, + ) { + if (event.payload.retraction === undefined) return; + const thread = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(thread) || thread.value.managedWorktree === null) return; + const hasOtherLiveReference = yield* projectionThreadRepository.hasOtherLiveWorktreeReference({ + threadId: event.payload.threadId, + worktreePath: thread.value.managedWorktree.path, + }); + const target = managedWorktreeCleanupTarget({ + event, + thread: thread.value, + hasOtherLiveReference, + }); + if (target === null) return; + yield* logCleanupCauseUnlessInterrupted({ + effect: gitWorkflow.removeWorktree({ cwd: target.projectCwd, path: target.path }), + message: "thread retraction cleanup skipped managed worktree removal", + threadId: event.payload.threadId, + }); + }); + const processThreadDeleted = Effect.fn("processThreadDeleted")(function* ( event: ThreadDeletedEvent, ) { const { threadId } = event.payload; yield* stopProviderSession(threadId); yield* closeThreadTerminals(threadId); + yield* removeRetractedManagedWorktree(event); }); const processThreadDeletedSafely = (event: ThreadDeletedEvent) => diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index fc9525ee6e4a..1254f866ad36 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -877,6 +877,37 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.managed-worktree.record": { + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + if (thread.managedWorktree != null) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Thread '${command.threadId}' already has managed-worktree provenance.`, + }); + } + const occurredAt = yield* nowIso; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.meta-updated", + payload: { + threadId: command.threadId, + branch: command.branch, + worktreePath: command.managedWorktree.path, + managedWorktree: command.managedWorktree, + updatedAt: occurredAt, + }, + }; + } + case "thread.title.regeneration.complete": { const thread = yield* requireThread({ readModel, @@ -1442,6 +1473,70 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.turn.retract.complete": { + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + const retraction = thread.turnRetraction; + if (retraction?.status !== "requested" || retraction.requestId !== command.requestId) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Thread '${command.threadId}' has no matching pending retraction '${command.requestId}'.`, + }); + } + + const revertedEvent: Omit = { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.reverted", + payload: { + threadId: command.threadId, + turnCount: retraction.baselineTurnCount, + retraction: { + requestId: retraction.requestId, + messageId: retraction.messageId, + turnId: retraction.targetTurnId, + firstUserMessage: retraction.firstUserMessage, + completedAt: command.createdAt, + }, + }, + }; + if (!retraction.firstUserMessage) { + return revertedEvent; + } + + const deletedEvent: Omit = { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.deleted", + payload: { + threadId: command.threadId, + deletedAt: command.createdAt, + retraction: { + requestId: retraction.requestId, + messageId: retraction.messageId, + firstUserMessage: true, + ...(thread.managedWorktree != null + ? { + managedWorktreeCreatedForCommandId: thread.managedWorktree.createdForCommandId, + } + : {}), + }, + }, + }; + return [revertedEvent, deletedEvent]; + } + case "thread.activity.append": { const thread = yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/decider.turnRetraction.test.ts b/apps/server/src/orchestration/decider.turnRetraction.test.ts index 3ce2c44369d8..065a6b49b8ac 100644 --- a/apps/server/src/orchestration/decider.turnRetraction.test.ts +++ b/apps/server/src/orchestration/decider.turnRetraction.test.ts @@ -99,6 +99,46 @@ function retract(thread: OrchestrationThread, commandId = "cmd-retract") { }); } +function pendingRetraction(firstUserMessage: boolean): OrchestrationThread { + return makeThread({ + managedWorktree: { + projectCwd: "/tmp/project", + path: "/tmp/project-worktree", + createdForCommandId: CommandId.make("cmd-first-send"), + }, + turnRetraction: { + requestId: CommandId.make("cmd-retract-request"), + messageId: MESSAGE_ID, + baselineTurnCount: firstUserMessage ? 0 : 2, + baselineCheckpointRef: CheckpointRef.make( + firstUserMessage + ? `refs/t3/checkpoints/${THREAD_ID}/0` + : `refs/t3/checkpoints/${THREAD_ID}/2`, + ), + targetTurnId: TURN_ID, + providerSendClaimed: true, + firstUserMessage, + requestedAt: MESSAGE_AT, + status: "requested", + completedAt: null, + failedAt: null, + }, + }); +} + +function completeRetraction(thread: OrchestrationThread) { + return decideOrchestrationCommand({ + command: { + type: "thread.turn.retract.complete", + commandId: CommandId.make("cmd-retract-complete"), + threadId: THREAD_ID, + requestId: CommandId.make("cmd-retract-request"), + createdAt: NOW, + }, + readModel: readModel(thread), + }); +} + function firstEvent( result: | Omit @@ -113,6 +153,57 @@ function invariantDetail(error: unknown): string { } it.layer(NodeServices.layer)("thread.turn.retract decider", (it) => { + it.effect("atomically emits reverted and deleted for first-message completion", () => + Effect.gen(function* () { + const decided = yield* completeRetraction(pendingRetraction(true)); + const events = Array.isArray(decided) ? decided : [decided]; + expect(events.map((event) => event.type)).toEqual(["thread.reverted", "thread.deleted"]); + expect(events[0]).toMatchObject({ + commandId: CommandId.make("cmd-retract-complete"), + type: "thread.reverted", + payload: { + threadId: THREAD_ID, + turnCount: 0, + retraction: { + requestId: CommandId.make("cmd-retract-request"), + messageId: MESSAGE_ID, + turnId: TURN_ID, + firstUserMessage: true, + completedAt: NOW, + }, + }, + }); + expect(events[1]).toMatchObject({ + commandId: CommandId.make("cmd-retract-complete"), + type: "thread.deleted", + payload: { + threadId: THREAD_ID, + deletedAt: NOW, + retraction: { + requestId: CommandId.make("cmd-retract-request"), + messageId: MESSAGE_ID, + firstUserMessage: true, + managedWorktreeCreatedForCommandId: CommandId.make("cmd-first-send"), + }, + }, + }); + }), + ); + + it.effect("emits only reverted for later-message completion", () => + Effect.gen(function* () { + const decided = yield* completeRetraction(pendingRetraction(false)); + const events = Array.isArray(decided) ? decided : [decided]; + expect(events.map((event) => event.type)).toEqual(["thread.reverted"]); + expect(events[0]).toMatchObject({ + payload: { + turnCount: 2, + retraction: { firstUserMessage: false }, + }, + }); + }), + ); + it.effect("accepts queued, starting, and matching running lifecycle states", () => Effect.gen(function* () { const queued = makeThread(); diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 58f8289cdcb3..b203f6bff31c 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -300,6 +300,7 @@ export function projectEvent( interactionMode: payload.interactionMode, branch: payload.branch, worktreePath: payload.worktreePath, + managedWorktree: null, latestTurn: null, createdAt: payload.createdAt, updatedAt: payload.updatedAt, @@ -459,6 +460,9 @@ export function projectEvent( : {}), ...(payload.branch !== undefined ? { branch: payload.branch } : {}), ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}), + ...(payload.managedWorktree !== undefined + ? { managedWorktree: payload.managedWorktree } + : {}), updatedAt: payload.updatedAt, }), })), diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index bebd8fbb4a7d..c3d377548a72 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -88,6 +88,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { interactionMode: "default", branch: null, worktreePath: null, + managedWorktree: null, latestTurnId: null, createdAt: "2026-03-24T00:00:00.000Z", updatedAt: "2026-03-24T00:00:00.000Z", @@ -151,6 +152,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { interactionMode: "default", branch: null, worktreePath: null, + managedWorktree: null, latestTurnId: null, createdAt: "2026-03-24T00:00:00.000Z", updatedAt: "2026-03-25T00:00:00.000Z", diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index b7d8ae137473..02a62a9038a3 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -2,6 +2,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Struct from "effect/Struct"; @@ -9,16 +10,18 @@ import { toPersistenceSqlError } from "../Errors.ts"; import { DeleteProjectionThreadInput, GetProjectionThreadInput, + HasOtherLiveWorktreeReferenceInput, ListProjectionThreadsByProjectInput, ProjectionThread, ProjectionThreadRepository, type ProjectionThreadRepositoryShape, } from "../Services/ProjectionThreads.ts"; -import { ModelSelection } from "@t3tools/contracts"; +import { ManagedWorktreeProvenance, ModelSelection } from "@t3tools/contracts"; const ProjectionThreadDbRow = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), + managedWorktree: Schema.NullOr(Schema.fromJsonString(ManagedWorktreeProvenance)), }), ); type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type; @@ -39,6 +42,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode, branch, worktree_path, + managed_worktree_json, latest_turn_id, created_at, updated_at, @@ -66,6 +70,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.interactionMode}, ${row.branch}, ${row.worktreePath}, + ${row.managedWorktree === null ? null : JSON.stringify(row.managedWorktree)}, ${row.latestTurnId}, ${row.createdAt}, ${row.updatedAt}, @@ -93,6 +98,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode = excluded.interaction_mode, branch = excluded.branch, worktree_path = excluded.worktree_path, + managed_worktree_json = excluded.managed_worktree_json, latest_turn_id = excluded.latest_turn_id, created_at = excluded.created_at, updated_at = excluded.updated_at, @@ -127,6 +133,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + managed_worktree_json AS "managedWorktree", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -163,6 +170,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + managed_worktree_json AS "managedWorktree", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -195,6 +203,20 @@ const makeProjectionThreadRepository = Effect.gen(function* () { `, }); + const hasOtherLiveWorktreeReferenceRow = SqlSchema.findOneOption({ + Request: HasOtherLiveWorktreeReferenceInput, + Result: Schema.Struct({ threadId: Schema.String }), + execute: ({ threadId, worktreePath }) => + sql` + SELECT thread_id AS "threadId" + FROM projection_threads + WHERE thread_id <> ${threadId} + AND deleted_at IS NULL + AND worktree_path = ${worktreePath} + LIMIT 1 + `, + }); + const upsert: ProjectionThreadRepositoryShape["upsert"] = (row) => upsertProjectionThreadRow(row).pipe( Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.upsert:query")), @@ -215,10 +237,20 @@ const makeProjectionThreadRepository = Effect.gen(function* () { Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.deleteById:query")), ); + const hasOtherLiveWorktreeReference: ProjectionThreadRepositoryShape["hasOtherLiveWorktreeReference"] = + (input) => + hasOtherLiveWorktreeReferenceRow(input).pipe( + Effect.map((row) => Option.isSome(row)), + Effect.mapError( + toPersistenceSqlError("ProjectionThreadRepository.hasOtherLiveWorktreeReference:query"), + ), + ); + return { upsert, getById, listByProjectId, + hasOtherLiveWorktreeReference, deleteById, } satisfies ProjectionThreadRepositoryShape; }); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 407f510af622..d53d62ae45e4 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -54,6 +54,7 @@ import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts"; import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts"; import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts"; import Migration0041 from "./Migrations/041_ProjectionTurnRetractions.ts"; +import Migration0042 from "./Migrations/042_ProjectionManagedWorktrees.ts"; /** * Migration loader with all migrations defined inline. @@ -107,6 +108,7 @@ export const migrationEntries = [ [39, "ProjectionProjectsDefaultThreadEnvMode", Migration0039], [40, "ProjectionProjectFaviconPath", Migration0040], [41, "ProjectionTurnRetractions", Migration0041], + [42, "ProjectionManagedWorktrees", Migration0042], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/042_ProjectionManagedWorktrees.test.ts b/apps/server/src/persistence/Migrations/042_ProjectionManagedWorktrees.test.ts new file mode 100644 index 000000000000..6479a69a4074 --- /dev/null +++ b/apps/server/src/persistence/Migrations/042_ProjectionManagedWorktrees.test.ts @@ -0,0 +1,25 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("042_ProjectionManagedWorktrees", (it) => { + it.effect("adds nullable managed-worktree provenance storage", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 41 }); + yield* runMigrations({ toMigrationInclusive: 42 }); + + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + assert.ok(columns.some((column) => column.name === "managed_worktree_json")); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/042_ProjectionManagedWorktrees.ts b/apps/server/src/persistence/Migrations/042_ProjectionManagedWorktrees.ts new file mode 100644 index 000000000000..1ed9e7bf76c0 --- /dev/null +++ b/apps/server/src/persistence/Migrations/042_ProjectionManagedWorktrees.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + + if (!columns.some((column) => column.name === "managed_worktree_json")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN managed_worktree_json TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index c572e1d11ccd..a662ebe990d3 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -9,6 +9,7 @@ import { CommandId, IsoDateTime, + ManagedWorktreeProvenance, ModelSelection, NonNegativeInt, ProjectId, @@ -33,6 +34,7 @@ export const ProjectionThread = Schema.Struct({ interactionMode: ProviderInteractionMode, branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), + managedWorktree: Schema.NullOr(ManagedWorktreeProvenance), latestTurnId: Schema.NullOr(TurnId), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -68,6 +70,12 @@ export const ListProjectionThreadsByProjectInput = Schema.Struct({ }); export type ListProjectionThreadsByProjectInput = typeof ListProjectionThreadsByProjectInput.Type; +export const HasOtherLiveWorktreeReferenceInput = Schema.Struct({ + threadId: ThreadId, + worktreePath: Schema.String, +}); +export type HasOtherLiveWorktreeReferenceInput = typeof HasOtherLiveWorktreeReferenceInput.Type; + /** * ProjectionThreadRepositoryShape - Service API for projected thread records. */ @@ -95,6 +103,11 @@ export interface ProjectionThreadRepositoryShape { input: ListProjectionThreadsByProjectInput, ) => Effect.Effect, ProjectionRepositoryError>; + /** Whether another non-deleted thread currently references this exact worktree path. */ + readonly hasOtherLiveWorktreeReference: ( + input: HasOtherLiveWorktreeReferenceInput, + ) => Effect.Effect; + /** * Soft-delete a projected thread row by id. */ diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 3f63eb4dbef7..3ff2111a8da9 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -7436,7 +7436,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { dispatchedCommands.map((command) => command.type), [ "thread.create", - "thread.meta.update", + "thread.managed-worktree.record", "thread.activity.append", "thread.activity.append", "thread.turn.start", @@ -7472,6 +7472,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }); assert.deepEqual(refreshStatus.mock.calls[0]?.[0], "/tmp/bootstrap-worktree"); + const provenanceCommand = dispatchedCommands[1]; + assertTrue(provenanceCommand?.type === "thread.managed-worktree.record"); + if (provenanceCommand?.type === "thread.managed-worktree.record") { + assert.deepEqual(provenanceCommand.managedWorktree, { + projectCwd: "/tmp/project", + path: "/tmp/bootstrap-worktree", + createdForCommandId: CommandId.make("cmd-bootstrap-turn-start"), + }); + } + const setupActivities = dispatchedCommands.filter( (command): command is Extract => command.type === "thread.activity.append", @@ -7592,6 +7602,66 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("does not record provenance for a draft pointed at a pre-existing worktree", () => + Effect.gen(function* () { + const dispatchedCommands: Array = []; + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + return { sequence: dispatchedCommands.length }; + }), + readEvents: () => Stream.empty, + }, + }, + }); + + const createdAt = "2026-01-01T00:00:00.000Z"; + const wsUrl = yield* getWsServerUrl("/ws"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-bootstrap-pre-existing-worktree"), + threadId: ThreadId.make("thread-bootstrap-pre-existing-worktree"), + message: { + messageId: MessageId.make("msg-bootstrap-pre-existing-worktree"), + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Existing Worktree Thread", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feature/existing", + worktreePath: "/tmp/pre-existing-worktree", + createdAt, + }, + }, + createdAt, + }), + ), + ); + + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.create", "thread.turn.start"], + ); + assertTrue( + dispatchedCommands.every((command) => command.type !== "thread.managed-worktree.record"), + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("records setup-script failures without aborting bootstrap turn start", () => Effect.gen(function* () { const dispatchedCommands: Array = []; @@ -7682,7 +7752,12 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(response.sequence, 4); assert.deepEqual( dispatchedCommands.map((command) => command.type), - ["thread.create", "thread.meta.update", "thread.activity.append", "thread.turn.start"], + [ + "thread.create", + "thread.managed-worktree.record", + "thread.activity.append", + "thread.turn.start", + ], ); const setupFailureActivity = dispatchedCommands.find( (command): command is Extract => @@ -7803,7 +7878,12 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(response.sequence, 4); assert.deepEqual( dispatchedCommands.map((command) => command.type), - ["thread.create", "thread.meta.update", "thread.activity.append", "thread.turn.start"], + [ + "thread.create", + "thread.managed-worktree.record", + "thread.activity.append", + "thread.turn.start", + ], ); const setupActivities = dispatchedCommands.filter( (command): command is Extract => diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 126222d214a2..d8aa3c2169b9 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -941,13 +941,28 @@ const makeWsRpcLayer = ( path: null, }); targetWorktreePath = worktree.worktree.path; - yield* orchestrationEngine.dispatch({ - type: "thread.meta.update", - commandId: yield* serverCommandId("bootstrap-thread-meta-update"), - threadId: command.threadId, - branch: worktree.worktree.refName, - worktreePath: targetWorktreePath, - }); + const metadataCommandId = yield* serverCommandId("bootstrap-thread-meta-update"); + yield* orchestrationEngine.dispatch( + createdThread + ? { + type: "thread.managed-worktree.record", + commandId: metadataCommandId, + threadId: command.threadId, + branch: worktree.worktree.refName, + managedWorktree: { + projectCwd: bootstrap.prepareWorktree.projectCwd, + path: targetWorktreePath, + createdForCommandId: command.commandId, + }, + } + : { + type: "thread.meta.update", + commandId: metadataCommandId, + threadId: command.threadId, + branch: worktree.worktree.refName, + worktreePath: targetWorktreePath, + }, + ); yield* refreshGitStatus(targetWorktreePath); } diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 874dcc95a5a2..9c125c124b69 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -82,6 +82,22 @@ it.effect("decodes thread.turn.retract in the client-dispatchable command union" }), ); +it.effect("keeps thread.turn.retract.complete internal-only", () => + Effect.gen(function* () { + const input = { + type: "thread.turn.retract.complete", + commandId: "cmd-retract-complete", + threadId: "thread-1", + requestId: "cmd-retract", + createdAt: "2026-01-01T00:00:05.000Z", + }; + const command = yield* decodeOrchestrationCommand(input); + assert.strictEqual(command.type, "thread.turn.retract.complete"); + const clientResult = yield* Effect.exit(decodeClientOrchestrationCommand(input)); + assert.strictEqual(clientResult._tag, "Failure"); + }), +); + it.effect("decodes historical interrupt and reverted payloads without retraction metadata", () => Effect.gen(function* () { const interrupt = yield* decodeThreadTurnInterruptRequestedPayload({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 78a150923339..3a4c254b779e 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -379,6 +379,13 @@ export const OrchestrationThreadTurnRetraction = Schema.Struct({ }); export type OrchestrationThreadTurnRetraction = typeof OrchestrationThreadTurnRetraction.Type; +export const ManagedWorktreeProvenance = Schema.Struct({ + projectCwd: TrimmedNonEmptyString, + path: TrimmedNonEmptyString, + createdForCommandId: CommandId, +}); +export type ManagedWorktreeProvenance = typeof ManagedWorktreeProvenance.Type; + export const OrchestrationThread = Schema.Struct({ id: ThreadId, projectId: ProjectId, @@ -390,6 +397,9 @@ export const OrchestrationThread = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + // Present only when first-send bootstrap created this worktree for the thread. + // Optional so snapshots from older servers remain compatible. + managedWorktree: Schema.optional(Schema.NullOr(ManagedWorktreeProvenance)), latestTurn: Schema.NullOr(OrchestrationLatestTurn), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -1045,6 +1055,22 @@ const ThreadRevertCompleteCommand = Schema.Struct({ createdAt: IsoDateTime, }); +const ThreadTurnRetractCompleteCommand = Schema.Struct({ + type: Schema.Literal("thread.turn.retract.complete"), + commandId: CommandId, + threadId: ThreadId, + requestId: CommandId, + createdAt: IsoDateTime, +}); + +const ThreadManagedWorktreeRecordCommand = Schema.Struct({ + type: Schema.Literal("thread.managed-worktree.record"), + commandId: CommandId, + threadId: ThreadId, + branch: TrimmedNonEmptyString, + managedWorktree: ManagedWorktreeProvenance, +}); + const ThreadTitleRegenerationCompleteCommand = Schema.Struct({ type: Schema.Literal("thread.title.regeneration.complete"), commandId: CommandId, @@ -1061,6 +1087,8 @@ const InternalOrchestrationCommand = Schema.Union([ ThreadTurnDiffCompleteCommand, ThreadActivityAppendCommand, ThreadRevertCompleteCommand, + ThreadTurnRetractCompleteCommand, + ThreadManagedWorktreeRecordCommand, ThreadTitleRegenerationCompleteCommand, ]); export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type; @@ -1156,6 +1184,14 @@ export const ThreadCreatedPayload = Schema.Struct({ export const ThreadDeletedPayload = Schema.Struct({ threadId: ThreadId, deletedAt: IsoDateTime, + retraction: Schema.optional( + Schema.Struct({ + requestId: CommandId, + messageId: MessageId, + firstUserMessage: Schema.Literal(true), + managedWorktreeCreatedForCommandId: Schema.optional(CommandId), + }), + ), }); export const ThreadArchivedPayload = Schema.Struct({ @@ -1231,6 +1267,7 @@ export const ThreadMetaUpdatedPayload = Schema.Struct({ modelSelection: Schema.optional(ModelSelection), branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + managedWorktree: Schema.optional(ManagedWorktreeProvenance), updatedAt: IsoDateTime, }); From 28460199efb12d85f2245540bd251e28abafeb38 Mon Sep 17 00:00:00 2001 From: Adam Firestone Date: Tue, 11 Aug 2026 11:47:38 -0500 Subject: [PATCH 05/18] feat(web): chat-scoped Esc trigger, pre-dispatch cancel latch, recovery draft handoff Esc now works with focus anywhere in the chat view (bubble phase; menus win), cancels sends locally before the start RPC dispatches, and capability-gated machinery hands first-message retractions back to a fresh seeded draft. Empty persisted threads render the project hero instead of the bare placeholder. Built with Claude Fable 5 via Claude Code, implementation by Codex (GPT-5.6-Sol). --- apps/web/src/components/ChatView.tsx | 175 +++++++++++---- .../src/components/ComposerPromptEditor.tsx | 13 +- apps/web/src/components/Sidebar.tsx | 2 +- apps/web/src/components/chat/ChatComposer.tsx | 34 +-- .../chat/RetractionRecoveryHandoff.tsx | 48 ++++ .../components/chat/chatEscapeTrigger.test.ts | 57 +++++ .../src/components/chat/chatEscapeTrigger.ts | 48 ++++ .../components/chat/emptyThreadHero.test.ts | 45 ++++ .../src/components/chat/emptyThreadHero.ts | 21 ++ .../chat/lastUserMessageRecovery.test.ts | 181 ++++++++++++++++ .../chat/lastUserMessageRecovery.ts | 205 ++++++++++++++++++ .../chat/preDispatchCancellationLatch.test.ts | 41 ++++ .../chat/preDispatchCancellationLatch.ts | 48 ++++ apps/web/src/composerDraftStore.ts | 56 ++++- apps/web/src/state/retractionCompletions.ts | 28 +++ 15 files changed, 923 insertions(+), 79 deletions(-) create mode 100644 apps/web/src/components/chat/RetractionRecoveryHandoff.tsx create mode 100644 apps/web/src/components/chat/chatEscapeTrigger.test.ts create mode 100644 apps/web/src/components/chat/chatEscapeTrigger.ts create mode 100644 apps/web/src/components/chat/emptyThreadHero.test.ts create mode 100644 apps/web/src/components/chat/emptyThreadHero.ts create mode 100644 apps/web/src/components/chat/lastUserMessageRecovery.test.ts create mode 100644 apps/web/src/components/chat/lastUserMessageRecovery.ts create mode 100644 apps/web/src/components/chat/preDispatchCancellationLatch.test.ts create mode 100644 apps/web/src/components/chat/preDispatchCancellationLatch.ts create mode 100644 apps/web/src/state/retractionCompletions.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e15ca85baf28..84d883a3ef60 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -254,7 +254,15 @@ import { LAST_USER_MESSAGE_POP_SETTLE_TIMEOUT_MS, mergePoppedPrompt, } from "./chat/lastUserMessagePop"; +import { createPreDispatchCancellationLatch } from "./chat/preDispatchCancellationLatch"; +import { CHAT_FLOATING_LAYER_SELECTOR, shouldHandleChatEscape } from "./chat/chatEscapeTrigger"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; +import { shouldRenderEmptyThreadHero } from "./chat/emptyThreadHero"; +import { RetractionRecoveryHandoff } from "./chat/RetractionRecoveryHandoff"; +import { + type FirstMessageRetractionCompletion, + useRetractionRecoveryStore, +} from "./chat/lastUserMessageRecovery"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; @@ -1381,6 +1389,7 @@ function ChatViewContent(props: ChatViewProps) { const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); const sendInFlightRef = useRef(false); + const preDispatchCancellationLatchRef = useRef(createPreDispatchCancellationLatch()); const terminalUiOpenByThreadRef = useRef>({}); useLayoutEffect(() => { @@ -1969,6 +1978,39 @@ function ChatViewContent(props: ChatViewProps) { : (primaryEnvironment?.serverConfig ?? null); const pullRequestsCapabilityKnown = serverConfig !== null; const supportsPullRequests = serverConfig?.environment.capabilities.pullRequests === true; + const supportsThreadTurnRetraction = + serverConfig?.environment.capabilities.threadTurnRetraction === true; + const pendingRetractionRecovery = useRetractionRecoveryStore((state) => + routeKind === "server" + ? (Object.values(state.byRequestId).find( + (recovery) => + recovery.sourceThreadRef.environmentId === routeThreadRef.environmentId && + recovery.sourceThreadRef.threadId === routeThreadRef.threadId, + ) ?? null) + : null, + ); + const projectedRetractionCompletion = useMemo(() => { + const retraction = activeThread?.turnRetraction; + if ( + !pendingRetractionRecovery || + !retraction || + retraction.status !== "completed" || + retraction.requestId !== pendingRetractionRecovery.requestId || + retraction.completedAt === null + ) { + return null; + } + return { + threadId: activeThread.id, + retraction: { + requestId: retraction.requestId, + messageId: retraction.messageId, + turnId: retraction.targetTurnId, + firstUserMessage: retraction.firstUserMessage, + completedAt: retraction.completedAt, + }, + }; + }, [activeThread, pendingRetractionRecovery]); const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); const versionMismatchDismissKey = versionMismatch && activeThread @@ -2565,8 +2607,14 @@ function ChatViewContent(props: ChatViewProps) { const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState(null); const draftHeroDockRequested = activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey; - const isDraftHeroState = - isLocalDraftThread && timelineEntries.length === 0 && !isWorking && !draftHeroDockRequested; + const isDraftHeroState = shouldRenderEmptyThreadHero({ + routeKind, + timelineEntryCount: timelineEntries.length, + isWorking, + phase, + dockRequested: draftHeroDockRequested, + threadDetailLoading, + }); const [ attachDraftHeroTransitionGroupRef, attachDraftHeroComposerAnchorRef, @@ -5081,6 +5129,13 @@ function ChatViewContent(props: ChatViewProps) { return; } + const composerImagesSnapshot = [...composerImages]; + const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; + const composerElementContextsSnapshot = [...composerElementContexts]; + const composerPreviewAnnotationsSnapshot = [...composerPreviewAnnotations]; + const composerReviewCommentsSnapshot: ReviewCommentContext[] = [...composerReviewComments]; + const messageIdForSend = newMessageId(); + preDispatchCancellationLatchRef.current.arm(messageIdForSend); sendInFlightRef.current = true; if (isDraftHeroState && activeThreadKey) { let resolveDockStarted: (() => void) | undefined; @@ -5097,13 +5152,16 @@ function ChatViewContent(props: ChatViewProps) { void dockTransition.catch(() => resolveDockStarted?.()); await dockStarted; } + if (preDispatchCancellationLatchRef.current.isCancelled(messageIdForSend)) { + preDispatchCancellationLatchRef.current.clear(messageIdForSend); + sendInFlightRef.current = false; + setDockedDraftHeroThreadKey((currentThreadKey) => + currentThreadKey === activeThreadKey ? null : currentThreadKey, + ); + return; + } beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) }); - const composerImagesSnapshot = [...composerImages]; - const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; - const composerElementContextsSnapshot = [...composerElementContexts]; - const composerPreviewAnnotationsSnapshot = [...composerPreviewAnnotations]; - const composerReviewCommentsSnapshot: ReviewCommentContext[] = [...composerReviewComments]; const messageTextWithContexts = appendElementContextsToPrompt( appendTerminalContextsToPrompt(promptForSend, composerTerminalContextsSnapshot), composerElementContextsSnapshot, @@ -5116,7 +5174,6 @@ function ChatViewContent(props: ChatViewProps) { messageTextWithPreviewAnnotations, composerReviewCommentsSnapshot, ); - const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); const outgoingMessageText = formatOutgoingPrompt({ provider: ctxSelectedProvider, @@ -5251,6 +5308,7 @@ function ChatViewContent(props: ChatViewProps) { } let turnStartSucceeded = false; + let preDispatchCancelled = false; if (failure === null && turnAttachmentsResult._tag === "Success") { const bootstrap = isLocalDraftThread || baseBranchForWorktree @@ -5282,34 +5340,39 @@ function ChatViewContent(props: ChatViewProps) { : {}), } : undefined; - beginLocalDispatch({ preparingWorktree: false }); - const startResult = await startThreadTurn({ - environmentId, - input: { - threadId: threadIdForSend, - message: { - messageId: messageIdForSend, - role: "user", - text: outgoingMessageText, - attachments: turnAttachmentsResult.value, - }, - modelSelection: ctxSelectedModelSelection, - titleSeed: title, - runtimeMode, - interactionMode, - ...(bootstrap ? { bootstrap } : {}), - createdAt: messageCreatedAt, - }, - }); - if (startResult._tag === "Failure") { - failure = startResult; + if (!preDispatchCancellationLatchRef.current.beginDispatch(messageIdForSend)) { + preDispatchCancelled = + preDispatchCancellationLatchRef.current.isCancelled(messageIdForSend); } else { - turnStartSucceeded = true; - acknowledgeActiveThreadWoke(); + beginLocalDispatch({ preparingWorktree: false }); + const startResult = await startThreadTurn({ + environmentId, + input: { + threadId: threadIdForSend, + message: { + messageId: messageIdForSend, + role: "user", + text: outgoingMessageText, + attachments: turnAttachmentsResult.value, + }, + modelSelection: ctxSelectedModelSelection, + titleSeed: title, + runtimeMode, + interactionMode, + ...(bootstrap ? { bootstrap } : {}), + createdAt: messageCreatedAt, + }, + }); + if (startResult._tag === "Failure") { + failure = startResult; + } else { + turnStartSucceeded = true; + acknowledgeActiveThreadWoke(); + } } } - if (failure !== null) { + if (failure !== null || preDispatchCancelled) { if ( promptRef.current.length === 0 && composerImagesRef.current.length === 0 && @@ -5345,7 +5408,7 @@ function ChatViewContent(props: ChatViewProps) { detectTrigger: true, }); } - if (!isAtomCommandInterrupted(failure)) { + if (failure !== null && !isAtomCommandInterrupted(failure)) { const error = squashAtomCommandFailure(failure); setThreadError( threadIdForSend, @@ -5353,6 +5416,7 @@ function ChatViewContent(props: ChatViewProps) { ); } } + preDispatchCancellationLatchRef.current.clear(messageIdForSend); sendInFlightRef.current = false; if (!turnStartSucceeded) { setDockedDraftHeroThreadKey((currentThreadKey) => @@ -6033,6 +6097,8 @@ function ChatViewContent(props: ChatViewProps) { } void onRevertToTurnCountRef.current(targetTurnCount); }, []); + // Post-dispatch seam: WO5b can replace this saga with thread.turn.retract; + // the pre-dispatch latch and chat-scoped Escape trigger stay unchanged. const onPopLastUserMessage = useCallback(async () => { if (!lastUserMessagePopCandidate || !activeThread || isPoppingLastUserMessage) return; @@ -6156,6 +6222,36 @@ function ChatViewContent(props: ChatViewProps) { waitForRunningTurnToSettle, ]); + useEffect(() => { + const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { + if ( + !shouldHandleChatEscape({ + event, + terminalFocused: getTerminalFocusOwner() !== null, + commandPaletteOpen: isCommandPaletteOpen(), + composerEscapeGateOpen: composerRef.current?.isEscapeGateOpen() ?? false, + floatingLayerOpen: document.querySelector(CHAT_FLOATING_LAYER_SELECTOR) !== null, + }) + ) { + return; + } + + const cancelledMessageId = preDispatchCancellationLatchRef.current.cancel(); + if (cancelledMessageId === null) { + if (lastUserMessagePopCandidate === null || isPoppingLastUserMessage) { + return; + } + void onPopLastUserMessage(); + } + + event.preventDefault(); + event.stopPropagation(); + }; + + window.addEventListener("keydown", onWindowKeyDown); + return () => window.removeEventListener("keydown", onWindowKeyDown); + }, [isPoppingLastUserMessage, lastUserMessagePopCandidate, onPopLastUserMessage]); + // Empty state: no active thread if (!activeThread) { return ; @@ -6299,6 +6395,14 @@ function ChatViewContent(props: ChatViewProps) { return (
+ {supportsThreadTurnRetraction && pendingRetractionRecovery ? ( + + ) : null} {rightPanelOpen && !shouldUseRightPanelSheet ? panelLayoutControls : null}
void; onCommandKeyDown?: ( - key: "ArrowDown" | "ArrowUp" | "Enter" | "Escape" | "Tab", + key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", event: KeyboardEvent, ) => boolean; onPaste: React.ClipboardEventHandler; @@ -903,7 +902,7 @@ interface ComposerPromptEditorProps { function ComposerCommandKeyPlugin(props: { onCommandKeyDown?: ( - key: "ArrowDown" | "ArrowUp" | "Enter" | "Escape" | "Tab", + key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", event: KeyboardEvent, ) => boolean; }) { @@ -911,7 +910,7 @@ function ComposerCommandKeyPlugin(props: { useEffect(() => { const handleCommand = ( - key: "ArrowDown" | "ArrowUp" | "Enter" | "Escape" | "Tab", + key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", event: KeyboardEvent | null, ): boolean => { if (!props.onCommandKeyDown || !event) { @@ -946,11 +945,6 @@ function ComposerCommandKeyPlugin(props: { (event) => handleCommand("Enter", event), COMMAND_PRIORITY_HIGH, ); - const unregisterEscape = editor.registerCommand( - KEY_ESCAPE_COMMAND, - (event) => handleCommand("Escape", event), - COMMAND_PRIORITY_HIGH, - ); const unregisterTab = editor.registerCommand( KEY_TAB_COMMAND, (event) => handleCommand("Tab", event), @@ -961,7 +955,6 @@ function ComposerCommandKeyPlugin(props: { unregisterArrowDown(); unregisterArrowUp(); unregisterEnter(); - unregisterEscape(); unregisterTab(); }; }, [editor, props]); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 18fa2a6708c1..84977a6b2d19 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -568,7 +568,7 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { // new-thread surfaces mint fresh drafts and leave invested ones behind // unmapped, so the mapping only knows about the latest per project. for (const [draftKey, session] of Object.entries(draftThreadsByThreadKey)) { - if (session.promotedTo != null) { + if (session.hidden || session.promotedTo != null) { continue; } if ( diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index a880aedfa015..2ef4b7d14ef4 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -63,6 +63,7 @@ import { } from "../../promptStashStore"; import { ComposerStashBadge } from "./ComposerStashBadge"; import { ComposerStashMenu } from "./ComposerStashMenu"; +import { CHAT_FLOATING_LAYER_SELECTOR } from "./chatEscapeTrigger"; import { compressImageForStash, compressImageToByteLimit } from "../../lib/imageCompression"; import { isCommandPaletteOpen } from "../../commandPaletteBus"; import { getTerminalFocusOwner } from "../../lib/terminalFocus"; @@ -254,14 +255,6 @@ const runtimeModeConfig: Record< }; const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[]; -const COMPOSER_FLOATING_LAYER_SELECTOR = [ - '[data-slot="popover-popup"]', - '[data-slot="menu-popup"]', - '[data-slot="select-popup"]', - '[data-slot="combobox-popup"]', - '[data-slot="autocomplete-popup"]', -].join(","); - const extendReplacementRangeForTrailingSpace = ( text: string, rangeEnd: number, @@ -291,7 +284,7 @@ const terminalContextIdListsEqual = ( contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]); function isInsideComposerFloatingLayer(element: Element): boolean { - return element.closest(COMPOSER_FLOATING_LAYER_SELECTOR) !== null; + return element.closest(CHAT_FLOATING_LAYER_SELECTOR) !== null; } const ComposerFooterModeControls = memo(function ComposerFooterModeControls(props: { @@ -454,6 +447,7 @@ export interface ChatComposerHandle { openModelPicker: () => void; toggleModelPicker: () => void; isModelPickerOpen: () => boolean; + isEscapeGateOpen: () => boolean; readSnapshot: () => { value: string; cursor: number; @@ -567,7 +561,6 @@ export interface ChatComposerProps { // Callbacks onSend: (e?: { preventDefault: () => void }) => void; - onPopLastUserMessage: (() => Promise) | null; onInterrupt: () => void; onImplementPlanInNewThread: () => void; onRespondToApproval: ( @@ -650,7 +643,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerTerminalContextsRef, composerElementContextsRef, onSend, - onPopLastUserMessage, onInterrupt, onImplementPlanInNewThread, onRespondToApproval, @@ -1867,24 +1859,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Callbacks: command key // ------------------------------------------------------------------ const onComposerCommandKey = ( - key: "ArrowDown" | "ArrowUp" | "Enter" | "Escape" | "Tab", + key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", event: KeyboardEvent, ) => { - if (key === "Escape") { - const { trigger } = resolveActiveComposerTrigger(); - const composerMenuOpen = - composerMenuOpenRef.current || - trigger !== null || - isStashMenuOpen || - isComposerModelPickerOpen || - isCommandPaletteOpen() || - document.querySelector(COMPOSER_FLOATING_LAYER_SELECTOR) !== null; - if (composerMenuOpen || onPopLastUserMessage === null) { - return false; - } - void onPopLastUserMessage(); - return true; - } if (key === "Tab" && event.shiftKey) { if (!planModeUiEnabled) return false; toggleInteractionMode(); @@ -2562,6 +2539,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setIsComposerModelPickerOpen((open) => !open); }, isModelPickerOpen: () => isComposerModelPickerOpen, + isEscapeGateOpen: () => + composerMenuOpenRef.current || isStashMenuOpen || isComposerModelPickerOpen, readSnapshot: () => { return readComposerSnapshot(); }, @@ -2652,6 +2631,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) projectSelectionRequired, applyPromptReplacement, isComposerModelPickerOpen, + isStashMenuOpen, readComposerSnapshot, selectedModel, selectedModelOptionsForDispatch, diff --git a/apps/web/src/components/chat/RetractionRecoveryHandoff.tsx b/apps/web/src/components/chat/RetractionRecoveryHandoff.tsx new file mode 100644 index 000000000000..818d617a4757 --- /dev/null +++ b/apps/web/src/components/chat/RetractionRecoveryHandoff.tsx @@ -0,0 +1,48 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { useEffect } from "react"; + +import { type DraftId, useComposerDraftStore } from "../../composerDraftStore"; +import { threadRetractionCompletions } from "../../state/retractionCompletions"; +import { + type FirstMessageRetractionCompletion, + handoffCompletedFirstMessageRetraction, + type PendingRetractionRecovery, +} from "./lastUserMessageRecovery"; + +export function RetractionRecoveryHandoff(props: { + environmentId: EnvironmentId; + recovery: PendingRetractionRecovery; + projectedCompletion: FirstMessageRetractionCompletion | null; + navigate: (input: { + to: "/draft/$draftId"; + params: { draftId: DraftId }; + replace: true; + }) => unknown; +}) { + const result = useAtomValue( + threadRetractionCompletions({ + environmentId: props.environmentId, + input: { threadId: props.recovery.sourceThreadRef.threadId, turnLimit: 1 }, + }), + ); + const liveCompletion = Option.getOrNull(AsyncResult.value(result)); + const completion = liveCompletion ?? props.projectedCompletion; + const recoveryDraftReady = useComposerDraftStore( + (store) => store.getDraftSession(props.recovery.draftId) !== null, + ); + + useEffect(() => { + if (!completion || !recoveryDraftReady) return; + handoffCompletedFirstMessageRetraction({ + capabilityEnabled: true, + environmentId: props.environmentId, + completion, + navigate: props.navigate, + }); + }, [completion, props.environmentId, props.navigate, recoveryDraftReady]); + + return null; +} diff --git a/apps/web/src/components/chat/chatEscapeTrigger.test.ts b/apps/web/src/components/chat/chatEscapeTrigger.test.ts new file mode 100644 index 000000000000..2a5e681351a4 --- /dev/null +++ b/apps/web/src/components/chat/chatEscapeTrigger.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { markChatEscapeHandled, shouldHandleChatEscape } from "./chatEscapeTrigger"; + +function shouldHandle( + event: KeyboardEvent, + overrides: Partial[0], "event">> = {}, +): boolean { + return shouldHandleChatEscape({ + event, + terminalFocused: false, + commandPaletteOpen: false, + composerEscapeGateOpen: false, + floatingLayerOpen: false, + ...overrides, + }); +} + +function keyboardEvent(overrides: Partial = {}): KeyboardEvent { + return { + key: "Escape", + isComposing: false, + defaultPrevented: false, + cancelBubble: false, + target: null, + ...overrides, + } as KeyboardEvent; +} + +describe("chat Escape trigger", () => { + it("handles an unconsumed Escape from timeline and button focus", () => { + expect(shouldHandle(keyboardEvent())).toBe(true); + }); + + it("honors preventDefault and the handled marker", () => { + const prevented = keyboardEvent({ defaultPrevented: true }); + expect(shouldHandle(prevented)).toBe(false); + + const marked = keyboardEvent(); + markChatEscapeHandled(marked); + expect(shouldHandle(marked)).toBe(false); + }); + + it("defers to terminal focus, palettes, composer menus, and floating layers", () => { + const event = keyboardEvent(); + + expect(shouldHandle(event, { terminalFocused: true })).toBe(false); + expect(shouldHandle(event, { commandPaletteOpen: true })).toBe(false); + expect(shouldHandle(event, { composerEscapeGateOpen: true })).toBe(false); + expect(shouldHandle(event, { floatingLayerOpen: true })).toBe(false); + }); + + it("does not take Escape from text inputs outside the composer", () => { + expect(shouldHandle(keyboardEvent(), { textEditingTargetOutsideComposer: true })).toBe(false); + expect(shouldHandle(keyboardEvent(), { textEditingTargetOutsideComposer: false })).toBe(true); + }); +}); diff --git a/apps/web/src/components/chat/chatEscapeTrigger.ts b/apps/web/src/components/chat/chatEscapeTrigger.ts new file mode 100644 index 000000000000..1eac0873fccb --- /dev/null +++ b/apps/web/src/components/chat/chatEscapeTrigger.ts @@ -0,0 +1,48 @@ +export const CHAT_FLOATING_LAYER_SELECTOR = [ + '[data-slot="popover-popup"]', + '[data-slot="menu-popup"]', + '[data-slot="select-popup"]', + '[data-slot="combobox-popup"]', + '[data-slot="autocomplete-popup"]', +].join(","); + +const handledChatEscapeEvents = new WeakSet(); + +/** Allows a chat surface that handles Escape without preventing default to opt out. */ +export function markChatEscapeHandled(event: KeyboardEvent): void { + handledChatEscapeEvents.add(event); +} + +export function isTextEditingTargetOutsideComposer(target: EventTarget | null): boolean { + if (typeof Element === "undefined") return false; + if (!(target instanceof Element)) return false; + if (target.closest('[data-chat-composer-overlay="true"]')) return false; + if (target.closest("input, textarea")) return true; + return target.closest('[contenteditable]:not([contenteditable="false"])') !== null; +} + +export function shouldHandleChatEscape(input: { + event: KeyboardEvent; + terminalFocused: boolean; + commandPaletteOpen: boolean; + composerEscapeGateOpen: boolean; + floatingLayerOpen: boolean; + textEditingTargetOutsideComposer?: boolean; +}): boolean { + const { event } = input; + if (event.key !== "Escape" || event.isComposing) return false; + if (event.defaultPrevented || event.cancelBubble || handledChatEscapeEvents.has(event)) { + return false; + } + if ( + input.terminalFocused || + input.commandPaletteOpen || + input.composerEscapeGateOpen || + input.floatingLayerOpen + ) { + return false; + } + return !( + input.textEditingTargetOutsideComposer ?? isTextEditingTargetOutsideComposer(event.target) + ); +} diff --git a/apps/web/src/components/chat/emptyThreadHero.test.ts b/apps/web/src/components/chat/emptyThreadHero.test.ts new file mode 100644 index 000000000000..d0693bd86bd9 --- /dev/null +++ b/apps/web/src/components/chat/emptyThreadHero.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { shouldRenderEmptyThreadHero } from "./emptyThreadHero"; + +const idlePersistedThread = { + routeKind: "server" as const, + timelineEntryCount: 0, + isWorking: false, + phase: "disconnected" as const, + dockRequested: false, + threadDetailLoading: false, +}; + +describe("empty thread hero", () => { + it("shows the project hero for an empty idle persisted thread", () => { + expect(shouldRenderEmptyThreadHero(idlePersistedThread)).toBe(true); + expect(shouldRenderEmptyThreadHero({ ...idlePersistedThread, phase: "ready" })).toBe(true); + }); + + it("keeps the timeline visible once content or work exists", () => { + expect(shouldRenderEmptyThreadHero({ ...idlePersistedThread, timelineEntryCount: 1 })).toBe( + false, + ); + expect(shouldRenderEmptyThreadHero({ ...idlePersistedThread, isWorking: true })).toBe(false); + }); + + it("does not flash the hero while a persisted thread is loading or starting", () => { + expect(shouldRenderEmptyThreadHero({ ...idlePersistedThread, threadDetailLoading: true })).toBe( + false, + ); + expect(shouldRenderEmptyThreadHero({ ...idlePersistedThread, phase: "connecting" })).toBe( + false, + ); + }); + + it("preserves the existing empty local-draft hero", () => { + expect( + shouldRenderEmptyThreadHero({ + ...idlePersistedThread, + routeKind: "draft", + phase: "connecting", + }), + ).toBe(true); + }); +}); diff --git a/apps/web/src/components/chat/emptyThreadHero.ts b/apps/web/src/components/chat/emptyThreadHero.ts new file mode 100644 index 000000000000..1b22b064cb73 --- /dev/null +++ b/apps/web/src/components/chat/emptyThreadHero.ts @@ -0,0 +1,21 @@ +import type { SessionPhase } from "../../types"; + +export function shouldRenderEmptyThreadHero(input: { + routeKind: "draft" | "server"; + timelineEntryCount: number; + isWorking: boolean; + phase: SessionPhase; + dockRequested: boolean; + threadDetailLoading: boolean; +}): boolean { + if ( + input.timelineEntryCount > 0 || + input.isWorking || + input.dockRequested || + input.threadDetailLoading + ) { + return false; + } + if (input.routeKind === "draft") return true; + return input.phase !== "connecting" && input.phase !== "running"; +} diff --git a/apps/web/src/components/chat/lastUserMessageRecovery.test.ts b/apps/web/src/components/chat/lastUserMessageRecovery.test.ts new file mode 100644 index 000000000000..e73027d927f2 --- /dev/null +++ b/apps/web/src/components/chat/lastUserMessageRecovery.test.ts @@ -0,0 +1,181 @@ +import { + CommandId, + EnvironmentId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { DraftId, useComposerDraftStore } from "../../composerDraftStore"; +import { + handoffCompletedFirstMessageRetraction, + snapshotLastUserMessageRecovery, + useRetractionRecoveryStore, +} from "./lastUserMessageRecovery"; + +const environmentId = EnvironmentId.make("environment-1"); +const projectId = ProjectId.make("project-1"); +const projectRef = scopeProjectRef(environmentId, projectId); +const sourceThreadId = ThreadId.make("source-thread"); +const sourceThreadRef = scopeThreadRef(environmentId, sourceThreadId); +const requestId = CommandId.make("request-1"); +const draftId = DraftId.make("recovery-draft"); +const futureThreadId = ThreadId.make("future-thread"); + +beforeEach(() => { + useComposerDraftStore.setState({ + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, + }); + useRetractionRecoveryStore.setState({ byRequestId: {} }); +}); + +describe("last user message recovery draft", () => { + it("snapshots the full restore bundle into a fresh unmapped draft", async () => { + const file = new File(["image"], "shot.png", { type: "image/png" }); + + const result = await snapshotLastUserMessageRecovery({ + requestId, + sourceThreadRef, + projectRef, + draftId, + futureThreadId, + createdAt: "2026-08-11T12:00:00.000Z", + bundle: { + prompt: "restore this prompt", + images: [ + { + type: "image", + id: "image-1", + name: file.name, + mimeType: file.type, + sizeBytes: file.size, + previewUrl: "blob:recovery-image", + file, + }, + ], + modelSelection: { + instanceId: ProviderInstanceId.make("codex-personal"), + model: "gpt-5.6", + options: [{ id: "reasoningEffort", value: "high" }], + }, + runtimeMode: "full-access", + interactionMode: "plan", + envMode: "worktree", + baseBranch: "main", + startFromOrigin: true, + }, + encodeImage: async () => "data:image/png;base64,aW1hZ2U=", + }); + await Promise.resolve(); + + expect(result).toEqual({ draftId, failedImageNames: [] }); + const session = useComposerDraftStore.getState().getDraftSession(draftId); + expect(session).toMatchObject({ + threadId: futureThreadId, + environmentId, + projectId, + runtimeMode: "full-access", + interactionMode: "plan", + envMode: "worktree", + branch: "main", + worktreePath: null, + startFromOrigin: true, + hidden: true, + }); + expect(useComposerDraftStore.getState().getDraftSessionByProjectRef(projectRef)).toBeNull(); + expect(useComposerDraftStore.getState().getComposerDraft(draftId)).toMatchObject({ + prompt: "restore this prompt", + activeProvider: ProviderInstanceId.make("codex-personal"), + runtimeMode: "full-access", + interactionMode: "plan", + images: [expect.objectContaining({ id: "image-1", file })], + }); + expect(useRetractionRecoveryStore.getState().byRequestId[requestId]).toMatchObject({ + draftId, + sourceThreadRef, + projectRef, + }); + }); + + it("maps and navigates only a capability-gated correlated first-message completion", async () => { + await snapshotLastUserMessageRecovery({ + requestId, + sourceThreadRef, + projectRef, + draftId, + futureThreadId, + createdAt: "2026-08-11T12:00:00.000Z", + bundle: { + prompt: "restore this prompt", + images: [], + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.6", + }, + runtimeMode: "approval-required", + interactionMode: "default", + envMode: "local", + baseBranch: "feature/current", + startFromOrigin: false, + }, + }); + const navigate = vi.fn(); + const completion = { + threadId: sourceThreadId, + retraction: { + requestId, + messageId: MessageId.make("message-1"), + turnId: null, + firstUserMessage: true, + completedAt: "2026-08-11T12:00:05.000Z", + }, + }; + + expect( + handoffCompletedFirstMessageRetraction({ + capabilityEnabled: false, + environmentId, + completion, + navigate, + }), + ).toBe(false); + expect(navigate).not.toHaveBeenCalled(); + + expect( + handoffCompletedFirstMessageRetraction({ + capabilityEnabled: true, + environmentId, + completion: { + ...completion, + retraction: { ...completion.retraction, firstUserMessage: false }, + }, + navigate, + }), + ).toBe(false); + expect(navigate).not.toHaveBeenCalled(); + + expect( + handoffCompletedFirstMessageRetraction({ + capabilityEnabled: true, + environmentId, + completion, + navigate, + }), + ).toBe(true); + expect(useComposerDraftStore.getState().getDraftSessionByProjectRef(projectRef)).toMatchObject({ + draftId, + hidden: false, + }); + expect(navigate).toHaveBeenCalledWith({ + to: "/draft/$draftId", + params: { draftId }, + replace: true, + }); + expect(useRetractionRecoveryStore.getState().byRequestId[requestId]).toBeUndefined(); + }); +}); diff --git a/apps/web/src/components/chat/lastUserMessageRecovery.ts b/apps/web/src/components/chat/lastUserMessageRecovery.ts new file mode 100644 index 000000000000..10780d267ed9 --- /dev/null +++ b/apps/web/src/components/chat/lastUserMessageRecovery.ts @@ -0,0 +1,205 @@ +import type { + CommandId, + MessageId, + ModelSelection, + ProviderInteractionMode, + RuntimeMode, + ScopedProjectRef, + ScopedThreadRef, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +import { + type ComposerImageAttachment, + type DraftId, + type DraftThreadEnvMode, + type PersistedComposerImageAttachment, + useComposerDraftStore, +} from "../../composerDraftStore"; +import { resolveStorage } from "../../lib/storage"; +import { readFileAsDataUrl } from "../ChatView.logic"; + +const RETRACTION_RECOVERY_STORAGE_KEY = "t3code:thread-retraction-recoveries:v1"; + +export interface PendingRetractionRecovery { + requestId: CommandId; + sourceThreadRef: ScopedThreadRef; + projectRef: ScopedProjectRef; + draftId: DraftId; + createdAt: string; +} + +interface RetractionRecoveryStoreState { + byRequestId: Record; + remember: (recovery: PendingRetractionRecovery) => void; + forget: (requestId: CommandId) => void; +} + +let recoveryStorage: Storage | undefined; +try { + recoveryStorage = typeof localStorage === "undefined" ? undefined : localStorage; +} catch { + recoveryStorage = undefined; +} + +export const useRetractionRecoveryStore = create()( + persist( + (set) => ({ + byRequestId: {}, + remember: (recovery) => + set((state) => ({ + byRequestId: { ...state.byRequestId, [recovery.requestId]: recovery }, + })), + forget: (requestId) => + set((state) => { + if (state.byRequestId[requestId] === undefined) return state; + const { [requestId]: _forgotten, ...byRequestId } = state.byRequestId; + return { byRequestId }; + }), + }), + { + name: RETRACTION_RECOVERY_STORAGE_KEY, + version: 1, + storage: createJSONStorage(() => resolveStorage(recoveryStorage)), + partialize: (state) => ({ byRequestId: state.byRequestId }), + }, + ), +); + +export interface LastUserMessageRestoreBundle { + prompt: string; + images: ComposerImageAttachment[]; + modelSelection: ModelSelection; + runtimeMode: RuntimeMode; + interactionMode: ProviderInteractionMode; + envMode: DraftThreadEnvMode; + baseBranch: string | null; + startFromOrigin: boolean; +} + +export interface FirstMessageRetractionCompletion { + threadId: ThreadId; + retraction?: { + requestId: CommandId; + messageId: MessageId; + turnId: TurnId | null; + firstUserMessage: boolean; + completedAt: string; + }; +} + +export async function snapshotLastUserMessageRecovery(input: { + requestId: CommandId; + sourceThreadRef: ScopedThreadRef; + projectRef: ScopedProjectRef; + draftId: DraftId; + futureThreadId: ThreadId; + createdAt: string; + bundle: LastUserMessageRestoreBundle; + encodeImage?: (file: File) => Promise; +}): Promise<{ draftId: DraftId; failedImageNames: string[] }> { + const store = useComposerDraftStore.getState(); + store.createUnmappedDraftSession(input.projectRef, input.draftId, { + threadId: input.futureThreadId, + createdAt: input.createdAt, + runtimeMode: input.bundle.runtimeMode, + interactionMode: input.bundle.interactionMode, + envMode: input.bundle.envMode, + branch: input.bundle.baseBranch, + worktreePath: null, + startFromOrigin: input.bundle.startFromOrigin, + hidden: true, + }); + store.setPrompt(input.draftId, input.bundle.prompt); + store.addImages(input.draftId, input.bundle.images); + store.setModelSelection(input.draftId, input.bundle.modelSelection, { replaceOptions: true }); + store.setRuntimeMode(input.draftId, input.bundle.runtimeMode); + store.setInteractionMode(input.draftId, input.bundle.interactionMode); + + const encodeImage = input.encodeImage ?? readFileAsDataUrl; + const encoded = await Promise.all( + input.bundle.images.map(async (image) => { + try { + const dataUrl = await encodeImage(image.file); + return { + attachment: { + id: image.id, + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + dataUrl, + } satisfies PersistedComposerImageAttachment, + failedName: null, + }; + } catch { + return { attachment: null, failedName: image.name }; + } + }), + ); + store.syncPersistedAttachments( + input.draftId, + encoded.flatMap((entry) => (entry.attachment ? [entry.attachment] : [])), + ); + + useRetractionRecoveryStore.getState().remember({ + requestId: input.requestId, + sourceThreadRef: input.sourceThreadRef, + projectRef: input.projectRef, + draftId: input.draftId, + createdAt: input.createdAt, + }); + + return { + draftId: input.draftId, + failedImageNames: encoded.flatMap((entry) => (entry.failedName ? [entry.failedName] : [])), + }; +} + +export function handoffCompletedFirstMessageRetraction(input: { + capabilityEnabled: boolean; + environmentId: ScopedThreadRef["environmentId"]; + completion: FirstMessageRetractionCompletion; + navigate: (input: { + to: "/draft/$draftId"; + params: { draftId: DraftId }; + replace: true; + }) => unknown; +}): boolean { + const metadata = input.completion.retraction; + if (!input.capabilityEnabled || !metadata?.firstUserMessage) return false; + + const recovery = useRetractionRecoveryStore.getState().byRequestId[metadata.requestId]; + if ( + !recovery || + recovery.sourceThreadRef.environmentId !== input.environmentId || + recovery.sourceThreadRef.threadId !== input.completion.threadId + ) { + return false; + } + + const composerStore = useComposerDraftStore.getState(); + const session = composerStore.getDraftSession(recovery.draftId); + if (!session) return false; + + composerStore.setProjectDraftThreadId(recovery.projectRef, recovery.draftId, { + threadId: session.threadId, + createdAt: session.createdAt, + runtimeMode: session.runtimeMode, + interactionMode: session.interactionMode, + branch: session.branch, + worktreePath: null, + envMode: session.envMode, + startFromOrigin: session.startFromOrigin, + hidden: false, + }); + useRetractionRecoveryStore.getState().forget(metadata.requestId); + void input.navigate({ + to: "/draft/$draftId", + params: { draftId: recovery.draftId }, + replace: true, + }); + return true; +} diff --git a/apps/web/src/components/chat/preDispatchCancellationLatch.test.ts b/apps/web/src/components/chat/preDispatchCancellationLatch.test.ts new file mode 100644 index 000000000000..3b8ef5856d52 --- /dev/null +++ b/apps/web/src/components/chat/preDispatchCancellationLatch.test.ts @@ -0,0 +1,41 @@ +import { MessageId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { createPreDispatchCancellationLatch } from "./preDispatchCancellationLatch"; + +describe("pre-dispatch cancellation latch", () => { + it("cancels the retained message before dispatch begins", () => { + const latch = createPreDispatchCancellationLatch(); + const messageId = MessageId.make("message-1"); + + latch.arm(messageId); + + expect(latch.cancel()).toBe(messageId); + expect(latch.isCancelled(messageId)).toBe(true); + expect(latch.beginDispatch(messageId)).toBe(false); + }); + + it("does nothing when Escape loses the race to an in-flight dispatch", () => { + const latch = createPreDispatchCancellationLatch(); + const messageId = MessageId.make("message-1"); + + latch.arm(messageId); + + expect(latch.beginDispatch(messageId)).toBe(true); + expect(latch.cancel()).toBeNull(); + expect(latch.isCancelled(messageId)).toBe(false); + }); + + it("does not let a stale message clear or dispatch a newer latch", () => { + const latch = createPreDispatchCancellationLatch(); + const staleMessageId = MessageId.make("message-stale"); + const currentMessageId = MessageId.make("message-current"); + + latch.arm(staleMessageId); + latch.arm(currentMessageId); + latch.clear(staleMessageId); + + expect(latch.beginDispatch(staleMessageId)).toBe(false); + expect(latch.cancel()).toBe(currentMessageId); + }); +}); diff --git a/apps/web/src/components/chat/preDispatchCancellationLatch.ts b/apps/web/src/components/chat/preDispatchCancellationLatch.ts new file mode 100644 index 000000000000..177fa2f780fb --- /dev/null +++ b/apps/web/src/components/chat/preDispatchCancellationLatch.ts @@ -0,0 +1,48 @@ +import type { MessageId } from "@t3tools/contracts"; + +type PreDispatchSendState = + | { readonly phase: "idle" } + | { readonly phase: "pending"; readonly messageId: MessageId } + | { readonly phase: "cancelled"; readonly messageId: MessageId } + | { readonly phase: "dispatching"; readonly messageId: MessageId }; + +export interface PreDispatchCancellationLatch { + arm: (messageId: MessageId) => void; + cancel: () => MessageId | null; + isCancelled: (messageId: MessageId) => boolean; + beginDispatch: (messageId: MessageId) => boolean; + clear: (messageId: MessageId) => void; +} + +/** + * Linearizes local Escape cancellation against the turn-start RPC boundary. + * JavaScript runs both transitions synchronously: whichever of `cancel` or + * `beginDispatch` wins first owns the send. + */ +export function createPreDispatchCancellationLatch(): PreDispatchCancellationLatch { + let state: PreDispatchSendState = { phase: "idle" }; + + return { + arm: (messageId) => { + state = { phase: "pending", messageId }; + }, + cancel: () => { + if (state.phase !== "pending") return null; + state = { phase: "cancelled", messageId: state.messageId }; + return state.messageId; + }, + isCancelled: (messageId) => state.phase === "cancelled" && state.messageId === messageId, + beginDispatch: (messageId) => { + if (state.phase !== "pending" || state.messageId !== messageId) { + return false; + } + state = { phase: "dispatching", messageId }; + return true; + }, + clear: (messageId) => { + if (state.phase !== "idle" && state.messageId === messageId) { + state = { phase: "idle" }; + } + }, + }; +} diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index ebafd3b04d29..d78de7a8cb1c 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -216,6 +216,7 @@ const PersistedDraftThreadState = Schema.Struct({ worktreePath: Schema.NullOr(Schema.String), envMode: DraftThreadEnvModeSchema, startFromOrigin: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + hidden: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), promotedTo: Schema.optionalKey( Schema.NullOr( Schema.Struct({ @@ -320,6 +321,7 @@ export interface DraftSessionState { worktreePath: string | null; envMode: DraftThreadEnvMode; startFromOrigin: boolean; + hidden: boolean; promotedTo?: ScopedThreadRef | null; } @@ -370,6 +372,22 @@ interface ComposerDraftStoreState { getDraftThread: (threadRef: ComposerThreadTarget) => DraftThreadState | null; listDraftThreadKeys: () => string[]; hasDraftThreadsInEnvironment: (environmentId: EnvironmentId) => boolean; + /** Creates draft metadata without making it the project's active draft. */ + createUnmappedDraftSession: ( + projectRef: ScopedProjectRef, + draftId: DraftId, + options: { + threadId: ThreadId; + branch?: string | null; + worktreePath?: string | null; + createdAt?: string; + envMode?: DraftThreadEnvMode; + startFromOrigin?: boolean; + runtimeMode?: RuntimeMode; + interactionMode?: ProviderInteractionMode; + hidden?: boolean; + }, + ) => void; /** Creates or updates the draft session tracked for a logical project. */ setLogicalProjectDraftThreadId: ( logicalProjectKey: string, @@ -384,6 +402,7 @@ interface ComposerDraftStoreState { startFromOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; + hidden?: boolean; }, ) => void; /** Creates or updates the draft session tracked for a concrete project ref. */ @@ -399,6 +418,7 @@ interface ComposerDraftStoreState { startFromOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; + hidden?: boolean; }, ) => void; /** Updates mutable draft-session metadata without touching composer content. */ @@ -413,6 +433,7 @@ interface ComposerDraftStoreState { startFromOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; + hidden?: boolean; }, ) => void; clearProjectDraftThreadId: (projectRef: ScopedProjectRef) => void; @@ -1363,6 +1384,7 @@ function createDraftThreadState( startFromOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; + hidden?: boolean; }, ): DraftThreadState { // A project change (including switching environments within a logical @@ -1403,6 +1425,7 @@ function createDraftThreadState( envMode: options?.envMode ?? (nextWorktreePath ? "worktree" : (existingThread?.envMode ?? "local")), startFromOrigin: nextStartFromOrigin, + hidden: options?.hidden ?? existingThread?.hidden ?? false, promotedTo: null, }; } @@ -1435,6 +1458,7 @@ function draftThreadsEqual(left: DraftThreadState | undefined, right: DraftThrea left.worktreePath === right.worktreePath && left.envMode === right.envMode && left.startFromOrigin === right.startFromOrigin && + left.hidden === right.hidden && scopedThreadRefsEqual(left.promotedTo, right.promotedTo) ); } @@ -1530,6 +1554,7 @@ function normalizePersistedDraftThreads( const branch = candidateDraftThread.branch; const worktreePath = candidateDraftThread.worktreePath; const startFromOrigin = candidateDraftThread.startFromOrigin === true; + const hidden = candidateDraftThread.hidden === true; const normalizedWorktreePath = typeof worktreePath === "string" ? worktreePath : null; const promotedToCandidate = candidateDraftThread.promotedTo; const promotedToRecord = @@ -1578,6 +1603,7 @@ function normalizePersistedDraftThreads( worktreePath: normalizedWorktreePath, envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath), startFromOrigin, + hidden, promotedTo, }; } @@ -1624,6 +1650,7 @@ function normalizePersistedDraftThreads( worktreePath: null, envMode: "local", startFromOrigin: false, + hidden: false, promotedTo: null, }; } else if ( @@ -2227,6 +2254,7 @@ function toHydratedDraftThreadState( worktreePath: persistedDraftThread.worktreePath, envMode: persistedDraftThread.envMode, startFromOrigin: persistedDraftThread.startFromOrigin, + hidden: persistedDraftThread.hidden, promotedTo: persistedDraftThread.promotedTo ? scopeThreadRef( persistedDraftThread.promotedTo.environmentId as EnvironmentId, @@ -2262,7 +2290,7 @@ const composerDraftStore = create()( return null; } const draftThread = get().draftThreadsByThreadKey[draftId]; - if (!draftThread || isDraftThreadPromoting(draftThread)) { + if (!draftThread || draftThread.hidden || isDraftThreadPromoting(draftThread)) { return null; } return toProjectDraftSession(DraftId.make(draftId), draftThread); @@ -2279,7 +2307,7 @@ const composerDraftStore = create()( state.logicalProjectDraftThreadKeyByLogicalProjectKey, )) { const draftThread = state.draftThreadsByThreadKey[draftId]; - if (!draftThread || isDraftThreadPromoting(draftThread)) { + if (!draftThread || draftThread.hidden || isDraftThreadPromoting(draftThread)) { continue; } if ( @@ -2290,7 +2318,7 @@ const composerDraftStore = create()( } } for (const [draftId, draftThread] of Object.entries(state.draftThreadsByThreadKey)) { - if (isDraftThreadPromoting(draftThread)) { + if (draftThread.hidden || isDraftThreadPromoting(draftThread)) { continue; } if ( @@ -2331,6 +2359,26 @@ const composerDraftStore = create()( Object.values(get().draftThreadsByThreadKey).some( (draftThread) => draftThread.environmentId === environmentId, ), + createUnmappedDraftSession: (projectRef, draftId, options) => { + if (draftId.length === 0) return; + set((state) => { + const existingThread = state.draftThreadsByThreadKey[draftId]; + const nextDraftThread = createDraftThreadState( + projectRef, + options.threadId, + projectDraftKey(projectRef), + existingThread, + options, + ); + if (draftThreadsEqual(existingThread, nextDraftThread)) return state; + return { + draftThreadsByThreadKey: { + ...state.draftThreadsByThreadKey, + [draftId]: nextDraftThread, + }, + }; + }); + }, setLogicalProjectDraftThreadId: (logicalProjectKey, projectRef, draftId, options) => { const normalizedLogicalProjectKey = logicalProjectDraftKey(logicalProjectKey); if (normalizedLogicalProjectKey.length === 0 || draftId.length === 0) { @@ -2461,6 +2509,7 @@ const composerDraftStore = create()( envMode: options.envMode ?? (nextWorktreePath ? "worktree" : (existing.envMode ?? "local")), startFromOrigin: nextStartFromOrigin, + hidden: options.hidden ?? existing.hidden, promotedTo: existing.promotedTo ?? null, }; const isUnchanged = @@ -2474,6 +2523,7 @@ const composerDraftStore = create()( nextDraftThread.worktreePath === existing.worktreePath && nextDraftThread.envMode === existing.envMode && nextDraftThread.startFromOrigin === existing.startFromOrigin && + nextDraftThread.hidden === existing.hidden && scopedThreadRefsEqual(nextDraftThread.promotedTo, existing.promotedTo); if (isUnchanged) { return state; diff --git a/apps/web/src/state/retractionCompletions.ts b/apps/web/src/state/retractionCompletions.ts new file mode 100644 index 000000000000..149f5788e419 --- /dev/null +++ b/apps/web/src/state/retractionCompletions.ts @@ -0,0 +1,28 @@ +import { ORCHESTRATION_WS_METHODS } from "@t3tools/contracts"; +import { createEnvironmentRpcSubscriptionAtomFamily } from "@t3tools/client-runtime/state/runtime"; +import * as Result from "effect/Result"; +import * as Stream from "effect/Stream"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const threadRetractionCompletions = createEnvironmentRpcSubscriptionAtomFamily( + connectionAtomRuntime, + { + label: "environment-data:thread-retraction-completions", + tag: ORCHESTRATION_WS_METHODS.subscribeThread, + idleTtlMs: 0, + transform: (stream) => + stream.pipe( + Stream.filterMap((item) => + item.kind === "event" && + item.event.type === "thread.reverted" && + item.event.payload.retraction !== undefined + ? Result.succeed({ + threadId: item.event.payload.threadId, + retraction: item.event.payload.retraction, + }) + : Result.failVoid, + ), + ), + }, +); From b27394b247a93587e1babdd408d189744c286bc1 Mon Sep 17 00:00:00 2001 From: Adam Firestone Date: Tue, 11 Aug 2026 11:52:10 -0500 Subject: [PATCH 06/18] test(server): reconcile snapshot fixtures after parallel merges --- .../src/orchestration/Layers/ProjectionSnapshotQuery.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 3fe81c023d6c..b68b10cac500 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -347,6 +347,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { planId: "plan-1", }, }, + managedWorktree: null, createdAt: "2026-02-24T00:00:02.000Z", updatedAt: "2026-02-24T00:00:03.000Z", archivedAt: null, From e06375a520e42657818df1a13c541a684295ae90 Mon Sep 17 00:00:00 2001 From: Adam Firestone Date: Tue, 11 Aug 2026 12:13:04 -0500 Subject: [PATCH 07/18] feat(server): durable turn-retraction process manager with absolute provider rollback TurnRetractionReactor drives requested retractions to completion or terminal failure from projected state, lifecycle events, and a startup scan. Adds rollbackConversationTo (verified absolute boundary) for Claude and Codex, late-event tombstone suppression, and new-turn blocking while pending. Built with Claude Fable 5 via Claude Code, implementation by Codex (GPT-5.6-Sol). --- .../OrchestrationEngineHarness.integration.ts | 7 + .../Layers/CheckpointReactor.test.ts | 1 + .../Layers/OrchestrationReactor.test.ts | 11 + .../Layers/OrchestrationReactor.ts | 3 + .../Layers/ProjectionPipeline.test.ts | 1 + .../Layers/ProjectionPipeline.ts | 54 ++ .../Layers/ProviderCommandReactor.test.ts | 1 + .../Layers/ProviderRuntimeIngestion.test.ts | 133 ++++ .../Layers/ProviderRuntimeIngestion.ts | 23 +- .../Layers/TurnRetractionReactor.test.ts | 601 ++++++++++++++++++ .../Layers/TurnRetractionReactor.ts | 470 ++++++++++++++ .../Services/TurnRetractionReactor.ts | 19 + apps/server/src/orchestration/decider.ts | 8 +- .../decider.turnRetraction.test.ts | 26 + .../src/orchestration/projector.test.ts | 1 + apps/server/src/orchestration/projector.ts | 24 +- .../Layers/ProjectionTurnRetractions.ts | 29 + .../src/provider/Layers/ClaudeAdapter.test.ts | 20 +- .../src/provider/Layers/ClaudeAdapter.ts | 34 + .../src/provider/Layers/CodexAdapter.test.ts | 31 + .../src/provider/Layers/CodexAdapter.ts | 34 + .../src/provider/Layers/ProviderService.ts | 70 ++ .../Layers/ProviderSessionReaper.test.ts | 1 + .../src/provider/Services/ProviderAdapter.ts | 11 + .../src/provider/Services/ProviderService.ts | 9 + apps/server/src/server.ts | 2 + packages/contracts/src/orchestration.ts | 1 + 27 files changed, 1620 insertions(+), 5 deletions(-) create mode 100644 apps/server/src/orchestration/Layers/TurnRetractionReactor.test.ts create mode 100644 apps/server/src/orchestration/Layers/TurnRetractionReactor.ts create mode 100644 apps/server/src/orchestration/Services/TurnRetractionReactor.ts diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 71ef59a0910c..7f00a05c1989 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -63,6 +63,7 @@ import { type OrchestrationEngineShape, } from "../src/orchestration/Services/OrchestrationEngine.ts"; import { ThreadDeletionReactor } from "../src/orchestration/Services/ThreadDeletionReactor.ts"; +import { TurnRetractionReactor } from "../src/orchestration/Services/TurnRetractionReactor.ts"; import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts"; import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts"; import { @@ -369,6 +370,12 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge(runtimeIngestionLayer), Layer.provideMerge(providerCommandReactorLayer), Layer.provideMerge(checkpointReactorLayer), + Layer.provideMerge( + Layer.succeed(TurnRetractionReactor, { + start: () => Effect.void, + drain: Effect.void, + }), + ), Layer.provideMerge( Layer.succeed(ThreadDeletionReactor, { start: () => Effect.void, diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 99c96825dd8d..aaedda18c71e 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -125,6 +125,7 @@ function createProviderServiceHarness( }, }), rollbackConversation, + rollbackConversationTo: () => unsupported(), get streamEvents() { return Stream.fromPubSub(runtimeEventPubSub); }, diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts index d2737b98f0e0..8ca843209d71 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts @@ -10,6 +10,7 @@ import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts"; +import { TurnRetractionReactor } from "../Services/TurnRetractionReactor.ts"; import { makeOrchestrationReactor } from "./OrchestrationReactor.ts"; import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; @@ -56,6 +57,15 @@ describe("OrchestrationReactor", () => { drain: Effect.void, }), ), + Layer.provideMerge( + Layer.succeed(TurnRetractionReactor, { + start: () => { + started.push("turn-retraction-reactor"); + return Effect.void; + }, + drain: Effect.void, + }), + ), Layer.provideMerge( Layer.succeed(ThreadDeletionReactor, { start: () => { @@ -85,6 +95,7 @@ describe("OrchestrationReactor", () => { "provider-runtime-ingestion", "provider-command-reactor", "checkpoint-reactor", + "turn-retraction-reactor", "thread-deletion-reactor", "agent-awareness-relay", ]); diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts index fb7543e31af0..d8b1a83c60b3 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts @@ -9,6 +9,7 @@ import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; +import { TurnRetractionReactor } from "../Services/TurnRetractionReactor.ts"; import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; export const makeOrchestrationReactor = Effect.gen(function* () { @@ -16,12 +17,14 @@ export const makeOrchestrationReactor = Effect.gen(function* () { const providerCommandReactor = yield* ProviderCommandReactor; const checkpointReactor = yield* CheckpointReactor; const threadDeletionReactor = yield* ThreadDeletionReactor; + const turnRetractionReactor = yield* TurnRetractionReactor; const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay; const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () { yield* providerRuntimeIngestion.start(); yield* providerCommandReactor.start(); yield* checkpointReactor.start(); + yield* turnRetractionReactor.start(); yield* threadDeletionReactor.start(); yield* agentAwarenessRelay.start(); }); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 22fe30f76239..6526bb584753 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -2876,6 +2876,7 @@ it.effect("retains provider-send claim classification across repository restart" const restarted = yield* Effect.gen(function* () { const retractions = yield* ProjectionTurnRetractionRepository; + assert.equal(yield* retractions.cancelPendingProviderSend({ threadId, messageId }), false); return yield* retractions.getByRequestId({ requestId }); }).pipe( Effect.provide( diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 88e98bc3183a..18fad5fd9297 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -496,6 +496,21 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const path = yield* Path.Path; const serverConfig = yield* ServerConfig; + const isCompletedRetractedTurn = Effect.fn("isCompletedRetractedTurn")(function* ( + threadId: ThreadId, + turnId: string | null, + ) { + if (turnId === null) return false; + const retraction = yield* projectionTurnRetractionRepository.getLatestByThreadId({ + threadId, + }); + return ( + Option.isSome(retraction) && + retraction.value.status === "completed" && + retraction.value.targetTurnId === turnId + ); + }); + const applyProjectsProjection: ProjectorDefinition["apply"] = Effect.fn( "applyProjectsProjection", )(function* (event, _attachmentSideEffects) { @@ -870,6 +885,18 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti case "thread.activity-appended": case "thread.approval-response-requested": case "thread.user-input-response-requested": { + const eventTurnId = + event.type === "thread.message-sent" + ? event.payload.turnId + : event.type === "thread.proposed-plan-upserted" + ? event.payload.proposedPlan.turnId + : null; + if ( + eventTurnId !== null && + (yield* isCompletedRetractedTurn(event.payload.threadId, eventTurnId)) + ) { + return; + } const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, }); @@ -902,6 +929,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti } case "thread.turn-diff-completed": { + if (yield* isCompletedRetractedTurn(event.payload.threadId, event.payload.turnId)) { + return; + } const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, }); @@ -965,6 +995,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti )(function* (event, attachmentSideEffects) { switch (event.type) { case "thread.message-sent": { + if ( + event.payload.turnId !== null && + (yield* isCompletedRetractedTurn(event.payload.threadId, event.payload.turnId)) + ) { + return; + } const existingMessage = yield* projectionThreadMessageRepository.getByMessageId({ messageId: event.payload.messageId, }); @@ -1044,6 +1080,15 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti )(function* (event, _attachmentSideEffects) { switch (event.type) { case "thread.proposed-plan-upserted": + if ( + event.payload.proposedPlan.turnId !== null && + (yield* isCompletedRetractedTurn( + event.payload.threadId, + event.payload.proposedPlan.turnId, + )) + ) { + return; + } yield* projectionThreadProposedPlanRepository.upsert({ planId: event.payload.proposedPlan.id, threadId: event.payload.threadId, @@ -1313,6 +1358,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti } case "thread.message-sent": { + if ( + event.payload.turnId !== null && + (yield* isCompletedRetractedTurn(event.payload.threadId, event.payload.turnId)) + ) { + return; + } if (event.payload.turnId === null || event.payload.role !== "assistant") { return; } @@ -1409,6 +1460,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti } case "thread.turn-diff-completed": { + if (yield* isCompletedRetractedTurn(event.payload.threadId, event.payload.turnId)) { + return; + } // Mid-turn diff updates produce placeholder checkpoints; record the // checkpoint, but don't settle a turn its session is still running. const session = yield* projectionThreadSessionRepository.getByThreadId({ diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index a8b22d07ebc4..57c1e6720b8d 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -347,6 +347,7 @@ describe("ProviderCommandReactor", () => { }); }, rollbackConversation: () => unsupported(), + rollbackConversationTo: () => unsupported(), get streamEvents() { return Stream.fromPubSub(runtimeEventPubSub); }, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 258aa010e3e6..b78a8b6b85fb 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -12,6 +12,7 @@ import { } from "@t3tools/contracts"; import { ApprovalRequestId, + CheckpointRef, CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, EventId, @@ -124,6 +125,7 @@ function createProviderServiceHarness() { }); }, rollbackConversation: () => unsupported(), + rollbackConversationTo: () => unsupported(), get streamEvents() { return Stream.fromPubSub(runtimeEventPubSub); }, @@ -364,6 +366,137 @@ describe("ProviderRuntimeIngestion", () => { expect(thread.session?.lastError).toBe("turn failed"); }); + it("ignores a late turn.completed for a completed retraction tombstone", async () => { + const harness = await createHarness(); + const threadId = asThreadId("thread-1"); + const firstMessageId = MessageId.make("message-before-retraction"); + const secondMessageId = MessageId.make("message-retracted-late-event"); + const firstTurnId = asTurnId("turn-before-retraction"); + const targetTurnId = asTurnId("turn-retracted-late-event"); + + await harness.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-first-turn-start"), + threadId, + message: { + messageId: firstMessageId, + role: "user", + text: "keep me", + attachments: [], + }, + runtimeMode: "approval-required", + interactionMode: "default", + createdAt: "2026-01-01T00:00:01.000Z", + }); + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-first-turn-started"), + provider: ProviderDriverKind.make("codex"), + threadId, + turnId: firstTurnId, + createdAt: "2026-01-01T00:00:02.000Z", + }); + await waitForThread(harness.readModel, (thread) => thread.session?.status === "running"); + harness.emit({ + type: "turn.completed", + eventId: asEventId("evt-first-turn-completed"), + provider: ProviderDriverKind.make("codex"), + threadId, + turnId: firstTurnId, + createdAt: "2026-01-01T00:00:03.000Z", + payload: { state: "completed" }, + }); + await waitForThread(harness.readModel, (thread) => thread.session?.status === "ready"); + await harness.dispatch({ + type: "thread.turn.diff.complete", + commandId: CommandId.make("cmd-first-checkpoint"), + threadId, + turnId: firstTurnId, + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.make("checkpoint-before-retraction"), + status: "ready", + files: [], + completedAt: "2026-01-01T00:00:03.000Z", + createdAt: "2026-01-01T00:00:03.000Z", + }); + await harness.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-target-turn-start"), + threadId, + message: { + messageId: secondMessageId, + role: "user", + text: "discard me", + attachments: [], + }, + runtimeMode: "approval-required", + interactionMode: "default", + createdAt: "2026-01-01T00:00:04.000Z", + }); + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-target-turn-started"), + provider: ProviderDriverKind.make("codex"), + threadId, + turnId: targetTurnId, + createdAt: "2026-01-01T00:00:05.000Z", + }); + await waitForThread( + harness.readModel, + (thread) => thread.session?.activeTurnId === targetTurnId, + ); + const requestId = CommandId.make("cmd-target-retract"); + await harness.dispatch({ + type: "thread.turn.retract", + commandId: requestId, + threadId, + messageId: secondMessageId, + createdAt: "2026-01-01T00:00:06.000Z", + }); + await harness.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-target-settled"), + threadId, + session: { + threadId, + status: "ready", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: "2026-01-01T00:00:07.000Z", + }, + createdAt: "2026-01-01T00:00:07.000Z", + }); + await harness.dispatch({ + type: "thread.turn.retract.complete", + commandId: CommandId.make("cmd-target-retract-complete"), + threadId, + requestId, + targetTurnId, + createdAt: "2026-01-01T00:00:08.000Z", + }); + + harness.emit({ + type: "turn.completed", + eventId: asEventId("evt-target-completed-late"), + provider: ProviderDriverKind.make("codex"), + threadId, + turnId: targetTurnId, + createdAt: "2026-01-01T00:00:09.000Z", + payload: { state: "failed", errorMessage: "must be ignored" }, + }); + await new Promise((resolve) => setImmediate(resolve)); + await harness.drain(); + + const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); + expect(thread?.session?.status).toBe("ready"); + expect(thread?.session?.lastError).toBeNull(); + expect(thread?.messages.map((message) => message.id)).toEqual([firstMessageId]); + expect(thread?.checkpoints.map((checkpoint) => checkpoint.turnId)).toEqual([firstTurnId]); + expect(thread?.latestTurn?.turnId).toBe(firstTurnId); + }); + it("applies provider session.state.changed transitions directly", async () => { const harness = await createHarness(); const waitingAt = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 03253797242e..72f3663df2d1 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -32,6 +32,8 @@ import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts"; import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; +import { ProjectionTurnRetractionRepositoryLive } from "../../persistence/Layers/ProjectionTurnRetractions.ts"; +import { ProjectionTurnRetractionRepository } from "../../persistence/Services/ProjectionTurnRetractions.ts"; import { isGitRepository } from "../../git/Utils.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts"; @@ -874,6 +876,7 @@ const make = Effect.gen(function* () { const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; const providerService = yield* ProviderService; const projectionTurnRepository = yield* ProjectionTurnRepository; + const projectionTurnRetractionRepository = yield* ProjectionTurnRetractionRepository; const serverSettingsService = yield* ServerSettingsService; const providerCommandId = (event: ProviderRuntimeEvent, tag: string) => crypto.randomUUIDv4.pipe( @@ -1489,6 +1492,21 @@ const make = Effect.gen(function* () { const now = event.createdAt; const eventTurnId = toTurnId(event.turnId); + if (eventTurnId !== undefined) { + const latestRetraction = yield* projectionTurnRetractionRepository.getLatestByThreadId({ + threadId: thread.id, + }); + if ( + Option.isSome(latestRetraction) && + latestRetraction.value.status === "completed" && + latestRetraction.value.targetTurnId === eventTurnId + ) { + // A completed retraction is a durable tombstone for every late + // provider event from the discarded turn. In particular, completion + // finalization and diff capture must not recreate messages or turns. + return; + } + } const activeTurnId = thread.session?.activeTurnId ?? null; const pendingTurnStart = yield* projectionTurnRepository.getPendingTurnStartByThreadId({ threadId: thread.id, @@ -2068,4 +2086,7 @@ const make = Effect.gen(function* () { export const ProviderRuntimeIngestionLive = Layer.effect( ProviderRuntimeIngestionService, make, -).pipe(Layer.provide(ProjectionTurnRepositoryLive)); +).pipe( + Layer.provide(ProjectionTurnRepositoryLive), + Layer.provide(ProjectionTurnRetractionRepositoryLive), +); diff --git a/apps/server/src/orchestration/Layers/TurnRetractionReactor.test.ts b/apps/server/src/orchestration/Layers/TurnRetractionReactor.test.ts new file mode 100644 index 000000000000..b1668a089b86 --- /dev/null +++ b/apps/server/src/orchestration/Layers/TurnRetractionReactor.test.ts @@ -0,0 +1,601 @@ +import { + CheckpointRef, + CommandId, + EventId, + MessageId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + TurnId, + type OrchestrationCommand, + type OrchestrationEvent, + type OrchestrationProjectShell, + type OrchestrationReadModel, + type OrchestrationSessionStatus, + type OrchestrationThread, + type ProviderRuntimeEvent, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it, vi } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as ManagedRuntime from "effect/ManagedRuntime"; +import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; + +import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts"; +import { ProviderAdapterRequestError, ProviderValidationError } from "../../provider/Errors.ts"; +import { + ProviderService, + type ProviderServiceShape, +} from "../../provider/Services/ProviderService.ts"; +import { + type ProjectionTurnRetraction, + ProjectionTurnRetractionRepository, + type ProjectionTurnRetractionRepositoryShape, +} from "../../persistence/Services/ProjectionTurnRetractions.ts"; +import { + ProjectionTurnRepository, + type ProjectionTurnRepositoryShape, +} from "../../persistence/Services/ProjectionTurns.ts"; +import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts"; +import { decideOrchestrationCommand } from "../decider.ts"; +import { projectEvent } from "../projector.ts"; +import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; +import { + ProjectionSnapshotQuery, + type ProjectionSnapshotQueryShape, +} from "../Services/ProjectionSnapshotQuery.ts"; +import { TurnRetractionReactor } from "../Services/TurnRetractionReactor.ts"; +import { makeTurnRetractionReactor } from "./TurnRetractionReactor.ts"; + +const NOW = "2026-08-11T12:00:00.000Z"; +const THREAD_ID = ThreadId.make("thread-retraction-reactor"); +const PROJECT_ID = ProjectId.make("project-retraction-reactor"); +const MESSAGE_ID = MessageId.make("message-retracted"); +const TURN_ID = TurnId.make("turn-retracted"); +const REQUEST_ID = CommandId.make("request-retract"); +const BASELINE_REF = CheckpointRef.make(`refs/t3/checkpoints/${THREAD_ID}/1`); + +type MutableState = { + row: ProjectionTurnRetraction; + sessionStatus: OrchestrationSessionStatus | null; + historyTurnCount: number; + filesystemRestored: boolean; + failRollbackAfterEffect: boolean; + failRestoreAfterEffect: boolean; + failCompletionAfterCommit: boolean; + terminalRollbackFailure: boolean; + readonly order: string[]; + readonly dispatched: OrchestrationCommand[]; +}; + +function pendingRow( + providerSendState: ProjectionTurnRetraction["providerSendState"], + firstUserMessage = false, +): ProjectionTurnRetraction { + return { + requestId: REQUEST_ID, + threadId: THREAD_ID, + messageId: MESSAGE_ID, + baselineTurnCount: 1, + baselineCheckpointRef: BASELINE_REF, + targetTurnId: providerSendState === "cancelled" ? null : TURN_ID, + providerSendClaimed: providerSendState === "claimed", + providerSendState, + firstUserMessage, + requestedAt: NOW, + status: "requested", + completedAt: null, + failedAt: null, + }; +} + +function makeState(providerSendState: ProjectionTurnRetraction["providerSendState"]): MutableState { + return { + row: pendingRow(providerSendState), + sessionStatus: providerSendState === "claimed" ? "running" : null, + historyTurnCount: 2, + filesystemRestored: false, + failRollbackAfterEffect: false, + failRestoreAfterEffect: false, + failCompletionAfterCommit: false, + terminalRollbackFailure: false, + order: [], + dispatched: [], + }; +} + +function projectedThread(state: MutableState): OrchestrationThread { + return { + id: THREAD_ID, + projectId: PROJECT_ID, + title: "Retraction reactor", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: "/tmp/retraction-reactor-workspace", + latestTurn: { + turnId: TURN_ID, + state: state.sessionStatus === "running" ? "running" : "interrupted", + requestedAt: NOW, + startedAt: NOW, + completedAt: state.sessionStatus === "running" ? null : NOW, + assistantMessageId: null, + }, + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, + deletedAt: null, + messages: [ + { + id: MESSAGE_ID, + role: "user", + text: "retract me", + turnId: null, + streaming: false, + createdAt: NOW, + updatedAt: NOW, + }, + ], + proposedPlans: [], + activities: [], + checkpoints: [], + session: + state.sessionStatus === null + ? null + : { + threadId: THREAD_ID, + status: state.sessionStatus, + providerName: "Codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "full-access", + activeTurnId: state.sessionStatus === "running" ? TURN_ID : null, + lastError: null, + updatedAt: NOW, + }, + turnRetraction: state.row, + }; +} + +function makeRepository(state: MutableState): ProjectionTurnRetractionRepositoryShape { + return { + upsertPending: (row) => Effect.sync(() => void (state.row = row)), + markCompleted: ({ completedAt, targetTurnId }) => + Effect.sync(() => { + state.row = { ...state.row, status: "completed", completedAt, targetTurnId }; + }), + markFailed: ({ failedAt }) => + Effect.sync(() => { + state.row = { ...state.row, status: "failed", failedAt }; + }), + claimProviderSend: () => Effect.succeed("claimed"), + cancelPendingProviderSend: () => + Effect.sync(() => { + if (state.row.providerSendState === "unclaimed") { + state.row = { + ...state.row, + providerSendClaimed: false, + providerSendState: "cancelled", + }; + return true; + } + return state.row.providerSendState === "cancelled"; + }), + getByRequestId: ({ requestId }) => + Effect.succeed(requestId === state.row.requestId ? Option.some(state.row) : Option.none()), + getLatestByThreadId: ({ threadId }) => + Effect.succeed(threadId === state.row.threadId ? Option.some(state.row) : Option.none()), + listPending: () => Effect.succeed(state.row.status === "requested" ? [state.row] : []), + }; +} + +const unsupported = () => + Effect.die(new Error("unsupported test operation")) as Effect.Effect; + +async function startHarness(state: MutableState) { + const domainEvents = Effect.runSync(PubSub.unbounded()); + const runtimeEvents = Effect.runSync(PubSub.unbounded()); + const repository = makeRepository(state); + const dispatch = vi.fn((command: OrchestrationCommand) => { + state.dispatched.push(command); + if (command.type === "thread.turn.retract.complete") { + state.order.push("complete"); + state.row = { + ...state.row, + status: "completed", + completedAt: command.createdAt, + targetTurnId: command.targetTurnId ?? state.row.targetTurnId, + }; + if (state.failCompletionAfterCommit) { + state.failCompletionAfterCommit = false; + return Effect.die(new Error("simulated crash after completion commit")); + } + } + if ( + command.type === "thread.activity.append" && + command.activity.kind === "turn.retract.failed" + ) { + state.row = { + ...state.row, + status: "failed", + failedAt: command.activity.createdAt, + }; + } + return Effect.succeed({ sequence: state.dispatched.length }); + }); + const engine = OrchestrationEngineService.of({ + readEvents: () => Stream.empty, + dispatch, + get streamDomainEvents() { + return Stream.fromPubSub(domainEvents); + }, + latestSequence: Effect.succeed(0), + }); + const query = ProjectionSnapshotQuery.of({ + getThreadDetailById: (threadId: ThreadId) => + Effect.succeed(threadId === THREAD_ID ? Option.some(projectedThread(state)) : Option.none()), + getProjectShellById: (projectId: ProjectId) => + Effect.succeed( + projectId === PROJECT_ID + ? Option.some({ + id: PROJECT_ID, + title: "Project", + workspaceRoot: "/tmp/retraction-reactor-workspace", + defaultModelSelection: null, + defaultThreadEnvMode: null, + faviconPath: null, + scripts: [], + createdAt: NOW, + updatedAt: NOW, + } as unknown as OrchestrationProjectShell) + : Option.none(), + ), + } as unknown as ProjectionSnapshotQueryShape); + const turnRepository = ProjectionTurnRepository.of({ + listByThreadId: () => + Effect.succeed([ + { + threadId: THREAD_ID, + turnId: TURN_ID, + pendingMessageId: MESSAGE_ID, + sourceProposedPlanThreadId: null, + sourceProposedPlanId: null, + assistantMessageId: null, + state: state.sessionStatus === "running" ? "running" : "interrupted", + requestedAt: NOW, + startedAt: NOW, + completedAt: state.sessionStatus === "running" ? null : NOW, + checkpointTurnCount: null, + checkpointRef: null, + checkpointStatus: null, + checkpointFiles: [], + }, + ]), + } as unknown as ProjectionTurnRepositoryShape); + const provider = ProviderService.of({ + startSession: () => unsupported(), + sendTurn: () => unsupported(), + interruptTurn: () => + Effect.sync(() => { + state.order.push("interrupt"); + }), + respondToRequest: () => unsupported(), + respondToUserInput: () => unsupported(), + stopSession: () => unsupported(), + listSessions: () => Effect.succeed([]), + getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), + getInstanceInfo: (instanceId) => + Effect.succeed({ + instanceId, + driverKind: ProviderDriverKind.make("codex"), + displayName: undefined, + enabled: true, + continuationIdentity: { + driverKind: ProviderDriverKind.make("codex"), + continuationKey: `codex:instance:${instanceId}`, + }, + }), + rollbackConversation: () => unsupported(), + rollbackConversationTo: ({ retainedTurnCount }) => + Effect.gen(function* () { + state.order.push("rollback"); + if (state.terminalRollbackFailure) { + return yield* new ProviderValidationError({ + operation: "ProviderService.rollbackConversationTo", + issue: "provider resume state is unavailable", + }); + } + state.historyTurnCount = retainedTurnCount; + if (state.failRollbackAfterEffect) { + state.failRollbackAfterEffect = false; + return yield* new ProviderAdapterRequestError({ + provider: "codex", + method: "thread/rollback", + detail: "simulated crash after provider rollback", + }); + } + }), + get streamEvents() { + return Stream.fromPubSub(runtimeEvents); + }, + } satisfies ProviderServiceShape); + const checkpointService = CheckpointStore.CheckpointStore.of({ + isGitRepository: () => Effect.succeed(true), + captureCheckpoint: () => Effect.void, + hasCheckpointRef: () => Effect.succeed(true), + restoreCheckpoint: () => + Effect.sync(() => { + state.order.push("restore"); + state.filesystemRestored = true; + if (state.failRestoreAfterEffect) { + state.failRestoreAfterEffect = false; + throw new Error("simulated crash after filesystem restore"); + } + return true; + }), + diffCheckpoints: () => unsupported(), + deleteCheckpointRefs: () => Effect.void, + }); + const workspaceEntries = WorkspaceEntries.WorkspaceEntries.of({ + browse: () => unsupported(), + list: () => unsupported(), + search: () => unsupported(), + searchContents: () => unsupported(), + refresh: () => Effect.void, + }); + + const layer = Layer.effect(TurnRetractionReactor, makeTurnRetractionReactor).pipe( + Layer.provideMerge(Layer.succeed(OrchestrationEngineService, engine)), + Layer.provideMerge(Layer.succeed(ProjectionSnapshotQuery, query)), + Layer.provideMerge(Layer.succeed(ProjectionTurnRetractionRepository, repository)), + Layer.provideMerge(Layer.succeed(ProjectionTurnRepository, turnRepository)), + Layer.provideMerge(Layer.succeed(ProviderService, provider)), + Layer.provideMerge(Layer.succeed(CheckpointStore.CheckpointStore, checkpointService)), + Layer.provideMerge(Layer.succeed(WorkspaceEntries.WorkspaceEntries, workspaceEntries)), + Layer.provideMerge(NodeServices.layer), + ); + const runtime = ManagedRuntime.make(layer); + const reactor = await runtime.runPromise(Effect.service(TurnRetractionReactor)); + const scope = await Effect.runPromise(Scope.make("sequential")); + await Effect.runPromise(reactor.start().pipe(Scope.provide(scope))); + await runtime.runPromise(reactor.drain); + return { + reactor, + runtime, + scope, + emitDomain: (event: OrchestrationEvent) => + runtime.runPromise(PubSub.publish(domainEvents, event)), + }; +} + +async function stopHarness(harness: Awaited>) { + await Effect.runPromise(Scope.close(harness.scope, Exit.void)); + await harness.runtime.dispose(); +} + +it("completes a cancelled provider-send path after filesystem convergence", async () => { + const state = makeState("cancelled"); + const harness = await startHarness(state); + expect(state.row.status).toBe("completed"); + expect(state.filesystemRestored).toBe(true); + expect(state.order).toEqual(["restore", "complete"]); + await stopHarness(harness); +}); + +it("drives claimed convergence from interrupt through a settlement event", async () => { + const state = makeState("claimed"); + const harness = await startHarness(state); + expect(state.order).toEqual(["interrupt"]); + expect(state.row.status).toBe("requested"); + + state.sessionStatus = "ready"; + await harness.emitDomain({ + sequence: 10, + eventId: EventId.make("evt-settled"), + aggregateKind: "thread", + aggregateId: THREAD_ID, + occurredAt: NOW, + commandId: CommandId.make("cmd-settled"), + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.session-set", + payload: { + threadId: THREAD_ID, + session: { + threadId: THREAD_ID, + status: "ready", + providerName: "Codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: NOW, + }, + }, + }); + await harness.runtime.runPromise(Effect.yieldNow); + await harness.runtime.runPromise(harness.reactor.drain); + + expect(state.order).toEqual(["interrupt", "rollback", "restore", "complete"]); + expect(state.historyTurnCount).toBe(1); + expect(state.row.status).toBe("completed"); + await stopHarness(harness); +}); + +it("resumes after crashes between interrupt, rollback, restore, and completion", async () => { + const state = makeState("claimed"); + + let harness = await startHarness(state); + expect(state.order).toEqual(["interrupt"]); + await stopHarness(harness); + + state.sessionStatus = "ready"; + state.failRollbackAfterEffect = true; + harness = await startHarness(state); + expect(state.row.status).toBe("requested"); + expect(state.historyTurnCount).toBe(1); + await stopHarness(harness); + + state.failRestoreAfterEffect = true; + harness = await startHarness(state); + expect(state.row.status).toBe("requested"); + expect(state.filesystemRestored).toBe(true); + await stopHarness(harness); + + state.failCompletionAfterCommit = true; + harness = await startHarness(state); + expect(state.row.status).toBe("completed"); + await stopHarness(harness); + + const callsBeforeFinalRestart = [...state.order]; + harness = await startHarness(state); + expect(state.order).toEqual(callsBeforeFinalRestart); + await stopHarness(harness); + expect(state.order).toEqual([ + "interrupt", + "rollback", + "rollback", + "restore", + "rollback", + "restore", + "complete", + ]); +}); + +it.effect("ignores late message and checkpoint events for a completed tombstone", () => + Effect.gen(function* () { + const state = makeState("claimed"); + state.row = { ...state.row, status: "completed", completedAt: NOW }; + const model: OrchestrationReadModel = { + snapshotSequence: 1, + projects: [], + threads: [ + { + ...projectedThread(state), + latestTurn: null, + session: null, + messages: [], + checkpoints: [], + }, + ], + updatedAt: NOW, + }; + const base = { + aggregateKind: "thread" as const, + aggregateId: THREAD_ID, + occurredAt: NOW, + commandId: CommandId.make("cmd-late"), + causationEventId: null, + correlationId: null, + metadata: {}, + }; + const afterMessage = yield* projectEvent(model, { + ...base, + sequence: 2, + eventId: EventId.make("evt-late-message"), + type: "thread.message-sent", + payload: { + threadId: THREAD_ID, + messageId: MessageId.make("late-assistant"), + role: "assistant", + text: "late", + turnId: TURN_ID, + streaming: false, + createdAt: NOW, + updatedAt: NOW, + }, + }); + const afterCheckpoint = yield* projectEvent(afterMessage, { + ...base, + sequence: 3, + eventId: EventId.make("evt-late-checkpoint"), + type: "thread.turn-diff-completed", + payload: { + threadId: THREAD_ID, + turnId: TURN_ID, + checkpointTurnCount: 2, + checkpointRef: CheckpointRef.make("late-ref"), + status: "ready", + files: [], + assistantMessageId: MessageId.make("late-assistant"), + completedAt: NOW, + }, + }); + expect(afterCheckpoint.threads[0]?.messages).toEqual([]); + expect(afterCheckpoint.threads[0]?.checkpoints).toEqual([]); + expect(afterCheckpoint.threads[0]?.latestTurn).toBeNull(); + }), +); + +it("marks terminal provider rollback failure with the correlated activity shape", async () => { + const state = makeState("claimed"); + state.sessionStatus = "ready"; + state.terminalRollbackFailure = true; + const harness = await startHarness(state); + expect(state.row.status).toBe("failed"); + const failure = state.dispatched.find( + (command) => + command.type === "thread.activity.append" && command.activity.kind === "turn.retract.failed", + ); + expect(failure).toMatchObject({ + type: "thread.activity.append", + activity: { + tone: "error", + kind: "turn.retract.failed", + summary: "Message retract failed", + payload: { + requestId: REQUEST_ID, + messageId: MESSAGE_ID, + stage: "provider-rollback", + retryable: false, + }, + }, + }); + await stopHarness(harness); +}); + +it.layer(NodeServices.layer)("first-message completion integration", (it) => { + it.effect("produces reverted and deleted atomically through the WO4a decider", () => + Effect.gen(function* () { + const state = makeState("cancelled"); + state.row = pendingRow("cancelled", true); + const thread = { + ...projectedThread(state), + managedWorktree: { + projectCwd: "/tmp/project", + path: "/tmp/project-worktree", + createdForCommandId: CommandId.make("cmd-first-send"), + }, + turnRetraction: state.row, + }; + const decided = yield* decideOrchestrationCommand({ + command: { + type: "thread.turn.retract.complete", + commandId: CommandId.make("cmd-complete-first-message"), + threadId: THREAD_ID, + requestId: REQUEST_ID, + createdAt: NOW, + }, + readModel: { + snapshotSequence: 0, + projects: [], + threads: [thread], + updatedAt: NOW, + }, + }); + const events = Array.isArray(decided) ? decided : [decided]; + expect(events.map((event) => event.type)).toEqual(["thread.reverted", "thread.deleted"]); + }), + ); +}); diff --git a/apps/server/src/orchestration/Layers/TurnRetractionReactor.ts b/apps/server/src/orchestration/Layers/TurnRetractionReactor.ts new file mode 100644 index 000000000000..edd0fc92f901 --- /dev/null +++ b/apps/server/src/orchestration/Layers/TurnRetractionReactor.ts @@ -0,0 +1,470 @@ +import { + CommandId, + EventId, + type OrchestrationEvent, + type ProviderRuntimeEvent, + type ThreadId, + type TurnId, +} from "@t3tools/contracts"; +import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; +import * as Cause from "effect/Cause"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts"; +import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; +import { + ProviderAdapterSessionClosedError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, + ProviderInstanceNotFoundError, + ProviderSessionNotFoundError, + ProviderUnsupportedError, + ProviderValidationError, +} from "../../provider/Errors.ts"; +import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import { ProjectionTurnRetractionRepositoryLive } from "../../persistence/Layers/ProjectionTurnRetractions.ts"; +import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; +import { + type ProjectionTurnRetraction, + ProjectionTurnRetractionRepository, +} from "../../persistence/Services/ProjectionTurnRetractions.ts"; +import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts"; +import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts"; +import { forkParked } from "../../serverActivation.ts"; +import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; +import { + TurnRetractionReactor, + type TurnRetractionReactorShape, +} from "../Services/TurnRetractionReactor.ts"; + +type RetractionStage = + | "eligibility" + | "interrupt" + | "settlement" + | "provider-rollback" + | "checkpoint-restore" + | "cleanup"; + +type StageFailure = { + readonly stage: RetractionStage; + readonly retryable: boolean; + readonly detail: string; +}; + +const terminalProviderErrorSchemas = [ + ProviderValidationError, + ProviderUnsupportedError, + ProviderInstanceNotFoundError, + ProviderSessionNotFoundError, + ProviderAdapterValidationError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterSessionClosedError, +] as const; +const isTerminalProviderError = (error: unknown): boolean => + terminalProviderErrorSchemas.some((errorSchema) => Schema.is(errorSchema)(error)); + +const failureDetail = (error: unknown): string => + error instanceof Error ? error.message : String(error); + +export const makeTurnRetractionReactor = Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const orchestrationEngine = yield* OrchestrationEngineService; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + const turnRetractions = yield* ProjectionTurnRetractionRepository; + const turns = yield* ProjectionTurnRepository; + const providerService = yield* ProviderService; + const checkpointStore = yield* CheckpointStore.CheckpointStore; + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + + const commandId = (tag: string) => + crypto.randomUUIDv4.pipe(Effect.map((uuid) => CommandId.make(`server:${tag}:${uuid}`))); + const eventId = crypto.randomUUIDv4.pipe(Effect.map(EventId.make)); + const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + + const appendTerminalFailure = Effect.fn("appendTerminalRetractionFailure")(function* ( + row: ProjectionTurnRetraction, + failure: StageFailure, + ) { + const createdAt = yield* nowIso; + yield* orchestrationEngine.dispatch({ + type: "thread.activity.append", + commandId: yield* commandId("turn-retract-failed"), + threadId: row.threadId, + activity: { + id: yield* eventId, + tone: "error", + kind: "turn.retract.failed", + summary: "Message retract failed", + payload: { + requestId: row.requestId, + messageId: row.messageId, + stage: failure.stage, + retryable: failure.retryable, + detail: failure.detail, + }, + turnId: row.targetTurnId, + createdAt, + }, + createdAt, + }); + }); + + const resolveTargetTurnId = Effect.fn("resolveRetractionTargetTurnId")(function* ( + row: ProjectionTurnRetraction, + ) { + if (row.targetTurnId !== null) { + return row.targetTurnId; + } + const projectedTurns = yield* turns.listByThreadId({ threadId: row.threadId }); + return ( + projectedTurns.find((turn) => turn.turnId !== null && turn.pendingMessageId === row.messageId) + ?.turnId ?? null + ); + }); + + const restoreFilesystem = Effect.fn("restoreRetractionFilesystem")(function* ( + row: ProjectionTurnRetraction, + captureMissingBaseline: boolean, + ): Effect.fn.Return { + const thread = yield* projectionSnapshotQuery.getThreadDetailById(row.threadId).pipe( + Effect.map(Option.getOrUndefined), + Effect.mapError((error) => ({ + stage: "checkpoint-restore" as const, + retryable: true, + detail: failureDetail(error), + })), + ); + if (!thread) { + return yield* Effect.fail({ + stage: "eligibility" as const, + retryable: false, + detail: `Thread '${row.threadId}' is unavailable while retracting the message.`, + }); + } + const project = yield* projectionSnapshotQuery.getProjectShellById(thread.projectId).pipe( + Effect.map(Option.getOrUndefined), + Effect.mapError((error) => ({ + stage: "checkpoint-restore" as const, + retryable: true, + detail: failureDetail(error), + })), + ); + const cwd = resolveThreadWorkspaceCwd({ + thread, + projects: project ? [project] : [], + }); + if (!cwd) return; + + const isGit = yield* checkpointStore.isGitRepository(cwd).pipe( + Effect.mapError((error) => ({ + stage: "checkpoint-restore" as const, + retryable: true, + detail: failureDetail(error), + })), + ); + if (!isGit) return; + + let baselineExists = yield* checkpointStore + .hasCheckpointRef({ cwd, checkpointRef: row.baselineCheckpointRef }) + .pipe( + Effect.mapError((error) => ({ + stage: "checkpoint-restore" as const, + retryable: true, + detail: failureDetail(error), + })), + ); + if (!baselineExists && captureMissingBaseline) { + yield* checkpointStore + .captureCheckpoint({ + cwd, + checkpointRef: row.baselineCheckpointRef, + }) + .pipe( + Effect.mapError((error) => ({ + stage: "checkpoint-restore" as const, + retryable: true, + detail: failureDetail(error), + })), + ); + baselineExists = true; + } + if (!baselineExists) { + return yield* Effect.fail({ + stage: "checkpoint-restore" as const, + retryable: false, + detail: `Filesystem baseline '${row.baselineCheckpointRef}' is unavailable.`, + }); + } + + const restored = yield* checkpointStore + .restoreCheckpoint({ + cwd, + checkpointRef: row.baselineCheckpointRef, + fallbackToHead: row.baselineTurnCount === 0, + }) + .pipe( + Effect.mapError((error) => ({ + stage: "checkpoint-restore" as const, + retryable: true, + detail: failureDetail(error), + })), + ); + if (!restored) { + return yield* Effect.fail({ + stage: "checkpoint-restore" as const, + retryable: false, + detail: `Filesystem baseline '${row.baselineCheckpointRef}' could not be restored.`, + }); + } + yield* workspaceEntries.refresh(cwd).pipe( + Effect.mapError((error) => ({ + stage: "checkpoint-restore" as const, + retryable: true, + detail: failureDetail(error), + })), + ); + }); + + const dispatchCompletion = Effect.fn("dispatchTurnRetractionCompletion")(function* ( + row: ProjectionTurnRetraction, + targetTurnId: TurnId | null, + ): Effect.fn.Return { + const createdAt = yield* nowIso; + yield* orchestrationEngine + .dispatch({ + type: "thread.turn.retract.complete", + commandId: yield* commandId("turn-retract-complete").pipe( + Effect.mapError((error) => ({ + stage: "cleanup" as const, + retryable: true, + detail: failureDetail(error), + })), + ), + threadId: row.threadId, + requestId: row.requestId, + ...(targetTurnId !== null ? { targetTurnId } : {}), + createdAt, + }) + .pipe( + Effect.mapError((error) => ({ + stage: "cleanup" as const, + retryable: true, + detail: failureDetail(error), + })), + ); + }); + + const converge = Effect.fn("convergeTurnRetraction")(function* ( + requestedRow: ProjectionTurnRetraction, + ): Effect.fn.Return { + const current = yield* turnRetractions + .getByRequestId({ requestId: requestedRow.requestId }) + .pipe( + Effect.mapError((error) => ({ + stage: "eligibility" as const, + retryable: true, + detail: failureDetail(error), + })), + ); + if (Option.isNone(current) || current.value.status !== "requested") return; + let row = current.value; + + if (row.providerSendState === "unclaimed") { + yield* turnRetractions + .cancelPendingProviderSend({ threadId: row.threadId, messageId: row.messageId }) + .pipe( + Effect.mapError((error) => ({ + stage: "eligibility" as const, + retryable: true, + detail: failureDetail(error), + })), + ); + const reconciled = yield* turnRetractions.getByRequestId({ requestId: row.requestId }).pipe( + Effect.mapError((error) => ({ + stage: "eligibility" as const, + retryable: true, + detail: failureDetail(error), + })), + ); + if (Option.isNone(reconciled) || reconciled.value.status !== "requested") return; + row = reconciled.value; + } + + const targetTurnId = yield* resolveTargetTurnId(row).pipe( + Effect.mapError((error) => ({ + stage: "settlement" as const, + retryable: true, + detail: failureDetail(error), + })), + ); + + if (row.providerSendState === "cancelled") { + yield* restoreFilesystem(row, true); + yield* dispatchCompletion(row, targetTurnId); + return; + } + + const thread = yield* projectionSnapshotQuery.getThreadDetailById(row.threadId).pipe( + Effect.map(Option.getOrUndefined), + Effect.mapError((error) => ({ + stage: "settlement" as const, + retryable: true, + detail: failureDetail(error), + })), + ); + if (!thread) { + return yield* Effect.fail({ + stage: "eligibility" as const, + retryable: false, + detail: `Thread '${row.threadId}' is unavailable while retracting the message.`, + }); + } + + const sessionActive = + thread.session?.status === "starting" || thread.session?.status === "running"; + if (sessionActive) { + yield* providerService + .interruptTurn({ + threadId: row.threadId, + ...(targetTurnId !== null ? { turnId: targetTurnId } : {}), + }) + .pipe( + Effect.mapError((error) => ({ + stage: "interrupt" as const, + retryable: !isTerminalProviderError(error), + detail: failureDetail(error), + })), + ); + + // Interrupt acknowledgement is not settlement. A later provider/runtime + // lifecycle event wakes this row after the projected session leaves + // starting/running. + const afterInterrupt = yield* projectionSnapshotQuery.getThreadDetailById(row.threadId).pipe( + Effect.map(Option.getOrUndefined), + Effect.mapError((error) => ({ + stage: "settlement" as const, + retryable: true, + detail: failureDetail(error), + })), + ); + if ( + afterInterrupt?.session?.status === "starting" || + afterInterrupt?.session?.status === "running" + ) { + return; + } + } + + yield* providerService + .rollbackConversationTo({ + threadId: row.threadId, + retainedTurnCount: row.baselineTurnCount, + }) + .pipe( + Effect.mapError((error) => ({ + stage: "provider-rollback" as const, + retryable: !isTerminalProviderError(error), + detail: failureDetail(error), + })), + ); + yield* restoreFilesystem(row, false); + yield* dispatchCompletion(row, targetTurnId); + }); + + const processThread = Effect.fn("processTurnRetractionThread")(function* (threadId: ThreadId) { + const latest = yield* turnRetractions.getLatestByThreadId({ threadId }); + if (Option.isNone(latest) || latest.value.status !== "requested") return; + yield* converge(latest.value).pipe( + Effect.catch((failure) => + failure.retryable + ? Effect.logWarning("turn retraction remains pending after retryable failure", { + threadId, + requestId: latest.value.requestId, + stage: failure.stage, + detail: failure.detail, + }) + : appendTerminalFailure(latest.value, failure).pipe( + Effect.catchCause((cause) => + Effect.logWarning("failed to persist terminal turn retraction failure", { + threadId, + requestId: latest.value.requestId, + cause: Cause.pretty(cause), + }), + ), + ), + ), + ); + }); + + const processThreadSafely = (threadId: ThreadId) => + processThread(threadId).pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt; + return Effect.logWarning("turn retraction reactor failed to process thread", { + threadId, + cause: Cause.pretty(cause), + }); + }), + ); + const worker = yield* makeDrainableWorker(processThreadSafely); + + const isDomainTrigger = (event: OrchestrationEvent): boolean => + (event.type === "thread.turn-interrupt-requested" && event.payload.retraction !== undefined) || + event.type === "thread.session-set" || + event.type === "thread.turn-diff-completed"; + + const isRuntimeTrigger = (event: ProviderRuntimeEvent): boolean => + event.type === "turn.started" || + event.type === "turn.completed" || + event.type === "turn.aborted" || + event.type === "session.started" || + event.type === "session.state.changed" || + event.type === "session.exited"; + + const start: TurnRetractionReactorShape["start"] = Effect.fn("start")(function* () { + yield* forkParked( + Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => + isDomainTrigger(event) && "threadId" in event.payload + ? worker.enqueue(event.payload.threadId) + : Effect.void, + ), + ); + yield* forkParked( + Stream.runForEach(providerService.streamEvents, (event) => + isRuntimeTrigger(event) ? worker.enqueue(event.threadId) : Effect.void, + ), + ); + + const pending = yield* turnRetractions.listPending().pipe( + Effect.catchCause((cause) => + Effect.logWarning("turn retraction startup scan failed", { + cause: Cause.pretty(cause), + }).pipe(Effect.as([] as ReadonlyArray)), + ), + ); + yield* Effect.forEach(pending, (row) => worker.enqueue(row.threadId), { + concurrency: 1, + discard: true, + }); + }); + + return { + start, + drain: worker.drain, + } satisfies TurnRetractionReactorShape; +}); + +export const TurnRetractionReactorLive = Layer.effect( + TurnRetractionReactor, + makeTurnRetractionReactor, +).pipe( + Layer.provide(ProjectionTurnRetractionRepositoryLive), + Layer.provide(ProjectionTurnRepositoryLive), +); diff --git a/apps/server/src/orchestration/Services/TurnRetractionReactor.ts b/apps/server/src/orchestration/Services/TurnRetractionReactor.ts new file mode 100644 index 000000000000..8c8517db4080 --- /dev/null +++ b/apps/server/src/orchestration/Services/TurnRetractionReactor.ts @@ -0,0 +1,19 @@ +/** + * Durable process manager for pending turn retractions. + * + * Runtime and domain events are only wakeups. Every transition is decided + * from projected state, and startup scans all requested rows. + */ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Scope from "effect/Scope"; + +export interface TurnRetractionReactorShape { + readonly start: () => Effect.Effect; + readonly drain: Effect.Effect; +} + +export class TurnRetractionReactor extends Context.Service< + TurnRetractionReactor, + TurnRetractionReactorShape +>()("t3/orchestration/Services/TurnRetractionReactor") {} diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 1254f866ad36..5401ca2994cf 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -985,6 +985,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, threadId: command.threadId, }); + if (targetThread.turnRetraction?.status === "requested") { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Thread '${command.threadId}' has pending retraction '${targetThread.turnRetraction.requestId}' and cannot start a new turn.`, + }); + } const sourceProposedPlan = command.sourceProposedPlan; const sourceThread = sourceProposedPlan ? yield* requireThread({ @@ -1501,7 +1507,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" retraction: { requestId: retraction.requestId, messageId: retraction.messageId, - turnId: retraction.targetTurnId, + turnId: command.targetTurnId ?? retraction.targetTurnId, firstUserMessage: retraction.firstUserMessage, completedAt: command.createdAt, }, diff --git a/apps/server/src/orchestration/decider.turnRetraction.test.ts b/apps/server/src/orchestration/decider.turnRetraction.test.ts index 065a6b49b8ac..724b6b203051 100644 --- a/apps/server/src/orchestration/decider.turnRetraction.test.ts +++ b/apps/server/src/orchestration/decider.turnRetraction.test.ts @@ -153,6 +153,32 @@ function invariantDetail(error: unknown): string { } it.layer(NodeServices.layer)("thread.turn.retract decider", (it) => { + it.effect("blocks new turn starts while a retraction remains requested", () => + Effect.gen(function* () { + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.turn.start", + commandId: CommandId.make("cmd-start-while-retracting"), + threadId: THREAD_ID, + message: { + messageId: MessageId.make("message-too-soon"), + role: "user", + text: "too soon", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: NOW, + }, + readModel: readModel(pendingRetraction(false)), + }).pipe(Effect.result); + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(invariantDetail(result.failure)).toContain("pending retraction"); + } + }), + ); + it.effect("atomically emits reverted and deleted for first-message completion", () => Effect.gen(function* () { const decided = yield* completeRetraction(pendingRetraction(true)); diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index 9c07a312023c..441bc8a9b029 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -85,6 +85,7 @@ describe("orchestration projector", () => { interactionMode: "default", branch: null, worktreePath: null, + managedWorktree: null, latestTurn: null, createdAt: now, updatedAt: now, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 132d01c5705d..845acbcc2d40 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -1,4 +1,9 @@ -import type { OrchestrationEvent, OrchestrationReadModel, ThreadId } from "@t3tools/contracts"; +import type { + OrchestrationEvent, + OrchestrationReadModel, + ThreadId, + TurnId, +} from "@t3tools/contracts"; import { OrchestrationCheckpointSummary, OrchestrationMessage, @@ -48,6 +53,14 @@ function checkpointStatusToLatestTurnState(status: "ready" | "missing" | "error" return "completed" as const; } +function isCompletedRetractedTurn(thread: OrchestrationThread, turnId: TurnId | null): boolean { + return ( + turnId !== null && + thread.turnRetraction?.status === "completed" && + thread.turnRetraction.targetTurnId === turnId + ); +} + /** * Turn state to settle a still-running latest turn with when its session * leaves the "running" status, or null while the session is (re)starting or @@ -507,6 +520,9 @@ export function projectEvent( if (!thread) { return nextBase; } + if (isCompletedRetractedTurn(thread, payload.turnId)) { + return nextBase; + } const message: OrchestrationMessage = yield* decodeForEvent( OrchestrationMessage, @@ -668,6 +684,9 @@ export function projectEvent( if (!thread) { return nextBase; } + if (isCompletedRetractedTurn(thread, payload.proposedPlan.turnId)) { + return nextBase; + } const proposedPlans = [ ...thread.proposedPlans.filter((entry) => entry.id !== payload.proposedPlan.id), @@ -700,6 +719,9 @@ export function projectEvent( if (!thread) { return nextBase; } + if (isCompletedRetractedTurn(thread, payload.turnId)) { + return nextBase; + } const checkpoint = yield* decodeForEvent( OrchestrationCheckpointSummary, diff --git a/apps/server/src/persistence/Layers/ProjectionTurnRetractions.ts b/apps/server/src/persistence/Layers/ProjectionTurnRetractions.ts index dc6d13394902..213b9fd852bd 100644 --- a/apps/server/src/persistence/Layers/ProjectionTurnRetractions.ts +++ b/apps/server/src/persistence/Layers/ProjectionTurnRetractions.ts @@ -135,6 +135,25 @@ const make = Effect.gen(function* () { `, }); + const hasProviderSendClaimRow = SqlSchema.findOneOption({ + Request: CancelProjectionTurnProviderSend, + Result: Schema.Struct({ claimed: Schema.Number }), + execute: ({ threadId, messageId }) => sql` + SELECT 1 AS claimed FROM provider_turn_send_claims + WHERE thread_id = ${threadId} AND message_id = ${messageId} + LIMIT 1 + `, + }); + + const markProviderSendClaimedRow = SqlSchema.void({ + Request: ProjectionTurnRetractionRequest, + execute: ({ requestId }) => sql` + UPDATE projection_turn_retractions + SET provider_send_claimed = 1, provider_send_state = 'claimed' + WHERE request_id = ${requestId} AND status = 'requested' + `, + }); + const getByRequestIdRow = SqlSchema.findOneOption({ Request: ProjectionTurnRetractionRequest, Result: ProjectionTurnRetractionDbRow, @@ -230,6 +249,16 @@ const make = Effect.gen(function* () { if (row.providerSendState === "claimed") { return false; } + // Reconcile the restart edge where the turn-start consumer claimed + // send ownership before the retraction projection row existed. + // The immutable claim ledger wins over the row's stale unclaimed + // value; otherwise cancellation wins atomically against any later + // claim attempt. + const existingClaim = yield* hasProviderSendClaimRow(input); + if (Option.isSome(existingClaim)) { + yield* markProviderSendClaimedRow({ requestId: row.requestId }); + return false; + } if (row.providerSendState === "unclaimed") { yield* markProviderSendCancelledRow({ requestId: row.requestId }); } diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 39742f761f3a..45c61442a040 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -3530,7 +3530,7 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("rolls the resume cursor back to the retained turn watermark", () => { + it.effect("rolls history to an absolute retained boundary idempotently", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; @@ -3615,11 +3615,27 @@ describe("ClaudeAdapterLive", () => { const threadBeforeRollback = yield* adapter.readThread(session.threadId); assert.equal(threadBeforeRollback.turns.length, 2); + assert.isDefined(adapter.rollbackThreadTo); + if (!adapter.rollbackThreadTo) return; - const rolledBack = yield* adapter.rollbackThread(session.threadId, 1); + const alreadyAtBoundary = yield* adapter.rollbackThreadTo(session.threadId, 2); + assert.equal(alreadyAtBoundary.turns.length, 2); + + const rolledBack = yield* adapter.rollbackThreadTo(session.threadId, 1); assert.equal(rolledBack.turns.length, 1); assert.equal(rolledBack.turns[0]?.id, firstTurn.turnId); + const repeated = yield* adapter.rollbackThreadTo(session.threadId, 1); + assert.equal(repeated.turns.length, 1); + + const shorterThanTarget = yield* adapter + .rollbackThreadTo(session.threadId, 2) + .pipe(Effect.result); + assert.equal(shorterThanTarget._tag, "Failure"); + if (shorterThanTarget._tag === "Failure") { + assert.equal(shorterThanTarget.failure._tag, "ProviderAdapterValidationError"); + } + const activeSessions = yield* adapter.listSessions(); assert.deepEqual(activeSessions[0]?.resumeCursor, { threadId: THREAD_ID, diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index bf0049772a17..7332c9be6de5 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -4513,6 +4513,39 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }, ); + const rollbackThreadTo: NonNullable = Effect.fn( + "rollbackThreadTo", + )(function* (threadId, retainedTurnCount) { + const context = yield* requireSession(threadId); + if (!Number.isInteger(retainedTurnCount) || retainedTurnCount < 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThreadTo", + issue: "retainedTurnCount must be an integer >= 0.", + }); + } + if (context.turns.length < retainedTurnCount) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThreadTo", + issue: `Provider history has ${context.turns.length} turns, below retained boundary ${retainedTurnCount}.`, + }); + } + const remainingDelta = context.turns.length - retainedTurnCount; + if (remainingDelta > 0) { + yield* rollbackThread(threadId, remainingDelta); + } + const snapshot = yield* snapshotThread(context); + if (snapshot.turns.length !== retainedTurnCount) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "thread/rollback-to", + detail: `Expected ${retainedTurnCount} retained turns, found ${snapshot.turns.length}.`, + }); + } + return snapshot; + }); + const respondToRequest: ClaudeAdapterShape["respondToRequest"] = Effect.fn("respondToRequest")( function* (threadId, requestId, decision) { const context = yield* requireSession(threadId); @@ -4602,6 +4635,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( interruptTurn, readThread, rollbackThread, + rollbackThreadTo, respondToRequest, respondToUserInput, stopSession, diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 7b8fbec56660..7270e5c26d29 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -309,6 +309,37 @@ const sessionErrorLayer = it.layer( ); sessionErrorLayer("CodexAdapterLive session errors", (it) => { + it.effect("computes the remaining absolute rollback delta and is idempotent", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const threadId = asThreadId("absolute-rollback-thread"); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId, + runtimeMode: "full-access", + }); + const runtime = sessionRuntimeFactory.lastRuntime; + NodeAssert.ok(runtime); + NodeAssert.ok(adapter.rollbackThreadTo); + const snapshot = (turnIds: ReadonlyArray): CodexThreadSnapshot => ({ + threadId: "provider-thread-1", + turns: turnIds.map((id) => ({ id: asTurnId(id), items: [] })), + }); + runtime.readThreadImpl + .mockResolvedValueOnce(snapshot(["turn-1", "turn-2", "turn-3"])) + .mockResolvedValue(snapshot(["turn-1"])); + runtime.rollbackThreadImpl.mockResolvedValue(snapshot(["turn-1"])); + + const first = yield* adapter.rollbackThreadTo(threadId, 1); + NodeAssert.equal(first.turns.length, 1); + NodeAssert.deepStrictEqual(runtime.rollbackThreadImpl.mock.calls, [[2]]); + + const repeated = yield* adapter.rollbackThreadTo(threadId, 1); + NodeAssert.equal(repeated.turns.length, 1); + NodeAssert.deepStrictEqual(runtime.rollbackThreadImpl.mock.calls, [[2]]); + }), + ); + it.effect("maps missing adapter sessions to ProviderAdapterSessionNotFoundError", () => Effect.gen(function* () { const adapter = yield* CodexAdapter; diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 6b99bf52b1e0..072c03bb1b72 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -1890,6 +1890,39 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ); }; + const rollbackThreadTo: NonNullable = Effect.fn( + "rollbackThreadTo", + )(function* (threadId, retainedTurnCount) { + if (!Number.isInteger(retainedTurnCount) || retainedTurnCount < 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThreadTo", + issue: "retainedTurnCount must be an integer >= 0.", + }); + } + const current = yield* readThread(threadId); + if (current.turns.length < retainedTurnCount) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThreadTo", + issue: `Provider history has ${current.turns.length} turns, below retained boundary ${retainedTurnCount}.`, + }); + } + const remainingDelta = current.turns.length - retainedTurnCount; + if (remainingDelta > 0) { + yield* rollbackThread(threadId, remainingDelta); + } + const verified = yield* readThread(threadId); + if (verified.turns.length !== retainedTurnCount) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "thread/rollback-to", + detail: `Expected ${retainedTurnCount} retained turns, found ${verified.turns.length}.`, + }); + } + return verified; + }); + const respondToRequest: CodexAdapterShape["respondToRequest"] = (threadId, requestId, decision) => requireSession(threadId).pipe( Effect.flatMap((session) => session.runtime.respondToRequest(requestId, decision)), @@ -1977,6 +2010,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( interruptTurn, readThread, rollbackThread, + rollbackThreadTo, respondToRequest, respondToUserInput, stopSession, diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 2ac00873df99..96c49bed97de 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -76,6 +76,11 @@ const ProviderRollbackConversationInput = Schema.Struct({ numTurns: NonNegativeInt, }); +const ProviderRollbackConversationToInput = Schema.Struct({ + threadId: ThreadId, + retainedTurnCount: NonNegativeInt, +}); + function toValidationError( operation: string, issue: string, @@ -1065,6 +1070,70 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ); }); + const rollbackConversationTo: ProviderServiceMethod<"rollbackConversationTo"> = Effect.fn( + "rollbackConversationTo", + )(function* (rawInput) { + const input = yield* decodeInputOrValidationError({ + operation: "ProviderService.rollbackConversationTo", + schema: ProviderRollbackConversationToInput, + payload: rawInput, + }); + let metricProvider = "unknown"; + return yield* Effect.gen(function* () { + const routed = yield* resolveRoutableSession({ + threadId: input.threadId, + operation: "ProviderService.rollbackConversationTo", + allowRecovery: true, + }); + metricProvider = routed.adapter.provider; + yield* Effect.annotateCurrentSpan({ + "provider.operation": "rollback-conversation-to", + "provider.kind": routed.adapter.provider, + "provider.thread_id": input.threadId, + "provider.retained_turn_count": input.retainedTurnCount, + }); + + if (routed.adapter.rollbackThreadTo !== undefined) { + yield* routed.adapter.rollbackThreadTo(routed.threadId, input.retainedTurnCount); + } else { + // Compatibility conversion for providers that only expose relative + // rollback: read the absolute length, apply only the remaining delta, + // then verify the retained boundary. + const current = yield* routed.adapter.readThread(routed.threadId); + if (current.turns.length < input.retainedTurnCount) { + return yield* toValidationError( + "ProviderService.rollbackConversationTo", + `Provider history has ${current.turns.length} turns, below retained boundary ${input.retainedTurnCount}.`, + ); + } + const remainingDelta = current.turns.length - input.retainedTurnCount; + if (remainingDelta > 0) { + yield* routed.adapter.rollbackThread(routed.threadId, remainingDelta); + } + const verified = yield* routed.adapter.readThread(routed.threadId); + if (verified.turns.length !== input.retainedTurnCount) { + return yield* toValidationError( + "ProviderService.rollbackConversationTo", + `Provider history verification expected ${input.retainedTurnCount} turns, found ${verified.turns.length}.`, + ); + } + } + + yield* analytics.record("provider.conversation.rolled_back", { + provider: routed.adapter.provider, + retainedTurns: input.retainedTurnCount, + }); + }).pipe( + withMetrics({ + counter: providerTurnsTotal, + outcomeAttributes: () => + providerMetricAttributes(metricProvider, { + operation: "rollback-to", + }), + }), + ); + }); + const runStopAll = Effect.fn("runStopAll")(function* () { const threadIds = yield* directory.listThreadIds(); const currentAdapters = yield* getAdapterEntries; @@ -1136,6 +1205,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( getCapabilities, getInstanceInfo, rollbackConversation, + rollbackConversationTo, // Each access creates a fresh PubSub subscription so that multiple // consumers (ProviderRuntimeIngestion, CheckpointReactor, etc.) each // independently receive all runtime events. diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 1281b2f70fe8..b424d91bee00 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -184,6 +184,7 @@ describe("ProviderSessionReaper", () => { }); }, rollbackConversation: () => unsupported(), + rollbackConversationTo: () => unsupported(), streamEvents: Stream.empty, }; diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index 01eeae7b7bd7..2f19d41dafb0 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -114,6 +114,17 @@ export interface ProviderAdapterShape { numTurns: number, ) => Effect.Effect; + /** + * Roll back to an absolute retained-turn boundary. Implementations must + * inspect and verify provider history so retries are idempotent. Adapters + * without a native implementation use ProviderService's read/relative/read + * compatibility shim. + */ + readonly rollbackThreadTo?: ( + threadId: ThreadId, + retainedTurnCount: number, + ) => Effect.Effect; + /** * Stop all sessions owned by this adapter. */ diff --git a/apps/server/src/provider/Services/ProviderService.ts b/apps/server/src/provider/Services/ProviderService.ts index 4d4cb4fa01a7..7a8fe9461f73 100644 --- a/apps/server/src/provider/Services/ProviderService.ts +++ b/apps/server/src/provider/Services/ProviderService.ts @@ -105,6 +105,15 @@ export interface ProviderServiceShape { readonly numTurns: number; }) => Effect.Effect; + /** + * Roll back provider conversation state to an absolute retained-turn + * boundary. Repeating the same target is harmless. + */ + readonly rollbackConversationTo: (input: { + readonly threadId: ThreadId; + readonly retainedTurnCount: number; + }) => Effect.Effect; + /** * Canonical provider runtime event stream. * diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 8d4f8bb61d75..6bdbb3fe28b3 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -57,6 +57,7 @@ import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus. import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion.ts"; import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts"; import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; +import { TurnRetractionReactorLive } from "./orchestration/Layers/TurnRetractionReactor.ts"; import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; @@ -241,6 +242,7 @@ const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(ProviderRuntimeIngestionLive), Layer.provideMerge(ProviderCommandReactorLive), Layer.provideMerge(CheckpointReactorLive), + Layer.provideMerge(TurnRetractionReactorLive), Layer.provideMerge(ThreadDeletionReactorLive), Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))), Layer.provideMerge(RuntimeReceiptBusLive), diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index f2b426aed53f..1d86c82f4f20 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1065,6 +1065,7 @@ const ThreadTurnRetractCompleteCommand = Schema.Struct({ commandId: CommandId, threadId: ThreadId, requestId: CommandId, + targetTurnId: Schema.optional(TurnId), createdAt: IsoDateTime, }); From 6179415faca1de609474c3e756a4c77bac536b48 Mon Sep 17 00:00:00 2001 From: Adam Firestone Date: Tue, 11 Aug 2026 12:14:20 -0500 Subject: [PATCH 08/18] feat(web): Esc dispatches thread.turn.retract; client saga removed Retraction is now one correlated command with persistent commandId; the interrupt/settle/revert saga and checkpoint heuristics are deleted (ChatView net -112 LOC). First-message completions navigate to a seeded fresh draft; mid-thread completions restore into the thread composer; failures always preserve the user's text. Built with Claude Fable 5 via Claude Code, implementation by Codex (GPT-5.6-Sol). --- apps/web/src/components/ChatView.tsx | 324 ++++++------------ .../chat/lastUserMessagePop.test.ts | 46 ++- .../src/components/chat/lastUserMessagePop.ts | 19 +- .../chat/lastUserMessageRecovery.test.ts | 183 +++++++++- .../chat/lastUserMessageRecovery.ts | 113 +++++- .../chat/useLastUserMessageRetraction.ts | 260 ++++++++++++++ docs/user/keybindings.md | 9 +- .../src/operations/commands.test.ts | 27 ++ .../client-runtime/src/operations/commands.ts | 13 + .../src/state/threadCommands.ts | 9 + 10 files changed, 756 insertions(+), 247 deletions(-) create mode 100644 apps/web/src/components/chat/useLastUserMessageRetraction.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 84d883a3ef60..4765966e8619 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -19,7 +19,6 @@ import { OrchestrationThreadActivity, ProviderInteractionMode, ProviderDriverKind, - PROVIDER_SEND_TURN_MAX_ATTACHMENTS, RuntimeMode, TerminalOpenInput, } from "@t3tools/contracts"; @@ -246,23 +245,17 @@ import { import { environmentShell } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { - captureLastUserMessageImages, - deriveLastUserMessageRestoredText, findLastUserMessagePopCandidate, IMAGE_ONLY_MESSAGE_PLACEHOLDER, isLastUserMessagePopWindowOpen, - LAST_USER_MESSAGE_POP_SETTLE_TIMEOUT_MS, - mergePoppedPrompt, } from "./chat/lastUserMessagePop"; import { createPreDispatchCancellationLatch } from "./chat/preDispatchCancellationLatch"; import { CHAT_FLOATING_LAYER_SELECTOR, shouldHandleChatEscape } from "./chat/chatEscapeTrigger"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { shouldRenderEmptyThreadHero } from "./chat/emptyThreadHero"; import { RetractionRecoveryHandoff } from "./chat/RetractionRecoveryHandoff"; -import { - type FirstMessageRetractionCompletion, - useRetractionRecoveryStore, -} from "./chat/lastUserMessageRecovery"; +import { useRetractionRecoveryStore } from "./chat/lastUserMessageRecovery"; +import { useLastUserMessageRetraction } from "./chat/useLastUserMessageRetraction"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; @@ -360,6 +353,10 @@ const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; +const sentMessageRecoveryContextByMessageId = new Map< + MessageId, + { envMode: DraftThreadEnvMode; baseBranch: string | null; startFromOrigin: boolean } +>(); function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { const transitionGroupRef = useRef(null); const composerAnchorRef = useRef(null); @@ -1348,7 +1345,6 @@ function ChatViewContent(props: ChatViewProps) { >({}); const [isConnecting, _setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); - const [isPoppingLastUserMessage, setIsPoppingLastUserMessage] = useState(false); const [maximizedRightPanelThreadKey, setMaximizedRightPanelThreadKey] = useState( null, ); @@ -1989,28 +1985,8 @@ function ChatViewContent(props: ChatViewProps) { ) ?? null) : null, ); - const projectedRetractionCompletion = useMemo(() => { - const retraction = activeThread?.turnRetraction; - if ( - !pendingRetractionRecovery || - !retraction || - retraction.status !== "completed" || - retraction.requestId !== pendingRetractionRecovery.requestId || - retraction.completedAt === null - ) { - return null; - } - return { - threadId: activeThread.id, - retraction: { - requestId: retraction.requestId, - messageId: retraction.messageId, - turnId: retraction.targetTurnId, - firstUserMessage: retraction.firstUserMessage, - completedAt: retraction.completedAt, - }, - }; - }, [activeThread, pendingRetractionRecovery]); + const retractionPending = + pendingRetractionRecovery !== null || activeThread?.turnRetraction?.status === "requested"; const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); const versionMismatchDismissKey = versionMismatch && activeThread @@ -2202,48 +2178,6 @@ function ChatViewContent(props: ChatViewProps) { ); const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; const phase = derivePhase(activeThread?.session ?? null); - const runningTurnStateRef = useRef({ threadId: activeThread?.id ?? null, phase }); - runningTurnStateRef.current = { threadId: activeThread?.id ?? null, phase }; - const runningTurnSettlementChecksRef = useRef(new Set<(cancel?: boolean) => void>()); - useEffect(() => { - for (const check of runningTurnSettlementChecksRef.current) { - check(); - } - }, [activeThread?.id, phase]); - useEffect( - () => () => { - for (const check of runningTurnSettlementChecksRef.current) { - check(true); - } - runningTurnSettlementChecksRef.current.clear(); - }, - [], - ); - const waitForRunningTurnToSettle = useCallback((threadId: ThreadId): Promise => { - const current = runningTurnStateRef.current; - if (current.threadId !== threadId) return Promise.resolve(false); - if (current.phase !== "running") return Promise.resolve(true); - - return new Promise((resolve) => { - let timeoutId: number | null = null; - const finish = (settled: boolean) => { - runningTurnSettlementChecksRef.current.delete(check); - if (timeoutId !== null) window.clearTimeout(timeoutId); - resolve(settled); - }; - const check = (cancel = false) => { - const next = runningTurnStateRef.current; - if (cancel || next.threadId !== threadId) { - finish(false); - } else if (next.phase !== "running") { - finish(true); - } - }; - runningTurnSettlementChecksRef.current.add(check); - timeoutId = window.setTimeout(() => finish(false), LAST_USER_MESSAGE_POP_SETTLE_TIMEOUT_MS); - check(); - }); - }, []); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]); const turnPlans = useMemo(() => deriveTurnPlans(threadActivities), [threadActivities]); @@ -2348,7 +2282,8 @@ function ChatViewContent(props: ChatViewProps) { activePendingUserInput: activePendingUserInput?.requestId ?? null, threadError, }); - const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; + const isWorking = + phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint || retractionPending; const activeWorkStartedAt = deriveActiveWorkStartedAt( activeLatestTurn, activeThread?.session ?? null, @@ -2662,25 +2597,18 @@ function ChatViewContent(props: ChatViewProps) { return byUserMessageId; }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]); - const latestCheckpoint = activeThread?.checkpoints.at(-1) ?? null; - const checkpointTurnCount = activeThread?.checkpoints.reduce( - (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount), - 0, - ); const activeRunningTurnId = activeThread?.session?.activeTurnId ?? null; const lastUserMessagePopWindowOpen = - latestCheckpoint?.turnId !== activeRunningTurnId && + supportsThreadTurnRetraction && isLastUserMessagePopWindowOpen({ phase, activeTurnId: activeRunningTurnId, timelineEntries, + localTurnStartPending: isSendBusy, + retractionPending, }); const lastUserMessagePopCandidate = lastUserMessagePopWindowOpen - ? findLastUserMessagePopCandidate({ - messages: timelineMessages, - turnCount: checkpointTurnCount ?? 0, - latestCheckpointCompletedAt: latestCheckpoint?.completedAt ?? null, - }) + ? findLastUserMessagePopCandidate({ messages: timelineMessages }) : null; const gitCwd = activeProject @@ -4975,6 +4903,16 @@ function ChatViewContent(props: ChatViewProps) { }), ); }; + if (retractionPending) { + toastManager.add( + stackedThreadToast({ + type: "info", + title: "Message retraction in progress", + description: "Wait for the current message to finish retracting before sending again.", + }), + ); + return; + } if ( !activeThread || isSendBusy || @@ -5135,6 +5073,12 @@ function ChatViewContent(props: ChatViewProps) { const composerPreviewAnnotationsSnapshot = [...composerPreviewAnnotations]; const composerReviewCommentsSnapshot: ReviewCommentContext[] = [...composerReviewComments]; const messageIdForSend = newMessageId(); + sentMessageRecoveryContextByMessageId.clear(); + sentMessageRecoveryContextByMessageId.set(messageIdForSend, { + envMode: sendEnvMode, + baseBranch: activeThreadBranch, + startFromOrigin, + }); preDispatchCancellationLatchRef.current.arm(messageIdForSend); sendInFlightRef.current = true; if (isDraftHeroState && activeThreadKey) { @@ -5153,6 +5097,7 @@ function ChatViewContent(props: ChatViewProps) { await dockStarted; } if (preDispatchCancellationLatchRef.current.isCancelled(messageIdForSend)) { + sentMessageRecoveryContextByMessageId.delete(messageIdForSend); preDispatchCancellationLatchRef.current.clear(messageIdForSend); sendInFlightRef.current = false; setDockedDraftHeroThreadKey((currentThreadKey) => @@ -5419,6 +5364,7 @@ function ChatViewContent(props: ChatViewProps) { preDispatchCancellationLatchRef.current.clear(messageIdForSend); sendInFlightRef.current = false; if (!turnStartSucceeded) { + sentMessageRecoveryContextByMessageId.delete(messageIdForSend); setDockedDraftHeroThreadKey((currentThreadKey) => currentThreadKey === activeThreadKey ? null : currentThreadKey, ); @@ -5621,6 +5567,7 @@ function ChatViewContent(props: ChatViewProps) { if ( !activeThread || !isServerThread || + retractionPending || isSendBusy || isConnecting || sendInFlightRef.current @@ -5763,6 +5710,7 @@ function ChatViewContent(props: ChatViewProps) { isConnecting, isSendBusy, isServerThread, + retractionPending, localCheckoutBranchMismatch, persistThreadSettingsForNextTurn, resetLocalDispatch, @@ -5781,6 +5729,7 @@ function ChatViewContent(props: ChatViewProps) { !activeProject || !activeProposedPlan || !isServerThread || + retractionPending || isSendBusy || isConnecting || activeEnvironmentUnavailable || @@ -5924,6 +5873,7 @@ function ChatViewContent(props: ChatViewProps) { isConnecting, isSendBusy, isServerThread, + retractionPending, navigate, resetLocalDispatch, runtimeMode, @@ -6097,130 +6047,28 @@ function ChatViewContent(props: ChatViewProps) { } void onRevertToTurnCountRef.current(targetTurnCount); }, []); - // Post-dispatch seam: WO5b can replace this saga with thread.turn.retract; - // the pre-dispatch latch and chat-scoped Escape trigger stay unchanged. - const onPopLastUserMessage = useCallback(async () => { - if (!lastUserMessagePopCandidate || !activeThread || isPoppingLastUserMessage) return; - - setIsPoppingLastUserMessage(true); - const poppedThreadId = activeThread.id; - const poppedDraftTarget = composerDraftTarget; - const poppedMessageId = lastUserMessagePopCandidate.message.id; - const restoredText = deriveLastUserMessageRestoredText( - lastUserMessagePopCandidate.message.text, - ); - const imageCapture = captureLastUserMessageImages(lastUserMessagePopCandidate.message); - let failureDescription: string | null = null; - - try { - const interrupted = await interruptActiveTurn(); - if (!interrupted) { - failureDescription = "The turn could not be interrupted."; - } else { - const settled = await waitForRunningTurnToSettle(poppedThreadId); - if (!settled) { - failureDescription = "The turn did not settle within 15 seconds."; - } else { - const reverted = await onRevertToTurnCountRef.current( - lastUserMessagePopCandidate.turnCount, - { skipConfirm: true }, - ); - if (!reverted) { - failureDescription = "The sent message could not be removed from thread history."; - } else { - setOptimisticUserMessages((existing) => { - const removed = existing.filter((message) => message.id === poppedMessageId); - for (const message of removed) { - revokeUserMessagePreviewUrls(message); - } - return existing.filter((message) => message.id !== poppedMessageId); - }); - clearAttachmentPreviewHandoff(poppedMessageId); - } - } - } - } catch (error) { - failureDescription = chatActionErrorMessage(error); - } - - const { images, failedNames } = await imageCapture.catch(() => ({ - images: [], - failedNames: (lastUserMessagePopCandidate.message.attachments ?? []).map( - (attachment) => attachment.name, - ), - })); - const currentDraft = useComposerDraftStore.getState().getComposerDraft(poppedDraftTarget); - const currentPrompt = currentDraft?.prompt ?? ""; - const nextPrompt = mergePoppedPrompt(currentPrompt, restoredText); - setComposerDraftPrompt(poppedDraftTarget, nextPrompt); - - const existingImages = currentDraft?.images ?? []; - const existingIds = new Set(existingImages.map((image) => image.id)); - const existingKeys = new Set( - existingImages.map((image) => JSON.stringify([image.mimeType, image.sizeBytes, image.name])), - ); - const acceptedImages: ComposerImageAttachment[] = []; - const overflowNames: string[] = []; - for (const image of images) { - const dedupKey = JSON.stringify([image.mimeType, image.sizeBytes, image.name]); - if (existingIds.has(image.id) || existingKeys.has(dedupKey)) { - revokeBlobPreviewUrl(image.previewUrl); - continue; - } - if (existingImages.length + acceptedImages.length >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { - overflowNames.push(image.name); - revokeBlobPreviewUrl(image.previewUrl); - continue; - } - existingIds.add(image.id); - existingKeys.add(dedupKey); - acceptedImages.push(image); - } - addComposerDraftImages(poppedDraftTarget, acceptedImages); - - if (runningTurnStateRef.current.threadId === poppedThreadId) { - promptRef.current = nextPrompt; - composerImagesRef.current = [...existingImages, ...acceptedImages]; - composerRef.current?.resetCursorState({ - cursor: collapseExpandedComposerCursor(nextPrompt, nextPrompt.length), - prompt: nextPrompt, - detectTrigger: true, - }); - window.requestAnimationFrame(() => { - composerRef.current?.focusAtEnd(); - }); - } - - const unrestoredImageNames = [...failedNames, ...overflowNames]; - if (unrestoredImageNames.length > 0) { - toastManager.add({ - type: "warning", - title: "Some images could not be restored", - description: `${unrestoredImageNames.join(", ")} could not be restored to the composer.`, - }); - } - if (failureDescription !== null) { - toastManager.add({ - type: "error", - title: "Message restored, but the turn could not be rewound", - description: failureDescription, - }); - } - setIsPoppingLastUserMessage(false); - }, [ + const lastUserMessageRecoveryContext = lastUserMessagePopCandidate + ? sentMessageRecoveryContextByMessageId.get(lastUserMessagePopCandidate.message.id) + : undefined; + const onPopLastUserMessage = useLastUserMessageRetraction({ activeThread, - addComposerDraftImages, - clearAttachmentPreviewHandoff, - composerDraftTarget, - composerImagesRef, + activeProjectRef, + activeThreadBranch: lastUserMessageRecoveryContext + ? lastUserMessageRecoveryContext.baseBranch + : activeThreadBranch, + activeEnvironmentUnavailable, + candidate: lastUserMessagePopCandidate, + pendingRecovery: pendingRetractionRecovery, + retractionPending, + runtimeMode, + interactionMode, + envMode: lastUserMessageRecoveryContext?.envMode ?? envMode, + startFromOrigin: lastUserMessageRecoveryContext?.startFromOrigin ?? startFromOrigin, composerRef, - interruptActiveTurn, - isPoppingLastUserMessage, - lastUserMessagePopCandidate, promptRef, - setComposerDraftPrompt, - waitForRunningTurnToSettle, - ]); + composerImagesRef, + setThreadError, + }); useEffect(() => { const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { @@ -6238,7 +6086,11 @@ function ChatViewContent(props: ChatViewProps) { const cancelledMessageId = preDispatchCancellationLatchRef.current.cancel(); if (cancelledMessageId === null) { - if (lastUserMessagePopCandidate === null || isPoppingLastUserMessage) { + if (retractionPending) { + return; + } + if (lastUserMessagePopCandidate === null) { + scheduleComposerFocus(); return; } void onPopLastUserMessage(); @@ -6250,7 +6102,7 @@ function ChatViewContent(props: ChatViewProps) { window.addEventListener("keydown", onWindowKeyDown); return () => window.removeEventListener("keydown", onWindowKeyDown); - }, [isPoppingLastUserMessage, lastUserMessagePopCandidate, onPopLastUserMessage]); + }, [lastUserMessagePopCandidate, onPopLastUserMessage, retractionPending, scheduleComposerFocus]); // Empty state: no active thread if (!activeThread) { @@ -6395,14 +6247,6 @@ function ChatViewContent(props: ChatViewProps) { return (
- {supportsThreadTurnRetraction && pendingRetractionRecovery ? ( - - ) : null} {rightPanelOpen && !shouldUseRightPanelSheet ? panelLayoutControls : null}
+ props.routeKind === "server" + ? (Object.values(state.byRequestId).find( + (recovery) => + recovery.sourceThreadRef.environmentId === props.environmentId && + recovery.sourceThreadRef.threadId === props.threadId, + ) ?? null) + : null, + ); + const retractionThread = useThread( + pendingRetractionRecovery ? pendingRetractionRecovery.sourceThreadRef : null, + ); + const projectedRetraction = retractionThread?.turnRetraction; + const projectedCompletion = + pendingRetractionRecovery && + projectedRetraction?.status === "completed" && + projectedRetraction.requestId === pendingRetractionRecovery.requestId && + projectedRetraction.completedAt !== null + ? { + threadId: pendingRetractionRecovery.sourceThreadRef.threadId, + retraction: { + requestId: projectedRetraction.requestId, + messageId: projectedRetraction.messageId, + turnId: projectedRetraction.targetTurnId, + firstUserMessage: projectedRetraction.firstUserMessage, + completedAt: projectedRetraction.completedAt, + }, + } + : null; return ( + {pendingRetractionRecovery ? ( + + ) : null} ); diff --git a/apps/web/src/components/chat/lastUserMessagePop.test.ts b/apps/web/src/components/chat/lastUserMessagePop.test.ts index de3d14a65140..43cb4bdcaa29 100644 --- a/apps/web/src/components/chat/lastUserMessagePop.test.ts +++ b/apps/web/src/components/chat/lastUserMessagePop.test.ts @@ -159,6 +159,35 @@ describe("last user message pop window", () => { }), ).toBe(false); }); + + it("opens while a dispatched turn is still starting", () => { + expect( + isLastUserMessagePopWindowOpen({ + phase: "connecting", + activeTurnId: null, + timelineEntries: [], + }), + ).toBe(true); + expect( + isLastUserMessagePopWindowOpen({ + phase: "ready", + activeTurnId: null, + timelineEntries: [], + localTurnStartPending: true, + }), + ).toBe(true); + }); + + it("closes while a correlated retraction is pending", () => { + expect( + isLastUserMessagePopWindowOpen({ + phase: "running", + activeTurnId: codexTurnId, + timelineEntries: [], + retractionPending: true, + }), + ).toBe(false); + }); }); describe("last user message selection", () => { @@ -167,10 +196,8 @@ describe("last user message selection", () => { expect( findLastUserMessagePopCandidate({ messages: [optimistic], - turnCount: 0, - latestCheckpointCompletedAt: null, }), - ).toEqual({ message: optimistic, turnCount: 0 }); + ).toEqual({ message: optimistic }); }); it("selects the optimistic follow-up after server messages", () => { @@ -193,20 +220,17 @@ describe("last user message selection", () => { expect( findLastUserMessagePopCandidate({ messages: [first, assistant, optimistic], - turnCount: 1, - latestCheckpointCompletedAt: "2026-08-10T12:00:02.000Z", }), - ).toEqual({ message: optimistic, turnCount: 1 }); + ).toEqual({ message: optimistic }); }); - it("does not select a user message that predates the latest checkpoint", () => { + it("selects the newest user message without checkpoint-race heuristics", () => { + const completed = message({ id: "completed-user", role: "user" }); expect( findLastUserMessagePopCandidate({ - messages: [message({ id: "completed-user", role: "user" })], - turnCount: 1, - latestCheckpointCompletedAt: "2026-08-10T12:00:01.000Z", + messages: [completed], }), - ).toBeNull(); + ).toEqual({ message: completed }); }); }); diff --git a/apps/web/src/components/chat/lastUserMessagePop.ts b/apps/web/src/components/chat/lastUserMessagePop.ts index da24d04cdabc..0f2a27fb1829 100644 --- a/apps/web/src/components/chat/lastUserMessagePop.ts +++ b/apps/web/src/components/chat/lastUserMessagePop.ts @@ -8,36 +8,31 @@ import type { TimelineEntry } from "../../session-logic"; import type { ChatMessage, SessionPhase } from "../../types"; import type { ComposerImageAttachment } from "../../composerDraftStore"; -export const LAST_USER_MESSAGE_POP_SETTLE_TIMEOUT_MS = 15_000; export const IMAGE_ONLY_MESSAGE_PLACEHOLDER = "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; export interface LastUserMessagePopCandidate { message: ChatMessage; - turnCount: number; } export function findLastUserMessagePopCandidate(input: { messages: ReadonlyArray; - turnCount: number; - latestCheckpointCompletedAt: string | null; }): LastUserMessagePopCandidate | null { const message = input.messages.findLast((entry) => entry.role === "user"); - if (!message) return null; - if ( - input.latestCheckpointCompletedAt !== null && - message.createdAt < input.latestCheckpointCompletedAt - ) { - return null; - } - return { message, turnCount: input.turnCount }; + return message ? { message } : null; } export function isLastUserMessagePopWindowOpen(input: { phase: SessionPhase; activeTurnId: TurnId | null; timelineEntries: ReadonlyArray; + localTurnStartPending?: boolean; + retractionPending?: boolean; }): boolean { + if (input.retractionPending) return false; + if (input.localTurnStartPending || input.phase === "connecting") { + return true; + } if (input.phase !== "running" || input.activeTurnId === null) { return false; } diff --git a/apps/web/src/components/chat/lastUserMessageRecovery.test.ts b/apps/web/src/components/chat/lastUserMessageRecovery.test.ts index e73027d927f2..e54e2ca448a4 100644 --- a/apps/web/src/components/chat/lastUserMessageRecovery.test.ts +++ b/apps/web/src/components/chat/lastUserMessageRecovery.test.ts @@ -11,7 +11,11 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { DraftId, useComposerDraftStore } from "../../composerDraftStore"; import { + buildRetractionCommandInput, handoffCompletedFirstMessageRetraction, + handoffCompletedMidThreadRetraction, + findCorrelatedRetractionFailure, + restoreRetractionRecoveryToThread, snapshotLastUserMessageRecovery, useRetractionRecoveryStore, } from "./lastUserMessageRecovery"; @@ -22,6 +26,7 @@ const projectRef = scopeProjectRef(environmentId, projectId); const sourceThreadId = ThreadId.make("source-thread"); const sourceThreadRef = scopeThreadRef(environmentId, sourceThreadId); const requestId = CommandId.make("request-1"); +const messageId = MessageId.make("message-1"); const draftId = DraftId.make("recovery-draft"); const futureThreadId = ThreadId.make("future-thread"); @@ -35,11 +40,31 @@ beforeEach(() => { }); describe("last user message recovery draft", () => { + it("reuses the persisted request ID and message correlation for reconnect dispatches", () => { + const recovery = { + requestId, + messageId, + sourceThreadRef, + projectRef, + draftId, + createdAt: "2026-08-11T12:00:00.000Z", + }; + + expect(buildRetractionCommandInput(recovery)).toEqual({ + commandId: requestId, + threadId: sourceThreadId, + messageId, + createdAt: recovery.createdAt, + }); + expect(buildRetractionCommandInput(recovery)).toEqual(buildRetractionCommandInput(recovery)); + }); + it("snapshots the full restore bundle into a fresh unmapped draft", async () => { const file = new File(["image"], "shot.png", { type: "image/png" }); const result = await snapshotLastUserMessageRecovery({ requestId, + messageId, sourceThreadRef, projectRef, draftId, @@ -105,6 +130,7 @@ describe("last user message recovery draft", () => { it("maps and navigates only a capability-gated correlated first-message completion", async () => { await snapshotLastUserMessageRecovery({ requestId, + messageId, sourceThreadRef, projectRef, draftId, @@ -129,7 +155,7 @@ describe("last user message recovery draft", () => { threadId: sourceThreadId, retraction: { requestId, - messageId: MessageId.make("message-1"), + messageId, turnId: null, firstUserMessage: true, completedAt: "2026-08-11T12:00:05.000Z", @@ -170,6 +196,17 @@ describe("last user message recovery draft", () => { expect(useComposerDraftStore.getState().getDraftSessionByProjectRef(projectRef)).toMatchObject({ draftId, hidden: false, + runtimeMode: "approval-required", + interactionMode: "default", + envMode: "local", + branch: "feature/current", + startFromOrigin: false, + }); + expect(useComposerDraftStore.getState().getComposerDraft(draftId)).toMatchObject({ + prompt: "restore this prompt", + activeProvider: ProviderInstanceId.make("codex"), + runtimeMode: "approval-required", + interactionMode: "default", }); expect(navigate).toHaveBeenCalledWith({ to: "/draft/$draftId", @@ -178,4 +215,148 @@ describe("last user message recovery draft", () => { }); expect(useRetractionRecoveryStore.getState().byRequestId[requestId]).toBeUndefined(); }); + + it("restores a correlated mid-thread completion into the same composer and cleans the hidden draft", async () => { + const typedImage = new File(["typed"], "typed.png", { type: "image/png" }); + const recoveredImage = new File(["recovered"], "recovered.png", { type: "image/png" }); + useComposerDraftStore.getState().setPrompt(sourceThreadRef, "typed while pending"); + useComposerDraftStore.getState().addImages(sourceThreadRef, [ + { + type: "image", + id: "typed-image", + name: typedImage.name, + mimeType: typedImage.type, + sizeBytes: typedImage.size, + previewUrl: "typed-preview", + file: typedImage, + }, + ]); + await snapshotLastUserMessageRecovery({ + requestId, + messageId, + sourceThreadRef, + projectRef, + draftId, + futureThreadId, + createdAt: "2026-08-11T12:00:00.000Z", + bundle: { + prompt: "original sent text", + images: [ + { + type: "image", + id: "recovered-image", + name: recoveredImage.name, + mimeType: recoveredImage.type, + sizeBytes: recoveredImage.size, + previewUrl: "recovered-preview", + file: recoveredImage, + }, + ], + modelSelection: { + instanceId: ProviderInstanceId.make("claude-work"), + model: "claude-opus-4-1", + }, + runtimeMode: "approval-required", + interactionMode: "plan", + envMode: "local", + baseBranch: "main", + startFromOrigin: false, + }, + encodeImage: async () => "data:image/png;base64,aW1hZ2U=", + }); + + const restored = handoffCompletedMidThreadRetraction({ + environmentId, + completion: { + threadId: sourceThreadId, + retraction: { + requestId, + messageId, + turnId: null, + firstUserMessage: false, + completedAt: "2026-08-11T12:00:05.000Z", + }, + }, + }); + + expect(restored).toMatchObject({ + prompt: "typed while pending\n\noriginal sent text", + unrestoredImageNames: [], + }); + expect(useComposerDraftStore.getState().getComposerDraft(sourceThreadRef)).toMatchObject({ + prompt: "typed while pending\n\noriginal sent text", + activeProvider: ProviderInstanceId.make("claude-work"), + runtimeMode: "approval-required", + interactionMode: "plan", + images: [ + expect.objectContaining({ id: "typed-image" }), + expect.objectContaining({ id: "recovered-image" }), + ], + }); + expect(useComposerDraftStore.getState().getDraftSession(draftId)).toBeNull(); + expect(useRetractionRecoveryStore.getState().byRequestId[requestId]).toBeUndefined(); + }); + + it("restores text and removes the hidden sidebar draft when a retraction fails", async () => { + await snapshotLastUserMessageRecovery({ + requestId, + messageId, + sourceThreadRef, + projectRef, + draftId, + futureThreadId, + createdAt: "2026-08-11T12:00:00.000Z", + bundle: { + prompt: "preserve me", + images: [], + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.6", + }, + runtimeMode: "full-access", + interactionMode: "default", + envMode: "worktree", + baseBranch: "main", + startFromOrigin: true, + }, + }); + + expect(restoreRetractionRecoveryToThread({ requestId, sourceThreadRef })).toMatchObject({ + prompt: "preserve me", + }); + expect(useComposerDraftStore.getState().getComposerDraft(sourceThreadRef)?.prompt).toBe( + "preserve me", + ); + expect(useComposerDraftStore.getState().getDraftSession(draftId)).toBeNull(); + expect(useComposerDraftStore.getState().getDraftSessionByProjectRef(projectRef)).toBeNull(); + expect(useRetractionRecoveryStore.getState().byRequestId[requestId]).toBeUndefined(); + }); + + it("finds only failure activity correlated to the pending request", () => { + expect( + findCorrelatedRetractionFailure( + [ + { + id: "event-1" as never, + tone: "error", + kind: "turn.retract.failed", + summary: "Message retract failed", + payload: { requestId: CommandId.make("other-request"), detail: "wrong failure" }, + turnId: null, + createdAt: "2026-08-11T12:00:01.000Z", + }, + { + id: "event-2" as never, + tone: "error", + kind: "turn.retract.failed", + summary: "Message retract failed", + payload: { requestId, detail: "provider rollback failed" }, + turnId: null, + createdAt: "2026-08-11T12:00:02.000Z", + }, + ], + requestId, + ), + ).toBe("provider rollback failed"); + }); }); diff --git a/apps/web/src/components/chat/lastUserMessageRecovery.ts b/apps/web/src/components/chat/lastUserMessageRecovery.ts index 10780d267ed9..ef2fc4982e1f 100644 --- a/apps/web/src/components/chat/lastUserMessageRecovery.ts +++ b/apps/web/src/components/chat/lastUserMessageRecovery.ts @@ -2,6 +2,7 @@ import type { CommandId, MessageId, ModelSelection, + OrchestrationThreadActivity, ProviderInteractionMode, RuntimeMode, ScopedProjectRef, @@ -9,6 +10,7 @@ import type { ThreadId, TurnId, } from "@t3tools/contracts"; +import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "@t3tools/contracts"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; @@ -20,18 +22,29 @@ import { useComposerDraftStore, } from "../../composerDraftStore"; import { resolveStorage } from "../../lib/storage"; -import { readFileAsDataUrl } from "../ChatView.logic"; +import { cloneComposerImageForRetry, readFileAsDataUrl } from "../ChatView.logic"; +import { mergePoppedPrompt } from "./lastUserMessagePop"; const RETRACTION_RECOVERY_STORAGE_KEY = "t3code:thread-retraction-recoveries:v1"; export interface PendingRetractionRecovery { requestId: CommandId; + messageId: MessageId; sourceThreadRef: ScopedThreadRef; projectRef: ScopedProjectRef; draftId: DraftId; createdAt: string; } +export function buildRetractionCommandInput(recovery: PendingRetractionRecovery) { + return { + commandId: recovery.requestId, + threadId: recovery.sourceThreadRef.threadId, + messageId: recovery.messageId, + createdAt: recovery.createdAt, + }; +} + interface RetractionRecoveryStoreState { byRequestId: Record; remember: (recovery: PendingRetractionRecovery) => void; @@ -93,6 +106,7 @@ export interface FirstMessageRetractionCompletion { export async function snapshotLastUserMessageRecovery(input: { requestId: CommandId; + messageId: MessageId; sourceThreadRef: ScopedThreadRef; projectRef: ScopedProjectRef; draftId: DraftId; @@ -146,6 +160,7 @@ export async function snapshotLastUserMessageRecovery(input: { useRetractionRecoveryStore.getState().remember({ requestId: input.requestId, + messageId: input.messageId, sourceThreadRef: input.sourceThreadRef, projectRef: input.projectRef, draftId: input.draftId, @@ -158,6 +173,102 @@ export async function snapshotLastUserMessageRecovery(input: { }; } +export interface AppliedRetractionRecovery { + prompt: string; + images: ComposerImageAttachment[]; + unrestoredImageNames: string[]; +} + +export function findCorrelatedRetractionFailure( + activities: ReadonlyArray, + requestId: CommandId, +): string | null { + const activity = activities.findLast((entry) => { + if (entry.kind !== "turn.retract.failed" || typeof entry.payload !== "object") return false; + return (entry.payload as { requestId?: unknown } | null)?.requestId === requestId; + }); + if (!activity) return null; + const detail = (activity.payload as { detail?: unknown } | null)?.detail; + return typeof detail === "string" && detail.trim().length > 0 ? detail : activity.summary; +} + +export function restoreRetractionRecoveryToThread(input: { + requestId: CommandId; + sourceThreadRef: ScopedThreadRef; +}): AppliedRetractionRecovery | null { + const recovery = useRetractionRecoveryStore.getState().byRequestId[input.requestId]; + if ( + !recovery || + recovery.sourceThreadRef.environmentId !== input.sourceThreadRef.environmentId || + recovery.sourceThreadRef.threadId !== input.sourceThreadRef.threadId + ) { + return null; + } + + const store = useComposerDraftStore.getState(); + const recoveredDraft = store.getComposerDraft(recovery.draftId); + if (!recoveredDraft) return null; + + const currentDraft = store.getComposerDraft(input.sourceThreadRef); + const prompt = mergePoppedPrompt(currentDraft?.prompt ?? "", recoveredDraft.prompt); + const existingImages = currentDraft?.images ?? []; + const existingIds = new Set(existingImages.map((image) => image.id)); + const existingKeys = new Set( + existingImages.map((image) => JSON.stringify([image.mimeType, image.sizeBytes, image.name])), + ); + const images: ComposerImageAttachment[] = []; + const unrestoredImageNames: string[] = []; + for (const recoveredImage of recoveredDraft.images) { + const key = JSON.stringify([ + recoveredImage.mimeType, + recoveredImage.sizeBytes, + recoveredImage.name, + ]); + if (existingIds.has(recoveredImage.id) || existingKeys.has(key)) continue; + if (existingImages.length + images.length >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { + unrestoredImageNames.push(recoveredImage.name); + continue; + } + existingIds.add(recoveredImage.id); + existingKeys.add(key); + images.push(cloneComposerImageForRetry(recoveredImage)); + } + + store.setPrompt(input.sourceThreadRef, prompt); + store.addImages(input.sourceThreadRef, images); + const recoveredModelSelection = recoveredDraft.activeProvider + ? recoveredDraft.modelSelectionByProvider[recoveredDraft.activeProvider] + : undefined; + store.setModelSelection(input.sourceThreadRef, recoveredModelSelection, { + replaceOptions: true, + }); + store.setRuntimeMode(input.sourceThreadRef, recoveredDraft.runtimeMode); + store.setInteractionMode(input.sourceThreadRef, recoveredDraft.interactionMode); + store.clearDraftThread(recovery.draftId); + useRetractionRecoveryStore.getState().forget(input.requestId); + + return { + prompt, + images: [...existingImages, ...images], + unrestoredImageNames, + }; +} + +export function handoffCompletedMidThreadRetraction(input: { + environmentId: ScopedThreadRef["environmentId"]; + completion: FirstMessageRetractionCompletion; +}): AppliedRetractionRecovery | null { + const metadata = input.completion.retraction; + if (!metadata || metadata.firstUserMessage) return null; + return restoreRetractionRecoveryToThread({ + requestId: metadata.requestId, + sourceThreadRef: { + environmentId: input.environmentId, + threadId: input.completion.threadId, + }, + }); +} + export function handoffCompletedFirstMessageRetraction(input: { capabilityEnabled: boolean; environmentId: ScopedThreadRef["environmentId"]; diff --git a/apps/web/src/components/chat/useLastUserMessageRetraction.ts b/apps/web/src/components/chat/useLastUserMessageRetraction.ts new file mode 100644 index 000000000000..3ddf508e285a --- /dev/null +++ b/apps/web/src/components/chat/useLastUserMessageRetraction.ts @@ -0,0 +1,260 @@ +import type { + ProviderInteractionMode, + RuntimeMode, + ScopedProjectRef, + ThreadId, +} from "@t3tools/contracts"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { useCallback, useEffect, useRef, type RefObject } from "react"; + +import type { ComposerHandleRef } from "../../composerHandleContext"; +import type { ComposerImageAttachment, DraftThreadEnvMode } from "../../composerDraftStore"; +import { newCommandId, newDraftId, newThreadId } from "../../lib/utils"; +import { threadEnvironment } from "../../state/threads"; +import type { Thread } from "../../types"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { collapseExpandedComposerCursor } from "../../composer-logic"; +import { stackedThreadToast, toastManager } from "../ui/toast"; +import type { LastUserMessagePopCandidate } from "./lastUserMessagePop"; +import { + captureLastUserMessageImages, + deriveLastUserMessageRestoredText, +} from "./lastUserMessagePop"; +import { + buildRetractionCommandInput, + findCorrelatedRetractionFailure, + handoffCompletedMidThreadRetraction, + type PendingRetractionRecovery, + restoreRetractionRecoveryToThread, + snapshotLastUserMessageRecovery, + useRetractionRecoveryStore, +} from "./lastUserMessageRecovery"; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : "An error occurred."; +} + +export function useLastUserMessageRetraction(input: { + activeThread: Thread | undefined; + activeProjectRef: ScopedProjectRef | null; + activeThreadBranch: string | null; + activeEnvironmentUnavailable: boolean; + candidate: LastUserMessagePopCandidate | null; + pendingRecovery: PendingRetractionRecovery | null; + retractionPending: boolean; + runtimeMode: RuntimeMode; + interactionMode: ProviderInteractionMode; + envMode: DraftThreadEnvMode; + startFromOrigin: boolean; + composerRef: ComposerHandleRef; + promptRef: RefObject; + composerImagesRef: RefObject; + setThreadError: (threadId: ThreadId | null, detail: string | null) => void; +}) { + const { + activeThread, + activeProjectRef, + activeThreadBranch, + activeEnvironmentUnavailable, + candidate, + pendingRecovery, + retractionPending, + runtimeMode, + interactionMode, + envMode, + startFromOrigin, + composerRef, + promptRef, + composerImagesRef, + setThreadError, + } = input; + const retractThreadTurn = useAtomCommand(threadEnvironment.retractTurn, { + reportFailure: false, + }); + const applyRestoredComposer = useCallback( + (restored: NonNullable>) => { + promptRef.current = restored.prompt; + composerImagesRef.current = restored.images; + composerRef.current?.resetCursorState({ + cursor: collapseExpandedComposerCursor(restored.prompt, restored.prompt.length), + prompt: restored.prompt, + detectTrigger: true, + }); + window.requestAnimationFrame(() => composerRef.current?.focusAtEnd()); + if (restored.unrestoredImageNames.length > 0) { + toastManager.add({ + type: "warning", + title: "Some images could not be restored", + description: `${restored.unrestoredImageNames.join(", ")} could not be restored to the composer.`, + }); + } + }, + [composerImagesRef, composerRef, promptRef], + ); + + const failPendingRetraction = useCallback( + (recovery: PendingRetractionRecovery, detail: string) => { + const restored = restoreRetractionRecoveryToThread({ + requestId: recovery.requestId, + sourceThreadRef: recovery.sourceThreadRef, + }); + if (restored) applyRestoredComposer(restored); + setThreadError(recovery.sourceThreadRef.threadId, detail); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Message restored, but the turn could not be retracted", + description: detail, + }), + ); + }, + [applyRestoredComposer, setThreadError], + ); + + const dispatchesRef = useRef(new Set()); + const recoveryPreparationRef = useRef(false); + const dispatchPendingRetraction = useCallback( + async (recovery: PendingRetractionRecovery) => { + if (dispatchesRef.current.has(recovery.requestId)) return; + dispatchesRef.current.add(recovery.requestId); + const result = await retractThreadTurn({ + environmentId: recovery.sourceThreadRef.environmentId, + input: buildRetractionCommandInput(recovery), + }); + dispatchesRef.current.delete(recovery.requestId); + if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + if ( + typeof error === "object" && + error !== null && + "_tag" in error && + error._tag === "EnvironmentRpcUnavailableError" + ) { + return; + } + failPendingRetraction(recovery, errorMessage(error)); + }, + [failPendingRetraction, retractThreadTurn], + ); + + useEffect(() => { + if (!pendingRecovery || activeEnvironmentUnavailable) return; + if (activeThread?.turnRetraction?.requestId === pendingRecovery.requestId) return; + void dispatchPendingRetraction(pendingRecovery); + }, [ + activeEnvironmentUnavailable, + activeThread?.turnRetraction, + dispatchPendingRetraction, + pendingRecovery, + ]); + + useEffect(() => { + if (!pendingRecovery || !activeThread) return; + const retraction = activeThread.turnRetraction; + if ( + retraction?.status !== "completed" || + retraction.requestId !== pendingRecovery.requestId || + retraction.completedAt === null || + retraction.firstUserMessage + ) { + return; + } + const restored = handoffCompletedMidThreadRetraction({ + environmentId: activeThread.environmentId, + completion: { + threadId: activeThread.id, + retraction: { + requestId: retraction.requestId, + messageId: retraction.messageId, + turnId: retraction.targetTurnId, + firstUserMessage: false, + completedAt: retraction.completedAt, + }, + }, + }); + if (restored) applyRestoredComposer(restored); + }, [activeThread, applyRestoredComposer, pendingRecovery]); + + useEffect(() => { + if (!pendingRecovery || !activeThread) return; + const activityFailure = findCorrelatedRetractionFailure( + activeThread.activities, + pendingRecovery.requestId, + ); + const projectedFailure = + activeThread.turnRetraction?.status === "failed" && + activeThread.turnRetraction.requestId === pendingRecovery.requestId; + if (!projectedFailure && activityFailure === null) return; + failPendingRetraction( + pendingRecovery, + activityFailure ?? "The server could not retract this message.", + ); + }, [activeThread, failPendingRetraction, pendingRecovery]); + + return useCallback(async () => { + if ( + !candidate || + !activeThread || + !activeProjectRef || + retractionPending || + recoveryPreparationRef.current + ) { + return; + } + recoveryPreparationRef.current = true; + + const requestId = newCommandId(); + const createdAt = new Date().toISOString(); + const { images, failedNames } = await captureLastUserMessageImages(candidate.message).catch( + () => ({ + images: [], + failedNames: (candidate.message.attachments ?? []).map((attachment) => attachment.name), + }), + ); + const snapshot = await snapshotLastUserMessageRecovery({ + requestId, + messageId: candidate.message.id, + sourceThreadRef: scopeThreadRef(activeThread.environmentId, activeThread.id), + projectRef: activeProjectRef, + draftId: newDraftId(), + futureThreadId: newThreadId(), + createdAt, + bundle: { + prompt: deriveLastUserMessageRestoredText(candidate.message.text), + images, + modelSelection: activeThread.modelSelection, + runtimeMode, + interactionMode, + envMode, + baseBranch: activeThreadBranch, + startFromOrigin, + }, + }); + const unrestoredImageNames = [...failedNames, ...snapshot.failedImageNames]; + if (unrestoredImageNames.length > 0) { + toastManager.add({ + type: "warning", + title: "Some images could not be saved for recovery", + description: `${[...new Set(unrestoredImageNames)].join(", ")} may not survive a reconnect.`, + }); + } + const recovery = useRetractionRecoveryStore.getState().byRequestId[requestId]; + recoveryPreparationRef.current = false; + if (recovery) void dispatchPendingRetraction(recovery); + }, [ + activeProjectRef, + activeThread, + activeThreadBranch, + candidate, + dispatchPendingRetraction, + envMode, + interactionMode, + retractionPending, + runtimeMode, + startFromOrigin, + ]); +} diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 344f60f16c18..405f2a8ebf39 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -55,10 +55,11 @@ characters and uses SQLite's ASCII case-insensitive matching. The full command list and the current defaults are shown in **Settings** → **Keybindings**, which always matches the build you are running. Use that rather than a copied list. -When the message composer is focused, press `Escape` to interrupt a running turn and move the -message you just sent back into the composer for editing. This only works before the agent has -shown assistant text, a tool call, a command, or another activity for that turn. Agent thinking does -not close this short window. The shortcut is built into the composer and is not configurable. +With focus anywhere in the chat view, press `Escape` to retract the message you just sent and move +it back into the composer for editing. This works from the instant you send until the agent shows +assistant text, a tool call, a command, or another activity for that turn. Agent thinking does not +close this short window. Retracting a thread's first message returns you to the draft page. The +shortcut is built into the chat view and is not configurable. Note that `chat.new` and `chat.newLocal` both create a thread through the same path. A new thread inherits the project you were in, along with model and mode selections. Branch, worktree, and diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index 0cb1650066c4..e4270e2f6053 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -1,6 +1,7 @@ import { CommandId, EnvironmentId, + MessageId, ORCHESTRATION_WS_METHODS, ProjectId, ThreadId, @@ -24,6 +25,7 @@ import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import { archiveThread, createProject, + retractThreadTurn, settleThread, stopThreadSession, unsettleThread, @@ -171,4 +173,29 @@ describe("environment commands", () => { ]); }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), ); + + it.effect("reuses caller-provided correlation metadata for turn retraction retries", () => + Effect.gen(function* () { + const dispatched: ClientOrchestrationCommand[] = []; + const supervisor = yield* makeSupervisor(dispatched); + const input = { + commandId: CommandId.make("retract-command"), + threadId: ThreadId.make("thread-1"), + messageId: MessageId.make("message-1"), + createdAt: "2026-08-11T12:00:00.000Z", + }; + + yield* retractThreadTurn(input).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ); + yield* retractThreadTurn(input).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ); + + expect(dispatched).toEqual([ + { type: "thread.turn.retract", ...input }, + { type: "thread.turn.retract", ...input }, + ]); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); }); diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index cb74f117b772..01b29e3f6dbb 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -47,6 +47,7 @@ export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">; export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">; export type StartThreadTurnInput = CommandInput<"thread.turn.start">; export type InterruptThreadTurnInput = CommandInput<"thread.turn.interrupt">; +export type RetractThreadTurnInput = CommandInput<"thread.turn.retract">; export type RespondToThreadApprovalInput = CommandInput<"thread.approval.respond">; export type RespondToThreadUserInputInput = CommandInput<"thread.user-input.respond">; export type RevertThreadCheckpointInput = CommandInput<"thread.checkpoint.revert">; @@ -287,6 +288,18 @@ export const interruptThreadTurn: (input: InterruptThreadTurnInput) => CommandEf }); }); +export const retractThreadTurn: (input: RetractThreadTurnInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.retractThreadTurn", +)(function* (input) { + const metadata = yield* timestampedCommandMetadata(input); + return yield* dispatch({ + ...input, + type: "thread.turn.retract", + commandId: metadata.commandId, + createdAt: metadata.createdAt, + }); +}); + export const respondToThreadApproval: (input: RespondToThreadApprovalInput) => CommandEffect = Effect.fn("EnvironmentCommands.respondToThreadApproval")(function* (input) { const metadata = yield* timestampedCommandMetadata(input); diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index ed3537e4f83b..f4fbfe4e08d2 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -7,6 +7,7 @@ import { type CreateThreadInput, type DeleteThreadInput, type InterruptThreadTurnInput, + type RetractThreadTurnInput, type RespondToThreadApprovalInput, type RespondToThreadUserInputInput, type RevertThreadCheckpointInput, @@ -27,6 +28,7 @@ import { createThread, deleteThread, interruptThreadTurn, + retractThreadTurn, respondToThreadApproval, respondToThreadUserInput, revertThreadCheckpoint, @@ -51,6 +53,7 @@ export type { CreateThreadInput, DeleteThreadInput, InterruptThreadTurnInput, + RetractThreadTurnInput, RespondToThreadApprovalInput, RespondToThreadUserInputInput, RevertThreadCheckpointInput, @@ -175,6 +178,12 @@ export function createThreadEnvironmentAtoms( scheduler, concurrency, }), + retractTurn: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:retract-turn", + execute: (input: RetractThreadTurnInput) => retractThreadTurn(input), + scheduler, + concurrency, + }), respondToApproval: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:respond-to-approval", execute: (input: RespondToThreadApprovalInput) => respondToThreadApproval(input), From a9bff29b08bf7d8b2ec854e0425444c0ed6c88c3 Mon Sep 17 00:00:00 2001 From: Adam Firestone Date: Tue, 11 Aug 2026 12:28:39 -0500 Subject: [PATCH 09/18] test: cross-version and fault hardening for turn retraction; periodic pending scan Adds a 30s pending-row re-enqueue so retryable retraction failures converge without lifecycle events, capability gating on the web dispatch seam, and coverage for commandId replay, duplicate completion, additive-metadata tolerance, and pre-retraction snapshot decode. Built with Claude Fable 5 via Claude Code, implementation by Codex (GPT-5.6-Sol). --- .../Layers/OrchestrationEngine.test.ts | 120 ++++++++++++++++++ .../Layers/TurnRetractionReactor.test.ts | 45 ++++++- .../Layers/TurnRetractionReactor.ts | 37 ++++-- .../decider.turnRetraction.test.ts | 19 +++ apps/web/src/components/ChatView.tsx | 34 +++-- .../components/chat/chatEscapeTrigger.test.ts | 30 ++++- .../src/components/chat/chatEscapeTrigger.ts | 19 +++ .../src/state/threadReducer.test.ts | 95 ++++++++++++++ packages/contracts/src/orchestration.test.ts | 96 ++++++++++++-- 9 files changed, 459 insertions(+), 36 deletions(-) diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 04b4bf0309f6..cbb3f473897b 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -22,11 +22,13 @@ import { describe, expect, it } from "vite-plus/test"; import { PersistenceSqlError } from "../../persistence/Errors.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; +import { ProjectionTurnRetractionRepositoryLive } from "../../persistence/Layers/ProjectionTurnRetractions.ts"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; import { OrchestrationEventStore, type OrchestrationEventStoreShape, } from "../../persistence/Services/OrchestrationEventStore.ts"; +import { ProjectionTurnRetractionRepository } from "../../persistence/Services/ProjectionTurnRetractions.ts"; import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; @@ -56,6 +58,7 @@ async function createOrchestrationSystem() { Layer.provide(OrchestrationProjectionPipelineLive), ), OrchestrationProjectionSnapshotQueryLive, + ProjectionTurnRetractionRepositoryLive, ).pipe( Layer.provide(ThreadBackgroundLiveness.layer), Layer.provide(ThreadPlanProgress.layer), @@ -69,8 +72,12 @@ async function createOrchestrationSystem() { const runtime = ManagedRuntime.make(orchestrationLayer); const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery)); + const turnRetractions = await runtime.runPromise( + Effect.service(ProjectionTurnRetractionRepository), + ); return { engine, + turnRetractions, readModel: () => runtime.runPromise(snapshotQuery.getSnapshot()), run: (effect: Effect.Effect) => runtime.runPromise(effect), dispose: () => runtime.dispose(), @@ -453,6 +460,119 @@ describe("OrchestrationEngine", () => { await system.dispose(); }); + it("deduplicates a replayed retract command and rejects a second completion commit", async () => { + const system = await createOrchestrationSystem(); + const { engine, turnRetractions } = system; + const createdAt = now(); + const projectId = asProjectId("project-retract-dedup"); + const threadId = ThreadId.make("thread-retract-dedup"); + const messageId = MessageId.make("message-retract-dedup"); + const requestId = CommandId.make("cmd-retract-dedup"); + + await system.run( + engine.dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-project-retract-dedup-create"), + projectId, + title: "Retract dedup project", + workspaceRoot: "/tmp/project-retract-dedup", + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + createdAt, + }), + ); + await system.run( + engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-thread-retract-dedup-create"), + threadId, + projectId, + title: "Retract dedup thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + }), + ); + await system.run( + engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-retract-dedup-start"), + threadId, + message: { + messageId, + role: "user", + text: "retract me", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + createdAt, + }), + ); + + const retractCommand = { + type: "thread.turn.retract" as const, + commandId: requestId, + threadId, + messageId, + createdAt, + }; + const original = await system.run(engine.dispatch(retractCommand)); + // A client reconnect can replay the same command id; the durable receipt + // must return the original result without deciding or projecting again. + const replay = await system.run(engine.dispatch(retractCommand)); + expect(replay).toEqual(original); + const pending = await system.run(turnRetractions.listPending()); + expect(pending).toHaveLength(1); + expect(pending[0]?.requestId).toBe(requestId); + + await system.run( + engine.dispatch({ + type: "thread.turn.retract.complete", + commandId: CommandId.make("cmd-retract-dedup-complete"), + threadId, + requestId, + createdAt, + }), + ); + const duplicateCompletion = await system.run( + Effect.exit( + engine.dispatch({ + type: "thread.turn.retract.complete", + commandId: CommandId.make("cmd-retract-dedup-complete-again"), + threadId, + requestId, + createdAt, + }), + ), + ); + expect(duplicateCompletion._tag).toBe("Failure"); + + const events = await system.run( + Stream.runCollect(engine.readEvents(0)).pipe( + Effect.map((chunk): OrchestrationEvent[] => Array.from(chunk)), + ), + ); + expect( + events.filter( + (event) => + event.type === "thread.turn-interrupt-requested" && + event.payload.retraction?.requestId === requestId, + ), + ).toHaveLength(1); + expect(events.filter((event) => event.type === "thread.reverted")).toHaveLength(1); + expect(events.filter((event) => event.type === "thread.deleted")).toHaveLength(1); + await system.dispose(); + }); + it("streams persisted domain events in order", async () => { const system = await createOrchestrationSystem(); const { engine } = system; diff --git a/apps/server/src/orchestration/Layers/TurnRetractionReactor.test.ts b/apps/server/src/orchestration/Layers/TurnRetractionReactor.test.ts index b1668a089b86..3efdfca962c5 100644 --- a/apps/server/src/orchestration/Layers/TurnRetractionReactor.test.ts +++ b/apps/server/src/orchestration/Layers/TurnRetractionReactor.test.ts @@ -24,6 +24,7 @@ import * as Layer from "effect/Layer"; import * as ManagedRuntime from "effect/ManagedRuntime"; import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; +import * as Queue from "effect/Queue"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; @@ -51,7 +52,7 @@ import { type ProjectionSnapshotQueryShape, } from "../Services/ProjectionSnapshotQuery.ts"; import { TurnRetractionReactor } from "../Services/TurnRetractionReactor.ts"; -import { makeTurnRetractionReactor } from "./TurnRetractionReactor.ts"; +import { makeTurnRetractionReactor, TurnRetractionRetryTicks } from "./TurnRetractionReactor.ts"; const NOW = "2026-08-11T12:00:00.000Z"; const THREAD_ID = ThreadId.make("thread-retraction-reactor"); @@ -205,6 +206,7 @@ const unsupported = () => async function startHarness(state: MutableState) { const domainEvents = Effect.runSync(PubSub.unbounded()); const runtimeEvents = Effect.runSync(PubSub.unbounded()); + const retryTicks = Effect.runSync(Queue.unbounded()); const repository = makeRepository(state); const dispatch = vi.fn((command: OrchestrationCommand) => { state.dispatched.push(command); @@ -355,6 +357,7 @@ async function startHarness(state: MutableState) { }); const layer = Layer.effect(TurnRetractionReactor, makeTurnRetractionReactor).pipe( + Layer.provideMerge(Layer.succeed(TurnRetractionRetryTicks, Stream.fromQueue(retryTicks))), Layer.provideMerge(Layer.succeed(OrchestrationEngineService, engine)), Layer.provideMerge(Layer.succeed(ProjectionSnapshotQuery, query)), Layer.provideMerge(Layer.succeed(ProjectionTurnRetractionRepository, repository)), @@ -375,6 +378,7 @@ async function startHarness(state: MutableState) { scope, emitDomain: (event: OrchestrationEvent) => runtime.runPromise(PubSub.publish(domainEvents, event)), + retryTick: () => runtime.runPromise(Queue.offer(retryTicks, undefined)), }; } @@ -433,6 +437,45 @@ it("drives claimed convergence from interrupt through a settlement event", async await stopHarness(harness); }); +it("retries a pending row on the next periodic tick without a lifecycle event", async () => { + const state = makeState("claimed"); + state.sessionStatus = "ready"; + state.failRollbackAfterEffect = true; + const harness = await startHarness(state); + + expect(state.row.status).toBe("requested"); + expect(state.historyTurnCount).toBe(1); + expect(state.order).toEqual(["rollback"]); + + await harness.retryTick(); + await harness.runtime.runPromise(harness.reactor.drain); + + expect(state.row.status).toBe("completed"); + expect(state.historyTurnCount).toBe(1); + expect(state.order).toEqual(["rollback", "rollback", "restore", "complete"]); + await stopHarness(harness); +}); + +it("repeats absolute provider rollback harmlessly after a post-rollback crash", async () => { + const state = makeState("claimed"); + state.sessionStatus = "ready"; + state.failRollbackAfterEffect = true; + const harness = await startHarness(state); + + expect(state.row.status).toBe("requested"); + expect(state.historyTurnCount).toBe(1); + expect(state.order.filter((entry) => entry === "rollback")).toHaveLength(1); + + await harness.retryTick(); + await harness.runtime.runPromise(harness.reactor.drain); + + expect(state.row.status).toBe("completed"); + expect(state.historyTurnCount).toBe(1); + expect(state.order.filter((entry) => entry === "rollback")).toHaveLength(2); + expect(state.order.slice(-3)).toEqual(["rollback", "restore", "complete"]); + await stopHarness(harness); +}); + it("resumes after crashes between interrupt, rollback, restore, and completion", async () => { const state = makeState("claimed"); diff --git a/apps/server/src/orchestration/Layers/TurnRetractionReactor.ts b/apps/server/src/orchestration/Layers/TurnRetractionReactor.ts index edd0fc92f901..f17dbc070d00 100644 --- a/apps/server/src/orchestration/Layers/TurnRetractionReactor.ts +++ b/apps/server/src/orchestration/Layers/TurnRetractionReactor.ts @@ -8,8 +8,10 @@ import { } from "@t3tools/contracts"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -73,7 +75,15 @@ const isTerminalProviderError = (error: unknown): boolean => const failureDetail = (error: unknown): string => error instanceof Error ? error.message : String(error); +export class TurnRetractionRetryTicks extends Context.Reference>( + "t3/orchestration/Layers/TurnRetractionReactor/TurnRetractionRetryTicks", + { + defaultValue: () => Stream.tick(Duration.seconds(30)).pipe(Stream.drop(1)), + }, +) {} + export const makeTurnRetractionReactor = Effect.gen(function* () { + const retryTicks = yield* TurnRetractionRetryTicks; const crypto = yield* Crypto.Crypto; const orchestrationEngine = yield* OrchestrationEngineService; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; @@ -428,6 +438,20 @@ export const makeTurnRetractionReactor = Effect.gen(function* () { event.type === "session.state.changed" || event.type === "session.exited"; + const enqueuePending = Effect.fn("enqueuePendingTurnRetractions")(function* () { + const pending = yield* turnRetractions.listPending().pipe( + Effect.catchCause((cause) => + Effect.logWarning("turn retraction pending scan failed", { + cause: Cause.pretty(cause), + }).pipe(Effect.as([] as ReadonlyArray)), + ), + ); + yield* Effect.forEach(pending, (row) => worker.enqueue(row.threadId), { + concurrency: 1, + discard: true, + }); + }); + const start: TurnRetractionReactorShape["start"] = Effect.fn("start")(function* () { yield* forkParked( Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => @@ -442,17 +466,8 @@ export const makeTurnRetractionReactor = Effect.gen(function* () { ), ); - const pending = yield* turnRetractions.listPending().pipe( - Effect.catchCause((cause) => - Effect.logWarning("turn retraction startup scan failed", { - cause: Cause.pretty(cause), - }).pipe(Effect.as([] as ReadonlyArray)), - ), - ); - yield* Effect.forEach(pending, (row) => worker.enqueue(row.threadId), { - concurrency: 1, - discard: true, - }); + yield* enqueuePending(); + yield* forkParked(Stream.runForEach(retryTicks, enqueuePending)); }); return { diff --git a/apps/server/src/orchestration/decider.turnRetraction.test.ts b/apps/server/src/orchestration/decider.turnRetraction.test.ts index 724b6b203051..208989555e64 100644 --- a/apps/server/src/orchestration/decider.turnRetraction.test.ts +++ b/apps/server/src/orchestration/decider.turnRetraction.test.ts @@ -230,6 +230,25 @@ it.layer(NodeServices.layer)("thread.turn.retract decider", (it) => { }), ); + it.effect("rejects a duplicate completion after the first commit completed the row", () => + Effect.gen(function* () { + const pending = pendingRetraction(false); + const completed = makeThread({ + ...pending, + turnRetraction: pending.turnRetraction + ? { + ...pending.turnRetraction, + status: "completed", + completedAt: NOW, + } + : null, + }); + + const error = yield* Effect.flip(completeRetraction(completed)); + expect(invariantDetail(error)).toContain("no matching pending retraction"); + }), + ); + it.effect("accepts queued, starting, and matching running lifecycle states", () => Effect.gen(function* () { const queued = makeThread(); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 4765966e8619..18709ab5f0cd 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -250,7 +250,11 @@ import { isLastUserMessagePopWindowOpen, } from "./chat/lastUserMessagePop"; import { createPreDispatchCancellationLatch } from "./chat/preDispatchCancellationLatch"; -import { CHAT_FLOATING_LAYER_SELECTOR, shouldHandleChatEscape } from "./chat/chatEscapeTrigger"; +import { + CHAT_FLOATING_LAYER_SELECTOR, + runChatEscapeAction, + shouldHandleChatEscape, +} from "./chat/chatEscapeTrigger"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { shouldRenderEmptyThreadHero } from "./chat/emptyThreadHero"; import { RetractionRecoveryHandoff } from "./chat/RetractionRecoveryHandoff"; @@ -6084,17 +6088,15 @@ function ChatViewContent(props: ChatViewProps) { return; } - const cancelledMessageId = preDispatchCancellationLatchRef.current.cancel(); - if (cancelledMessageId === null) { - if (retractionPending) { - return; - } - if (lastUserMessagePopCandidate === null) { - scheduleComposerFocus(); - return; - } - void onPopLastUserMessage(); - } + const handled = runChatEscapeAction({ + cancelPreDispatch: () => preDispatchCancellationLatchRef.current.cancel(), + retractionPending, + threadTurnRetraction: supportsThreadTurnRetraction, + hasRetractionCandidate: lastUserMessagePopCandidate !== null, + focusComposer: scheduleComposerFocus, + retractLastUserMessage: () => void onPopLastUserMessage(), + }); + if (!handled) return; event.preventDefault(); event.stopPropagation(); @@ -6102,7 +6104,13 @@ function ChatViewContent(props: ChatViewProps) { window.addEventListener("keydown", onWindowKeyDown); return () => window.removeEventListener("keydown", onWindowKeyDown); - }, [lastUserMessagePopCandidate, onPopLastUserMessage, retractionPending, scheduleComposerFocus]); + }, [ + lastUserMessagePopCandidate, + onPopLastUserMessage, + retractionPending, + scheduleComposerFocus, + supportsThreadTurnRetraction, + ]); // Empty state: no active thread if (!activeThread) { diff --git a/apps/web/src/components/chat/chatEscapeTrigger.test.ts b/apps/web/src/components/chat/chatEscapeTrigger.test.ts index 2a5e681351a4..fe29b0bf1105 100644 --- a/apps/web/src/components/chat/chatEscapeTrigger.test.ts +++ b/apps/web/src/components/chat/chatEscapeTrigger.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; -import { markChatEscapeHandled, shouldHandleChatEscape } from "./chatEscapeTrigger"; +import { + markChatEscapeHandled, + runChatEscapeAction, + shouldHandleChatEscape, +} from "./chatEscapeTrigger"; function shouldHandle( event: KeyboardEvent, @@ -54,4 +58,28 @@ describe("chat Escape trigger", () => { expect(shouldHandle(keyboardEvent(), { textEditingTargetOutsideComposer: true })).toBe(false); expect(shouldHandle(keyboardEvent(), { textEditingTargetOutsideComposer: false })).toBe(true); }); + + it.each([undefined, false])( + "does not dispatch retraction when the server capability is %s", + (threadTurnRetraction) => { + const retractLastUserMessage = () => { + throw new Error("unsupported server command was dispatched"); + }; + let focusCount = 0; + + expect(() => + runChatEscapeAction({ + cancelPreDispatch: () => null, + retractionPending: false, + threadTurnRetraction, + hasRetractionCandidate: true, + focusComposer: () => { + focusCount += 1; + }, + retractLastUserMessage, + }), + ).not.toThrow(); + expect(focusCount).toBe(1); + }, + ); }); diff --git a/apps/web/src/components/chat/chatEscapeTrigger.ts b/apps/web/src/components/chat/chatEscapeTrigger.ts index 1eac0873fccb..ef9b6caf7738 100644 --- a/apps/web/src/components/chat/chatEscapeTrigger.ts +++ b/apps/web/src/components/chat/chatEscapeTrigger.ts @@ -46,3 +46,22 @@ export function shouldHandleChatEscape(input: { input.textEditingTargetOutsideComposer ?? isTextEditingTargetOutsideComposer(event.target) ); } + +/** Runs the chat action selected after an otherwise eligible Escape keypress. */ +export function runChatEscapeAction(input: { + cancelPreDispatch: () => unknown | null; + retractionPending: boolean; + threadTurnRetraction: boolean | undefined; + hasRetractionCandidate: boolean; + focusComposer: () => void; + retractLastUserMessage: () => void; +}): boolean { + if (input.cancelPreDispatch() !== null) return true; + if (input.retractionPending) return false; + if (input.threadTurnRetraction !== true || !input.hasRetractionCandidate) { + input.focusComposer(); + return true; + } + input.retractLastUserMessage(); + return true; +} diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index fa8e716db37d..a1cd0490fa5e 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -949,6 +949,101 @@ describe("applyThreadDetailEvent", () => { }); }); + describe("additive retraction metadata compatibility", () => { + it("reduces interrupt, revert, and delete events exactly as legacy payloads", () => { + const runningThread: OrchestrationThread = { + ...baseThread, + latestTurn: { + turnId: TurnId.make("turn-retracted"), + state: "running", + requestedAt: "2026-04-01T14:00:00.000Z", + startedAt: "2026-04-01T14:00:01.000Z", + completedAt: null, + assistantMessageId: null, + }, + }; + const interruptBase = { + ...baseEventFields, + sequence: 16, + occurredAt: "2026-04-01T14:00:02.000Z", + aggregateKind: "thread" as const, + aggregateId: ThreadId.make("thread-1"), + type: "thread.turn-interrupt-requested" as const, + payload: { + threadId: ThreadId.make("thread-1"), + turnId: TurnId.make("turn-retracted"), + createdAt: "2026-04-01T14:00:02.000Z", + }, + }; + expect( + applyThreadDetailEvent(runningThread, { + ...interruptBase, + payload: { + ...interruptBase.payload, + retraction: { + requestId: CommandId.make("cmd-retract"), + messageId: MessageId.make("message-retracted"), + targetTurnId: TurnId.make("turn-retracted"), + baselineTurnCount: 0, + firstUserMessage: true, + }, + }, + }), + ).toEqual(applyThreadDetailEvent(runningThread, interruptBase)); + + const revertedBase = { + ...baseEventFields, + sequence: 17, + occurredAt: "2026-04-01T14:00:03.000Z", + aggregateKind: "thread" as const, + aggregateId: ThreadId.make("thread-1"), + type: "thread.reverted" as const, + payload: { threadId: ThreadId.make("thread-1"), turnCount: 0 }, + }; + expect( + applyThreadDetailEvent(baseThread, { + ...revertedBase, + payload: { + ...revertedBase.payload, + retraction: { + requestId: CommandId.make("cmd-retract"), + messageId: MessageId.make("message-retracted"), + turnId: TurnId.make("turn-retracted"), + firstUserMessage: false, + completedAt: "2026-04-01T14:00:03.000Z", + }, + }, + }), + ).toEqual(applyThreadDetailEvent(baseThread, revertedBase)); + + const deletedBase = { + ...baseEventFields, + sequence: 18, + occurredAt: "2026-04-01T14:00:04.000Z", + aggregateKind: "thread" as const, + aggregateId: ThreadId.make("thread-1"), + type: "thread.deleted" as const, + payload: { + threadId: ThreadId.make("thread-1"), + deletedAt: "2026-04-01T14:00:04.000Z", + }, + }; + expect( + applyThreadDetailEvent(baseThread, { + ...deletedBase, + payload: { + ...deletedBase.payload, + retraction: { + requestId: CommandId.make("cmd-retract"), + messageId: MessageId.make("message-retracted"), + firstUserMessage: true, + }, + }, + }), + ).toEqual(applyThreadDetailEvent(baseThread, deletedBase)); + }); + }); + describe("no-op events", () => { it("returns unchanged for approval-response-requested", () => { const result = applyThreadDetailEvent(baseThread, { diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 9c125c124b69..7edba09cf797 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -12,6 +12,7 @@ import { OrchestrationGetFullThreadDiffInput, OrchestrationGetTurnDiffInput, OrchestrationLatestTurn, + OrchestrationReadModel, ProjectCreatedPayload, ProjectMetaUpdatedPayload, OrchestrationProposedPlan, @@ -41,6 +42,7 @@ const decodeThreadTurnStartRequestedPayload = Schema.decodeUnknownEffect( ThreadTurnStartRequestedPayload, ); const decodeOrchestrationLatestTurn = Schema.decodeUnknownEffect(OrchestrationLatestTurn); +const decodeOrchestrationReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); const decodeOrchestrationProposedPlan = Schema.decodeUnknownEffect(OrchestrationProposedPlan); const decodeOrchestrationSession = Schema.decodeUnknownEffect(OrchestrationSession); const decodeOrchestrationThread = Schema.decodeUnknownEffect(OrchestrationThread); @@ -127,6 +129,44 @@ it.effect("decodes historical interrupt and reverted payloads without retraction }, }); assert.strictEqual(storedInterrupt.type, "thread.turn-interrupt-requested"); + + const storedReverted = yield* decodeOrchestrationEvent({ + sequence: 2, + eventId: "event-reverted-old", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: "2026-01-01T00:00:01.000Z", + commandId: "cmd-reverted-old", + causationEventId: null, + correlationId: "cmd-reverted-old", + metadata: {}, + type: "thread.reverted", + payload: { threadId: "thread-1", turnCount: 0 }, + }); + const storedDeleted = yield* decodeOrchestrationEvent({ + sequence: 3, + eventId: "event-deleted-old", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: "2026-01-01T00:00:02.000Z", + commandId: "cmd-deleted-old", + causationEventId: null, + correlationId: "cmd-deleted-old", + metadata: {}, + type: "thread.deleted", + payload: { + threadId: "thread-1", + deletedAt: "2026-01-01T00:00:02.000Z", + }, + }); + if (storedReverted.type !== "thread.reverted") { + return assert.fail("expected historical thread.reverted event"); + } + assert.strictEqual(storedReverted.payload.retraction, undefined); + if (storedDeleted.type !== "thread.deleted") { + return assert.fail("expected historical thread.deleted event"); + } + assert.strictEqual(storedDeleted.payload.retraction, undefined); }), ); @@ -513,7 +553,7 @@ it.effect("decodes thread settle and unsettle commands", () => }), ); -it.effect("defaults settled fields when decoding historical thread data", () => +it.effect("decodes pre-retraction snapshots without new optional state", () => Effect.gen(function* () { const common = { id: "thread-1", @@ -530,14 +570,22 @@ it.effect("defaults settled fields when decoding historical thread data", () => archivedAt: null, session: null, }; - const thread = yield* decodeOrchestrationThread({ - ...common, - deletedAt: null, - messages: [], - proposedPlans: [], - activities: [], - checkpoints: [], + const snapshot = yield* decodeOrchestrationReadModel({ + snapshotSequence: 10, + projects: [], + threads: [ + { + ...common, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + }, + ], + updatedAt: "2026-01-01T00:00:00.000Z", }); + const thread = snapshot.threads[0]; const shell = yield* decodeOrchestrationThreadShell({ ...common, latestUserMessageAt: null, @@ -546,10 +594,38 @@ it.effect("defaults settled fields when decoding historical thread data", () => hasActionableProposedPlan: false, }); - assert.strictEqual(thread.settledOverride, null); - assert.strictEqual(thread.settledAt, null); + assert.strictEqual(thread?.settledOverride, null); + assert.strictEqual(thread?.settledAt, null); + assert.strictEqual(thread?.managedWorktree, undefined); + assert.strictEqual(thread?.turnRetraction, undefined); assert.strictEqual(shell.settledOverride, null); assert.strictEqual(shell.settledAt, null); + + const snapshotBeforeProviderSendState = yield* decodeOrchestrationThread({ + ...common, + turnRetraction: { + requestId: "cmd-retract", + messageId: "message-retracted", + baselineTurnCount: 0, + baselineCheckpointRef: "refs/t3/checkpoints/thread-1/0", + targetTurnId: null, + providerSendClaimed: false, + firstUserMessage: true, + requestedAt: "2026-01-01T00:00:00.000Z", + status: "requested", + completedAt: null, + failedAt: null, + }, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + }); + assert.strictEqual( + snapshotBeforeProviderSendState.turnRetraction?.providerSendState, + undefined, + ); }), ); From 5e2990f00eab913e6c66bb2753325ad79a287c25 Mon Sep 17 00:00:00 2001 From: Adam Firestone Date: Tue, 11 Aug 2026 12:30:37 -0500 Subject: [PATCH 10/18] fix(web): drive first-message retraction handoff from durable state The handoff watched a transient completion stream; cancelled-path completions land in milliseconds and beat the subscription, stranding the user on a deleted thread. The watcher now lives in the persistent chat layout and fires on correlated completion, thread deletion, shell absence, correlated failure, or a 60s staleness guard. Built with Claude Fable 5 via Claude Code, implementation by Codex (GPT-5.6-Sol). --- apps/web/src/components/ChatView.tsx | 39 --- .../chat/RetractionRecoveryHandoff.test.ts | 270 ++++++++++++++++++ .../chat/RetractionRecoveryHandoff.tsx | 268 ++++++++++++++++- .../chat/lastUserMessageRecovery.ts | 28 +- .../chat/useLastUserMessageRetraction.ts | 3 +- apps/web/src/routes/_chat.tsx | 3 + 6 files changed, 552 insertions(+), 59 deletions(-) create mode 100644 apps/web/src/components/chat/RetractionRecoveryHandoff.test.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 4765966e8619..94f8bb92b45f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -253,7 +253,6 @@ import { createPreDispatchCancellationLatch } from "./chat/preDispatchCancellati import { CHAT_FLOATING_LAYER_SELECTOR, shouldHandleChatEscape } from "./chat/chatEscapeTrigger"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { shouldRenderEmptyThreadHero } from "./chat/emptyThreadHero"; -import { RetractionRecoveryHandoff } from "./chat/RetractionRecoveryHandoff"; import { useRetractionRecoveryStore } from "./chat/lastUserMessageRecovery"; import { useLastUserMessageRetraction } from "./chat/useLastUserMessageRetraction"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; @@ -6725,46 +6724,8 @@ function ChatViewContent(props: ChatViewProps) { } export default function ChatView(props: ChatViewProps) { - const navigate = useNavigate(); - const pendingRetractionRecovery = useRetractionRecoveryStore((state) => - props.routeKind === "server" - ? (Object.values(state.byRequestId).find( - (recovery) => - recovery.sourceThreadRef.environmentId === props.environmentId && - recovery.sourceThreadRef.threadId === props.threadId, - ) ?? null) - : null, - ); - const retractionThread = useThread( - pendingRetractionRecovery ? pendingRetractionRecovery.sourceThreadRef : null, - ); - const projectedRetraction = retractionThread?.turnRetraction; - const projectedCompletion = - pendingRetractionRecovery && - projectedRetraction?.status === "completed" && - projectedRetraction.requestId === pendingRetractionRecovery.requestId && - projectedRetraction.completedAt !== null - ? { - threadId: pendingRetractionRecovery.sourceThreadRef.threadId, - retraction: { - requestId: projectedRetraction.requestId, - messageId: projectedRetraction.messageId, - turnId: projectedRetraction.targetTurnId, - firstUserMessage: projectedRetraction.firstUserMessage, - completedAt: projectedRetraction.completedAt, - }, - } - : null; return ( - {pendingRetractionRecovery ? ( - - ) : null} ); diff --git a/apps/web/src/components/chat/RetractionRecoveryHandoff.test.ts b/apps/web/src/components/chat/RetractionRecoveryHandoff.test.ts new file mode 100644 index 000000000000..f478c510aa8c --- /dev/null +++ b/apps/web/src/components/chat/RetractionRecoveryHandoff.test.ts @@ -0,0 +1,270 @@ +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + CommandId, + EnvironmentId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { DraftId, useComposerDraftStore } from "../../composerDraftStore"; +import { + applyRetractionRecoverySignal, + RETRACTION_RECOVERY_STALE_AFTER_MS, + resolveRetractionRecoverySignal, +} from "./RetractionRecoveryHandoff"; +import { + snapshotLastUserMessageRecovery, + useRetractionRecoveryStore, +} from "./lastUserMessageRecovery"; + +const environmentId = EnvironmentId.make("environment-1"); +const projectRef = scopeProjectRef(environmentId, ProjectId.make("project-1")); +const sourceThreadRef = scopeThreadRef(environmentId, ThreadId.make("source-thread")); +const requestId = CommandId.make("request-1"); +const messageId = MessageId.make("message-1"); +const draftId = DraftId.make("recovery-draft"); +const futureThreadId = ThreadId.make("future-thread"); +const createdAt = "2026-08-11T12:00:00.000Z"; + +beforeEach(() => { + useComposerDraftStore.setState({ + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, + }); + useRetractionRecoveryStore.setState({ byRequestId: {} }); +}); + +async function seedRecovery() { + await snapshotLastUserMessageRecovery({ + requestId, + messageId, + sourceThreadRef, + projectRef, + draftId, + futureThreadId, + createdAt, + bundle: { + prompt: "preserve this message", + images: [], + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.6", + }, + runtimeMode: "full-access", + interactionMode: "default", + envMode: "worktree", + baseBranch: "main", + startFromOrigin: true, + }, + }); + const recovery = useRetractionRecoveryStore.getState().byRequestId[requestId]; + if (!recovery) throw new Error("Expected recovery fixture"); + return recovery; +} + +describe("retraction recovery handoff", () => { + it("hands off when completion deletes detail before the live subscription exists", async () => { + const recovery = await seedRecovery(); + const navigate = vi.fn(); + const signal = resolveRetractionRecoverySignal({ + recovery, + liveCompletion: null, + projectedRetraction: null, + activities: [], + threadStatus: "deleted", + threadDetailExists: false, + shellSnapshotReady: true, + sourceThreadInShell: true, + nowMs: Date.parse(createdAt) + 115, + }); + + expect(signal).toEqual({ kind: "source-thread-gone" }); + expect(signal && applyRetractionRecoverySignal({ recovery, signal, navigate })).toBe( + "draft-surfaced", + ); + expect(navigate).toHaveBeenCalledWith({ + to: "/draft/$draftId", + params: { draftId }, + replace: true, + }); + expect(useComposerDraftStore.getState().getDraftSession(draftId)?.hidden).toBe(false); + expect(useRetractionRecoveryStore.getState().byRequestId[requestId]).toBeUndefined(); + }); + + it("hands off after reload when the persisted recovery mounts with its thread already gone", async () => { + const persistedRecovery = await seedRecovery(); + const navigate = vi.fn(); + + // Reinstall only the serialized recovery record to model Zustand hydration + // before the route-level watcher mounts on a fresh client session. + useRetractionRecoveryStore.setState({ + byRequestId: { [requestId]: persistedRecovery }, + }); + const recovery = useRetractionRecoveryStore.getState().byRequestId[requestId]; + if (!recovery) throw new Error("Expected rehydrated recovery fixture"); + const signal = resolveRetractionRecoverySignal({ + recovery, + liveCompletion: null, + projectedRetraction: null, + activities: [], + threadStatus: "empty", + threadDetailExists: false, + shellSnapshotReady: true, + sourceThreadInShell: false, + nowMs: Date.parse(createdAt) + 1_000, + }); + + expect(signal).toEqual({ kind: "source-thread-gone" }); + expect(signal && applyRetractionRecoverySignal({ recovery, signal, navigate })).toBe( + "draft-surfaced", + ); + expect(navigate).toHaveBeenCalledTimes(1); + expect(useComposerDraftStore.getState().getComposerDraft(draftId)?.prompt).toBe( + "preserve this message", + ); + expect(useComposerDraftStore.getState().getDraftSession(draftId)?.hidden).toBe(false); + }); + + it("keeps correlated projected completion metadata as the fast path", async () => { + const recovery = await seedRecovery(); + const signal = resolveRetractionRecoverySignal({ + recovery, + liveCompletion: null, + projectedRetraction: { + requestId, + messageId, + targetTurnId: null, + firstUserMessage: true, + status: "completed", + completedAt: "2026-08-11T12:00:00.115Z", + }, + activities: [], + threadStatus: "live", + threadDetailExists: true, + shellSnapshotReady: true, + sourceThreadInShell: true, + nowMs: Date.parse(createdAt) + 115, + }); + + expect(signal?.kind).toBe("completed"); + }); + + it("restores into an existing source composer for a correlated failed row", async () => { + const recovery = await seedRecovery(); + const signal = resolveRetractionRecoverySignal({ + recovery, + liveCompletion: null, + projectedRetraction: { + requestId, + messageId, + targetTurnId: null, + firstUserMessage: true, + status: "failed", + completedAt: null, + }, + activities: [], + threadStatus: "live", + threadDetailExists: true, + shellSnapshotReady: true, + sourceThreadInShell: true, + nowMs: Date.parse(createdAt) + 500, + }); + + expect(signal).toMatchObject({ kind: "failed", sourceThreadExists: true }); + expect(signal && applyRetractionRecoverySignal({ recovery, signal, navigate: vi.fn() })).toBe( + "thread-restored", + ); + expect(useComposerDraftStore.getState().getComposerDraft(sourceThreadRef)?.prompt).toBe( + "preserve this message", + ); + expect(useComposerDraftStore.getState().getDraftSession(draftId)).toBeNull(); + }); + + it("surfaces the recovery draft when failure activity outlives the source thread", async () => { + const recovery = await seedRecovery(); + const navigate = vi.fn(); + const signal = resolveRetractionRecoverySignal({ + recovery, + liveCompletion: null, + projectedRetraction: null, + activities: [ + { + id: "failure-activity" as never, + tone: "error", + kind: "turn.retract.failed", + summary: "Message retract failed", + payload: { requestId, detail: "provider rollback failed" }, + turnId: null, + createdAt: "2026-08-11T12:00:00.500Z", + }, + ], + threadStatus: "cached", + threadDetailExists: true, + shellSnapshotReady: true, + sourceThreadInShell: false, + nowMs: Date.parse(createdAt) + 500, + }); + + expect(signal).toEqual({ + kind: "failed", + detail: "provider rollback failed", + sourceThreadExists: false, + }); + expect(signal && applyRetractionRecoverySignal({ recovery, signal, navigate })).toBe( + "draft-surfaced", + ); + expect(useComposerDraftStore.getState().getComposerDraft(sourceThreadRef)).toBeNull(); + expect(useComposerDraftStore.getState().getDraftSession(draftId)?.hidden).toBe(false); + }); + + it("surfaces a warning recovery after the bounded window when no row correlates", async () => { + const recovery = await seedRecovery(); + const navigate = vi.fn(); + const signal = resolveRetractionRecoverySignal({ + recovery, + liveCompletion: null, + projectedRetraction: null, + activities: [], + threadStatus: "live", + threadDetailExists: true, + shellSnapshotReady: true, + sourceThreadInShell: true, + nowMs: Date.parse(createdAt) + RETRACTION_RECOVERY_STALE_AFTER_MS, + }); + + expect(signal).toEqual({ kind: "stale" }); + expect(signal && applyRetractionRecoverySignal({ recovery, signal, navigate })).toBe( + "draft-surfaced", + ); + expect(useComposerDraftStore.getState().getDraftSession(draftId)?.hidden).toBe(false); + }); + + it("does not age out a correlated retraction that is still pending", async () => { + const recovery = await seedRecovery(); + const signal = resolveRetractionRecoverySignal({ + recovery, + liveCompletion: null, + projectedRetraction: { + requestId, + messageId, + targetTurnId: null, + firstUserMessage: true, + status: "requested", + completedAt: null, + }, + activities: [], + threadStatus: "live", + threadDetailExists: true, + shellSnapshotReady: true, + sourceThreadInShell: true, + nowMs: Date.parse(createdAt) + RETRACTION_RECOVERY_STALE_AFTER_MS * 2, + }); + + expect(signal).toBeNull(); + expect(useComposerDraftStore.getState().getDraftSession(draftId)?.hidden).toBe(true); + }); +}); diff --git a/apps/web/src/components/chat/RetractionRecoveryHandoff.tsx b/apps/web/src/components/chat/RetractionRecoveryHandoff.tsx index 818d617a4757..f92f8c895b35 100644 --- a/apps/web/src/components/chat/RetractionRecoveryHandoff.tsx +++ b/apps/web/src/components/chat/RetractionRecoveryHandoff.tsx @@ -1,48 +1,286 @@ import { useAtomValue } from "@effect/atom-react"; -import type { EnvironmentId } from "@t3tools/contracts"; +import type { + OrchestrationThreadActivity, + OrchestrationThreadTurnRetraction, +} from "@t3tools/contracts"; import * as Option from "effect/Option"; import { AsyncResult } from "effect/unstable/reactivity"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { type DraftId, useComposerDraftStore } from "../../composerDraftStore"; +import { useEnvironmentThreadRefs, useThread, useThreadStatus } from "../../state/entities"; +import { useEnvironmentQuery } from "../../state/query"; import { threadRetractionCompletions } from "../../state/retractionCompletions"; +import { environmentShell } from "../../state/shell"; +import { stackedThreadToast, toastManager } from "../ui/toast"; import { type FirstMessageRetractionCompletion, + findCorrelatedRetractionFailure, handoffCompletedFirstMessageRetraction, + handoffCompletedMidThreadRetraction, type PendingRetractionRecovery, + restoreRetractionRecoveryToThread, + surfaceRetractionRecoveryDraft, + useRetractionRecoveryStore, } from "./lastUserMessageRecovery"; -export function RetractionRecoveryHandoff(props: { - environmentId: EnvironmentId; +export const RETRACTION_RECOVERY_STALE_AFTER_MS = 60_000; + +type ThreadDetailStatus = "empty" | "cached" | "synchronizing" | "live" | "deleted"; +type RetractionProjection = Pick< + OrchestrationThreadTurnRetraction, + "requestId" | "messageId" | "targetTurnId" | "firstUserMessage" | "status" | "completedAt" +>; + +export type RetractionRecoverySignal = + | { kind: "completed"; completion: FirstMessageRetractionCompletion } + | { kind: "failed"; detail: string; sourceThreadExists: boolean } + | { kind: "source-thread-gone" } + | { kind: "stale" } + | null; + +function correlatedCompletion(input: { + recovery: PendingRetractionRecovery; + liveCompletion: FirstMessageRetractionCompletion | null; + projectedRetraction: RetractionProjection | null; +}): FirstMessageRetractionCompletion | null { + const liveMetadata = input.liveCompletion?.retraction; + if ( + input.liveCompletion?.threadId === input.recovery.sourceThreadRef.threadId && + liveMetadata?.requestId === input.recovery.requestId + ) { + return input.liveCompletion; + } + + const projected = input.projectedRetraction; + if ( + projected?.status !== "completed" || + projected.requestId !== input.recovery.requestId || + projected.completedAt === null + ) { + return null; + } + return { + threadId: input.recovery.sourceThreadRef.threadId, + retraction: { + requestId: projected.requestId, + messageId: projected.messageId, + turnId: projected.targetTurnId, + firstUserMessage: projected.firstUserMessage, + completedAt: projected.completedAt, + }, + }; +} + +/** + * Resolves transient and durable recovery evidence in priority order. Failure + * correlation wins over disappearance, then completion metadata keeps the + * low-latency path, and finally deletion/shell absence provide the race-proof + * first-message handoff. + */ +export function resolveRetractionRecoverySignal(input: { + recovery: PendingRetractionRecovery; + liveCompletion: FirstMessageRetractionCompletion | null; + projectedRetraction: RetractionProjection | null; + activities: ReadonlyArray; + threadStatus: ThreadDetailStatus; + threadDetailExists: boolean; + shellSnapshotReady: boolean; + sourceThreadInShell: boolean; + nowMs: number; +}): RetractionRecoverySignal { + const activityFailure = findCorrelatedRetractionFailure( + input.activities, + input.recovery.requestId, + ); + const projectedFailure = + input.projectedRetraction?.requestId === input.recovery.requestId && + input.projectedRetraction.status === "failed"; + const sourceThreadExists = + input.threadStatus !== "deleted" && + (input.shellSnapshotReady ? input.sourceThreadInShell : input.threadDetailExists); + if (projectedFailure || activityFailure !== null) { + return { + kind: "failed", + detail: activityFailure ?? "The server could not retract this message.", + sourceThreadExists, + }; + } + + const completion = correlatedCompletion(input); + if (completion !== null) { + return { kind: "completed", completion }; + } + + if ( + input.threadStatus === "deleted" || + (input.shellSnapshotReady && !input.sourceThreadInShell) + ) { + return { kind: "source-thread-gone" }; + } + + const createdAtMs = Date.parse(input.recovery.createdAt); + const hasCorrelatedPendingRow = + input.projectedRetraction?.requestId === input.recovery.requestId && + input.projectedRetraction.status === "requested"; + const stale = + input.threadDetailExists && + !hasCorrelatedPendingRow && + Number.isFinite(createdAtMs) && + input.nowMs - createdAtMs >= RETRACTION_RECOVERY_STALE_AFTER_MS; + return stale ? { kind: "stale" } : null; +} + +export function applyRetractionRecoverySignal(input: { + recovery: PendingRetractionRecovery; + signal: Exclude; + navigate: (input: { + to: "/draft/$draftId"; + params: { draftId: DraftId }; + replace: true; + }) => unknown; +}): "draft-surfaced" | "thread-restored" | null { + if (input.signal.kind === "completed") { + if (input.signal.completion.retraction?.firstUserMessage) { + return handoffCompletedFirstMessageRetraction({ + capabilityEnabled: true, + environmentId: input.recovery.sourceThreadRef.environmentId, + completion: input.signal.completion, + navigate: input.navigate, + }) + ? "draft-surfaced" + : null; + } + return handoffCompletedMidThreadRetraction({ + environmentId: input.recovery.sourceThreadRef.environmentId, + completion: input.signal.completion, + }) + ? "thread-restored" + : null; + } + + if (input.signal.kind === "failed" && input.signal.sourceThreadExists) { + const restored = restoreRetractionRecoveryToThread({ + requestId: input.recovery.requestId, + sourceThreadRef: input.recovery.sourceThreadRef, + }); + if (restored !== null) return "thread-restored"; + } + + return surfaceRetractionRecoveryDraft({ + requestId: input.recovery.requestId, + sourceThreadRef: input.recovery.sourceThreadRef, + navigate: input.navigate, + }) + ? "draft-surfaced" + : null; +} + +function PendingRetractionRecoveryWatcher(props: { recovery: PendingRetractionRecovery; - projectedCompletion: FirstMessageRetractionCompletion | null; navigate: (input: { to: "/draft/$draftId"; params: { draftId: DraftId }; replace: true; }) => unknown; }) { + const recoveryDraftReady = useComposerDraftStore( + (store) => store.getDraftSession(props.recovery.draftId) !== null, + ); + const sourceThread = useThread(props.recovery.sourceThreadRef); + const threadStatus = useThreadStatus(props.recovery.sourceThreadRef); + const environmentThreadRefs = useEnvironmentThreadRefs( + props.recovery.sourceThreadRef.environmentId, + ); + const shell = useEnvironmentQuery( + environmentShell.stateAtom(props.recovery.sourceThreadRef.environmentId), + ); const result = useAtomValue( threadRetractionCompletions({ - environmentId: props.environmentId, + environmentId: props.recovery.sourceThreadRef.environmentId, input: { threadId: props.recovery.sourceThreadRef.threadId, turnLimit: 1 }, }), ); const liveCompletion = Option.getOrNull(AsyncResult.value(result)); - const completion = liveCompletion ?? props.projectedCompletion; - const recoveryDraftReady = useComposerDraftStore( - (store) => store.getDraftSession(props.recovery.draftId) !== null, + const projectedRetraction = sourceThread?.turnRetraction ?? null; + const shellSnapshotReady = shell.data?.snapshot._tag === "Some"; + const sourceThreadInShell = environmentThreadRefs.some( + (ref) => ref.threadId === props.recovery.sourceThreadRef.threadId, ); + const [nowMs, setNowMs] = useState(Date.now); useEffect(() => { - if (!completion || !recoveryDraftReady) return; - handoffCompletedFirstMessageRetraction({ - capabilityEnabled: true, - environmentId: props.environmentId, - completion, + const createdAtMs = Date.parse(props.recovery.createdAt); + if (!Number.isFinite(createdAtMs)) return; + const remainingMs = createdAtMs + RETRACTION_RECOVERY_STALE_AFTER_MS - Date.now(); + if (remainingMs <= 0) { + setNowMs(Date.now()); + return; + } + const timeout = window.setTimeout(() => setNowMs(Date.now()), remainingMs); + return () => window.clearTimeout(timeout); + }, [props.recovery.createdAt]); + + const signal = resolveRetractionRecoverySignal({ + recovery: props.recovery, + liveCompletion, + projectedRetraction, + activities: sourceThread?.activities ?? [], + threadStatus, + threadDetailExists: sourceThread !== null, + shellSnapshotReady, + sourceThreadInShell, + nowMs, + }); + + useEffect(() => { + if (signal === null || !recoveryDraftReady) return; + const outcome = applyRetractionRecoverySignal({ + recovery: props.recovery, + signal, navigate: props.navigate, }); - }, [completion, props.environmentId, props.navigate, recoveryDraftReady]); + if (outcome === null) return; + + if (signal.kind === "failed") { + toastManager.add( + stackedThreadToast({ + type: "error", + title: + outcome === "thread-restored" + ? "Message restored, but the turn could not be retracted" + : "Recovery draft opened because the turn could not be retracted", + description: signal.detail, + }), + ); + } else if (signal.kind === "stale") { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Recovery draft opened", + description: + "The retraction result could not be confirmed after 60 seconds, so your message was preserved in a draft.", + }), + ); + } + }, [props.navigate, props.recovery, recoveryDraftReady, signal]); return null; } + +export function RetractionRecoveryHandoff(props: { + navigate: (input: { + to: "/draft/$draftId"; + params: { draftId: DraftId }; + replace: true; + }) => unknown; +}) { + const recoveries = useRetractionRecoveryStore((state) => Object.values(state.byRequestId)); + return recoveries.map((recovery) => ( + + )); +} diff --git a/apps/web/src/components/chat/lastUserMessageRecovery.ts b/apps/web/src/components/chat/lastUserMessageRecovery.ts index ef2fc4982e1f..457fa8558484 100644 --- a/apps/web/src/components/chat/lastUserMessageRecovery.ts +++ b/apps/web/src/components/chat/lastUserMessageRecovery.ts @@ -282,11 +282,31 @@ export function handoffCompletedFirstMessageRetraction(input: { const metadata = input.completion.retraction; if (!input.capabilityEnabled || !metadata?.firstUserMessage) return false; - const recovery = useRetractionRecoveryStore.getState().byRequestId[metadata.requestId]; + return surfaceRetractionRecoveryDraft({ + requestId: metadata.requestId, + sourceThreadRef: { + environmentId: input.environmentId, + threadId: input.completion.threadId, + }, + navigate: input.navigate, + }); +} + +export function surfaceRetractionRecoveryDraft(input: { + requestId: CommandId; + sourceThreadRef: ScopedThreadRef; + navigate: (input: { + to: "/draft/$draftId"; + params: { draftId: DraftId }; + replace: true; + }) => unknown; +}): boolean { + const recovery = useRetractionRecoveryStore.getState().byRequestId[input.requestId]; + if ( !recovery || - recovery.sourceThreadRef.environmentId !== input.environmentId || - recovery.sourceThreadRef.threadId !== input.completion.threadId + recovery.sourceThreadRef.environmentId !== input.sourceThreadRef.environmentId || + recovery.sourceThreadRef.threadId !== input.sourceThreadRef.threadId ) { return false; } @@ -306,7 +326,7 @@ export function handoffCompletedFirstMessageRetraction(input: { startFromOrigin: session.startFromOrigin, hidden: false, }); - useRetractionRecoveryStore.getState().forget(metadata.requestId); + useRetractionRecoveryStore.getState().forget(input.requestId); void input.navigate({ to: "/draft/$draftId", params: { draftId: recovery.draftId }, diff --git a/apps/web/src/components/chat/useLastUserMessageRetraction.ts b/apps/web/src/components/chat/useLastUserMessageRetraction.ts index 3ddf508e285a..35676c58501c 100644 --- a/apps/web/src/components/chat/useLastUserMessageRetraction.ts +++ b/apps/web/src/components/chat/useLastUserMessageRetraction.ts @@ -102,7 +102,8 @@ export function useLastUserMessageRetraction(input: { requestId: recovery.requestId, sourceThreadRef: recovery.sourceThreadRef, }); - if (restored) applyRestoredComposer(restored); + if (!restored) return; + applyRestoredComposer(restored); setThreadError(recovery.sourceThreadRef.threadId, detail); toastManager.add( stackedThreadToast({ diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index e084e22c2cbb..4bc8bfac293b 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -10,6 +10,7 @@ import { usePrimaryEnvironmentId } from "../state/environments"; import { selectProjectGroupingSettings } from "../logicalProject"; import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; import { dispatchPreviewAction } from "../components/preview/previewActionBus"; +import { RetractionRecoveryHandoff } from "../components/chat/RetractionRecoveryHandoff"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; import { isPreviewFocused } from "../lib/previewFocus"; @@ -175,10 +176,12 @@ function ChatRouteGlobalShortcuts() { } function ChatRouteLayout() { + const navigate = Route.useNavigate(); return ( <> + ); } From 6768d3eb450ae8546abc9de571a618159a0baa32 Mon Sep 17 00:00:00 2001 From: Adam Firestone Date: Tue, 11 Aug 2026 13:06:56 -0500 Subject: [PATCH 11/18] fix(web): stop infinite re-render from unstable retraction-store selector Object.values inside the zustand selector returned a fresh array every snapshot, looping useSyncExternalStore from the persistent chat layout and crashing every route. Select the stable map and derive values outside. Built with Claude Fable 5 via Claude Code. --- apps/web/src/components/chat/RetractionRecoveryHandoff.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/RetractionRecoveryHandoff.tsx b/apps/web/src/components/chat/RetractionRecoveryHandoff.tsx index f92f8c895b35..2d3ed5d6f094 100644 --- a/apps/web/src/components/chat/RetractionRecoveryHandoff.tsx +++ b/apps/web/src/components/chat/RetractionRecoveryHandoff.tsx @@ -275,8 +275,10 @@ export function RetractionRecoveryHandoff(props: { replace: true; }) => unknown; }) { - const recoveries = useRetractionRecoveryStore((state) => Object.values(state.byRequestId)); - return recoveries.map((recovery) => ( + // Select the stable map reference; deriving Object.values inside the + // selector returns a fresh array every snapshot and loops the store. + const byRequestId = useRetractionRecoveryStore((state) => state.byRequestId); + return Object.values(byRequestId).map((recovery) => ( Date: Tue, 11 Aug 2026 13:33:47 -0500 Subject: [PATCH 12/18] fix(server,web): unwedge Codex retraction convergence, fix Claude rollback arithmetic, correct client retraction correlation The retraction worker blocked forever awaiting a redundant turn/interrupt against a settled turn; interrupts are now once-per-request, bounded, and every convergence evaluation logs its outcome. Claude absolute rollback now offsets session-local turns by the resumed cursor's lifetime count. The client reducer maintains turnRetraction incrementally so pending rows suppress the staleness fallback, and failure/stale recovery never navigates. Built with Claude Fable 5 via Claude Code, implementation by Codex (GPT-5.6-Sol). --- apps/server/scripts/turn-retraction-repro.ts | 159 ++++++++++++++++++ .../Layers/TurnRetractionReactor.test.ts | 32 +++- .../Layers/TurnRetractionReactor.ts | 136 ++++++++++++--- .../src/provider/Layers/ClaudeAdapter.test.ts | 70 ++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 37 ++-- apps/web/src/components/ChatView.tsx | 17 +- .../chat/RetractionRecoveryHandoff.test.ts | 6 +- .../chat/RetractionRecoveryHandoff.tsx | 8 +- .../chat/lastUserMessageRecovery.ts | 14 +- .../src/state/threadReducer.test.ts | 146 ++++++++++------ .../client-runtime/src/state/threadReducer.ts | 96 +++++++++-- 11 files changed, 603 insertions(+), 118 deletions(-) create mode 100644 apps/server/scripts/turn-retraction-repro.ts diff --git a/apps/server/scripts/turn-retraction-repro.ts b/apps/server/scripts/turn-retraction-repro.ts new file mode 100644 index 000000000000..dc4241f1ca96 --- /dev/null +++ b/apps/server/scripts/turn-retraction-repro.ts @@ -0,0 +1,159 @@ +// @effect-diagnostics nodeBuiltinImport:off cryptoRandomUUID:off globalFetch:off globalDate:off globalDateInEffect:off globalConsole:off globalTimers:off - Standalone black-box probe intentionally uses host APIs around the real Effect RPC client. +import * as NodeSocket from "@effect/platform-node/NodeSocket"; +import { + CommandId, + MessageId, + ORCHESTRATION_WS_METHODS, + ProjectId, + ProviderInstanceId, + ThreadId, + WsRpcGroup, +} from "@t3tools/contracts"; +import { DatabaseSync } from "node:sqlite"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; +import * as Socket from "effect/unstable/socket/Socket"; + +const [baseDir, httpOrigin, pairingCredential, timing = "immediate"] = process.argv.slice(2); +if (!baseDir || !httpOrigin || !pairingCredential) { + throw new Error( + "usage: node apps/server/scripts/turn-retraction-repro.ts [immediate|mid-thinking]", + ); +} + +const delayMs = timing === "mid-thinking" ? 2_000 : 0; +const suffix = crypto.randomUUID(); +const projectId = ProjectId.make(`repro-project-${suffix}`); +const threadId = ThreadId.make(`repro-thread-${suffix}`); +const messageId = MessageId.make(`repro-message-${suffix}`); +const retractionRequestId = CommandId.make(`repro-retract-${suffix}`); +const workspaceRoot = `${baseDir}/workspace-${suffix}`; + +const bootstrapResponse = await fetch(`${httpOrigin}/api/auth/browser-session`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ credential: pairingCredential }), +}); +if (!bootstrapResponse.ok) { + throw new Error(`pairing credential exchange failed (${bootstrapResponse.status})`); +} +const sessionCookie = bootstrapResponse.headers.getSetCookie()[0]?.split(";", 1)[0]; +if (!sessionCookie) throw new Error("pairing credential exchange returned no session cookie"); + +const wsUrl = `${httpOrigin.replace(/^http/, "ws")}/ws`; +const socketConstructorLayer = Layer.succeed( + Socket.WebSocketConstructor, + (url, protocols) => + new NodeSocket.NodeWS.WebSocket(url, protocols, { + headers: { cookie: sessionCookie }, + }) as unknown as globalThis.WebSocket, +); +const protocolLayer = RpcClient.layerProtocolSocket().pipe( + Layer.provide(Socket.layerWebSocket(wsUrl).pipe(Layer.provide(socketConstructorLayer))), + Layer.provide(RpcSerialization.layerJson), +); +const makeClient = RpcClient.make(WsRpcGroup); + +const run = Effect.gen(function* () { + const client = yield* makeClient; + const createdAt = new Date().toISOString(); + yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "project.create", + commandId: CommandId.make(`repro-create-project-${suffix}`), + projectId, + title: `Retraction repro ${timing}`, + workspaceRoot, + createWorkspaceRootIfMissing: true, + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + createdAt, + }); + yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.create", + commandId: CommandId.make(`repro-create-thread-${suffix}`), + threadId, + projectId, + title: `Retraction repro ${timing}`, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + }); + yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make(`repro-start-${suffix}`), + threadId, + message: { + messageId, + role: "user", + text: + timing === "mid-thinking" + ? "Use the shell to run sleep 20, then reply with exactly done." + : "Reply with exactly done.", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: new Date().toISOString(), + }); + if (delayMs > 0) yield* Effect.sleep(`${delayMs} millis`); + yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.retract", + commandId: retractionRequestId, + threadId, + messageId, + createdAt: new Date().toISOString(), + }); +}).pipe(Effect.provide(protocolLayer)); + +await Effect.runPromise(Effect.scoped(run)); + +const database = new DatabaseSync(`${baseDir}/userdata/state.sqlite`, { readOnly: true }); +const readRow = () => + database + .prepare( + `SELECT status, provider_send_state AS providerSendState, + target_turn_id AS targetTurnId, completed_at AS completedAt, failed_at AS failedAt + FROM projection_turn_retractions WHERE request_id = ?`, + ) + .get(retractionRequestId); +const readSession = () => + database + .prepare( + `SELECT status, active_turn_id AS activeTurnId, updated_at AS updatedAt + FROM projection_thread_sessions WHERE thread_id = ?`, + ) + .get(threadId); + +console.log(`scenario=${timing} delayMs=${delayMs} threadId=${threadId}`); +for (const elapsedMs of [0, 2_000, 35_000]) { + if (elapsedMs > 0) + await new Promise((resolve) => setTimeout(resolve, elapsedMs === 2_000 ? 2_000 : 33_000)); + console.log( + JSON.stringify({ elapsedMs, retraction: readRow() ?? null, session: readSession() ?? null }), + ); + const row = readRow() as { status?: string } | undefined; + if (row?.status === "completed" || row?.status === "failed") break; +} + +const events = database + .prepare( + `SELECT sequence, event_type AS eventType, payload_json AS payload + FROM orchestration_events WHERE stream_id = ? ORDER BY sequence`, + ) + .all(threadId) + .map((row) => ({ + sequence: row.sequence, + eventType: row.eventType, + payload: JSON.parse(String(row.payload)), + })); +console.log(JSON.stringify({ terminalRetraction: readRow() ?? null, events }, null, 2)); +database.close(); diff --git a/apps/server/src/orchestration/Layers/TurnRetractionReactor.test.ts b/apps/server/src/orchestration/Layers/TurnRetractionReactor.test.ts index 3efdfca962c5..68516aabb849 100644 --- a/apps/server/src/orchestration/Layers/TurnRetractionReactor.test.ts +++ b/apps/server/src/orchestration/Layers/TurnRetractionReactor.test.ts @@ -52,7 +52,11 @@ import { type ProjectionSnapshotQueryShape, } from "../Services/ProjectionSnapshotQuery.ts"; import { TurnRetractionReactor } from "../Services/TurnRetractionReactor.ts"; -import { makeTurnRetractionReactor, TurnRetractionRetryTicks } from "./TurnRetractionReactor.ts"; +import { + makeTurnRetractionReactor, + TurnRetractionInterruptTimeout, + TurnRetractionRetryTicks, +} from "./TurnRetractionReactor.ts"; const NOW = "2026-08-11T12:00:00.000Z"; const THREAD_ID = ThreadId.make("thread-retraction-reactor"); @@ -71,6 +75,7 @@ type MutableState = { failRestoreAfterEffect: boolean; failCompletionAfterCommit: boolean; terminalRollbackFailure: boolean; + interruptAcknowledgementHangs: boolean; readonly order: string[]; readonly dispatched: OrchestrationCommand[]; }; @@ -106,6 +111,7 @@ function makeState(providerSendState: ProjectionTurnRetraction["providerSendStat failRestoreAfterEffect: false, failCompletionAfterCommit: false, terminalRollbackFailure: false, + interruptAcknowledgementHangs: false, order: [], dispatched: [], }; @@ -290,7 +296,12 @@ async function startHarness(state: MutableState) { interruptTurn: () => Effect.sync(() => { state.order.push("interrupt"); - }), + if (state.interruptAcknowledgementHangs) { + // Models Codex emitting turn/completed while its turn/interrupt RPC + // response remains unresolved. + state.sessionStatus = "ready"; + } + }).pipe(Effect.andThen(state.interruptAcknowledgementHangs ? Effect.never : Effect.void)), respondToRequest: () => unsupported(), respondToUserInput: () => unsupported(), stopSession: () => unsupported(), @@ -358,6 +369,7 @@ async function startHarness(state: MutableState) { const layer = Layer.effect(TurnRetractionReactor, makeTurnRetractionReactor).pipe( Layer.provideMerge(Layer.succeed(TurnRetractionRetryTicks, Stream.fromQueue(retryTicks))), + Layer.provideMerge(Layer.succeed(TurnRetractionInterruptTimeout, "1 millis")), Layer.provideMerge(Layer.succeed(OrchestrationEngineService, engine)), Layer.provideMerge(Layer.succeed(ProjectionSnapshotQuery, query)), Layer.provideMerge(Layer.succeed(ProjectionTurnRetractionRepository, repository)), @@ -402,6 +414,10 @@ it("drives claimed convergence from interrupt through a settlement event", async expect(state.order).toEqual(["interrupt"]); expect(state.row.status).toBe("requested"); + await harness.retryTick(); + await harness.runtime.runPromise(harness.reactor.drain); + expect(state.order).toEqual(["interrupt"]); + state.sessionStatus = "ready"; await harness.emitDomain({ sequence: 10, @@ -437,6 +453,18 @@ it("drives claimed convergence from interrupt through a settlement event", async await stopHarness(harness); }); +it("converges when settlement is projected but the interrupt acknowledgement hangs", async () => { + const state = makeState("claimed"); + state.interruptAcknowledgementHangs = true; + const harness = await startHarness(state); + + expect(state.order).toEqual(["interrupt", "rollback", "restore", "complete"]); + expect(state.sessionStatus).toBe("ready"); + expect(state.historyTurnCount).toBe(1); + expect(state.row.status).toBe("completed"); + await stopHarness(harness); +}); + it("retries a pending row on the next periodic tick without a lifecycle event", async () => { const state = makeState("claimed"); state.sessionStatus = "ready"; diff --git a/apps/server/src/orchestration/Layers/TurnRetractionReactor.ts b/apps/server/src/orchestration/Layers/TurnRetractionReactor.ts index f17dbc070d00..8677cd14f953 100644 --- a/apps/server/src/orchestration/Layers/TurnRetractionReactor.ts +++ b/apps/server/src/orchestration/Layers/TurnRetractionReactor.ts @@ -82,8 +82,16 @@ export class TurnRetractionRetryTicks extends Context.Reference( + "t3/orchestration/Layers/TurnRetractionReactor/TurnRetractionInterruptTimeout", + { + defaultValue: () => Duration.seconds(15), + }, +) {} + export const makeTurnRetractionReactor = Effect.gen(function* () { const retryTicks = yield* TurnRetractionRetryTicks; + const interruptTimeout = yield* TurnRetractionInterruptTimeout; const crypto = yield* Crypto.Crypto; const orchestrationEngine = yield* OrchestrationEngineService; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; @@ -92,12 +100,27 @@ export const makeTurnRetractionReactor = Effect.gen(function* () { const providerService = yield* ProviderService; const checkpointStore = yield* CheckpointStore.CheckpointStore; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const interruptedRequestIds = new Set(); const commandId = (tag: string) => crypto.randomUUIDv4.pipe(Effect.map((uuid) => CommandId.make(`server:${tag}:${uuid}`))); const eventId = crypto.randomUUIDv4.pipe(Effect.map(EventId.make)); const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + const logConvergence = ( + row: ProjectionTurnRetraction, + stage: RetractionStage, + outcome: "completed" | "failed" | "pending" | "skipped", + fields: { readonly action?: string; readonly reason?: string } = {}, + ) => + Effect.logInfo("turn retraction convergence evaluated", { + threadId: row.threadId, + requestId: row.requestId, + stage, + outcome, + ...fields, + }); + const appendTerminalFailure = Effect.fn("appendTerminalRetractionFailure")(function* ( row: ProjectionTurnRetraction, failure: StageFailure, @@ -283,7 +306,15 @@ export const makeTurnRetractionReactor = Effect.gen(function* () { detail: failureDetail(error), })), ); - if (Option.isNone(current) || current.value.status !== "requested") return; + if (Option.isNone(current) || current.value.status !== "requested") { + interruptedRequestIds.delete(requestedRow.requestId); + yield* logConvergence(requestedRow, "eligibility", "skipped", { + reason: Option.isNone(current) + ? "retraction row no longer exists" + : `retraction status is '${current.value.status}'`, + }); + return; + } let row = current.value; if (row.providerSendState === "unclaimed") { @@ -303,7 +334,16 @@ export const makeTurnRetractionReactor = Effect.gen(function* () { detail: failureDetail(error), })), ); - if (Option.isNone(reconciled) || reconciled.value.status !== "requested") return; + if (Option.isNone(reconciled) || reconciled.value.status !== "requested") { + interruptedRequestIds.delete(row.requestId); + yield* logConvergence(row, "eligibility", "skipped", { + action: "cancel-provider-send", + reason: Option.isNone(reconciled) + ? "retraction row disappeared while cancelling provider send" + : `retraction status became '${reconciled.value.status}' while cancelling provider send`, + }); + return; + } row = reconciled.value; } @@ -318,6 +358,10 @@ export const makeTurnRetractionReactor = Effect.gen(function* () { if (row.providerSendState === "cancelled") { yield* restoreFilesystem(row, true); yield* dispatchCompletion(row, targetTurnId); + interruptedRequestIds.delete(row.requestId); + yield* logConvergence(row, "cleanup", "completed", { + action: "restore-filesystem-and-complete-cancelled-send", + }); return; } @@ -340,18 +384,30 @@ export const makeTurnRetractionReactor = Effect.gen(function* () { const sessionActive = thread.session?.status === "starting" || thread.session?.status === "running"; if (sessionActive) { - yield* providerService - .interruptTurn({ - threadId: row.threadId, - ...(targetTurnId !== null ? { turnId: targetTurnId } : {}), - }) - .pipe( - Effect.mapError((error) => ({ - stage: "interrupt" as const, - retryable: !isTerminalProviderError(error), - detail: failureDetail(error), - })), - ); + const interruptAlreadyRequested = interruptedRequestIds.has(row.requestId); + const interruptAcknowledged = interruptAlreadyRequested + ? undefined + : yield* Effect.sync(() => interruptedRequestIds.add(row.requestId)).pipe( + Effect.andThen( + providerService + .interruptTurn({ + threadId: row.threadId, + ...(targetTurnId !== null ? { turnId: targetTurnId } : {}), + }) + .pipe( + Effect.mapError((error) => ({ + stage: "interrupt" as const, + retryable: !isTerminalProviderError(error), + detail: failureDetail(error), + })), + Effect.tapError(() => + Effect.sync(() => interruptedRequestIds.delete(row.requestId)), + ), + Effect.timeoutOption(interruptTimeout), + Effect.map(Option.isSome), + ), + ), + ); // Interrupt acknowledgement is not settlement. A later provider/runtime // lifecycle event wakes this row after the projected session leaves @@ -368,6 +424,14 @@ export const makeTurnRetractionReactor = Effect.gen(function* () { afterInterrupt?.session?.status === "starting" || afterInterrupt?.session?.status === "running" ) { + yield* logConvergence(row, "settlement", "pending", { + action: interruptAlreadyRequested + ? "interrupt-already-requested" + : interruptAcknowledged + ? "interrupt-acknowledged" + : "interrupt-timed-out", + reason: `projected session remains '${afterInterrupt.session.status}'`, + }); return; } } @@ -386,6 +450,12 @@ export const makeTurnRetractionReactor = Effect.gen(function* () { ); yield* restoreFilesystem(row, false); yield* dispatchCompletion(row, targetTurnId); + interruptedRequestIds.delete(row.requestId); + yield* logConvergence(row, "cleanup", "completed", { + action: sessionActive + ? "interrupt-settled-provider-rollback-restore-and-complete" + : "provider-rollback-restore-and-complete", + }); }); const processThread = Effect.fn("processTurnRetractionThread")(function* (threadId: ThreadId) { @@ -393,22 +463,32 @@ export const makeTurnRetractionReactor = Effect.gen(function* () { if (Option.isNone(latest) || latest.value.status !== "requested") return; yield* converge(latest.value).pipe( Effect.catch((failure) => - failure.retryable - ? Effect.logWarning("turn retraction remains pending after retryable failure", { - threadId, - requestId: latest.value.requestId, - stage: failure.stage, - detail: failure.detail, - }) - : appendTerminalFailure(latest.value, failure).pipe( - Effect.catchCause((cause) => - Effect.logWarning("failed to persist terminal turn retraction failure", { + logConvergence(latest.value, failure.stage, "failed", { + action: failure.retryable ? "leave-pending-for-retry" : "persist-terminal-failure", + reason: failure.detail, + }).pipe( + Effect.andThen( + failure.retryable + ? Effect.logWarning("turn retraction remains pending after retryable failure", { threadId, requestId: latest.value.requestId, - cause: Cause.pretty(cause), - }), - ), - ), + stage: failure.stage, + detail: failure.detail, + }) + : appendTerminalFailure(latest.value, failure).pipe( + Effect.tap(() => + Effect.sync(() => interruptedRequestIds.delete(latest.value.requestId)), + ), + Effect.catchCause((cause) => + Effect.logWarning("failed to persist terminal turn retraction failure", { + threadId, + requestId: latest.value.requestId, + cause: Cause.pretty(cause), + }), + ), + ), + ), + ), ), ); }); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 45c61442a040..985f362fc8ac 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -3653,6 +3653,76 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("rolls a resumed session back to its lifetime turn boundary", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const resumeSessionId = "550e8400-e29b-41d4-a716-446655440000"; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + resumeCursor: { + threadId: THREAD_ID, + resume: resumeSessionId, + resumeSessionAt: "assistant-before-restart", + turnCount: 4, + }, + }); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "fifth", + attachments: [], + }); + const completedFiber = yield* Stream.filter( + adapter.streamEvents, + (event) => event.type === "turn.completed", + ).pipe(Stream.runHead, Effect.forkChild); + harness.query.emit({ + type: "assistant", + session_id: resumeSessionId, + uuid: "assistant-fifth", + parent_tool_use_id: null, + message: { + id: "assistant-message-fifth", + content: [{ type: "text", text: "fifth response" }], + }, + } as unknown as SDKMessage); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: resumeSessionId, + uuid: "result-fifth", + } as unknown as SDKMessage); + yield* Fiber.join(completedFiber); + + const beforeRollback = yield* adapter.listSessions(); + assert.equal( + (beforeRollback[0]?.resumeCursor as { turnCount?: number } | undefined)?.turnCount, + 5, + ); + + assert.isDefined(adapter.rollbackThreadTo); + if (!adapter.rollbackThreadTo) return; + const rolledBack = yield* adapter.rollbackThreadTo(session.threadId, 4); + assert.equal(rolledBack.turns.length, 0); + + const afterRollback = yield* adapter.listSessions(); + assert.deepEqual(afterRollback[0]?.resumeCursor, { + threadId: THREAD_ID, + resume: resumeSessionId, + resumeSessionAt: "assistant-before-restart", + turnCount: 4, + }); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("clears resume state at turn zero and recycles before the next prompt", () => { const harness = makeHarness({ queryFactory: () => new FakeClaudeQuery(), diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 7332c9be6de5..7e535eb50a27 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -211,6 +211,8 @@ interface ClaudeTaskAgentState { interface ClaudeSessionContext { session: ProviderSession; + /** Lifetime turns already represented by the cursor used to start this SDK session. */ + sessionBaseTurnCount: number; readonly promptQueue: Queue.Queue; readonly query: ClaudeQueryRuntime; streamFiber: Fiber.Fiber | undefined; @@ -1747,7 +1749,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( threadId, ...(context.resumeSessionId ? { resume: context.resumeSessionId } : {}), ...(context.lastAssistantUuid ? { resumeSessionAt: context.lastAssistantUuid } : {}), - turnCount: context.turns.length, + turnCount: context.sessionBaseTurnCount + context.turns.length, }; context.session = { @@ -4207,6 +4209,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const context: ClaudeSessionContext = { session, + sessionBaseTurnCount: resumeState?.turnCount ?? 0, promptQueue, query: queryRuntime, streamFiber: undefined, @@ -4503,10 +4506,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const nextLength = Math.max(0, context.turns.length - numTurns); context.turns.splice(nextLength); const retainedTurn = context.turns.at(-1); - context.lastAssistantUuid = retainedTurn?.lastAssistantUuid; - if (!retainedTurn) { - context.resumeSessionId = undefined; - } + const sessionBase = readClaudeResumeState(context.restartInput.resumeCursor); + context.lastAssistantUuid = retainedTurn?.lastAssistantUuid ?? sessionBase?.resumeSessionAt; + context.resumeSessionId = retainedTurn ? context.resumeSessionId : sessionBase?.resume; context.recycleBeforeNextTurn = true; yield* updateResumeCursor(context); return yield* snapshotThread(context); @@ -4524,23 +4526,34 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( issue: "retainedTurnCount must be an integer >= 0.", }); } - if (context.turns.length < retainedTurnCount) { + const lifetimeTurnCount = context.sessionBaseTurnCount + context.turns.length; + if (lifetimeTurnCount < retainedTurnCount) { return yield* new ProviderAdapterValidationError({ provider: PROVIDER, operation: "rollbackThreadTo", - issue: `Provider history has ${context.turns.length} turns, below retained boundary ${retainedTurnCount}.`, + issue: `Provider history has ${lifetimeTurnCount} turns, below retained boundary ${retainedTurnCount}.`, }); } - const remainingDelta = context.turns.length - retainedTurnCount; - if (remainingDelta > 0) { - yield* rollbackThread(threadId, remainingDelta); + const delta = lifetimeTurnCount - retainedTurnCount; + const sessionLocalTurnCount = context.turns.length; + if (delta > 0) { + yield* rollbackThread(threadId, Math.min(delta, sessionLocalTurnCount)); + } + if (delta > sessionLocalTurnCount) { + // The requested boundary predates this SDK session. Claude only gives us + // the cursor that opened the session, not intermediate historical + // watermarks, so retain that oldest available resume position while + // moving the logical lifetime boundary to the requested count. + context.sessionBaseTurnCount = retainedTurnCount; + yield* updateResumeCursor(context); } const snapshot = yield* snapshotThread(context); - if (snapshot.turns.length !== retainedTurnCount) { + const resultingLifetimeTurnCount = context.sessionBaseTurnCount + snapshot.turns.length; + if (resultingLifetimeTurnCount !== retainedTurnCount) { return yield* new ProviderAdapterRequestError({ provider: PROVIDER, method: "thread/rollback-to", - detail: `Expected ${retainedTurnCount} retained turns, found ${snapshot.turns.length}.`, + detail: `Expected ${retainedTurnCount} retained turns, found ${resultingLifetimeTurnCount}.`, }); } return snapshot; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c14f41b45af4..0c60c6b62920 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -257,7 +257,10 @@ import { } from "./chat/chatEscapeTrigger"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { shouldRenderEmptyThreadHero } from "./chat/emptyThreadHero"; -import { useRetractionRecoveryStore } from "./chat/lastUserMessageRecovery"; +import { + findCorrelatedRetractionFailure, + useRetractionRecoveryStore, +} from "./chat/lastUserMessageRecovery"; import { useLastUserMessageRetraction } from "./chat/useLastUserMessageRetraction"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; @@ -1507,8 +1510,18 @@ function ChatViewContent(props: ChatViewProps) { // depend on which route is mounted. const isServerThread = activeServerThread !== null; const activeThread = activeServerThread ?? localDraftThread; + const retractionFailureDetail = + activeServerThread?.turnRetraction?.status === "failed" + ? findCorrelatedRetractionFailure( + activeServerThread.activities, + activeServerThread.turnRetraction.requestId, + ) + : null; const threadError = isServerThread - ? (localServerError ?? activeServerThread?.session?.lastError ?? null) + ? (localServerError ?? + retractionFailureDetail ?? + activeServerThread?.session?.lastError ?? + null) : localDraftError; const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; // Plan mode is legacy (Settings → Beta). With the flag off the effective diff --git a/apps/web/src/components/chat/RetractionRecoveryHandoff.test.ts b/apps/web/src/components/chat/RetractionRecoveryHandoff.test.ts index f478c510aa8c..5a38aa5e9632 100644 --- a/apps/web/src/components/chat/RetractionRecoveryHandoff.test.ts +++ b/apps/web/src/components/chat/RetractionRecoveryHandoff.test.ts @@ -155,6 +155,7 @@ describe("retraction recovery handoff", () => { it("restores into an existing source composer for a correlated failed row", async () => { const recovery = await seedRecovery(); + const navigate = vi.fn(); const signal = resolveRetractionRecoverySignal({ recovery, liveCompletion: null, @@ -175,9 +176,10 @@ describe("retraction recovery handoff", () => { }); expect(signal).toMatchObject({ kind: "failed", sourceThreadExists: true }); - expect(signal && applyRetractionRecoverySignal({ recovery, signal, navigate: vi.fn() })).toBe( + expect(signal && applyRetractionRecoverySignal({ recovery, signal, navigate })).toBe( "thread-restored", ); + expect(navigate).not.toHaveBeenCalled(); expect(useComposerDraftStore.getState().getComposerDraft(sourceThreadRef)?.prompt).toBe( "preserve this message", ); @@ -217,6 +219,7 @@ describe("retraction recovery handoff", () => { expect(signal && applyRetractionRecoverySignal({ recovery, signal, navigate })).toBe( "draft-surfaced", ); + expect(navigate).not.toHaveBeenCalled(); expect(useComposerDraftStore.getState().getComposerDraft(sourceThreadRef)).toBeNull(); expect(useComposerDraftStore.getState().getDraftSession(draftId)?.hidden).toBe(false); }); @@ -240,6 +243,7 @@ describe("retraction recovery handoff", () => { expect(signal && applyRetractionRecoverySignal({ recovery, signal, navigate })).toBe( "draft-surfaced", ); + expect(navigate).not.toHaveBeenCalled(); expect(useComposerDraftStore.getState().getDraftSession(draftId)?.hidden).toBe(false); }); diff --git a/apps/web/src/components/chat/RetractionRecoveryHandoff.tsx b/apps/web/src/components/chat/RetractionRecoveryHandoff.tsx index 2d3ed5d6f094..cb317db3ebc4 100644 --- a/apps/web/src/components/chat/RetractionRecoveryHandoff.tsx +++ b/apps/web/src/components/chat/RetractionRecoveryHandoff.tsx @@ -170,7 +170,7 @@ export function applyRetractionRecoverySignal(input: { return surfaceRetractionRecoveryDraft({ requestId: input.recovery.requestId, sourceThreadRef: input.recovery.sourceThreadRef, - navigate: input.navigate, + ...(input.signal.kind === "source-thread-gone" ? { navigate: input.navigate } : {}), }) ? "draft-surfaced" : null; @@ -249,7 +249,7 @@ function PendingRetractionRecoveryWatcher(props: { title: outcome === "thread-restored" ? "Message restored, but the turn could not be retracted" - : "Recovery draft opened because the turn could not be retracted", + : "Recovery draft preserved because the turn could not be retracted", description: signal.detail, }), ); @@ -257,9 +257,9 @@ function PendingRetractionRecoveryWatcher(props: { toastManager.add( stackedThreadToast({ type: "warning", - title: "Recovery draft opened", + title: "Recovery draft preserved", description: - "The retraction result could not be confirmed after 60 seconds, so your message was preserved in a draft.", + "The retraction result could not be confirmed after 60 seconds, so your message is visible as a draft in the sidebar.", }), ); } diff --git a/apps/web/src/components/chat/lastUserMessageRecovery.ts b/apps/web/src/components/chat/lastUserMessageRecovery.ts index 457fa8558484..1e3a2d98bdd6 100644 --- a/apps/web/src/components/chat/lastUserMessageRecovery.ts +++ b/apps/web/src/components/chat/lastUserMessageRecovery.ts @@ -295,7 +295,7 @@ export function handoffCompletedFirstMessageRetraction(input: { export function surfaceRetractionRecoveryDraft(input: { requestId: CommandId; sourceThreadRef: ScopedThreadRef; - navigate: (input: { + navigate?: (input: { to: "/draft/$draftId"; params: { draftId: DraftId }; replace: true; @@ -327,10 +327,12 @@ export function surfaceRetractionRecoveryDraft(input: { hidden: false, }); useRetractionRecoveryStore.getState().forget(input.requestId); - void input.navigate({ - to: "/draft/$draftId", - params: { draftId: recovery.draftId }, - replace: true, - }); + if (input.navigate) { + void input.navigate({ + to: "/draft/$draftId", + params: { draftId: recovery.draftId }, + replace: true, + }); + } return true; } diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index a1cd0490fa5e..b2c28cf0de3a 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -949,8 +949,8 @@ describe("applyThreadDetailEvent", () => { }); }); - describe("additive retraction metadata compatibility", () => { - it("reduces interrupt, revert, and delete events exactly as legacy payloads", () => { + describe("retraction projection", () => { + it("tracks requested and completed retractions incrementally", () => { const runningThread: OrchestrationThread = { ...baseThread, latestTurn: { @@ -975,72 +975,114 @@ describe("applyThreadDetailEvent", () => { createdAt: "2026-04-01T14:00:02.000Z", }, }; - expect( - applyThreadDetailEvent(runningThread, { - ...interruptBase, - payload: { - ...interruptBase.payload, - retraction: { - requestId: CommandId.make("cmd-retract"), - messageId: MessageId.make("message-retracted"), - targetTurnId: TurnId.make("turn-retracted"), - baselineTurnCount: 0, - firstUserMessage: true, - }, + const requested = applyThreadDetailEvent(runningThread, { + ...interruptBase, + payload: { + ...interruptBase.payload, + retraction: { + requestId: CommandId.make("cmd-retract"), + messageId: MessageId.make("message-retracted"), + targetTurnId: TurnId.make("turn-retracted"), + baselineTurnCount: 0, + firstUserMessage: false, }, - }), - ).toEqual(applyThreadDetailEvent(runningThread, interruptBase)); + }, + }); + expect(requested.kind).toBe("updated"); + if (requested.kind !== "updated") return; + expect(requested.thread.turnRetraction).toMatchObject({ + requestId: "cmd-retract", + messageId: "message-retracted", + targetTurnId: "turn-retracted", + baselineTurnCount: 0, + providerSendClaimed: false, + providerSendState: "unclaimed", + firstUserMessage: false, + status: "requested", + completedAt: null, + failedAt: null, + }); - const revertedBase = { + const completedAt = "2026-04-01T14:00:03.000Z"; + const completed = applyThreadDetailEvent(requested.thread, { ...baseEventFields, sequence: 17, - occurredAt: "2026-04-01T14:00:03.000Z", + occurredAt: completedAt, aggregateKind: "thread" as const, aggregateId: ThreadId.make("thread-1"), type: "thread.reverted" as const, - payload: { threadId: ThreadId.make("thread-1"), turnCount: 0 }, - }; - expect( - applyThreadDetailEvent(baseThread, { - ...revertedBase, - payload: { - ...revertedBase.payload, - retraction: { - requestId: CommandId.make("cmd-retract"), - messageId: MessageId.make("message-retracted"), - turnId: TurnId.make("turn-retracted"), - firstUserMessage: false, - completedAt: "2026-04-01T14:00:03.000Z", - }, + payload: { + threadId: ThreadId.make("thread-1"), + turnCount: 0, + retraction: { + requestId: CommandId.make("cmd-retract"), + messageId: MessageId.make("message-retracted"), + turnId: TurnId.make("turn-retracted"), + firstUserMessage: false, + completedAt, }, - }), - ).toEqual(applyThreadDetailEvent(baseThread, revertedBase)); + }, + }); + expect(completed.kind).toBe("updated"); + if (completed.kind === "updated") { + expect(completed.thread.turnRetraction).toMatchObject({ + requestId: "cmd-retract", + status: "completed", + completedAt, + failedAt: null, + }); + } + }); - const deletedBase = { + it("marks a correlated terminal retraction failure incrementally", () => { + const pendingThread: OrchestrationThread = { + ...baseThread, + turnRetraction: { + requestId: CommandId.make("cmd-retract"), + messageId: MessageId.make("message-retracted"), + baselineTurnCount: 0, + baselineCheckpointRef: CheckpointRef.make("baseline-ref"), + targetTurnId: TurnId.make("turn-retracted"), + providerSendClaimed: true, + providerSendState: "claimed", + firstUserMessage: false, + requestedAt: "2026-04-01T14:00:02.000Z", + status: "requested", + completedAt: null, + failedAt: null, + }, + }; + const failedAt = "2026-04-01T14:00:04.000Z"; + const failed = applyThreadDetailEvent(pendingThread, { ...baseEventFields, sequence: 18, - occurredAt: "2026-04-01T14:00:04.000Z", + occurredAt: failedAt, aggregateKind: "thread" as const, aggregateId: ThreadId.make("thread-1"), - type: "thread.deleted" as const, + type: "thread.activity-appended" as const, payload: { threadId: ThreadId.make("thread-1"), - deletedAt: "2026-04-01T14:00:04.000Z", - }, - }; - expect( - applyThreadDetailEvent(baseThread, { - ...deletedBase, - payload: { - ...deletedBase.payload, - retraction: { - requestId: CommandId.make("cmd-retract"), - messageId: MessageId.make("message-retracted"), - firstUserMessage: true, - }, + activity: { + id: EventId.make("failure-activity"), + tone: "error" as const, + kind: "turn.retract.failed", + summary: "Message retract failed", + payload: { requestId: CommandId.make("cmd-retract"), detail: "rollback failed" }, + turnId: TurnId.make("turn-retracted"), + createdAt: failedAt, }, - }), - ).toEqual(applyThreadDetailEvent(baseThread, deletedBase)); + }, + }); + + expect(failed.kind).toBe("updated"); + if (failed.kind === "updated") { + expect(failed.thread.turnRetraction).toMatchObject({ + requestId: "cmd-retract", + status: "failed", + completedAt: null, + failedAt, + }); + } }); }); diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 1e7f4dfdbd75..7cdb05abab43 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -12,6 +12,8 @@ import type { OrchestrationThreadActivity, TurnId, } from "@t3tools/contracts"; +import { CheckpointRef } from "@t3tools/contracts"; +import * as Encoding from "effect/Encoding"; export type ThreadDetailReducerResult = | { readonly kind: "updated"; readonly thread: OrchestrationThread } @@ -35,6 +37,9 @@ const activityOrder = O.combineAll([ O.mapInput(O.String, (a) => a.id), ]); +const checkpointRefForThreadTurn = (threadId: string, turnCount: number) => + CheckpointRef.make(`refs/t3/checkpoints/${Encoding.encodeBase64Url(threadId)}/turn/${turnCount}`); + /** * Matches the validity rule in `deriveLatestContextWindowSnapshot` (and the * server's snapshot-side `dropStaleContextWindowActivities`): rows without a @@ -258,23 +263,45 @@ export function applyThreadDetailEvent( }; case "thread.turn-interrupt-requested": { - if (event.payload.turnId === undefined) { - return { kind: "unchanged" }; - } const latestTurn = thread.latestTurn; - if (latestTurn === null || latestTurn.turnId !== event.payload.turnId) { + const interruptsLatestTurn = + event.payload.turnId !== undefined && latestTurn?.turnId === event.payload.turnId; + if (!interruptsLatestTurn && event.payload.retraction === undefined) { return { kind: "unchanged" }; } return { kind: "updated", thread: { ...thread, - latestTurn: { - ...latestTurn, - state: "interrupted", - startedAt: latestTurn.startedAt ?? event.payload.createdAt, - completedAt: latestTurn.completedAt ?? event.payload.createdAt, - }, + latestTurn: interruptsLatestTurn + ? { + ...latestTurn, + state: "interrupted", + startedAt: latestTurn.startedAt ?? event.payload.createdAt, + completedAt: latestTurn.completedAt ?? event.payload.createdAt, + } + : latestTurn, + ...(event.payload.retraction !== undefined + ? { + turnRetraction: { + requestId: event.payload.retraction.requestId, + messageId: event.payload.retraction.messageId, + baselineTurnCount: event.payload.retraction.baselineTurnCount, + baselineCheckpointRef: checkpointRefForThreadTurn( + event.payload.threadId, + event.payload.retraction.baselineTurnCount, + ), + targetTurnId: event.payload.retraction.targetTurnId, + providerSendClaimed: false, + providerSendState: "unclaimed" as const, + firstUserMessage: event.payload.retraction.firstUserMessage, + requestedAt: event.payload.createdAt, + status: "requested" as const, + completedAt: null, + failedAt: null, + }, + } + : {}), updatedAt: event.occurredAt, }, }; @@ -558,6 +585,30 @@ export function applyThreadDetailEvent( completedAt: latestCheckpoint.completedAt, assistantMessageId: latestCheckpoint.assistantMessageId ?? null, }, + ...(event.payload.retraction !== undefined + ? { + turnRetraction: { + requestId: event.payload.retraction.requestId, + messageId: event.payload.retraction.messageId, + baselineTurnCount: event.payload.turnCount, + baselineCheckpointRef: checkpointRefForThreadTurn( + event.payload.threadId, + event.payload.turnCount, + ), + targetTurnId: event.payload.retraction.turnId, + providerSendClaimed: thread.turnRetraction?.providerSendClaimed ?? false, + providerSendState: thread.turnRetraction?.providerSendState ?? "unclaimed", + firstUserMessage: event.payload.retraction.firstUserMessage, + requestedAt: + thread.turnRetraction?.requestId === event.payload.retraction.requestId + ? thread.turnRetraction.requestedAt + : event.payload.retraction.completedAt, + status: "completed" as const, + completedAt: event.payload.retraction.completedAt, + failedAt: null, + }, + } + : {}), updatedAt: event.occurredAt, }, }; @@ -566,6 +617,14 @@ export function applyThreadDetailEvent( // ── Activities ────────────────────────────────────────────────── case "thread.activity-appended": { const activity = event.payload.activity; + const failedRetractionRequestId = + activity.kind === "turn.retract.failed" && + typeof activity.payload === "object" && + activity.payload !== null && + "requestId" in activity.payload && + typeof activity.payload.requestId === "string" + ? activity.payload.requestId + : null; // A resolvable context-window update supersedes earlier resolvable ones // for the same turn: consumers only read the latest value (walking the // array backwards), and providers stream these updates continuously, so @@ -592,7 +651,22 @@ export function applyThreadDetailEvent( return { kind: "updated", - thread: { ...thread, activities, updatedAt: event.occurredAt }, + thread: { + ...thread, + activities, + ...(failedRetractionRequestId !== null && + thread.turnRetraction?.requestId === failedRetractionRequestId + ? { + turnRetraction: { + ...thread.turnRetraction, + status: "failed" as const, + completedAt: null, + failedAt: activity.createdAt, + }, + } + : {}), + updatedAt: event.occurredAt, + }, }; } From a4393429dbe9596a99badc3f6f3b17ae873269cd Mon Sep 17 00:00:00 2001 From: Adam Firestone Date: Tue, 11 Aug 2026 14:05:32 -0500 Subject: [PATCH 13/18] fix(server): retracted turns now truly leave the model context on both providers Claude's absolute rollback skipped cursor rewind and query recycle at zero delta, so the live SDK session kept interrupted prompts. Codex count arithmetic saw retained==reported and rolled back nothing while the interrupted turn remained live. Both paths now share finalization (Claude) or roll back from the concrete target turn with absence and retained-prefix verification (Codex). The wire repro gates on post-retract interrogation. Built with Claude Fable 5 via Claude Code, implementation by Codex (GPT-5.6-Sol). --- apps/server/scripts/turn-retraction-repro.ts | 283 +++++++++++++----- .../Layers/TurnRetractionReactor.test.ts | 6 +- .../Layers/TurnRetractionReactor.ts | 1 + .../src/provider/Layers/ClaudeAdapter.test.ts | 42 +++ .../src/provider/Layers/ClaudeAdapter.ts | 47 +-- .../src/provider/Layers/CodexAdapter.test.ts | 35 +++ .../src/provider/Layers/CodexAdapter.ts | 60 +++- .../src/provider/Layers/ProviderService.ts | 8 +- .../src/provider/Services/ProviderAdapter.ts | 1 + .../src/provider/Services/ProviderService.ts | 2 + 10 files changed, 386 insertions(+), 99 deletions(-) diff --git a/apps/server/scripts/turn-retraction-repro.ts b/apps/server/scripts/turn-retraction-repro.ts index dc4241f1ca96..00c204fcac6b 100644 --- a/apps/server/scripts/turn-retraction-repro.ts +++ b/apps/server/scripts/turn-retraction-repro.ts @@ -1,4 +1,4 @@ -// @effect-diagnostics nodeBuiltinImport:off cryptoRandomUUID:off globalFetch:off globalDate:off globalDateInEffect:off globalConsole:off globalTimers:off - Standalone black-box probe intentionally uses host APIs around the real Effect RPC client. +// @effect-diagnostics nodeBuiltinImport:off cryptoRandomUUID:off globalFetch:off globalDate:off globalConsole:off globalTimers:off - Standalone black-box probe intentionally uses host APIs around the real Effect RPC client. import * as NodeSocket from "@effect/platform-node/NodeSocket"; import { CommandId, @@ -9,26 +9,60 @@ import { ThreadId, WsRpcGroup, } from "@t3tools/contracts"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, writeFileSync } from "node:fs"; import { DatabaseSync } from "node:sqlite"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; import * as Socket from "effect/unstable/socket/Socket"; -const [baseDir, httpOrigin, pairingCredential, timing = "immediate"] = process.argv.slice(2); +type Provider = "claudeAgent" | "codex"; +type Timing = "immediate" | "mid-thinking"; + +const [baseDir, httpOrigin, pairingCredential, providerArg = "codex", timingArg = "immediate"] = + process.argv.slice(2); if (!baseDir || !httpOrigin || !pairingCredential) { throw new Error( - "usage: node apps/server/scripts/turn-retraction-repro.ts [immediate|mid-thinking]", + "usage: node apps/server/scripts/turn-retraction-repro.ts [codex|claudeAgent] [immediate|mid-thinking]", ); } +if (providerArg !== "codex" && providerArg !== "claudeAgent") { + throw new Error(`unsupported provider '${providerArg}'`); +} +if (timingArg !== "immediate" && timingArg !== "mid-thinking") { + throw new Error(`unsupported timing '${timingArg}'`); +} +const provider: Provider = providerArg; +const timing: Timing = timingArg; const delayMs = timing === "mid-thinking" ? 2_000 : 0; const suffix = crypto.randomUUID(); const projectId = ProjectId.make(`repro-project-${suffix}`); const threadId = ThreadId.make(`repro-thread-${suffix}`); -const messageId = MessageId.make(`repro-message-${suffix}`); +const baselineMessageId = MessageId.make(`repro-baseline-message-${suffix}`); +const retractedMessageId = MessageId.make(`repro-retracted-message-${suffix}`); +const interrogationMessageId = MessageId.make(`repro-interrogation-message-${suffix}`); const retractionRequestId = CommandId.make(`repro-retract-${suffix}`); const workspaceRoot = `${baseDir}/workspace-${suffix}`; +const retainedMarker = `KEPT_MARKER_${suffix}`; +const retractedMarker = `REMOVED_MARKER_${suffix}`; +const hostNowIso = () => new Date().toISOString(); +const stringifyJson = (value: unknown) => JSON.stringify(value); +const modelSelection = { + instanceId: ProviderInstanceId.make(provider), + model: provider === "codex" ? "gpt-5.4" : "claude-sonnet-4-6", +}; + +mkdirSync(workspaceRoot, { recursive: true }); +writeFileSync(`${workspaceRoot}/README.md`, "# Turn retraction repro\n"); +execFileSync("git", ["init", "--quiet"], { cwd: workspaceRoot }); +execFileSync("git", ["config", "user.name", "T3 Retraction Repro"], { cwd: workspaceRoot }); +execFileSync("git", ["config", "user.email", "repro@t3.local"], { cwd: workspaceRoot }); +execFileSync("git", ["add", "README.md"], { cwd: workspaceRoot }); +execFileSync("git", ["commit", "--allow-empty", "--quiet", "-m", "repro baseline"], { + cwd: workspaceRoot, +}); const bootstrapResponse = await fetch(`${httpOrigin}/api/auth/browser-session`, { method: "POST", @@ -41,6 +75,71 @@ if (!bootstrapResponse.ok) { const sessionCookie = bootstrapResponse.headers.getSetCookie()[0]?.split(";", 1)[0]; if (!sessionCookie) throw new Error("pairing credential exchange returned no session cookie"); +const database = new DatabaseSync(`${baseDir}/userdata/state.sqlite`, { readOnly: true }); +const queryOne = (sql: string, ...params: ReadonlyArray): T | undefined => + database.prepare(sql).get(...params) as T | undefined; +const waitFor = async ( + label: string, + read: () => T | undefined, + complete: (value: T) => boolean, + timeoutMs = 120_000, +): Promise => { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + const value = read(); + if (value !== undefined && complete(value)) return value; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`timed out waiting for ${label}: ${JSON.stringify(read() ?? null)}`); +}; +const readTurn = (messageId: MessageId) => + queryOne<{ + state: string; + turnId: string | null; + checkpointTurnCount: number | null; + checkpointStatus: string | null; + }>( + `SELECT state, turn_id AS turnId, checkpoint_turn_count AS checkpointTurnCount, + checkpoint_status AS checkpointStatus + FROM projection_turns + WHERE thread_id = ? AND pending_message_id = ? + ORDER BY row_id DESC LIMIT 1`, + threadId, + messageId, + ); +const readSession = () => + queryOne<{ status: string; activeTurnId: string | null }>( + `SELECT status, active_turn_id AS activeTurnId + FROM projection_thread_sessions WHERE thread_id = ?`, + threadId, + ); +const readRetraction = () => + queryOne<{ + status: string; + providerSendState: string; + targetTurnId: string | null; + baselineTurnCount: number; + completedAt: string | null; + failedAt: string | null; + }>( + `SELECT status, provider_send_state AS providerSendState, + target_turn_id AS targetTurnId, baseline_turn_count AS baselineTurnCount, + completed_at AS completedAt, failed_at AS failedAt + FROM projection_turn_retractions WHERE request_id = ?`, + retractionRequestId, + ); +const readAssistantReply = (messageId: MessageId) => + queryOne<{ text: string }>( + `SELECT messages.text + FROM projection_turns AS turns + JOIN projection_thread_messages AS messages + ON messages.message_id = turns.assistant_message_id + WHERE turns.thread_id = ? AND turns.pending_message_id = ? + ORDER BY turns.row_id DESC LIMIT 1`, + threadId, + messageId, + )?.text; + const wsUrl = `${httpOrigin.replace(/^http/, "ws")}/ws`; const socketConstructorLayer = Layer.succeed( Socket.WebSocketConstructor, @@ -57,18 +156,31 @@ const makeClient = RpcClient.make(WsRpcGroup); const run = Effect.gen(function* () { const client = yield* makeClient; - const createdAt = new Date().toISOString(); + const dispatchTurn = (messageId: MessageId, text: string) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make(`repro-start-${messageId}`), + threadId, + message: { + messageId, + role: "user", + text, + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: hostNowIso(), + }); + + const createdAt = hostNowIso(); yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ type: "project.create", commandId: CommandId.make(`repro-create-project-${suffix}`), projectId, - title: `Retraction repro ${timing}`, + title: `Retraction repro ${provider} ${timing}`, workspaceRoot, createWorkspaceRootIfMissing: true, - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.4", - }, + defaultModelSelection: modelSelection, createdAt, }); yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ @@ -76,84 +188,109 @@ const run = Effect.gen(function* () { commandId: CommandId.make(`repro-create-thread-${suffix}`), threadId, projectId, - title: `Retraction repro ${timing}`, - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.4", - }, + title: `Retraction repro ${provider} ${timing}`, + modelSelection, runtimeMode: "full-access", interactionMode: "default", branch: null, worktreePath: null, createdAt, }); - yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ - type: "thread.turn.start", - commandId: CommandId.make(`repro-start-${suffix}`), - threadId, - message: { - messageId, - role: "user", - text: - timing === "mid-thinking" - ? "Use the shell to run sleep 20, then reply with exactly done." - : "Reply with exactly done.", - attachments: [], - }, - runtimeMode: "full-access", - interactionMode: "default", - createdAt: new Date().toISOString(), - }); + + yield* dispatchTurn( + baselineMessageId, + `Remember this exact token: ${retainedMarker}. Reply with exactly BASELINE_ACK.`, + ); + const baselineTurn = yield* Effect.promise(() => + waitFor( + "completed baseline turn and checkpoint", + () => readTurn(baselineMessageId), + (turn) => + turn.state === "completed" && + turn.checkpointTurnCount === 1 && + turn.checkpointStatus === "ready", + ), + ); + + yield* dispatchTurn( + retractedMessageId, + timing === "mid-thinking" + ? `Remember this exact token: ${retractedMarker}. Use the shell to run sleep 20, then reply with exactly RETRACTED_ACK.` + : `Remember this exact token: ${retractedMarker}. Reply with exactly RETRACTED_ACK.`, + ); + const retractedTurn = yield* Effect.promise(() => + waitFor( + "provider to start the retractable turn", + () => ({ session: readSession(), turn: readTurn(retractedMessageId) }), + (value) => + value.session?.status === "running" && + value.session.activeTurnId !== null && + value.turn?.turnId === value.session.activeTurnId, + ), + ); if (delayMs > 0) yield* Effect.sleep(`${delayMs} millis`); yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ type: "thread.turn.retract", commandId: retractionRequestId, threadId, - messageId, - createdAt: new Date().toISOString(), + messageId: retractedMessageId, + createdAt: hostNowIso(), }); -}).pipe(Effect.provide(protocolLayer)); + const retraction = yield* Effect.promise(() => + waitFor( + "terminal retraction", + readRetraction, + (row) => row.status === "completed" || row.status === "failed", + ), + ); + if (retraction.status !== "completed") { + throw new Error(`retraction failed: ${stringifyJson(retraction)}`); + } + if (retraction.providerSendState !== "claimed") { + throw new Error(`retraction did not exercise provider rollback: ${stringifyJson(retraction)}`); + } -await Effect.runPromise(Effect.scoped(run)); + yield* dispatchTurn( + interrogationMessageId, + "List every user message I have sent in this conversation before this one, verbatim.", + ); + const interrogationTurn = yield* Effect.promise(() => + waitFor( + "completed interrogation turn", + () => readTurn(interrogationMessageId), + (turn) => turn.state === "completed", + ), + ); + const reply = yield* Effect.promise(() => + waitFor( + "interrogation assistant reply", + () => readAssistantReply(interrogationMessageId), + (text) => text.length > 0, + ), + ); -const database = new DatabaseSync(`${baseDir}/userdata/state.sqlite`, { readOnly: true }); -const readRow = () => - database - .prepare( - `SELECT status, provider_send_state AS providerSendState, - target_turn_id AS targetTurnId, completed_at AS completedAt, failed_at AS failedAt - FROM projection_turn_retractions WHERE request_id = ?`, - ) - .get(retractionRequestId); -const readSession = () => - database - .prepare( - `SELECT status, active_turn_id AS activeTurnId, updated_at AS updatedAt - FROM projection_thread_sessions WHERE thread_id = ?`, - ) - .get(threadId); + return { baselineTurn, retractedTurn, retraction, interrogationTurn, reply }; +}).pipe(Effect.provide(protocolLayer)); -console.log(`scenario=${timing} delayMs=${delayMs} threadId=${threadId}`); -for (const elapsedMs of [0, 2_000, 35_000]) { - if (elapsedMs > 0) - await new Promise((resolve) => setTimeout(resolve, elapsedMs === 2_000 ? 2_000 : 33_000)); +try { + const result = await Effect.runPromise(Effect.scoped(run)); + const retractedMarkerStatus = result.reply.includes(retractedMarker) ? "PRESENT" : "ABSENT"; + const retainedMarkerStatus = result.reply.includes(retainedMarker) ? "PRESENT" : "ABSENT"; console.log( - JSON.stringify({ elapsedMs, retraction: readRow() ?? null, session: readSession() ?? null }), + `scenario provider=${provider} timing=${timing} session=live delayMs=${delayMs} threadId=${threadId}`, ); - const row = readRow() as { status?: string } | undefined; - if (row?.status === "completed" || row?.status === "failed") break; + console.log( + `rollback baselineTurnCount=${result.retraction.baselineTurnCount} providerSendState=${result.retraction.providerSendState} targetTurnId=${result.retraction.targetTurnId}`, + ); + console.log(`retractedMarker=${retractedMarker} status=${retractedMarkerStatus}`); + console.log(`retainedMarker=${retainedMarker} status=${retainedMarkerStatus}`); + console.log(`interrogationReplyChars=${result.reply.length}`); + if (retractedMarkerStatus !== "ABSENT" || retainedMarkerStatus !== "PRESENT") { + throw new Error( + `model-context gate failed: retracted=${retractedMarkerStatus} retained=${retainedMarkerStatus}`, + ); + } + console.log("gate=PASS"); +} finally { + database.close(); } - -const events = database - .prepare( - `SELECT sequence, event_type AS eventType, payload_json AS payload - FROM orchestration_events WHERE stream_id = ? ORDER BY sequence`, - ) - .all(threadId) - .map((row) => ({ - sequence: row.sequence, - eventType: row.eventType, - payload: JSON.parse(String(row.payload)), - })); -console.log(JSON.stringify({ terminalRetraction: readRow() ?? null, events }, null, 2)); -database.close(); diff --git a/apps/server/src/orchestration/Layers/TurnRetractionReactor.test.ts b/apps/server/src/orchestration/Layers/TurnRetractionReactor.test.ts index 68516aabb849..cedeff847d13 100644 --- a/apps/server/src/orchestration/Layers/TurnRetractionReactor.test.ts +++ b/apps/server/src/orchestration/Layers/TurnRetractionReactor.test.ts @@ -70,6 +70,7 @@ type MutableState = { row: ProjectionTurnRetraction; sessionStatus: OrchestrationSessionStatus | null; historyTurnCount: number; + rollbackTargetTurnId: TurnId | undefined; filesystemRestored: boolean; failRollbackAfterEffect: boolean; failRestoreAfterEffect: boolean; @@ -106,6 +107,7 @@ function makeState(providerSendState: ProjectionTurnRetraction["providerSendStat row: pendingRow(providerSendState), sessionStatus: providerSendState === "claimed" ? "running" : null, historyTurnCount: 2, + rollbackTargetTurnId: undefined, filesystemRestored: false, failRollbackAfterEffect: false, failRestoreAfterEffect: false, @@ -319,9 +321,10 @@ async function startHarness(state: MutableState) { }, }), rollbackConversation: () => unsupported(), - rollbackConversationTo: ({ retainedTurnCount }) => + rollbackConversationTo: ({ retainedTurnCount, targetTurnId }) => Effect.gen(function* () { state.order.push("rollback"); + state.rollbackTargetTurnId = targetTurnId; if (state.terminalRollbackFailure) { return yield* new ProviderValidationError({ operation: "ProviderService.rollbackConversationTo", @@ -449,6 +452,7 @@ it("drives claimed convergence from interrupt through a settlement event", async expect(state.order).toEqual(["interrupt", "rollback", "restore", "complete"]); expect(state.historyTurnCount).toBe(1); + expect(state.rollbackTargetTurnId).toBe(TURN_ID); expect(state.row.status).toBe("completed"); await stopHarness(harness); }); diff --git a/apps/server/src/orchestration/Layers/TurnRetractionReactor.ts b/apps/server/src/orchestration/Layers/TurnRetractionReactor.ts index 8677cd14f953..99eb7fbbecf5 100644 --- a/apps/server/src/orchestration/Layers/TurnRetractionReactor.ts +++ b/apps/server/src/orchestration/Layers/TurnRetractionReactor.ts @@ -440,6 +440,7 @@ export const makeTurnRetractionReactor = Effect.gen(function* () { .rollbackConversationTo({ threadId: row.threadId, retainedTurnCount: row.baselineTurnCount, + ...(targetTurnId !== null ? { targetTurnId } : {}), }) .pipe( Effect.mapError((error) => ({ diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 985f362fc8ac..3ae491edca3f 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -3723,6 +3723,48 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("recycles after an absolute rollback already at its retained boundary", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + assert.isDefined(adapter.rollbackThreadTo); + if (!adapter.rollbackThreadTo) return; + const absoluteSnapshot = yield* adapter.rollbackThreadTo(session.threadId, 0); + assert.equal(absoluteSnapshot.turns.length, 0); + const absoluteSessions = yield* adapter.listSessions(); + assert.deepEqual(absoluteSessions[0]?.resumeCursor, { + threadId: THREAD_ID, + turnCount: 0, + }); + yield* adapter.rollbackThread(session.threadId, 0); + const relativeSessions = yield* adapter.listSessions(); + assert.deepEqual(relativeSessions[0]?.resumeCursor, absoluteSessions[0]?.resumeCursor); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "replacement", + attachments: [], + }); + + assert.equal(harness.queries.length, 2); + assert.equal(harness.query.closeCalls, 1); + const restartInput = harness.getCreateQueryInputs()[1]; + assert.equal(restartInput?.options.resume, undefined); + assert.equal(restartInput?.options.resumeSessionAt, undefined); + const replacementPrompt = yield* Effect.promise(() => readFirstPromptText(restartInput)); + assert.equal(replacementPrompt, "replacement"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("clears resume state at turn zero and recycles before the next prompt", () => { const harness = makeHarness({ queryFactory: () => new FakeClaudeQuery(), diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 7e535eb50a27..618e4b412406 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -4500,18 +4500,27 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }, ); + const applyRollback = Effect.fn("applyClaudeRollback")(function* ( + context: ClaudeSessionContext, + nextLength: number, + sessionBaseTurnCount = context.sessionBaseTurnCount, + ) { + context.turns.splice(nextLength); + context.sessionBaseTurnCount = sessionBaseTurnCount; + const retainedTurn = context.turns.at(-1); + const sessionBase = readClaudeResumeState(context.restartInput.resumeCursor); + context.lastAssistantUuid = retainedTurn?.lastAssistantUuid ?? sessionBase?.resumeSessionAt; + context.resumeSessionId = retainedTurn ? context.resumeSessionId : sessionBase?.resume; + context.recycleBeforeNextTurn = true; + yield* updateResumeCursor(context); + return yield* snapshotThread(context); + }); + const rollbackThread: ClaudeAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")( function* (threadId, numTurns) { const context = yield* requireSession(threadId); const nextLength = Math.max(0, context.turns.length - numTurns); - context.turns.splice(nextLength); - const retainedTurn = context.turns.at(-1); - const sessionBase = readClaudeResumeState(context.restartInput.resumeCursor); - context.lastAssistantUuid = retainedTurn?.lastAssistantUuid ?? sessionBase?.resumeSessionAt; - context.resumeSessionId = retainedTurn ? context.resumeSessionId : sessionBase?.resume; - context.recycleBeforeNextTurn = true; - yield* updateResumeCursor(context); - return yield* snapshotThread(context); + return yield* applyRollback(context, nextLength); }, ); @@ -4536,18 +4545,16 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } const delta = lifetimeTurnCount - retainedTurnCount; const sessionLocalTurnCount = context.turns.length; - if (delta > 0) { - yield* rollbackThread(threadId, Math.min(delta, sessionLocalTurnCount)); - } - if (delta > sessionLocalTurnCount) { - // The requested boundary predates this SDK session. Claude only gives us - // the cursor that opened the session, not intermediate historical - // watermarks, so retain that oldest available resume position while - // moving the logical lifetime boundary to the requested count. - context.sessionBaseTurnCount = retainedTurnCount; - yield* updateResumeCursor(context); - } - const snapshot = yield* snapshotThread(context); + const nextLength = sessionLocalTurnCount - Math.min(delta, sessionLocalTurnCount); + // When the requested boundary predates this SDK session, Claude only gives + // us the cursor that opened the session, not intermediate historical + // watermarks. Retain that oldest available resume position while moving + // the logical lifetime boundary to the requested count. + const nextSessionBaseTurnCount = + delta > sessionLocalTurnCount ? retainedTurnCount : context.sessionBaseTurnCount; + // Always apply the rewind, including a zero completed-turn delta: the SDK + // query may still hold a just-interrupted prompt in process memory. + const snapshot = yield* applyRollback(context, nextLength, nextSessionBaseTurnCount); const resultingLifetimeTurnCount = context.sessionBaseTurnCount + snapshot.turns.length; if (resultingLifetimeTurnCount !== retainedTurnCount) { return yield* new ProviderAdapterRequestError({ diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 7270e5c26d29..2c3c7b4946f3 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -340,6 +340,41 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { }), ); + it.effect("rolls back from the target turn when absolute counts already match", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const threadId = asThreadId("target-rollback-thread"); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId, + runtimeMode: "full-access", + }); + const runtime = sessionRuntimeFactory.lastRuntime; + NodeAssert.ok(runtime); + NodeAssert.ok(adapter.rollbackThreadTo); + const snapshot = (turnIds: ReadonlyArray): CodexThreadSnapshot => ({ + threadId: "provider-thread-1", + turns: turnIds.map((id) => ({ id: asTurnId(id), items: [] })), + }); + const targetTurnId = asTurnId("turn-retracted"); + runtime.readThreadImpl + .mockResolvedValueOnce(snapshot(["turn-1", "turn-2", targetTurnId])) + .mockResolvedValue(snapshot(["turn-1", "turn-2"])); + runtime.rollbackThreadImpl.mockResolvedValue(snapshot(["turn-1", "turn-2"])); + + const first = yield* adapter.rollbackThreadTo(threadId, 3, targetTurnId); + NodeAssert.deepStrictEqual( + first.turns.map((turn) => turn.id), + [asTurnId("turn-1"), asTurnId("turn-2")], + ); + NodeAssert.deepStrictEqual(runtime.rollbackThreadImpl.mock.calls, [[1]]); + + const repeated = yield* adapter.rollbackThreadTo(threadId, 3, targetTurnId); + NodeAssert.equal(repeated.turns.length, 2); + NodeAssert.deepStrictEqual(runtime.rollbackThreadImpl.mock.calls, [[1]]); + }), + ); + it.effect("maps missing adapter sessions to ProviderAdapterSessionNotFoundError", () => Effect.gen(function* () { const adapter = yield* CodexAdapter; diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 072c03bb1b72..3697dc29ec12 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -24,6 +24,7 @@ import { type RuntimeTaskUsage, ProviderApprovalDecision, ThreadId, + type TurnId, ProviderSendTurnInput, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -92,6 +93,7 @@ interface CodexAdapterSessionContext { readonly scope: Scope.Closeable; readonly runtime: CodexSessionRuntimeShape; readonly eventFiber: Fiber.Fiber; + lastStartedTurnId: TurnId | undefined; stopped: boolean; } @@ -1756,6 +1758,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( scope: sessionScope, runtime, eventFiber, + lastStartedTurnId: undefined, stopped: false, }); sessionScopeTransferred = true; @@ -1812,7 +1815,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( input.modelSelection?.instanceId === boundInstanceId ? getCodexServiceTierOptionValue(input.modelSelection) : undefined; - return yield* session.runtime + const result = yield* session.runtime .sendTurn({ ...(input.input !== undefined ? { input: input.input } : {}), ...(input.modelSelection?.instanceId === boundInstanceId @@ -1828,6 +1831,8 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ...(codexAttachments.length > 0 ? { attachments: codexAttachments } : {}), }) .pipe(Effect.mapError((cause) => mapCodexRuntimeError(input.threadId, "turn/start", cause))); + session.lastStartedTurnId = result.turnId; + return result; }); const requireSession = Effect.fn("requireSession")(function* (threadId: ThreadId) { @@ -1892,7 +1897,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( const rollbackThreadTo: NonNullable = Effect.fn( "rollbackThreadTo", - )(function* (threadId, retainedTurnCount) { + )(function* (threadId, retainedTurnCount, targetTurnId) { if (!Number.isInteger(retainedTurnCount) || retainedTurnCount < 0) { return yield* new ProviderAdapterValidationError({ provider: PROVIDER, @@ -1900,19 +1905,66 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( issue: "retainedTurnCount must be an integer >= 0.", }); } + const session = yield* requireSession(threadId); const current = yield* readThread(threadId); - if (current.turns.length < retainedTurnCount) { + const targetIndex = + targetTurnId === undefined ? -1 : current.turns.findIndex((turn) => turn.id === targetTurnId); + if (targetTurnId === undefined && current.turns.length < retainedTurnCount) { return yield* new ProviderAdapterValidationError({ provider: PROVIDER, operation: "rollbackThreadTo", issue: `Provider history has ${current.turns.length} turns, below retained boundary ${retainedTurnCount}.`, }); } - const remainingDelta = current.turns.length - retainedTurnCount; + // A resumed Codex thread can report a durable turn count that already + // equals T3's checkpoint boundary while its live context still contains + // the just-interrupted turn. Prefer the concrete provider turn id over + // count arithmetic. If thread/read has not exposed a turn started by this + // runtime yet, one native rollback still removes that hidden live turn. + const hiddenCurrentRuntimeTarget = + targetTurnId !== undefined && targetIndex < 0 && session.lastStartedTurnId === targetTurnId; + const remainingDelta = + targetIndex >= 0 + ? current.turns.length - targetIndex + : hiddenCurrentRuntimeTarget + ? 1 + : targetTurnId !== undefined + ? 0 + : current.turns.length - retainedTurnCount; if (remainingDelta > 0) { yield* rollbackThread(threadId, remainingDelta); } const verified = yield* readThread(threadId); + if (targetTurnId !== undefined) { + if (verified.turns.some((turn) => turn.id === targetTurnId)) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "thread/rollback-to", + detail: `Provider history still contains retracted turn '${targetTurnId}'.`, + }); + } + const expectedVisibleTurns = + targetIndex >= 0 + ? current.turns.slice(0, targetIndex) + : hiddenCurrentRuntimeTarget + ? current.turns + : undefined; + if ( + expectedVisibleTurns !== undefined && + (verified.turns.length !== expectedVisibleTurns.length || + verified.turns.some((turn, index) => turn.id !== expectedVisibleTurns[index]?.id)) + ) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "thread/rollback-to", + detail: `Provider rollback for '${targetTurnId}' did not preserve the preceding turn history.`, + }); + } + if (session.lastStartedTurnId === targetTurnId) { + session.lastStartedTurnId = undefined; + } + return verified; + } if (verified.turns.length !== retainedTurnCount) { return yield* new ProviderAdapterRequestError({ provider: PROVIDER, diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 96c49bed97de..609aadca143f 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -13,6 +13,7 @@ import { ModelSelection, NonNegativeInt, ThreadId, + TurnId, ProviderInterruptTurnInput, ProviderRespondToRequestInput, ProviderRespondToUserInputInput, @@ -79,6 +80,7 @@ const ProviderRollbackConversationInput = Schema.Struct({ const ProviderRollbackConversationToInput = Schema.Struct({ threadId: ThreadId, retainedTurnCount: NonNegativeInt, + targetTurnId: Schema.optional(TurnId), }); function toValidationError( @@ -1094,7 +1096,11 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }); if (routed.adapter.rollbackThreadTo !== undefined) { - yield* routed.adapter.rollbackThreadTo(routed.threadId, input.retainedTurnCount); + yield* routed.adapter.rollbackThreadTo( + routed.threadId, + input.retainedTurnCount, + input.targetTurnId, + ); } else { // Compatibility conversion for providers that only expose relative // rollback: read the absolute length, apply only the remaining delta, diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index 2f19d41dafb0..489615be5735 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -123,6 +123,7 @@ export interface ProviderAdapterShape { readonly rollbackThreadTo?: ( threadId: ThreadId, retainedTurnCount: number, + targetTurnId?: TurnId, ) => Effect.Effect; /** diff --git a/apps/server/src/provider/Services/ProviderService.ts b/apps/server/src/provider/Services/ProviderService.ts index 7a8fe9461f73..71400712473e 100644 --- a/apps/server/src/provider/Services/ProviderService.ts +++ b/apps/server/src/provider/Services/ProviderService.ts @@ -23,6 +23,7 @@ import type { ProviderStopSessionInput, ThreadId, ProviderTurnStartResult, + TurnId, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; @@ -112,6 +113,7 @@ export interface ProviderServiceShape { readonly rollbackConversationTo: (input: { readonly threadId: ThreadId; readonly retainedTurnCount: number; + readonly targetTurnId?: TurnId; }) => Effect.Effect; /** From 3b64bd520d23a8607e27ad95708e22feabc9a87f Mon Sep 17 00:00:00 2001 From: Adam Firestone Date: Tue, 11 Aug 2026 14:47:02 -0500 Subject: [PATCH 14/18] fix(server): interrupt the concrete turn, not the request, when retracting An interrupt issued while the session was still starting consumed the request's once-only budget, so long Claude turns ran to natural completion before rollback. The reactor now waits for the concrete turn and interrupts per turn id; the repro gains long-response scenarios asserting retraction beats natural completion on both providers. Built with Claude Fable 5 via Claude Code, implementation by Codex (GPT-5.6-Sol). --- apps/server/scripts/turn-retraction-repro.ts | 180 ++++++++++++++++-- .../Layers/TurnRetractionReactor.test.ts | 62 +++++- .../Layers/TurnRetractionReactor.ts | 77 ++++++-- 3 files changed, 279 insertions(+), 40 deletions(-) diff --git a/apps/server/scripts/turn-retraction-repro.ts b/apps/server/scripts/turn-retraction-repro.ts index 00c204fcac6b..a3de3bceea93 100644 --- a/apps/server/scripts/turn-retraction-repro.ts +++ b/apps/server/scripts/turn-retraction-repro.ts @@ -18,36 +18,42 @@ import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; import * as Socket from "effect/unstable/socket/Socket"; type Provider = "claudeAgent" | "codex"; -type Timing = "immediate" | "mid-thinking"; +type Timing = "immediate" | "long-response" | "mid-thinking"; const [baseDir, httpOrigin, pairingCredential, providerArg = "codex", timingArg = "immediate"] = process.argv.slice(2); if (!baseDir || !httpOrigin || !pairingCredential) { throw new Error( - "usage: node apps/server/scripts/turn-retraction-repro.ts [codex|claudeAgent] [immediate|mid-thinking]", + "usage: node apps/server/scripts/turn-retraction-repro.ts [codex|claudeAgent] [immediate|mid-thinking|long-response]", ); } if (providerArg !== "codex" && providerArg !== "claudeAgent") { throw new Error(`unsupported provider '${providerArg}'`); } -if (timingArg !== "immediate" && timingArg !== "mid-thinking") { +if (timingArg !== "immediate" && timingArg !== "mid-thinking" && timingArg !== "long-response") { throw new Error(`unsupported timing '${timingArg}'`); } const provider: Provider = providerArg; const timing: Timing = timingArg; -const delayMs = timing === "mid-thinking" ? 2_000 : 0; +const delayMs = timing === "mid-thinking" ? 250 : timing === "long-response" ? 1_000 : 0; +const maxRetractionCompletionMs = 20_000; const suffix = crypto.randomUUID(); const projectId = ProjectId.make(`repro-project-${suffix}`); const threadId = ThreadId.make(`repro-thread-${suffix}`); const baselineMessageId = MessageId.make(`repro-baseline-message-${suffix}`); +const naturalControlMessageId = MessageId.make(`repro-natural-control-message-${suffix}`); +const setupRetractionMessageId = MessageId.make(`repro-setup-retraction-message-${suffix}`); const retractedMessageId = MessageId.make(`repro-retracted-message-${suffix}`); const interrogationMessageId = MessageId.make(`repro-interrogation-message-${suffix}`); +const setupRetractionRequestId = CommandId.make(`repro-setup-retract-${suffix}`); const retractionRequestId = CommandId.make(`repro-retract-${suffix}`); const workspaceRoot = `${baseDir}/workspace-${suffix}`; const retainedMarker = `KEPT_MARKER_${suffix}`; const retractedMarker = `REMOVED_MARKER_${suffix}`; +const setupRetractionMarker = `SETUP_REMOVED_MARKER_${suffix}`; const hostNowIso = () => new Date().toISOString(); +const hostNowMs = () => Date.now(); const stringifyJson = (value: unknown) => JSON.stringify(value); const modelSelection = { instanceId: ProviderInstanceId.make(provider), @@ -113,7 +119,14 @@ const readSession = () => FROM projection_thread_sessions WHERE thread_id = ?`, threadId, ); -const readRetraction = () => +const readProviderSendClaimed = (messageId: MessageId) => + queryOne<{ claimedAt: string }>( + `SELECT claimed_at AS claimedAt FROM provider_turn_send_claims + WHERE thread_id = ? AND message_id = ?`, + threadId, + messageId, + ); +const readRetraction = (requestId = retractionRequestId) => queryOne<{ status: string; providerSendState: string; @@ -126,7 +139,7 @@ const readRetraction = () => target_turn_id AS targetTurnId, baseline_turn_count AS baselineTurnCount, completed_at AS completedAt, failed_at AS failedAt FROM projection_turn_retractions WHERE request_id = ?`, - retractionRequestId, + requestId, ); const readAssistantReply = (messageId: MessageId) => queryOne<{ text: string }>( @@ -212,23 +225,109 @@ const run = Effect.gen(function* () { ), ); + if (timing === "immediate") { + yield* dispatchTurn( + setupRetractionMessageId, + `Remember this exact token: ${setupRetractionMarker}. Use the shell to run sleep 20, then reply with exactly SETUP_ACK.`, + ); + yield* Effect.promise(() => + waitFor( + "provider to start setup retraction turn", + () => ({ session: readSession(), turn: readTurn(setupRetractionMessageId) }), + (value) => + value.session?.status === "running" && + value.session.activeTurnId !== null && + value.turn?.turnId === value.session.activeTurnId, + ), + ); + yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.retract", + commandId: setupRetractionRequestId, + threadId, + messageId: setupRetractionMessageId, + createdAt: hostNowIso(), + }); + const setupRetraction = yield* Effect.promise(() => + waitFor( + "completed setup retraction", + () => readRetraction(setupRetractionRequestId), + (row) => row.status === "completed" || row.status === "failed", + ), + ); + if (setupRetraction.status !== "completed") { + throw new Error(`setup retraction failed: ${stringifyJson(setupRetraction)}`); + } + } + + let naturalCompletionMs: number | null = null; + let expectedRetractionBaselineTurnCount = 1; + if (timing === "long-response") { + const naturalStartedAtMs = hostNowMs(); + yield* dispatchTurn( + naturalControlMessageId, + "Count from 1 to 400, one number per line, no other text.", + ); + yield* Effect.promise(() => + waitFor( + "natural long-response control and checkpoint", + () => readTurn(naturalControlMessageId), + (turn) => + turn.state === "completed" && + turn.checkpointTurnCount === 2 && + turn.checkpointStatus === "ready", + ), + ); + naturalCompletionMs = hostNowMs() - naturalStartedAtMs; + expectedRetractionBaselineTurnCount = 2; + } + yield* dispatchTurn( retractedMessageId, timing === "mid-thinking" ? `Remember this exact token: ${retractedMarker}. Use the shell to run sleep 20, then reply with exactly RETRACTED_ACK.` - : `Remember this exact token: ${retractedMarker}. Reply with exactly RETRACTED_ACK.`, - ); - const retractedTurn = yield* Effect.promise(() => - waitFor( - "provider to start the retractable turn", - () => ({ session: readSession(), turn: readTurn(retractedMessageId) }), - (value) => - value.session?.status === "running" && - value.session.activeTurnId !== null && - value.turn?.turnId === value.session.activeTurnId, - ), + : timing === "long-response" + ? `Remember this exact token: ${retractedMarker}. Then count from 1 to 400, one number per line, no other text.` + : `Remember this exact token: ${retractedMarker}. Reply with exactly RETRACTED_ACK.`, ); + let sessionStatusAtRetraction: string; + if (timing === "immediate") { + const claimedSend = yield* Effect.promise(() => + waitFor( + "claimed provider send before immediate retraction", + () => ({ claim: readProviderSendClaimed(retractedMessageId), session: readSession() }), + (value) => + value.claim !== undefined && + (value.session?.status === "starting" || value.session?.status === "running"), + ), + ); + sessionStatusAtRetraction = claimedSend.session?.status ?? "missing"; + } else { + const startedTurn = yield* Effect.promise(() => + waitFor( + "provider to start the retractable turn", + () => ({ session: readSession(), turn: readTurn(retractedMessageId) }), + (value) => + value.session?.status === "running" && + value.session.activeTurnId !== null && + value.turn?.turnId === value.session.activeTurnId, + ), + ); + sessionStatusAtRetraction = startedTurn.session?.status ?? "missing"; + } if (delayMs > 0) yield* Effect.sleep(`${delayMs} millis`); + const beforeRetraction = yield* Effect.sync(() => ({ + session: readSession(), + turn: readTurn(retractedMessageId), + })); + if ( + timing === "long-response" && + (beforeRetraction.session?.status !== "running" || beforeRetraction.turn?.state !== "running") + ) { + throw new Error( + `long response completed before retraction was requested: ${stringifyJson(beforeRetraction)}`, + ); + } + const retractionRequestedAtMs = hostNowMs(); yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ type: "thread.turn.retract", commandId: retractionRequestId, @@ -243,12 +342,28 @@ const run = Effect.gen(function* () { (row) => row.status === "completed" || row.status === "failed", ), ); + const retractionCompletionMs = hostNowMs() - retractionRequestedAtMs; if (retraction.status !== "completed") { throw new Error(`retraction failed: ${stringifyJson(retraction)}`); } if (retraction.providerSendState !== "claimed") { throw new Error(`retraction did not exercise provider rollback: ${stringifyJson(retraction)}`); } + if (retraction.baselineTurnCount !== expectedRetractionBaselineTurnCount) { + throw new Error( + `unexpected rollback boundary: expected ${expectedRetractionBaselineTurnCount}, got ${retraction.baselineTurnCount}`, + ); + } + if ( + timing === "long-response" && + (naturalCompletionMs === null || + retractionCompletionMs >= maxRetractionCompletionMs || + retractionCompletionMs >= naturalCompletionMs) + ) { + throw new Error( + `long-response timing gate failed: retractionCompletionMs=${retractionCompletionMs} naturalCompletionMs=${naturalCompletionMs} maxRetractionCompletionMs=${maxRetractionCompletionMs}`, + ); + } yield* dispatchTurn( interrogationMessageId, @@ -269,25 +384,48 @@ const run = Effect.gen(function* () { ), ); - return { baselineTurn, retractedTurn, retraction, interrogationTurn, reply }; + return { + baselineTurn, + retraction, + interrogationTurn, + reply, + naturalCompletionMs, + retractionCompletionMs, + sessionStatusAtRetraction, + }; }).pipe(Effect.provide(protocolLayer)); try { const result = await Effect.runPromise(Effect.scoped(run)); const retractedMarkerStatus = result.reply.includes(retractedMarker) ? "PRESENT" : "ABSENT"; const retainedMarkerStatus = result.reply.includes(retainedMarker) ? "PRESENT" : "ABSENT"; + const setupRetractionMarkerStatus = result.reply.includes(setupRetractionMarker) + ? "PRESENT" + : "ABSENT"; console.log( - `scenario provider=${provider} timing=${timing} session=live delayMs=${delayMs} threadId=${threadId}`, + `scenario provider=${provider} timing=${timing} sessionAtRetraction=${result.sessionStatusAtRetraction} delayMs=${delayMs} threadId=${threadId}`, ); console.log( `rollback baselineTurnCount=${result.retraction.baselineTurnCount} providerSendState=${result.retraction.providerSendState} targetTurnId=${result.retraction.targetTurnId}`, ); + console.log( + `timings retractionCompletionMs=${result.retractionCompletionMs} naturalCompletionMs=${result.naturalCompletionMs ?? "n/a"} maxRetractionCompletionMs=${maxRetractionCompletionMs}`, + ); console.log(`retractedMarker=${retractedMarker} status=${retractedMarkerStatus}`); + if (timing === "immediate") { + console.log( + `setupRetractedMarker=${setupRetractionMarker} status=${setupRetractionMarkerStatus}`, + ); + } console.log(`retainedMarker=${retainedMarker} status=${retainedMarkerStatus}`); console.log(`interrogationReplyChars=${result.reply.length}`); - if (retractedMarkerStatus !== "ABSENT" || retainedMarkerStatus !== "PRESENT") { + if ( + retractedMarkerStatus !== "ABSENT" || + retainedMarkerStatus !== "PRESENT" || + (timing === "immediate" && setupRetractionMarkerStatus !== "ABSENT") + ) { throw new Error( - `model-context gate failed: retracted=${retractedMarkerStatus} retained=${retainedMarkerStatus}`, + `model-context gate failed: retracted=${retractedMarkerStatus} setupRetracted=${setupRetractionMarkerStatus} retained=${retainedMarkerStatus}`, ); } console.log("gate=PASS"); diff --git a/apps/server/src/orchestration/Layers/TurnRetractionReactor.test.ts b/apps/server/src/orchestration/Layers/TurnRetractionReactor.test.ts index cedeff847d13..6a5947d79d67 100644 --- a/apps/server/src/orchestration/Layers/TurnRetractionReactor.test.ts +++ b/apps/server/src/orchestration/Layers/TurnRetractionReactor.test.ts @@ -69,6 +69,7 @@ const BASELINE_REF = CheckpointRef.make(`refs/t3/checkpoints/${THREAD_ID}/1`); type MutableState = { row: ProjectionTurnRetraction; sessionStatus: OrchestrationSessionStatus | null; + activeTurnId: TurnId | null; historyTurnCount: number; rollbackTargetTurnId: TurnId | undefined; filesystemRestored: boolean; @@ -78,6 +79,7 @@ type MutableState = { terminalRollbackFailure: boolean; interruptAcknowledgementHangs: boolean; readonly order: string[]; + readonly interruptedTurnIds: Array; readonly dispatched: OrchestrationCommand[]; }; @@ -106,6 +108,7 @@ function makeState(providerSendState: ProjectionTurnRetraction["providerSendStat return { row: pendingRow(providerSendState), sessionStatus: providerSendState === "claimed" ? "running" : null, + activeTurnId: providerSendState === "claimed" ? TURN_ID : null, historyTurnCount: 2, rollbackTargetTurnId: undefined, filesystemRestored: false, @@ -115,6 +118,7 @@ function makeState(providerSendState: ProjectionTurnRetraction["providerSendStat terminalRollbackFailure: false, interruptAcknowledgementHangs: false, order: [], + interruptedTurnIds: [], dispatched: [], }; } @@ -168,7 +172,7 @@ function projectedThread(state: MutableState): OrchestrationThread { providerName: "Codex", providerInstanceId: ProviderInstanceId.make("codex"), runtimeMode: "full-access", - activeTurnId: state.sessionStatus === "running" ? TURN_ID : null, + activeTurnId: state.sessionStatus === "running" ? state.activeTurnId : null, lastError: null, updatedAt: NOW, }, @@ -295,9 +299,10 @@ async function startHarness(state: MutableState) { const provider = ProviderService.of({ startSession: () => unsupported(), sendTurn: () => unsupported(), - interruptTurn: () => + interruptTurn: ({ turnId }) => Effect.sync(() => { state.order.push("interrupt"); + state.interruptedTurnIds.push(turnId); if (state.interruptAcknowledgementHangs) { // Models Codex emitting turn/completed while its turn/interrupt RPC // response remains unresolved. @@ -393,6 +398,8 @@ async function startHarness(state: MutableState) { scope, emitDomain: (event: OrchestrationEvent) => runtime.runPromise(PubSub.publish(domainEvents, event)), + emitRuntime: (event: ProviderRuntimeEvent) => + runtime.runPromise(PubSub.publish(runtimeEvents, event)), retryTick: () => runtime.runPromise(Queue.offer(retryTicks, undefined)), }; } @@ -457,6 +464,57 @@ it("drives claimed convergence from interrupt through a settlement event", async await stopHarness(harness); }); +it("waits through starting and interrupts the concrete turn after it starts", async () => { + const state = makeState("claimed"); + state.sessionStatus = "starting"; + state.activeTurnId = null; + const harness = await startHarness(state); + + expect(state.order).toEqual([]); + await harness.retryTick(); + await harness.runtime.runPromise(harness.reactor.drain); + expect(state.order).toEqual([]); + + state.sessionStatus = "running"; + state.activeTurnId = TURN_ID; + await harness.emitRuntime({ + type: "turn.started", + eventId: EventId.make("evt-target-turn-started"), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt: NOW, + threadId: THREAD_ID, + turnId: TURN_ID, + payload: {}, + }); + await harness.runtime.runPromise(Effect.yieldNow); + await harness.runtime.runPromise(harness.reactor.drain); + + expect(state.order).toEqual(["interrupt"]); + expect(state.interruptedTurnIds).toEqual([TURN_ID]); + + await harness.retryTick(); + await harness.runtime.runPromise(harness.reactor.drain); + expect(state.order).toEqual(["interrupt"]); + + state.sessionStatus = "ready"; + state.activeTurnId = null; + await harness.retryTick(); + await harness.runtime.runPromise(harness.reactor.drain); + expect(state.order).toEqual(["interrupt", "rollback", "restore", "complete"]); + await stopHarness(harness); +}); + +it("does not interrupt a foreign active turn", async () => { + const state = makeState("claimed"); + state.activeTurnId = TurnId.make("turn-foreign"); + const harness = await startHarness(state); + + expect(state.order).toEqual([]); + expect(state.interruptedTurnIds).toEqual([]); + expect(state.row.status).toBe("requested"); + await stopHarness(harness); +}); + it("converges when settlement is projected but the interrupt acknowledgement hangs", async () => { const state = makeState("claimed"); state.interruptAcknowledgementHangs = true; diff --git a/apps/server/src/orchestration/Layers/TurnRetractionReactor.ts b/apps/server/src/orchestration/Layers/TurnRetractionReactor.ts index 99eb7fbbecf5..63310ff545af 100644 --- a/apps/server/src/orchestration/Layers/TurnRetractionReactor.ts +++ b/apps/server/src/orchestration/Layers/TurnRetractionReactor.ts @@ -100,7 +100,24 @@ export const makeTurnRetractionReactor = Effect.gen(function* () { const providerService = yield* ProviderService; const checkpointStore = yield* CheckpointStore.CheckpointStore; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const interruptedRequestIds = new Set(); + const interruptedTurnIdsByRequest = new Map>(); + + const clearIssuedInterrupts = (requestId: string) => + interruptedTurnIdsByRequest.delete(requestId); + const hasIssuedInterrupt = (requestId: string, turnId: TurnId) => + interruptedTurnIdsByRequest.get(requestId)?.has(turnId) === true; + const markInterruptIssued = (requestId: string, turnId: TurnId) => { + const issuedTurnIds = interruptedTurnIdsByRequest.get(requestId) ?? new Set(); + issuedTurnIds.add(turnId); + interruptedTurnIdsByRequest.set(requestId, issuedTurnIds); + }; + const clearIssuedInterrupt = (requestId: string, turnId: TurnId) => { + const issuedTurnIds = interruptedTurnIdsByRequest.get(requestId); + issuedTurnIds?.delete(turnId); + if (issuedTurnIds?.size === 0) { + interruptedTurnIdsByRequest.delete(requestId); + } + }; const commandId = (tag: string) => crypto.randomUUIDv4.pipe(Effect.map((uuid) => CommandId.make(`server:${tag}:${uuid}`))); @@ -307,7 +324,7 @@ export const makeTurnRetractionReactor = Effect.gen(function* () { })), ); if (Option.isNone(current) || current.value.status !== "requested") { - interruptedRequestIds.delete(requestedRow.requestId); + clearIssuedInterrupts(requestedRow.requestId); yield* logConvergence(requestedRow, "eligibility", "skipped", { reason: Option.isNone(current) ? "retraction row no longer exists" @@ -335,7 +352,7 @@ export const makeTurnRetractionReactor = Effect.gen(function* () { })), ); if (Option.isNone(reconciled) || reconciled.value.status !== "requested") { - interruptedRequestIds.delete(row.requestId); + clearIssuedInterrupts(row.requestId); yield* logConvergence(row, "eligibility", "skipped", { action: "cancel-provider-send", reason: Option.isNone(reconciled) @@ -358,7 +375,7 @@ export const makeTurnRetractionReactor = Effect.gen(function* () { if (row.providerSendState === "cancelled") { yield* restoreFilesystem(row, true); yield* dispatchCompletion(row, targetTurnId); - interruptedRequestIds.delete(row.requestId); + clearIssuedInterrupts(row.requestId); yield* logConvergence(row, "cleanup", "completed", { action: "restore-filesystem-and-complete-cancelled-send", }); @@ -381,18 +398,44 @@ export const makeTurnRetractionReactor = Effect.gen(function* () { }); } - const sessionActive = + const sessionWasActive = thread.session?.status === "starting" || thread.session?.status === "running"; - if (sessionActive) { - const interruptAlreadyRequested = interruptedRequestIds.has(row.requestId); + if ( + thread.session?.status === "starting" || + (thread.session?.status === "running" && thread.session.activeTurnId === null) + ) { + yield* logConvergence(row, "settlement", "pending", { + action: "awaiting-turn-start", + reason: `projected session is '${thread.session.status}' with no interruptible active turn`, + }); + return; + } + if (thread.session?.status === "running") { + const activeTurnId = thread.session.activeTurnId; + if (targetTurnId === null) { + yield* logConvergence(row, "settlement", "pending", { + action: "awaiting-target-turn-resolution", + reason: `active turn '${activeTurnId}' cannot yet be correlated to message '${row.messageId}'`, + }); + return; + } + if (activeTurnId !== targetTurnId) { + yield* logConvergence(row, "settlement", "pending", { + action: "active-turn-diverged", + reason: `active turn '${activeTurnId}' differs from retraction target '${targetTurnId}'; foreign turn will not be interrupted`, + }); + return; + } + + const interruptAlreadyRequested = hasIssuedInterrupt(row.requestId, targetTurnId); const interruptAcknowledged = interruptAlreadyRequested ? undefined - : yield* Effect.sync(() => interruptedRequestIds.add(row.requestId)).pipe( + : yield* Effect.sync(() => markInterruptIssued(row.requestId, targetTurnId)).pipe( Effect.andThen( providerService .interruptTurn({ threadId: row.threadId, - ...(targetTurnId !== null ? { turnId: targetTurnId } : {}), + turnId: targetTurnId, }) .pipe( Effect.mapError((error) => ({ @@ -401,7 +444,7 @@ export const makeTurnRetractionReactor = Effect.gen(function* () { detail: failureDetail(error), })), Effect.tapError(() => - Effect.sync(() => interruptedRequestIds.delete(row.requestId)), + Effect.sync(() => clearIssuedInterrupt(row.requestId, targetTurnId)), ), Effect.timeoutOption(interruptTimeout), Effect.map(Option.isSome), @@ -426,11 +469,11 @@ export const makeTurnRetractionReactor = Effect.gen(function* () { ) { yield* logConvergence(row, "settlement", "pending", { action: interruptAlreadyRequested - ? "interrupt-already-requested" + ? "turn-interrupt-already-requested" : interruptAcknowledged - ? "interrupt-acknowledged" - : "interrupt-timed-out", - reason: `projected session remains '${afterInterrupt.session.status}'`, + ? "turn-interrupt-acknowledged" + : "turn-interrupt-timed-out", + reason: `projected session remains '${afterInterrupt.session.status}' for target turn '${targetTurnId}'`, }); return; } @@ -451,9 +494,9 @@ export const makeTurnRetractionReactor = Effect.gen(function* () { ); yield* restoreFilesystem(row, false); yield* dispatchCompletion(row, targetTurnId); - interruptedRequestIds.delete(row.requestId); + clearIssuedInterrupts(row.requestId); yield* logConvergence(row, "cleanup", "completed", { - action: sessionActive + action: sessionWasActive ? "interrupt-settled-provider-rollback-restore-and-complete" : "provider-rollback-restore-and-complete", }); @@ -478,7 +521,7 @@ export const makeTurnRetractionReactor = Effect.gen(function* () { }) : appendTerminalFailure(latest.value, failure).pipe( Effect.tap(() => - Effect.sync(() => interruptedRequestIds.delete(latest.value.requestId)), + Effect.sync(() => clearIssuedInterrupts(latest.value.requestId)), ), Effect.catchCause((cause) => Effect.logWarning("failed to persist terminal turn retraction failure", { From ef56e93efded0874a1e6b7201576e25db4e0f8ab Mon Sep 17 00:00:00 2001 From: Adam Firestone Date: Tue, 11 Aug 2026 15:29:19 -0500 Subject: [PATCH 15/18] fix(server): latch early Claude interrupts and re-issue on a bounded cadence Interrupts landing before the SDK begins processing were silently swallowed; the adapter now replays a latched interrupt on system/status=requesting, and the reactor re-issues every 2s while the target turn stays projected-running. Double-pop gate: 8/8 Claude retractions <= 723ms (was up to 18.9s). Built with Claude Fable 5 via Claude Code, implementation by Codex (GPT-5.6-Sol). --- apps/server/scripts/turn-retraction-repro.ts | 383 ++++++++++++------ .../Layers/TurnRetractionReactor.test.ts | 40 +- .../Layers/TurnRetractionReactor.ts | 133 ++++-- .../src/provider/Layers/ClaudeAdapter.test.ts | 63 +++ .../src/provider/Layers/ClaudeAdapter.ts | 46 ++- 5 files changed, 490 insertions(+), 175 deletions(-) diff --git a/apps/server/scripts/turn-retraction-repro.ts b/apps/server/scripts/turn-retraction-repro.ts index a3de3bceea93..b753efb59a1d 100644 --- a/apps/server/scripts/turn-retraction-repro.ts +++ b/apps/server/scripts/turn-retraction-repro.ts @@ -18,19 +18,24 @@ import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; import * as Socket from "effect/unstable/socket/Socket"; type Provider = "claudeAgent" | "codex"; -type Timing = "immediate" | "long-response" | "mid-thinking"; +type Timing = "double-pop" | "immediate" | "long-response" | "mid-thinking"; const [baseDir, httpOrigin, pairingCredential, providerArg = "codex", timingArg = "immediate"] = process.argv.slice(2); if (!baseDir || !httpOrigin || !pairingCredential) { throw new Error( - "usage: node apps/server/scripts/turn-retraction-repro.ts [codex|claudeAgent] [immediate|mid-thinking|long-response]", + "usage: node apps/server/scripts/turn-retraction-repro.ts [codex|claudeAgent] [immediate|mid-thinking|long-response|double-pop]", ); } if (providerArg !== "codex" && providerArg !== "claudeAgent") { throw new Error(`unsupported provider '${providerArg}'`); } -if (timingArg !== "immediate" && timingArg !== "mid-thinking" && timingArg !== "long-response") { +if ( + timingArg !== "immediate" && + timingArg !== "mid-thinking" && + timingArg !== "long-response" && + timingArg !== "double-pop" +) { throw new Error(`unsupported timing '${timingArg}'`); } @@ -38,6 +43,8 @@ const provider: Provider = providerArg; const timing: Timing = timingArg; const delayMs = timing === "mid-thinking" ? 250 : timing === "long-response" ? 1_000 : 0; const maxRetractionCompletionMs = 20_000; +const maxDoublePopCompletionMs = 5_000; +const doublePopIterations = 4; const suffix = crypto.randomUUID(); const projectId = ProjectId.make(`repro-project-${suffix}`); const threadId = ThreadId.make(`repro-thread-${suffix}`); @@ -225,144 +232,248 @@ const run = Effect.gen(function* () { ), ); - if (timing === "immediate") { + type RetractionRow = NonNullable>; + const doublePopAttempts: Array<{ + readonly iteration: number; + readonly phase: "first" | "resend"; + readonly marker: string; + readonly delayMs: number; + readonly sessionStatusAtRetraction: string; + readonly requestToCompleteMs: number; + readonly sendToCompleteMs: number; + }> = []; + let retraction: RetractionRow | undefined; + let naturalCompletionMs: number | null = null; + let retractionCompletionMs: number | null = null; + let sessionStatusAtRetraction = "n/a"; + + if (timing === "double-pop") { + for (let iteration = 1; iteration <= doublePopIterations; iteration += 1) { + for (const phase of ["first", "resend"] as const) { + const phaseDelayMs = phase === "first" ? 1_000 : 300; + const marker = `DOUBLE_REMOVED_MARKER_${suffix}_${iteration}_${phase}`; + const messageId = MessageId.make(`repro-double-${suffix}-${iteration}-${phase}`); + const requestId = CommandId.make(`repro-double-retract-${suffix}-${iteration}-${phase}`); + const sentAtMs = hostNowMs(); + yield* dispatchTurn( + messageId, + `Remember this exact token: ${marker}. Then count from 1 to 400, one number per line, no other text.`, + ); + yield* Effect.promise(() => + waitFor( + `double-pop ${iteration} ${phase} turn start`, + () => ({ session: readSession(), turn: readTurn(messageId) }), + (value) => + value.session?.status === "running" && + value.session.activeTurnId !== null && + value.turn?.state === "running" && + value.turn.turnId === value.session.activeTurnId, + ), + ); + yield* Effect.sleep(`${phaseDelayMs} millis`); + const beforeRetraction = yield* Effect.sync(() => ({ + session: readSession(), + turn: readTurn(messageId), + })); + if ( + beforeRetraction.session?.status !== "running" || + beforeRetraction.turn?.state !== "running" + ) { + throw new Error( + `double-pop ${iteration} ${phase} completed before retraction: ${stringifyJson(beforeRetraction)}`, + ); + } + const requestedAtMs = hostNowMs(); + yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.retract", + commandId: requestId, + threadId, + messageId, + createdAt: hostNowIso(), + }); + const attemptRetraction = yield* Effect.promise(() => + waitFor( + `double-pop ${iteration} ${phase} retraction`, + () => readRetraction(requestId), + (row) => row.status === "completed" || row.status === "failed", + ), + ); + const completedAtMs = hostNowMs(); + const requestToCompleteMs = completedAtMs - requestedAtMs; + const sendToCompleteMs = completedAtMs - sentAtMs; + if ( + attemptRetraction.status !== "completed" || + attemptRetraction.providerSendState !== "claimed" || + attemptRetraction.baselineTurnCount !== 1 + ) { + throw new Error( + `double-pop ${iteration} ${phase} rollback failed: ${stringifyJson(attemptRetraction)}`, + ); + } + if ( + requestToCompleteMs > maxDoublePopCompletionMs || + sendToCompleteMs > maxDoublePopCompletionMs + ) { + throw new Error( + `double-pop ${iteration} ${phase} timing gate failed: requestToCompleteMs=${requestToCompleteMs} sendToCompleteMs=${sendToCompleteMs} maxDoublePopCompletionMs=${maxDoublePopCompletionMs}`, + ); + } + doublePopAttempts.push({ + iteration, + phase, + marker, + delayMs: phaseDelayMs, + sessionStatusAtRetraction: beforeRetraction.session.status, + requestToCompleteMs, + sendToCompleteMs, + }); + retraction = attemptRetraction; + } + } + } else { + if (timing === "immediate") { + yield* dispatchTurn( + setupRetractionMessageId, + `Remember this exact token: ${setupRetractionMarker}. Use the shell to run sleep 20, then reply with exactly SETUP_ACK.`, + ); + yield* Effect.promise(() => + waitFor( + "provider to start setup retraction turn", + () => ({ session: readSession(), turn: readTurn(setupRetractionMessageId) }), + (value) => + value.session?.status === "running" && + value.session.activeTurnId !== null && + value.turn?.turnId === value.session.activeTurnId, + ), + ); + yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.retract", + commandId: setupRetractionRequestId, + threadId, + messageId: setupRetractionMessageId, + createdAt: hostNowIso(), + }); + const setupRetraction = yield* Effect.promise(() => + waitFor( + "completed setup retraction", + () => readRetraction(setupRetractionRequestId), + (row) => row.status === "completed" || row.status === "failed", + ), + ); + if (setupRetraction.status !== "completed") { + throw new Error(`setup retraction failed: ${stringifyJson(setupRetraction)}`); + } + } + + let expectedRetractionBaselineTurnCount = 1; + if (timing === "long-response") { + const naturalStartedAtMs = hostNowMs(); + yield* dispatchTurn( + naturalControlMessageId, + "Count from 1 to 400, one number per line, no other text.", + ); + yield* Effect.promise(() => + waitFor( + "natural long-response control and checkpoint", + () => readTurn(naturalControlMessageId), + (turn) => + turn.state === "completed" && + turn.checkpointTurnCount === 2 && + turn.checkpointStatus === "ready", + ), + ); + naturalCompletionMs = hostNowMs() - naturalStartedAtMs; + expectedRetractionBaselineTurnCount = 2; + } + yield* dispatchTurn( - setupRetractionMessageId, - `Remember this exact token: ${setupRetractionMarker}. Use the shell to run sleep 20, then reply with exactly SETUP_ACK.`, - ); - yield* Effect.promise(() => - waitFor( - "provider to start setup retraction turn", - () => ({ session: readSession(), turn: readTurn(setupRetractionMessageId) }), - (value) => - value.session?.status === "running" && - value.session.activeTurnId !== null && - value.turn?.turnId === value.session.activeTurnId, - ), + retractedMessageId, + timing === "mid-thinking" + ? `Remember this exact token: ${retractedMarker}. Use the shell to run sleep 20, then reply with exactly RETRACTED_ACK.` + : timing === "long-response" + ? `Remember this exact token: ${retractedMarker}. Then count from 1 to 400, one number per line, no other text.` + : `Remember this exact token: ${retractedMarker}. Reply with exactly RETRACTED_ACK.`, ); + if (timing === "immediate") { + const claimedSend = yield* Effect.promise(() => + waitFor( + "claimed provider send before immediate retraction", + () => ({ claim: readProviderSendClaimed(retractedMessageId), session: readSession() }), + (value) => + value.claim !== undefined && + (value.session?.status === "starting" || value.session?.status === "running"), + ), + ); + sessionStatusAtRetraction = claimedSend.session?.status ?? "missing"; + } else { + const startedTurn = yield* Effect.promise(() => + waitFor( + "provider to start the retractable turn", + () => ({ session: readSession(), turn: readTurn(retractedMessageId) }), + (value) => + value.session?.status === "running" && + value.session.activeTurnId !== null && + value.turn?.turnId === value.session.activeTurnId, + ), + ); + sessionStatusAtRetraction = startedTurn.session?.status ?? "missing"; + } + if (delayMs > 0) yield* Effect.sleep(`${delayMs} millis`); + const beforeRetraction = yield* Effect.sync(() => ({ + session: readSession(), + turn: readTurn(retractedMessageId), + })); + if ( + timing === "long-response" && + (beforeRetraction.session?.status !== "running" || beforeRetraction.turn?.state !== "running") + ) { + throw new Error( + `long response completed before retraction was requested: ${stringifyJson(beforeRetraction)}`, + ); + } + const retractionRequestedAtMs = hostNowMs(); yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ type: "thread.turn.retract", - commandId: setupRetractionRequestId, + commandId: retractionRequestId, threadId, - messageId: setupRetractionMessageId, + messageId: retractedMessageId, createdAt: hostNowIso(), }); - const setupRetraction = yield* Effect.promise(() => + retraction = yield* Effect.promise(() => waitFor( - "completed setup retraction", - () => readRetraction(setupRetractionRequestId), + "terminal retraction", + readRetraction, (row) => row.status === "completed" || row.status === "failed", ), ); - if (setupRetraction.status !== "completed") { - throw new Error(`setup retraction failed: ${stringifyJson(setupRetraction)}`); + retractionCompletionMs = hostNowMs() - retractionRequestedAtMs; + if (retraction.status !== "completed") { + throw new Error(`retraction failed: ${stringifyJson(retraction)}`); + } + if (retraction.providerSendState !== "claimed") { + throw new Error( + `retraction did not exercise provider rollback: ${stringifyJson(retraction)}`, + ); + } + if (retraction.baselineTurnCount !== expectedRetractionBaselineTurnCount) { + throw new Error( + `unexpected rollback boundary: expected ${expectedRetractionBaselineTurnCount}, got ${retraction.baselineTurnCount}`, + ); + } + if ( + timing === "long-response" && + (naturalCompletionMs === null || + retractionCompletionMs >= maxRetractionCompletionMs || + retractionCompletionMs >= naturalCompletionMs) + ) { + throw new Error( + `long-response timing gate failed: retractionCompletionMs=${retractionCompletionMs} naturalCompletionMs=${naturalCompletionMs} maxRetractionCompletionMs=${maxRetractionCompletionMs}`, + ); } } - let naturalCompletionMs: number | null = null; - let expectedRetractionBaselineTurnCount = 1; - if (timing === "long-response") { - const naturalStartedAtMs = hostNowMs(); - yield* dispatchTurn( - naturalControlMessageId, - "Count from 1 to 400, one number per line, no other text.", - ); - yield* Effect.promise(() => - waitFor( - "natural long-response control and checkpoint", - () => readTurn(naturalControlMessageId), - (turn) => - turn.state === "completed" && - turn.checkpointTurnCount === 2 && - turn.checkpointStatus === "ready", - ), - ); - naturalCompletionMs = hostNowMs() - naturalStartedAtMs; - expectedRetractionBaselineTurnCount = 2; - } - - yield* dispatchTurn( - retractedMessageId, - timing === "mid-thinking" - ? `Remember this exact token: ${retractedMarker}. Use the shell to run sleep 20, then reply with exactly RETRACTED_ACK.` - : timing === "long-response" - ? `Remember this exact token: ${retractedMarker}. Then count from 1 to 400, one number per line, no other text.` - : `Remember this exact token: ${retractedMarker}. Reply with exactly RETRACTED_ACK.`, - ); - let sessionStatusAtRetraction: string; - if (timing === "immediate") { - const claimedSend = yield* Effect.promise(() => - waitFor( - "claimed provider send before immediate retraction", - () => ({ claim: readProviderSendClaimed(retractedMessageId), session: readSession() }), - (value) => - value.claim !== undefined && - (value.session?.status === "starting" || value.session?.status === "running"), - ), - ); - sessionStatusAtRetraction = claimedSend.session?.status ?? "missing"; - } else { - const startedTurn = yield* Effect.promise(() => - waitFor( - "provider to start the retractable turn", - () => ({ session: readSession(), turn: readTurn(retractedMessageId) }), - (value) => - value.session?.status === "running" && - value.session.activeTurnId !== null && - value.turn?.turnId === value.session.activeTurnId, - ), - ); - sessionStatusAtRetraction = startedTurn.session?.status ?? "missing"; - } - if (delayMs > 0) yield* Effect.sleep(`${delayMs} millis`); - const beforeRetraction = yield* Effect.sync(() => ({ - session: readSession(), - turn: readTurn(retractedMessageId), - })); - if ( - timing === "long-response" && - (beforeRetraction.session?.status !== "running" || beforeRetraction.turn?.state !== "running") - ) { - throw new Error( - `long response completed before retraction was requested: ${stringifyJson(beforeRetraction)}`, - ); - } - const retractionRequestedAtMs = hostNowMs(); - yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ - type: "thread.turn.retract", - commandId: retractionRequestId, - threadId, - messageId: retractedMessageId, - createdAt: hostNowIso(), - }); - const retraction = yield* Effect.promise(() => - waitFor( - "terminal retraction", - readRetraction, - (row) => row.status === "completed" || row.status === "failed", - ), - ); - const retractionCompletionMs = hostNowMs() - retractionRequestedAtMs; - if (retraction.status !== "completed") { - throw new Error(`retraction failed: ${stringifyJson(retraction)}`); - } - if (retraction.providerSendState !== "claimed") { - throw new Error(`retraction did not exercise provider rollback: ${stringifyJson(retraction)}`); - } - if (retraction.baselineTurnCount !== expectedRetractionBaselineTurnCount) { - throw new Error( - `unexpected rollback boundary: expected ${expectedRetractionBaselineTurnCount}, got ${retraction.baselineTurnCount}`, - ); - } - if ( - timing === "long-response" && - (naturalCompletionMs === null || - retractionCompletionMs >= maxRetractionCompletionMs || - retractionCompletionMs >= naturalCompletionMs) - ) { - throw new Error( - `long-response timing gate failed: retractionCompletionMs=${retractionCompletionMs} naturalCompletionMs=${naturalCompletionMs} maxRetractionCompletionMs=${maxRetractionCompletionMs}`, - ); + if (!retraction) { + throw new Error("scenario completed without a retraction result"); } yield* dispatchTurn( @@ -392,6 +503,7 @@ const run = Effect.gen(function* () { naturalCompletionMs, retractionCompletionMs, sessionStatusAtRetraction, + doublePopAttempts, }; }).pipe(Effect.provide(protocolLayer)); @@ -411,7 +523,19 @@ try { console.log( `timings retractionCompletionMs=${result.retractionCompletionMs} naturalCompletionMs=${result.naturalCompletionMs ?? "n/a"} maxRetractionCompletionMs=${maxRetractionCompletionMs}`, ); - console.log(`retractedMarker=${retractedMarker} status=${retractedMarkerStatus}`); + if (timing === "double-pop") { + for (const attempt of result.doublePopAttempts) { + const markerStatus = result.reply.includes(attempt.marker) ? "PRESENT" : "ABSENT"; + console.log( + `doublePop iteration=${attempt.iteration} phase=${attempt.phase} sessionAtRetraction=${attempt.sessionStatusAtRetraction} delayMs=${attempt.delayMs} requestToCompleteMs=${attempt.requestToCompleteMs} sendToCompleteMs=${attempt.sendToCompleteMs} marker=${attempt.marker} status=${markerStatus}`, + ); + } + console.log( + `doublePopSummary attempts=${result.doublePopAttempts.length} maxRequestToCompleteMs=${Math.max(...result.doublePopAttempts.map((attempt) => attempt.requestToCompleteMs))} maxSendToCompleteMs=${Math.max(...result.doublePopAttempts.map((attempt) => attempt.sendToCompleteMs))} maxAllowedMs=${maxDoublePopCompletionMs}`, + ); + } else { + console.log(`retractedMarker=${retractedMarker} status=${retractedMarkerStatus}`); + } if (timing === "immediate") { console.log( `setupRetractedMarker=${setupRetractionMarker} status=${setupRetractionMarkerStatus}`, @@ -420,7 +544,8 @@ try { console.log(`retainedMarker=${retainedMarker} status=${retainedMarkerStatus}`); console.log(`interrogationReplyChars=${result.reply.length}`); if ( - retractedMarkerStatus !== "ABSENT" || + (timing !== "double-pop" && retractedMarkerStatus !== "ABSENT") || + result.doublePopAttempts.some((attempt) => result.reply.includes(attempt.marker)) || retainedMarkerStatus !== "PRESENT" || (timing === "immediate" && setupRetractionMarkerStatus !== "ABSENT") ) { diff --git a/apps/server/src/orchestration/Layers/TurnRetractionReactor.test.ts b/apps/server/src/orchestration/Layers/TurnRetractionReactor.test.ts index 6a5947d79d67..060273eaa723 100644 --- a/apps/server/src/orchestration/Layers/TurnRetractionReactor.test.ts +++ b/apps/server/src/orchestration/Layers/TurnRetractionReactor.test.ts @@ -19,6 +19,7 @@ import { import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import type * as Duration from "effect/Duration"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as ManagedRuntime from "effect/ManagedRuntime"; @@ -54,6 +55,8 @@ import { import { TurnRetractionReactor } from "../Services/TurnRetractionReactor.ts"; import { makeTurnRetractionReactor, + TurnRetractionInterruptRetryCadence, + TurnRetractionInterruptRetryTicks, TurnRetractionInterruptTimeout, TurnRetractionRetryTicks, } from "./TurnRetractionReactor.ts"; @@ -215,10 +218,14 @@ function makeRepository(state: MutableState): ProjectionTurnRetractionRepository const unsupported = () => Effect.die(new Error("unsupported test operation")) as Effect.Effect; -async function startHarness(state: MutableState) { +async function startHarness( + state: MutableState, + options: { readonly interruptRetryCadence?: Duration.Input } = {}, +) { const domainEvents = Effect.runSync(PubSub.unbounded()); const runtimeEvents = Effect.runSync(PubSub.unbounded()); const retryTicks = Effect.runSync(Queue.unbounded()); + const interruptRetryTicks = Effect.runSync(Queue.unbounded()); const repository = makeRepository(state); const dispatch = vi.fn((command: OrchestrationCommand) => { state.dispatched.push(command); @@ -377,7 +384,16 @@ async function startHarness(state: MutableState) { const layer = Layer.effect(TurnRetractionReactor, makeTurnRetractionReactor).pipe( Layer.provideMerge(Layer.succeed(TurnRetractionRetryTicks, Stream.fromQueue(retryTicks))), + Layer.provideMerge( + Layer.succeed(TurnRetractionInterruptRetryTicks, Stream.fromQueue(interruptRetryTicks)), + ), Layer.provideMerge(Layer.succeed(TurnRetractionInterruptTimeout, "1 millis")), + Layer.provideMerge( + Layer.succeed( + TurnRetractionInterruptRetryCadence, + options.interruptRetryCadence ?? "2 seconds", + ), + ), Layer.provideMerge(Layer.succeed(OrchestrationEngineService, engine)), Layer.provideMerge(Layer.succeed(ProjectionSnapshotQuery, query)), Layer.provideMerge(Layer.succeed(ProjectionTurnRetractionRepository, repository)), @@ -401,6 +417,7 @@ async function startHarness(state: MutableState) { emitRuntime: (event: ProviderRuntimeEvent) => runtime.runPromise(PubSub.publish(runtimeEvents, event)), retryTick: () => runtime.runPromise(Queue.offer(retryTicks, undefined)), + interruptRetryTick: () => runtime.runPromise(Queue.offer(interruptRetryTicks, undefined)), }; } @@ -424,7 +441,7 @@ it("drives claimed convergence from interrupt through a settlement event", async expect(state.order).toEqual(["interrupt"]); expect(state.row.status).toBe("requested"); - await harness.retryTick(); + await harness.interruptRetryTick(); await harness.runtime.runPromise(harness.reactor.drain); expect(state.order).toEqual(["interrupt"]); @@ -504,6 +521,25 @@ it("waits through starting and interrupts the concrete turn after it starts", as await stopHarness(harness); }); +it("reissues an interrupt for the same running turn after the retry cadence", async () => { + const state = makeState("claimed"); + const harness = await startHarness(state, { interruptRetryCadence: "0 millis" }); + + expect(state.order).toEqual(["interrupt"]); + await harness.interruptRetryTick(); + await harness.runtime.runPromise(harness.reactor.drain); + + expect(state.order).toEqual(["interrupt", "interrupt"]); + expect(state.interruptedTurnIds).toEqual([TURN_ID, TURN_ID]); + + state.sessionStatus = "ready"; + state.activeTurnId = null; + await harness.retryTick(); + await harness.runtime.runPromise(harness.reactor.drain); + expect(state.order).toEqual(["interrupt", "interrupt", "rollback", "restore", "complete"]); + await stopHarness(harness); +}); + it("does not interrupt a foreign active turn", async () => { const state = makeState("claimed"); state.activeTurnId = TurnId.make("turn-foreign"); diff --git a/apps/server/src/orchestration/Layers/TurnRetractionReactor.ts b/apps/server/src/orchestration/Layers/TurnRetractionReactor.ts index 63310ff545af..117447449619 100644 --- a/apps/server/src/orchestration/Layers/TurnRetractionReactor.ts +++ b/apps/server/src/orchestration/Layers/TurnRetractionReactor.ts @@ -82,6 +82,13 @@ export class TurnRetractionRetryTicks extends Context.Reference>( + "t3/orchestration/Layers/TurnRetractionReactor/TurnRetractionInterruptRetryTicks", + { + defaultValue: () => Stream.tick(Duration.seconds(2)).pipe(Stream.drop(1)), + }, +) {} + export class TurnRetractionInterruptTimeout extends Context.Reference( "t3/orchestration/Layers/TurnRetractionReactor/TurnRetractionInterruptTimeout", { @@ -89,9 +96,18 @@ export class TurnRetractionInterruptTimeout extends Context.Reference( + "t3/orchestration/Layers/TurnRetractionReactor/TurnRetractionInterruptRetryCadence", + { + defaultValue: () => Duration.seconds(2), + }, +) {} + export const makeTurnRetractionReactor = Effect.gen(function* () { const retryTicks = yield* TurnRetractionRetryTicks; + const interruptRetryTicks = yield* TurnRetractionInterruptRetryTicks; const interruptTimeout = yield* TurnRetractionInterruptTimeout; + const interruptRetryCadence = yield* TurnRetractionInterruptRetryCadence; const crypto = yield* Crypto.Crypto; const orchestrationEngine = yield* OrchestrationEngineService; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; @@ -100,22 +116,37 @@ export const makeTurnRetractionReactor = Effect.gen(function* () { const providerService = yield* ProviderService; const checkpointStore = yield* CheckpointStore.CheckpointStore; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const interruptedTurnIdsByRequest = new Map>(); - - const clearIssuedInterrupts = (requestId: string) => - interruptedTurnIdsByRequest.delete(requestId); - const hasIssuedInterrupt = (requestId: string, turnId: TurnId) => - interruptedTurnIdsByRequest.get(requestId)?.has(turnId) === true; - const markInterruptIssued = (requestId: string, turnId: TurnId) => { - const issuedTurnIds = interruptedTurnIdsByRequest.get(requestId) ?? new Set(); - issuedTurnIds.add(turnId); - interruptedTurnIdsByRequest.set(requestId, issuedTurnIds); + const interruptAttemptsByRequest = new Map< + string, + Map + >(); + const interruptThreadIdsByRequest = new Map(); + + const clearIssuedInterrupts = (requestId: string) => { + interruptAttemptsByRequest.delete(requestId); + interruptThreadIdsByRequest.delete(requestId); + }; + const readInterruptAttempt = (requestId: string, turnId: TurnId) => + interruptAttemptsByRequest.get(requestId)?.get(turnId); + const markInterruptIssued = ( + requestId: string, + threadId: ThreadId, + turnId: TurnId, + issuedAtMillis: number, + ) => { + const attempts = interruptAttemptsByRequest.get(requestId) ?? new Map(); + const attempt = (attempts.get(turnId)?.attempt ?? 0) + 1; + attempts.set(turnId, { attempt, issuedAtMillis }); + interruptAttemptsByRequest.set(requestId, attempts); + interruptThreadIdsByRequest.set(requestId, threadId); + return attempt; }; const clearIssuedInterrupt = (requestId: string, turnId: TurnId) => { - const issuedTurnIds = interruptedTurnIdsByRequest.get(requestId); - issuedTurnIds?.delete(turnId); - if (issuedTurnIds?.size === 0) { - interruptedTurnIdsByRequest.delete(requestId); + const attempts = interruptAttemptsByRequest.get(requestId); + attempts?.delete(turnId); + if (attempts?.size === 0) { + interruptAttemptsByRequest.delete(requestId); + interruptThreadIdsByRequest.delete(requestId); } }; @@ -128,7 +159,11 @@ export const makeTurnRetractionReactor = Effect.gen(function* () { row: ProjectionTurnRetraction, stage: RetractionStage, outcome: "completed" | "failed" | "pending" | "skipped", - fields: { readonly action?: string; readonly reason?: string } = {}, + fields: { + readonly action?: string; + readonly reason?: string; + readonly attempt?: number; + } = {}, ) => Effect.logInfo("turn retraction convergence evaluated", { threadId: row.threadId, @@ -427,30 +462,33 @@ export const makeTurnRetractionReactor = Effect.gen(function* () { return; } - const interruptAlreadyRequested = hasIssuedInterrupt(row.requestId, targetTurnId); - const interruptAcknowledged = interruptAlreadyRequested - ? undefined - : yield* Effect.sync(() => markInterruptIssued(row.requestId, targetTurnId)).pipe( - Effect.andThen( - providerService - .interruptTurn({ - threadId: row.threadId, - turnId: targetTurnId, - }) - .pipe( - Effect.mapError((error) => ({ - stage: "interrupt" as const, - retryable: !isTerminalProviderError(error), - detail: failureDetail(error), - })), - Effect.tapError(() => - Effect.sync(() => clearIssuedInterrupt(row.requestId, targetTurnId)), - ), - Effect.timeoutOption(interruptTimeout), - Effect.map(Option.isSome), - ), - ), - ); + const nowMillis = DateTime.toEpochMillis(yield* DateTime.now); + const priorAttempt = readInterruptAttempt(row.requestId, targetTurnId); + const retryCadenceMillis = Duration.toMillis(interruptRetryCadence); + const interruptDue = + priorAttempt === undefined || nowMillis - priorAttempt.issuedAtMillis >= retryCadenceMillis; + const attempt = interruptDue + ? markInterruptIssued(row.requestId, row.threadId, targetTurnId, nowMillis) + : priorAttempt.attempt; + const interruptAcknowledged = interruptDue + ? yield* providerService + .interruptTurn({ + threadId: row.threadId, + turnId: targetTurnId, + }) + .pipe( + Effect.mapError((error) => ({ + stage: "interrupt" as const, + retryable: !isTerminalProviderError(error), + detail: failureDetail(error), + })), + Effect.tapError(() => + Effect.sync(() => clearIssuedInterrupt(row.requestId, targetTurnId)), + ), + Effect.timeoutOption(interruptTimeout), + Effect.map(Option.isSome), + ) + : undefined; // Interrupt acknowledgement is not settlement. A later provider/runtime // lifecycle event wakes this row after the projected session leaves @@ -468,12 +506,15 @@ export const makeTurnRetractionReactor = Effect.gen(function* () { afterInterrupt?.session?.status === "running" ) { yield* logConvergence(row, "settlement", "pending", { - action: interruptAlreadyRequested + action: !interruptDue ? "turn-interrupt-already-requested" - : interruptAcknowledged - ? "turn-interrupt-acknowledged" - : "turn-interrupt-timed-out", + : attempt > 1 + ? "turn-interrupt-reissued" + : interruptAcknowledged + ? "turn-interrupt-acknowledged" + : "turn-interrupt-timed-out", reason: `projected session remains '${afterInterrupt.session.status}' for target turn '${targetTurnId}'`, + attempt, }); return; } @@ -576,6 +617,11 @@ export const makeTurnRetractionReactor = Effect.gen(function* () { }); }); + const enqueueInterruptRetries = Effect.fn("enqueueInterruptRetractions")(function* () { + const threadIds = new Set(interruptThreadIdsByRequest.values()); + yield* Effect.forEach(threadIds, worker.enqueue, { concurrency: 1, discard: true }); + }); + const start: TurnRetractionReactorShape["start"] = Effect.fn("start")(function* () { yield* forkParked( Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => @@ -592,6 +638,7 @@ export const makeTurnRetractionReactor = Effect.gen(function* () { yield* enqueuePending(); yield* forkParked(Stream.runForEach(retryTicks, enqueuePending)); + yield* forkParked(Stream.runForEach(interruptRetryTicks, enqueueInterruptRetries)); }); return { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 3ae491edca3f..eb2ad0d1d31f 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -1676,6 +1676,69 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("replays an interrupt acknowledged before the SDK begins processing the turn", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "count for a long time", + attachments: [], + }); + + yield* adapter.interruptTurn(session.threadId, turn.turnId); + assert.equal(harness.query.interruptCalls.length, 1); + + const requestingFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.type === "session.state.changed" && event.payload.reason === "status:requesting", + ), + Stream.take(1), + Stream.runDrain, + Effect.forkChild, + ); + harness.query.emit({ + type: "system", + subtype: "status", + status: "requesting", + uuid: "status-requesting-1", + session_id: "sdk-session", + } as unknown as SDKMessage); + yield* Fiber.join(requestingFiber); + + assert.equal(harness.query.interruptCalls.length, 2); + + const secondStatusFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.type === "session.state.changed" && event.payload.reason === "status:requesting", + ), + Stream.take(1), + Stream.runDrain, + Effect.forkChild, + ); + harness.query.emit({ + type: "system", + subtype: "status", + status: "requesting", + uuid: "status-requesting-2", + session_id: "sdk-session", + } as unknown as SDKMessage); + yield* Fiber.join(secondStatusFiber); + + assert.equal(harness.query.interruptCalls.length, 2); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("workflow member coalescing: identical snapshots suppress, changes emit", () => { 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 618e4b412406..270cf61c3f02 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -142,6 +142,10 @@ interface ClaudeTurnState { readonly assistantTextBlocks: Map; readonly assistantTextBlockOrder: Array; readonly capturedProposedPlanKeys: Set; + /** The SDK has emitted activity attributable to this queued turn. */ + sdkProcessingObserved?: boolean; + /** Replay one early interrupt after the SDK begins processing the turn. */ + interruptReplayPending?: boolean; nextSyntheticAssistantBlockIndex: number; } @@ -3497,6 +3501,34 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* logNativeSdkMessage(context, message); yield* ensureThreadId(context, message); + const turnState = context.turnState; + const sdkTurnActivityObserved = + (message.type === "system" && + message.subtype === "status" && + message.status === "requesting") || + message.type === "stream_event" || + message.type === "assistant" || + message.type === "user" || + message.type === "tool_progress"; + if (turnState && sdkTurnActivityObserved) { + turnState.sdkProcessingObserved = true; + if (turnState.interruptReplayPending) { + turnState.interruptReplayPending = false; + yield* Effect.tryPromise({ + try: () => context.query.interrupt(), + catch: (cause) => toRequestError(context.session.threadId, "turn/interruptReplay", cause), + }).pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to replay an early Claude turn interrupt.", { + threadId: context.session.threadId, + turnId: turnState.turnId, + cause, + }), + ), + ); + } + } + switch (message.type) { case "stream_event": yield* handleStreamEvent(context, message); @@ -4432,8 +4464,20 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); const interruptTurn: ClaudeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( - function* (threadId, _turnId) { + function* (threadId, turnId) { const context = yield* requireSession(threadId); + const activeTurnState = context.turnState; + if ( + activeTurnState && + (turnId === undefined || activeTurnState.turnId === turnId) && + activeTurnState.sdkProcessingObserved !== true + ) { + // `turn.started` intentionally precedes offering the prompt to the + // SDK queue. An interrupt in that gap is acknowledged by the SDK but + // can be forgotten before query processing begins. Keep one replay + // latched until the first per-turn processing signal arrives. + activeTurnState.interruptReplayPending = true; + } // Stop-everything semantics: users reach for Stop precisely when a // fleet ran away. interrupt() alone only ends the parent turn — // background subagents/shells keep running and keep burning tokens. From 849d7e2633b3a81e7845b7dde7fcc2b8b45af3d0 Mon Sep 17 00:00:00 2001 From: Adam Firestone Date: Tue, 11 Aug 2026 16:05:43 -0500 Subject: [PATCH 16/18] feat(web): optimistic Esc restore with silent reconciliation The composer restores and the message row hides synchronously at keypress; completion reconciles without double-merges, failures un-hide and toast, first-message recovery navigates on command acceptance, and sends during a pending retraction hold up to 20s instead of erroring. Built with Claude Fable 5 via Claude Code, implementation by Codex (GPT-5.6-Sol). --- apps/web/src/components/ChatView.tsx | 163 +++++++++++++++--- .../chat/RetractionRecoveryHandoff.test.ts | 30 ++++ .../chat/RetractionRecoveryHandoff.tsx | 40 +++++ .../chat/lastUserMessageRecovery.test.ts | 96 +++++++++++ .../chat/lastUserMessageRecovery.ts | 153 +++++++++++++++- .../chat/optimisticRetraction.test.ts | 45 +++++ .../components/chat/optimisticRetraction.ts | 30 ++++ .../chat/pendingRetractionSendGate.test.ts | 26 +++ .../chat/pendingRetractionSendGate.ts | 42 +++++ .../chat/useLastUserMessageRetraction.ts | 160 +++++++++++++---- docs/user/keybindings.md | 3 +- 11 files changed, 715 insertions(+), 73 deletions(-) create mode 100644 apps/web/src/components/chat/optimisticRetraction.test.ts create mode 100644 apps/web/src/components/chat/optimisticRetraction.ts create mode 100644 apps/web/src/components/chat/pendingRetractionSendGate.test.ts create mode 100644 apps/web/src/components/chat/pendingRetractionSendGate.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0c60c6b62920..fcded9de326e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,5 +1,6 @@ import { type ApprovalRequestId, + type CommandId, DEFAULT_MODEL, defaultInstanceIdForDriver, type EnvironmentId, @@ -250,6 +251,11 @@ import { isLastUserMessagePopWindowOpen, } from "./chat/lastUserMessagePop"; import { createPreDispatchCancellationLatch } from "./chat/preDispatchCancellationLatch"; +import { createPendingRetractionSendGate } from "./chat/pendingRetractionSendGate"; +import { + hideOptimisticallyRetractedMessage, + unhideOptimisticallyRetractedMessage, +} from "./chat/optimisticRetraction"; import { CHAT_FLOATING_LAYER_SELECTOR, runChatEscapeAction, @@ -361,7 +367,13 @@ const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; const sentMessageRecoveryContextByMessageId = new Map< MessageId, - { envMode: DraftThreadEnvMode; baseBranch: string | null; startFromOrigin: boolean } + { + envMode: DraftThreadEnvMode; + baseBranch: string | null; + startFromOrigin: boolean; + prompt: string; + images: ComposerImageAttachment[]; + } >(); function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { const transitionGroupRef = useRef(null); @@ -1341,6 +1353,9 @@ function ChatViewContent(props: ChatViewProps) { const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [expandedImage, setExpandedImage] = useState(null); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); + const [optimisticRetractionsByMessageId, setOptimisticRetractionsByMessageId] = useState< + Record + >({}); const optimisticUserMessagesRef = useRef(optimisticUserMessages); optimisticUserMessagesRef.current = optimisticUserMessages; const [localDraftErrorsByDraftId, setLocalDraftErrorsByDraftId] = useState< @@ -2003,6 +2018,19 @@ function ChatViewContent(props: ChatViewProps) { ); const retractionPending = pendingRetractionRecovery !== null || activeThread?.turnRetraction?.status === "requested"; + const retractionPendingRef = useRef(retractionPending); + retractionPendingRef.current = retractionPending; + const pendingRetractionSendGateRef = useRef(createPendingRetractionSendGate()); + const [heldSendPending, setHeldSendPending] = useState(false); + useEffect(() => { + if (!retractionPending) pendingRetractionSendGateRef.current.release(); + }, [retractionPending]); + useEffect( + () => () => { + pendingRetractionSendGateRef.current.dispose(); + }, + [], + ); const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); const versionMismatchDismissKey = versionMismatch && activeThread @@ -2299,7 +2327,12 @@ function ChatViewContent(props: ChatViewProps) { threadError, }); const isWorking = - phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint || retractionPending; + phase === "running" || + isSendBusy || + heldSendPending || + isConnecting || + isRevertingCheckpoint || + retractionPending; const activeWorkStartedAt = deriveActiveWorkStartedAt( activeLatestTurn, activeThread?.session ?? null, @@ -2535,16 +2568,46 @@ function ChatViewContent(props: ChatViewProps) { return changed ? { ...message, attachments } : message; }); - if (optimisticUserMessages.length === 0) { - return serverMessagesWithPreviewHandoff; - } const serverIds = new Set(serverMessagesWithPreviewHandoff.map((message) => message.id)); const pendingMessages = optimisticUserMessages.filter((message) => !serverIds.has(message.id)); - if (pendingMessages.length === 0) { - return serverMessagesWithPreviewHandoff; - } - return [...serverMessagesWithPreviewHandoff, ...pendingMessages]; - }, [attachmentPreviewHandoffByMessageId, displayServerMessages, optimisticUserMessages]); + const allMessages = + pendingMessages.length === 0 + ? serverMessagesWithPreviewHandoff + : [...serverMessagesWithPreviewHandoff, ...pendingMessages]; + if (Object.keys(optimisticRetractionsByMessageId).length === 0) return allMessages; + return allMessages.filter( + (message) => optimisticRetractionsByMessageId[message.id] === undefined, + ); + }, [ + attachmentPreviewHandoffByMessageId, + displayServerMessages, + optimisticRetractionsByMessageId, + optimisticUserMessages, + ]); + + useEffect(() => { + if (Object.keys(optimisticRetractionsByMessageId).length === 0) return; + const visibleMessageIds = new Set([ + ...displayServerMessages.map((message) => message.id), + ...optimisticUserMessages.map((message) => message.id), + ]); + setOptimisticRetractionsByMessageId((existing) => { + const next = Object.fromEntries( + Object.entries(existing).filter(([messageId]) => visibleMessageIds.has(messageId)), + ) as Record; + return Object.keys(next).length === Object.keys(existing).length ? existing : next; + }); + }, [displayServerMessages, optimisticRetractionsByMessageId, optimisticUserMessages]); + useEffect(() => { + const retraction = activeThread?.turnRetraction; + if (retraction?.status !== "failed") return; + setOptimisticRetractionsByMessageId((existing) => + unhideOptimisticallyRetractedMessage(existing, { + requestId: retraction.requestId, + messageId: retraction.messageId, + }), + ); + }, [activeThread?.turnRetraction]); const timelineEntries = useMemo( () => deriveTimelineEntries( @@ -4919,15 +4982,24 @@ function ChatViewContent(props: ChatViewProps) { }), ); }; - if (retractionPending) { - toastManager.add( - stackedThreadToast({ - type: "info", - title: "Message retraction in progress", - description: "Wait for the current message to finish retracting before sending again.", - }), - ); - return; + if (retractionPendingRef.current) { + if (sendInFlightRef.current) return; + sendInFlightRef.current = true; + setHeldSendPending(true); + const released = await pendingRetractionSendGateRef.current.wait(); + setHeldSendPending(false); + sendInFlightRef.current = false; + if (!released || retractionPendingRef.current) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Message not sent", + description: + "The previous message was still retracting after 20 seconds. Your draft is unchanged; try sending it again.", + }), + ); + return; + } } if ( !activeThread || @@ -5094,6 +5166,8 @@ function ChatViewContent(props: ChatViewProps) { envMode: sendEnvMode, baseBranch: activeThreadBranch, startFromOrigin, + prompt: promptForSend, + images: composerImagesSnapshot, }); preDispatchCancellationLatchRef.current.arm(messageIdForSend); sendInFlightRef.current = true; @@ -6066,6 +6140,35 @@ function ChatViewContent(props: ChatViewProps) { const lastUserMessageRecoveryContext = lastUserMessagePopCandidate ? sentMessageRecoveryContextByMessageId.get(lastUserMessagePopCandidate.message.id) : undefined; + const onOptimisticRetractionStarted = useCallback( + ({ requestId, messageId }: { requestId: CommandId; messageId: MessageId }) => { + retractionPendingRef.current = true; + setOptimisticRetractionsByMessageId((existing) => + hideOptimisticallyRetractedMessage(existing, { requestId, messageId }), + ); + }, + [], + ); + const onOptimisticRetractionFailed = useCallback( + ({ requestId, messageId }: { requestId: CommandId; messageId: MessageId }) => { + retractionPendingRef.current = false; + pendingRetractionSendGateRef.current.release(); + setOptimisticRetractionsByMessageId((existing) => + unhideOptimisticallyRetractedMessage(existing, { requestId, messageId }), + ); + }, + [], + ); + const navigateToRecoveryDraft = useCallback( + (recoveryDraftId: DraftId) => { + void navigate({ + to: "/draft/$draftId", + params: buildDraftThreadRouteParams(recoveryDraftId), + replace: true, + }); + }, + [navigate], + ); const onPopLastUserMessage = useLastUserMessageRetraction({ activeThread, activeProjectRef, @@ -6074,6 +6177,15 @@ function ChatViewContent(props: ChatViewProps) { : activeThreadBranch, activeEnvironmentUnavailable, candidate: lastUserMessagePopCandidate, + isFirstUserMessage: timelineMessages.filter((message) => message.role === "user").length === 1, + ...(lastUserMessageRecoveryContext + ? { + optimisticBundle: { + prompt: lastUserMessageRecoveryContext.prompt, + images: lastUserMessageRecoveryContext.images, + }, + } + : {}), pendingRecovery: pendingRetractionRecovery, retractionPending, runtimeMode, @@ -6083,6 +6195,9 @@ function ChatViewContent(props: ChatViewProps) { composerRef, promptRef, composerImagesRef, + onOptimisticRetractionStarted, + onOptimisticRetractionFailed, + navigateToRecoveryDraft, setThreadError, }); @@ -6472,14 +6587,8 @@ function ChatViewContent(props: ChatViewProps) { projectSelectionRequired={isLocalDraftThread && activeProject === null} phase={phase} isConnecting={isConnecting} - isSendBusy={isSendBusy} - sendDisabledReason={ - retractionPending - ? "Message retraction in progress" - : threadDetailLoading - ? "Messages loading" - : null - } + isSendBusy={isSendBusy || heldSendPending} + sendDisabledReason={threadDetailLoading ? "Messages loading" : null} isPreparingWorktree={isPreparingWorktree} environmentUnavailable={activeEnvironmentUnavailableState} activePendingApproval={activePendingApproval} diff --git a/apps/web/src/components/chat/RetractionRecoveryHandoff.test.ts b/apps/web/src/components/chat/RetractionRecoveryHandoff.test.ts index 5a38aa5e9632..49dfba980f26 100644 --- a/apps/web/src/components/chat/RetractionRecoveryHandoff.test.ts +++ b/apps/web/src/components/chat/RetractionRecoveryHandoff.test.ts @@ -153,6 +153,36 @@ describe("retraction recovery handoff", () => { expect(signal?.kind).toBe("completed"); }); + it("navigates an optimistic first-message recovery if completion beats acceptance", async () => { + const recovery = await seedRecovery(); + useRetractionRecoveryStore.getState().setOptimisticDestination(recovery.requestId, "thread"); + const optimisticRecovery = useRetractionRecoveryStore.getState().byRequestId[requestId]; + if (!optimisticRecovery) throw new Error("Expected optimistic recovery fixture"); + const navigate = vi.fn(); + + expect( + applyRetractionRecoverySignal({ + recovery: optimisticRecovery, + signal: { + kind: "completed", + completion: { + threadId: sourceThreadRef.threadId, + retraction: { + requestId, + messageId, + turnId: null, + firstUserMessage: true, + completedAt: "2026-08-11T12:00:00.115Z", + }, + }, + }, + navigate, + }), + ).toBe("draft-surfaced"); + expect(navigate).toHaveBeenCalledOnce(); + expect(useComposerDraftStore.getState().getDraftSession(draftId)?.hidden).toBe(false); + }); + it("restores into an existing source composer for a correlated failed row", async () => { const recovery = await seedRecovery(); const navigate = vi.fn(); diff --git a/apps/web/src/components/chat/RetractionRecoveryHandoff.tsx b/apps/web/src/components/chat/RetractionRecoveryHandoff.tsx index cb317db3ebc4..2d30cea1ef7c 100644 --- a/apps/web/src/components/chat/RetractionRecoveryHandoff.tsx +++ b/apps/web/src/components/chat/RetractionRecoveryHandoff.tsx @@ -15,6 +15,7 @@ import { environmentShell } from "../../state/shell"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { type FirstMessageRetractionCompletion, + discardRetractionRecovery, findCorrelatedRetractionFailure, handoffCompletedFirstMessageRetraction, handoffCompletedMidThreadRetraction, @@ -140,6 +141,45 @@ export function applyRetractionRecoverySignal(input: { replace: true; }) => unknown; }): "draft-surfaced" | "thread-restored" | null { + const optimisticDestination = input.recovery.optimisticDestination; + if (optimisticDestination && input.signal.kind !== "stale") { + if ( + input.signal.kind === "completed" && + input.signal.completion.retraction?.firstUserMessage && + optimisticDestination === "thread" + ) { + return surfaceRetractionRecoveryDraft({ + requestId: input.recovery.requestId, + sourceThreadRef: input.recovery.sourceThreadRef, + navigate: input.navigate, + }) + ? "draft-surfaced" + : null; + } + if (input.signal.kind === "failed" && !input.signal.sourceThreadExists) { + return surfaceRetractionRecoveryDraft({ + requestId: input.recovery.requestId, + sourceThreadRef: input.recovery.sourceThreadRef, + }) + ? "draft-surfaced" + : null; + } + if (input.signal.kind === "source-thread-gone" && optimisticDestination === "thread") { + return surfaceRetractionRecoveryDraft({ + requestId: input.recovery.requestId, + sourceThreadRef: input.recovery.sourceThreadRef, + navigate: input.navigate, + }) + ? "draft-surfaced" + : null; + } + discardRetractionRecovery({ + requestId: input.recovery.requestId, + preserveDraft: optimisticDestination === "draft", + }); + return optimisticDestination === "draft" ? "draft-surfaced" : "thread-restored"; + } + if (input.signal.kind === "completed") { if (input.signal.completion.retraction?.firstUserMessage) { return handoffCompletedFirstMessageRetraction({ diff --git a/apps/web/src/components/chat/lastUserMessageRecovery.test.ts b/apps/web/src/components/chat/lastUserMessageRecovery.test.ts index e54e2ca448a4..acc872b3a78b 100644 --- a/apps/web/src/components/chat/lastUserMessageRecovery.test.ts +++ b/apps/web/src/components/chat/lastUserMessageRecovery.test.ts @@ -11,12 +11,14 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { DraftId, useComposerDraftStore } from "../../composerDraftStore"; import { + applyOptimisticRetractionRecoveryToThread, buildRetractionCommandInput, handoffCompletedFirstMessageRetraction, handoffCompletedMidThreadRetraction, findCorrelatedRetractionFailure, restoreRetractionRecoveryToThread, snapshotLastUserMessageRecovery, + surfaceRetractionRecoveryDraft, useRetractionRecoveryStore, } from "./lastUserMessageRecovery"; @@ -216,6 +218,49 @@ describe("last user message recovery draft", () => { expect(useRetractionRecoveryStore.getState().byRequestId[requestId]).toBeUndefined(); }); + it("surfaces a first-message recovery draft on acceptance while retaining its watcher", async () => { + await snapshotLastUserMessageRecovery({ + requestId, + messageId, + sourceThreadRef, + projectRef, + draftId, + futureThreadId, + createdAt: "2026-08-11T12:00:00.000Z", + bundle: { + prompt: "restore this prompt", + images: [], + modelSelection: { + instanceId: ProviderInstanceId.make("claude-work"), + model: "claude-opus-4-1", + }, + runtimeMode: "approval-required", + interactionMode: "default", + envMode: "local", + baseBranch: "main", + startFromOrigin: false, + }, + optimisticDestination: "thread", + }); + useRetractionRecoveryStore.getState().setOptimisticDestination(requestId, "draft"); + const navigate = vi.fn(); + + expect( + surfaceRetractionRecoveryDraft({ + requestId, + sourceThreadRef, + navigate, + retainRecovery: true, + }), + ).toBe(true); + + expect(navigate).toHaveBeenCalledOnce(); + expect(useComposerDraftStore.getState().getDraftSession(draftId)?.hidden).toBe(false); + expect(useRetractionRecoveryStore.getState().byRequestId[requestId]).toMatchObject({ + optimisticDestination: "draft", + }); + }); + it("restores a correlated mid-thread completion into the same composer and cleans the hidden draft", async () => { const typedImage = new File(["typed"], "typed.png", { type: "image/png" }); const recoveredImage = new File(["recovered"], "recovered.png", { type: "image/png" }); @@ -297,6 +342,57 @@ describe("last user message recovery draft", () => { expect(useRetractionRecoveryStore.getState().byRequestId[requestId]).toBeUndefined(); }); + it("does not merge the prompt again when an optimistic restore completes", async () => { + const bundle = { + prompt: "original sent text", + images: [], + modelSelection: { + instanceId: ProviderInstanceId.make("claude-work"), + model: "claude-opus-4-1", + }, + runtimeMode: "approval-required" as const, + interactionMode: "default" as const, + envMode: "local" as const, + baseBranch: "main", + startFromOrigin: false, + }; + const snapshot = snapshotLastUserMessageRecovery({ + requestId, + messageId, + sourceThreadRef, + projectRef, + draftId, + futureThreadId, + createdAt: "2026-08-11T12:00:00.000Z", + bundle, + optimisticDestination: "thread", + }); + applyOptimisticRetractionRecoveryToThread({ sourceThreadRef, bundle }); + useComposerDraftStore.getState().setPrompt(sourceThreadRef, "original sent text, edited"); + await snapshot; + + expect( + handoffCompletedMidThreadRetraction({ + environmentId, + completion: { + threadId: sourceThreadId, + retraction: { + requestId, + messageId, + turnId: null, + firstUserMessage: false, + completedAt: "2026-08-11T12:00:05.000Z", + }, + }, + }), + ).toBeNull(); + expect(useComposerDraftStore.getState().getComposerDraft(sourceThreadRef)?.prompt).toBe( + "original sent text, edited", + ); + expect(useComposerDraftStore.getState().getDraftSession(draftId)).toBeNull(); + expect(useRetractionRecoveryStore.getState().byRequestId[requestId]).toBeUndefined(); + }); + it("restores text and removes the hidden sidebar draft when a retraction fails", async () => { await snapshotLastUserMessageRecovery({ requestId, diff --git a/apps/web/src/components/chat/lastUserMessageRecovery.ts b/apps/web/src/components/chat/lastUserMessageRecovery.ts index 1e3a2d98bdd6..9d28c97a5b13 100644 --- a/apps/web/src/components/chat/lastUserMessageRecovery.ts +++ b/apps/web/src/components/chat/lastUserMessageRecovery.ts @@ -34,6 +34,8 @@ export interface PendingRetractionRecovery { projectRef: ScopedProjectRef; draftId: DraftId; createdAt: string; + firstUserMessage?: boolean; + optimisticDestination?: "thread" | "draft"; } export function buildRetractionCommandInput(recovery: PendingRetractionRecovery) { @@ -48,6 +50,7 @@ export function buildRetractionCommandInput(recovery: PendingRetractionRecovery) interface RetractionRecoveryStoreState { byRequestId: Record; remember: (recovery: PendingRetractionRecovery) => void; + setOptimisticDestination: (requestId: CommandId, destination: "thread" | "draft") => void; forget: (requestId: CommandId) => void; } @@ -66,6 +69,17 @@ export const useRetractionRecoveryStore = create() set((state) => ({ byRequestId: { ...state.byRequestId, [recovery.requestId]: recovery }, })), + setOptimisticDestination: (requestId, optimisticDestination) => + set((state) => { + const recovery = state.byRequestId[requestId]; + if (!recovery || recovery.optimisticDestination === optimisticDestination) return state; + return { + byRequestId: { + ...state.byRequestId, + [requestId]: { ...recovery, optimisticDestination }, + }, + }; + }), forget: (requestId) => set((state) => { if (state.byRequestId[requestId] === undefined) return state; @@ -113,6 +127,8 @@ export async function snapshotLastUserMessageRecovery(input: { futureThreadId: ThreadId; createdAt: string; bundle: LastUserMessageRestoreBundle; + firstUserMessage?: boolean; + optimisticDestination?: "thread" | "draft"; encodeImage?: (file: File) => Promise; }): Promise<{ draftId: DraftId; failedImageNames: string[] }> { const store = useComposerDraftStore.getState(); @@ -128,11 +144,25 @@ export async function snapshotLastUserMessageRecovery(input: { hidden: true, }); store.setPrompt(input.draftId, input.bundle.prompt); - store.addImages(input.draftId, input.bundle.images); + store.addImages(input.draftId, input.bundle.images.map(cloneComposerImageForRetry)); store.setModelSelection(input.draftId, input.bundle.modelSelection, { replaceOptions: true }); store.setRuntimeMode(input.draftId, input.bundle.runtimeMode); store.setInteractionMode(input.draftId, input.bundle.interactionMode); + // Register the recovery before image persistence. The command may be + // dispatched as soon as this synchronous preparation finishes; encoding + // attachments must not sit on the perceived Esc path. + useRetractionRecoveryStore.getState().remember({ + requestId: input.requestId, + messageId: input.messageId, + sourceThreadRef: input.sourceThreadRef, + projectRef: input.projectRef, + draftId: input.draftId, + createdAt: input.createdAt, + ...(input.firstUserMessage !== undefined ? { firstUserMessage: input.firstUserMessage } : {}), + ...(input.optimisticDestination ? { optimisticDestination: input.optimisticDestination } : {}), + }); + const encodeImage = input.encodeImage ?? readFileAsDataUrl; const encoded = await Promise.all( input.bundle.images.map(async (image) => { @@ -158,21 +188,118 @@ export async function snapshotLastUserMessageRecovery(input: { encoded.flatMap((entry) => (entry.attachment ? [entry.attachment] : [])), ); - useRetractionRecoveryStore.getState().remember({ - requestId: input.requestId, - messageId: input.messageId, - sourceThreadRef: input.sourceThreadRef, - projectRef: input.projectRef, + return { draftId: input.draftId, - createdAt: input.createdAt, + failedImageNames: encoded.flatMap((entry) => (entry.failedName ? [entry.failedName] : [])), + }; +} + +export function applyOptimisticRetractionRecoveryToThread(input: { + sourceThreadRef: ScopedThreadRef; + bundle: LastUserMessageRestoreBundle; +}): AppliedRetractionRecovery { + const store = useComposerDraftStore.getState(); + const currentDraft = store.getComposerDraft(input.sourceThreadRef); + const prompt = mergePoppedPrompt(currentDraft?.prompt ?? "", input.bundle.prompt); + const existingImages = currentDraft?.images ?? []; + const existingIds = new Set(existingImages.map((image) => image.id)); + const existingKeys = new Set( + existingImages.map((image) => JSON.stringify([image.mimeType, image.sizeBytes, image.name])), + ); + const images: ComposerImageAttachment[] = []; + const unrestoredImageNames: string[] = []; + for (const recoveredImage of input.bundle.images) { + const key = JSON.stringify([ + recoveredImage.mimeType, + recoveredImage.sizeBytes, + recoveredImage.name, + ]); + if (existingIds.has(recoveredImage.id) || existingKeys.has(key)) continue; + if (existingImages.length + images.length >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { + unrestoredImageNames.push(recoveredImage.name); + continue; + } + existingIds.add(recoveredImage.id); + existingKeys.add(key); + images.push(cloneComposerImageForRetry(recoveredImage)); + } + + store.setPrompt(input.sourceThreadRef, prompt); + store.addImages(input.sourceThreadRef, images); + store.setModelSelection(input.sourceThreadRef, input.bundle.modelSelection, { + replaceOptions: true, }); + store.setRuntimeMode(input.sourceThreadRef, input.bundle.runtimeMode); + store.setInteractionMode(input.sourceThreadRef, input.bundle.interactionMode); return { - draftId: input.draftId, + prompt, + images: [...existingImages, ...images], + unrestoredImageNames, + }; +} + +export async function appendImagesToOptimisticRetractionRecovery(input: { + requestId: CommandId; + sourceThreadRef: ScopedThreadRef; + images: ComposerImageAttachment[]; + bundle: Omit; + encodeImage?: (file: File) => Promise; +}): Promise<{ restored: AppliedRetractionRecovery | null; failedImageNames: string[] }> { + const recovery = useRetractionRecoveryStore.getState().byRequestId[input.requestId]; + if (!recovery || input.images.length === 0) { + return { restored: null, failedImageNames: [] }; + } + const store = useComposerDraftStore.getState(); + store.addImages(recovery.draftId, input.images); + const restored = applyOptimisticRetractionRecoveryToThread({ + sourceThreadRef: input.sourceThreadRef, + bundle: { ...input.bundle, prompt: "", images: input.images }, + }); + const encodeImage = input.encodeImage ?? readFileAsDataUrl; + const encoded = await Promise.all( + input.images.map(async (image) => { + try { + return { + attachment: { + id: image.id, + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + dataUrl: await encodeImage(image.file), + } satisfies PersistedComposerImageAttachment, + failedName: null, + }; + } catch { + return { attachment: null, failedName: image.name }; + } + }), + ); + if (useRetractionRecoveryStore.getState().byRequestId[input.requestId]) { + store.syncPersistedAttachments( + recovery.draftId, + encoded.flatMap((entry) => (entry.attachment ? [entry.attachment] : [])), + ); + } + return { + restored, failedImageNames: encoded.flatMap((entry) => (entry.failedName ? [entry.failedName] : [])), }; } +export function discardRetractionRecovery(input: { + requestId: CommandId; + preserveDraft?: boolean; +}): boolean { + const recovery = useRetractionRecoveryStore.getState().byRequestId[input.requestId]; + if (!recovery) return false; + if (!input.preserveDraft) { + useComposerDraftStore.getState().clearDraftThread(recovery.draftId); + } + useRetractionRecoveryStore.getState().forget(input.requestId); + return true; +} + export interface AppliedRetractionRecovery { prompt: string; images: ComposerImageAttachment[]; @@ -260,6 +387,11 @@ export function handoffCompletedMidThreadRetraction(input: { }): AppliedRetractionRecovery | null { const metadata = input.completion.retraction; if (!metadata || metadata.firstUserMessage) return null; + const recovery = useRetractionRecoveryStore.getState().byRequestId[metadata.requestId]; + if (recovery?.optimisticDestination === "thread") { + discardRetractionRecovery({ requestId: metadata.requestId }); + return null; + } return restoreRetractionRecoveryToThread({ requestId: metadata.requestId, sourceThreadRef: { @@ -300,6 +432,7 @@ export function surfaceRetractionRecoveryDraft(input: { params: { draftId: DraftId }; replace: true; }) => unknown; + retainRecovery?: boolean; }): boolean { const recovery = useRetractionRecoveryStore.getState().byRequestId[input.requestId]; @@ -326,7 +459,9 @@ export function surfaceRetractionRecoveryDraft(input: { startFromOrigin: session.startFromOrigin, hidden: false, }); - useRetractionRecoveryStore.getState().forget(input.requestId); + if (!input.retainRecovery) { + useRetractionRecoveryStore.getState().forget(input.requestId); + } if (input.navigate) { void input.navigate({ to: "/draft/$draftId", diff --git a/apps/web/src/components/chat/optimisticRetraction.test.ts b/apps/web/src/components/chat/optimisticRetraction.test.ts new file mode 100644 index 000000000000..3db5c1f71604 --- /dev/null +++ b/apps/web/src/components/chat/optimisticRetraction.test.ts @@ -0,0 +1,45 @@ +import { CommandId, MessageId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + beginOptimisticRetraction, + hideOptimisticallyRetractedMessage, + unhideOptimisticallyRetractedMessage, +} from "./optimisticRetraction"; + +describe("optimistic turn retraction", () => { + it("restores and hides synchronously before dispatch resolves", async () => { + const order: string[] = []; + let resolveDispatch: (() => void) | undefined; + const dispatched = new Promise((resolve) => { + resolveDispatch = resolve; + }); + + const result = beginOptimisticRetraction({ + restoreComposer: () => order.push("restore"), + hideMessage: () => order.push("hide"), + dispatch: () => { + order.push("dispatch"); + return dispatched; + }, + }); + + expect(order).toEqual(["restore", "hide", "dispatch"]); + resolveDispatch?.(); + await result; + }); + + it("only unhides the row for the correlated rejected request", () => { + const messageId = MessageId.make("message-1"); + const requestId = CommandId.make("request-1"); + const hidden = hideOptimisticallyRetractedMessage({}, { messageId, requestId }); + + expect( + unhideOptimisticallyRetractedMessage(hidden, { + messageId, + requestId: CommandId.make("request-2"), + }), + ).toBe(hidden); + expect(unhideOptimisticallyRetractedMessage(hidden, { messageId, requestId })).toEqual({}); + }); +}); diff --git a/apps/web/src/components/chat/optimisticRetraction.ts b/apps/web/src/components/chat/optimisticRetraction.ts new file mode 100644 index 000000000000..1f4ff9c2ff9a --- /dev/null +++ b/apps/web/src/components/chat/optimisticRetraction.ts @@ -0,0 +1,30 @@ +import type { CommandId, MessageId } from "@t3tools/contracts"; + +export type OptimisticRetractionsByMessageId = Record; + +export function hideOptimisticallyRetractedMessage( + existing: OptimisticRetractionsByMessageId, + input: { requestId: CommandId; messageId: MessageId }, +): OptimisticRetractionsByMessageId { + return { ...existing, [input.messageId]: input.requestId }; +} + +export function unhideOptimisticallyRetractedMessage( + existing: OptimisticRetractionsByMessageId, + input: { requestId: CommandId; messageId: MessageId }, +): OptimisticRetractionsByMessageId { + if (existing[input.messageId] !== input.requestId) return existing; + const { [input.messageId]: _removed, ...next } = existing; + return next; +} + +/** Executes all visible optimistic work before starting the asynchronous command. */ +export function beginOptimisticRetraction(input: { + restoreComposer: () => void; + hideMessage: () => void; + dispatch: () => T; +}): T { + input.restoreComposer(); + input.hideMessage(); + return input.dispatch(); +} diff --git a/apps/web/src/components/chat/pendingRetractionSendGate.test.ts b/apps/web/src/components/chat/pendingRetractionSendGate.test.ts new file mode 100644 index 000000000000..346d8cd6dad8 --- /dev/null +++ b/apps/web/src/components/chat/pendingRetractionSendGate.test.ts @@ -0,0 +1,26 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { createPendingRetractionSendGate } from "./pendingRetractionSendGate"; + +describe("pending retraction send gate", () => { + afterEach(() => vi.useRealTimers()); + + it("releases a held send when retraction completes", async () => { + const gate = createPendingRetractionSendGate(); + const held = gate.wait(); + + gate.release(); + + await expect(held).resolves.toBe(true); + }); + + it("times out a held send without dispatching it", async () => { + vi.useFakeTimers(); + const gate = createPendingRetractionSendGate({ timeoutMs: 20_000 }); + const held = gate.wait(); + + await vi.advanceTimersByTimeAsync(20_000); + + await expect(held).resolves.toBe(false); + }); +}); diff --git a/apps/web/src/components/chat/pendingRetractionSendGate.ts b/apps/web/src/components/chat/pendingRetractionSendGate.ts new file mode 100644 index 000000000000..b8e1efedec85 --- /dev/null +++ b/apps/web/src/components/chat/pendingRetractionSendGate.ts @@ -0,0 +1,42 @@ +export const PENDING_RETRACTION_SEND_TIMEOUT_MS = 20_000; + +interface PendingWaiter { + resolve: (released: boolean) => void; + timeoutId: ReturnType; +} + +export interface PendingRetractionSendGate { + wait: () => Promise; + release: () => void; + dispose: () => void; +} + +export function createPendingRetractionSendGate(input?: { + timeoutMs?: number; +}): PendingRetractionSendGate { + const timeoutMs = input?.timeoutMs ?? PENDING_RETRACTION_SEND_TIMEOUT_MS; + const waiters = new Set(); + + const settle = (waiter: PendingWaiter, released: boolean) => { + if (!waiters.delete(waiter)) return; + clearTimeout(waiter.timeoutId); + waiter.resolve(released); + }; + + return { + wait: () => + new Promise((resolve) => { + const waiter = { + resolve, + timeoutId: setTimeout(() => settle(waiter, false), timeoutMs), + } satisfies PendingWaiter; + waiters.add(waiter); + }), + release: () => { + for (const waiter of waiters) settle(waiter, true); + }, + dispose: () => { + for (const waiter of waiters) settle(waiter, false); + }, + }; +} diff --git a/apps/web/src/components/chat/useLastUserMessageRetraction.ts b/apps/web/src/components/chat/useLastUserMessageRetraction.ts index 35676c58501c..83edd380f9a9 100644 --- a/apps/web/src/components/chat/useLastUserMessageRetraction.ts +++ b/apps/web/src/components/chat/useLastUserMessageRetraction.ts @@ -1,4 +1,6 @@ import type { + CommandId, + MessageId, ProviderInteractionMode, RuntimeMode, ScopedProjectRef, @@ -26,13 +28,18 @@ import { } from "./lastUserMessagePop"; import { buildRetractionCommandInput, + appendImagesToOptimisticRetractionRecovery, + applyOptimisticRetractionRecoveryToThread, + discardRetractionRecovery, findCorrelatedRetractionFailure, handoffCompletedMidThreadRetraction, type PendingRetractionRecovery, restoreRetractionRecoveryToThread, snapshotLastUserMessageRecovery, + surfaceRetractionRecoveryDraft, useRetractionRecoveryStore, } from "./lastUserMessageRecovery"; +import { beginOptimisticRetraction } from "./optimisticRetraction"; function errorMessage(error: unknown): string { return error instanceof Error ? error.message : "An error occurred."; @@ -44,6 +51,11 @@ export function useLastUserMessageRetraction(input: { activeThreadBranch: string | null; activeEnvironmentUnavailable: boolean; candidate: LastUserMessagePopCandidate | null; + isFirstUserMessage: boolean; + optimisticBundle?: { + prompt: string; + images: ComposerImageAttachment[]; + }; pendingRecovery: PendingRetractionRecovery | null; retractionPending: boolean; runtimeMode: RuntimeMode; @@ -53,6 +65,9 @@ export function useLastUserMessageRetraction(input: { composerRef: ComposerHandleRef; promptRef: RefObject; composerImagesRef: RefObject; + onOptimisticRetractionStarted: (input: { requestId: CommandId; messageId: MessageId }) => void; + onOptimisticRetractionFailed: (input: { requestId: CommandId; messageId: MessageId }) => void; + navigateToRecoveryDraft: (draftId: PendingRetractionRecovery["draftId"]) => void; setThreadError: (threadId: ThreadId | null, detail: string | null) => void; }) { const { @@ -61,6 +76,8 @@ export function useLastUserMessageRetraction(input: { activeThreadBranch, activeEnvironmentUnavailable, candidate, + isFirstUserMessage, + optimisticBundle, pendingRecovery, retractionPending, runtimeMode, @@ -70,6 +87,9 @@ export function useLastUserMessageRetraction(input: { composerRef, promptRef, composerImagesRef, + onOptimisticRetractionStarted, + onOptimisticRetractionFailed, + navigateToRecoveryDraft, setThreadError, } = input; const retractThreadTurn = useAtomCommand(threadEnvironment.retractTurn, { @@ -98,12 +118,18 @@ export function useLastUserMessageRetraction(input: { const failPendingRetraction = useCallback( (recovery: PendingRetractionRecovery, detail: string) => { - const restored = restoreRetractionRecoveryToThread({ + const restored = + recovery.optimisticDestination === "thread" + ? (discardRetractionRecovery({ requestId: recovery.requestId }), null) + : restoreRetractionRecoveryToThread({ + requestId: recovery.requestId, + sourceThreadRef: recovery.sourceThreadRef, + }); + if (restored) applyRestoredComposer(restored); + onOptimisticRetractionFailed({ requestId: recovery.requestId, - sourceThreadRef: recovery.sourceThreadRef, + messageId: recovery.messageId, }); - if (!restored) return; - applyRestoredComposer(restored); setThreadError(recovery.sourceThreadRef.threadId, detail); toastManager.add( stackedThreadToast({ @@ -113,7 +139,7 @@ export function useLastUserMessageRetraction(input: { }), ); }, - [applyRestoredComposer, setThreadError], + [applyRestoredComposer, onOptimisticRetractionFailed, setThreadError], ); const dispatchesRef = useRef(new Set()); @@ -127,7 +153,21 @@ export function useLastUserMessageRetraction(input: { input: buildRetractionCommandInput(recovery), }); dispatchesRef.current.delete(recovery.requestId); - if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; + if (result._tag !== "Failure") { + if (recovery.firstUserMessage ?? isFirstUserMessage) { + useRetractionRecoveryStore + .getState() + .setOptimisticDestination(recovery.requestId, "draft"); + surfaceRetractionRecoveryDraft({ + requestId: recovery.requestId, + sourceThreadRef: recovery.sourceThreadRef, + retainRecovery: true, + navigate: ({ params }) => navigateToRecoveryDraft(params.draftId), + }); + } + return; + } + if (isAtomCommandInterrupted(result)) return; const error = squashAtomCommandFailure(result); if ( typeof error === "object" && @@ -139,7 +179,7 @@ export function useLastUserMessageRetraction(input: { } failPendingRetraction(recovery, errorMessage(error)); }, - [failPendingRetraction, retractThreadTurn], + [failPendingRetraction, isFirstUserMessage, navigateToRecoveryDraft, retractThreadTurn], ); useEffect(() => { @@ -210,42 +250,86 @@ export function useLastUserMessageRetraction(input: { const requestId = newCommandId(); const createdAt = new Date().toISOString(); - const { images, failedNames } = await captureLastUserMessageImages(candidate.message).catch( - () => ({ - images: [], - failedNames: (candidate.message.attachments ?? []).map((attachment) => attachment.name), - }), - ); - const snapshot = await snapshotLastUserMessageRecovery({ - requestId, - messageId: candidate.message.id, - sourceThreadRef: scopeThreadRef(activeThread.environmentId, activeThread.id), - projectRef: activeProjectRef, - draftId: newDraftId(), - futureThreadId: newThreadId(), - createdAt, - bundle: { - prompt: deriveLastUserMessageRestoredText(candidate.message.text), - images, - modelSelection: activeThread.modelSelection, - runtimeMode, - interactionMode, - envMode, - baseBranch: activeThreadBranch, - startFromOrigin, + const sourceThreadRef = scopeThreadRef(activeThread.environmentId, activeThread.id); + const prompt = + optimisticBundle?.prompt ?? deriveLastUserMessageRestoredText(candidate.message.text); + const images = optimisticBundle?.images ?? []; + const bundle = { + prompt, + images, + modelSelection: activeThread.modelSelection, + runtimeMode, + interactionMode, + envMode, + baseBranch: activeThreadBranch, + startFromOrigin, + }; + const draftId = newDraftId(); + const snapshotPromise = beginOptimisticRetraction({ + restoreComposer: () => { + const restored = applyOptimisticRetractionRecoveryToThread({ + sourceThreadRef, + bundle, + }); + applyRestoredComposer(restored); + }, + hideMessage: () => + onOptimisticRetractionStarted({ requestId, messageId: candidate.message.id }), + dispatch: () => { + const snapshot = snapshotLastUserMessageRecovery({ + requestId, + messageId: candidate.message.id, + sourceThreadRef, + projectRef: activeProjectRef, + draftId, + futureThreadId: newThreadId(), + createdAt, + bundle, + firstUserMessage: isFirstUserMessage, + optimisticDestination: "thread", + }); + const recovery = useRetractionRecoveryStore.getState().byRequestId[requestId]; + recoveryPreparationRef.current = false; + if (recovery) void dispatchPendingRetraction(recovery); + return snapshot; }, }); - const unrestoredImageNames = [...failedNames, ...snapshot.failedImageNames]; - if (unrestoredImageNames.length > 0) { + + void snapshotPromise.then((snapshot) => { + if (snapshot.failedImageNames.length === 0) return; toastManager.add({ type: "warning", title: "Some images could not be saved for recovery", - description: `${[...new Set(unrestoredImageNames)].join(", ")} may not survive a reconnect.`, + description: `${[...new Set(snapshot.failedImageNames)].join(", ")} may not survive a reconnect.`, + }); + }); + + if (!optimisticBundle && (candidate.message.attachments?.length ?? 0) > 0) { + void captureLastUserMessageImages(candidate.message).then(async (captured) => { + const appended = await appendImagesToOptimisticRetractionRecovery({ + requestId, + sourceThreadRef, + images: captured.images, + bundle: { + modelSelection: bundle.modelSelection, + runtimeMode: bundle.runtimeMode, + interactionMode: bundle.interactionMode, + envMode: bundle.envMode, + baseBranch: bundle.baseBranch, + startFromOrigin: bundle.startFromOrigin, + }, + }); + if (appended.restored) applyRestoredComposer(appended.restored); + const failedNames = [...captured.failedNames, ...appended.failedImageNames]; + if (failedNames.length > 0) { + toastManager.add({ + type: "warning", + title: "Some images could not be restored", + description: `${[...new Set(failedNames)].join(", ")} could not be restored to the composer.`, + }); + } }); } - const recovery = useRetractionRecoveryStore.getState().byRequestId[requestId]; - recoveryPreparationRef.current = false; - if (recovery) void dispatchPendingRetraction(recovery); }, [ activeProjectRef, activeThread, @@ -254,8 +338,12 @@ export function useLastUserMessageRetraction(input: { dispatchPendingRetraction, envMode, interactionMode, + isFirstUserMessage, + onOptimisticRetractionStarted, + optimisticBundle, retractionPending, runtimeMode, startFromOrigin, + applyRestoredComposer, ]); } diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 405f2a8ebf39..681f37407bf4 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -56,7 +56,8 @@ The full command list and the current defaults are shown in **Settings** → **K always matches the build you are running. Use that rather than a copied list. With focus anywhere in the chat view, press `Escape` to retract the message you just sent and move -it back into the composer for editing. This works from the instant you send until the agent shows +it back into the composer for editing immediately, while the agent stops in the background. This +works from the instant you send until the agent shows assistant text, a tool call, a command, or another activity for that turn. Agent thinking does not close this short window. Retracting a thread's first message returns you to the draft page. The shortcut is built into the chat view and is not configurable. From 37844f80180166b892383fdfe2beb0c779f17507 Mon Sep 17 00:00:00 2001 From: Adam Firestone Date: Tue, 11 Aug 2026 17:04:51 -0500 Subject: [PATCH 17/18] feat(web): hide retraction settling behind the optimistic pop While a client-known pending retraction exists, one effective-presentation derivation keeps the timeline working row, the composer stop button and the sidebar Working badge quiet, so the popped turn reads as never started. Suppression drops on terminal failure or when a different turn goes live (a held send dispatching), and shares the pending-recovery lifetime so a stuck retraction cannot masquerade as idle. Built with Claude Fable 5 via Claude Code, implementation by Claude Opus 5. --- apps/web/src/components/ChatView.tsx | 49 ++-- apps/web/src/components/Sidebar.logic.ts | 17 +- apps/web/src/components/Sidebar.tsx | 12 +- .../chat/retractedTurnPresentation.test.ts | 213 ++++++++++++++++++ .../chat/retractedTurnPresentation.ts | 154 +++++++++++++ 5 files changed, 422 insertions(+), 23 deletions(-) create mode 100644 apps/web/src/components/chat/retractedTurnPresentation.test.ts create mode 100644 apps/web/src/components/chat/retractedTurnPresentation.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index fcded9de326e..575820762df8 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -263,10 +263,11 @@ import { } from "./chat/chatEscapeTrigger"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { shouldRenderEmptyThreadHero } from "./chat/emptyThreadHero"; +import { findCorrelatedRetractionFailure } from "./chat/lastUserMessageRecovery"; import { - findCorrelatedRetractionFailure, - useRetractionRecoveryStore, -} from "./chat/lastUserMessageRecovery"; + deriveEffectiveSessionPresentation, + usePendingRetractionForThread, +} from "./chat/retractedTurnPresentation"; import { useLastUserMessageRetraction } from "./chat/useLastUserMessageRetraction"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; @@ -2007,14 +2008,8 @@ function ChatViewContent(props: ChatViewProps) { const supportsPullRequests = serverConfig?.environment.capabilities.pullRequests === true; const supportsThreadTurnRetraction = serverConfig?.environment.capabilities.threadTurnRetraction === true; - const pendingRetractionRecovery = useRetractionRecoveryStore((state) => - routeKind === "server" - ? (Object.values(state.byRequestId).find( - (recovery) => - recovery.sourceThreadRef.environmentId === routeThreadRef.environmentId && - recovery.sourceThreadRef.threadId === routeThreadRef.threadId, - ) ?? null) - : null, + const pendingRetractionRecovery = usePendingRetractionForThread( + routeKind === "server" ? routeThreadRef : null, ); const retractionPending = pendingRetractionRecovery !== null || activeThread?.turnRetraction?.status === "requested"; @@ -2326,13 +2321,27 @@ function ChatViewContent(props: ChatViewProps) { activePendingUserInput: activePendingUserInput?.requestId ?? null, threadError, }); - const isWorking = - phase === "running" || - isSendBusy || - heldSendPending || - isConnecting || - isRevertingCheckpoint || - retractionPending; + // The just-popped turn is still settling server-side for a beat. Every + // surface reads this one derivation so the thread presents as if the turn + // never started. `phase` stays raw for the decisions that must respect the + // real session (the pop window, the revert-checkpoint guard, local dispatch + // bookkeeping); `presentedPhase` is what the composer and timeline read. + const { + phase: presentedPhase, + isWorking, + activeTurnInProgress, + } = deriveEffectiveSessionPresentation({ + phase, + pendingRetraction: pendingRetractionRecovery, + projectedRetraction: activeThread?.turnRetraction ?? null, + activeTurnId: activeThread?.session?.activeTurnId ?? null, + retractionPending, + latestTurnSettled, + isSendBusy, + heldSendPending, + isConnecting, + isRevertingCheckpoint, + }); const activeWorkStartedAt = deriveActiveWorkStartedAt( activeLatestTurn, activeThread?.session ?? null, @@ -6461,7 +6470,7 @@ function ChatViewContent(props: ChatViewProps) { key={activeThread.id} isWorking={isWorking} workingStepLabel={workingStepLabel} - activeTurnInProgress={isWorking || !latestTurnSettled} + activeTurnInProgress={activeTurnInProgress} activeTurnStartedAt={activeWorkStartedAt} listRef={legendListRef} timelineEntries={timelineEntries} @@ -6585,7 +6594,7 @@ function ChatViewContent(props: ChatViewProps) { isLocalDraftThread={isLocalDraftThread} forceExpandedOnMobile={forceExpandedMobileComposer && isDraftHeroState} projectSelectionRequired={isLocalDraftThread && activeProject === null} - phase={phase} + phase={presentedPhase} isConnecting={isConnecting} isSendBusy={isSendBusy || heldSendPending} sendDisabledReason={threadDetailLoading ? "Messages loading" : null} diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index cae26f5d6bde..e143ce4a9d8a 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -443,14 +443,27 @@ type SidebarThreadStatusInput = Pick< "hasPendingApprovals" | "hasPendingUserInput" | "session" | "backgroundLiveness" >; -export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): SidebarThreadStatus { +export function resolveSidebarThreadStatus( + thread: SidebarThreadStatusInput, + options?: { + /** + * The live turn was retracted from the composer and the server is still + * settling it. Presentation-only: the row shows what it looked like before + * the turn started, so the retraction never surfaces as work. + */ + suppressRunningTurn?: boolean; + }, +): SidebarThreadStatus { if (thread.hasPendingApprovals) { return "approval"; } if (thread.hasPendingUserInput) { return "input"; } - if (thread.session?.status === "running" || thread.session?.status === "starting") { + if ( + !options?.suppressRunningTurn && + (thread.session?.status === "running" || thread.session?.status === "starting") + ) { return "working"; } // A failed session outranks lingering background liveness: the user must diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 84977a6b2d19..124b2d98c038 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -135,6 +135,7 @@ import { sortSettledThreadsForSidebar, sortThreadsForSidebar, } from "./Sidebar.logic"; +import { useRetractedTurnPresentationSuppressed } from "./chat/retractedTurnPresentation"; import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; import { ThreadWorktreeIndicator, @@ -753,7 +754,16 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // Same semantics as the legacy sidebar (never-visited counts as read): // switching sidebars must not light up every historical thread as unread. const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt }); - const status = resolveSidebarThreadStatus(thread); + // A turn the user just popped back into the composer is still settling on + // the server. The row keeps its pre-turn presentation until it lands, so the + // retraction never flashes a Working badge here. + const retractedTurnSuppressed = useRetractedTurnPresentationSuppressed({ + threadRef, + activeTurnId: thread.session?.activeTurnId ?? null, + }); + const status = resolveSidebarThreadStatus(thread, { + suppressRunningTurn: retractedTurnSuppressed, + }); // A woken thread reappears at its original position (the sort is // deliberately static), so the pill has to carry the weight. Snoozing is // an explicit act, so the pill clears only when the user re-engages: diff --git a/apps/web/src/components/chat/retractedTurnPresentation.test.ts b/apps/web/src/components/chat/retractedTurnPresentation.test.ts new file mode 100644 index 000000000000..d3cef0584de0 --- /dev/null +++ b/apps/web/src/components/chat/retractedTurnPresentation.test.ts @@ -0,0 +1,213 @@ +import { + CommandId, + DEFAULT_RUNTIME_MODE, + EnvironmentId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { beforeEach, describe, expect, it } from "vite-plus/test"; + +import { DraftId } from "../../composerDraftStore"; +import { resolveSidebarThreadStatus } from "../Sidebar.logic"; +import { useRetractionRecoveryStore } from "./lastUserMessageRecovery"; +import { deriveMessagesTimelineRows } from "./MessagesTimeline.logic"; +import { + deriveEffectiveSessionPresentation, + findPendingRetractionForThread, + isRetractedTurnPresentationSuppressed, + suppressRetractedTurnPhase, +} from "./retractedTurnPresentation"; + +const environmentId = EnvironmentId.make("environment-1"); +const projectRef = scopeProjectRef(environmentId, ProjectId.make("project-1")); +const threadId = ThreadId.make("thread-1"); +const threadRef = scopeThreadRef(environmentId, threadId); +const siblingThreadRef = scopeThreadRef(environmentId, ThreadId.make("thread-2")); +const requestId = CommandId.make("request-1"); +const retractedTurnId = TurnId.make("turn-1"); +const nextTurnId = TurnId.make("turn-2"); + +const recovery = { + requestId, + messageId: MessageId.make("message-1"), + sourceThreadRef: threadRef, + projectRef, + draftId: DraftId.make("recovery-draft"), + createdAt: "2026-08-11T12:00:00.000Z", +}; + +const idlePresentationInput = { + retractionPending: false, + latestTurnSettled: false, + isSendBusy: false, + heldSendPending: false, + isConnecting: false, + isRevertingCheckpoint: false, +}; + +function presentation( + overrides: Partial[0]> = {}, +) { + return deriveEffectiveSessionPresentation({ + phase: "running", + pendingRetraction: recovery, + projectedRetraction: null, + activeTurnId: retractedTurnId, + ...idlePresentationInput, + retractionPending: true, + ...overrides, + }); +} + +function timelineWorkingRowIds(isWorking: boolean) { + return deriveMessagesTimelineRows({ + timelineEntries: [], + latestTurn: null, + isWorking, + activeTurnStartedAt: "2026-08-11T12:00:00.000Z", + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }).map((row) => row.kind); +} + +const runningSession = { + threadId, + status: "running" as const, + providerName: "Claude", + providerInstanceId: ProviderInstanceId.make("claude"), + runtimeMode: DEFAULT_RUNTIME_MODE, + activeTurnId: retractedTurnId, + lastError: null, + updatedAt: "2026-08-11T12:00:00.000Z", +}; +const idleThread = { hasPendingApprovals: false, hasPendingUserInput: false }; + +beforeEach(() => { + useRetractionRecoveryStore.setState({ byRequestId: {} }); +}); + +describe("retracted turn presentation suppression", () => { + it("hides the running turn while the client knows a retraction is pending", () => { + const suppressed = presentation(); + + expect(suppressed.retractedTurnSuppressed).toBe(true); + expect(suppressed.phase).toBe("ready"); + expect(suppressed.isWorking).toBe(false); + expect(suppressed.activeTurnInProgress).toBe(false); + }); + + it("drops the timeline working row while suppressed and keeps it otherwise", () => { + expect(timelineWorkingRowIds(presentation().isWorking)).not.toContain("working"); + expect(timelineWorkingRowIds(presentation({ pendingRetraction: null }).isWorking)).toContain( + "working", + ); + }); + + it("keeps the sidebar row settled while suppressed", () => { + expect( + resolveSidebarThreadStatus( + { ...idleThread, session: runningSession }, + { suppressRunningTurn: true }, + ), + ).toBe("ready"); + expect(resolveSidebarThreadStatus({ ...idleThread, session: runningSession })).toBe("working"); + }); + + it("still reports genuine background work in the sidebar while suppressed", () => { + expect( + resolveSidebarThreadStatus( + { ...idleThread, session: runningSession, backgroundLiveness: "working" }, + { suppressRunningTurn: true }, + ), + ).toBe("working"); + }); + + it("presents a starting session as settled too, so nothing reads as Connecting", () => { + expect(suppressRetractedTurnPhase("connecting", true)).toBe("ready"); + expect(suppressRetractedTurnPhase("disconnected", true)).toBe("disconnected"); + expect(suppressRetractedTurnPhase("running", false)).toBe("running"); + }); + + it("holds suppression until the server acknowledges the retraction", () => { + expect( + presentation({ + projectedRetraction: { requestId, targetTurnId: retractedTurnId, status: "requested" }, + }).retractedTurnSuppressed, + ).toBe(true); + }); +}); + +describe("retracted turn presentation lifetime", () => { + it("stops suppressing once the completed retraction forgets the recovery", () => { + useRetractionRecoveryStore.getState().remember(recovery); + expect( + findPendingRetractionForThread(useRetractionRecoveryStore.getState().byRequestId, threadRef), + ).toMatchObject({ requestId }); + + useRetractionRecoveryStore.getState().forget(requestId); + const pendingRetraction = findPendingRetractionForThread( + useRetractionRecoveryStore.getState().byRequestId, + threadRef, + ); + + expect(pendingRetraction).toBeNull(); + const settled = presentation({ pendingRetraction, retractionPending: false }); + expect(settled.retractedTurnSuppressed).toBe(false); + expect(settled.phase).toBe("running"); + expect(settled.isWorking).toBe(true); + }); + + it("restores the true running presentation when the retraction fails", () => { + const failed = presentation({ + projectedRetraction: { requestId, targetTurnId: retractedTurnId, status: "failed" }, + }); + + expect(failed.retractedTurnSuppressed).toBe(false); + expect(failed.phase).toBe("running"); + expect(failed.isWorking).toBe(true); + expect(timelineWorkingRowIds(failed.isWorking)).toContain("working"); + }); +}); + +describe("retracted turn presentation scope", () => { + it("leaves sibling threads untouched", () => { + useRetractionRecoveryStore.getState().remember(recovery); + const byRequestId = useRetractionRecoveryStore.getState().byRequestId; + + expect(findPendingRetractionForThread(byRequestId, siblingThreadRef)).toBeNull(); + expect( + findPendingRetractionForThread( + byRequestId, + scopeThreadRef(EnvironmentId.make("environment-2"), threadId), + ), + ).toBeNull(); + expect( + isRetractedTurnPresentationSuppressed({ + pendingRetraction: findPendingRetractionForThread(byRequestId, siblingThreadRef), + }), + ).toBe(false); + }); + + it("renders a newer turn normally when a held send dispatches", () => { + const newTurn = presentation({ + projectedRetraction: { requestId, targetTurnId: retractedTurnId, status: "completed" }, + activeTurnId: nextTurnId, + }); + + expect(newTurn.retractedTurnSuppressed).toBe(false); + expect(newTurn.phase).toBe("running"); + expect(newTurn.isWorking).toBe(true); + }); + + it("reports a held send as sending even while the retraction stays hidden", () => { + const held = presentation({ heldSendPending: true }); + + expect(held.retractedTurnSuppressed).toBe(true); + expect(held.phase).toBe("ready"); + expect(held.isWorking).toBe(true); + }); +}); diff --git a/apps/web/src/components/chat/retractedTurnPresentation.ts b/apps/web/src/components/chat/retractedTurnPresentation.ts new file mode 100644 index 000000000000..14e469e3a494 --- /dev/null +++ b/apps/web/src/components/chat/retractedTurnPresentation.ts @@ -0,0 +1,154 @@ +import type { + CommandId, + OrchestrationThreadTurnRetraction, + ScopedThreadRef, + TurnId, +} from "@t3tools/contracts"; + +import type { SessionPhase } from "../../types"; +import { + type PendingRetractionRecovery, + useRetractionRecoveryStore, +} from "./lastUserMessageRecovery"; + +/** + * Esc pops the just-sent message back into the composer instantly, but the + * server takes a beat (~1-2s on Claude) to actually retract the turn. Nothing + * about that settling belongs on screen: the user's mental model is that the + * turn never started, so the timeline working row, the composer stop button + * and the sidebar Working badge all have to keep quiet until it lands. + * + * Every surface derives that from this one predicate so they cannot disagree, + * and suppression lives exactly as long as the pending recovery entry does — + * completion, failure and the 60s staleness path all forget the entry, so a + * stuck retraction can never masquerade as an idle thread forever. + */ +export interface RetractedTurnProjection { + requestId: CommandId; + targetTurnId: TurnId | null; + status: OrchestrationThreadTurnRetraction["status"]; +} + +export function findPendingRetractionForThread( + byRequestId: Record, + threadRef: ScopedThreadRef | null, +): PendingRetractionRecovery | null { + if (!threadRef) return null; + return ( + Object.values(byRequestId).find( + (recovery) => + recovery.sourceThreadRef.environmentId === threadRef.environmentId && + recovery.sourceThreadRef.threadId === threadRef.threadId, + ) ?? null + ); +} + +/** + * Selects the recovery record itself, never a derived array: a selector that + * builds a fresh reference every snapshot re-renders forever (this store is + * read from a layout-mounted component). + */ +export function usePendingRetractionForThread( + threadRef: ScopedThreadRef | null, +): PendingRetractionRecovery | null { + return useRetractionRecoveryStore((state) => + findPendingRetractionForThread(state.byRequestId, threadRef), + ); +} + +export function isRetractedTurnPresentationSuppressed(input: { + pendingRetraction: Pick | null; + /** The server's projection of our retraction, once it has caught up. */ + projectedRetraction?: RetractedTurnProjection | null; + activeTurnId?: TurnId | null; +}): boolean { + const pending = input.pendingRetraction; + if (!pending) return false; + + const projected = input.projectedRetraction ?? null; + // Not acknowledged yet (or a different request): the optimistic pop is all + // we have, and it is the window this exists for. + if (projected === null || projected.requestId !== pending.requestId) return true; + + // The retraction terminally failed. The turn really is running, so the true + // presentation has to come back alongside the failure surfacing. + if (projected.status === "failed") return false; + + // A different turn is running than the one we retracted — a held send that + // dispatched, say. New work renders normally. + const activeTurnId = input.activeTurnId ?? null; + if (projected.targetTurnId !== null && activeTurnId !== null) { + return activeTurnId === projected.targetTurnId; + } + return true; +} + +export function useRetractedTurnPresentationSuppressed(input: { + threadRef: ScopedThreadRef | null; + projectedRetraction?: RetractedTurnProjection | null; + activeTurnId?: TurnId | null; +}): boolean { + const pendingRetraction = usePendingRetractionForThread(input.threadRef); + return isRetractedTurnPresentationSuppressed({ + pendingRetraction, + projectedRetraction: input.projectedRetraction ?? null, + activeTurnId: input.activeTurnId ?? null, + }); +} + +/** Presentation-only phase: the retracted turn reads as settled, never live. */ +export function suppressRetractedTurnPhase(phase: SessionPhase, suppressed: boolean): SessionPhase { + if (!suppressed) return phase; + return phase === "running" || phase === "connecting" ? "ready" : phase; +} + +export interface EffectiveSessionPresentation { + /** True while a client-known retraction is hiding the turn it retracted. */ + retractedTurnSuppressed: boolean; + /** Phase for the composer and the timeline. Raw phase still drives the pop + window, the revert guard and local dispatch bookkeeping. */ + phase: SessionPhase; + /** Drives the timeline "Working…" row and the send-vs-stop affordance. */ + isWorking: boolean; + /** Work rows read as settled again while the retracted turn is hidden. */ + activeTurnInProgress: boolean; +} + +/** + * The single presentation derivation the thread view, the working row and the + * composer all read, so they cannot disagree about whether a thread is busy. + */ +export function deriveEffectiveSessionPresentation(input: { + phase: SessionPhase; + pendingRetraction: Pick | null; + projectedRetraction?: RetractedTurnProjection | null; + activeTurnId?: TurnId | null; + retractionPending: boolean; + latestTurnSettled: boolean; + isSendBusy: boolean; + heldSendPending: boolean; + isConnecting: boolean; + isRevertingCheckpoint: boolean; +}): EffectiveSessionPresentation { + const retractedTurnSuppressed = isRetractedTurnPresentationSuppressed({ + pendingRetraction: input.pendingRetraction, + projectedRetraction: input.projectedRetraction ?? null, + activeTurnId: input.activeTurnId ?? null, + }); + const phase = suppressRetractedTurnPhase(input.phase, retractedTurnSuppressed); + // heldSendPending stays outside the suppression: the user pressed send, so + // "sending" reports their own action, not the retraction settling. + const isWorking = + phase === "running" || + input.isSendBusy || + input.heldSendPending || + input.isConnecting || + input.isRevertingCheckpoint || + (input.retractionPending && !retractedTurnSuppressed); + return { + retractedTurnSuppressed, + phase, + isWorking, + activeTurnInProgress: isWorking || (!input.latestTurnSettled && !retractedTurnSuppressed), + }; +} From efea84b8deaf7f9803f57f3b087ff8b23cfee617 Mon Sep 17 00:00:00 2001 From: Adam Firestone Date: Tue, 11 Aug 2026 17:57:43 -0500 Subject: [PATCH 18/18] fix(server): serialize Claude sendTurn per thread across session recycle The post-rollback recycle replaces the SDK session over several hundred ms during which the thread looks idle, so a second send could race the recycle: both called startSession, and the losing session kept an already-opened, prompt-offered turn no live query could settle, pinning the session "running" forever. A per-thread semaphore makes recycle + turn-open + prompt-offer atomic. Adds interrupt-replay arm/fire logging and a wire-level pop-resend-follow repro scenario (fails pre-fix, passes N=3 post-fix). Built with Claude Fable 5 via Claude Code, implementation by Claude Opus 5. --- apps/server/scripts/turn-retraction-repro.ts | 207 +++++++++++++++++- .../src/provider/Layers/ClaudeAdapter.ts | 39 +++- 2 files changed, 240 insertions(+), 6 deletions(-) diff --git a/apps/server/scripts/turn-retraction-repro.ts b/apps/server/scripts/turn-retraction-repro.ts index b753efb59a1d..3058516be8ec 100644 --- a/apps/server/scripts/turn-retraction-repro.ts +++ b/apps/server/scripts/turn-retraction-repro.ts @@ -18,13 +18,13 @@ import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; import * as Socket from "effect/unstable/socket/Socket"; type Provider = "claudeAgent" | "codex"; -type Timing = "double-pop" | "immediate" | "long-response" | "mid-thinking"; +type Timing = "double-pop" | "immediate" | "long-response" | "mid-thinking" | "pop-resend-follow"; const [baseDir, httpOrigin, pairingCredential, providerArg = "codex", timingArg = "immediate"] = process.argv.slice(2); if (!baseDir || !httpOrigin || !pairingCredential) { throw new Error( - "usage: node apps/server/scripts/turn-retraction-repro.ts [codex|claudeAgent] [immediate|mid-thinking|long-response|double-pop]", + "usage: node apps/server/scripts/turn-retraction-repro.ts [codex|claudeAgent] [immediate|mid-thinking|long-response|double-pop|pop-resend-follow]", ); } if (providerArg !== "codex" && providerArg !== "claudeAgent") { @@ -34,7 +34,8 @@ if ( timingArg !== "immediate" && timingArg !== "mid-thinking" && timingArg !== "long-response" && - timingArg !== "double-pop" + timingArg !== "double-pop" && + timingArg !== "pop-resend-follow" ) { throw new Error(`unsupported timing '${timingArg}'`); } @@ -45,6 +46,10 @@ const delayMs = timing === "mid-thinking" ? 250 : timing === "long-response" ? 1 const maxRetractionCompletionMs = 20_000; const maxDoublePopCompletionMs = 5_000; const doublePopIterations = 4; +const popResendFollowIterations = 3; +const popResendFollowRetractionDelayMs = 400; +const popResendFollowFollowDelayMs = 500; +const maxPopResendFollowSettleMs = 30_000; const suffix = crypto.randomUUID(); const projectId = ProjectId.make(`repro-project-${suffix}`); const threadId = ThreadId.make(`repro-thread-${suffix}`); @@ -148,6 +153,32 @@ const readRetraction = (requestId = retractionRequestId) => FROM projection_turn_retractions WHERE request_id = ?`, requestId, ); +/** Assistant text anywhere in the thread, regardless of which turn owns it. */ +const readAssistantTextContaining = (marker: string) => + queryOne<{ text: string }>( + `SELECT text FROM projection_thread_messages + WHERE thread_id = ? AND role = 'assistant' AND text LIKE '%' || ? || '%' + ORDER BY rowid DESC LIMIT 1`, + threadId, + marker, + )?.text; +/** + * Turns holding assistant output that no user message owns. A turn detached + * from its message is the shape of the stall: nothing settles it, so the + * session keeps reporting work the user cannot stop. + */ +const readDetachedTurns = () => + queryOne<{ detached: number }>( + `SELECT COUNT(*) AS detached FROM projection_turns + WHERE thread_id = ? AND pending_message_id IS NULL AND assistant_message_id IS NOT NULL`, + threadId, + )?.detached; +const readUnsettledTurns = () => + queryOne<{ unsettled: number }>( + `SELECT COUNT(*) AS unsettled FROM projection_turns + WHERE thread_id = ? AND state IN ('pending', 'running')`, + threadId, + )?.unsettled; const readAssistantReply = (messageId: MessageId) => queryOne<{ text: string }>( `SELECT messages.text @@ -242,6 +273,19 @@ const run = Effect.gen(function* () { readonly requestToCompleteMs: number; readonly sendToCompleteMs: number; }> = []; + const popResendFollowAttempts: Array<{ + readonly iteration: number; + readonly retractedMarker: string; + readonly resendMarker: string; + readonly followMarker: string; + readonly retractionCompletionMs: number; + readonly resendSettleMs: number; + readonly followSettleMs: number; + readonly finalSessionStatus: string; + readonly detachedTurns: number; + readonly resendReply: string | undefined; + readonly followReply: string | undefined; + }> = []; let retraction: RetractionRow | undefined; let naturalCompletionMs: number | null = null; let retractionCompletionMs: number | null = null; @@ -330,6 +374,136 @@ const run = Effect.gen(function* () { retraction = attemptRetraction; } } + } else if (timing === "pop-resend-follow") { + for (let iteration = 1; iteration <= popResendFollowIterations; iteration += 1) { + const iterationRetractedMarker = `POP_REMOVED_MARKER_${suffix}_${iteration}`; + const resendMarker = `RESEND_MARKER_${suffix}_${iteration}`; + const followMarker = `FOLLOW_MARKER_${suffix}_${iteration}`; + const popMessageId = MessageId.make(`repro-pop-${suffix}-${iteration}`); + const resendMessageId = MessageId.make(`repro-resend-${suffix}-${iteration}`); + const followMessageId = MessageId.make(`repro-follow-${suffix}-${iteration}`); + const popRequestId = CommandId.make(`repro-pop-retract-${suffix}-${iteration}`); + + yield* dispatchTurn( + popMessageId, + `Remember this exact token: ${iterationRetractedMarker}. Then count from 1 to 400, one number per line, no other text.`, + ); + yield* Effect.promise(() => + waitFor( + `pop-resend-follow ${iteration} retractable turn start`, + () => ({ session: readSession(), turn: readTurn(popMessageId) }), + (value) => + value.session?.status === "running" && + value.session.activeTurnId !== null && + value.turn?.state === "running" && + value.turn.turnId === value.session.activeTurnId, + ), + ); + yield* Effect.sleep(`${popResendFollowRetractionDelayMs} millis`); + const beforeRetraction = yield* Effect.sync(() => ({ + session: readSession(), + turn: readTurn(popMessageId), + })); + if ( + beforeRetraction.session?.status !== "running" || + beforeRetraction.turn?.state !== "running" + ) { + throw new Error( + `pop-resend-follow ${iteration} completed before retraction: ${stringifyJson(beforeRetraction)}`, + ); + } + + const retractionRequestedAtMs = hostNowMs(); + yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.retract", + commandId: popRequestId, + threadId, + messageId: popMessageId, + createdAt: hostNowIso(), + }); + const completedRetraction = yield* Effect.promise(() => + waitFor( + `pop-resend-follow ${iteration} held resend release`, + () => readRetraction(popRequestId), + (row) => row.status === "completed" || row.status === "failed", + maxRetractionCompletionMs, + ), + ); + if (completedRetraction.status !== "completed") { + throw new Error( + `pop-resend-follow ${iteration} retraction failed: ${stringifyJson(completedRetraction)}`, + ); + } + const resendSentAtMs = hostNowMs(); + yield* dispatchTurn(resendMessageId, `Reply with exactly ${resendMarker}.`); + yield* Effect.sleep(`${popResendFollowFollowDelayMs} millis`); + const followSentAtMs = hostNowMs(); + yield* dispatchTurn(followMessageId, `Reply with exactly ${followMarker}.`); + + const remainingSettleMs = Math.max( + 1, + maxPopResendFollowSettleMs - (hostNowMs() - resendSentAtMs), + ); + // Both sends land while the recycled session is still coming up, so the + // provider may answer them as one steered turn or as two. Either shape is + // fine; what must hold is that the thread stops reporting work, both + // answers arrive, and no turn is left owning output no message claims. + const settled = yield* Effect.promise(() => + waitFor( + `pop-resend-follow ${iteration} both answers and a settled session`, + () => ({ + retraction: completedRetraction, + resendReply: readAssistantTextContaining(resendMarker), + followReply: readAssistantTextContaining(followMarker), + detachedTurns: readDetachedTurns(), + unsettledTurns: readUnsettledTurns(), + session: readSession(), + }), + (value) => + value.retraction?.status === "completed" && + value.resendReply !== undefined && + value.followReply !== undefined && + value.session?.status === "ready" && + value.session.activeTurnId === null && + value.unsettledTurns === 0, + remainingSettleMs, + ), + ); + if (settled.retraction?.providerSendState !== "claimed") { + throw new Error( + `pop-resend-follow ${iteration} rollback failed: ${stringifyJson(settled.retraction)}`, + ); + } + // Detached turns are reported, not fatal. Two sends that overlap inside + // one provider turn (a steer) still scramble which message owns the turn + // row — a separate defect from the stall this scenario gates, and one + // that reproduces with no retraction in play. + if (settled.detachedTurns !== 0) { + yield* Effect.logWarning( + `popResendFollow iteration=${iteration} detachedTurns=${settled.detachedTurns} (steered send lost its turn row; tracked separately from the stall gate)`, + ); + } + + const settledAtMs = hostNowMs(); + const attempt = { + iteration, + retractedMarker: iterationRetractedMarker, + resendMarker, + followMarker, + retractionCompletionMs: + Date.parse(settled.retraction.completedAt ?? hostNowIso()) - retractionRequestedAtMs, + resendSettleMs: settledAtMs - resendSentAtMs, + followSettleMs: settledAtMs - followSentAtMs, + finalSessionStatus: settled.session?.status ?? "missing", + detachedTurns: settled.detachedTurns ?? 0, + resendReply: settled.resendReply, + followReply: settled.followReply, + }; + popResendFollowAttempts.push(attempt); + retraction = settled.retraction; + retractionCompletionMs = attempt.retractionCompletionMs; + sessionStatusAtRetraction = beforeRetraction.session.status; + } } else { if (timing === "immediate") { yield* dispatchTurn( @@ -504,6 +678,7 @@ const run = Effect.gen(function* () { retractionCompletionMs, sessionStatusAtRetraction, doublePopAttempts, + popResendFollowAttempts, }; }).pipe(Effect.provide(protocolLayer)); @@ -533,6 +708,22 @@ try { console.log( `doublePopSummary attempts=${result.doublePopAttempts.length} maxRequestToCompleteMs=${Math.max(...result.doublePopAttempts.map((attempt) => attempt.requestToCompleteMs))} maxSendToCompleteMs=${Math.max(...result.doublePopAttempts.map((attempt) => attempt.sendToCompleteMs))} maxAllowedMs=${maxDoublePopCompletionMs}`, ); + } else if (timing === "pop-resend-follow") { + for (const attempt of result.popResendFollowAttempts) { + const retractedStatus = result.reply.includes(attempt.retractedMarker) ? "PRESENT" : "ABSENT"; + const resendContextStatus = result.reply.includes(attempt.resendMarker) + ? "PRESENT" + : "ABSENT"; + const followContextStatus = result.reply.includes(attempt.followMarker) + ? "PRESENT" + : "ABSENT"; + console.log( + `popResendFollow iteration=${attempt.iteration} retractionCompletionMs=${attempt.retractionCompletionMs} resendSettleMs=${attempt.resendSettleMs} followSettleMs=${attempt.followSettleMs} finalSession=${attempt.finalSessionStatus} detachedTurns=${attempt.detachedTurns} retracted=${retractedStatus} resendReply=${attempt.resendReply} followReply=${attempt.followReply} resendContext=${resendContextStatus} followContext=${followContextStatus}`, + ); + } + console.log( + `popResendFollowSummary iterations=${result.popResendFollowAttempts.length} maxResendSettleMs=${Math.max(...result.popResendFollowAttempts.map((attempt) => attempt.resendSettleMs))} maxFollowSettleMs=${Math.max(...result.popResendFollowAttempts.map((attempt) => attempt.followSettleMs))} maxAllowedMs=${maxPopResendFollowSettleMs}`, + ); } else { console.log(`retractedMarker=${retractedMarker} status=${retractedMarkerStatus}`); } @@ -544,8 +735,16 @@ try { console.log(`retainedMarker=${retainedMarker} status=${retainedMarkerStatus}`); console.log(`interrogationReplyChars=${result.reply.length}`); if ( - (timing !== "double-pop" && retractedMarkerStatus !== "ABSENT") || + (timing !== "double-pop" && + timing !== "pop-resend-follow" && + retractedMarkerStatus !== "ABSENT") || result.doublePopAttempts.some((attempt) => result.reply.includes(attempt.marker)) || + result.popResendFollowAttempts.some( + (attempt) => + result.reply.includes(attempt.retractedMarker) || + !result.reply.includes(attempt.resendMarker) || + !result.reply.includes(attempt.followMarker), + ) || retainedMarkerStatus !== "PRESENT" || (timing === "immediate" && setupRetractionMarkerStatus !== "ABSENT") ) { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 270cf61c3f02..52ea1700168e 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -58,10 +58,12 @@ import { getProviderOptionDescriptors, resolvePromptInjectedEffort, } from "@t3tools/shared/model"; +import * as Cache from "effect/Cache"; 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"; @@ -71,6 +73,7 @@ import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; @@ -1666,6 +1669,20 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }) as ClaudeQueryRuntime); const sessions = new Map(); + /** + * One send at a time per thread. `sendTurn` can replace the SDK session + * (post-rollback recycle), and that replacement takes hundreds of ms during + * which the thread looks idle to the orchestration side. A second send + * arriving in that window used to race the recycle: both sends called + * `startSession`, and whichever session lost the race kept the turn that had + * already been opened and offered on it — a turn no live query could ever + * settle, so the session stayed "running" forever. + */ + const sendTurnLocks = yield* Cache.make({ + capacity: 10_000, + timeToLive: Duration.minutes(30), + lookup: () => Semaphore.make(1), + }); const runtimeEventQueue = yield* Queue.unbounded(); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); @@ -3514,6 +3531,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( turnState.sdkProcessingObserved = true; if (turnState.interruptReplayPending) { turnState.interruptReplayPending = false; + yield* Effect.logInfo("claude.turn.interrupt-replay-fired", { + threadId: context.session.threadId, + turnId: turnState.turnId, + }); yield* Effect.tryPromise({ try: () => context.query.interrupt(), catch: (cause) => toRequestError(context.session.threadId, "turn/interruptReplay", cause), @@ -4345,7 +4366,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }, ); - const sendTurn: ClaudeAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) { + const sendTurnExclusive = Effect.fn("sendTurnExclusive")(function* ( + input: Parameters[0], + ) { let context = yield* requireSession(input.threadId); if (context.recycleBeforeNextTurn) { yield* startSession({ @@ -4463,6 +4486,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }; }); + const sendTurn: ClaudeAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) { + const lock = yield* Cache.get(sendTurnLocks, input.threadId); + return yield* lock.withPermits(1)(sendTurnExclusive(input)); + }); + const interruptTurn: ClaudeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( function* (threadId, turnId) { const context = yield* requireSession(threadId); @@ -4475,8 +4503,15 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( // `turn.started` intentionally precedes offering the prompt to the // SDK queue. An interrupt in that gap is acknowledged by the SDK but // can be forgotten before query processing begins. Keep one replay - // latched until the first per-turn processing signal arrives. + // latched until the first per-turn processing signal arrives. The latch + // lives on the turn's own state, which is never reused by a later turn, + // so it dies with the turn it was armed for. activeTurnState.interruptReplayPending = true; + yield* Effect.logInfo("claude.turn.interrupt-replay-armed", { + threadId, + turnId: activeTurnState.turnId, + requestedTurnId: turnId ?? null, + }); } // Stop-everything semantics: users reach for Stop precisely when a // fleet ran away. interrupt() alone only ends the parent turn —