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/scripts/turn-retraction-repro.ts b/apps/server/scripts/turn-retraction-repro.ts new file mode 100644 index 000000000000..3058516be8ec --- /dev/null +++ b/apps/server/scripts/turn-retraction-repro.ts @@ -0,0 +1,758 @@ +// @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, + MessageId, + ORCHESTRATION_WS_METHODS, + ProjectId, + ProviderInstanceId, + 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"; + +type Provider = "claudeAgent" | "codex"; +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|pop-resend-follow]", + ); +} +if (providerArg !== "codex" && providerArg !== "claudeAgent") { + throw new Error(`unsupported provider '${providerArg}'`); +} +if ( + timingArg !== "immediate" && + timingArg !== "mid-thinking" && + timingArg !== "long-response" && + timingArg !== "double-pop" && + timingArg !== "pop-resend-follow" +) { + throw new Error(`unsupported timing '${timingArg}'`); +} + +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 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}`); +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), + 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", + 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 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 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; + 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 = ?`, + 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 + 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, + (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 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 ${provider} ${timing}`, + workspaceRoot, + createWorkspaceRootIfMissing: true, + defaultModelSelection: modelSelection, + createdAt, + }); + yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.create", + commandId: CommandId.make(`repro-create-thread-${suffix}`), + threadId, + projectId, + title: `Retraction repro ${provider} ${timing}`, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + }); + + 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", + ), + ); + + 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; + }> = []; + 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; + 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 === "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( + 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( + 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: retractionRequestId, + threadId, + messageId: retractedMessageId, + createdAt: hostNowIso(), + }); + retraction = yield* Effect.promise(() => + waitFor( + "terminal retraction", + readRetraction, + (row) => row.status === "completed" || row.status === "failed", + ), + ); + 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( + 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, + ), + ); + + return { + baselineTurn, + retraction, + interrogationTurn, + reply, + naturalCompletionMs, + retractionCompletionMs, + sessionStatusAtRetraction, + doublePopAttempts, + popResendFollowAttempts, + }; +}).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} 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}`, + ); + 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 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}`); + } + if (timing === "immediate") { + console.log( + `setupRetractedMarker=${setupRetractionMarker} status=${setupRetractionMarkerStatus}`, + ); + } + console.log(`retainedMarker=${retainedMarker} status=${retainedMarkerStatus}`); + console.log(`interrogationReplyChars=${result.reply.length}`); + if ( + (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") + ) { + throw new Error( + `model-context gate failed: retracted=${retractedMarkerStatus} setupRetracted=${setupRetractionMarkerStatus} retained=${retainedMarkerStatus}`, + ); + } + console.log("gate=PASS"); +} finally { + database.close(); +} 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/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 08ea1437bb29..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); }, @@ -148,12 +149,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 +165,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")); @@ -446,6 +450,7 @@ describe("CheckpointReactor", () => { return { engine, + reactor, readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()), provider, cwd, @@ -453,6 +458,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"; @@ -1151,6 +1197,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..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, }); }); @@ -774,7 +760,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, @@ -938,6 +932,7 @@ const make = Effect.gen(function* () { }); return { + ensurePreTurnBaseline, start, drain: worker.drain, } satisfies CheckpointReactorShape; diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 19290d6ec40e..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; @@ -606,17 +726,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/OrchestrationReactor.test.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts index 300d1526bb9a..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"; @@ -48,6 +49,7 @@ describe("OrchestrationReactor", () => { ), Layer.provideMerge( Layer.succeed(CheckpointReactor, { + ensurePreTurnBaseline: () => Effect.succeed(null), start: () => { started.push("checkpoint-reactor"); return Effect.void; @@ -55,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: () => { @@ -84,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 e3b18d74a9a7..6526bb584753 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -19,11 +19,13 @@ 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, } 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 +243,166 @@ 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]?.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"), + 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* 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"), + 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) => { @@ -2670,6 +2832,78 @@ 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; + assert.equal(yield* retractions.cancelPendingProviderSend({ threadId, messageId }), false); + 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 e9a625dd91cf..18fad5fd9297 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,11 +490,27 @@ 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; 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) { @@ -611,6 +637,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, @@ -799,6 +826,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; @@ -855,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, }); @@ -887,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, }); @@ -950,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, }); @@ -1029,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, @@ -1298,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; } @@ -1394,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({ @@ -1606,6 +1675,61 @@ 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, + providerSendState: "unclaimed", + 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 +1763,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 +1873,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..b68b10cac500 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` @@ -316,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, @@ -326,6 +358,20 @@ 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, + providerSendState: "claimed", + firstUserMessage: false, + requestedAt: "2026-02-24T00:00:08.500Z", + status: "requested", + completedAt: null, + failedAt: null, + }, deletedAt: null, messages: [ { @@ -383,6 +429,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..4c64b5b67804 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, @@ -21,6 +22,7 @@ import { type OrchestrationProject, type OrchestrationSession, type OrchestrationThreadActivity, + type OrchestrationThreadTurnRetraction, type OrchestrationThreadShell, ModelSelection, ProjectId, @@ -52,6 +54,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, @@ -85,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( @@ -99,6 +103,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 +206,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 +302,25 @@ 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, + providerSendState: row.providerSendClaimed !== 0 ? "claimed" : row.providerSendState, + firstUserMessage: row.firstUserMessage !== 0, + requestedAt: row.requestedAt, + status: row.status, + completedAt: row.completedAt, + failedAt: row.failedAt, + }; +} + function mapSessionRow( row: Schema.Schema.Type, ): OrchestrationSession { @@ -418,6 +448,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", @@ -454,6 +485,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", @@ -492,6 +524,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", @@ -536,6 +569,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 +640,86 @@ 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", + 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 + 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", + 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} + 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, @@ -934,6 +1075,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", @@ -1382,6 +1524,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 +1553,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { sessionRows, checkpointRows, latestTurnRows, + retractionRows, stateRows, ]) => Effect.gen(function* () { @@ -1412,6 +1563,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 +1675,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, { @@ -1567,6 +1725,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, @@ -1578,6 +1737,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 +1788,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 +1804,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 +1820,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 +1836,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 +1856,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 +1935,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 +1949,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) { @@ -1774,6 +2031,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, @@ -1785,11 +2043,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 +2636,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { checkpointRows, latestTurnRow, sessionRow, + retractionRow, ] = yield* Effect.all([ getActiveThreadRowById({ threadId }).pipe( Effect.mapError( @@ -2440,6 +2700,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), + getLatestTurnRetractionRowByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:getTurnRetraction:query", + "ProjectionSnapshotQuery.getThreadDetailById:getTurnRetraction:decodeRow", + ), + ), + ), ]); if (Option.isNone(threadRow)) { @@ -2455,6 +2723,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, @@ -2466,6 +2735,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/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 2b4d3771605a..57c1e6720b8d 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); @@ -340,6 +347,7 @@ describe("ProviderCommandReactor", () => { }); }, rollbackConversation: () => unsupported(), + rollbackConversationTo: () => unsupported(), get streamEvents() { return Stream.fromPubSub(runtimeEventPubSub); }, @@ -361,6 +369,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 +414,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 +446,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 +536,7 @@ describe("ProviderCommandReactor", () => { generateBranchName, generateThreadTitle, runtimeSessions, + ensurePreTurnBaseline, stateDir, drain, runEffect, @@ -552,6 +586,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/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/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/Layers/TurnRetractionReactor.test.ts b/apps/server/src/orchestration/Layers/TurnRetractionReactor.test.ts new file mode 100644 index 000000000000..060273eaa723 --- /dev/null +++ b/apps/server/src/orchestration/Layers/TurnRetractionReactor.test.ts @@ -0,0 +1,770 @@ +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 type * as Duration from "effect/Duration"; +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 Queue from "effect/Queue"; +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, + TurnRetractionInterruptRetryCadence, + TurnRetractionInterruptRetryTicks, + TurnRetractionInterruptTimeout, + TurnRetractionRetryTicks, +} 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; + activeTurnId: TurnId | null; + historyTurnCount: number; + rollbackTargetTurnId: TurnId | undefined; + filesystemRestored: boolean; + failRollbackAfterEffect: boolean; + failRestoreAfterEffect: boolean; + failCompletionAfterCommit: boolean; + terminalRollbackFailure: boolean; + interruptAcknowledgementHangs: boolean; + readonly order: string[]; + readonly interruptedTurnIds: Array; + 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, + activeTurnId: providerSendState === "claimed" ? TURN_ID : null, + historyTurnCount: 2, + rollbackTargetTurnId: undefined, + filesystemRestored: false, + failRollbackAfterEffect: false, + failRestoreAfterEffect: false, + failCompletionAfterCommit: false, + terminalRollbackFailure: false, + interruptAcknowledgementHangs: false, + order: [], + interruptedTurnIds: [], + 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" ? state.activeTurnId : 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, + 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); + 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: ({ 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. + state.sessionStatus = "ready"; + } + }).pipe(Effect.andThen(state.interruptAcknowledgementHangs ? Effect.never : Effect.void)), + 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, targetTurnId }) => + Effect.gen(function* () { + state.order.push("rollback"); + state.rollbackTargetTurnId = targetTurnId; + 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(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)), + 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)), + emitRuntime: (event: ProviderRuntimeEvent) => + runtime.runPromise(PubSub.publish(runtimeEvents, event)), + retryTick: () => runtime.runPromise(Queue.offer(retryTicks, undefined)), + interruptRetryTick: () => runtime.runPromise(Queue.offer(interruptRetryTicks, undefined)), + }; +} + +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"); + + await harness.interruptRetryTick(); + await harness.runtime.runPromise(harness.reactor.drain); + expect(state.order).toEqual(["interrupt"]); + + 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.rollbackTargetTurnId).toBe(TURN_ID); + expect(state.row.status).toBe("completed"); + 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("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"); + 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; + 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"; + 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"); + + 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..117447449619 --- /dev/null +++ b/apps/server/src/orchestration/Layers/TurnRetractionReactor.ts @@ -0,0 +1,656 @@ +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 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"; +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 class TurnRetractionRetryTicks extends Context.Reference>( + "t3/orchestration/Layers/TurnRetractionReactor/TurnRetractionRetryTicks", + { + defaultValue: () => Stream.tick(Duration.seconds(30)).pipe(Stream.drop(1)), + }, +) {} + +export class TurnRetractionInterruptRetryTicks 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", + { + defaultValue: () => Duration.seconds(15), + }, +) {} + +export class TurnRetractionInterruptRetryCadence 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; + const turnRetractions = yield* ProjectionTurnRetractionRepository; + const turns = yield* ProjectionTurnRepository; + const providerService = yield* ProviderService; + const checkpointStore = yield* CheckpointStore.CheckpointStore; + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + 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 attempts = interruptAttemptsByRequest.get(requestId); + attempts?.delete(turnId); + if (attempts?.size === 0) { + interruptAttemptsByRequest.delete(requestId); + interruptThreadIdsByRequest.delete(requestId); + } + }; + + 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; + readonly attempt?: number; + } = {}, + ) => + 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, + ) { + 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") { + clearIssuedInterrupts(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") { + 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") { + clearIssuedInterrupts(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; + } + + 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); + clearIssuedInterrupts(row.requestId); + yield* logConvergence(row, "cleanup", "completed", { + action: "restore-filesystem-and-complete-cancelled-send", + }); + 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 sessionWasActive = + thread.session?.status === "starting" || thread.session?.status === "running"; + 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 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 + // 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" + ) { + yield* logConvergence(row, "settlement", "pending", { + action: !interruptDue + ? "turn-interrupt-already-requested" + : 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; + } + } + + yield* providerService + .rollbackConversationTo({ + threadId: row.threadId, + retainedTurnCount: row.baselineTurnCount, + ...(targetTurnId !== null ? { targetTurnId } : {}), + }) + .pipe( + Effect.mapError((error) => ({ + stage: "provider-rollback" as const, + retryable: !isTerminalProviderError(error), + detail: failureDetail(error), + })), + ); + yield* restoreFilesystem(row, false); + yield* dispatchCompletion(row, targetTurnId); + clearIssuedInterrupts(row.requestId); + yield* logConvergence(row, "cleanup", "completed", { + action: sessionWasActive + ? "interrupt-settled-provider-rollback-restore-and-complete" + : "provider-rollback-restore-and-complete", + }); + }); + + 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) => + 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, + stage: failure.stage, + detail: failure.detail, + }) + : appendTerminalFailure(latest.value, failure).pipe( + Effect.tap(() => + Effect.sync(() => clearIssuedInterrupts(latest.value.requestId)), + ), + 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 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 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) => + 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, + ), + ); + + yield* enqueuePending(); + yield* forkParked(Stream.runForEach(retryTicks, enqueuePending)); + yield* forkParked(Stream.runForEach(interruptRetryTicks, enqueueInterruptRetries)); + }); + + 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/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/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 a48bb29e154b..5401ca2994cf 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"]; @@ -840,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, @@ -917,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({ @@ -1046,6 +1120,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, @@ -1336,6 +1479,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: command.targetTurnId ?? 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 new file mode 100644 index 000000000000..208989555e64 --- /dev/null +++ b/apps/server/src/orchestration/decider.turnRetraction.test.ts @@ -0,0 +1,464 @@ +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 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 + | 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("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)); + 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("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(); + 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.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 f486dcb2bcbc..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, @@ -8,6 +13,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 +39,7 @@ import { ThreadUnsnoozedPayload, ThreadRevertedPayload, ThreadSessionSetPayload, + ThreadTurnInterruptRequestedPayload, ThreadTurnDiffCompletedPayload, } from "./Schemas.ts"; @@ -45,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 @@ -297,6 +313,7 @@ export function projectEvent( interactionMode: payload.interactionMode, branch: payload.branch, worktreePath: payload.worktreePath, + managedWorktree: null, latestTurn: null, createdAt: payload.createdAt, updatedAt: payload.updatedAt, @@ -456,6 +473,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, }), })), @@ -500,6 +520,9 @@ export function projectEvent( if (!thread) { return nextBase; } + if (isCompletedRetractedTurn(thread, payload.turnId)) { + return nextBase; + } const message: OrchestrationMessage = yield* decodeForEvent( OrchestrationMessage, @@ -612,6 +635,43 @@ 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, + providerSendState: "unclaimed", + 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( @@ -624,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), @@ -656,6 +719,9 @@ export function projectEvent( if (!thread) { return nextBase; } + if (isCompletedRetractedTurn(thread, payload.turnId)) { + return nextBase; + } const checkpoint = yield* decodeForEvent( OrchestrationCheckpointSummary, @@ -764,6 +830,30 @@ 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: thread.turnRetraction?.providerSendClaimed ?? false, + providerSendState: thread.turnRetraction?.providerSendState ?? "unclaimed", + 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 +880,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/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/Layers/ProjectionTurnRetractions.ts b/apps/server/src/persistence/Layers/ProjectionTurnRetractions.ts new file mode 100644 index 000000000000..213b9fd852bd --- /dev/null +++ b/apps/server/src/persistence/Layers/ProjectionTurnRetractions.ts @@ -0,0 +1,284 @@ +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, + CancelProjectionTurnProviderSend, + ProjectionTurnRetraction, + ProjectionTurnRetractionRepository, + ProjectionTurnRetractionRequest, + ProjectionTurnRetractionThread, + ProjectionTurnProviderSend, + 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 { + const providerSendState = row.providerSendClaimed !== 0 ? "claimed" : row.providerSendState; + return { + ...row, + providerSendClaimed: providerSendState === "claimed", + providerSendState, + 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, 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}, + 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 + 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 = 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, + 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 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 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, + 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", + 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 + `, + }); + + 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", + 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} + 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", + 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' + 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)), + 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; + } + // 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 }); + } + return true; + }), + ) + .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..b185bdb89a09 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -53,6 +53,9 @@ 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"; +import Migration0042 from "./Migrations/042_ProjectionTurnDispatchOwnership.ts"; +import Migration0043 from "./Migrations/043_ProjectionManagedWorktrees.ts"; /** * Migration loader with all migrations defined inline. @@ -105,6 +108,9 @@ export const migrationEntries = [ [38, "ProjectionThreadsPinOrderKey", Migration0038], [39, "ProjectionProjectsDefaultThreadEnvMode", Migration0039], [40, "ProjectionProjectFaviconPath", Migration0040], + [41, "ProjectionTurnRetractions", Migration0041], + [42, "ProjectionTurnDispatchOwnership", Migration0042], + [43, "ProjectionManagedWorktrees", Migration0043], ] 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/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/Migrations/043_ProjectionManagedWorktrees.test.ts b/apps/server/src/persistence/Migrations/043_ProjectionManagedWorktrees.test.ts new file mode 100644 index 000000000000..7a6417e67dc8 --- /dev/null +++ b/apps/server/src/persistence/Migrations/043_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("043_ProjectionManagedWorktrees", (it) => { + it.effect("adds nullable managed-worktree provenance storage", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 42 }); + yield* runMigrations({ toMigrationInclusive: 43 }); + + 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/043_ProjectionManagedWorktrees.ts b/apps/server/src/persistence/Migrations/043_ProjectionManagedWorktrees.ts new file mode 100644 index 000000000000..1ed9e7bf76c0 --- /dev/null +++ b/apps/server/src/persistence/Migrations/043_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/persistence/Services/ProjectionTurnRetractions.ts b/apps/server/src/persistence/Services/ProjectionTurnRetractions.ts new file mode 100644 index 000000000000..4e84458c2913 --- /dev/null +++ b/apps/server/src/persistence/Services/ProjectionTurnRetractions.ts @@ -0,0 +1,101 @@ +import { + CheckpointRef, + CommandId, + IsoDateTime, + MessageId, + NonNegativeInt, + ThreadId, + ThreadTurnProviderSendState, + 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, + providerSendState: ThreadTurnProviderSendState, + 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 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, + 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; + /** + * 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>; + 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, + ProjectionTurnProviderSend, + CancelProjectionTurnProviderSend, + ProjectionTurnRetractionRequest, + ProjectionTurnRetractionThread, +}; diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 711b0f6f6aa3..eb2ad0d1d31f 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, }; } @@ -1665,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* () { @@ -3362,7 +3436,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 +3464,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 +3593,317 @@ 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 history to an absolute retained boundary idempotently", () => { + 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); + assert.isDefined(adapter.rollbackThreadTo); + if (!adapter.rollbackThreadTo) return; - const rolledBack = yield* adapter.rollbackThread(session.threadId, 1); - assert.equal(rolledBack.turns.length, 1); - assert.equal(rolledBack.turns[0]?.id, firstTurn.turnId); + const alreadyAtBoundary = yield* adapter.rollbackThreadTo(session.threadId, 2); + assert.equal(alreadyAtBoundary.turns.length, 2); - 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 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, + 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("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("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(), + }); + 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..52ea1700168e 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, @@ -57,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"; @@ -70,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"; @@ -141,6 +145,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; } @@ -210,6 +218,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; @@ -225,6 +235,7 @@ interface ClaudeSessionContext { readonly turns: Array<{ id: TurnId; items: Array; + lastAssistantUuid: string | undefined; }>; readonly inFlightTools: Map; readonly claudeTasks: Map; @@ -245,6 +256,8 @@ interface ClaudeSessionContext { lastKnownTotalProcessedTokens: number | undefined; lastAssistantUuid: string | undefined; lastThreadStartedId: string | undefined; + restartInput: ProviderSessionStartInput; + recycleBeforeNextTurn: boolean; stopped: boolean; } @@ -1656,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); @@ -1743,7 +1770,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 = { @@ -2315,6 +2342,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( context.turns.push({ id: turnState.turnId, items: [...turnState.items], + lastAssistantUuid: context.lastAssistantUuid, }); yield* emitThreadTokenUsage(context, usageSnapshot, { @@ -3490,6 +3518,38 @@ 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.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), + }).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); @@ -4117,6 +4177,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, @@ -4199,6 +4262,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const context: ClaudeSessionContext = { session, + sessionBaseTurnCount: resumeState?.turnCount ?? 0, promptQueue, query: queryRuntime, streamFiber: undefined, @@ -4221,6 +4285,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); @@ -4300,8 +4366,18 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }, ); - const sendTurn: ClaudeAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) { - const context = yield* requireSession(input.threadId); + const sendTurnExclusive = Effect.fn("sendTurnExclusive")(function* ( + input: Parameters[0], + ) { + 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 @@ -4410,9 +4486,33 @@ 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) { + 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. 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 — // background subagents/shells keep running and keep burning tokens. @@ -4479,16 +4579,72 @@ 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); - yield* updateResumeCursor(context); - return yield* snapshotThread(context); + return yield* applyRollback(context, nextLength); }, ); + 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.", + }); + } + const lifetimeTurnCount = context.sessionBaseTurnCount + context.turns.length; + if (lifetimeTurnCount < retainedTurnCount) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThreadTo", + issue: `Provider history has ${lifetimeTurnCount} turns, below retained boundary ${retainedTurnCount}.`, + }); + } + const delta = lifetimeTurnCount - retainedTurnCount; + const sessionLocalTurnCount = context.turns.length; + 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({ + provider: PROVIDER, + method: "thread/rollback-to", + detail: `Expected ${retainedTurnCount} retained turns, found ${resultingLifetimeTurnCount}.`, + }); + } + return snapshot; + }); + const respondToRequest: ClaudeAdapterShape["respondToRequest"] = Effect.fn("respondToRequest")( function* (threadId, requestId, decision) { const context = yield* requireSession(threadId); @@ -4578,6 +4734,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..2c3c7b4946f3 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -309,6 +309,72 @@ 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("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 6b99bf52b1e0..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) { @@ -1890,6 +1895,86 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ); }; + const rollbackThreadTo: NonNullable = Effect.fn( + "rollbackThreadTo", + )(function* (threadId, retainedTurnCount, targetTurnId) { + if (!Number.isInteger(retainedTurnCount) || retainedTurnCount < 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThreadTo", + issue: "retainedTurnCount must be an integer >= 0.", + }); + } + const session = yield* requireSession(threadId); + const current = yield* readThread(threadId); + 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}.`, + }); + } + // 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, + 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 +2062,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..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, @@ -76,6 +77,12 @@ const ProviderRollbackConversationInput = Schema.Struct({ numTurns: NonNegativeInt, }); +const ProviderRollbackConversationToInput = Schema.Struct({ + threadId: ThreadId, + retainedTurnCount: NonNegativeInt, + targetTurnId: Schema.optional(TurnId), +}); + function toValidationError( operation: string, issue: string, @@ -1065,6 +1072,74 @@ 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, + input.targetTurnId, + ); + } 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 +1211,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..489615be5735 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -114,6 +114,18 @@ 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, + targetTurnId?: TurnId, + ) => 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..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"; @@ -105,6 +106,16 @@ 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; + readonly targetTurnId?: TurnId; + }) => Effect.Effect; + /** * Canonical provider runtime event stream. * 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/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/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/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 762dac559f21..575820762df8 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, @@ -244,7 +245,30 @@ import { } from "../state/entities"; import { environmentShell } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; +import { + findLastUserMessagePopCandidate, + IMAGE_ONLY_MESSAGE_PLACEHOLDER, + 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, + shouldHandleChatEscape, +} from "./chat/chatEscapeTrigger"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; +import { shouldRenderEmptyThreadHero } from "./chat/emptyThreadHero"; +import { findCorrelatedRetractionFailure } from "./chat/lastUserMessageRecovery"; +import { + deriveEffectiveSessionPresentation, + usePendingRetractionForThread, +} from "./chat/retractedTurnPresentation"; +import { useLastUserMessageRetraction } from "./chat/useLastUserMessageRetraction"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; @@ -338,12 +362,20 @@ 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"] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; +const sentMessageRecoveryContextByMessageId = new Map< + MessageId, + { + envMode: DraftThreadEnvMode; + baseBranch: string | null; + startFromOrigin: boolean; + prompt: string; + images: ComposerImageAttachment[]; + } +>(); function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { const transitionGroupRef = useRef(null); const composerAnchorRef = useRef(null); @@ -1322,6 +1354,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< @@ -1372,6 +1407,7 @@ function ChatViewContent(props: ChatViewProps) { const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); const sendInFlightRef = useRef(false); + const preDispatchCancellationLatchRef = useRef(createPreDispatchCancellationLatch()); const terminalUiOpenByThreadRef = useRef>({}); useLayoutEffect(() => { @@ -1490,8 +1526,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 @@ -1960,6 +2006,26 @@ 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 = usePendingRetractionForThread( + routeKind === "server" ? routeThreadRef : null, + ); + 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 @@ -2255,7 +2321,27 @@ function ChatViewContent(props: ChatViewProps) { activePendingUserInput: activePendingUserInput?.requestId ?? null, threadError, }); - const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; + // 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, @@ -2491,16 +2577,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( @@ -2514,8 +2630,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, @@ -2563,6 +2685,19 @@ function ChatViewContent(props: ChatViewProps) { return byUserMessageId; }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]); + const activeRunningTurnId = activeThread?.session?.activeTurnId ?? null; + const lastUserMessagePopWindowOpen = + supportsThreadTurnRetraction && + isLastUserMessagePopWindowOpen({ + phase, + activeTurnId: activeRunningTurnId, + timelineEntries, + localTurnStartPending: isSendBusy, + retractionPending, + }); + const lastUserMessagePopCandidate = lastUserMessagePopWindowOpen + ? findLastUserMessagePopCandidate({ messages: timelineMessages }) + : null; const gitCwd = activeProject ? projectScriptCwd({ @@ -4770,50 +4905,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, @@ -4847,6 +4991,25 @@ function ChatViewContent(props: ChatViewProps) { }), ); }; + 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 || isSendBusy || @@ -5001,6 +5164,21 @@ function ChatViewContent(props: ChatViewProps) { return; } + const composerImagesSnapshot = [...composerImages]; + const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; + const composerElementContextsSnapshot = [...composerElementContexts]; + const composerPreviewAnnotationsSnapshot = [...composerPreviewAnnotations]; + const composerReviewCommentsSnapshot: ReviewCommentContext[] = [...composerReviewComments]; + const messageIdForSend = newMessageId(); + sentMessageRecoveryContextByMessageId.clear(); + sentMessageRecoveryContextByMessageId.set(messageIdForSend, { + envMode: sendEnvMode, + baseBranch: activeThreadBranch, + startFromOrigin, + prompt: promptForSend, + images: composerImagesSnapshot, + }); + preDispatchCancellationLatchRef.current.arm(messageIdForSend); sendInFlightRef.current = true; if (isDraftHeroState && activeThreadKey) { let resolveDockStarted: (() => void) | undefined; @@ -5017,13 +5195,17 @@ function ChatViewContent(props: ChatViewProps) { void dockTransition.catch(() => resolveDockStarted?.()); await dockStarted; } + if (preDispatchCancellationLatchRef.current.isCancelled(messageIdForSend)) { + sentMessageRecoveryContextByMessageId.delete(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, @@ -5036,14 +5218,13 @@ function ChatViewContent(props: ChatViewProps) { messageTextWithPreviewAnnotations, composerReviewCommentsSnapshot, ); - const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); const outgoingMessageText = formatOutgoingPrompt({ provider: ctxSelectedProvider, 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) => ({ @@ -5171,6 +5352,7 @@ function ChatViewContent(props: ChatViewProps) { } let turnStartSucceeded = false; + let preDispatchCancelled = false; if (failure === null && turnAttachmentsResult._tag === "Success") { const bootstrap = isLocalDraftThread || baseBranchForWorktree @@ -5202,34 +5384,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 && @@ -5265,7 +5452,7 @@ function ChatViewContent(props: ChatViewProps) { detectTrigger: true, }); } - if (!isAtomCommandInterrupted(failure)) { + if (failure !== null && !isAtomCommandInterrupted(failure)) { const error = squashAtomCommandFailure(failure); setThreadError( threadIdForSend, @@ -5273,8 +5460,10 @@ function ChatViewContent(props: ChatViewProps) { ); } } + preDispatchCancellationLatchRef.current.clear(messageIdForSend); sendInFlightRef.current = false; if (!turnStartSucceeded) { + sentMessageRecoveryContextByMessageId.delete(messageIdForSend); setDockedDraftHeroThreadKey((currentThreadKey) => currentThreadKey === activeThreadKey ? null : currentThreadKey, ); @@ -5282,20 +5471,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) => { @@ -5470,6 +5666,7 @@ function ChatViewContent(props: ChatViewProps) { if ( !activeThread || !isServerThread || + retractionPending || isSendBusy || isConnecting || sendInFlightRef.current @@ -5612,6 +5809,7 @@ function ChatViewContent(props: ChatViewProps) { isConnecting, isSendBusy, isServerThread, + retractionPending, localCheckoutBranchMismatch, persistThreadSettingsForNextTurn, resetLocalDispatch, @@ -5630,6 +5828,7 @@ function ChatViewContent(props: ChatViewProps) { !activeProject || !activeProposedPlan || !isServerThread || + retractionPending || isSendBusy || isConnecting || activeEnvironmentUnavailable || @@ -5773,6 +5972,7 @@ function ChatViewContent(props: ChatViewProps) { isConnecting, isSendBusy, isServerThread, + retractionPending, navigate, resetLocalDispatch, runtimeMode, @@ -5946,6 +6146,107 @@ function ChatViewContent(props: ChatViewProps) { } void onRevertToTurnCountRef.current(targetTurnCount); }, []); + 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, + activeThreadBranch: lastUserMessageRecoveryContext + ? lastUserMessageRecoveryContext.baseBranch + : 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, + interactionMode, + envMode: lastUserMessageRecoveryContext?.envMode ?? envMode, + startFromOrigin: lastUserMessageRecoveryContext?.startFromOrigin ?? startFromOrigin, + composerRef, + promptRef, + composerImagesRef, + onOptimisticRetractionStarted, + onOptimisticRetractionFailed, + navigateToRecoveryDraft, + setThreadError, + }); + + 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 handled = runChatEscapeAction({ + cancelPreDispatch: () => preDispatchCancellationLatchRef.current.cancel(), + retractionPending, + threadTurnRetraction: supportsThreadTurnRetraction, + hasRetractionCandidate: lastUserMessagePopCandidate !== null, + focusComposer: scheduleComposerFocus, + retractLastUserMessage: () => void onPopLastUserMessage(), + }); + if (!handled) return; + + event.preventDefault(); + event.stopPropagation(); + }; + + window.addEventListener("keydown", onWindowKeyDown); + return () => window.removeEventListener("keydown", onWindowKeyDown); + }, [ + lastUserMessagePopCandidate, + onPopLastUserMessage, + retractionPending, + scheduleComposerFocus, + supportsThreadTurnRetraction, + ]); // Empty state: no active thread if (!activeThread) { @@ -6169,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} @@ -6293,9 +6594,9 @@ function ChatViewContent(props: ChatViewProps) { isLocalDraftThread={isLocalDraftThread} forceExpandedOnMobile={forceExpandedMobileComposer && isDraftHeroState} projectSelectionRequired={isLocalDraftThread && activeProject === null} - phase={phase} + phase={presentedPhase} isConnecting={isConnecting} - isSendBusy={isSendBusy} + isSendBusy={isSendBusy || heldSendPending} sendDisabledReason={threadDetailLoading ? "Messages loading" : null} isPreparingWorktree={isPreparingWorktree} environmentUnavailable={activeEnvironmentUnavailableState} 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 18fa2a6708c1..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, @@ -568,7 +569,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 ( @@ -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/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 38490aeaa7e4..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; @@ -2545,6 +2539,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setIsComposerModelPickerOpen((open) => !open); }, isModelPickerOpen: () => isComposerModelPickerOpen, + isEscapeGateOpen: () => + composerMenuOpenRef.current || isStashMenuOpen || isComposerModelPickerOpen, readSnapshot: () => { return readComposerSnapshot(); }, @@ -2635,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.test.ts b/apps/web/src/components/chat/RetractionRecoveryHandoff.test.ts new file mode 100644 index 000000000000..49dfba980f26 --- /dev/null +++ b/apps/web/src/components/chat/RetractionRecoveryHandoff.test.ts @@ -0,0 +1,304 @@ +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("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(); + 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 })).toBe( + "thread-restored", + ); + expect(navigate).not.toHaveBeenCalled(); + 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(navigate).not.toHaveBeenCalled(); + 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(navigate).not.toHaveBeenCalled(); + 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 new file mode 100644 index 000000000000..2d30cea1ef7c --- /dev/null +++ b/apps/web/src/components/chat/RetractionRecoveryHandoff.tsx @@ -0,0 +1,328 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { + OrchestrationThreadActivity, + OrchestrationThreadTurnRetraction, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/unstable/reactivity"; +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, + discardRetractionRecovery, + findCorrelatedRetractionFailure, + handoffCompletedFirstMessageRetraction, + handoffCompletedMidThreadRetraction, + type PendingRetractionRecovery, + restoreRetractionRecoveryToThread, + surfaceRetractionRecoveryDraft, + useRetractionRecoveryStore, +} from "./lastUserMessageRecovery"; + +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 { + 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({ + 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, + ...(input.signal.kind === "source-thread-gone" ? { navigate: input.navigate } : {}), + }) + ? "draft-surfaced" + : null; +} + +function PendingRetractionRecoveryWatcher(props: { + recovery: PendingRetractionRecovery; + 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.recovery.sourceThreadRef.environmentId, + input: { threadId: props.recovery.sourceThreadRef.threadId, turnLimit: 1 }, + }), + ); + const liveCompletion = Option.getOrNull(AsyncResult.value(result)); + 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(() => { + 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, + }); + 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 preserved because the turn could not be retracted", + description: signal.detail, + }), + ); + } else if (signal.kind === "stale") { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Recovery draft preserved", + description: + "The retraction result could not be confirmed after 60 seconds, so your message is visible as a draft in the sidebar.", + }), + ); + } + }, [props.navigate, props.recovery, recoveryDraftReady, signal]); + + return null; +} + +export function RetractionRecoveryHandoff(props: { + navigate: (input: { + to: "/draft/$draftId"; + params: { draftId: DraftId }; + replace: true; + }) => unknown; +}) { + // 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) => ( + + )); +} 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..fe29b0bf1105 --- /dev/null +++ b/apps/web/src/components/chat/chatEscapeTrigger.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + markChatEscapeHandled, + runChatEscapeAction, + 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); + }); + + 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 new file mode 100644 index 000000000000..ef9b6caf7738 --- /dev/null +++ b/apps/web/src/components/chat/chatEscapeTrigger.ts @@ -0,0 +1,67 @@ +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) + ); +} + +/** 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/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/lastUserMessagePop.test.ts b/apps/web/src/components/chat/lastUserMessagePop.test.ts new file mode 100644 index 000000000000..43cb4bdcaa29 --- /dev/null +++ b/apps/web/src/components/chat/lastUserMessagePop.test.ts @@ -0,0 +1,268 @@ +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); + }); + + 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", () => { + it("selects an optimistic first message for turn zero", () => { + const optimistic = message({ id: "optimistic-first", role: "user" }); + expect( + findLastUserMessagePopCandidate({ + messages: [optimistic], + }), + ).toEqual({ message: optimistic }); + }); + + 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], + }), + ).toEqual({ message: optimistic }); + }); + + it("selects the newest user message without checkpoint-race heuristics", () => { + const completed = message({ id: "completed-user", role: "user" }); + expect( + findLastUserMessagePopCandidate({ + messages: [completed], + }), + ).toEqual({ message: completed }); + }); +}); + +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..0f2a27fb1829 --- /dev/null +++ b/apps/web/src/components/chat/lastUserMessagePop.ts @@ -0,0 +1,125 @@ +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 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; +} + +export function findLastUserMessagePopCandidate(input: { + messages: ReadonlyArray; +}): LastUserMessagePopCandidate | null { + const message = input.messages.findLast((entry) => entry.role === "user"); + 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; + } + + 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/apps/web/src/components/chat/lastUserMessageRecovery.test.ts b/apps/web/src/components/chat/lastUserMessageRecovery.test.ts new file mode 100644 index 000000000000..acc872b3a78b --- /dev/null +++ b/apps/web/src/components/chat/lastUserMessageRecovery.test.ts @@ -0,0 +1,458 @@ +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 { + applyOptimisticRetractionRecoveryToThread, + buildRetractionCommandInput, + handoffCompletedFirstMessageRetraction, + handoffCompletedMidThreadRetraction, + findCorrelatedRetractionFailure, + restoreRetractionRecoveryToThread, + snapshotLastUserMessageRecovery, + surfaceRetractionRecoveryDraft, + 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 messageId = MessageId.make("message-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("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, + 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, + messageId, + 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, + 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, + 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", + params: { draftId }, + replace: true, + }); + 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" }); + 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("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, + 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 new file mode 100644 index 000000000000..9d28c97a5b13 --- /dev/null +++ b/apps/web/src/components/chat/lastUserMessageRecovery.ts @@ -0,0 +1,473 @@ +import type { + CommandId, + MessageId, + ModelSelection, + OrchestrationThreadActivity, + ProviderInteractionMode, + RuntimeMode, + ScopedProjectRef, + ScopedThreadRef, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } 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 { 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; + firstUserMessage?: boolean; + optimisticDestination?: "thread" | "draft"; +} + +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; + setOptimisticDestination: (requestId: CommandId, destination: "thread" | "draft") => 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 }, + })), + 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; + 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; + messageId: MessageId; + sourceThreadRef: ScopedThreadRef; + projectRef: ScopedProjectRef; + draftId: DraftId; + futureThreadId: ThreadId; + createdAt: string; + bundle: LastUserMessageRestoreBundle; + firstUserMessage?: boolean; + optimisticDestination?: "thread" | "draft"; + 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.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) => { + 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] : [])), + ); + + return { + draftId: input.draftId, + 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 { + 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[]; + 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; + const recovery = useRetractionRecoveryStore.getState().byRequestId[metadata.requestId]; + if (recovery?.optimisticDestination === "thread") { + discardRetractionRecovery({ requestId: metadata.requestId }); + return null; + } + return restoreRetractionRecoveryToThread({ + requestId: metadata.requestId, + sourceThreadRef: { + environmentId: input.environmentId, + threadId: input.completion.threadId, + }, + }); +} + +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; + + 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; + retainRecovery?: boolean; +}): boolean { + const recovery = useRetractionRecoveryStore.getState().byRequestId[input.requestId]; + + if ( + !recovery || + recovery.sourceThreadRef.environmentId !== input.sourceThreadRef.environmentId || + recovery.sourceThreadRef.threadId !== input.sourceThreadRef.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, + }); + if (!input.retainRecovery) { + useRetractionRecoveryStore.getState().forget(input.requestId); + } + if (input.navigate) { + void input.navigate({ + to: "/draft/$draftId", + params: { draftId: recovery.draftId }, + replace: true, + }); + } + return true; +} 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/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/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), + }; +} diff --git a/apps/web/src/components/chat/useLastUserMessageRetraction.ts b/apps/web/src/components/chat/useLastUserMessageRetraction.ts new file mode 100644 index 000000000000..83edd380f9a9 --- /dev/null +++ b/apps/web/src/components/chat/useLastUserMessageRetraction.ts @@ -0,0 +1,349 @@ +import type { + CommandId, + MessageId, + 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, + 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."; +} + +export function useLastUserMessageRetraction(input: { + activeThread: Thread | undefined; + activeProjectRef: ScopedProjectRef | null; + activeThreadBranch: string | null; + activeEnvironmentUnavailable: boolean; + candidate: LastUserMessagePopCandidate | null; + isFirstUserMessage: boolean; + optimisticBundle?: { + prompt: string; + images: ComposerImageAttachment[]; + }; + pendingRecovery: PendingRetractionRecovery | null; + retractionPending: boolean; + runtimeMode: RuntimeMode; + interactionMode: ProviderInteractionMode; + envMode: DraftThreadEnvMode; + startFromOrigin: boolean; + 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 { + activeThread, + activeProjectRef, + activeThreadBranch, + activeEnvironmentUnavailable, + candidate, + isFirstUserMessage, + optimisticBundle, + pendingRecovery, + retractionPending, + runtimeMode, + interactionMode, + envMode, + startFromOrigin, + composerRef, + promptRef, + composerImagesRef, + onOptimisticRetractionStarted, + onOptimisticRetractionFailed, + navigateToRecoveryDraft, + 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 = + recovery.optimisticDestination === "thread" + ? (discardRetractionRecovery({ requestId: recovery.requestId }), null) + : restoreRetractionRecoveryToThread({ + requestId: recovery.requestId, + sourceThreadRef: recovery.sourceThreadRef, + }); + if (restored) applyRestoredComposer(restored); + onOptimisticRetractionFailed({ + requestId: recovery.requestId, + messageId: recovery.messageId, + }); + setThreadError(recovery.sourceThreadRef.threadId, detail); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Message restored, but the turn could not be retracted", + description: detail, + }), + ); + }, + [applyRestoredComposer, onOptimisticRetractionFailed, 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") { + 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" && + error !== null && + "_tag" in error && + error._tag === "EnvironmentRpcUnavailableError" + ) { + return; + } + failPendingRetraction(recovery, errorMessage(error)); + }, + [failPendingRetraction, isFirstUserMessage, navigateToRecoveryDraft, 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 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; + }, + }); + + 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(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.`, + }); + } + }); + } + }, [ + activeProjectRef, + activeThread, + activeThreadBranch, + candidate, + dispatchPendingRetraction, + envMode, + interactionMode, + isFirstUserMessage, + onOptimisticRetractionStarted, + optimisticBundle, + retractionPending, + runtimeMode, + startFromOrigin, + applyRestoredComposer, + ]); +} 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/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 ( <> + ); } 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, + ), + ), + }, +); diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index f7f6facbe594..681f37407bf4 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -55,6 +55,13 @@ 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. +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 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. + 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/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), diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 8b2479c7a349..b2c28cf0de3a 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -880,6 +880,210 @@ 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("retraction projection", () => { + it("tracks requested and completed retractions incrementally", () => { + 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", + }, + }; + 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, + }, + }, + }); + 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 completedAt = "2026-04-01T14:00:03.000Z"; + const completed = applyThreadDetailEvent(requested.thread, { + ...baseEventFields, + sequence: 17, + occurredAt: completedAt, + aggregateKind: "thread" as const, + aggregateId: ThreadId.make("thread-1"), + type: "thread.reverted" as const, + 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, + }, + }, + }); + expect(completed.kind).toBe("updated"); + if (completed.kind === "updated") { + expect(completed.thread.turnRetraction).toMatchObject({ + requestId: "cmd-retract", + status: "completed", + completedAt, + failedAt: null, + }); + } + }); + + 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: failedAt, + aggregateKind: "thread" as const, + aggregateId: ThreadId.make("thread-1"), + type: "thread.activity-appended" as const, + payload: { + threadId: ThreadId.make("thread-1"), + 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, + }, + }, + }); + + expect(failed.kind).toBe("updated"); + if (failed.kind === "updated") { + expect(failed.thread.turnRetraction).toMatchObject({ + requestId: "cmd-retract", + status: "failed", + completedAt: null, + failedAt, + }); + } + }); }); describe("no-op events", () => { diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 970fd94b1a16..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, }, }; @@ -522,7 +549,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)), @@ -554,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, }, }; @@ -562,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 @@ -588,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, + }, }; } @@ -654,16 +732,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)); } 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..7edba09cf797 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -5,12 +5,14 @@ import * as Schema from "effect/Schema"; import { DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, + ClientOrchestrationCommand, ModelSelection, OrchestrationCommand, OrchestrationEvent, OrchestrationGetFullThreadDiffInput, OrchestrationGetTurnDiffInput, OrchestrationLatestTurn, + OrchestrationReadModel, ProjectCreatedPayload, ProjectMetaUpdatedPayload, OrchestrationProposedPlan, @@ -23,6 +25,9 @@ import { ThreadCreatedPayload, ThreadTurnDiff, ThreadTurnStartRequestedPayload, + ThreadTurnRetractCommand, + ThreadTurnInterruptRequestedPayload, + ThreadRevertedPayload, } from "./orchestration.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; @@ -37,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); @@ -51,8 +57,170 @@ 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("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({ + 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"); + + 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); + }), +); + +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* () { @@ -385,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", @@ -402,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, @@ -418,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, + ); }), ); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 35fef721efa7..1d86c82f4f20 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -361,6 +361,36 @@ 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 ThreadTurnProviderSendState = Schema.Literals(["unclaimed", "claimed", "cancelled"]); +export type ThreadTurnProviderSendState = typeof ThreadTurnProviderSendState.Type; + +export const OrchestrationThreadTurnRetraction = Schema.Struct({ + requestId: CommandId, + messageId: MessageId, + baselineTurnCount: NonNegativeInt, + 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, + completedAt: Schema.NullOr(IsoDateTime), + failedAt: Schema.NullOr(IsoDateTime), +}); +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, @@ -372,6 +402,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, @@ -397,6 +430,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 +891,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 +958,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadInteractionModeSetCommand, ThreadTurnStartCommand, ThreadTurnInterruptCommand, + ThreadTurnRetractCommand, ThreadApprovalRespondCommand, ThreadUserInputRespondCommand, ThreadCheckpointRevertCommand, @@ -943,6 +987,7 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadInteractionModeSetCommand, ClientThreadTurnStartCommand, ThreadTurnInterruptCommand, + ThreadTurnRetractCommand, ThreadApprovalRespondCommand, ThreadUserInputRespondCommand, ThreadCheckpointRevertCommand, @@ -1015,6 +1060,23 @@ const ThreadRevertCompleteCommand = Schema.Struct({ createdAt: IsoDateTime, }); +const ThreadTurnRetractCompleteCommand = Schema.Struct({ + type: Schema.Literal("thread.turn.retract.complete"), + commandId: CommandId, + threadId: ThreadId, + requestId: CommandId, + targetTurnId: Schema.optional(TurnId), + 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, @@ -1031,6 +1093,8 @@ const InternalOrchestrationCommand = Schema.Union([ ThreadTurnDiffCompleteCommand, ThreadActivityAppendCommand, ThreadRevertCompleteCommand, + ThreadTurnRetractCompleteCommand, + ThreadManagedWorktreeRecordCommand, ThreadTitleRegenerationCompleteCommand, ]); export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type; @@ -1126,6 +1190,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({ @@ -1201,6 +1273,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, }); @@ -1247,6 +1320,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 +1354,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({