diff --git a/.agents/skills/verify-mcode/SKILL.md b/.agents/skills/verify-mcode/SKILL.md index 51360fc6e..cfc53cb9a 100644 --- a/.agents/skills/verify-mcode/SKILL.md +++ b/.agents/skills/verify-mcode/SKILL.md @@ -73,7 +73,7 @@ Composer-queue `check` runs the deterministic verifier checks. Its tests do not Use `runtime live --provider codex --model gpt-5.6-terra --scenario subagent --confirm-provider-call` for the Codex V2 subagent persistence journey. Read `references/features/codex-subagent-view.md` and complete its Electron steps for navigation, color, and reload proof. -Run `runtime worktree-setup --confirm-cleanup` after changes to managed-worktree creation or automatic Setup. It creates an owned Git project, starts a queued New-worktree turn, proves automatic Setup reads the completed checkout, and removes all generated state without making a provider call. If a proof is interrupted, run `runtime worktree-setup-cleanup --confirm-cleanup` before retrying. +Run `runtime worktree-setup --confirm-cleanup` after changes to managed-worktree creation or automatic Setup. It creates an owned Git project, drops the first `agent.createAndSend` response, then retries the same request after a second client observes its bound startup. It proves one persisted thread, worktree, and queued first turn, checks that Setup reads the completed checkout, and removes all generated state without making a provider call. If a proof is interrupted, run `runtime worktree-setup-cleanup --confirm-cleanup` before retrying. Run `runtime console-audit` after changes to child-process spawn, startup, or cleanup code on Windows. It lists visible windows owned by runtime-process descendants; the server tree must never own one. Use `--watch ` across a runtime restart or packaged launch to catch transient flashes, and read `references/features/windows-console-hygiene.md` for proof and Terminal-hosting limits. diff --git a/.agents/skills/verify-mcode/references/features/README.md b/.agents/skills/verify-mcode/references/features/README.md index f9c357201..ae99cca14 100644 --- a/.agents/skills/verify-mcode/references/features/README.md +++ b/.agents/skills/verify-mcode/references/features/README.md @@ -33,7 +33,7 @@ | Codex subagents retain task, state, transcript, navigation, and identity color across both protocol shapes | [Codex subagent view](codex-subagent-view.md) | `runtime check`, Terra `runtime live --scenario subagent`, and Electron UI proof | | Thread deletion and provider-session cleanup retain runtime ownership | [Resource lifecycle](resource-lifecycle.md) | `runtime check` and controlled thread cleanup | | Pointer-selected assistant text opens a compact comment editor and retains native copy actions | [Selected text comments](selected-text-comments.md) | Electron public UI proof | -| A New worktree completes checkout before automatic Setup starts and can cancel a held Setup safely | [Managed-worktree Setup readiness](managed-worktree-setup.md) | `runtime worktree-setup --confirm-cleanup` and `runtime check` | +| A lost New-worktree create response can be retried with one startup ID; checkout completes before automatic Setup and held Setup can be cancelled safely | [Managed-worktree Setup readiness](managed-worktree-setup.md) | `runtime worktree-setup --confirm-cleanup` and `runtime check` | | Local, managed-worktree, and PR-created threads show truthful startup progress and remove it after success | [Thread startup progress](thread-startup-progress.md) | Electron public UI proof and focused startup tests | | A user completes a worktree thread and the app schedules its cleanup | [Completed-thread cleanup](completed-thread-cleanup.md) | `thread-lifecycle proof --confirm-cleanup` and `thread-lifecycle check` | | Queued composer messages continue in FIFO order after completion and stay paused after Stop | [Composer queue](composer-queue.md) | `composer-queue proof --cursor-model --allow-enable-cursor --confirm-provider-calls --confirm-cleanup` | @@ -77,7 +77,6 @@ Read [Multi-surface journeys](multi-surface-journeys.md) for a workflow that cro - The selected-text-comments live proof covers rendered source cards. It does not load a source that is absent from the current transcript window. - Saved-comment edit, delete, and focus return need the card and marker entry points planned for #1557 and #1558. - Thread retention has a minimum of one day. The live proof shows the scheduled deletion, while focused integration checks show later worktree cleanup. -- If `agent.createAndSend` creates a thread but its response is lost before the ID arrives, the public RPC has no safe cleanup identifier. Record this as a coverage gap. Do not delete threads by heuristic. - If `thread.create` creates a managed-worktree thread but its response is lost before the ID arrives, the lifecycle verifier has no safe cleanup identifier. Record this as a coverage gap. Do not delete threads by heuristic. - The ACP narrative fixture does not emit permission prompts, plans, subagents, or usage updates; those paths still need live provider coverage or focused tests. - The Windows console audit cannot attribute Windows Terminal-hosted console windows to a process tree; watch-mode sightings need manual correlation, and an uncorrelated sighting is a gap rather than a pass or defect. diff --git a/.agents/skills/verify-mcode/references/features/managed-worktree-setup.md b/.agents/skills/verify-mcode/references/features/managed-worktree-setup.md index 89d43a99a..3f4b4be2f 100644 --- a/.agents/skills/verify-mcode/references/features/managed-worktree-setup.md +++ b/.agents/skills/verify-mcode/references/features/managed-worktree-setup.md @@ -3,6 +3,7 @@ ## Behavior - Mcode returns a managed worktree only after Git completes its checkout. +- A second client can retry the same create request after losing the first response. The startup ID still identifies one bound startup, one thread, one worktree, and one persisted queued first turn. - The first turn enters the automatic Setup gate after the managed worktree exists. - Automatic Setup runs in the managed worktree, not the source workspace. - Setup reads every tracked fixture file before it writes the proof marker. @@ -15,9 +16,9 @@ 1. Start the runtime and run `runtime health`. 2. Run `runtime check` for the focused Git failure regression. 3. Run `runtime worktree-setup --confirm-cleanup`. -4. Inspect the redacted receipt. It must report the complete checkout, a terminal cancelled startup, a cancelled Setup step, an interrupted Setup attempt, a stopped fixture process, no agent runtime, no provider call, and successful cleanup. +4. Inspect the redacted receipt. `lostResponse` must report one bound startup, thread, worktree, queued turn, and persisted prompt after the retry. It must also report the complete checkout, a terminal cancelled startup, a cancelled Setup step, an interrupted Setup attempt, a stopped fixture process, no agent runtime, no provider call, and successful cleanup. -The verifier holds Setup open after the marker. It records the fixture process ID, then uses `thread.startup.cancel`. The first turn stays queued, so this proof does not call a provider. +The verifier discards the first `agent.createAndSend` response, observes the bound startup through a second authenticated socket, closes the first socket, and retries the exact request. It holds Setup open after the marker, records the fixture process ID, then uses `thread.startup.cancel`. The first turn stays queued, so this proof does not call a provider. ## Failure handling diff --git a/.agents/skills/verify-mcode/references/features/multi-surface-journeys.md b/.agents/skills/verify-mcode/references/features/multi-surface-journeys.md index e5235ee91..fdcb8c8d6 100644 --- a/.agents/skills/verify-mcode/references/features/multi-surface-journeys.md +++ b/.agents/skills/verify-mcode/references/features/multi-surface-journeys.md @@ -143,8 +143,8 @@ Use `runtime check` for both protocol shapes. Use `runtime live --scenario subag ## Managed-worktree Setup readiness -1. Create a New-worktree first turn through the public agent API. -2. Wait for Git to finish the checkout before the thread is returned. +1. Send a New-worktree first turn through the public agent API with one startup ID and discard its response. +2. From a second client, observe that the startup is bound to a thread, close the first client, and retry the exact request. The response must wait for Git checkout to finish. Confirm one durable startup, thread, worktree, queued first turn, and user prompt. 3. Keep the first turn queued while automatic Setup reads every tracked fixture file, records its PID, and writes its proof marker. 4. Cancel startup through the public API. Confirm the terminal startup state, stopped Setup process, interrupted Setup attempt, queued first turn, and no agent runtime. 5. Remove the generated thread, worktree, workspace, and fixture repository. diff --git a/.agents/skills/verify-mcode/scripts/runtime.mjs b/.agents/skills/verify-mcode/scripts/runtime.mjs index 96f0c3f06..00d5351f0 100644 --- a/.agents/skills/verify-mcode/scripts/runtime.mjs +++ b/.agents/skills/verify-mcode/scripts/runtime.mjs @@ -112,7 +112,7 @@ Commands: with the project-scoped always-allow option, records the .devin config file it writes, and removes that grant after the proof. worktree-setup --confirm-cleanup - Create an owned Git project, verify its held automatic Setup gate, cancel Setup through the public API, then remove the project, workspace, thread, and worktree. Does not make a provider call. + Create an owned Git project, lose the first create response, retry with the same startup ID, verify held Setup, cancel it, then remove owned state. Does not make a provider call. worktree-setup-cleanup --confirm-cleanup Remove a retained worktree-setup run after an interrupted proof. diagnostics @@ -544,14 +544,25 @@ async function worktreeSetup(repoRoot) { const report = createWorktreeSetupReport(); const deadline = Date.now() + LIVE_TIMEOUT_MS; let socket = null; + let firstSocket = null; try { await health(repoRoot); - socket = await openSocket(repoRoot, readRuntime(repoRoot)); + firstSocket = await openSocket(repoRoot, readRuntime(repoRoot)); + socket = firstSocket; await createWorktreeSetupFixture(run.record); const workspace = await createWorktreeSetupWorkspace(socket, run.evidenceDirectory, run.record, deadline); await saveWorktreeSetupConfiguration(socket, workspace.id, deadline); - const thread = await createWorktreeSetupThread(socket, workspace.id, run.record.startupId, deadline); + const request = worktreeSetupCreateRequest(workspace.id, run.record.startupId); + await firstSocket.sendWithoutResponse("agent.createAndSend", request); + socket = await openSocket(repoRoot, readRuntime(repoRoot)); + const boundThreadId = await waitForBoundWorktreeSetup(socket, run.record, deadline); + await firstSocket.close(); + const thread = await createWorktreeSetupThread(socket, request, deadline); + if (thread.id !== boundThreadId) { + throw actionable("The retried create request returned a different thread", "Run worktree-setup-cleanup with --confirm-cleanup, inspect runtime diagnostics, then retry."); + } recordWorktreeSetupThread(run.evidenceDirectory, run.record, thread); + report.lostResponse = await proveSingleWorktreeSetupCreation(socket, run.record, request.content, deadline); report.checkout = await proveWorktreeSetup(socket, run.evidenceDirectory, run.record, deadline); report.cancellation = await cancelWorktreeSetup(socket, run.record, deadline); } catch (error) { @@ -563,6 +574,7 @@ async function worktreeSetup(repoRoot) { report.cleanup.failure = safeError(error); } await socket?.close(); + if (firstSocket && firstSocket !== socket) await firstSocket.close(); } return writeWorktreeSetupReceipt(repoRoot, run.receiptPath, report); } @@ -570,6 +582,7 @@ async function worktreeSetup(repoRoot) { function createWorktreeSetupReport() { return { command: "worktree-setup", + lostResponse: null, checkout: null, cancellation: null, cleanup: { @@ -705,8 +718,8 @@ function worktreeSetupFixtureScript() { ].join("\n"); } -async function createWorktreeSetupThread(socket, workspaceId, startupId, deadline) { - const thread = await socket.rpc("agent.createAndSend", { +function worktreeSetupCreateRequest(workspaceId, startupId) { + return { workspaceId, startupId, content: "Verify automatic Setup checkout readiness.", @@ -715,7 +728,24 @@ async function createWorktreeSetupThread(socket, workspaceId, startupId, deadlin permissionMode: "full", mode: "worktree", branch: "main", - }, deadline); + }; +} + +async function waitForBoundWorktreeSetup(socket, record, deadline) { + while (Date.now() < deadline) { + const startup = await socket.rpc("thread.startup.get", { startupId: record.startupId }, deadline); + if (startup?.threadId) { + if (startup.workspaceId === record.workspaceId && startup.kind === "managed-worktree") return startup.threadId; + break; + } + if (startup && ["failed", "cancelled", "interrupted"].includes(startup.state)) break; + await delayUntil(deadline); + } + throw actionable("The first create request did not bind an owned managed-worktree startup", "Run worktree-setup-cleanup with --confirm-cleanup, inspect runtime diagnostics, then retry."); +} + +async function createWorktreeSetupThread(socket, request, deadline) { + const thread = await socket.rpc("agent.createAndSend", request, deadline); if ( typeof thread?.id !== "string" || typeof thread?.worktree_path !== "string" || @@ -728,6 +758,49 @@ async function createWorktreeSetupThread(socket, workspaceId, startupId, deadlin return thread; } +async function proveSingleWorktreeSetupCreation(socket, record, prompt, deadline) { + const [threads, startups, automatic, messages] = await Promise.all([ + socket.rpc("thread.list", { workspaceId: record.workspaceId }, deadline), + socket.rpc("thread.startup.list", { workspaceId: record.workspaceId }, deadline), + socket.rpc("workspace.environment.automaticSetup.get", { threadId: record.threadId }, deadline), + socket.rpc("message.list", { threadId: record.threadId, limit: 10 }, deadline), + ]); + if (!hasOneWorktreeSetupThread(threads, record.threadId)) { + throw actionable("The lost-response retry did not retain exactly one thread", "Run worktree-setup-cleanup with --confirm-cleanup, inspect runtime diagnostics, then retry."); + } + if (!hasOneBoundWorktreeSetup(startups, record)) { + throw actionable("The lost-response retry did not retain exactly one bound startup", "Run worktree-setup-cleanup with --confirm-cleanup, inspect runtime diagnostics, then retry."); + } + if (!hasQueuedFirstTurn(automatic)) { + throw actionable("The lost-response retry did not retain exactly one queued first Turn", "Run worktree-setup-cleanup with --confirm-cleanup, inspect runtime diagnostics, then retry."); + } + if (!hasOnePersistedWorktreeSetupPrompt(messages, automatic.queuedTurns[0].messageId, prompt)) { + throw actionable("The lost-response retry did not retain one durable first-turn prompt", "Run worktree-setup-cleanup with --confirm-cleanup, inspect runtime diagnostics, then retry."); + } + const listed = await runFixtureGit(record.sourceRepositoryPath, ["worktree", "list", "--porcelain"]); + const worktrees = fixtureWorktreePaths(listed).filter((path) => !pathsMatch(path, record.sourceRepositoryPath)); + if (worktrees.length !== 1 || !pathsMatch(worktrees[0], record.worktreePath)) { + throw actionable("The lost-response retry did not retain exactly one managed worktree", "Run worktree-setup-cleanup with --confirm-cleanup, inspect runtime diagnostics, then retry."); + } + return { firstResponseUnavailable: true, sameStartupIdRetried: true, oneBoundStartup: true, oneThread: true, oneWorktree: true, oneQueuedFirstTurn: true, onePersistedPrompt: true }; +} + +function hasOneWorktreeSetupThread(threads, threadId) { + return Array.isArray(threads) && threads.length === 1 && threads[0]?.id === threadId; +} + +function hasOneBoundWorktreeSetup(startups, record) { + return Array.isArray(startups?.records) && startups.records.length === 1 + && startups.records[0]?.startupId === record.startupId + && startups.records[0]?.threadId === record.threadId; +} + +function hasOnePersistedWorktreeSetupPrompt(result, messageId, prompt) { + return Array.isArray(result?.messages) && result.messages.length === 1 && result.hasMore === false + && result.messages[0]?.id === messageId && result.messages[0]?.role === "user" + && result.messages[0]?.content === prompt; +} + function recordWorktreeSetupThread(evidenceDirectory, record, thread) { record.threadId = thread.id; record.worktreePath = thread.worktree_path; @@ -1039,6 +1112,7 @@ function writeWorktreeSetupReceipt(repoRoot, receiptPath, report) { const receipt = { ok: report.failure === null && report.cleanup.failure === null, command: report.command, + lostResponse: report.lostResponse, checkout: report.checkout, cancellation: report.cancellation, cleanup: report.cleanup, @@ -2265,10 +2339,25 @@ async function waitForSocketOpen(ws, state, pending) { function createSocketClient(ws, state, pending) { return { rpc: (method, params, deadline) => sendSocketRpc(ws, state, pending, method, params, deadline), + sendWithoutResponse: (method, params) => sendSocketRpcWithoutResponse(ws, state, method, params), close: () => closeSocketClient(ws, state, pending), }; } +function sendSocketRpcWithoutResponse(ws, state, method, params) { + if (state.failure) return Promise.reject(state.failure); + if (ws.readyState !== state.openState) { + return Promise.reject(socketFailure("The WebSocket is not open", "Run bun .agents/skills/verify-mcode/scripts/verify-mcode.mjs runtime health, then retry the command.")); + } + const id = `verify-${++state.counter}`; + return new Promise((resolve, reject) => { + ws.send(JSON.stringify({ id, method, params }), (error) => { + if (error) reject(socketFailure(`RPC ${method} could not be sent`, "Run bun .agents/skills/verify-mcode/scripts/verify-mcode.mjs runtime health, then retry the command.")); + else resolve(); + }); + }); +} + function sendSocketRpc(ws, state, pending, method, params, deadline) { if (state.failure) return Promise.reject(state.failure); if (ws.readyState !== state.openState) { diff --git a/.agents/skills/verify-mcode/scripts/runtime.test.mjs b/.agents/skills/verify-mcode/scripts/runtime.test.mjs index 7496511f2..2a6e03de0 100644 --- a/.agents/skills/verify-mcode/scripts/runtime.test.mjs +++ b/.agents/skills/verify-mcode/scripts/runtime.test.mjs @@ -1,6 +1,7 @@ import * as NodeAssertStrict from "node:assert/strict"; import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; +import * as NodeModule from "node:module"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeTest from "node:test"; @@ -149,6 +150,28 @@ NodeTest.test("rejects desktop verification sockets without loopback authenticat await NodeAssertStrict.rejects(openVerificationSocketUrl(process.cwd(), "ws://localhost/"), /lacks its authentication token/); }); +NodeTest.test("lost RPC response does not satisfy the next request on the same socket", async () => { + const serverRequire = NodeModule.createRequire(NodePath.join(process.cwd(), "apps", "server", "package.json")); + const { WebSocketServer } = serverRequire("ws"); + const server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + let socket; + try { + await new Promise((resolve) => server.once("listening", resolve)); + server.on("connection", (connection) => { + connection.on("message", (raw) => { + const request = JSON.parse(raw.toString()); + connection.send(JSON.stringify({ id: request.id, result: request.method })); + }); + }); + socket = await openVerificationSocketUrl(process.cwd(), `ws://127.0.0.1:${server.address().port}/?token=fixture`); + await socket.sendWithoutResponse("agent.createAndSend", { startupId: "fixture" }); + NodeAssertStrict.equal(await socket.rpc("thread.startup.get", { startupId: "fixture" }), "thread.startup.get"); + } finally { + await socket?.close(); + await new Promise((resolve) => server.close(resolve)); + } +}); + function writeTimestampedFile(path, modifiedMs) { NodeFS.mkdirSync(NodePath.dirname(path), { recursive: true }); NodeFS.writeFileSync(path, "fixture\n"); diff --git a/apps/server/drizzle/0064_fuzzy_jimmy_woo.sql b/apps/server/drizzle/0064_fuzzy_jimmy_woo.sql new file mode 100644 index 000000000..ca33d3e0f --- /dev/null +++ b/apps/server/drizzle/0064_fuzzy_jimmy_woo.sql @@ -0,0 +1 @@ +ALTER TABLE `thread_startups` ADD `request_fingerprint` text; \ No newline at end of file diff --git a/apps/server/drizzle/meta/0064_snapshot.json b/apps/server/drizzle/meta/0064_snapshot.json new file mode 100644 index 000000000..7d21153e3 --- /dev/null +++ b/apps/server/drizzle/meta/0064_snapshot.json @@ -0,0 +1,4914 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "991b4c44-b789-4554-99a3-77caea9d753d", + "prevId": "4864081e-36fd-4c20-9df8-0b852ef24985", + "tables": { + "canonical_agent_events": { + "name": "canonical_agent_events", + "columns": { + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accepted_sequence": { + "name": "accepted_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "durable_revision": { + "name": "durable_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "roster_revision": { + "name": "roster_revision", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "envelope_json": { + "name": "envelope_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "persisted_at": { + "name": "persisted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_canonical_agent_events_execution_sequence": { + "name": "idx_canonical_agent_events_execution_sequence", + "columns": [ + "execution_id", + "accepted_sequence" + ], + "isUnique": true + }, + "idx_canonical_agent_events_thread_revision": { + "name": "idx_canonical_agent_events_thread_revision", + "columns": [ + "thread_id", + "durable_revision" + ], + "isUnique": false + } + }, + "foreignKeys": { + "canonical_agent_events_thread_id_canonical_agent_threads_id_fk": { + "name": "canonical_agent_events_thread_id_canonical_agent_threads_id_fk", + "tableFrom": "canonical_agent_events", + "tableTo": "canonical_agent_threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "canonical_agent_ingest_checkpoints": { + "name": "canonical_agent_ingest_checkpoints", + "columns": { + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_accepted_sequence": { + "name": "last_accepted_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_durable_sequence": { + "name": "last_durable_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "native_cursor_json": { + "name": "native_cursor_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "terminal_outcome": { + "name": "terminal_outcome", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recovery_incident_id": { + "name": "recovery_incident_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_canonical_agent_checkpoints_thread": { + "name": "idx_canonical_agent_checkpoints_thread", + "columns": [ + "thread_id", + "updated_at" + ], + "isUnique": false + }, + "idx_canonical_agent_checkpoints_recovery_incident": { + "name": "idx_canonical_agent_checkpoints_recovery_incident", + "columns": [ + "recovery_incident_id", + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "canonical_agent_ingest_checkpoints_thread_id_canonical_agent_threads_id_fk": { + "name": "canonical_agent_ingest_checkpoints_thread_id_canonical_agent_threads_id_fk", + "tableFrom": "canonical_agent_ingest_checkpoints", + "tableTo": "canonical_agent_threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "canonical_agent_ingest_checkpoints_turn_id_canonical_agent_turns_id_fk": { + "name": "canonical_agent_ingest_checkpoints_turn_id_canonical_agent_turns_id_fk", + "tableFrom": "canonical_agent_ingest_checkpoints", + "tableTo": "canonical_agent_turns", + "columnsFrom": [ + "turn_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "canonical_agent_items": { + "name": "canonical_agent_items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_item_id": { + "name": "parent_item_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_identities_json": { + "name": "provider_identities_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "payload_json": { + "name": "payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_canonical_agent_items_turn": { + "name": "idx_canonical_agent_items_turn", + "columns": [ + "turn_id", + "created_at" + ], + "isUnique": false + }, + "idx_canonical_agent_items_thread": { + "name": "idx_canonical_agent_items_thread", + "columns": [ + "thread_id", + "created_at" + ], + "isUnique": false + }, + "idx_canonical_agent_items_created_id": { + "name": "idx_canonical_agent_items_created_id", + "columns": [ + "created_at", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "canonical_agent_items_thread_id_canonical_agent_threads_id_fk": { + "name": "canonical_agent_items_thread_id_canonical_agent_threads_id_fk", + "tableFrom": "canonical_agent_items", + "tableTo": "canonical_agent_threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "canonical_agent_items_turn_id_canonical_agent_turns_id_fk": { + "name": "canonical_agent_items_turn_id_canonical_agent_turns_id_fk", + "tableFrom": "canonical_agent_items", + "tableTo": "canonical_agent_turns", + "columnsFrom": [ + "turn_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "canonical_agent_threads": { + "name": "canonical_agent_threads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_thread_id": { + "name": "parent_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "root_thread_id": { + "name": "root_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owning_parent_thread_id": { + "name": "owning_parent_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_identities_json": { + "name": "provider_identities_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "activity_state": { + "name": "activity_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conversation_revision": { + "name": "conversation_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "roster_revision": { + "name": "roster_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_canonical_agent_threads_workspace": { + "name": "idx_canonical_agent_threads_workspace", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "idx_canonical_agent_threads_root": { + "name": "idx_canonical_agent_threads_root", + "columns": [ + "root_thread_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "canonical_agent_threads_id_threads_id_fk": { + "name": "canonical_agent_threads_id_threads_id_fk", + "tableFrom": "canonical_agent_threads", + "tableTo": "threads", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "canonical_agent_threads_workspace_id_workspaces_id_fk": { + "name": "canonical_agent_threads_workspace_id_workspaces_id_fk", + "tableFrom": "canonical_agent_threads", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "canonical_agent_turns": { + "name": "canonical_agent_turns", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger_json": { + "name": "trigger_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_mode": { + "name": "permission_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approval_review_mode": { + "name": "approval_review_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'manual'" + }, + "approval_review_reason": { + "name": "approval_review_reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'manual-requested'" + }, + "provider_identities_json": { + "name": "provider_identities_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_canonical_agent_turns_execution": { + "name": "idx_canonical_agent_turns_execution", + "columns": [ + "execution_id" + ], + "isUnique": true + }, + "idx_canonical_agent_turns_thread": { + "name": "idx_canonical_agent_turns_thread", + "columns": [ + "thread_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "canonical_agent_turns_thread_id_canonical_agent_threads_id_fk": { + "name": "canonical_agent_turns_thread_id_canonical_agent_threads_id_fk", + "tableFrom": "canonical_agent_turns", + "tableTo": "canonical_agent_threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "canonical_collaboration_actions": { + "name": "canonical_collaboration_actions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_thread_id": { + "name": "source_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_turn_id": { + "name": "source_turn_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_item_id": { + "name": "source_item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_thread_id": { + "name": "target_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_turn_id": { + "name": "target_turn_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "delivery_unknown": { + "name": "delivery_unknown", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_identities_json": { + "name": "provider_identities_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_canonical_collaboration_source": { + "name": "idx_canonical_collaboration_source", + "columns": [ + "source_thread_id", + "created_at" + ], + "isUnique": false + }, + "idx_canonical_collaboration_target": { + "name": "idx_canonical_collaboration_target", + "columns": [ + "target_thread_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "canonical_conversation_display_mappings": { + "name": "canonical_conversation_display_mappings", + "columns": { + "source_item_id": { + "name": "source_item_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "target_kind": { + "name": "target_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_updated_at": { + "name": "source_updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "idx_canonical_conversation_display_mappings_target": { + "name": "idx_canonical_conversation_display_mappings_target", + "columns": [ + "target_kind", + "target_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "canonical_conversation_display_mappings_source_item_id_canonical_agent_items_id_fk": { + "name": "canonical_conversation_display_mappings_source_item_id_canonical_agent_items_id_fk", + "tableFrom": "canonical_conversation_display_mappings", + "tableTo": "canonical_agent_items", + "columnsFrom": [ + "source_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "canonical_legacy_message_provenance": { + "name": "canonical_legacy_message_provenance", + "columns": { + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "migration_version": { + "name": "migration_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mapping_status": { + "name": "mapping_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "canonical_thread_id": { + "name": "canonical_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "canonical_turn_id": { + "name": "canonical_turn_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "canonical_item_id": { + "name": "canonical_item_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "idx_canonical_legacy_message_mapping": { + "name": "idx_canonical_legacy_message_mapping", + "columns": [ + "mapping_status", + "message_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "canonical_legacy_message_provenance_message_id_messages_id_fk": { + "name": "canonical_legacy_message_provenance_message_id_messages_id_fk", + "tableFrom": "canonical_legacy_message_provenance", + "tableTo": "messages", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "canonical_legacy_migration_checkpoints": { + "name": "canonical_legacy_migration_checkpoints", + "columns": { + "version": { + "name": "version", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "migrated_messages": { + "name": "migrated_messages", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "ambiguous_messages": { + "name": "ambiguous_messages", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "cleanup_jobs": { + "name": "cleanup_jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_path": { + "name": "workspace_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worktree_path": { + "name": "worktree_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'explicit'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "cleanup_jobs_thread_id_unique": { + "name": "cleanup_jobs_thread_id_unique", + "columns": [ + "thread_id" + ], + "isUnique": true + }, + "idx_cleanup_jobs_retry": { + "name": "idx_cleanup_jobs_retry", + "columns": [ + "next_retry_at", + "attempts", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "conversation_display_materialization_state": { + "name": "conversation_display_materialization_state", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "last_source_created_at": { + "name": "last_source_created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_source_id": { + "name": "last_source_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed": { + "name": "completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "diff_summaries": { + "name": "diff_summaries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "turn_count": { + "name": "turn_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_turn_id": { + "name": "last_turn_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "idx_diff_summaries_thread": { + "name": "idx_diff_summaries_thread", + "columns": [ + "thread_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "diff_summaries_thread_id_threads_id_fk": { + "name": "diff_summaries_thread_id_threads_id_fk", + "tableFrom": "diff_summaries", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_thread_control_deliveries": { + "name": "external_thread_control_deliveries", + "columns": { + "pairing_id": { + "name": "pairing_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "authority_epoch": { + "name": "authority_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "delivery_id": { + "name": "delivery_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'in_flight'" + }, + "result_json": { + "name": "result_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_external_thread_control_delivery_identity": { + "name": "idx_external_thread_control_delivery_identity", + "columns": [ + "pairing_id", + "authority_epoch", + "delivery_id" + ], + "isUnique": true + }, + "idx_external_thread_control_delivery_retention": { + "name": "idx_external_thread_control_delivery_retention", + "columns": [ + "pairing_id", + "status", + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_thread_control_pairings": { + "name": "external_thread_control_pairings", + "columns": { + "pairing_id": { + "name": "pairing_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_hash": { + "name": "credential_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_ids_json": { + "name": "workspace_ids_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "scopes_json": { + "name": "scopes_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "calls_per_minute": { + "name": "calls_per_minute", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "max_active_threads": { + "name": "max_active_threads", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "authority_epoch": { + "name": "authority_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "replaced_by_pairing_id": { + "name": "replaced_by_pairing_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "replaces_pairing_id": { + "name": "replaces_pairing_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "external_thread_control_pairings_credential_hash_unique": { + "name": "external_thread_control_pairings_credential_hash_unique", + "columns": [ + "credential_hash" + ], + "isUnique": true + }, + "idx_external_thread_control_pairings_integration": { + "name": "idx_external_thread_control_pairings_integration", + "columns": [ + "integration_id", + "status" + ], + "isUnique": false + }, + "idx_external_thread_control_active_integration": { + "name": "idx_external_thread_control_active_integration", + "columns": [ + "integration_id" + ], + "isUnique": true, + "where": "status = 'active'" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "hook_executions": { + "name": "hook_executions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hook_name": { + "name": "hook_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "did_block": { + "name": "did_block", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "ended_at": { + "name": "ended_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_hook_executions_message_sort_order_id": { + "name": "idx_hook_executions_message_sort_order_id", + "columns": [ + "message_id", + "sort_order", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "hook_executions_message_id_messages_id_fk": { + "name": "hook_executions_message_id_messages_id_fk", + "tableFrom": "hook_executions", + "tableTo": "messages", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "messages": { + "name": "messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tool_calls": { + "name": "tool_calls", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "files_changed": { + "name": "files_changed", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tokens_used": { + "name": "tokens_used", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attachments": { + "name": "attachments", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "preview_annotations": { + "name": "preview_annotations", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mentions": { + "name": "mentions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "selected_text_comments": { + "name": "selected_text_comments", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reply_to_message_id": { + "name": "reply_to_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quoted_text": { + "name": "quoted_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_type": { + "name": "origin_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'legacy'" + }, + "source_thread_id": { + "name": "source_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_turn_id": { + "name": "source_turn_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_provider_id": { + "name": "source_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "legacy_provenance": { + "name": "legacy_provenance", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_agent_provenance": { + "name": "parent_agent_provenance", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_internal": { + "name": "is_internal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "outcome_execution_id": { + "name": "outcome_execution_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "system_notice": { + "name": "system_notice", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_messages_thread": { + "name": "idx_messages_thread", + "columns": [ + "thread_id" + ], + "isUnique": false + }, + "idx_messages_sequence": { + "name": "idx_messages_sequence", + "columns": [ + "thread_id", + "sequence" + ], + "isUnique": false + }, + "idx_messages_thread_sequence_id": { + "name": "idx_messages_thread_sequence_id", + "columns": [ + "thread_id", + "sequence", + "id" + ], + "isUnique": false + }, + "idx_messages_notice_session_sequence": { + "name": "idx_messages_notice_session_sequence", + "columns": [ + "thread_id", + "json_extract(\"system_notice\", '$.sessionId')", + "\"sequence\" desc" + ], + "isUnique": false + } + }, + "foreignKeys": { + "messages_thread_id_threads_id_fk": { + "name": "messages_thread_id_threads_id_fk", + "tableFrom": "messages", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "messages_reply_to_message_id_messages_id_fk": { + "name": "messages_reply_to_message_id_messages_id_fk", + "tableFrom": "messages", + "tableTo": "messages", + "columnsFrom": [ + "reply_to_message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "parent_assistant_text_checkpoint_chunks": { + "name": "parent_assistant_text_checkpoint_chunks", + "columns": { + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_sequence": { + "name": "first_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_sequence": { + "name": "last_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "byte_length": { + "name": "byte_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_parent_assistant_text_checkpoint_chunks_sequence": { + "name": "idx_parent_assistant_text_checkpoint_chunks_sequence", + "columns": [ + "execution_id", + "first_sequence" + ], + "isUnique": true + } + }, + "foreignKeys": { + "parent_assistant_text_checkpoint_chunks_execution_id_parent_assistant_text_checkpoints_execution_id_fk": { + "name": "parent_assistant_text_checkpoint_chunks_execution_id_parent_assistant_text_checkpoints_execution_id_fk", + "tableFrom": "parent_assistant_text_checkpoint_chunks", + "tableTo": "parent_assistant_text_checkpoints", + "columnsFrom": [ + "execution_id" + ], + "columnsTo": [ + "execution_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "parent_assistant_text_checkpoints": { + "name": "parent_assistant_text_checkpoints", + "columns": { + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_sequence": { + "name": "last_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retained_bytes": { + "name": "retained_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retained_chunks": { + "name": "retained_chunks", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_parent_assistant_text_checkpoints_thread": { + "name": "idx_parent_assistant_text_checkpoints_thread", + "columns": [ + "thread_id", + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "parent_assistant_text_checkpoints_thread_id_canonical_agent_threads_id_fk": { + "name": "parent_assistant_text_checkpoints_thread_id_canonical_agent_threads_id_fk", + "tableFrom": "parent_assistant_text_checkpoints", + "tableTo": "canonical_agent_threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "parent_assistant_text_checkpoints_turn_id_canonical_agent_turns_id_fk": { + "name": "parent_assistant_text_checkpoints_turn_id_canonical_agent_turns_id_fk", + "tableFrom": "parent_assistant_text_checkpoints", + "tableTo": "canonical_agent_turns", + "columnsFrom": [ + "turn_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plan_question_answers": { + "name": "plan_question_answers", + "columns": { + "assistant_message_id": { + "name": "assistant_message_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "answered_at": { + "name": "answered_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "idx_plan_question_answers_thread_answered_at": { + "name": "idx_plan_question_answers_thread_answered_at", + "columns": [ + "thread_id", + "answered_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "plan_question_answers_assistant_message_id_messages_id_fk": { + "name": "plan_question_answers_assistant_message_id_messages_id_fk", + "tableFrom": "plan_question_answers", + "tableTo": "messages", + "columnsFrom": [ + "assistant_message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plan_question_answers_thread_id_threads_id_fk": { + "name": "plan_question_answers_thread_id_threads_id_fk", + "tableFrom": "plan_question_answers", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plans": { + "name": "plans", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_md": { + "name": "content_md", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sections_json": { + "name": "sections_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "change_summary": { + "name": "change_summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "idx_plans_thread": { + "name": "idx_plans_thread", + "columns": [ + "thread_id" + ], + "isUnique": false + }, + "idx_plans_thread_version": { + "name": "idx_plans_thread_version", + "columns": [ + "thread_id", + "version" + ], + "isUnique": false + } + }, + "foreignKeys": { + "plans_thread_id_threads_id_fk": { + "name": "plans_thread_id_threads_id_fk", + "tableFrom": "plans", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plans_message_id_messages_id_fk": { + "name": "plans_message_id_messages_id_fk", + "tableFrom": "plans", + "tableTo": "messages", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_action_runs": { + "name": "project_action_runs", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action_id": { + "name": "action_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "terminal_session_id": { + "name": "terminal_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action_name": { + "name": "action_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "snapshot_json": { + "name": "snapshot_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "transcript": { + "name": "transcript", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "transcript_truncated": { + "name": "transcript_truncated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "idx_project_action_runs_slot": { + "name": "idx_project_action_runs_slot", + "columns": [ + "thread_id", + "action_id" + ], + "isUnique": true + }, + "idx_project_action_runs_thread": { + "name": "idx_project_action_runs_thread", + "columns": [ + "thread_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "project_action_runs_thread_id_threads_id_fk": { + "name": "project_action_runs_thread_id_threads_id_fk", + "tableFrom": "project_action_runs", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_action_runs_workspace_id_workspaces_id_fk": { + "name": "project_action_runs_workspace_id_workspaces_id_fk", + "tableFrom": "project_action_runs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "provider_catalog_snapshots": { + "name": "provider_catalog_snapshots", + "columns": { + "context_key": { + "name": "context_key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "snapshot_json": { + "name": "snapshot_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "idx_provider_catalog_snapshots_workspace": { + "name": "idx_provider_catalog_snapshots_workspace", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "idx_provider_catalog_snapshots_provider": { + "name": "idx_provider_catalog_snapshots_provider", + "columns": [ + "provider_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "provider_catalog_snapshots_workspace_id_workspaces_id_fk": { + "name": "provider_catalog_snapshots_workspace_id_workspaces_id_fk", + "tableFrom": "provider_catalog_snapshots", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "provider_model_cache": { + "name": "provider_model_cache", + "columns": { + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "models_json": { + "name": "models_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "model_count": { + "name": "model_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pull_request_review_links": { + "name": "pull_request_review_links", + "columns": { + "worktree_id": { + "name": "worktree_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_node_id": { + "name": "repository_node_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pull_request_number": { + "name": "pull_request_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_state": { + "name": "pr_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worktree_path": { + "name": "worktree_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worktree_managed": { + "name": "worktree_managed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "head_repository_node_id": { + "name": "head_repository_node_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_repository_owner": { + "name": "head_repository_owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_repository_name": { + "name": "head_repository_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_ref": { + "name": "head_ref", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_oid": { + "name": "head_oid", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "local_branch": { + "name": "local_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "push_remote": { + "name": "push_remote", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "push_ref": { + "name": "push_ref", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "managed_remote_name": { + "name": "managed_remote_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "primary_thread_id": { + "name": "primary_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "idx_pull_request_review_links_identity": { + "name": "idx_pull_request_review_links_identity", + "columns": [ + "provider", + "repository_node_id", + "pull_request_number" + ], + "isUnique": true + }, + "idx_pull_request_review_links_primary_thread": { + "name": "idx_pull_request_review_links_primary_thread", + "columns": [ + "primary_thread_id" + ], + "isUnique": true + }, + "idx_pull_request_review_links_workspace": { + "name": "idx_pull_request_review_links_workspace", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "idx_pull_request_review_links_worktree_path": { + "name": "idx_pull_request_review_links_worktree_path", + "columns": [ + "worktree_path" + ], + "isUnique": false + } + }, + "foreignKeys": { + "pull_request_review_links_workspace_id_workspaces_id_fk": { + "name": "pull_request_review_links_workspace_id_workspaces_id_fk", + "tableFrom": "pull_request_review_links", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pull_request_review_links_primary_thread_id_threads_id_fk": { + "name": "pull_request_review_links_primary_thread_id_threads_id_fk", + "tableFrom": "pull_request_review_links", + "tableTo": "threads", + "columnsFrom": [ + "primary_thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "terminal_cleanup_ledger": { + "name": "terminal_cleanup_ledger", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "host_generation": { + "name": "host_generation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "root_pid": { + "name": "root_pid", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "process_group_id": { + "name": "process_group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "containment": { + "name": "containment", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thought_segments": { + "name": "thought_segments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "ended_at": { + "name": "ended_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_final_response": { + "name": "is_final_response", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_thought_segments_message_final_sort_order_id": { + "name": "idx_thought_segments_message_final_sort_order_id", + "columns": [ + "message_id", + "is_final_response", + "sort_order", + "id" + ], + "isUnique": false + }, + "idx_thought_segments_final_message_sort_order_id": { + "name": "idx_thought_segments_final_message_sort_order_id", + "columns": [ + "message_id", + "sort_order", + "id" + ], + "isUnique": false, + "where": "\"thought_segments\".\"is_final_response\" <> 0" + } + }, + "foreignKeys": { + "thought_segments_message_id_messages_id_fk": { + "name": "thought_segments_message_id_messages_id_fk", + "tableFrom": "thought_segments", + "tableTo": "messages", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_control_approvals": { + "name": "thread_control_approvals", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "execution_json": { + "name": "execution_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "placement_json": { + "name": "placement_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "caller_id": { + "name": "caller_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_thread_id": { + "name": "source_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_turn_id": { + "name": "source_turn_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_provider_id": { + "name": "source_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'thread_create_batch'" + }, + "operation_phase": { + "name": "operation_phase", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pre_provision'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "resolved_at": { + "name": "resolved_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_thread_control_approvals_thread": { + "name": "idx_thread_control_approvals_thread", + "columns": [ + "thread_id", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "thread_control_approvals_thread_id_threads_id_fk": { + "name": "thread_control_approvals_thread_id_threads_id_fk", + "tableFrom": "thread_control_approvals", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "thread_control_approvals_workspace_id_workspaces_id_fk": { + "name": "thread_control_approvals_workspace_id_workspaces_id_fk", + "tableFrom": "thread_control_approvals", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_control_audit": { + "name": "thread_control_audit", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "caller_id": { + "name": "caller_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_thread_id": { + "name": "source_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "idx_thread_control_audit_thread": { + "name": "idx_thread_control_audit_thread", + "columns": [ + "thread_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_startups": { + "name": "thread_startups", + "columns": { + "startup_id": { + "name": "startup_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "steps_json": { + "name": "steps_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "transcript_json": { + "name": "transcript_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cancellation": { + "name": "cancellation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'none'" + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_fingerprint": { + "name": "request_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_json": { + "name": "error_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "block_json": { + "name": "block_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "idx_thread_startups_workspace_updated": { + "name": "idx_thread_startups_workspace_updated", + "columns": [ + "workspace_id", + "\"updated_at\" desc" + ], + "isUnique": false + }, + "idx_thread_startups_state": { + "name": "idx_thread_startups_state", + "columns": [ + "state" + ], + "isUnique": false + } + }, + "foreignKeys": { + "thread_startups_workspace_id_workspaces_id_fk": { + "name": "thread_startups_workspace_id_workspaces_id_fk", + "tableFrom": "thread_startups", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "thread_startups_thread_id_threads_id_fk": { + "name": "thread_startups_thread_id_threads_id_fk", + "tableFrom": "thread_startups", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_tasks": { + "name": "thread_tasks", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tasks_json": { + "name": "tasks_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": {}, + "foreignKeys": { + "thread_tasks_thread_id_threads_id_fk": { + "name": "thread_tasks_thread_id_threads_id_fk", + "tableFrom": "thread_tasks", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "threads": { + "name": "threads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'direct'" + }, + "worktree_path": { + "name": "worktree_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checkout_state": { + "name": "checkout_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'named'" + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "issue_number": { + "name": "issue_number", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_status": { + "name": "pr_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_completed_at": { + "name": "user_completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scheduled_deletion_at": { + "name": "scheduled_deletion_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cleanup_state": { + "name": "cleanup_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cleanup_reason": { + "name": "cleanup_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_managed": { + "name": "worktree_managed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "sdk_session_id": { + "name": "sdk_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_context_tokens": { + "name": "last_context_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "context_window": { + "name": "context_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'claude'" + }, + "reasoning_level": { + "name": "reasoning_level", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "interaction_mode": { + "name": "interaction_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "orchestration_mode": { + "name": "orchestration_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "permission_mode": { + "name": "permission_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_thread_id": { + "name": "parent_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "forked_from_message_id": { + "name": "forked_from_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "delegation_coordinator_thread_id": { + "name": "delegation_coordinator_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "delegation_creator_turn_id": { + "name": "delegation_creator_turn_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "delegation_creator_tool_call_id": { + "name": "delegation_creator_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "delegation_creation_kind": { + "name": "delegation_creation_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by_integration_id": { + "name": "created_by_integration_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_compact_summary": { + "name": "last_compact_summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "copilot_agent": { + "name": "copilot_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "devin_mode": { + "name": "devin_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "context_window_mode": { + "name": "context_window_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thinking": { + "name": "thinking", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "codex_fast_mode": { + "name": "codex_fast_mode", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_open_in_app": { + "name": "default_open_in_app", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "has_file_changes": { + "name": "has_file_changes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "current_notice_session_id": { + "name": "current_notice_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_threads_workspace": { + "name": "idx_threads_workspace", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "idx_threads_workspace_deleted": { + "name": "idx_threads_workspace_deleted", + "columns": [ + "workspace_id", + "deleted_at" + ], + "isUnique": false + }, + "idx_threads_workspace_completed": { + "name": "idx_threads_workspace_completed", + "columns": [ + "workspace_id", + "user_completed_at" + ], + "isUnique": false + }, + "idx_threads_cleanup_due": { + "name": "idx_threads_cleanup_due", + "columns": [ + "cleanup_state", + "scheduled_deletion_at" + ], + "isUnique": false + }, + "idx_threads_workspace_recency": { + "name": "idx_threads_workspace_recency", + "columns": [ + "workspace_id", + "\"updated_at\" desc" + ], + "isUnique": false + }, + "idx_threads_status": { + "name": "idx_threads_status", + "columns": [ + "status" + ], + "isUnique": false + }, + "idx_threads_parent_thread_id": { + "name": "idx_threads_parent_thread_id", + "columns": [ + "parent_thread_id" + ], + "isUnique": false + }, + "idx_threads_forked_from_message_id": { + "name": "idx_threads_forked_from_message_id", + "columns": [ + "forked_from_message_id" + ], + "isUnique": false + }, + "idx_threads_delegation_coordinator": { + "name": "idx_threads_delegation_coordinator", + "columns": [ + "delegation_coordinator_thread_id" + ], + "isUnique": false + }, + "idx_threads_created_by_integration": { + "name": "idx_threads_created_by_integration", + "columns": [ + "created_by_integration_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "threads_workspace_id_workspaces_id_fk": { + "name": "threads_workspace_id_workspaces_id_fk", + "tableFrom": "threads", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "threads_delegation_coordinator_thread_id_threads_id_fk": { + "name": "threads_delegation_coordinator_thread_id_threads_id_fk", + "tableFrom": "threads", + "tableTo": "threads", + "columnsFrom": [ + "delegation_coordinator_thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tool_call_records": { + "name": "tool_call_records", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_tool_call_id": { + "name": "parent_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_agent_key": { + "name": "provider_agent_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subagent_identity_key": { + "name": "subagent_identity_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subagent_provider_name": { + "name": "subagent_provider_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subagent_prompt": { + "name": "subagent_prompt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subagent_type": { + "name": "subagent_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subagent_agent_id": { + "name": "subagent_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subagent_duration_ms": { + "name": "subagent_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reasoning_effort": { + "name": "reasoning_effort", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "input_summary": { + "name": "input_summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "output_summary": { + "name": "output_summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "output_truncated": { + "name": "output_truncated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "output_total_bytes": { + "name": "output_total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_artifact_path": { + "name": "output_artifact_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_tool_call_records_message_sort_order_id": { + "name": "idx_tool_call_records_message_sort_order_id", + "columns": [ + "message_id", + "sort_order", + "id" + ], + "isUnique": false + }, + "idx_tool_call_records_parent": { + "name": "idx_tool_call_records_parent", + "columns": [ + "parent_tool_call_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tool_call_records_message_id_messages_id_fk": { + "name": "tool_call_records_message_id_messages_id_fk", + "tableFrom": "tool_call_records", + "tableTo": "messages", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "turn_diff_snapshots": { + "name": "turn_diff_snapshots", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fidelity": { + "name": "fidelity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "patch": { + "name": "patch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "idx_turn_diff_snapshots_message": { + "name": "idx_turn_diff_snapshots_message", + "columns": [ + "message_id" + ], + "isUnique": true + }, + "idx_turn_diff_snapshots_thread": { + "name": "idx_turn_diff_snapshots_thread", + "columns": [ + "thread_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "turn_diff_snapshots_message_id_messages_id_fk": { + "name": "turn_diff_snapshots_message_id_messages_id_fk", + "tableFrom": "turn_diff_snapshots", + "tableTo": "messages", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "turn_diff_snapshots_thread_id_threads_id_fk": { + "name": "turn_diff_snapshots_thread_id_threads_id_fk", + "tableFrom": "turn_diff_snapshots", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "turn_snapshots": { + "name": "turn_snapshots", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref_before": { + "name": "ref_before", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref_after": { + "name": "ref_after", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "files_changed": { + "name": "files_changed", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "file_effects": { + "name": "file_effects", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{\"revision\":0,\"fileCount\":0,\"additions\":0,\"deletions\":0,\"effects\":[]}'" + }, + "worktree_path": { + "name": "worktree_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "idx_turn_snapshots_message": { + "name": "idx_turn_snapshots_message", + "columns": [ + "message_id" + ], + "isUnique": false + }, + "idx_turn_snapshots_thread": { + "name": "idx_turn_snapshots_thread", + "columns": [ + "thread_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "turn_snapshots_message_id_messages_id_fk": { + "name": "turn_snapshots_message_id_messages_id_fk", + "tableFrom": "turn_snapshots", + "tableTo": "messages", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "turn_snapshots_thread_id_threads_id_fk": { + "name": "turn_snapshots_thread_id_threads_id_fk", + "tableFrom": "turn_snapshots", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace_environment_automatic_setup_attempts": { + "name": "workspace_environment_automatic_setup_attempts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "launch_snapshot_json": { + "name": "launch_snapshot_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output": { + "name": "output", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "output_truncated": { + "name": "output_truncated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "idx_workspace_environment_automatic_setup_attempts_thread": { + "name": "idx_workspace_environment_automatic_setup_attempts_thread", + "columns": [ + "thread_id", + "\"created_at\" desc" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspace_environment_automatic_setup_attempts_thread_id_threads_id_fk": { + "name": "workspace_environment_automatic_setup_attempts_thread_id_threads_id_fk", + "tableFrom": "workspace_environment_automatic_setup_attempts", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace_environment_command_approvals": { + "name": "workspace_environment_command_approvals", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "command_id": { + "name": "command_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approved_at": { + "name": "approved_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "idx_workspace_environment_command_approvals_command": { + "name": "idx_workspace_environment_command_approvals_command", + "columns": [ + "workspace_id", + "command_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "workspace_environment_command_approvals_workspace_id_workspaces_id_fk": { + "name": "workspace_environment_command_approvals_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_environment_command_approvals", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace_environment_queued_turns": { + "name": "workspace_environment_queued_turns", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "queue_position": { + "name": "queue_position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "submission_json": { + "name": "submission_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "released_at": { + "name": "released_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dispatching_at": { + "name": "dispatching_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dispatched_at": { + "name": "dispatched_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_workspace_environment_queued_turns_state": { + "name": "idx_workspace_environment_queued_turns_state", + "columns": [ + "state", + "created_at" + ], + "isUnique": false + }, + "idx_workspace_environment_queued_turns_thread_state_position": { + "name": "idx_workspace_environment_queued_turns_thread_state_position", + "columns": [ + "thread_id", + "state", + "queue_position" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspace_environment_queued_turns_thread_id_threads_id_fk": { + "name": "workspace_environment_queued_turns_thread_id_threads_id_fk", + "tableFrom": "workspace_environment_queued_turns", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace_environment_setup_gates": { + "name": "workspace_environment_setup_gates", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempt_id": { + "name": "attempt_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_environment_setup_gates_thread_id_threads_id_fk": { + "name": "workspace_environment_setup_gates_thread_id_threads_id_fk", + "tableFrom": "workspace_environment_setup_gates", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace_environment_storage_settings": { + "name": "workspace_environment_storage_settings", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "storage_mode": { + "name": "storage_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_environment_storage_settings_workspace_id_workspaces_id_fk": { + "name": "workspace_environment_storage_settings_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_environment_storage_settings", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace_terminal_preferences": { + "name": "workspace_terminal_preferences", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "default_profile_id": { + "name": "default_profile_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_terminal_preferences_workspace_id_workspaces_id_fk": { + "name": "workspace_terminal_preferences_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_terminal_preferences", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace_worktrees": { + "name": "workspace_worktrees", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "canonical_path": { + "name": "canonical_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_ref": { + "name": "base_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "managed": { + "name": "managed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "stale": { + "name": "stale", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_workspace_worktrees_path": { + "name": "idx_workspace_worktrees_path", + "columns": [ + "workspace_id", + "canonical_path" + ], + "isUnique": true + }, + "idx_workspace_worktrees_workspace": { + "name": "idx_workspace_worktrees_workspace", + "columns": [ + "workspace_id", + "stale" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspace_worktrees_workspace_id_workspaces_id_fk": { + "name": "workspace_worktrees_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_worktrees", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_config": { + "name": "provider_config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "pinned": { + "name": "pinned", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_git_repo": { + "name": "is_git_repo", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "workspaces_path_unique": { + "name": "workspaces_path_unique", + "columns": [ + "path" + ], + "isUnique": true + }, + "idx_workspaces_sort_order": { + "name": "idx_workspaces_sort_order", + "columns": [ + "\"sort_order\" asc" + ], + "isUnique": false + }, + "idx_workspaces_pinned_last_opened": { + "name": "idx_workspaces_pinned_last_opened", + "columns": [ + "\"pinned\" desc", + "\"last_opened_at\" desc" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": { + "idx_messages_notice_session_sequence": { + "columns": { + "json_extract(\"system_notice\", '$.sessionId')": { + "isExpression": true + }, + "\"sequence\" desc": { + "isExpression": true + } + } + }, + "idx_thread_startups_workspace_updated": { + "columns": { + "\"updated_at\" desc": { + "isExpression": true + } + } + }, + "idx_threads_workspace_recency": { + "columns": { + "\"updated_at\" desc": { + "isExpression": true + } + } + }, + "idx_workspace_environment_automatic_setup_attempts_thread": { + "columns": { + "\"created_at\" desc": { + "isExpression": true + } + } + }, + "idx_workspaces_sort_order": { + "columns": { + "\"sort_order\" asc": { + "isExpression": true + } + } + }, + "idx_workspaces_pinned_last_opened": { + "columns": { + "\"pinned\" desc": { + "isExpression": true + }, + "\"last_opened_at\" desc": { + "isExpression": true + } + } + } + } + } +} \ No newline at end of file diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index aacbf495d..af57bc677 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -456,6 +456,13 @@ "when": 1790092742883, "tag": "0063_sleepy_harrier", "breakpoints": true + }, + { + "idx": 64, + "version": "6", + "when": 1790262706352, + "tag": "0064_fuzzy_jimmy_woo", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/server/src/application/transport/__tests__/push.test.ts b/apps/server/src/application/transport/__tests__/push.test.ts index e9b17cb46..871ad7851 100644 --- a/apps/server/src/application/transport/__tests__/push.test.ts +++ b/apps/server/src/application/transport/__tests__/push.test.ts @@ -6,7 +6,6 @@ import { broadcast, broadcastTerminalData, clientCount, - MAX_PUSH_SOCKET_BUFFERED_BYTES, removeClient, sendToClient, setClientThreadSubscriptions, @@ -22,18 +21,20 @@ import { setTransportPayloadValidatorForTest, } from "../payload-validation.js"; -function fakeOpenSocket(received: Array<{ buf: Buffer; binary: boolean }>): WebSocket { +function fakeOpenSocket(received: Array<{ buf: Buffer; binary: boolean }>, completeImmediately = true): WebSocket { const ws: Partial = { readyState: 1, // OPEN OPEN: 1, bufferedAmount: 0, close: () => undefined, terminate: () => undefined, - send: ((data: unknown, opts?: { binary?: boolean }) => { + send: ((data: unknown, optsOrComplete?: { binary?: boolean } | (() => void), complete?: () => void) => { const buf = Buffer.isBuffer(data) ? data : Buffer.from(data as Uint8Array); + const opts = typeof optsOrComplete === "function" ? undefined : optsOrComplete; received.push({ buf, binary: !!opts?.binary }); + if (completeImmediately) (typeof optsOrComplete === "function" ? optsOrComplete : complete)?.(); }) as WebSocket["send"], }; return ws as WebSocket; @@ -75,13 +76,13 @@ describe("broadcastTerminalData", () => { expect(decoded.payload).toEqual(payload); }); - it("terminates only the slow terminal client before it can pause every terminal", () => { + it("keeps terminal delivery alive while an RPC response is buffered", () => { const slowReceived: Array<{ buf: Buffer; binary: boolean }> = []; const healthyReceived: Array<{ buf: Buffer; binary: boolean }> = []; const slow = fakeOpenSocket(slowReceived); const terminate = vi.fn(); Object.defineProperties(slow, { - bufferedAmount: { value: MAX_PUSH_SOCKET_BUFFERED_BYTES }, + bufferedAmount: { value: 35 * 1_024 * 1_024 }, terminate: { value: terminate }, }); addClient(slow); @@ -89,10 +90,31 @@ describe("broadcastTerminalData", () => { broadcastTerminalData("pty-1", 1, new Uint8Array([0x41])); - expect(terminate).toHaveBeenCalledOnce(); - expect(slowReceived).toHaveLength(0); + expect(terminate).not.toHaveBeenCalled(); + expect(slowReceived).toHaveLength(1); expect(healthyReceived).toHaveLength(1); }); + + it("releases the push budget when queued frames finish sending", () => { + const completions: Array<() => void> = []; + const terminate = vi.fn(); + const send = vi.fn((_data: unknown, _options: unknown, complete: () => void) => { + completions.push(complete); + }); + const ws = { readyState: 1, OPEN: 1, bufferedAmount: 0, send, terminate } as unknown as WebSocket; + addClient(ws); + + for (let index = 0; index < 15; index++) { + broadcastTerminalData("pty-1", index, new Uint8Array(1_024 * 1_024)); + } + for (const complete of completions.splice(0)) complete(); + for (let index = 15; index < 30; index++) { + broadcastTerminalData("pty-1", index, new Uint8Array(1_024 * 1_024)); + } + + expect(send).toHaveBeenCalledTimes(30); + expect(terminate).not.toHaveBeenCalled(); + }); }); describe("broadcast", () => { @@ -334,13 +356,12 @@ describe("broadcast", () => { expect(good).toHaveLength(1); }); - it("terminates a slow agent-event client and replays its retained gap after reconnect", () => { + it("terminates a client with unsent pushes and replays its retained agent gap", () => { const slowReceived: Array<{ buf: Buffer; binary: boolean }> = []; const healthyReceived: Array<{ buf: Buffer; binary: boolean }> = []; - const slow = Object.assign(new NodeEvents.EventEmitter(), fakeOpenSocket(slowReceived)); + const slow = Object.assign(new NodeEvents.EventEmitter(), fakeOpenSocket(slowReceived, false)); const terminate = vi.fn(() => slow.emit("close")); Object.defineProperties(slow, { - bufferedAmount: { value: MAX_PUSH_SOCKET_BUFFERED_BYTES }, terminate: { value: terminate }, }); const healthy = fakeOpenSocket(healthyReceived); @@ -350,12 +371,16 @@ describe("broadcast", () => { subscribeClientToThread(slow, "thread-recovery"); subscribeClientToThread(healthy, "thread-recovery"); + for (let index = 0; index < 16; index++) { + broadcastTerminalData("pty-1", index, new Uint8Array(1_024 * 1_024)); + } + broadcast("agent.event", { type: "textDelta", threadId: "thread-recovery", delta: "retained" }); expect(terminate).toHaveBeenCalledOnce(); expect(clientCount()).toBe(1); - expect(slowReceived).toHaveLength(0); - expect(healthyReceived).toHaveLength(1); + expect(slowReceived).toHaveLength(15); + expect(healthyReceived).toHaveLength(17); const recoveredReceived: Array<{ buf: Buffer; binary: boolean }> = []; const recovered = fakeOpenSocket(recoveredReceived); @@ -443,12 +468,12 @@ describe("sendToClient", () => { expect(second).toHaveLength(0); }); - it("terminates a slow directed push client before replay can add to its backlog", () => { + it("delivers a directed push while an unrelated response is buffered", () => { const received: Array<{ buf: Buffer; binary: boolean }> = []; const ws = fakeOpenSocket(received); const terminate = vi.fn(); Object.defineProperties(ws, { - bufferedAmount: { value: MAX_PUSH_SOCKET_BUFFERED_BYTES }, + bufferedAmount: { value: 35 * 1_024 * 1_024 }, terminate: { value: terminate }, }); addClient(ws); @@ -470,11 +495,11 @@ describe("sendToClient", () => { requestId: "request-a", sequence: 1, reason: "deadline-exceeded", - })).toBe(false); + })).toBe(true); - expect(terminate).toHaveBeenCalledOnce(); - expect(clientCount()).toBe(0); - expect(received).toHaveLength(0); + expect(terminate).not.toHaveBeenCalled(); + expect(clientCount()).toBe(1); + expect(received).toHaveLength(1); }); it("reports a synchronous socket delivery failure without throwing", () => { diff --git a/apps/server/src/application/transport/push.ts b/apps/server/src/application/transport/push.ts index e004aba7a..57203ef1c 100644 --- a/apps/server/src/application/transport/push.ts +++ b/apps/server/src/application/transport/push.ts @@ -13,10 +13,11 @@ import { getTransportPayloadValidator } from "./payload-validation.js"; export const MAX_AGENT_EVENT_JOURNAL_EVENTS_PER_THREAD = 256; /** Maximum thread journals retained by the process. */ export const MAX_AGENT_EVENT_JOURNAL_THREADS = 100; -/** Maximum buffered push bytes for one connected client. */ -export const MAX_PUSH_SOCKET_BUFFERED_BYTES = 16 * 1_024 * 1_024; +/** Maximum unsent push bytes for one connected client, excluding RPC response bytes. */ +export const MAX_QUEUED_PUSH_BYTES = 16 * 1_024 * 1_024; const clients = new Set(); +const queuedPushBytes = new Map(); const threadSubscriptions = new Map>(); const threadJournals = new Map(); const nextSequenceByThread = new Map(); @@ -59,6 +60,7 @@ export function onSessionChange(cb: (count: number) => void): () => void { /** Register a WebSocket client for push event delivery. */ export function addClient(ws: WebSocket): void { clients.add(ws); + queuedPushBytes.set(ws, 0); threadSubscriptions.set(ws, new Set()); _sessionCount++; for (let i = 0; i < sessionChangeListeners.length; i++) sessionChangeListeners[i](_sessionCount); @@ -67,6 +69,7 @@ export function addClient(ws: WebSocket): void { /** Remove a disconnected WebSocket client. No-op if already removed. */ export function removeClient(ws: WebSocket): void { if (!clients.delete(ws)) return; + queuedPushBytes.delete(ws); threadSubscriptions.delete(ws); _sessionCount--; for (let i = 0; i < sessionChangeListeners.length; i++) sessionChangeListeners[i](_sessionCount); @@ -232,24 +235,18 @@ function sendBroadcastPayload(channel: WsChannelName, threadId: string | undefin for (const ws of clients) { if (ws.readyState !== ws.OPEN) continue; if (requiresThreadSubscription && threadId && !threadSubscriptions.get(ws)?.has(threadId)) continue; - if (!canSendPayload(ws, channel, payloadBytes)) continue; - try { - ws.send(payload); - } catch { - // A socket can die between the readyState check and the send; one dead - // client must not abort the broadcast or take the server down. - logger.warn("Broadcast send failed", { channel }); - } + sendPush(ws, channel, payload, payloadBytes); } } -/** Terminate only the lagging connection so its next subscription can replay or hydrate. */ +/** Count only push writes, so a large RPC response cannot evict its own client. */ function canSendPayload(ws: WebSocket, channel: WsChannelName | "terminal.data", payloadBytes: number): boolean { - const bufferedAmount = ws.bufferedAmount; - if (bufferedAmount + payloadBytes <= MAX_PUSH_SOCKET_BUFFERED_BYTES) return true; + const queuedBytes = queuedPushBytes.get(ws) ?? 0; + if (queuedBytes + payloadBytes <= MAX_QUEUED_PUSH_BYTES) return true; logger.warn("Terminating slow WebSocket client for push recovery", { channel, - bufferedAmount, + queuedPushBytes: queuedBytes, + bufferedAmount: ws.bufferedAmount, payloadBytes, }); try { @@ -264,6 +261,33 @@ function canSendPayload(ws: WebSocket, channel: WsChannelName | "terminal.data", return false; } +function sendPush( + ws: WebSocket, + channel: WsChannelName | "terminal.data", + payload: string | Uint8Array, + payloadBytes: number, +): boolean { + if (!canSendPayload(ws, channel, payloadBytes)) return false; + queuedPushBytes.set(ws, (queuedPushBytes.get(ws) ?? 0) + payloadBytes); + const complete = (error?: Error) => { + const queued = queuedPushBytes.get(ws); + if (queued !== undefined) queuedPushBytes.set(ws, Math.max(0, queued - payloadBytes)); + if (error) logger.warn("Push delivery failed", { channel, error: error.message }); + }; + try { + if (typeof payload === "string") ws.send(payload, complete); + else ws.send(payload, { binary: true }, complete); + return true; + } catch (error) { + complete(); + logger.warn("Push send failed", { + channel, + error: error instanceof Error ? error.message : String(error), + }); + return false; + } +} + /** Sends one validated push event to exactly one connected WebSocket client. */ export function sendToClient( ws: WebSocket, @@ -276,17 +300,7 @@ export function sendToClient( const validation = getTransportPayloadValidator().validatePush(channel, data, schema); if (!validation.ok) return false; const payload = JSON.stringify({ type: "push" as const, channel, data: validation.data }); - if (!canSendPayload(ws, channel, Buffer.byteLength(payload, "utf8"))) return false; - try { - ws.send(payload); - return true; - } catch (error) { - logger.warn("Directed push delivery failed", { - channel, - error: error instanceof Error ? error.message : String(error), - }); - return false; - } + return sendPush(ws, channel, payload, Buffer.byteLength(payload, "utf8")); } /** @@ -303,16 +317,7 @@ export function broadcastTerminalData( const frame = encodeTerminalDataFrame(ptyId, seq, payload); for (const ws of clients) { if (ws.readyState === ws.OPEN) { - if (!canSendPayload(ws, "terminal.data", frame.byteLength)) continue; - try { - ws.send(frame, { binary: true }); - } catch (err) { - // One bad socket must not interrupt delivery to the remaining clients. - // Log and continue — the client will reconnect and re-request state. - logger.warn("broadcastTerminalData: ws.send failed for a client", { - error: err instanceof Error ? err.message : String(err), - }); - } + sendPush(ws, "terminal.data", frame, frame.byteLength); } } } @@ -328,6 +333,7 @@ export function _resetForTest(): void { _sessionCount = 0; sessionChangeListeners.length = 0; clients.clear(); + queuedPushBytes.clear(); threadSubscriptions.clear(); threadJournals.clear(); nextSequenceByThread.clear(); diff --git a/apps/server/src/features/agents/orchestration/__tests__/agent-service-existing-worktree.test.ts b/apps/server/src/features/agents/orchestration/__tests__/agent-service-existing-worktree.test.ts index 21820e9dc..abb2fe070 100644 --- a/apps/server/src/features/agents/orchestration/__tests__/agent-service-existing-worktree.test.ts +++ b/apps/server/src/features/agents/orchestration/__tests__/agent-service-existing-worktree.test.ts @@ -789,6 +789,25 @@ describe("AgentService.createAndSend defaults", () => { expect(result.runtimeSnapshot.turnExecutionId).toEqual(expect.any(String)); }); + it("replays a lost createAndSend response without creating or dispatching twice", async () => { + const { workspaceRepo, threadRepo, service, provider, messageRepo } = createAgentServiceHarness(); + const workspace = workspaceRepo.create("Repo", "/repo"); + const command = { + workspaceId: workspace.id, + content: "Start exactly once", + startupId: "00000000-0000-4000-8000-000000000041", + }; + + const first = await service.createAndSend(command); + const replay = await service.createAndSend(command); + + expect(replay.id).toBe(first.id); + expect(replay.runtimeSnapshot).toEqual(first.runtimeSnapshot); + expect(threadRepo.listByWorkspace(workspace.id)).toHaveLength(1); + await eventually(() => expect(provider.sendTurn).toHaveBeenCalledOnce()); + expect(messageRepo.listByThread(first.id, 10).messages).toHaveLength(1); + }); + it("completes startup when the initial native command is handled without a provider turn", async () => { const { workspaceRepo, service, provider, threadStartups } = createAgentServiceHarness(undefined, true); const workspace = workspaceRepo.create("Repo", "/repo"); diff --git a/apps/server/src/features/agents/orchestration/turn-runtime-controller.ts b/apps/server/src/features/agents/orchestration/turn-runtime-controller.ts index 6fb1bce42..cb4b8682e 100644 --- a/apps/server/src/features/agents/orchestration/turn-runtime-controller.ts +++ b/apps/server/src/features/agents/orchestration/turn-runtime-controller.ts @@ -43,6 +43,7 @@ import { TURN_RUNTIME_PERSISTENCE, type TurnRuntimePersistence } from "../turns/ import { ThreadCreationCoordinator, type CreateAndSendCommand, + type CreatedInitialTurn, } from "../turns/thread-creation-coordinator.js"; import { TurnAdmissionDispatchCoordinator, @@ -1337,6 +1338,12 @@ export class TurnRuntimeController implements TurnLifecycleControl, TurnRuntimeE /** Provision a thread through its coordinator, then start its generic first-turn command. */ async createAndSend(command: CreateAndSendCommand): Promise { const created = await this.threadCreation.createInitialTurn(command); + if (created.kind === "replay") { + return { + ...created.thread, + runtimeSnapshot: this.turnRuntime.snapshot(created.thread.id) ?? idleRuntime(created.thread.id), + }; + } if (created.kind === "queued") { return { ...created.thread, @@ -1344,6 +1351,12 @@ export class TurnRuntimeController implements TurnLifecycleControl, TurnRuntimeE ...(created.thread.warnings?.length ? { warnings: created.thread.warnings } : {}), }; } + return this.dispatchInitialTurn(created); + } + + private async dispatchInitialTurn( + created: Extract, + ): Promise { let runtimeSnapshot: TurnRuntimeSnapshot; try { this.threadCreation.startInitialAgent(created.startupId); diff --git a/apps/server/src/features/agents/turns/__tests__/thread-creation-startup.test.ts b/apps/server/src/features/agents/turns/__tests__/thread-creation-startup.test.ts index 03cbe3763..0b9b4e419 100644 --- a/apps/server/src/features/agents/turns/__tests__/thread-creation-startup.test.ts +++ b/apps/server/src/features/agents/turns/__tests__/thread-creation-startup.test.ts @@ -2,8 +2,10 @@ import "reflect-metadata"; import { describe, expect, it, vi } from "vitest"; import { openMemoryDatabase } from "../../../../runtime/persistence/sqlite/database.js"; import { WorkspaceRepo } from "../../../projects/persistence/workspace-repo.js"; +import { ThreadBranchingService, type BranchedThreadLifecycle, type CreateBranchedThreadInput } from "../../../projects/worktrees/thread-branching-service.js"; import { ThreadRepo } from "../../../thread-control/persistence/thread-repo.js"; import type { ThreadService } from "../../../thread-control/index.js"; +import { MessageRepo } from "../../conversation/persistence/message-repo.js"; import { ThreadStartupRepo } from "../../../thread-startup/persistence/thread-startup-repo.js"; import { ThreadStartupService } from "../../../thread-startup/thread-startup-service.js"; import { ThreadCreationCoordinator } from "../thread-creation-coordinator.js"; @@ -38,7 +40,265 @@ function harness() { return { db, workspace, threads, startups, threadService, admissions, gitRepository, coordinator }; } +function branchHarness() { + const base = harness(); + const messages = new MessageRepo(base.db); + const parent = base.threads.create(base.workspace.id, "Parent", "direct", "main", true, "claude"); + const fork = messages.create(parent.id, "user", "Start here", 1); + const handoffs = { deliverHandoff: vi.fn(async () => ({ providerWireOverride: "Parent handoff" })) }; + const branching = new ThreadBranchingService( + base.threads, + messages, + base.threadService, + { listWorktrees: vi.fn(async () => []) } as never, + handoffs as never, + { platform: "win32" } as never, + ); + const makeCoordinator = (startupService = base.startups) => new ThreadCreationCoordinator( + base.threads, () => base.threadService, base.admissions as never, base.gitRepository, + () => branching, undefined, () => startupService, + ); + return { ...base, parent, fork, handoffs, makeCoordinator }; +} + describe("ThreadCreationCoordinator startup lifecycle", () => { + it("binds a managed branch before checkout provisioning can be interrupted", async () => { + const { db, workspace, threads, threadService, startups, parent, fork, makeCoordinator } = branchHarness(); + let failProvision: ((error: Error) => void) | undefined; + vi.mocked(threadService.create).mockImplementation(async (workspaceId, title, _mode, branch, options) => { + const child = threads.create(workspaceId, title, "worktree", branch, true, "claude"); + options.lifecycle?.onThreadPersisted(child); + await new Promise((_resolve, reject) => { failProvision = reject; }); + return child; + }); + const command = { + workspaceId: workspace.id, content: "Branch while checkout runs", mode: "worktree" as const, + branch: "feature/child", parentThreadId: parent.id, forkedFromMessageId: fork.id, + startupId: managedStartupId, + }; + const pending = makeCoordinator().createInitialTurn(command); + const rejected = expect(pending).rejects.toThrow("Checkout interrupted"); + await vi.waitFor(() => expect(startups.get(managedStartupId)?.phase).toBe("worktree")); + const childId = startups.get(managedStartupId)?.threadId; + if (!childId) throw new Error("Managed branch child was not bound"); + expect(threads.findById(childId)).toMatchObject({ parent_thread_id: parent.id, provider: "claude" }); + startups.interruptNonterminalOnStartup(); + failProvision?.(new Error("Checkout interrupted")); + await rejected; + + const restarted = makeCoordinator(new ThreadStartupService(new ThreadStartupRepo(db))); + await expect(restarted.createInitialTurn(command)).rejects.toThrow("was interrupted"); + expect(threads.listByWorkspace(workspace.id)).toHaveLength(2); + expect(threadService.create).toHaveBeenCalledOnce(); + db.close(); + }); + + it("rolls back a direct branch child when its startup binding fails", async () => { + const { db, workspace, threads, startups, parent, fork, makeCoordinator } = branchHarness(); + vi.spyOn(startups, "bindThread").mockImplementationOnce(() => { throw new Error("Binding failed"); }); + await expect(makeCoordinator().createInitialTurn({ + workspaceId: workspace.id, content: "Branch directly", parentThreadId: parent.id, + forkedFromMessageId: fork.id, startupId: directStartupId, + })).rejects.toThrow("Binding failed"); + expect(threads.listByWorkspace(workspace.id)).toHaveLength(1); + expect(startups.get(directStartupId)).toMatchObject({ state: "failed", phase: "thread" }); + db.close(); + }); + + it("clears a managed branch binding when checkout failure deletes its child", async () => { + const { db, workspace, threads, threadService, startups, parent, fork, makeCoordinator } = branchHarness(); + vi.mocked(threadService.create).mockImplementation(async (workspaceId, title, _mode, branch, options) => { + const child = threads.create(workspaceId, title, "worktree", branch, true, "claude"); + options.lifecycle?.onThreadPersisted(child); + threads.hardDelete(child.id); + throw new Error("Checkout failed"); + }); + await expect(makeCoordinator().createInitialTurn({ + workspaceId: workspace.id, content: "Branch in a worktree", mode: "worktree", + branch: "feature/child", parentThreadId: parent.id, forkedFromMessageId: fork.id, + startupId: managedStartupId, + })).rejects.toThrow("Checkout failed"); + expect(startups.get(managedStartupId)).toMatchObject({ state: "failed", phase: "worktree" }); + expect(startups.get(managedStartupId)?.threadId).toBeUndefined(); + expect(threads.listByWorkspace(workspace.id)).toHaveLength(1); + db.close(); + }); + + it("keeps a failed branch bound when its child still exists", async () => { + const { db, workspace, threads, startups, parent, fork, handoffs, makeCoordinator } = branchHarness(); + handoffs.deliverHandoff.mockRejectedValueOnce(new Error("Handoff failed")); + await expect(makeCoordinator().createInitialTurn({ + workspaceId: workspace.id, content: "Branch directly", parentThreadId: parent.id, + forkedFromMessageId: fork.id, startupId: directStartupId, + })).rejects.toThrow("Handoff failed"); + const startup = startups.get(directStartupId); + expect(startup).toMatchObject({ state: "failed", phase: "thread" }); + if (!startup?.threadId) throw new Error("Persisted child lost its startup binding"); + expect(threads.findById(startup.threadId)).toMatchObject({ parent_thread_id: parent.id }); + expect(threads.listByWorkspace(workspace.id)).toHaveLength(2); + db.close(); + }); + + it("creates one child for concurrent branch retries and replays it after reconstruction", async () => { + const { db, workspace, threads, threadService, admissions, gitRepository, startups } = harness(); + const parent = threads.create(workspace.id, "Parent", "direct", "main", true, "claude"); + let finishBranch: (() => void) | undefined; + const branching = { create: vi.fn(async (_input: CreateBranchedThreadInput, lifecycle?: BranchedThreadLifecycle) => { + await new Promise((resolve) => { finishBranch = resolve; }); + return { + thread: lifecycle ? lifecycle.createAndBindDirectThread(() => threads.create(workspace.id, "Child", "direct", "main", true, "claude", { + parentThreadId: parent.id, + })) : threads.create(workspace.id, "Child", "direct", "main", true, "claude", { parentThreadId: parent.id }), + providerWireOverride: "Parent handoff", + }; + }) }; + const makeCoordinator = (startupService = startups) => new ThreadCreationCoordinator( + threads, () => threadService, admissions as never, gitRepository, + () => branching as never, undefined, () => startupService, + ); + const coordinator = makeCoordinator(); + const command = { + workspaceId: workspace.id, + content: "Branch this conversation", + parentThreadId: parent.id, + startupId: directStartupId, + }; + + const first = coordinator.createInitialTurn(command); + const second = coordinator.createInitialTurn(command); + await vi.waitFor(() => expect(finishBranch).toBeDefined()); + await expect(coordinator.createInitialTurn({ ...command, content: "Different branch request" })) + .rejects.toThrow("already assigned to a different request"); + finishBranch?.(); + const [created, concurrentReplay] = await Promise.all([first, second]); + expect(created).toMatchObject({ + kind: "dispatch", startupId: directStartupId, + command: { threadId: created.thread.id, providerWireOverride: "Parent handoff" }, + }); + expect(concurrentReplay).toMatchObject({ kind: "replay", thread: { id: created.thread.id } }); + coordinator.startInitialAgent(directStartupId); + coordinator.completeInitialAgent(directStartupId); + expect(startups.get(directStartupId)).toMatchObject({ state: "completed", threadId: created.thread.id }); + + const restarted = makeCoordinator(new ThreadStartupService(new ThreadStartupRepo(db))); + const restartedReplay = await restarted.createInitialTurn(command); + expect(restartedReplay).toMatchObject({ + kind: "replay", startupId: directStartupId, thread: { id: created.thread.id }, + }); + await expect(restarted.createInitialTurn({ ...command, content: "Different branch request" })) + .rejects.toThrow("already assigned to a different request"); + expect(branching.create).toHaveBeenCalledOnce(); + expect(threads.listByWorkspace(workspace.id)).toHaveLength(2); + db.close(); + }); + + it("skips Setup for a managed branch and rejects replay of an interrupted first turn", async () => { + const { db, workspace, threads, threadService, admissions, gitRepository, startups } = harness(); + const parent = threads.create(workspace.id, "Parent", "direct", "main", true, "claude"); + const branching = { create: vi.fn(async (_input: CreateBranchedThreadInput, lifecycle?: BranchedThreadLifecycle) => { + const thread = threads.create(workspace.id, "Child", "worktree", "feature/child", true, "claude", { + parentThreadId: parent.id, + }); + lifecycle?.onManagedThreadPersisted(thread); + return { thread, providerWireOverride: "Parent handoff" }; + }) }; + const makeCoordinator = (startupService = startups) => new ThreadCreationCoordinator( + threads, () => threadService, admissions as never, gitRepository, + () => branching as never, undefined, () => startupService, + ); + const command = { + workspaceId: workspace.id, content: "Branch in a worktree", mode: "worktree" as const, + branch: "feature/child", parentThreadId: parent.id, startupId: managedStartupId, + }; + const coordinator = makeCoordinator(); + const created = await coordinator.createInitialTurn(command); + expect(created).toMatchObject({ kind: "dispatch", startupId: managedStartupId }); + expect(startups.get(managedStartupId)).toMatchObject({ + state: "running", phase: "agent", threadId: created.thread.id, + steps: [ + { phase: "thread", state: "completed" }, + { phase: "worktree", state: "completed" }, + { phase: "setup", state: "skipped" }, + { phase: "agent", state: "running" }, + ], + }); + startups.interruptNonterminalOnStartup(); + + const restarted = makeCoordinator(new ThreadStartupService(new ThreadStartupRepo(db))); + await expect(restarted.createInitialTurn(command)) + .rejects.toThrow("was interrupted; inspect the workspace before retrying"); + expect(branching.create).toHaveBeenCalledOnce(); + expect(threads.listByWorkspace(workspace.id)).toHaveLength(2); + db.close(); + }); + + it("replays the bound thread after startup service reconstruction without admitting another first turn", async () => { + const { db, workspace, threads, threadService, admissions, gitRepository, coordinator } = harness(); + const command = { workspaceId: workspace.id, content: "Create once", startupId: directStartupId }; + const first = await coordinator.createInitialTurn(command); + const restarted = new ThreadCreationCoordinator( + threads, + () => threadService, + admissions as never, + gitRepository, + undefined, + undefined, + () => new ThreadStartupService(new ThreadStartupRepo(db)), + ); + + const replay = await restarted.createInitialTurn(command); + expect(replay).toMatchObject({ kind: "replay", startupId: directStartupId, thread: { id: first.thread.id } }); + expect(admissions.admitInitialAutomaticTurn).toHaveBeenCalledOnce(); + expect(threads.listByWorkspace(workspace.id)).toHaveLength(1); + await expect(restarted.createInitialTurn({ ...command, content: "Different prompt" })) + .rejects.toThrow("already assigned to a different request"); + db.close(); + }); + + it("shares one creation for concurrent calls with the same startup ID", async () => { + const { db, workspace, threads, admissions, gitRepository, coordinator } = harness(); + let finishFetch: (() => void) | undefined; + vi.mocked(gitRepository.fetchBranch).mockImplementation(() => new Promise((resolve) => { + finishFetch = resolve; + })); + const command = { + workspaceId: workspace.id, + content: "Concurrent first turn", + pullRequestNumber: 42, + startupId: directStartupId, + }; + const first = coordinator.createInitialTurn(command); + const second = coordinator.createInitialTurn(command); + await vi.waitFor(() => expect(finishFetch).toBeDefined()); + await expect(coordinator.createInitialTurn({ ...command, content: "Different prompt" })) + .rejects.toThrow("already assigned to a different request"); + finishFetch?.(); + + const [a, b] = await Promise.all([first, second]); + expect(a.thread.id).toBe(b.thread.id); + expect(b.kind).toBe("replay"); + expect(gitRepository.fetchBranch).toHaveBeenCalledOnce(); + expect(admissions.admitInitialAutomaticTurn).toHaveBeenCalledOnce(); + expect(threads.listByWorkspace(workspace.id)).toHaveLength(1); + db.close(); + }); + + it("does not create another thread when an interrupted startup has no durable binding", async () => { + const { db, workspace, threads, startups, admissions, coordinator } = harness(); + startups.start({ startupId: directStartupId, workspaceId: workspace.id, kind: "direct" }); + startups.advance(directStartupId, "thread"); + startups.interruptNonterminalOnStartup(); + + await expect(coordinator.createInitialTurn({ + workspaceId: workspace.id, + content: "Retry after interruption", + startupId: directStartupId, + })).rejects.toThrow("inspect the workspace before retrying"); + expect(threads.listByWorkspace(workspace.id)).toHaveLength(0); + expect(admissions.admitInitialAutomaticTurn).not.toHaveBeenCalled(); + db.close(); + }); + it("completes Direct startup only after first runtime admission and records a first-dispatch failure", async () => { const { db, workspace, startups, coordinator } = harness(); @@ -109,6 +369,15 @@ describe("ThreadCreationCoordinator startup lifecycle", () => { { phase: "agent", state: "pending" }, ], }); + expect(await coordinator.createInitialTurn({ + workspaceId: workspace.id, + content: "Queue managed work", + mode: "worktree", + branch: "feature/managed", + startupId: managedStartupId, + })).toMatchObject({ kind: "replay", thread: { id: managed.id } }); + expect(threadService.create).toHaveBeenCalledOnce(); + expect(admissions.admitInitialAutomaticTurn).toHaveBeenCalledOnce(); db.close(); }); @@ -157,6 +426,15 @@ describe("ThreadCreationCoordinator startup lifecycle", () => { phase: "thread", error: { code: "THREAD_CREATE_FAILED", retryable: true }, }); + await expect(coordinator.createInitialTurn({ + workspaceId: workspace.id, + content: "Review this PR", + mode: "worktree", + branch: "contributor/review", + pullRequestNumber: 42, + startupId: managedStartupId, + })).rejects.toThrow("retry with a new startup ID"); + expect(threadService.create).not.toHaveBeenCalled(); db.close(); }); diff --git a/apps/server/src/features/agents/turns/thread-creation-coordinator.ts b/apps/server/src/features/agents/turns/thread-creation-coordinator.ts index 4200e1989..ce8f423f7 100644 --- a/apps/server/src/features/agents/turns/thread-creation-coordinator.ts +++ b/apps/server/src/features/agents/turns/thread-creation-coordinator.ts @@ -1,3 +1,4 @@ +import * as NodeCrypto from "node:crypto"; import { inject, injectable } from "tsyringe"; import type { ContextWindowMode, @@ -10,14 +11,15 @@ import type { ProviderId, ReasoningLevel, Thread, + ThreadStartup, } from "@mcode/contracts"; import { ThreadService } from "../../thread-control/index.js"; import { ThreadRepo } from "../../thread-control/persistence/thread-repo.js"; import { GitRepositoryService } from "../../projects/git/git-repository-service.js"; -import { ThreadBranchingService } from "../../projects/worktrees/thread-branching-service.js"; +import { ThreadBranchingService, type BranchedThreadLifecycle } from "../../projects/worktrees/thread-branching-service.js"; import { PlanTurnService } from "../planning/plan-turn-service.js"; -import { ThreadStartupService } from "../../thread-startup/thread-startup-service.js"; +import { ThreadStartupConflictError, ThreadStartupService } from "../../thread-startup/thread-startup-service.js"; import { TURN_ADMISSION_DISPATCH_COORDINATOR, type SendMessageCommand, @@ -39,6 +41,7 @@ export type CreateAndSendCommand = Omit< /** A newly provisioned thread plus its first admitted runtime command. */ export type CreatedInitialTurn = | { readonly kind: "queued"; readonly thread: Thread & { warnings?: string[] }; readonly startupId?: string } + | { readonly kind: "replay"; readonly thread: Thread; readonly startupId: string } | { readonly kind: "dispatch"; readonly thread: Thread & { warnings?: string[] }; readonly command: SendMessageCommand; readonly startupId?: string }; /** Input for a direct or managed-worktree thread provisioned before its first turn. */ @@ -106,6 +109,8 @@ class StartupCancelledError extends Error { /** Owns persistence and worktree provisioning for a thread before first-turn admission. */ @injectable() export class ThreadCreationCoordinator { + private readonly inFlightStartups = new Map }>(); + constructor( @inject(ThreadRepo) private readonly threads: ThreadRepo, private readonly threadService: () => ThreadService, @@ -119,9 +124,25 @@ export class ThreadCreationCoordinator { /** Provision a thread and prepare its first command without acquiring runtime authority. */ async createInitialTurn(command: CreateAndSendCommand): Promise { const params = this.initialTurnParams(command); - return params.parentThreadId - ? this.createBranchedInitialTurn(params as BranchedInitialTurnParams & { parentThreadId: string }) - : this.createStandaloneInitialTurn(command, params); + if (!command.startupId) return this.provisionInitialTurn(command, params); + + const fingerprint = NodeCrypto.createHash("sha256").update(JSON.stringify(params)).digest("hex"); + const inFlight = this.inFlightStartups.get(command.startupId); + if (inFlight) { + if (inFlight.fingerprint !== fingerprint) throw new ThreadStartupConflictError(command.startupId); + const created = await inFlight.result; + return { kind: "replay", thread: created.thread, startupId: command.startupId }; + } + const started = this.startStartup(command, params, fingerprint); + const result = started?.existing + ? Promise.resolve(this.replayStartup(started.startup)) + : this.provisionInitialTurn(command, params, started?.startup); + this.inFlightStartups.set(command.startupId, { fingerprint, result }); + try { + return await result; + } finally { + this.inFlightStartups.delete(command.startupId); + } } /** Enter the first runtime phase after direct thread provisioning. */ @@ -227,8 +248,8 @@ export class ThreadCreationCoordinator { private async createStandaloneInitialTurn( command: CreateAndSendCommand, params: BranchedInitialTurnParams, + startup?: ThreadStartup, ): Promise { - const startup = this.startStartup(command, params); const creation = { workspaceId: params.workspaceId, title: params.title, @@ -266,10 +287,14 @@ export class ThreadCreationCoordinator { await this.gitRepository.fetchBranch(input.workspaceId, input.branch, input.pullRequestNumber); this.cancelIfRequested(startupId); } + const startupService = startupId ? this.startups() : undefined; const created = input.mode === "worktree" ? await this.createManagedThread(input, startupId) - : this.threads.create(input.workspaceId, input.title, "direct", input.branch, true, input.provider); - if (startupId && input.mode === "direct") this.startups()?.bindThread(startupId, created.id); + : startupId && startupService + ? startupService.createAndBindThread(startupId, () => this.threads.create( + input.workspaceId, input.title, "direct", input.branch, true, input.provider, + )) + : this.threads.create(input.workspaceId, input.title, "direct", input.branch, true, input.provider); return this.configure(created, input); } @@ -399,20 +424,49 @@ export class ThreadCreationCoordinator { }; } - private startStartup(command: CreateAndSendCommand, params: BranchedInitialTurnParams) { + private startStartup(command: CreateAndSendCommand, params: BranchedInitialTurnParams, fingerprint?: string) { if (!command.startupId) return undefined; const startupService = this.startups(); if (!startupService) return undefined; + const existing = startupService.get(command.startupId) !== null; const startup = startupService.start({ startupId: command.startupId, workspaceId: params.workspaceId, kind: params.mode === "worktree" && !params.existingWorktreePath ? "managed-worktree" : "direct", - }); - startupService.advance(startup.startupId, "thread"); - this.cancelIfRequested(startup.startupId); - return startup; + }, fingerprint); + if (!existing) { + startupService.advance(startup.startupId, "thread"); + this.cancelIfRequested(startup.startupId); + } + return { startup, existing }; + } + + private provisionInitialTurn( + command: CreateAndSendCommand, + params: BranchedInitialTurnParams, + startup?: ThreadStartup, + ): Promise { + if (params.parentThreadId) { + return this.createBranchedInitialTurn(params as BranchedInitialTurnParams & { parentThreadId: string }, startup); + } + return this.createStandaloneInitialTurn(command, params, startup); + } + + private replayStartup(startup: ThreadStartup): CreatedInitialTurn { + const thread = startup.threadId ? this.threads.findById(startup.threadId) : null; + if (thread?.deleted_at) throw new Error(`Startup ${startup.startupId} belongs to a deleted thread`); + if (startup.state === "interrupted") { + throw new Error(`Startup ${startup.startupId} was interrupted; inspect the workspace before retrying`); + } + if (startup.state === "failed" || startup.state === "cancelled") { + throw new Error(`Startup ${startup.startupId} failed or was cancelled; retry with a new startup ID`); + } + if (!thread) { + throw new Error(`Startup ${startup.startupId} has no available thread yet; wait for its status to settle`); + } + return { kind: "replay", thread, startupId: startup.startupId }; } private cancelIfRequested(startupId: string | undefined): void { @@ -511,6 +565,28 @@ export class ThreadCreationCoordinator { private async createBranchedInitialTurn( params: BranchedInitialTurnParams & { parentThreadId: string }, + startup?: ThreadStartup, + ): Promise { + try { + return await this.provisionBranchedInitialTurn(params, startup); + } catch (error) { + this.clearRemovedBranchedThreadBinding(startup); + if (!(error instanceof StartupCancelledError)) this.failStartup(startup?.startupId); + throw error; + } + } + + private clearRemovedBranchedThreadBinding(startup: ThreadStartup | undefined): void { + if (!startup) return; + const boundThreadId = this.startups()?.get(startup.startupId)?.threadId; + if (!boundThreadId) return; + const thread = this.threads.findById(boundThreadId); + if (!thread || thread.deleted_at) this.startups()?.clearThreadBinding(startup.startupId); + } + + private async provisionBranchedInitialTurn( + params: BranchedInitialTurnParams & { parentThreadId: string }, + startup?: ThreadStartup, ): Promise { const branching = this.branching?.(); if (!branching) throw new Error("Thread branching is not configured"); @@ -536,12 +612,14 @@ export class ThreadCreationCoordinator { codexFastMode: params.codexFastMode, devinMode: params.devinMode, orchestrationMode: params.orchestrationMode, - }); + }, this.branchedLifecycle(startup)); + this.finishBranchedStartup(startup); const providerWireOverride = params.interactionMode === "plan" ? this.plans()?.buildQuestionPrompt(provisioned.providerWireOverride) ?? provisioned.providerWireOverride : provisioned.providerWireOverride; return { kind: "dispatch", + ...(startup ? { startupId: startup.startupId } : {}), thread: { ...provisioned.thread, ...(provisioned.warnings?.length ? { warnings: provisioned.warnings } : {}), @@ -573,6 +651,32 @@ export class ThreadCreationCoordinator { }, }; } + + private branchedLifecycle(startup: ThreadStartup | undefined): BranchedThreadLifecycle | undefined { + if (!startup) return undefined; + const startupService = this.startups(); + if (!startupService) throw new Error(`Startup ${startup.startupId} has no lifecycle service`); + return { + createAndBindDirectThread: (create) => { + this.cancelIfRequested(startup.startupId); + return startupService.createAndBindThread(startup.startupId, create); + }, + onManagedThreadPersisted: (thread) => { + this.cancelIfRequested(startup.startupId); + startupService.bindThread(startup.startupId, thread.id); + startupService.advance(startup.startupId, "worktree"); + }, + }; + } + + private finishBranchedStartup(startup: ThreadStartup | undefined): void { + if (!startup) return; + if (startup.kind === "managed-worktree") { + this.startups()?.advance(startup.startupId, "setup"); + this.startups()?.skip(startup.startupId, "setup"); + } + this.cancelIfRequested(startup.startupId); + } } function titleFrom(content: string): string { diff --git a/apps/server/src/features/projects/worktrees/thread-branching-service.ts b/apps/server/src/features/projects/worktrees/thread-branching-service.ts index f73931439..691e77f8f 100644 --- a/apps/server/src/features/projects/worktrees/thread-branching-service.ts +++ b/apps/server/src/features/projects/worktrees/thread-branching-service.ts @@ -59,6 +59,12 @@ export interface ProvisionedBranchedThread { warnings?: string[]; } +/** Binds startup ownership immediately after the branched child is persisted. */ +export interface BranchedThreadLifecycle { + createAndBindDirectThread(create: () => T): T; + onManagedThreadPersisted(thread: Thread): void; +} + type Fork = { parent: Thread; message: Message; @@ -87,10 +93,10 @@ export class ThreadBranchingService { ) {} /** Create the child and durable handoff before the runtime owner dispatches its first turn. */ - async create(input: CreateBranchedThreadInput): Promise { + async create(input: CreateBranchedThreadInput, lifecycle?: BranchedThreadLifecycle): Promise { const fork = this.loadFork(input); const inherited = this.inheritSettings(fork.parent, input); - const created = await this.createChild(input, fork.messageId); + const created = await this.createChild(input, fork.messageId, lifecycle); const thread = this.configureChild(created.thread, input, inherited, fork.messageId); const handoff = await this.handoffs.deliverHandoff({ parentThread: fork.parent, @@ -146,46 +152,50 @@ export class ThreadBranchingService { }; } - private async createChild(input: CreateBranchedThreadInput, messageId: string) { + private async createChild(input: CreateBranchedThreadInput, messageId: string, lifecycle?: BranchedThreadLifecycle) { if (input.existingWorktreePath) { - return { thread: await this.attachExisting(input, messageId) }; + return { thread: await this.attachExisting(input, messageId, lifecycle) }; } - if (input.mode === "worktree") return this.createManaged(input, messageId); - return { thread: this.threads.create(input.workspaceId, input.title, "direct", input.branch, true, input.provider, { + if (input.mode === "worktree") return this.createManaged(input, messageId, lifecycle); + const create = () => this.threads.create(input.workspaceId, input.title, "direct", input.branch, true, input.provider, { parentThreadId: input.parentThreadId, forkedFromMessageId: messageId, - }) }; + }); + return { thread: lifecycle ? lifecycle.createAndBindDirectThread(create) : create() }; } - private async createManaged(input: CreateBranchedThreadInput, messageId: string) { + private async createManaged(input: CreateBranchedThreadInput, messageId: string, lifecycle?: BranchedThreadLifecycle) { const created = await this.threadService.create(input.workspaceId, input.title, "worktree", input.branch, { branchless: input.worktreeBranchMode !== "named", + lifecycle: { + onThreadPersisted: (thread) => { + this.threads.updateLineage(thread.id, input.parentThreadId, messageId); + this.threads.updateProvider(thread.id, input.provider); + lifecycle?.onManagedThreadPersisted(thread); + }, + }, }); - try { - this.threads.updateLineage(created.id, input.parentThreadId, messageId); - this.threads.updateProvider(created.id, input.provider); - } catch (error) { - this.threads.softDelete(created.id); - throw error; - } return { thread: { ...created, provider: input.provider, parent_thread_id: input.parentThreadId, forked_from_message_id: messageId }, warnings: created.warnings, }; } - private async attachExisting(input: CreateBranchedThreadInput, messageId: string): Promise { + private async attachExisting(input: CreateBranchedThreadInput, messageId: string, lifecycle?: BranchedThreadLifecycle): Promise { const known = await this.worktrees.listWorktrees(input.workspaceId); const worktree = known.find((candidate) => this.samePath(candidate.path, input.existingWorktreePath!)); if (!worktree) throw new Error("Path is not a recognized worktree"); const branch = this.attachBranch(worktree.branch, input.existingWorktreeBaseBranch); const detached = worktree.branch === "(detached)"; - const thread = this.threads.create(input.workspaceId, input.title, "worktree", branch, false, input.provider, { - parentThreadId: input.parentThreadId, - forkedFromMessageId: messageId, - }, detached ? "branchless" : "named", detached ? branch : null); - this.threads.updateWorktreePath(thread.id, worktree.path); - return { ...thread, worktree_path: worktree.path }; + const create = () => { + const thread = this.threads.create(input.workspaceId, input.title, "worktree", branch, false, input.provider, { + parentThreadId: input.parentThreadId, + forkedFromMessageId: messageId, + }, detached ? "branchless" : "named", detached ? branch : null); + this.threads.updateWorktreePath(thread.id, worktree.path); + return { ...thread, worktree_path: worktree.path }; + }; + return lifecycle ? lifecycle.createAndBindDirectThread(create) : create(); } private attachBranch(branch: string, baseBranch: string | undefined): string { diff --git a/apps/server/src/features/thread-startup/__tests__/thread-startup-service.test.ts b/apps/server/src/features/thread-startup/__tests__/thread-startup-service.test.ts index 26fc6ec90..2854be321 100644 --- a/apps/server/src/features/thread-startup/__tests__/thread-startup-service.test.ts +++ b/apps/server/src/features/thread-startup/__tests__/thread-startup-service.test.ts @@ -70,6 +70,20 @@ describe("ThreadStartupService", () => { db.close(); }); + it("rejects a changed durable request identity after a new service instance opens the same database", () => { + const { db, service } = createHarness(); + service.start(input(), "first-request"); + const restarted = new ThreadStartupService(new ThreadStartupRepo(db)); + + expect(restarted.start(input(), "first-request").startupId).toBe(firstStartupId); + expect(() => restarted.start(input(), "changed-request")).toThrow(ThreadStartupConflictError); + + // Rows created before the fingerprint migration remain readable and replayable. + service.start(input(secondStartupId)); + expect(restarted.start(input(secondStartupId), "new-request").startupId).toBe(secondStartupId); + db.close(); + }); + it("keeps terminal records stable", () => { const { db, service } = createHarness(); service.start(input()); diff --git a/apps/server/src/features/thread-startup/persistence/thread-startup-repo.ts b/apps/server/src/features/thread-startup/persistence/thread-startup-repo.ts index 6f05cda3c..f1524cdcb 100644 --- a/apps/server/src/features/thread-startup/persistence/thread-startup-repo.ts +++ b/apps/server/src/features/thread-startup/persistence/thread-startup-repo.ts @@ -21,7 +21,7 @@ export class ThreadStartupRepo { } /** Insert one new startup snapshot. */ - insert(startup: ThreadStartup): void { + insert(startup: ThreadStartup, requestFingerprint?: string): void { this.orm.insert(threadStartups).values({ startupId: startup.startupId, workspaceId: startup.workspaceId, @@ -32,6 +32,7 @@ export class ThreadStartupRepo { transcriptJson: JSON.stringify(startup.transcript), cancellation: startup.cancellation, revision: startup.revision, + requestFingerprint: requestFingerprint ?? null, threadId: startup.threadId ?? null, errorJson: startup.error ? JSON.stringify(startup.error) : null, blockJson: startup.block ? JSON.stringify(startup.block) : null, @@ -40,6 +41,21 @@ export class ThreadStartupRepo { }).run(); } + /** Return the private request identity without exposing it in lifecycle snapshots. */ + requestFingerprint(startupId: string): string | null { + const row = this.orm + .select({ requestFingerprint: threadStartups.requestFingerprint }) + .from(threadStartups) + .where(eq(threadStartups.startupId, startupId)) + .get(); + return row?.requestFingerprint ?? null; + } + + /** Commit direct thread creation and its startup binding together. */ + transaction(operation: () => T): T { + return this.orm.transaction(operation); + } + /** Return one startup snapshot by its client-generated identity. */ findById(startupId: string): ThreadStartup | null { const row = this.orm diff --git a/apps/server/src/features/thread-startup/thread-startup-service.ts b/apps/server/src/features/thread-startup/thread-startup-service.ts index 46e3047af..b666e0b6d 100644 --- a/apps/server/src/features/thread-startup/thread-startup-service.ts +++ b/apps/server/src/features/thread-startup/thread-startup-service.ts @@ -42,10 +42,12 @@ export class ThreadStartupService { ) {} /** Start one lifecycle or return its existing snapshot when the request matches. */ - start(input: ThreadStartupStartInput): ThreadStartup { + start(input: ThreadStartupStartInput, requestFingerprint?: string): ThreadStartup { const existing = this.startupRepo.findById(input.startupId); if (existing) { - if (existing.workspaceId === input.workspaceId && existing.kind === input.kind) return existing; + const persistedFingerprint = this.startupRepo.requestFingerprint(input.startupId); + if (existing.workspaceId === input.workspaceId && existing.kind === input.kind + && (!persistedFingerprint || !requestFingerprint || persistedFingerprint === requestFingerprint)) return existing; throw new ThreadStartupConflictError(input.startupId); } @@ -62,10 +64,19 @@ export class ThreadStartupService { createdAt: timestamp, updatedAt: timestamp, }; - this.startupRepo.insert(startup); + this.startupRepo.insert(startup, requestFingerprint); return this.publish(startup); } + /** Bind a newly persisted direct thread in the same SQLite commit. */ + createAndBindThread(startupId: string, create: () => T): T { + return this.startupRepo.transaction(() => { + const thread = create(); + this.bindThread(startupId, thread.id); + return thread; + }); + } + /** Read one authoritative lifecycle snapshot. */ get(startupId: string): ThreadStartup | null { return this.startupRepo.findById(startupId); @@ -120,6 +131,13 @@ export class ThreadStartupService { return this.persistNext({ ...startup, threadId }); } + /** Remove a binding after the owning creation flow confirms that its child was deleted. */ + clearThreadBinding(startupId: string): ThreadStartup { + const startup = this.require(startupId); + if (!startup.threadId) return startup; + return this.persistNext({ ...startup, threadId: undefined }); + } + /** Append one bounded output entry and retain only the newest bounded transcript. */ appendOutput(startupId: string, content: string): ThreadStartup { const startup = this.require(startupId); diff --git a/apps/server/src/runtime/persistence/sqlite/schema.ts b/apps/server/src/runtime/persistence/sqlite/schema.ts index 0b4e8c90f..b026777a7 100644 --- a/apps/server/src/runtime/persistence/sqlite/schema.ts +++ b/apps/server/src/runtime/persistence/sqlite/schema.ts @@ -163,6 +163,7 @@ export const threadStartups = sqliteTable( transcriptJson: text("transcript_json").notNull(), cancellation: text("cancellation").notNull().default("none"), revision: integer("revision").notNull(), + requestFingerprint: text("request_fingerprint"), threadId: text("thread_id").references(() => threads.id, { onDelete: "set null" }), errorJson: text("error_json"), blockJson: text("block_json"), diff --git a/apps/web/src/components/chat/__tests__/empty-state.test.tsx b/apps/web/src/components/chat/__tests__/empty-state.test.tsx index 582b72494..c6feb74ed 100644 --- a/apps/web/src/components/chat/__tests__/empty-state.test.tsx +++ b/apps/web/src/components/chat/__tests__/empty-state.test.tsx @@ -10,10 +10,12 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; // Store mocks must be declared before importing the component under test. const emptyDisplayedConversationIds = vi.hoisted((): readonly string[] => []); +const workspaceStateRef = vi.hoisted(() => ({ current: null as unknown })); vi.mock("@/features/projects/state/workspaceStore", () => ({ - useWorkspaceStore: vi.fn((selector: (s: unknown) => unknown) => - selector(defaultWorkspaceState()) + useWorkspaceStore: Object.assign( + vi.fn((selector: (s: unknown) => unknown) => selector(workspaceStateRef.current)), + { getState: () => workspaceStateRef.current }, ), })); @@ -71,7 +73,6 @@ vi.mock("@/components/chat/CliErrorBanner", () => ({ isCliError: () => false, })); -import { useWorkspaceStore } from "@/features/projects/state/workspaceStore"; import { ChatView } from "@/features/conversation"; /** Produces a workspace state that shows the new-thread empty state. */ @@ -96,9 +97,7 @@ function defaultWorkspaceState() { } function setupWorkspaceMock(state: ReturnType) { - (useWorkspaceStore as unknown as { mockImplementation: (fn: (selector: (s: unknown) => unknown) => unknown) => void }).mockImplementation( - (selector) => selector(state) - ); + workspaceStateRef.current = state; } describe("NewThreadWelcome", () => { diff --git a/apps/web/src/features/conversation/composer/execution/__tests__/Composer.checkout-dialog.test.tsx b/apps/web/src/features/conversation/composer/execution/__tests__/Composer.checkout-dialog.test.tsx index 46ed5ef0a..11d4141c5 100644 --- a/apps/web/src/features/conversation/composer/execution/__tests__/Composer.checkout-dialog.test.tsx +++ b/apps/web/src/features/conversation/composer/execution/__tests__/Composer.checkout-dialog.test.tsx @@ -2,7 +2,7 @@ import React from "react"; import { act, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import type { AgentEvent, SelectedTextComment } from "@mcode/contracts"; +import { ORCHESTRATION_MODES, type AgentEvent, type SelectedTextComment } from "@mcode/contracts"; import { Composer } from "../../Composer"; import { useWorkspaceStore } from "@/features/projects/state/workspaceStore"; import { @@ -20,8 +20,9 @@ import { import { useThreadStore } from "@/stores/threadStore"; import { useToastStore } from "@/stores/toastStore"; import { useComposerDraftStore } from "@/stores/composerDraftStore"; +import { useThreadDraftStore } from "@/stores/threadDraftStore"; import { mockTransport, createMockThread, createMockWorkspace } from "@/__tests__/mocks/transport"; -import type { GitBranch } from "@/transport"; +import { INTERACTION_MODES, PERMISSION_MODES, type GitBranch } from "@/transport"; let lastComposerText = ""; let lastFileAutocompleteOptions: Record | undefined; @@ -723,6 +724,37 @@ describe("Composer checkout confirmation", () => { ).toMatch(/^[0-9a-f-]{36}$/); }); + it("does not restore a Draft row while a lost response is replayed", async () => { + seedComposerState("worktree"); + useThreadDraftStore.setState({ drafts: {} }); + let resolveReply!: (value: unknown) => void; + (mockTransport.createAndSendMessage as ReturnType).mockReturnValueOnce( + new Promise((resolve) => { resolveReply = resolve; }), + ); + const onThreadPreparing = vi.fn(); + const onThreadCreated = vi.fn(); + const onThreadCreationFailed = vi.fn(); + render(); + + await typeAndSend(); + await waitFor(() => expect(onThreadPreparing).toHaveBeenCalledOnce()); + const placeholderId = onThreadPreparing.mock.calls[0]?.[0].id; + expect(useComposerDraftStore.getState().getDraft(placeholderId)?.input).toBe("Build this"); + expect(onThreadCreationFailed).not.toHaveBeenCalled(); + expect(Object.keys(useThreadDraftStore.getState().drafts)).toHaveLength(0); + + const durable = createMockThread({ id: "thread-replayed", workspace_id: "ws-1" }); + await act(async () => { + resolveReply({ ...durable, runtimeSnapshot: { threadId: durable.id, turnExecutionId: "exec-replayed", phase: "running" } }); + }); + await waitFor(() => expect(onThreadCreated).toHaveBeenCalledWith(expect.objectContaining({ id: durable.id }))); + expect(useWorkspaceStore.getState().threads.map((thread) => thread.id)).toEqual([durable.id]); + expect(useComposerDraftStore.getState().getDraft(placeholderId)).toBeUndefined(); + expect(Object.keys(useThreadDraftStore.getState().drafts)).toHaveLength(0); + expect(onThreadCreationFailed).not.toHaveBeenCalled(); + }); + it("reports a failed new-thread request after exposing its optimistic startup row", async () => { seedComposerState("worktree"); (mockTransport.createAndSendMessage as ReturnType).mockRejectedValue( @@ -745,6 +777,64 @@ describe("Composer checkout confirmation", () => { expect(onThreadCreationFailed).toHaveBeenCalledOnce(); }); + it("keeps a failed saved-Draft submission with its preparing task", async () => { + seedComposerState("worktree"); + useThreadDraftStore.setState({ drafts: {} }); + const draftId = useThreadDraftStore.getState().saveDraft({ + workspaceId: "ws-1", + draft: { + input: "Build from saved draft", + mentions: [], + selectedTextComments: [], + attachments: [], + modelId: "gpt-5.5", + provider: "codex", + reasoning: "high", + }, + selection: { + interactionMode: INTERACTION_MODES.PLAN, + permissionMode: PERMISSION_MODES.FULL, + orchestrationMode: ORCHESTRATION_MODES.STANDARD, + approvalReviewMode: "manual", + copilotAgent: null, + thinking: null, + }, + target: { + mode: "worktree", + branch: "feature/base", + branchSource: "branch", + customBranchName: "", + autoPreviewBranch: "", + selectedWorktree: null, + branchManuallySelected: false, + }, + }); + expect(draftId).not.toBeNull(); + useWorkspaceStore.setState({ activeDraftId: draftId }); + let rejectCreate!: (error: Error) => void; + (mockTransport.createAndSendMessage as ReturnType).mockReturnValueOnce( + new Promise((_resolve, reject) => { rejectCreate = reject; }), + ); + const onThreadPreparing = vi.fn(); + const onThreadCreationFailed = vi.fn(); + render(); + + await waitFor(() => expect(lastComposerText).toBe("Build from saved draft")); + await userEvent.setup().click(screen.getByLabelText("Send message")); + await waitFor(() => expect(onThreadPreparing).toHaveBeenCalledOnce()); + const placeholderId = onThreadPreparing.mock.calls[0]?.[0].id; + expect(useThreadDraftStore.getState().drafts[draftId!]).toBeUndefined(); + await act(async () => { rejectCreate(new Error("thread creation failed")); }); + + await waitFor(() => expect(onThreadCreationFailed).toHaveBeenCalledOnce()); + expect(Object.keys(useThreadDraftStore.getState().drafts)).toHaveLength(0); + expect(useComposerDraftStore.getState().getDraft(placeholderId)?.input).toBe("Build from saved draft"); + expect(useWorkspaceStore.getState().threads).toEqual([ + expect.objectContaining({ id: placeholderId, clientError: "Error: thread creation failed" }), + ]); + }); + it("clears annotations and comments after a successful feedback send", async () => { const workspace = createMockWorkspace({ id: "ws-1", is_git_repo: true }); const thread = createMockThread({ id: "thread-1", workspace_id: "ws-1" }); diff --git a/apps/web/src/features/conversation/composer/submission/useComposerSubmissionController.test.tsx b/apps/web/src/features/conversation/composer/submission/useComposerSubmissionController.test.tsx index 6208a79bb..f2464bea8 100644 --- a/apps/web/src/features/conversation/composer/submission/useComposerSubmissionController.test.tsx +++ b/apps/web/src/features/conversation/composer/submission/useComposerSubmissionController.test.tsx @@ -1,6 +1,9 @@ import { act, renderHook, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { SelectedTextComment } from "@mcode/contracts"; +import { ORCHESTRATION_MODES, type SelectedTextComment } from "@mcode/contracts"; +import { INTERACTION_MODES, PERMISSION_MODES } from "@/transport"; +import { useWorkspaceStore } from "@/features/projects/state/workspaceStore"; +import { useThreadDraftStore } from "@/stores/threadDraftStore"; import type { ComposerExecutionTargetController } from "../execution/useComposerExecutionTarget"; import { useComposerFormController } from "../draft/useComposerFormController"; @@ -65,10 +68,11 @@ function execution(): ComposerExecutionTargetController { }; } -function useHarness(queueOverrides: Partial = {}) { +function useHarness(queueOverrides: Partial = {}, draftId?: string) { const form = useComposerFormController({ isNewThread: true, workspaceId: "workspace-1", + draftId, }); const controller = useComposerSubmissionController({ isNewThread: true, @@ -140,6 +144,55 @@ describe("useComposerSubmissionController selected-text comments", () => { expect(result.current.form.state.selectedTextComments).toEqual([comment]); }); + it("restores a saved Draft when dispatch fails before a preparing task exists", async () => { + useThreadDraftStore.setState({ drafts: {} }); + useWorkspaceStore.setState({ activeDraftId: null, activeWorkspaceId: "workspace-1" }); + const draftId = useThreadDraftStore.getState().saveDraft({ + workspaceId: "workspace-1", + draft: { + input: "Keep this draft", + mentions: [], + selectedTextComments: [], + attachments: [], + modelId: "gpt-5.5", + provider: "codex", + reasoning: "high", + }, + selection: { + interactionMode: INTERACTION_MODES.PLAN, + permissionMode: PERMISSION_MODES.FULL, + orchestrationMode: ORCHESTRATION_MODES.STANDARD, + approvalReviewMode: "manual", + copilotAgent: null, + thinking: null, + }, + target: { + mode: "direct", + branch: "main", + branchSource: "branch", + customBranchName: "", + autoPreviewBranch: "", + selectedWorktree: null, + branchManuallySelected: false, + }, + }); + expect(draftId).not.toBeNull(); + useWorkspaceStore.setState({ activeDraftId: draftId }); + let rejectDispatch!: (error: Error) => void; + routeMocks.dispatchComposerTarget.mockReturnValueOnce(new Promise((_resolve, reject) => { + rejectDispatch = reject; + })); + const { result } = renderHook(() => useHarness({}, draftId!)); + + await waitFor(() => expect(result.current.form.state.text).toBe("Keep this draft")); + act(() => { void result.current.controller.submit(); }); + await waitFor(() => expect(useThreadDraftStore.getState().drafts[draftId!]).toBeUndefined()); + await act(async () => { rejectDispatch(new Error("invalid target")); }); + + await waitFor(() => expect(useThreadDraftStore.getState().drafts[draftId!]?.draft.input).toBe("Keep this draft")); + expect(useWorkspaceStore.getState().activeDraftId).toBe(draftId); + }); + it("dispatches saved cards after a dirty editor completes its dismissal warning flow", async () => { const { result } = renderHook(useHarness); diff --git a/apps/web/src/features/conversation/composer/submission/useComposerSubmissionController.ts b/apps/web/src/features/conversation/composer/submission/useComposerSubmissionController.ts index 4dc29b6a0..3ce0a0621 100644 --- a/apps/web/src/features/conversation/composer/submission/useComposerSubmissionController.ts +++ b/apps/web/src/features/conversation/composer/submission/useComposerSubmissionController.ts @@ -135,6 +135,7 @@ export function useComposerSubmissionController({ ); annotations.clearBeforeDispatch(); queue.consumeEditForDispatch(); + let placeholderAccepted = false; const dispatch = dispatchComposerTarget({ threadId, workspaceId, @@ -145,7 +146,10 @@ export function useComposerSubmissionController({ submission, onBranchModeExit, onThreadCreated, - onThreadPreparing, + onThreadPreparing: (thread) => { + placeholderAccepted = true; + onThreadPreparing?.(thread); + }, onThreadCreationFailed, }); const draftCleared = form.clearSubmittedDraft(submission.snapshot); @@ -155,7 +159,7 @@ export function useComposerSubmissionController({ await dispatch; } catch (error) { queue.releaseConsumedEdit(); - if (draftCleared) form.restoreFailedDispatch(); + if (draftCleared && !placeholderAccepted) form.restoreFailedDispatch(); annotations.restoreAfterFailure(); showDispatchFailure(error, submission.snapshot.selectedTextComments.length > 0); return; diff --git a/apps/web/src/features/conversation/messages/ChatView.tsx b/apps/web/src/features/conversation/messages/ChatView.tsx index 13124c3a9..967810680 100644 --- a/apps/web/src/features/conversation/messages/ChatView.tsx +++ b/apps/web/src/features/conversation/messages/ChatView.tsx @@ -247,12 +247,10 @@ export function ChatView({ onSubagentSelect, onOpenSubagents }: ChatViewProps = useEffect(() => { const previousStatus = previousConnectionStatusRef.current; previousConnectionStatusRef.current = state.connectionStatus; - if (previousStatus !== "connected") return; - if (state.connectionStatus !== "reconnecting" && state.connectionStatus !== "authFailed") return; - const activeThreadId = useWorkspaceStore.getState().activeThreadId; - if (!activeThreadId) return; - const thread = useWorkspaceStore.getState().threads.find((candidate) => candidate.id === activeThreadId); - if (thread?.clientPreparing) useWorkspaceStore.getState().failPreparingThreadOnConnectionLost(activeThreadId); + const hasFailedPlaceholder = useWorkspaceStore.getState().threads.some((thread) => thread.clientError != null); + if (state.connectionStatus === "connected" && (previousStatus !== "connected" || hasFailedPlaceholder)) { + void useWorkspaceStore.getState().recoverPreparingThreads(); + } }, [state.connectionStatus]); useThreadSubscriptionReconciler({ diff --git a/apps/web/src/features/conversation/messages/__tests__/ChatView.test.tsx b/apps/web/src/features/conversation/messages/__tests__/ChatView.test.tsx index d8904c583..c145f40f5 100644 --- a/apps/web/src/features/conversation/messages/__tests__/ChatView.test.tsx +++ b/apps/web/src/features/conversation/messages/__tests__/ChatView.test.tsx @@ -20,6 +20,10 @@ const { chatViewResidencyMock, chatViewDisplayLeaseIdsRef, chatViewDisplayLeaseListeners, + chatViewGetDraftMock, + chatViewSetPendingPrefillMock, + chatViewSaveDraftMock, + chatViewRemoveDraftMock, } = vi.hoisted(() => { const chatViewDisplayLeaseIdsRef = { current: [] as readonly string[] }; const chatViewDisplayLeaseListeners = new Set<() => void>(); @@ -49,6 +53,10 @@ const { chatViewApplyCanonicalRecoveriesMock: vi.fn(), chatViewDisplayLeaseIdsRef, chatViewDisplayLeaseListeners, + chatViewGetDraftMock: vi.fn(), + chatViewSetPendingPrefillMock: vi.fn(), + chatViewSaveDraftMock: vi.fn(), + chatViewRemoveDraftMock: vi.fn(), chatViewResidencyMock: { invalidateConversation: vi.fn(), refresh: vi.fn().mockResolvedValue(undefined), @@ -122,8 +130,19 @@ vi.mock("@/stores/thread-selectors", async () => { }); vi.mock("@/stores/composerDraftStore", () => ({ - useComposerDraftStore: vi.fn((selector: (s: unknown) => unknown) => - selector({ drafts: {}, setPendingPrefill: vi.fn() }) + draftHasNoSendableContent: (draft: { input: string; attachments: unknown[]; selectedTextComments?: unknown[]; selectedTextCommentEditor?: unknown }) => + draft.input.trim() === "" && draft.attachments.length === 0 + && (draft.selectedTextComments?.length ?? 0) === 0 && !draft.selectedTextCommentEditor, + useComposerDraftStore: Object.assign( + vi.fn((selector: (s: unknown) => unknown) => + selector({ drafts: {}, setPendingPrefill: chatViewSetPendingPrefillMock }) + ), + { getState: () => ({ + getDraft: chatViewGetDraftMock, + setPendingPrefill: chatViewSetPendingPrefillMock, + saveDraft: chatViewSaveDraftMock, + removeDraftAfterAttachmentTransfer: chatViewRemoveDraftMock, + }) }, ), })); @@ -178,6 +197,8 @@ import { useRecoveryIncidentStore } from "@/features/recovery/state/recoveryInci import { createEmptyThreadRecord } from "@/stores/thread-record"; import { createMockMessage } from "@/__tests__/mocks/transport"; import { useThreadStartupStore } from "@/features/thread-startup"; +import { useProjectAutomaticSetupStore } from "@/features/projects/environment/ProjectAutomaticSetupControl"; +import { useThreadDraftStore } from "@/stores/threadDraftStore"; import { __resetThreadSwitchTelemetryForTests, getThreadSwitchTelemetryCounters, @@ -284,12 +305,21 @@ function defaultWorkspaceState(overrides: Partial<{ createWorkspace: vi.fn(), deleteWorkspace: vi.fn(), deleteThread: vi.fn(), + openThreadDraft: vi.fn(), setPendingNewThread: vi.fn(), updateThreadTitle: overrides.updateThreadTitle ?? vi.fn().mockResolvedValue(undefined), - failPreparingThreadOnConnectionLost: vi.fn(), + recoverPreparingThreads: vi.fn().mockResolvedValue(undefined), retryPreparingThread: vi.fn(), dismissPreparingThread: vi.fn(), loadWorktrees: vi.fn(), + newThreadMode: "direct" as const, + newThreadBranch: "main", + newThreadBranchSource: "branch" as const, + newThreadPullRequestNumber: undefined, + customBranchName: "", + autoPreviewBranch: "preview", + selectedWorktree: null, + branchManuallySelected: false, worktrees: [], worktreesLoadedForWorkspace: null, checksById: {}, @@ -384,6 +414,10 @@ describe("ChatView - Thread Title Double-Click Rename", () => { }); chatViewGetTransportMock.mockReset(); chatViewGetTransportMock.mockReturnValue(chatViewTransportMock); + chatViewGetDraftMock.mockReset(); + chatViewSetPendingPrefillMock.mockReset(); + chatViewSaveDraftMock.mockReset(); + chatViewRemoveDraftMock.mockReset(); chatViewThreadStoreSetStateMock.mockClear(); chatViewApplyCanonicalRecoveriesMock.mockClear(); chatViewResidencyMock.invalidateConversation.mockClear(); @@ -1034,6 +1068,169 @@ describe("ChatView - Thread Title Double-Click Rename", () => { expect(screen.queryByTestId("chat-message-stage")).not.toBeInTheDocument(); }); + it("offers removal after a bound managed startup stops before setup begins", async () => { + const thread = makeThread({ + id: "thread-incomplete", + title: "Incomplete thread", + mode: "worktree", + worktree_managed: true, + worktree_path: "/repo/incomplete", + }); + useThreadStartupStore.getState().apply({ + startupId: "00000000-0000-4000-8000-000000000027", + workspaceId: thread.workspace_id, + kind: "managed-worktree", + state: "interrupted", + phase: "worktree", + steps: [ + { phase: "thread", state: "completed" }, + { phase: "worktree", state: "interrupted" }, + { phase: "setup", state: "pending" }, + { phase: "agent", state: "pending" }, + ], + transcript: [], + cancellation: "none", + revision: 1, + threadId: thread.id, + createdAt: "2026-09-02T12:00:00.000Z", + updatedAt: "2026-09-02T12:00:00.000Z", + }); + let resolveSetup!: (snapshot: WorkspaceEnvironmentAutomaticSetupSnapshot) => void; + chatViewTransportMock.getAutomaticSetup.mockReturnValueOnce(new Promise((resolve) => { + resolveSetup = resolve; + })); + const workspace = defaultWorkspaceState({ + activeThreadId: thread.id, + threads: [thread], + pendingStartupByThreadId: { + [thread.id]: { startupId: "00000000-0000-4000-8000-000000000027", context: "new-worktree", queuedMessage: "Build the feature" }, + }, + }); + setupWorkspaceMock(workspace); + chatViewThreadMockRef.current = defaultThreadState({ currentThreadId: thread.id }); + + render(); + expect(screen.queryByRole("button", { name: "Remove incomplete thread" })).toBeNull(); + await act(async () => { + resolveSetup({ gate: "not-required", attempt: null, queuedTurns: [] }); + }); + await userEvent.setup().click(screen.getByRole("button", { name: "Remove incomplete thread" })); + + expect(workspace.deleteThread).toHaveBeenCalledWith(thread.id, true); + expect(chatViewSetPendingPrefillMock).toHaveBeenCalledWith("Build the feature"); + }); + + it("moves the full unsent draft before removing an incomplete thread", async () => { + const thread = makeThread({ + id: "thread-incomplete-rich-draft", + mode: "worktree", + worktree_managed: true, + worktree_path: "/repo/incomplete-rich", + branch: "generated-branch", + base_branch: "feature/source", + }); + useThreadStartupStore.getState().apply({ + startupId: "00000000-0000-4000-8000-000000000029", + workspaceId: thread.workspace_id, + kind: "managed-worktree", + state: "interrupted", + phase: "worktree", + steps: [ + { phase: "thread", state: "completed" }, + { phase: "worktree", state: "interrupted" }, + { phase: "setup", state: "pending" }, + { phase: "agent", state: "pending" }, + ], + transcript: [], + cancellation: "none", + revision: 1, + threadId: thread.id, + createdAt: "2026-09-02T12:00:00.000Z", + updatedAt: "2026-09-02T12:00:00.000Z", + }); + chatViewTransportMock.getAutomaticSetup.mockResolvedValueOnce({ gate: "not-required", attempt: null, queuedTurns: [] }); + const workspace = defaultWorkspaceState({ activeThreadId: thread.id, threads: [thread] }); + workspace.deleteThread.mockRejectedValueOnce(new Error("Delete failed")); + setupWorkspaceMock(workspace); + chatViewThreadMockRef.current = defaultThreadState({ currentThreadId: thread.id }); + const draft = { + input: "", + attachments: [{ id: "attachment", name: "image.png", mimeType: "image/png", sizeBytes: 12, filePath: "C:/tmp/image.png", previewUrl: "blob:incomplete" }], + mentions: [{ kind: "file", id: "mention", label: "file", range: { start: 0, end: 4 }, path: "/repo/file" }], + selectedTextComments: [{ + id: "11111111-1111-4111-8111-111111111111", + displayNumber: 1, + source: { threadId: "parent", messageId: "message", sourceRole: "assistant", start: 0, end: 4, quote: "text" }, + note: "Keep this note", + mentions: [], + }], + modelId: "gpt-5.5", + provider: "codex", + reasoning: "high", + }; + chatViewGetDraftMock.mockReturnValue(draft); + useThreadDraftStore.setState({ drafts: {} }); + const revokePreview = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {}); + + render(); + const user = userEvent.setup(); + const remove = await screen.findByRole("button", { name: "Remove incomplete thread" }); + await user.click(remove); + expect(await screen.findByRole("alert")).toHaveTextContent("Delete failed"); + expect(useThreadDraftStore.getState().drafts).toEqual({}); + expect(chatViewSaveDraftMock).toHaveBeenCalledWith(thread.id, draft); + expect(revokePreview).not.toHaveBeenCalled(); + + await user.click(remove); + + const saved = Object.values(useThreadDraftStore.getState().drafts)[0]; + expect(saved?.draft).toEqual(draft); + expect(saved?.target).toEqual(expect.objectContaining({ mode: "worktree", branch: "feature/source", selectedWorktree: null })); + expect(workspace.openThreadDraft).toHaveBeenCalledWith(thread.workspace_id, saved?.id); + expect(workspace.deleteThread).toHaveBeenCalledWith(thread.id, true); + expect(chatViewRemoveDraftMock).toHaveBeenCalledWith(thread.id); + expect(chatViewSetPendingPrefillMock).not.toHaveBeenCalled(); + expect(revokePreview).not.toHaveBeenCalled(); + revokePreview.mockRestore(); + }); + + it("does not offer incomplete-thread removal when agent startup fails after checkout", async () => { + const thread = makeThread({ + id: "thread-agent-failed", + mode: "worktree", + worktree_managed: true, + worktree_path: "/repo/valid-checkout", + }); + useThreadStartupStore.getState().apply({ + startupId: "00000000-0000-4000-8000-000000000028", + workspaceId: thread.workspace_id, + kind: "managed-worktree", + state: "failed", + phase: "agent", + steps: [ + { phase: "thread", state: "completed" }, + { phase: "worktree", state: "completed" }, + { phase: "setup", state: "completed" }, + { phase: "agent", state: "failed" }, + ], + transcript: [], + cancellation: "none", + revision: 1, + threadId: thread.id, + createdAt: "2026-09-02T12:00:00.000Z", + updatedAt: "2026-09-02T12:00:00.000Z", + }); + chatViewTransportMock.getAutomaticSetup.mockResolvedValueOnce({ gate: "not-required", attempt: null, queuedTurns: [] }); + setupWorkspaceMock(defaultWorkspaceState({ activeThreadId: thread.id, threads: [thread] })); + chatViewThreadMockRef.current = defaultThreadState({ currentThreadId: thread.id }); + + render(); + await waitFor(() => expect(useProjectAutomaticSetupStore.getState().snapshotsByThread[thread.id]) + .toEqual({ gate: "not-required", attempt: null, queuedTurns: [] })); + expect(screen.getByTestId("startup-progress")).toHaveTextContent("Startup failed"); + expect(screen.queryByRole("button", { name: "Remove incomplete thread" })).toBeNull(); + }); + it("keeps a recovered startup visible while the durable thread hydrates", () => { const startupThread = makeThread({ id: "thread-2", title: "Thread 2" }); useThreadStartupStore.getState().apply({ diff --git a/apps/web/src/features/conversation/messages/chat-view/ChatViewSurface.tsx b/apps/web/src/features/conversation/messages/chat-view/ChatViewSurface.tsx index 2aa48a4ea..65792b7ff 100644 --- a/apps/web/src/features/conversation/messages/chat-view/ChatViewSurface.tsx +++ b/apps/web/src/features/conversation/messages/chat-view/ChatViewSurface.tsx @@ -1,4 +1,4 @@ -import { type ComponentProps, type ReactNode } from "react"; +import { type ComponentProps, type ReactNode, useState } from "react"; import { Bug, GitFork, Hammer, SearchCode, ScanSearch } from "lucide-react"; import type { RecoveryIncident, SelectedTextComment } from "@mcode/contracts"; import { Badge } from "@/components/ui/badge"; @@ -20,6 +20,7 @@ import { SidebarRevealButton } from "@/components/sidebar/SidebarRevealButton"; import { useWorkspaceStore } from "@/features/projects/state/workspaceStore"; import type { SelectedTextCommentEditorDraft } from "@/stores/composerDraftStore"; import { useComposerDraftStore } from "@/stores/composerDraftStore"; +import { useThreadDraftStore, type ThreadDraftPayload } from "@/stores/threadDraftStore"; import { ProjectAutomaticSetupCard, useProjectAutomaticSetup } from "@/features/projects/environment"; import { ProjectCommandApprovalDialog } from "@/features/projects/environment/ProjectCommandApprovalDialog"; import { StartupProgressCard, useThreadStartup, type StartupDisplayContext } from "@/features/thread-startup"; @@ -298,7 +299,7 @@ function ThreadPreparingShell({

{pendingStartup?.queuedMessage || thread.title}

- } /> + } /> @@ -426,7 +427,87 @@ function SetupRecoveryActions({ automaticSetup }: { readonly automaticSetup: Ret ); } -function StartupAutomaticSetupActions({ automaticSetup }: { readonly automaticSetup: ReturnType }) { +function preserveIncompleteDraft(thread: WorkspaceThread, draft: ThreadDraftPayload["draft"], autoPreviewBranch: string): string { + const id = useThreadDraftStore.getState().saveDraft({ + workspaceId: thread.workspace_id, + draft, + selection: { + interactionMode: thread.interaction_mode ?? "build", + permissionMode: thread.permission_mode ?? "full", + orchestrationMode: thread.orchestration_mode ?? "standard", + approvalReviewMode: "manual", + copilotAgent: thread.copilot_agent, + thinking: thread.thinking, + }, + target: { + mode: "worktree", + branch: thread.base_branch || thread.branch || "main", + branchSource: "branch", + customBranchName: "", + autoPreviewBranch, + selectedWorktree: null, + branchManuallySelected: true, + }, + }); + if (!id) throw new Error("Could not keep this draft"); + useComposerDraftStore.getState().removeDraftAfterAttachmentTransfer(thread.id); + return id; +} + +function RemoveIncompleteThreadAction({ thread, pendingStartup }: { + readonly thread: WorkspaceThread; + readonly pendingStartup: PendingStartup | undefined; +}) { + const [removing, setRemoving] = useState(false); + const [error, setError] = useState(null); + const remove = async () => { + const draftStore = useComposerDraftStore.getState(); + const workspace = useWorkspaceStore.getState(); + const draft = draftStore.getDraft(thread.id); + const prompt = pendingStartup?.queuedMessage || thread.title; + const threadDraftStore = useThreadDraftStore.getState(); + let savedDraftId: string | null = null; + setRemoving(true); + setError(null); + try { + if (draft) savedDraftId = preserveIncompleteDraft(thread, draft, workspace.autoPreviewBranch); + await workspace.deleteThread(thread.id, true); + } catch (failure) { + if (savedDraftId && draft) { + draftStore.saveDraft(thread.id, draft); + threadDraftStore.removeDraftAfterAttachmentTransfer(savedDraftId); + } + setError(String(failure)); + return; + } finally { + setRemoving(false); + } + if (savedDraftId) workspace.openThreadDraft(thread.workspace_id, savedDraftId); + else if (prompt) draftStore.setPendingPrefill(prompt); + }; + return ( + <> + + {error && {error}} + + ); +} + +function StartupAutomaticSetupActions({ automaticSetup, thread, startup, pendingStartup }: { + readonly automaticSetup: ReturnType; + readonly thread: WorkspaceThread; + readonly startup: ReturnType; + readonly pendingStartup: PendingStartup | undefined; +}) { + if (startup?.threadId === thread.id + && thread.mode === "worktree" && thread.worktree_managed + && (startup.state === "failed" || startup.state === "interrupted") + && startup.phase !== "agent" + && automaticSetup.snapshotLoaded && !automaticSetup.snapshot.attempt) { + return ; + } return ; } diff --git a/apps/web/src/features/projects/environment/ProjectAutomaticSetupControl.tsx b/apps/web/src/features/projects/environment/ProjectAutomaticSetupControl.tsx index 282aaa43d..a62b9d4ff 100644 --- a/apps/web/src/features/projects/environment/ProjectAutomaticSetupControl.tsx +++ b/apps/web/src/features/projects/environment/ProjectAutomaticSetupControl.tsx @@ -131,6 +131,7 @@ export function useProjectAutomaticSetup(threadId: string, enabled = true) { return { snapshot, + snapshotLoaded: !enabled || storedSnapshot !== undefined, busy, error, continueWithoutSetup, diff --git a/apps/web/src/features/projects/state/__tests__/workspace-behavior.test.ts b/apps/web/src/features/projects/state/__tests__/workspace-behavior.test.ts index 4add0e725..509d97260 100644 --- a/apps/web/src/features/projects/state/__tests__/workspace-behavior.test.ts +++ b/apps/web/src/features/projects/state/__tests__/workspace-behavior.test.ts @@ -19,7 +19,7 @@ import { createMockWorkspace, createMockThread, } from "../../../../__tests__/mocks/transport"; -import type { CreateAndSendResult, SelectedTextComment, TurnRuntimeSnapshot } from "@mcode/contracts"; +import type { CreateAndSendResult, SelectedTextComment, ThreadStartup, TurnRuntimeSnapshot } from "@mcode/contracts"; import { act, renderHook } from "@testing-library/react"; import { useQueuedMessageDispatch } from "@/features/conversation/composer/queue/useQueuedMessageDispatch"; import { useThreadStartupStore } from "@/features/thread-startup"; @@ -75,6 +75,25 @@ function createMockCreateAndSendResult( }; } +function cancelledBeforeThread(startupId: string, workspaceId: string) { + return { + startupId, + workspaceId, + kind: "direct", + state: "cancelled", + phase: "thread", + steps: [ + { phase: "thread", state: "cancelled" }, + { phase: "agent", state: "pending" }, + ], + transcript: [], + cancellation: "requested", + revision: 1, + createdAt: "2026-09-02T12:00:00.000Z", + updatedAt: "2026-09-02T12:00:00.000Z", + }; +} + function expectPreparedCodexThread( thread: ReturnType["threads"][number] | undefined, ) { @@ -1325,6 +1344,319 @@ describe("Workspace Behavior", () => { }); describe("optimistic thread scaffolding", () => { + it("rekeys a confirmed creation after its response is lost and refreshes the worktree list", async () => { + const ws = createMockWorkspace({ id: "ws-lost-response" }); + const durable = createMockThread({ + id: "durable-after-reconnect", + workspace_id: ws.id, + mode: "worktree", + worktree_path: "/repo/new-worktree", + }); + let rejectRpc!: (reason: Error) => void; + let resolveWorktrees!: (value: Awaited>) => void; + (mockTransport.createAndSendMessage as ReturnType).mockReturnValueOnce( + new Promise((_resolve, reject) => { rejectRpc = reject; }), + ); + (mockTransport.listWorktrees as ReturnType).mockReturnValueOnce( + new Promise((resolve) => { resolveWorktrees = resolve; }), + ); + useWorkspaceStore.setState({ + workspaces: [ws], + activeWorkspaceId: ws.id, + newThreadMode: "worktree", + newThreadBranch: "main", + worktreesLoadedForWorkspace: ws.id, + worktrees: [], + }); + + const sending = useWorkspaceStore.getState().createAndSendMessage("Hello", "gpt-5.5"); + const placeholderId = useWorkspaceStore.getState().activeThreadId!; + (mockTransport.getThreadStartup as ReturnType).mockResolvedValueOnce({ + startupId: placeholderId, + workspaceId: ws.id, + kind: "managed-worktree", + state: "running", + phase: "agent", + steps: [ + { phase: "thread", state: "completed" }, + { phase: "worktree", state: "completed" }, + { phase: "setup", state: "completed" }, + { phase: "agent", state: "running" }, + ], + transcript: [], + cancellation: "none", + revision: 1, + threadId: durable.id, + createdAt: "2026-09-02T12:00:00.000Z", + updatedAt: "2026-09-02T12:00:00.000Z", + }); + (mockTransport.listThreads as ReturnType).mockResolvedValueOnce([durable]); + + await useWorkspaceStore.getState().recoverPreparingThreads(); + expect(useWorkspaceStore.getState().activeThreadId).toBe(durable.id); + expect(useWorkspaceStore.getState().threads.map((thread) => thread.id)).toEqual([durable.id]); + expect(useWorkspaceStore.getState().pendingStartupByThreadId[durable.id]?.startupId).toBe(placeholderId); + expect(useWorkspaceStore.getState().worktreesLoadedForWorkspace).toBeNull(); + expect(mockTransport.listWorktrees).toHaveBeenCalledWith(ws.id); + + rejectRpc(new Error("Connection closed before reply")); + await expect(sending).rejects.toThrow("Connection closed before reply"); + expect(useWorkspaceStore.getState().threads[0]?.clientError).toBeUndefined(); + expect(mockTransport.createAndSendMessage).toHaveBeenCalledTimes(1); + + const worktrees = [{ name: "new-worktree", path: durable.worktree_path!, branch: "main", managed: true }]; + resolveWorktrees(worktrees); + await vi.waitFor(() => expect(useWorkspaceStore.getState().worktreesLoadedForWorkspace).toBe(ws.id)); + expect(useWorkspaceStore.getState().worktrees).toEqual(worktrees); + }); + + it("uses a binding push when an in-flight lookup returns an older unbound startup", async () => { + const ws = createMockWorkspace({ id: "ws-late-binding-push" }); + const durable = createMockThread({ id: "durable-late-binding", workspace_id: ws.id }); + useWorkspaceStore.setState({ workspaces: [ws], activeWorkspaceId: ws.id }); + (mockTransport.createAndSendMessage as ReturnType) + .mockRejectedValueOnce(new Error("Response lost")); + await expect(useWorkspaceStore.getState().createAndSendMessage("Hello", "gpt-5.5")) + .rejects.toThrow("Response lost"); + const placeholderId = useWorkspaceStore.getState().activeThreadId!; + let resolveLookup!: (record: ThreadStartup | null) => void; + (mockTransport.getThreadStartup as ReturnType).mockReturnValueOnce( + new Promise((resolve) => { resolveLookup = resolve; }), + ); + (mockTransport.listThreads as ReturnType).mockResolvedValueOnce([durable]); + const recovery = useWorkspaceStore.getState().recoverPreparingThreads(); + const startup: ThreadStartup = { + startupId: placeholderId, + workspaceId: ws.id, + kind: "direct", + state: "running", + phase: "thread", + steps: [{ phase: "thread", state: "running" }, { phase: "agent", state: "pending" }], + transcript: [], + cancellation: "none", + revision: 1, + createdAt: "2026-09-02T12:00:00.000Z", + updatedAt: "2026-09-02T12:00:00.000Z", + }; + useThreadStartupStore.getState().apply({ ...startup, threadId: durable.id, revision: 2 }); + resolveLookup(startup); + await recovery; + + expect(useWorkspaceStore.getState().threads.map((thread) => thread.id)).toEqual([durable.id]); + expect(useWorkspaceStore.getState().activeThreadId).toBe(durable.id); + expect(mockTransport.listThreads).toHaveBeenCalledTimes(1); + expect(mockTransport.createAndSendMessage).toHaveBeenCalledTimes(1); + }); + + it("keeps the unsent prompt when a bound startup is interrupted before agent admission", async () => { + const ws = createMockWorkspace({ id: "ws-interrupted-before-agent" }); + const durable = createMockThread({ + id: "durable-interrupted", + workspace_id: ws.id, + mode: "worktree", + worktree_path: "/repo/interrupted", + }); + useWorkspaceStore.setState({ + workspaces: [ws], + activeWorkspaceId: ws.id, + newThreadMode: "worktree", + newThreadBranch: "main", + }); + (mockTransport.createAndSendMessage as ReturnType) + .mockRejectedValueOnce(new Error("Reply lost")); + await expect(useWorkspaceStore.getState().createAndSendMessage("Keep the whole prompt", "gpt-5.5")) + .rejects.toThrow("Reply lost"); + const placeholderId = useWorkspaceStore.getState().activeThreadId!; + const parkedDraft = { + ...pendingComposerDraft, + input: "Keep the whole prompt", + attachments: [{ + id: "pending-preview", + name: "preview.png", + mimeType: "image/png", + sizeBytes: 12, + filePath: "C:/tmp/preview.png", + previewUrl: "blob:pending-preview", + }], + }; + useComposerDraftStore.getState().saveDraft(placeholderId, parkedDraft); + const revokePreview = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {}); + (mockTransport.getThreadStartup as ReturnType).mockResolvedValueOnce({ + startupId: placeholderId, + workspaceId: ws.id, + kind: "managed-worktree", + state: "interrupted", + phase: "worktree", + steps: [ + { phase: "thread", state: "completed" }, + { phase: "worktree", state: "interrupted" }, + { phase: "setup", state: "pending" }, + { phase: "agent", state: "pending" }, + ], + transcript: [], + cancellation: "none", + revision: 1, + threadId: durable.id, + createdAt: "2026-09-02T12:00:00.000Z", + updatedAt: "2026-09-02T12:00:00.000Z", + }); + (mockTransport.listThreads as ReturnType).mockResolvedValueOnce([durable]); + + await useWorkspaceStore.getState().recoverPreparingThreads(); + + expect(useWorkspaceStore.getState().activeThreadId).toBe(durable.id); + expect(useComposerDraftStore.getState().getDraft(durable.id)).toEqual(parkedDraft); + expect(useComposerDraftStore.getState().getDraft(placeholderId)).toBeUndefined(); + expect(revokePreview).not.toHaveBeenCalled(); + expect(mockTransport.createAndSendMessage).toHaveBeenCalledTimes(1); + revokePreview.mockRestore(); + }); + + it("finishes reconciliation when a startup binds after the reconnect lookup", async () => { + const ws = createMockWorkspace({ id: "ws-late-binding" }); + const durable = createMockThread({ id: "durable-late-binding", workspace_id: ws.id }); + useWorkspaceStore.setState({ workspaces: [ws], activeWorkspaceId: ws.id }); + (mockTransport.createAndSendMessage as ReturnType) + .mockRejectedValueOnce(new Error("Response lost")); + await expect(useWorkspaceStore.getState().createAndSendMessage("Hello", "gpt-5.5")) + .rejects.toThrow("Response lost"); + const placeholderId = useWorkspaceStore.getState().activeThreadId!; + const unbound = { + startupId: placeholderId, + workspaceId: ws.id, + kind: "direct" as const, + state: "running" as const, + phase: "thread" as const, + steps: [ + { phase: "thread" as const, state: "running" as const }, + { phase: "agent" as const, state: "pending" as const }, + ], + transcript: [], + cancellation: "none" as const, + revision: 1, + createdAt: "2026-09-02T12:00:00.000Z", + updatedAt: "2026-09-02T12:00:00.000Z", + }; + (mockTransport.getThreadStartup as ReturnType).mockResolvedValueOnce(unbound); + await useWorkspaceStore.getState().recoverPreparingThreads(); + expect(useWorkspaceStore.getState().activeThreadId).toBe(placeholderId); + + const bound = { + ...unbound, + phase: "agent" as const, + steps: [ + { phase: "thread" as const, state: "completed" as const }, + { phase: "agent" as const, state: "running" as const }, + ], + threadId: durable.id, + revision: 2, + }; + (mockTransport.getThreadStartup as ReturnType).mockResolvedValueOnce(bound); + (mockTransport.listThreads as ReturnType).mockResolvedValueOnce([durable]); + useThreadStartupStore.getState().apply(bound); + + await vi.waitFor(() => expect(useWorkspaceStore.getState().activeThreadId).toBe(durable.id)); + expect(useWorkspaceStore.getState().threads.map((thread) => thread.id)).toEqual([durable.id]); + expect(mockTransport.createAndSendMessage).toHaveBeenCalledTimes(1); + }); + + it("retries an unconfirmed creation with its original startup id", async () => { + const ws = createMockWorkspace({ id: "ws-retry-same-startup" }); + useWorkspaceStore.setState({ workspaces: [ws], activeWorkspaceId: ws.id }); + (mockTransport.createAndSendMessage as ReturnType) + .mockRejectedValueOnce(new Error("Response lost")) + .mockResolvedValueOnce(createMockCreateAndSendResult({ id: "durable-retry", workspace_id: ws.id })); + + await expect(useWorkspaceStore.getState().createAndSendMessage("Hello", "gpt-5.5")) + .rejects.toThrow("Response lost"); + const placeholderId = useWorkspaceStore.getState().activeThreadId!; + const editedDraft = { ...pendingComposerDraft, input: "Edited while disconnected" }; + useComposerDraftStore.getState().saveDraft(placeholderId, editedDraft); + (mockTransport.getThreadStartup as ReturnType).mockResolvedValueOnce(null); + await useWorkspaceStore.getState().retryPreparingThread(placeholderId); + + const calls = (mockTransport.createAndSendMessage as ReturnType).mock.calls; + expect(calls).toHaveLength(2); + expect(calls[0]?.[0].startupId).toBe(placeholderId); + expect(calls[1]?.[0].startupId).toBe(placeholderId); + expect(calls[1]?.[0].content).toBe("Hello"); + expect(useComposerDraftStore.getState().getDraft("durable-retry")).toEqual(editedDraft); + }); + + it("uses a new startup id only after the server confirms the original ended without a thread", async () => { + const ws = createMockWorkspace({ id: "ws-terminal-retry" }); + useWorkspaceStore.setState({ workspaces: [ws], activeWorkspaceId: ws.id }); + (mockTransport.createAndSendMessage as ReturnType) + .mockRejectedValueOnce(new Error("Cancelled")) + .mockResolvedValueOnce(createMockCreateAndSendResult({ id: "durable-new-attempt", workspace_id: ws.id })); + + await expect(useWorkspaceStore.getState().createAndSendMessage("Hello", "gpt-5.5")) + .rejects.toThrow("Cancelled"); + const placeholderId = useWorkspaceStore.getState().activeThreadId!; + (mockTransport.getThreadStartup as ReturnType).mockResolvedValueOnce({ + startupId: placeholderId, + workspaceId: ws.id, + kind: "direct", + state: "cancelled", + phase: "thread", + steps: [ + { phase: "thread", state: "cancelled" }, + { phase: "agent", state: "pending" }, + ], + transcript: [], + cancellation: "requested", + revision: 1, + createdAt: "2026-09-02T12:00:00.000Z", + updatedAt: "2026-09-02T12:00:00.000Z", + }); + + const firstRetry = useWorkspaceStore.getState().retryPreparingThread(placeholderId); + const secondRetry = useWorkspaceStore.getState().retryPreparingThread(placeholderId); + expect(secondRetry).toBe(firstRetry); + await firstRetry; + const calls = (mockTransport.createAndSendMessage as ReturnType).mock.calls; + expect(calls).toHaveLength(2); + expect(calls[1]?.[0].startupId).not.toBe(calls[0]?.[0].startupId); + }); + + it("does not retry while the startup lookup is uncertain", async () => { + const ws = createMockWorkspace({ id: "ws-uncertain-retry" }); + useWorkspaceStore.setState({ workspaces: [ws], activeWorkspaceId: ws.id }); + (mockTransport.createAndSendMessage as ReturnType) + .mockRejectedValueOnce(new Error("Connection lost")); + await expect(useWorkspaceStore.getState().createAndSendMessage("Hello", "gpt-5.5")) + .rejects.toThrow("Connection lost"); + const placeholderId = useWorkspaceStore.getState().activeThreadId!; + (mockTransport.getThreadStartup as ReturnType) + .mockRejectedValueOnce(new Error("Still offline")); + + await expect(useWorkspaceStore.getState().retryPreparingThread(placeholderId)) + .rejects.toThrow("Still offline"); + expect(mockTransport.createAndSendMessage).toHaveBeenCalledTimes(1); + expect(useWorkspaceStore.getState().threads[0]?.id).toBe(placeholderId); + }); + + it("does not make a new startup for an interrupted unbound record", async () => { + const ws = createMockWorkspace({ id: "ws-interrupted-unbound" }); + useWorkspaceStore.setState({ workspaces: [ws], activeWorkspaceId: ws.id }); + (mockTransport.createAndSendMessage as ReturnType) + .mockRejectedValueOnce(new Error("Connection lost")); + await expect(useWorkspaceStore.getState().createAndSendMessage("Hello", "gpt-5.5")) + .rejects.toThrow("Connection lost"); + const placeholderId = useWorkspaceStore.getState().activeThreadId!; + (mockTransport.getThreadStartup as ReturnType).mockResolvedValueOnce({ + ...cancelledBeforeThread(placeholderId, ws.id), + state: "interrupted", + cancellation: "none", + steps: [{ phase: "thread", state: "interrupted" }, { phase: "agent", state: "pending" }], + }); + + await expect(useWorkspaceStore.getState().retryPreparingThread(placeholderId)) + .rejects.toThrow("Inspect this workspace before starting again"); + expect(mockTransport.createAndSendMessage).toHaveBeenCalledTimes(1); + expect(useWorkspaceStore.getState().threads[0]?.id).toBe(placeholderId); + }); + it.each(["direct", "worktree"] as const)( "retains the pending composer draft through a cancelled new %s creation until acknowledgement", async (mode) => { @@ -1379,6 +1711,8 @@ describe("Workspace Behavior", () => { selectedTextComments: [editedComment], }; useComposerDraftStore.getState().saveDraft(placeholderId, editedDraft); + (mockTransport.getThreadStartup as ReturnType) + .mockResolvedValueOnce(cancelledBeforeThread(placeholderId, ws.id)); await useWorkspaceStore.getState().retryPreparingThread(placeholderId); @@ -1430,6 +1764,8 @@ describe("Workspace Behavior", () => { }], }; useComposerDraftStore.getState().saveDraft(placeholderId, commentOnlyDraft); + (mockTransport.getThreadStartup as ReturnType) + .mockResolvedValueOnce(cancelledBeforeThread(placeholderId, ws.id)); await useWorkspaceStore.getState().retryPreparingThread(placeholderId); @@ -1517,6 +1853,8 @@ describe("Workspace Behavior", () => { )).rejects.toThrow("Creation failed"); const placeholderId = useWorkspaceStore.getState().activeThreadId!; + (mockTransport.getThreadStartup as ReturnType) + .mockResolvedValueOnce(cancelledBeforeThread(placeholderId, ws.id)); await useWorkspaceStore.getState().retryPreparingThread(placeholderId); expect(revokeObjectUrl).toHaveBeenCalledOnce(); diff --git a/apps/web/src/features/projects/state/workspaceStore.ts b/apps/web/src/features/projects/state/workspaceStore.ts index 1c6f82eef..403e192c8 100644 --- a/apps/web/src/features/projects/state/workspaceStore.ts +++ b/apps/web/src/features/projects/state/workspaceStore.ts @@ -13,8 +13,10 @@ import { type MessageMention, type PreviewAnnotationBundle, type SelectedTextComment, + type ThreadStartup, } from "@mcode/contracts"; import { getTransport } from "@/transport"; +import { useThreadStartupStore } from "@/features/thread-startup/state/thread-startup-store"; import { scheduleDrainAfterEdit, useThreadStore } from "@/stores/threadStore"; import { deleteThreadRecord, patchThreadRecord } from "@/stores/thread-record"; import { getConversationResidency } from "@/features/conversation/residency/conversation-residency"; @@ -43,6 +45,11 @@ function generateBranchId(): string { return `mcode-${Math.random().toString(36).slice(2, 10)}`; } +function unboundStartupResolution(state: ThreadStartup["state"]): "unbound" | "terminal" | "interrupted" { + if (state === "interrupted") return "interrupted"; + return state === "failed" || state === "cancelled" ? "terminal" : "unbound"; +} + /** Minimum interval between syncThreadPrs calls per workspace. */ const SYNC_THROTTLE_MS = 30_000; /** Tracks the last syncThreadPrs request time per workspace. */ @@ -550,6 +557,12 @@ function pendingCreationWithCurrentDraft( }; } +function draftChangedSinceSubmission(pending: PendingThreadCreation, draft: ComposerDraft | undefined): draft is ComposerDraft { + if (!draft) return false; + if (pending.composerDraft) return JSON.stringify(draft) !== JSON.stringify(pending.composerDraft); + return draft.input.trim() !== (pending.displayContent ?? pending.content).trim(); +} + /** * Optional RPC dispatch callback used by workspace actions. Tests inject a * stub here; production code uses {@link getTransport} directly. The shape @@ -656,6 +669,8 @@ interface WorkspaceState { * Re-run server creation for a placeholder thread after {@link WorkspaceThread.clientError}. */ retryPreparingThread: (placeholderId: string) => Promise; + /** Resolve in-memory placeholders against authoritative startup records after reconnect. */ + recoverPreparingThreads: () => Promise; /** Remove a failed or abandoned placeholder row and drop selection when it was active. */ dismissPreparingThread: (placeholderId: string) => void; /** @@ -663,8 +678,6 @@ interface WorkspaceState { * durable thread id or by startup id so call order does not matter. */ resolvePendingStartup: (ref: { threadId?: string; startupId?: string }) => void; - /** Surface connection loss while {@link WorkspaceThread.clientPreparing} is true. */ - failPreparingThreadOnConnectionLost: (placeholderId: string) => void; deleteThread: (threadId: string, cleanupWorktree: boolean) => Promise; /** Complete an idle thread and release renderer-owned runtime resources. */ completeThread: (threadId: string) => Promise; @@ -793,6 +806,8 @@ export const useWorkspaceStore = create((set, get) => { bumpThreadListMutationEpoch(workspaceId); pendingThreadCreationByPlaceholderId.delete(placeholderId); const draftStore = useComposerDraftStore.getState(); + const draft = draftStore.getDraft(placeholderId); + const editedDraft = !releasePlaceholderDraft && pending && draftChangedSinceSubmission(pending, draft) ? draft : undefined; if (releasePlaceholderDraft) draftStore.clearDraft(placeholderId); else draftStore.removeDraftAfterAttachmentTransfer(placeholderId); // Apply the snapshot before the transfer so a pre-agent "idle" snapshot @@ -812,9 +827,11 @@ export const useWorkspaceStore = create((set, get) => { if (get().activeThreadId === thread.id) { void getConversationResidency().activate(thread.id, get().threads); } + if (editedDraft) draftStore.saveDraft(thread.id, editedDraft); }; - const applyOptimisticFailure = (placeholderId: string, err: unknown) => { + const applyOptimisticFailure = (placeholderId: string, err: unknown, attempt: PendingThreadCreation) => { + if (pendingThreadCreationByPlaceholderId.get(placeholderId) !== attempt) return; const msg = String(err); useThreadStore.setState((state) => { const nextRunning = new Set(state.runningThreadIds); @@ -914,11 +931,96 @@ export const useWorkspaceStore = create((set, get) => { ); return result; } catch (error) { - applyOptimisticFailure(placeholderId, error); + applyOptimisticFailure(placeholderId, error, attempt); throw error; } }; + const applyRecoveredCreation = (placeholderId: string, thread: Thread) => { + const pending = pendingThreadCreationByPlaceholderId.get(placeholderId); + if (!pending || !get().workspaces.some((workspace) => workspace.id === pending.workspaceId)) return; + const draftStore = useComposerDraftStore.getState(); + const draft = draftStore.getDraft(placeholderId); + const editedDraft = draftChangedSinceSubmission(pending, draft) ? draft : undefined; + bumpThreadListMutationEpoch(pending.workspaceId); + pendingThreadCreationByPlaceholderId.delete(placeholderId); + draftStore.removeDraftAfterAttachmentTransfer(placeholderId); + useThreadStore.getState().transferThreadRuntime(placeholderId, thread.id); + useDiffStore.getState().hideRightPanel(pending.workspaceId, thread.id); + set((state) => optimisticCreationSuccessState( + state, + placeholderId, + thread, + undefined, + pending, + pending.transportMode === "worktree", + )); + if (get().activeThreadId === thread.id) { + void getConversationResidency().activate(thread.id, get().threads); + } + if (editedDraft) draftStore.saveDraft(thread.id, editedDraft); + if (pending.transportMode === "worktree") { + void get().loadWorktrees(pending.workspaceId); + } + }; + + // A reconnect and a user Retry can race. Both must observe the same lookup. + const creationLookups = new Map>(); + const creationRetries = new Map>(); + const reconcilePendingCreation = (placeholderId: string): Promise<"missing" | "unbound" | "terminal" | "interrupted" | Thread> => { + const existing = creationLookups.get(placeholderId); + if (existing) return existing; + const lookup = (async () => { + const pending = pendingThreadCreationByPlaceholderId.get(placeholderId); + if (!pending?.startupId) return "unbound"; + const fetched = await getTransport().getThreadStartup(pending.startupId); + if (!pendingThreadCreationByPlaceholderId.has(placeholderId)) return "unbound"; + if (fetched) useThreadStartupStore.getState().apply(fetched); + // A binding push may have arrived while the lookup was in flight. The + // startup store rejects stale revisions, so read its latest record. + const startup = useThreadStartupStore.getState().recordsByStartupId[pending.startupId]; + if (!startup) return "missing"; + if (!startup.threadId) return unboundStartupResolution(startup.state); + const threads = await getTransport().listThreads(pending.workspaceId); + const thread = threads.find((candidate) => candidate.id === startup.threadId); + if (!thread) return "unbound"; + applyRecoveredCreation(placeholderId, thread); + return thread; + })(); + creationLookups.set(placeholderId, lookup); + void lookup.finally(() => creationLookups.delete(placeholderId)).catch(() => {}); + return lookup; + }; + + const prepareCreationRetry = async (placeholderId: string): Promise< + | { kind: "recovered"; thread: Thread } + | { kind: "retry"; pending: PendingThreadCreation & { startupId: string }; newStartup: boolean } + > => { + const pending = pendingThreadCreationByPlaceholderId.get(placeholderId); + if (!pending?.startupId) throw new Error("No pending startup identity for this thread"); + const row = get().threads.find((thread) => thread.id === placeholderId); + if (!row?.clientError) throw new Error("Thread is not in a retryable state"); + const recovered = await reconcilePendingCreation(placeholderId); + if (typeof recovered !== "string") return { kind: "recovered", thread: recovered }; + if (recovered === "unbound") { + throw new Error("This thread is still being prepared. Try again after it finishes."); + } + if (recovered === "interrupted") { + throw new Error("Thread creation was interrupted before its thread could be confirmed. Inspect this workspace before starting again."); + } + // Only an authoritative terminal record proves a new lifecycle is needed. + const newStartup = recovered === "terminal"; + const attempt = { ...pending, startupId: newStartup ? crypto.randomUUID() : pending.startupId }; + const withDraft = newStartup + ? pendingCreationWithCurrentDraft(attempt, useComposerDraftStore.getState().getDraft(placeholderId)) + : attempt; + return { + kind: "retry", + pending: { ...withDraft, startupId: attempt.startupId }, + newStartup, + }; + }; + const clearThreadResources = (threadId: string) => { get().resolvePendingStartup({ threadId }); useTerminalStore.getState().clearThread(threadId); @@ -1448,58 +1550,74 @@ export const useWorkspaceStore = create((set, get) => { return createPendingThread(pending, threadCreationContext("branch", params.mode)); }, - retryPreparingThread: async (placeholderId) => { - const pending = pendingThreadCreationByPlaceholderId.get(placeholderId); - if (!pending) { - throw new Error("No pending creation for this thread"); - } - const row = get().threads.find((t) => t.id === placeholderId); - if (!row?.clientError) { - throw new Error("Thread is not in a retryable state"); - } - const attempt = { ...pending, startupId: crypto.randomUUID() }; - const currentPending = pendingCreationWithCurrentDraft( - attempt, - useComposerDraftStore.getState().getDraft(placeholderId), - ); - pendingThreadCreationByPlaceholderId.set(placeholderId, currentPending); - set((state) => ({ - error: null, - threads: state.threads.map((t) => - t.id === placeholderId - ? { ...t, clientPreparing: true, clientError: null } - : t, - ), - pendingStartupByThreadId: { - ...state.pendingStartupByThreadId, - [placeholderId]: { - startupId: attempt.startupId, - context: currentPending.clientPreparingContext ?? state.pendingStartupByThreadId[placeholderId]?.context ?? "new-direct", - queuedMessage: currentPending.displayContent ?? currentPending.content, + retryPreparingThread: (placeholderId) => { + const existing = creationRetries.get(placeholderId); + if (existing) return existing; + const operation = (async () => { + const retry = await prepareCreationRetry(placeholderId); + if (retry.kind === "recovered") return retry.thread; + const currentPending = retry.pending; + pendingThreadCreationByPlaceholderId.set(placeholderId, currentPending); + set((state) => ({ + error: null, + threads: state.threads.map((t) => + t.id === placeholderId + ? { ...t, clientPreparing: true, clientError: null } + : t, + ), + pendingStartupByThreadId: { + ...state.pendingStartupByThreadId, + [placeholderId]: { + startupId: currentPending.startupId, + context: currentPending.clientPreparingContext ?? state.pendingStartupByThreadId[placeholderId]?.context ?? "new-direct", + queuedMessage: currentPending.displayContent ?? currentPending.content, + }, }, - }, - })); - useThreadStore.setState((state) => ({ - runningThreadIds: new Set([...state.runningThreadIds, placeholderId]), - records: patchThreadRecord(state.records, placeholderId, { - agentStartTime: Date.now(), - runtimePhase: "running", - }), + })); + useThreadStore.setState((state) => ({ + runningThreadIds: new Set([...state.runningThreadIds, placeholderId]), + records: patchThreadRecord(state.records, placeholderId, { + agentStartTime: Date.now(), + runtimePhase: "running", + }), + })); + try { + const result = await runCreateAndSend(currentPending); + applyOptimisticSuccess( + placeholderId, + currentPending.workspaceId, + result, + currentPending.transportMode === "worktree", + retry.newStartup, + ); + return result; + } catch (error) { + applyOptimisticFailure(placeholderId, error, currentPending); + throw error; + } + })(); + creationRetries.set(placeholderId, operation); + void operation.finally(() => creationRetries.delete(placeholderId)).catch(() => {}); + return operation; + }, + + recoverPreparingThreads: async () => { + await Promise.all([...pendingThreadCreationByPlaceholderId].map(async ([placeholderId, pending]) => { + try { + const recovered = await reconcilePendingCreation(placeholderId); + if ((recovered === "missing" || recovered === "terminal" || recovered === "interrupted") + && get().threads.some((thread) => thread.id === placeholderId && thread.clientPreparing)) { + const message = recovered === "interrupted" + ? "Thread creation was interrupted before its thread could be confirmed. Inspect this workspace before starting again." + : recovered === "terminal" + ? "Thread creation ended before a thread was made. Retry to start again." + : "Thread creation was not confirmed. Retry to continue."; + applyOptimisticFailure(placeholderId, new Error(message), pending); + } + } catch { + // A failed lookup proves nothing about whether the server created the thread. + } })); - try { - const result = await runCreateAndSend(currentPending); - applyOptimisticSuccess( - placeholderId, - currentPending.workspaceId, - result, - currentPending.transportMode === "worktree", - true, - ); - return result; - } catch (e) { - applyOptimisticFailure(placeholderId, e); - throw e; - } }, resolvePendingStartup: ({ threadId, startupId }) => { @@ -1528,11 +1646,6 @@ export const useWorkspaceStore = create((set, get) => { if (didClearActiveThread) reconcileSelectedConversation(); }, - failPreparingThreadOnConnectionLost: (placeholderId) => { - const row = get().threads.find((t) => t.id === placeholderId); - if (!row?.clientPreparing) return; - applyOptimisticFailure(placeholderId, new Error("Connection lost while creating this thread. Try again.")); - }, deleteThread: async (threadId, cleanupWorktree) => { set({ error: null }); diff --git a/apps/web/src/features/thread-startup/state/thread-startup-store.ts b/apps/web/src/features/thread-startup/state/thread-startup-store.ts index c3f9f929e..0da9d24e5 100644 --- a/apps/web/src/features/thread-startup/state/thread-startup-store.ts +++ b/apps/web/src/features/thread-startup/state/thread-startup-store.ts @@ -29,13 +29,13 @@ function startupForThread( return threadId ? recordsByStartupId[startupIdByThreadId[threadId] ?? ""] : undefined; } -/** Hand a vetoed queued message back to the composer on the durable thread. */ -function restoreCancelledDraft(startup: ThreadStartup, pendingKey: string): void { +/** Keep the unsent first message available when startup stops before agent admission. */ +function restoreUnsentDraft(startup: ThreadStartup, pendingKey: string): void { const draftStore = useComposerDraftStore.getState(); const parked = draftStore.getDraft(pendingKey); - const target = startup.threadId; - if (target && parked) draftStore.saveDraft(target, parked); - else if (target) { + const target = startup.threadId ?? pendingKey; + if (parked && target !== pendingKey) draftStore.saveDraft(target, parked); + else if (!parked) { const queued = useWorkspaceStore.getState().pendingStartupByThreadId[pendingKey]?.queuedMessage; if (queued) { const selection = createDefaultComposerAgentSelection(); @@ -48,7 +48,7 @@ function restoreCancelledDraft(startup: ThreadStartup, pendingKey: string): void }); } } - if (parked) draftStore.clearDraft(pendingKey); + if (parked && target !== pendingKey) draftStore.removeDraftAfterAttachmentTransfer(pendingKey); } /** @@ -81,6 +81,20 @@ const TERMINAL_STARTUP_STATES: ReadonlySet = new Set([ "interrupted", ]); +function reconcileBoundPlaceholder(startup: ThreadStartup, pendingKey: string | undefined): void { + if (!startup.threadId || !pendingKey || pendingKey === startup.threadId) return; + const ws = useWorkspaceStore.getState(); + if (ws.threads.some((thread) => thread.id === pendingKey && (thread.clientPreparing || thread.clientError))) { + // A later binding push can now replace the unreconciled placeholder. + void ws.recoverPreparingThreads(); + } +} + +function stoppedBeforeAgentAdmission(startup: ThreadStartup): boolean { + return startup.state === "cancelled" + || startup.phase !== "agent" && (startup.state === "failed" || startup.state === "interrupted"); +} + /** Holds authoritative startup records and their durable thread bindings. */ export const useThreadStartupStore = create((set, get) => ({ recordsByStartupId: {}, @@ -101,18 +115,21 @@ export const useThreadStartupStore = create((set, get) => ({ ? { ...state.startupIdByThreadId, [startup.threadId]: startup.startupId } : state.startupIdByThreadId, })); + const ws = useWorkspaceStore.getState(); + const pendingKey = Object.keys(ws.pendingStartupByThreadId).find( + (id) => ws.pendingStartupByThreadId[id]?.startupId === startup.startupId, + ); + reconcileBoundPlaceholder(startup, pendingKey); // Blocked is a pause, not a resolution: approval or decline resumes the // same startup, so the pending entry must survive it. if (!TERMINAL_STARTUP_STATES.has(startup.state)) return; - const ws = useWorkspaceStore.getState(); // The pending entry may still sit under the placeholder key when a terminal // push beats the creation response; match by startup id either way. - const pendingKey = Object.keys(ws.pendingStartupByThreadId).find( - (id) => ws.pendingStartupByThreadId[id]?.startupId === startup.startupId, - ); - // On cancel the queued turn is vetoed before it persists, so the pending - // entry is the last copy of the user's message. - if (startup.state === "cancelled" && pendingKey) restoreCancelledDraft(startup, pendingKey); + // Before agent admission, the pending entry may be the last copy of the + // first message, including when a restart interrupts checkout creation. + if (pendingKey && stoppedBeforeAgentAdmission(startup)) { + restoreUnsentDraft(startup, pendingKey); + } ws.resolvePendingStartup({ threadId: startup.threadId, startupId: startup.startupId }); // Only a pending entry owned by this startup proves the optimistic mark is // ours; foreign or historical terminal records must not touch a live turn. diff --git a/apps/web/src/transport/__tests__/agent-commands.test.ts b/apps/web/src/transport/__tests__/agent-commands.test.ts index ae7d97a4e..71b90c372 100644 --- a/apps/web/src/transport/__tests__/agent-commands.test.ts +++ b/apps/web/src/transport/__tests__/agent-commands.test.ts @@ -254,4 +254,112 @@ describe("agent command transport", () => { await pending; transport.close(); }); + + it("keeps a startup request pending and replays its exact params after socket loss", async () => { + useSettingsStore.setState({ loaded: true }); + let now = Date.now(); + vi.spyOn(Date, "now").mockImplementation(() => now); + const transport = createWsTransport("ws://localhost:1234"); + mockWs.simulateOpen(); + const startupId = "00000000-0000-4000-8000-000000000073"; + const pending = transport.createAndSendMessage({ + workspaceId: "workspace-1", + content: "Build once", + model: "gpt-5", + mode: "worktree", + branch: "main", + startupId, + }); + const settled = vi.fn(); + void pending.then(settled, settled); + await vi.waitFor(() => expect(mockWs.sent.some((row) => JSON.parse(row).method === "agent.createAndSend")).toBe(true)); + const firstSocket = mockWs; + const firstRequest = firstSocket.sent.map((row) => JSON.parse(row) as { id: string; method: string; params: Record }) + .find((row) => row.method === "agent.createAndSend")!; + expect(firstRequest.params).toMatchObject({ startupId, maxBudgetUsd: expect.anything(), maxTurns: expect.anything() }); + + // A healthy server request can outlast the offline wait budget. + now += 121_000; + firstSocket.close(); + await Promise.resolve(); + expect(settled).not.toHaveBeenCalled(); + await vi.waitFor(() => expect(mockWs).not.toBe(firstSocket), { timeout: 3000 }); + mockWs.simulateOpen(); + await vi.waitFor(() => expect(mockWs.sent.some((row) => JSON.parse(row).method === "agent.createAndSend")).toBe(true)); + const replay = mockWs.sent.map((row) => JSON.parse(row) as { id: string; method: string; params: Record }) + .find((row) => row.method === "agent.createAndSend")!; + expect(replay.params).toEqual(firstRequest.params); + expect(replay.id).not.toBe(firstRequest.id); + mockWs.respond(replay.id, { thread: {}, handoff: null }); + await expect(pending).resolves.toBeDefined(); + expect(settled).toHaveBeenCalledOnce(); + transport.close(); + }); + + it("sends an offline startup after its initial socket fails and reconnects", async () => { + const transport = createWsTransport("ws://localhost:1234"); + const pending = transport.createAndSendMessage({ + workspaceId: "workspace-1", content: "Build once", model: "gpt-5", mode: "direct", branch: "main", + startupId: "00000000-0000-4000-8000-000000000075", + }); + const initialSocket = mockWs; + expect(initialSocket.sent).toHaveLength(0); + initialSocket.close(); + await vi.waitFor(() => expect(mockWs).not.toBe(initialSocket), { timeout: 3000 }); + mockWs.simulateOpen(); + await vi.waitFor(() => expect(mockWs.sent.some((row) => JSON.parse(row).method === "agent.createAndSend")).toBe(true)); + const request = mockWs.sent.map((row) => JSON.parse(row) as { id: string; method: string; params: { startupId: string } }) + .find((row) => row.method === "agent.createAndSend")!; + expect(request.params.startupId).toBe("00000000-0000-4000-8000-000000000075"); + mockWs.respond(request.id, { thread: {}, handoff: null }); + await expect(pending).resolves.toBeDefined(); + transport.close(); + }); + + it("bounds a startup submitted while the socket remains offline", async () => { + const transport = createWsTransport("ws://localhost:1234"); + vi.useFakeTimers(); + try { + const pending = transport.createAndSendMessage({ + workspaceId: "workspace-1", content: "Build once", model: "gpt-5", mode: "direct", branch: "main", + startupId: "00000000-0000-4000-8000-000000000076", + }); + const rejection = expect(pending).rejects.toThrow("Could not connect to server"); + await vi.advanceTimersByTimeAsync(120_001); + await rejection; + expect(mockWs.sent).toHaveLength(0); + } finally { + transport.close(); + vi.useRealTimers(); + } + }); + + it("does not replay an intentional transport close", async () => { + const transport = createWsTransport("ws://localhost:1234"); + mockWs.simulateOpen(); + const pending = transport.createAndSendMessage({ + workspaceId: "workspace-1", content: "Build once", model: "gpt-5", mode: "direct", branch: "main", + startupId: "00000000-0000-4000-8000-000000000074", + }); + await vi.waitFor(() => expect(mockWs.sent.some((row) => JSON.parse(row).method === "agent.createAndSend")).toBe(true)); + transport.close(); + await expect(pending).rejects.toThrow("Transport closed"); + expect(mockWs.sent.filter((row) => JSON.parse(row).method === "agent.createAndSend")).toHaveLength(1); + }); + + it("does not replay a server error whose text resembles a disconnect", async () => { + const transport = createWsTransport("ws://localhost:1234"); + mockWs.simulateOpen(); + const pending = transport.createAndSendMessage({ + workspaceId: "workspace-1", content: "Build once", model: "gpt-5", mode: "direct", branch: "main", + startupId: "00000000-0000-4000-8000-000000000077", + }); + await vi.waitFor(() => expect(mockWs.sent.some((row) => JSON.parse(row).method === "agent.createAndSend")).toBe(true)); + const request = mockWs.sent.map((row) => JSON.parse(row) as { id: string; method: string }) + .find((row) => row.method === "agent.createAndSend")!; + mockWs.onmessage?.({ data: JSON.stringify({ id: request.id, error: { code: "SERVER_FAILURE", message: "WebSocket disconnected" } }) }); + await expect(pending).rejects.toMatchObject({ name: "RpcError", code: "SERVER_FAILURE" }); + expect(mockWs.sent.filter((row) => JSON.parse(row).method === "agent.createAndSend")).toHaveLength(1); + transport.close(); + }); }); diff --git a/apps/web/src/transport/ws-transport.ts b/apps/web/src/transport/ws-transport.ts index b319d1417..5329652c7 100644 --- a/apps/web/src/transport/ws-transport.ts +++ b/apps/web/src/transport/ws-transport.ts @@ -93,6 +93,7 @@ import { TerminalRpcError } from "./terminal-rpc-error"; const MIN_RECONNECT_MS = 1000; /** Maximum reconnect delay in milliseconds. */ const MAX_RECONNECT_MS = 30_000; +const CREATE_REPLAY_CONNECTION_TIMEOUT_MS = 120_000; /** Number of immediate (delay=0) retries on auth failure before falling back to exponential backoff. */ const MAX_IMMEDIATE_AUTH_RETRIES = 3; /** Deadline for model discovery requested from the visible model picker. */ @@ -276,6 +277,10 @@ interface LateResponseHandler { interface RpcOptions { /** Bound this request without changing the transport's default timeout. */ readonly timeoutMs?: number; + /** Bound only the wait before the request is sent. */ + readonly waitToSendTimeoutMs?: number; + /** Observe a successful send without changing response handling. */ + readonly onRequestSent?: () => void; /** Reclaims a resource when a timed-out request eventually succeeds. */ readonly onLateSuccess?: (result: unknown) => void; /** Runs request-specific cleanup after a late response settles. */ @@ -578,7 +583,8 @@ export function createWsTransport( // Deferred import avoids a circular dependency at module evaluation time. const nowForThreads = Date.now(); import("@/features/projects/state/workspaceStore").then(({ useWorkspaceStore }) => { - const { activeWorkspaceId, loadThreads, refreshActiveConversation } = useWorkspaceStore.getState(); + const { activeWorkspaceId, loadThreads, refreshActiveConversation, recoverPreparingThreads } = useWorkspaceStore.getState(); + void recoverPreparingThreads(); if (!activeWorkspaceId) return; const last = lastLoadThreadsAtByWorkspace.get(activeWorkspaceId) ?? 0; if (nowForThreads - last <= LOAD_THREADS_RECONNECT_COOLDOWN_MS) { @@ -695,6 +701,7 @@ export function createWsTransport( } let settled = false; let requestId: string | null = null; + let waitToSendTimer: ReturnType | null = null; const deadline = options?.timeoutMs === undefined ? null : setTimeout(() => { @@ -715,6 +722,7 @@ export function createWsTransport( if (settled) return; settled = true; if (deadline !== null) clearTimeout(deadline); + if (waitToSendTimer !== null) clearTimeout(waitToSendTimer); options?.onRequestSettled?.(); resolve(value as T); }; @@ -722,9 +730,13 @@ export function createWsTransport( if (settled) return; settled = true; if (deadline !== null) clearTimeout(deadline); + if (waitToSendTimer !== null) clearTimeout(waitToSendTimer); options?.onRequestSettled?.(); reject(error); }; + if (options?.waitToSendTimeoutMs !== undefined) { + waitToSendTimer = setTimeout(() => fail(new Error("Could not reconnect to confirm thread creation")), options.waitToSendTimeoutMs); + } void ready.then( () => { @@ -740,6 +752,8 @@ export function createWsTransport( pending.set(requestId, { resolve: complete, reject: fail }); try { ws.send(JSON.stringify({ id: requestId, method, params })); + if (waitToSendTimer !== null) clearTimeout(waitToSendTimer); + options?.onRequestSent?.(); } catch (error) { pending.delete(requestId); fail(error instanceof Error ? error : new Error("WebSocket send failed")); @@ -900,6 +914,38 @@ export function createWsTransport( }); } + async function waitForCreateReplayConnection(deadline: number): Promise { + while (!closed) { + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new Error("Could not reconnect to confirm thread creation"); + await waitForConnection(remaining); + if (ws.readyState === WebSocket.OPEN) return; + // `ready` can still refer to the old socket before its close event runs. + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error("Transport closed"); + } + + async function createAndSendWithReconnect(params: Record): Promise { + let reconnectDeadline = Date.now() + CREATE_REPLAY_CONNECTION_TIMEOUT_MS; + while (true) { + if (ws.readyState !== WebSocket.OPEN) await waitForCreateReplayConnection(reconnectDeadline); + const remaining = reconnectDeadline - Date.now(); + if (remaining <= 0) throw new Error("Could not reconnect to confirm thread creation"); + let requestSent = false; + try { + return await rpc("agent.createAndSend", params, { + waitToSendTimeoutMs: remaining, + onRequestSent: () => { requestSent = true; }, + }); + } catch (error) { + if (closed || !(error instanceof Error) || error instanceof RpcError || error.message !== "WebSocket disconnected") throw error; + if (requestSent) reconnectDeadline = Date.now() + CREATE_REPLAY_CONNECTION_TIMEOUT_MS; + await waitForCreateReplayConnection(reconnectDeadline); + } + } + } + // Kick off the first connection. connect(); @@ -1123,10 +1169,13 @@ export function createWsTransport( const guardrails = state.loaded ? { maxBudgetUsd: state.settings.agent.guardrails.maxBudgetUsd, maxTurns: state.settings.agent.guardrails.maxTurns } : {}; - return rpc("agent.createAndSend", { + const params = { ...input, ...guardrails, - }); + }; + return input.startupId + ? createAndSendWithReconnect(params) + : rpc("agent.createAndSend", params); }, stopAgent: (threadId) => rpc("agent.stop", { threadId }), continueWithoutSaving: (executionId) =>