From 004154bc30fc45cb94d4803202c566612d9e5893 Mon Sep 17 00:00:00 2001 From: deepagent-ai Date: Tue, 11 Aug 2026 11:23:28 +0800 Subject: [PATCH] V4.0.7-r1 (#107) ### Issue for this PR Closes # ### Type of change - [ ] Bug fix - [ ] New feature - [ ] Refactor / code improvement - [ ] Documentation ### What does this PR do? Please provide a description of the issue, the changes you made to fix it, and why they work. It is expected that you understand why your changes work and if you do not understand why at least say as much so a maintainer knows how much to value the PR. **If you paste a large clearly AI generated description here your PR may be IGNORED or CLOSED!** ### How did you verify your code works? ### Screenshots / recordings _If this is a UI change, please include a screenshot or recording._ ### Checklist - [ ] I have tested my changes locally - [ ] I have not included unrelated changes in this PR _If you do not follow this template your PR will be automatically rejected._ --- packages/app/src/components/dialog-fork.tsx | 12 +- .../components/prompt-input/submit.test.ts | 151 +- .../app/src/components/prompt-input/submit.ts | 96 +- .../src/components/session-context-usage.tsx | 9 +- .../session/session-context-metrics.test.ts | 75 +- .../session/session-context-metrics.ts | 68 +- .../session/session-context-tab.tsx | 10 +- .../app/src/context/directory-sync.test.ts | 221 + packages/app/src/context/directory-sync.ts | 89 +- .../context/global-sync/event-reducer.test.ts | 36 + .../src/context/global-sync/event-reducer.ts | 18 + .../context/global-sync/prompt-admission.ts | 13 + .../app/src/context/sync-optimistic.test.ts | 81 + packages/app/src/context/sync.tsx | 107 +- .../app/src/pages/session/helpers.test.ts | 48 + packages/app/src/pages/session/helpers.ts | 64 +- .../session/message-timeline.data.test.ts | 41 + .../pages/session/use-session-commands.tsx | 10 +- packages/app/src/utils/id.ts | 1 + .../migration.sql | 3 + .../snapshot.json | 6916 +++++++++++++++++ packages/core/script/migration.ts | 27 +- packages/core/src/agent-gateway.ts | 15 +- packages/core/src/database/migration.gen.ts | 9 + ...0260809120000_session_history_authority.ts | 487 ++ ...20260810100000_prompt_authority_receipt.ts | 22 + ...20260810110000_fork_side_effect_receipt.ts | 40 + ...60810120000_prompt_authority_quarantine.ts | 119 + ...0260810130000_bug_012_runtime_integrity.ts | 200 + .../20260810140000_bug_012_compaction_cas.ts | 86 + ...260810150000_provider_receipt_authority.ts | 102 + ...60000_compaction_continuation_admission.ts | 197 + .../20260810170000_part_integrity_backfill.ts | 64 + .../core/src/deepagent/context/world-state.ts | 4 +- .../core/src/deepagent/plan-controller.ts | 69 +- packages/core/src/deepagent/prompt-policy.ts | 44 +- packages/core/src/event.ts | 55 +- packages/core/src/session/compaction.ts | 42 +- packages/core/src/session/projector.ts | 34 +- packages/core/src/session/runner/model.ts | 2 +- packages/core/src/session/sql.ts | 163 + packages/core/src/util/canonical-json.ts | 27 + packages/core/src/v1/session.ts | 3 + packages/core/test/database-migration.test.ts | 601 ++ .../test/deepagent/plan-controller.test.ts | 80 +- .../core/test/deepagent/prompt-policy.test.ts | 21 + packages/core/test/event.test.ts | 53 + packages/core/test/session-compaction.test.ts | 5 + .../core/test/session-runner-model.test.ts | 4 +- packages/core/test/session-runner.test.ts | 7 + packages/deepagent-code/package.json | 7 +- packages/deepagent-code/script/build.ts | 19 +- .../script/live-llm/compaction-retention.ts | 76 +- .../script/live-llm/context-authority.ts | 19 + .../script/live-llm/dispatcher.ts | 8 + .../script/live-llm/finalizer-isolation.ts | 98 +- .../script/live-llm/plan-advance-contract.ts | 226 + .../script/live-llm/plan-advance-oracle.ts | 226 + .../deepagent-code/script/live-llm/routes.ts | 67 + .../deepagent-code/script/live-llm/runtime.ts | 183 +- .../script/packaged-fork-smoke.ts | 26 + packages/deepagent-code/src/acp/service.ts | 24 +- packages/deepagent-code/src/acp/usage.ts | 48 +- packages/deepagent-code/src/cli/cmd/run.ts | 4 + .../deepagent-code/src/cli/cmd/run/runtime.ts | 10 +- .../src/effect/runtime-flags.ts | 12 +- .../src/provider/catalog-spec.ts | 33 +- packages/deepagent-code/src/server/mdns.ts | 53 +- .../routes/instance/httpapi/groups/session.ts | 17 +- .../instance/httpapi/handlers/im-websocket.ts | 12 +- .../routes/instance/httpapi/handlers/pty.ts | 29 +- .../httpapi/handlers/session-errors.ts | 12 +- .../instance/httpapi/handlers/session.ts | 49 +- .../instance/httpapi/middleware/proxy.ts | 19 +- .../instance/httpapi/websocket-tracker.ts | 58 +- packages/deepagent-code/src/server/server.ts | 156 +- .../src/session/compaction-sql.ts | 48 +- .../deepagent-code/src/session/compaction.ts | 1101 ++- .../src/session/context-ledger.ts | 208 +- .../src/session/history-authority.ts | 63 + packages/deepagent-code/src/session/llm.ts | 112 +- .../deepagent-code/src/session/llm/request.ts | 39 +- .../deepagent-code/src/session/message-v2.ts | 1026 ++- .../deepagent-code/src/session/overflow.ts | 47 +- .../deepagent-code/src/session/processor.ts | 264 +- .../src/session/prompt-epoch.sql.ts | 10 + .../src/session/prompt-epoch.ts | 81 +- packages/deepagent-code/src/session/prompt.ts | 1128 ++- .../deepagent-code/src/session/reminders.ts | 7 +- .../deepagent-code/src/session/session.ts | 1438 +++- packages/deepagent-code/src/session/steer.ts | 83 +- .../src/session/task-delivery.ts | 1 + .../deepagent-code/src/session/task-fork.ts | 238 +- .../src/session/tool-request-receipt.sql.ts | 25 + .../deepagent-code/src/tool/plan-write.ts | 320 +- .../deepagent-code/src/tool/plan-write.txt | 37 +- packages/deepagent-code/src/tool/task.ts | 65 +- packages/deepagent-code/src/tool/task_read.ts | 55 +- packages/deepagent-code/src/worktree/index.ts | 37 +- .../test/acp/service-session.test.ts | 9 +- .../deepagent-code/test/acp/usage.test.ts | 44 +- .../test/agent/pr-collaboration.test.ts | 1 + .../test/cli/run/run-process.test.ts | 13 +- .../test/cli/run/stream.transport.test.ts | 22 +- .../cli/serve/live-context-authority.test.ts | 678 ++ .../test/cli/serve/packaged-fork.test.ts | 372 + .../test/control-plane/delivery.test.ts | 9 + .../test/deepagent/plan-status-cache.test.ts | 99 +- .../deepagent-code/test/lib/cli-process.ts | 12 +- .../test/provider/catalog-spec.test.ts | 16 + .../live-llm-plan-advance-oracle.test.ts | 166 + .../test/script/live-llm-routes.test.ts | 31 +- .../test/script/run-live-llm-all.test.ts | 74 +- .../server/httpapi-exercise/environment.ts | 1 + .../test/server/httpapi-exercise/index.ts | 50 +- .../test/server/httpapi-exercise/runner.ts | 10 +- .../test/server/httpapi-instance.test.ts | 6 +- .../test/server/httpapi-layer.ts | 17 +- .../test/server/httpapi-listen.test.ts | 11 +- .../test/server/httpapi-mdns.test.ts | 64 +- .../test/server/httpapi-sdk.test.ts | 84 +- .../test/server/httpapi-session.test.ts | 164 +- .../test/server/session-actions.test.ts | 2 +- .../test/server/websocket-tracker.test.ts | 97 + .../test/session/compacted-fork.test.ts | 388 + .../test/session/compaction.test.ts | 723 +- .../test/session/context-window.test.ts | 539 ++ .../test/session/messages-pagination.test.ts | 2 +- .../test/session/overflow.test.ts | 68 +- .../test/session/processor-effect.test.ts | 20 +- .../test/session/prompt.test.ts | 726 +- .../test/session/schema-decoding.test.ts | 6 +- .../test/session/session.test.ts | 140 +- .../deepagent-code/test/session/steer.test.ts | 57 + .../test/session/structured-output.test.ts | 9 + .../test/session/task-fork.test.ts | 218 + .../session/tool-sequence-tracker.test.ts | 159 +- .../test/tool/parameters.test.ts | 33 + .../test/tool/plan-write.test.ts | 423 + .../test/tool/task-finalizer.test.ts | 82 +- .../test/tool/task-read.test.ts | 84 +- packages/llm/script/live-llm/config.ts | 137 +- packages/llm/src/schema/options.ts | 1 + packages/sdk/js/src/gen/sdk.gen.ts | 34 +- packages/sdk/js/src/gen/types.gen.ts | 156 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 34 +- packages/sdk/js/src/v2/gen/types.gen.ts | 156 +- packages/tui/src/app.tsx | 44 +- packages/tui/src/component/prompt/index.tsx | 23 +- .../src/feature-plugins/sidebar/context.tsx | 16 +- .../session/dialog-fork-from-timeline.tsx | 34 +- .../tui/src/routes/session/dialog-message.tsx | 27 +- .../src/routes/session/subagent-footer.tsx | 18 +- packages/tui/src/util/session.ts | 78 + .../inline-tool-wrap-snapshot.test.tsx.snap | 33 +- packages/tui/test/util/session.test.ts | 190 +- script/run-live-llm-all.ts | 12 + 157 files changed, 23396 insertions(+), 1901 deletions(-) create mode 100644 packages/app/src/context/directory-sync.test.ts create mode 100644 packages/app/src/context/global-sync/prompt-admission.ts create mode 100644 packages/app/src/pages/session/message-timeline.data.test.ts create mode 100644 packages/core/migration/20260810150000_provider_receipt_authority/migration.sql create mode 100644 packages/core/migration/20260810150000_provider_receipt_authority/snapshot.json create mode 100644 packages/core/src/database/migration/20260809120000_session_history_authority.ts create mode 100644 packages/core/src/database/migration/20260810100000_prompt_authority_receipt.ts create mode 100644 packages/core/src/database/migration/20260810110000_fork_side_effect_receipt.ts create mode 100644 packages/core/src/database/migration/20260810120000_prompt_authority_quarantine.ts create mode 100644 packages/core/src/database/migration/20260810130000_bug_012_runtime_integrity.ts create mode 100644 packages/core/src/database/migration/20260810140000_bug_012_compaction_cas.ts create mode 100644 packages/core/src/database/migration/20260810150000_provider_receipt_authority.ts create mode 100644 packages/core/src/database/migration/20260810160000_compaction_continuation_admission.ts create mode 100644 packages/core/src/database/migration/20260810170000_part_integrity_backfill.ts create mode 100644 packages/core/src/util/canonical-json.ts create mode 100644 packages/deepagent-code/script/live-llm/context-authority.ts create mode 100644 packages/deepagent-code/script/live-llm/plan-advance-contract.ts create mode 100644 packages/deepagent-code/script/live-llm/plan-advance-oracle.ts create mode 100644 packages/deepagent-code/script/packaged-fork-smoke.ts create mode 100644 packages/deepagent-code/src/session/history-authority.ts create mode 100644 packages/deepagent-code/test/cli/serve/live-context-authority.test.ts create mode 100644 packages/deepagent-code/test/cli/serve/packaged-fork.test.ts create mode 100644 packages/deepagent-code/test/script/live-llm-plan-advance-oracle.test.ts create mode 100644 packages/deepagent-code/test/server/websocket-tracker.test.ts create mode 100644 packages/deepagent-code/test/session/compacted-fork.test.ts create mode 100644 packages/deepagent-code/test/session/context-window.test.ts create mode 100644 packages/deepagent-code/test/session/task-fork.test.ts create mode 100644 packages/deepagent-code/test/tool/plan-write.test.ts diff --git a/packages/app/src/components/dialog-fork.tsx b/packages/app/src/components/dialog-fork.tsx index cb592500..71ede02d 100644 --- a/packages/app/src/components/dialog-fork.tsx +++ b/packages/app/src/components/dialog-fork.tsx @@ -11,6 +11,7 @@ import { extractPromptFromParts } from "@/utils/prompt" import type { TextPart as SDKTextPart } from "@deepagent-code/sdk/v2/client" import { base64Encode } from "@deepagent-code/core/util/encode" import { useLanguage } from "@/context/language" +import { Identifier } from "@/utils/id" interface ForkableMessage { id: string @@ -30,6 +31,8 @@ export const DialogFork: Component = () => { const prompt = usePrompt() const dialog = useDialog() const language = useLanguage() + const pendingIntents = new Map() + const pendingRequests = new Set() const messages = createMemo((): ForkableMessage[] => { const sessionID = params.id @@ -68,13 +71,19 @@ export const DialogFork: Component = () => { }) const dir = base64Encode(sdk.directory) + const intentKey = `${sessionID}:${item.id}` + if (pendingRequests.has(intentKey)) return + const intentID = pendingIntents.get(intentKey) ?? Identifier.ascending("fork") + pendingIntents.set(intentKey, intentID) + pendingRequests.add(intentKey) sdk.client.session - .fork({ sessionID, messageID: item.id }) + .fork({ sessionID, messageID: item.id, intentID }) .then((forked) => { if (!forked.data) { showToast({ title: language.t("common.requestFailed") }) return } + pendingIntents.delete(intentKey) dialog.close() prompt.set(restored, undefined, { dir, id: forked.data.id }) navigate(`/${dir}/session/${forked.data.id}`) @@ -83,6 +92,7 @@ export const DialogFork: Component = () => { const message = err instanceof Error ? err.message : String(err) showToast({ title: language.t("common.requestFailed"), description: message }) }) + .finally(() => pendingRequests.delete(intentKey)) } return ( diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index 1c7de9e1..0d18e6f9 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -1,5 +1,5 @@ import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" -import type { Prompt } from "@/context/prompt" +import type { ContextItem, Prompt } from "@/context/prompt" let createPromptSubmit: typeof import("./submit").createPromptSubmit @@ -16,6 +16,7 @@ const optimistic: Array<{ } }> = [] const optimisticSeeded: boolean[] = [] +const optimisticRemoved: string[] = [] const storedSessions: Record> = {} const promoted: Array<{ directory: string; sessionID: string }> = [] const sentShell: string[] = [] @@ -32,6 +33,8 @@ const sentPromptAsync: Array<{ directory: string metadata?: unknown text?: string + parts?: Array<{ id?: string; type: string; text?: string }> + messageID?: string intentID?: string intentSource?: string intentVariant?: string @@ -47,8 +50,10 @@ let variant: string | undefined let promptMode: "direct" | "intelligence" | "wish" = "direct" let appLocale = "en" let releaseDelayedPrompt: (() => void) | undefined +const rejectedAdmissionReceipts = new Set() const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }] +const promptContextItems: Array = [] const flushAsyncSubmit = () => new Promise((resolve) => setTimeout(resolve, 0)) const clientFor = (directory: string) => { @@ -72,6 +77,7 @@ const clientFor = (directory: string) => { promptAsync: async (payload?: { metadata?: unknown parts?: Array<{ type: string; text?: string }> + messageID?: string intentID?: string intentSource?: string intentVariant?: string @@ -80,17 +86,26 @@ const clientFor = (directory: string) => { directory, metadata: payload?.metadata, text: payload?.parts?.find((part) => part.type === "text")?.text, + parts: payload?.parts, + messageID: payload?.messageID, intentID: payload?.intentID, intentSource: payload?.intentSource, intentVariant: payload?.intentVariant, } sentPromptAsync.push(sent) + if ( + (sent.text === "receipt lost" || sent.text === "Edited retry goal") && + !rejectedAdmissionReceipts.has(sent.text) + ) { + rejectedAdmissionReceipts.add(sent.text) + throw new Error("connection closed after durable admission") + } if (sent.text === "prompt waits after admission") { await new Promise((resolve) => { releaseDelayedPrompt = resolve }) } - return { data: undefined } + return { data: { messageID: "msg_server_admitted", delivery: "steer" } } }, command: async () => ({ data: undefined }), abort: async () => ({ data: undefined }), @@ -139,8 +154,8 @@ const clientFor = (directory: string) => { } } const result = { - prompt_draft_id: "prompt_draft:test:1", - context_plan_id: "context_plan:test:1", + prompt_draft_id: `prompt_draft:test:${preparedDrafts.length}`, + context_plan_id: `context_plan:test:${preparedDrafts.length}`, state: "draft_ready", mode: payload.body?.mode ?? "intelligence", route: text === "hello" ? "general" : "code", @@ -227,9 +242,14 @@ beforeAll(async () => { reset: () => undefined, set: () => undefined, context: { - add: () => undefined, - remove: () => undefined, - items: () => [], + add: (item: ContextItem) => { + promptContextItems.push({ ...item, key: `restored:${promptContextItems.length}:${item.path}` }) + }, + remove: (key: string) => { + const index = promptContextItems.findIndex((item) => item.key === key) + if (index >= 0) promptContextItems.splice(index, 1) + }, + items: () => promptContextItems, }, }), })) @@ -274,7 +294,9 @@ beforeAll(async () => { !!storedSessions[value.directory]?.find((item) => item.id === value.sessionID)?.title, ) }, - remove: () => undefined, + remove: (value: { messageID: string }) => { + optimisticRemoved.push(value.messageID) + }, }, }, set: () => undefined, @@ -338,6 +360,7 @@ beforeEach(() => { enabledAutoAccept.length = 0 optimistic.length = 0 optimisticSeeded.length = 0 + optimisticRemoved.length = 0 promoted.length = 0 params = {} sentShell.length = 0 @@ -347,6 +370,7 @@ beforeEach(() => { sentPromptAsync.length = 0 promptPrepareEvents.length = 0 promptPrepareProgress.length = 0 + promptContextItems.length = 0 promptValue[0] = { type: "text", content: "ls", start: 0, end: 2 } selected = "/repo/worktree-a" variant = undefined @@ -354,6 +378,7 @@ beforeEach(() => { appLocale = "en" releaseDelayedPrompt?.() releaseDelayedPrompt = undefined + rejectedAdmissionReceipts.clear() for (const key of Object.keys(storedSessions)) delete storedSessions[key] }) @@ -455,6 +480,7 @@ describe("prompt submit worktree selection", () => { model: { providerID: "provider", modelID: "model", variant: "high" }, }, }) + expect(optimisticRemoved).toHaveLength(1) }) test("seeds new sessions before optimistic prompts are added", async () => { @@ -784,4 +810,113 @@ describe("prompt submit worktree selection", () => { expect(sentPromptAsync).toHaveLength(1) promptValue[0] = { type: "text", content: "ls", start: 0, end: 2 } }) + + test("reuses submission identity when the durable admission receipt is lost", async () => { + params = { id: "session-1" } + promptValue[0] = { type: "text", content: "receipt lost", start: 0, end: 12 } + const context = { + type: "file" as const, + path: "src/retry.ts", + comment: "keep this review context", + commentID: "comment-1", + key: "original-ui-key", + } + promptContextItems.push(context, { + type: "file", + path: "src/reference.ts", + key: "reference-ui-key", + }) + + const submit = createPromptSubmit({ + info: () => ({ id: "session-1" }), + imageAttachments: () => [], + commentCount: () => 0, + autoAccept: () => false, + mode: () => "normal", + working: () => false, + editor: () => undefined, + queueScroll: () => undefined, + promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), + addToHistory: () => undefined, + resetHistoryNavigation: () => undefined, + setMode: () => undefined, + setPopover: () => undefined, + onSubmit: () => undefined, + }) + + const event = { preventDefault: () => undefined } as unknown as Event + await submit.handleSubmit(event) + await flushAsyncSubmit() + + expect(promptContextItems.find((item) => item.commentID === context.commentID)?.key).not.toBe(context.key) + + await submit.handleSubmit(event) + await flushAsyncSubmit() + + expect(sentPromptAsync).toHaveLength(2) + expect(sentPromptAsync[0]?.messageID).toBeDefined() + expect(sentPromptAsync[1]?.messageID).toBe(sentPromptAsync[0]?.messageID) + expect(sentPromptAsync[0]?.intentID).toBeDefined() + expect(sentPromptAsync[1]?.intentID).toBe(sentPromptAsync[0]?.intentID) + expect(sentPromptAsync[1]?.parts).toEqual(sentPromptAsync[0]?.parts) + + promptContextItems.push({ ...context, key: "new-ui-key-after-success" }) + await submit.handleSubmit(event) + await flushAsyncSubmit() + + expect(sentPromptAsync).toHaveLength(3) + expect(sentPromptAsync[2]?.messageID).not.toBe(sentPromptAsync[0]?.messageID) + expect(sentPromptAsync[2]?.intentID).not.toBe(sentPromptAsync[0]?.intentID) + promptValue[0] = { type: "text", content: "ls", start: 0, end: 2 } + }) + + test("reuses the final prepared payload when an intelligence admission receipt is lost", async () => { + params = { id: "session-1" } + promptMode = "intelligence" + promptValue[0] = { type: "text", content: "intelligence receipt lost", start: 0, end: 25 } + let confirms = 0 + + const submit = createPromptSubmit({ + info: () => ({ id: "session-1" }), + imageAttachments: () => [], + commentCount: () => 0, + autoAccept: () => false, + mode: () => "normal", + working: () => false, + editor: () => undefined, + queueScroll: () => undefined, + promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), + addToHistory: () => undefined, + resetHistoryNavigation: () => undefined, + setMode: () => undefined, + setPopover: () => undefined, + confirmPromptDraft: async () => { + confirms += 1 + return { editedGoal: "Edited retry goal" } + }, + onSubmit: () => undefined, + }) + + const event = { preventDefault: () => undefined } as unknown as Event + await submit.handleSubmit(event) + await flushAsyncSubmit() + await submit.handleSubmit(event) + await flushAsyncSubmit() + + expect(preparedDrafts).toHaveLength(1) + expect(confirms).toBe(1) + expect(sentPromptAsync).toHaveLength(2) + expect(sentPromptAsync[1]?.messageID).toBe(sentPromptAsync[0]?.messageID) + expect(sentPromptAsync[1]?.intentID).toBe(sentPromptAsync[0]?.intentID) + expect(sentPromptAsync[1]?.parts).toEqual(sentPromptAsync[0]?.parts) + expect(sentPromptAsync[1]?.metadata).toEqual(sentPromptAsync[0]?.metadata) + expect(sentPromptAsync[1]?.metadata).toMatchObject({ + deepagent: { + prompt_pipeline: { + confirmed_draft_id: "prompt_draft:test:1", + }, + }, + }) + promptValue[0] = { type: "text", content: "ls", start: 0, end: 2 } + }) }) diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 814be647..9b4a4e00 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -1,4 +1,4 @@ -import type { Message, Session } from "@deepagent-code/sdk/v2/client" +import type { Message, Part, Session } from "@deepagent-code/sdk/v2/client" import { showToast } from "@/utils/toast" import { base64Encode } from "@deepagent-code/core/util/encode" import { Binary } from "@deepagent-code/core/util/binary" @@ -70,6 +70,18 @@ type SessionPromptAsyncInput = Parameters["client"]["s metadata?: FollowupDraft["metadata"] } +type AdmissionRetry = { + promptInput: SessionPromptAsyncInput & { messageID: string; intentID: string } + optimisticParts: Part[] +} + +type SubmissionIdentity = { + fingerprint: string + messageID: string + intentID: string + request?: AdmissionRetry +} + type RawSdkClient = { client: { request(options: { @@ -96,10 +108,12 @@ type FollowupSendInput = { draft: FollowupDraft messageID?: string intentID?: string + retry?: AdmissionRetry intentSource?: "composer" | "intelligence" | "followup" | "rewrite" optimisticBusy?: boolean before?: () => Promise | boolean onBeforeSubmit?: () => void + onPromptInput?: (request: AdmissionRetry) => void onPromptPrepareStart?: () => void onPromptPrepareProgress?: (preview: string) => void onPromptPrepareEnd?: () => void @@ -113,6 +127,28 @@ const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image") +const submissionFingerprint = (draft: FollowupDraft) => + JSON.stringify({ + sessionID: draft.sessionID, + sessionDirectory: draft.sessionDirectory, + prompt: draft.prompt, + context: draft.context + .map((item) => ({ + type: item.type, + path: item.path, + selection: item.selection, + comment: item.comment, + commentID: item.commentID, + commentOrigin: item.commentOrigin, + preview: item.preview, + })) + .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))), + agent: draft.agent, + model: draft.model, + variant: draft.variant, + metadata: draft.metadata, + }) + const promptPipelineMode = (metadata: FollowupDraft["metadata"]): DeepAgentPromptModeForConfirmation | undefined => { const mode = metadata?.deepagent?.prompt_pipeline?.mode return mode === "intelligence" ? mode : undefined @@ -259,8 +295,8 @@ export async function sendFollowupDraft(input: FollowupSendInput) { } } - const messageID = input.messageID ?? Identifier.ascending("message") - const intentID = input.intentID ?? Identifier.ascending("message") + const messageID = input.retry?.promptInput.messageID ?? input.messageID ?? Identifier.ascending("message") + const intentID = input.retry?.promptInput.intentID ?? input.intentID ?? Identifier.ascending("message") const buildParts = (promptText: string) => buildRequestParts({ prompt: input.draft.prompt, @@ -271,13 +307,13 @@ export async function sendFollowupDraft(input: FollowupSendInput) { messageID, sessionDirectory: input.draft.sessionDirectory, }) - const preparedParts = buildParts(text) + const preparedParts = input.retry ? undefined : buildParts(text) - const mode = promptPipelineMode(input.draft.metadata) + const mode = input.retry ? undefined : promptPipelineMode(input.draft.metadata) let waited = false let metadata = input.draft.metadata let confirmedDraft: DeepAgentPromptConfirmResult | undefined - if (mode) { + if (mode && preparedParts) { const ok = await wait() waited = true if (!ok) return false @@ -340,7 +376,15 @@ export async function sendFollowupDraft(input: FollowupSendInput) { } } - const submittedParts = confirmedDraft ? buildParts(confirmedDraft.editedGoal) : preparedParts + const submittedParts = input.retry + ? { + requestParts: input.retry.promptInput.parts, + optimisticParts: input.retry.optimisticParts, + } + : confirmedDraft + ? buildParts(confirmedDraft.editedGoal) + : preparedParts + if (!submittedParts) throw new Error("Prompt submission has no request parts") const message: Message = { id: messageID, @@ -378,7 +422,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) { add() }) - const promptInput: SessionPromptAsyncInput = { + const promptInput: AdmissionRetry["promptInput"] = input.retry?.promptInput ?? { sessionID: input.draft.sessionID, agent: input.draft.agent, model: input.draft.model, @@ -390,7 +434,13 @@ export async function sendFollowupDraft(input: FollowupSendInput) { variant: input.draft.variant, metadata, } - await input.client.session.promptAsync(promptInput) + input.onPromptInput?.({ promptInput, optimisticParts: submittedParts.optimisticParts }) + const admission = await input.client.session.promptAsync(promptInput) + if (!admission.data?.messageID) throw new Error("Prompt admission returned no durable receipt") + // The server may mint a different canonical ID for a busy-session steer. The durable event is + // authoritative, so remove a mismatched client-keyed placeholder once admission succeeds instead of + // leaving it around to render beside the canonical server message. + if (admission.data.messageID !== messageID) remove() return true } catch (err) { batch(() => { @@ -452,10 +502,12 @@ export function createPromptSubmit(input: PromptSubmitInput) { const params = useParams() const pendingKey = (sessionID: string) => ScopedKey.from(sdk.scope, sessionID) let submissionSequence = 0 + let retryIdentity: SubmissionIdentity | undefined let activeSubmission: | { id: number sessionID: string + fingerprint: string controller: AbortController preparing: boolean admissionStarted: boolean @@ -481,6 +533,9 @@ export function createPromptSubmit(input: PromptSubmitInput) { else input.onPromptPrepareDiscard?.() } await submission.promise + if (submission.admissionStarted && retryIdentity?.fingerprint === submission.fingerprint) { + retryIdentity = undefined + } return submission } @@ -778,8 +833,17 @@ export function createPromptSubmit(input: PromptSubmitInput) { } const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim()) - const messageID = Identifier.ascending("message") - const intentID = Identifier.ascending("message") + const fingerprint = submissionFingerprint(draft) + const identity = + retryIdentity?.fingerprint === fingerprint + ? retryIdentity + : { + fingerprint, + messageID: Identifier.ascending("message"), + intentID: Identifier.ascending("message"), + } + const messageID = identity.messageID + const intentID = identity.intentID const preparesPromptDraft = promptPipelineMode(draft.metadata) === "intelligence" const controller = new AbortController() @@ -855,8 +919,9 @@ export function createPromptSubmit(input: PromptSubmitInput) { const operation = { id: ++submissionSequence, sessionID: session.id, + fingerprint, controller, - preparing: preparesPromptDraft, + preparing: preparesPromptDraft && !identity.request, admissionStarted: false, promise: Promise.resolve(), } @@ -869,13 +934,19 @@ export function createPromptSubmit(input: PromptSubmitInput) { draft, messageID, intentID, + retry: identity.request, intentSource: preparesPromptDraft ? "intelligence" : "composer", optimisticBusy: sessionDirectory === projectDirectory, before: waitForWorktree, onBeforeSubmit: () => { operation.admissionStarted = true + retryIdentity = identity input.onSubmit?.() }, + onPromptInput: (request) => { + identity.request = request + retryIdentity = identity + }, onPromptPrepareStart: () => { if (ownsOperation()) input.onPromptPrepareStart?.() }, @@ -898,6 +969,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { pending.delete(pendingKey(session.id)) if (controller.signal.aborted) return if (sent) { + if (retryIdentity?.fingerprint === fingerprint) retryIdentity = undefined if (preparesPromptDraft) { clearContext() clearInput() diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index d492c128..4038ff79 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -2,6 +2,7 @@ import { Match, Show, Switch, createMemo } from "solid-js" import { Tooltip, type TooltipProps } from "@deepagent-code/ui/tooltip" import { ProgressCircle } from "@deepagent-code/ui/progress-circle" import { Button } from "@deepagent-code/ui/button" +import type { Part } from "@deepagent-code/sdk/v2/client" import { useFile } from "@/context/file" import { useLayout } from "@/context/layout" @@ -44,7 +45,13 @@ export function SessionContextUsage(props: SessionContextUsageProps) { }) const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) - const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()])) + const metrics = createMemo(() => + getSessionContextMetrics( + messages(), + [...providers.all().values()], + sync.data.part as Record, + ), + ) const context = createMemo(() => metrics().context) // Cumulative tokens across the whole conversation (all turns + subagent child sessions). Distinct // from `context()` which is the current retained-window occupancy. Cost is intentionally not shown diff --git a/packages/app/src/components/session/session-context-metrics.test.ts b/packages/app/src/components/session/session-context-metrics.test.ts index 63181d21..12c8635d 100644 --- a/packages/app/src/components/session/session-context-metrics.test.ts +++ b/packages/app/src/components/session/session-context-metrics.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { Message, Session } from "@deepagent-code/sdk/v2/client" +import type { Message, Part, Session } from "@deepagent-code/sdk/v2/client" import { getConversationTokens, getSessionContextMetrics, @@ -38,12 +38,26 @@ const assistant = ( const user = (id: string) => { return { id, + sessionID: "ses_1", role: "user", + agent: "build", + model: { providerID: "openai", modelID: "gpt-4.1" }, cost: 0, time: { created: 1 }, } as unknown as Message } +const compactionPart = (messageID: string, contextTokens?: number) => { + return { + id: `part-${messageID}`, + sessionID: "ses_1", + messageID, + type: "compaction", + auto: false, + context_tokens: contextTokens, + } as unknown as Part +} + describe("getSessionContextMetrics", () => { test("computes totals and usage from latest assistant with tokens", () => { const messages = [ @@ -106,6 +120,65 @@ describe("getSessionContextMetrics", () => { expect(metrics.totalCost).toBe(0) expect(metrics.context).toBeUndefined() }) + + test("uses the committed compaction snapshot until a normal provider turn reports usage", () => { + const marker = user("u2") + const summary = { + ...assistant("a2", { input: 80_000, output: 1_200, reasoning: 0, read: 0, write: 0 }, 0.5), + parentID: "u2", + summary: true, + finish: "stop", + } as Message + const messages = [ + user("u1"), + assistant("a1", { input: 75_000, output: 500, reasoning: 0, read: 5_000, write: 0 }, 1), + marker, + summary, + ] + const providers = [ + { + id: "openai", + name: "OpenAI", + models: { "gpt-4.1": { name: "GPT-4.1", limit: { context: 100_000, input: 50_000 } } }, + }, + ] + const parts = { u2: [compactionPart("u2", 4_000)] } + + const compacted = getSessionContextMetrics(messages, providers, parts) + expect(compacted.context?.source).toBe("compaction") + expect(compacted.context?.total).toBe(4_000) + expect(compacted.context?.limit).toBe(50_000) + expect(compacted.context?.usage).toBe(8) + + messages.push(user("u3")) + messages.push(assistant("a3", { input: 4_500, output: 200, reasoning: 0, read: 500, write: 0 }, 0.25)) + const measured = getSessionContextMetrics(messages, providers, parts) + expect(measured.context?.source).toBe("provider") + expect(measured.context?.message.id).toBe("a3") + expect(measured.context?.total).toBe(5_000) + expect(measured.context?.usage).toBe(10) + }) + + test("does not treat an uncommitted compaction marker as a refreshed context", () => { + const marker = user("u2") + const summary = { + ...assistant("a2", { input: 80_000, output: 1_200, reasoning: 0, read: 0, write: 0 }, 0.5), + parentID: "u2", + summary: true, + finish: "stop", + } as Message + const messages = [ + user("u1"), + assistant("a1", { input: 75_000, output: 500, reasoning: 0, read: 5_000, write: 0 }, 1), + marker, + summary, + ] + + const metrics = getSessionContextMetrics(messages, [], { u2: [compactionPart("u2")] }) + expect(metrics.context?.source).toBe("provider") + expect(metrics.context?.message.id).toBe("a2") + expect(metrics.context?.total).toBe(80_000) + }) }) const session = ( diff --git a/packages/app/src/components/session/session-context-metrics.ts b/packages/app/src/components/session/session-context-metrics.ts index e42f494c..142c2efb 100644 --- a/packages/app/src/components/session/session-context-metrics.ts +++ b/packages/app/src/components/session/session-context-metrics.ts @@ -1,4 +1,4 @@ -import type { AssistantMessage, Message, Session } from "@deepagent-code/sdk/v2/client" +import type { AssistantMessage, Message, Part, Session, UserMessage } from "@deepagent-code/sdk/v2/client" type Provider = { id: string @@ -10,11 +10,13 @@ type Model = { name?: string limit: { context: number + input?: number } } type Context = { message: AssistantMessage + source: "provider" | "compaction" provider?: Provider model?: Model providerLabel: string @@ -51,20 +53,72 @@ const lastAssistantWithTokens = (messages: Message[]) => { } } -const build = (messages: Message[] = [], providers: Provider[] = []): Metrics => { +const compactedContext = (messages: Message[], parts: Record) => { + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i] + if (message.role !== "assistant") continue + if (!message.summary) { + if (tokenTotal(message) > 0) return + continue + } + if (!message.finish || message.error) continue + const marker = parts[message.parentID]?.find((part) => part.type === "compaction") + if (marker?.type !== "compaction" || marker.context_tokens === undefined) continue + const parent = messages.find((item): item is UserMessage => item.id === message.parentID && item.role === "user") + return { + message, + parent, + total: marker.context_tokens, + } + } +} + +const build = ( + messages: Message[] = [], + providers: Provider[] = [], + parts: Record = {}, +): Metrics => { const totalCost = messages.reduce((sum, msg) => sum + (msg.role === "assistant" ? msg.cost : 0), 0) + const compacted = compactedContext(messages, parts) + if (compacted) { + const providerID = compacted.parent?.model.providerID ?? compacted.message.providerID + const modelID = compacted.parent?.model.modelID ?? compacted.message.modelID + const provider = providers.find((item) => item.id === providerID) + const model = provider?.models[modelID] + const limit = model?.limit.input ?? model?.limit.context + return { + totalCost, + context: { + message: compacted.message, + source: "compaction", + provider, + model, + providerLabel: provider?.name ?? providerID, + modelLabel: model?.name ?? modelID, + limit, + input: compacted.total, + output: 0, + reasoning: 0, + cacheRead: 0, + cacheWrite: 0, + total: compacted.total, + usage: limit ? Math.round((compacted.total / limit) * 100) : null, + }, + } + } const message = lastAssistantWithTokens(messages) if (!message) return { totalCost, context: undefined } const provider = providers.find((item) => item.id === message.providerID) const model = provider?.models[message.modelID] - const limit = model?.limit.context + const limit = model?.limit.input ?? model?.limit.context const total = tokenTotal(message) return { totalCost, context: { message, + source: "provider", provider, model, providerLabel: provider?.name ?? message.providerID, @@ -81,8 +135,12 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Metrics => } } -export function getSessionContextMetrics(messages: Message[] = [], providers: Provider[] = []) { - return build(messages, providers) +export function getSessionContextMetrics( + messages: Message[] = [], + providers: Provider[] = [], + parts: Record = {}, +) { + return build(messages, providers, parts) } // All tokens a session's persisted running total accounts for (input + output + reasoning + cache). diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index 1494a63e..f58b1507 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -124,7 +124,13 @@ export function SessionContextTab() { { equals: same }, ) - const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()])) + const metrics = createMemo(() => + getSessionContextMetrics( + messages(), + [...providers.all().values()], + sync.data.part as Record, + ), + ) const ctx = createMemo(() => metrics().context) const formatter = createMemo(() => createSessionContextFormatter(language.intl())) @@ -169,7 +175,7 @@ export function SessionContextTab() { () => [ctx()?.message.id, ctx()?.input, messages().length, systemPrompt()], () => { const c = ctx() - if (!c?.input) return [] + if (!c?.input || c.source === "compaction") return [] return estimateSessionContextBreakdown({ messages: messages(), parts: sync.data.part as Record, diff --git a/packages/app/src/context/directory-sync.test.ts b/packages/app/src/context/directory-sync.test.ts new file mode 100644 index 00000000..ac3ed9c3 --- /dev/null +++ b/packages/app/src/context/directory-sync.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, test } from "bun:test" +import { createRoot } from "solid-js" +import { createStore } from "solid-js/store" +import type { Message, Part } from "@deepagent-code/sdk/v2/client" +import { ServerScope } from "@/utils/server-scope" +import { createDirSyncContext } from "./directory-sync" + +const state = () => + createStore({ + path: { directory: "/repo" }, + session: [] as Array<{ id: string }>, + mcp: {}, + message: {} as Record, + part: {} as Record, + }) + +const userMessage = (id: string, sessionID: string): Message => ({ + id, + sessionID, + role: "user", + time: { created: 1 }, + agent: "assistant", + model: { providerID: "openai", modelID: "gpt" }, +}) + +describe("directory optimistic targeting", () => { + test("writes and removes an explicit worktree optimistic message in that child store", () => + createRoot((dispose) => { + const current = state() + const worktree = state() + const children = new Map([ + ["/repo/main", current], + ["/repo/worktree", worktree], + ]) + const serverSync = { + child(directory: string) { + const child = children.get(directory) + if (!child) throw new Error(`Unknown child: ${directory}`) + return child + }, + } as unknown as Parameters[1] + const sync = createDirSyncContext("/repo/main", serverSync, { + createClient() { + return {} + }, + } as unknown as Parameters[2]) + const message = userMessage("msg_client", "ses_1") + + sync.session.optimistic.add({ + directory: "/repo/worktree", + sessionID: message.sessionID, + message, + parts: [], + }) + + expect(current[0].message[message.sessionID]).toBeUndefined() + expect(worktree[0].message[message.sessionID]?.map((item) => item.id)).toEqual([message.id]) + + sync.session.optimistic.remove({ + directory: "/repo/worktree", + sessionID: message.sessionID, + messageID: message.id, + }) + + expect(worktree[0].message[message.sessionID]).toEqual([]) + dispose() + })) + + test("does not restore an optimistic message after the canonical steer arrives before the retry", () => + createRoot((dispose) => { + const current = state() + const serverSync = { + child() { + return current + }, + } as unknown as Parameters[1] + const sync = createDirSyncContext("/repo/main", serverSync, { + createClient() { + return {} + }, + } as unknown as Parameters[2]) + const sessionID = "ses_1" + const client = userMessage("msg_client", sessionID) + const canonical = { + ...userMessage("msg_server", sessionID), + metadata: { + deepagent: { + promptAdmission: { + clientMessageID: client.id, + }, + }, + }, + } + + sync.session.optimistic.add({ sessionID, message: client, parts: [] }) + expect(current[0].message[sessionID]?.map((message) => message.id)).toEqual([client.id]) + + current[1]("message", sessionID, [canonical]) + sync.session.optimistic.add({ sessionID, message: client, parts: [] }) + + expect(current[0].message[sessionID]?.map((message) => message.id)).toEqual([canonical.id]) + expect(current[0].part[client.id]).toBeUndefined() + dispose() + })) + + test("does not merge a private placeholder from a stale page after the canonical event", async () => { + let releaseStalePage: (() => void) | undefined + let calls = 0 + const current = state() + const serverSync = { + child() { + return current + }, + plan: { + async sync() {}, + }, + } as unknown as Parameters[1] + const sync = createDirSyncContext("/repo/main", serverSync, { + scope: ServerScope.local, + createClient() { + return { + session: { + async get() { + return { data: { id: "ses_1" } } + }, + async messages() { + calls += 1 + if (calls === 1) { + return { + data: [], + response: { headers: new Headers({ "x-next-cursor": "older" }) }, + } + } + await new Promise((resolve) => { + releaseStalePage = resolve + }) + return { data: [], response: { headers: new Headers() } } + }, + }, + } + }, + } as unknown as Parameters[2]) + const sessionID = "ses_1" + const client = userMessage("msg_client", sessionID) + const canonical = { + ...userMessage("msg_server", sessionID), + metadata: { + deepagent: { + promptAdmission: { + clientMessageID: client.id, + }, + }, + }, + } + current[1]("session", [current[0].session.length], { id: sessionID }) + sync.session.optimistic.add({ sessionID, message: client, parts: [] }) + await sync.session.sync(sessionID) + + const stale = sync.session.history.loadMore(sessionID) + await Promise.resolve() + current[1]("message", sessionID, [canonical]) + releaseStalePage?.() + await stale + + expect(calls).toBe(2) + expect(current[0].message[sessionID]?.map((message) => message.id)).toEqual([canonical.id]) + }) + + test("preserves a canonical steer when a stale replace page returns after the event", async () => { + let releaseStalePage: (() => void) | undefined + const current = state() + const serverSync = { + child() { + return current + }, + plan: { + async sync() {}, + }, + } as unknown as Parameters[1] + const sync = createDirSyncContext("/repo/main", serverSync, { + scope: ServerScope.local, + createClient() { + return { + session: { + async get() { + return { data: { id: "ses_1" } } + }, + async messages() { + await new Promise((resolve) => { + releaseStalePage = resolve + }) + return { data: [], response: { headers: new Headers() } } + }, + }, + } + }, + } as unknown as Parameters[2]) + const sessionID = "ses_1" + const client = userMessage("msg_client", sessionID) + const canonical = { + ...userMessage("msg_server", sessionID), + metadata: { + deepagent: { + promptAdmission: { + clientMessageID: client.id, + }, + }, + }, + } + current[1]("session", [current[0].session.length], { id: sessionID }) + sync.session.optimistic.add({ sessionID, message: client, parts: [] }) + const stale = sync.session.sync(sessionID, { force: true }) + await Promise.resolve() + expect(releaseStalePage).toBeDefined() + current[1]("message", sessionID, [canonical]) + releaseStalePage?.() + await stale + + expect(current[0].message[sessionID]?.map((message) => message.id)).toEqual([canonical.id]) + }) +}) diff --git a/packages/app/src/context/directory-sync.ts b/packages/app/src/context/directory-sync.ts index d0994a93..6c7ba80c 100644 --- a/packages/app/src/context/directory-sync.ts +++ b/packages/app/src/context/directory-sync.ts @@ -13,6 +13,7 @@ import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } fro import { diffs as list, message as clean } from "@/utils/diffs" import { createServerSdkContext, useServerSDK } from "./server-sdk" import { type createServerSyncContextInner } from "./server-sync" +import { promptAdmissionClientMessageID } from "./global-sync/prompt-admission" const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) @@ -67,6 +68,11 @@ type OptimisticItem = { parts: Part[] } +type OptimisticResolution = { + pending: OptimisticItem[] + canonical: Message[] +} + type MessagePage = { session: Message[] part: { id: string; part: Part[] }[] @@ -93,14 +99,27 @@ const mergeParts = (parts: Part[] | undefined, want: Part[]) => { return next } +const hasCanonicalPromptAdmission = (messages: Message[] | undefined, clientMessageID: string) => + messages?.some((message) => promptAdmissionClientMessageID(message) === clientMessageID) ?? false + export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) { if (items.length === 0) return { ...page, confirmed: [] as string[] } const session = [...page.session] const part = new Map(page.part.map((item) => [item.id, sortParts(item.part)])) const confirmed: string[] = [] + const correlated = new Set( + page.session + .map(promptAdmissionClientMessageID) + .filter((messageID): messageID is string => messageID !== undefined), + ) for (const item of items) { + if (correlated.has(item.message.id)) { + confirmed.push(item.message.id) + part.delete(item.message.id) + continue + } const result = Binary.search(session, item.message.id, (message) => message.id) const found = result.found if (!found) session.splice(result.index, 0, item.message) @@ -125,9 +144,14 @@ export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) { const messages = draft.message[input.sessionID] + if (hasCanonicalPromptAdmission(messages, input.message.id)) { + delete draft.part[input.message.id] + return + } if (messages) { const result = Binary.search(messages, input.message.id, (m) => m.id) - messages.splice(result.index, 0, input.message) + if (result.found) messages[result.index] = input.message + else messages.splice(result.index, 0, input.message) } else { draft.message[input.sessionID] = [input.message] } @@ -148,7 +172,8 @@ function setOptimisticAdd(setStore: (...args: unknown[]) => void, input: Optimis if (!messages) return [input.message] const result = Binary.search(messages, input.message.id, (m) => m.id) const next = [...messages] - next.splice(result.index, 0, input.message) + if (result.found) next[result.index] = input.message + else next.splice(result.index, 0, input.message) return next }) setStore("part", input.message.id, sortParts(input.parts)) @@ -163,12 +188,7 @@ function setOptimisticRemove(setStore: (...args: unknown[]) => void, input: Opti next.splice(result.index, 1) return next }) - setStore("part", (part: Record) => { - if (!(input.messageID in part)) return part - const next = { ...part } - delete next[input.messageID] - return next - }) + setStore("part", input.messageID, undefined) } export const createDirSyncContext = ( @@ -182,9 +202,9 @@ export const createDirSyncContext = ( type Setter = Child[1] const current = createMemo(() => serverSync.child(directory, { mcp: true })) - const target = (directory?: string) => { - if (!directory || directory === directory) return current() - return serverSync.child(directory) + const target = (targetDirectory?: string) => { + if (!targetDirectory || targetDirectory === directory) return current() + return serverSync.child(targetDirectory) } const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/") const initialMessagePageSize = 80 @@ -231,9 +251,23 @@ export const createDirSyncContext = ( if (list.size === 0) optimistic.delete(key) } - const getOptimistic = (directory: string, sessionID: string) => [ - ...(optimistic.get(keyFor(directory, sessionID))?.values() ?? []), - ] + const getOptimistic = (directory: string, sessionID: string): OptimisticResolution => { + const messages = serverSync.child(directory, { bootstrap: false })[0].message[sessionID] + const items = [...(optimistic.get(keyFor(directory, sessionID))?.values() ?? [])] + const clientMessageIDs = new Set(items.map((item) => item.message.id)) + const canonical = (messages ?? []).filter((message) => { + const clientMessageID = promptAdmissionClientMessageID(message) + return clientMessageID !== undefined && clientMessageIDs.has(clientMessageID) + }) + const confirmed = new Set(canonical.map((message) => promptAdmissionClientMessageID(message))) + for (const messageID of confirmed) { + if (messageID) clearOptimistic(directory, sessionID, messageID) + } + return { + pending: items.filter((item) => !confirmed.has(item.message.id)), + canonical, + } + } const seenFor = (directory: string) => { const existing = seen.get(directory) @@ -327,13 +361,14 @@ export const createDirSyncContext = ( await fetchMessages(input) .then((page) => { if (!tracked(input.directory, input.sessionID)) return - const next = mergeOptimisticPage(page, getOptimistic(input.directory, input.sessionID)) + const optimistic = getOptimistic(input.directory, input.sessionID) + const next = mergeOptimisticPage(page, optimistic.pending) for (const messageID of next.confirmed) { clearOptimistic(input.directory, input.sessionID, messageID) } const [store] = serverSync.child(input.directory, { bootstrap: false }) - const cached = input.mode === "prepend" ? (store.message[input.sessionID] ?? []) : [] - const message = input.mode === "prepend" ? merge(cached, next.session) : next.session + const cached = input.mode === "prepend" ? (store.message[input.sessionID] ?? []) : optimistic.canonical + const message = merge(cached, next.session) batch(() => { input.setStore("message", input.sessionID, reconcile(message, { key: "id" })) for (const p of next.part) { @@ -396,7 +431,15 @@ export const createDirSyncContext = ( optimistic: { add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) { const _directory = input.directory ?? directory - const [, setStore] = target(input.directory) + const [store, setStore] = target(input.directory) + if (hasCanonicalPromptAdmission(store.message[input.sessionID], input.message.id)) { + clearOptimistic(_directory, input.sessionID, input.message.id) + setOptimisticRemove(setStore as (...args: unknown[]) => void, { + sessionID: input.sessionID, + messageID: input.message.id, + }) + return + } setOptimistic(_directory, input.sessionID, { message: input.message, parts: input.parts }) setOptimisticAdd(setStore as (...args: unknown[]) => void, input) }, @@ -423,7 +466,15 @@ export const createDirSyncContext = ( agent: input.agent, model: { ...input.model, variant: input.variant }, } - const [, setStore] = target() + const [store, setStore] = target() + if (hasCanonicalPromptAdmission(store.message[input.sessionID], message.id)) { + clearOptimistic(directory, input.sessionID, message.id) + setOptimisticRemove(setStore as (...args: unknown[]) => void, { + sessionID: input.sessionID, + messageID: message.id, + }) + return + } setOptimistic(directory, input.sessionID, { message, parts: input.parts }) setOptimisticAdd(setStore as (...args: unknown[]) => void, { sessionID: input.sessionID, diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts index bbc88ca9..b99973d4 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -430,6 +430,42 @@ describe("applyDirectoryEvent", () => { expect(store.part.msg_2).toBeUndefined() }) + test("reconciles a canonical steer event before its HTTP receipt", () => { + const sessionID = "ses_1" + const clientMessageID = "msg_client" + const canonical = { + ...userMessage("msg_server", sessionID), + metadata: { + deepagent: { + promptAdmission: { + clientMessageID, + }, + }, + }, + } as Message + const clientPart = textPart("prt_client", sessionID, clientMessageID) + const [store, setStore] = createStore( + baseState({ + message: { [sessionID]: [userMessage(clientMessageID, sessionID)] }, + part: { [clientMessageID]: [clientPart] }, + part_text_accum_delta: { [clientPart.id]: "pending" }, + }), + ) + + applyDirectoryEvent({ + event: { type: "message.updated", properties: { info: canonical } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + + expect(store.message[sessionID]?.map((message) => message.id)).toEqual([canonical.id]) + expect(store.part[clientMessageID]).toBeUndefined() + expect(store.part_text_accum_delta[clientPart.id]).toBeUndefined() + }) + test("upserts and prunes message parts", () => { const sessionID = "ses_1" const messageID = "msg_1" diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 8ef6fec8..bbbbb387 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -14,6 +14,7 @@ import type { State, VcsCache, SessionPlan, SessionGoal, SessionPlanUpdateOption import { trimSessions } from "./session-trim" import { dropSessionCaches } from "./session-cache" import { diffs as list, message as clean } from "@/utils/diffs" +import { promptAdmissionClientMessageID } from "./prompt-admission" const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) @@ -226,6 +227,23 @@ export function applyDirectoryEvent(input: { } case "message.updated": { const info = clean((event.properties as { info: Message }).info) + const clientMessageID = promptAdmissionClientMessageID(info) + if (clientMessageID && clientMessageID !== info.id) { + input.setStore( + produce((draft) => { + const messages = draft.message[info.sessionID] + if (messages) { + const result = Binary.search(messages, clientMessageID, (message) => message.id) + if (result.found) messages.splice(result.index, 1) + } + const parts = draft.part[clientMessageID] + if (parts) { + for (const part of parts) delete draft.part_text_accum_delta[part.id] + } + delete draft.part[clientMessageID] + }), + ) + } const messages = input.store.message[info.sessionID] if (!messages) { input.setStore("message", info.sessionID, [info]) diff --git a/packages/app/src/context/global-sync/prompt-admission.ts b/packages/app/src/context/global-sync/prompt-admission.ts new file mode 100644 index 00000000..65419cf6 --- /dev/null +++ b/packages/app/src/context/global-sync/prompt-admission.ts @@ -0,0 +1,13 @@ +import type { Message } from "@deepagent-code/sdk/v2/client" + +const isRecord = (value: unknown): value is Record => + !!value && typeof value === "object" && !Array.isArray(value) + +export function promptAdmissionClientMessageID(message: Message): string | undefined { + if (message.role !== "user") return undefined + const deepagent = message.metadata?.deepagent + if (!isRecord(deepagent)) return undefined + const admission = deepagent.promptAdmission + if (!isRecord(admission)) return undefined + return typeof admission.clientMessageID === "string" ? admission.clientMessageID : undefined +} diff --git a/packages/app/src/context/sync-optimistic.test.ts b/packages/app/src/context/sync-optimistic.test.ts index 92d40e2f..2802cfb1 100644 --- a/packages/app/src/context/sync-optimistic.test.ts +++ b/packages/app/src/context/sync-optimistic.test.ts @@ -39,6 +39,56 @@ describe("sync optimistic reducers", () => { expect(draft.part.msg_1?.map((x) => x.id)).toEqual(["prt_1", "prt_2"]) }) + test("applyOptimisticAdd replaces an existing placeholder with the same client ID", () => { + const sessionID = "ses_1" + const draft = { + message: { [sessionID]: [userMessage("msg_1", sessionID)] }, + part: {} as Record, + } + const replacement = { ...userMessage("msg_1", sessionID), time: { created: 2 } } + + applyOptimisticAdd(draft, { + sessionID, + message: replacement, + parts: [textPart("prt_1", sessionID, replacement.id)], + }) + + expect(draft.message[sessionID]).toEqual([replacement]) + expect(draft.part.msg_1?.map((part) => part.id)).toEqual(["prt_1"]) + }) + + test("applyOptimisticAdd does not restore a placeholder after its canonical steer arrives", () => { + const sessionID = "ses_1" + const draft = { + message: { + [sessionID]: [ + { + ...userMessage("msg_server", sessionID), + metadata: { + deepagent: { + promptAdmission: { + clientMessageID: "msg_client", + }, + }, + }, + }, + ], + }, + part: { + msg_client: [textPart("prt_client", sessionID, "msg_client")], + } as Record, + } + + applyOptimisticAdd(draft, { + sessionID, + message: userMessage("msg_client", sessionID), + parts: [textPart("prt_client", sessionID, "msg_client")], + }) + + expect(draft.message[sessionID]?.map((message) => message.id)).toEqual(["msg_server"]) + expect(draft.part.msg_client).toBeUndefined() + }) + test("applyOptimisticRemove removes message and part entries", () => { const sessionID = "ses_1" const draft = { @@ -120,4 +170,35 @@ describe("sync optimistic reducers", () => { { id: "prt_2", type: "text", text: "prt_2" }, ]) }) + + test("mergeOptimisticPage replaces a client placeholder with its canonical steer", () => { + const sessionID = "ses_1" + const canonical = { + ...userMessage("msg_server", sessionID), + metadata: { + deepagent: { + promptAdmission: { + clientMessageID: "msg_client", + }, + }, + }, + } + const page = mergeOptimisticPage( + { + session: [canonical], + part: [{ id: canonical.id, part: [textPart("prt_server", sessionID, canonical.id)] }], + complete: true, + }, + [ + { + message: userMessage("msg_client", sessionID), + parts: [textPart("prt_client", sessionID, "msg_client")], + }, + ], + ) + + expect(page.session.map((message) => message.id)).toEqual(["msg_server"]) + expect(page.part.map((item) => item.id)).toEqual(["msg_server"]) + expect(page.confirmed).toEqual(["msg_client"]) + }) }) diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index febc5464..bdde4a19 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -1,112 +1,7 @@ -import { Binary } from "@deepagent-code/core/util/binary" import { useServerSync } from "./server-sync" import { useSDK } from "./sdk" -import type { Message, Part } from "@deepagent-code/sdk/v2/client" -const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) - -function sortParts(parts: Part[]) { - return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)) -} - -const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) - -type OptimisticStore = { - message: Record - part: Record -} - -type OptimisticAddInput = { - sessionID: string - message: Message - parts: Part[] -} - -type OptimisticRemoveInput = { - sessionID: string - messageID: string -} - -type OptimisticItem = { - message: Message - parts: Part[] -} - -type MessagePage = { - session: Message[] - part: { id: string; part: Part[] }[] - cursor?: string - complete: boolean -} - -const hasParts = (parts: Part[] | undefined, want: Part[]) => { - if (!parts) return want.length === 0 - return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found) -} - -const mergeParts = (parts: Part[] | undefined, want: Part[]) => { - if (!parts) return sortParts(want) - const next = [...parts] - let changed = false - for (const part of want) { - const result = Binary.search(next, part.id, (item) => item.id) - if (result.found) continue - next.splice(result.index, 0, part) - changed = true - } - if (!changed) return parts - return next -} - -export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) { - if (items.length === 0) return { ...page, confirmed: [] as string[] } - - const session = [...page.session] - const part = new Map(page.part.map((item) => [item.id, sortParts(item.part)])) - const confirmed: string[] = [] - - for (const item of items) { - const result = Binary.search(session, item.message.id, (message) => message.id) - const found = result.found - if (!found) session.splice(result.index, 0, item.message) - - const current = part.get(item.message.id) - if (found && hasParts(current, item.parts)) { - confirmed.push(item.message.id) - continue - } - - part.set(item.message.id, mergeParts(current, item.parts)) - } - - return { - cursor: page.cursor, - complete: page.complete, - session, - part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, part]) => ({ id, part })), - confirmed, - } -} - -export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) { - const messages = draft.message[input.sessionID] - if (messages) { - const result = Binary.search(messages, input.message.id, (m) => m.id) - messages.splice(result.index, 0, input.message) - } else { - draft.message[input.sessionID] = [input.message] - } - draft.part[input.message.id] = sortParts(input.parts) -} - -export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) { - const messages = draft.message[input.sessionID] - if (messages) { - const result = Binary.search(messages, input.messageID, (m) => m.id) - if (result.found) messages.splice(result.index, 1) - } - delete draft.part[input.messageID] -} +export { applyOptimisticAdd, applyOptimisticRemove, mergeOptimisticPage } from "./directory-sync" export const useSync = () => { const serverSync = useServerSync() diff --git a/packages/app/src/pages/session/helpers.test.ts b/packages/app/src/pages/session/helpers.test.ts index 3ecab203..91be067c 100644 --- a/packages/app/src/pages/session/helpers.test.ts +++ b/packages/app/src/pages/session/helpers.test.ts @@ -84,6 +84,54 @@ describe("createForkAction", () => { expect(opened).toBe(Sentinel) }) + + test("reuses the fork intent after a failed request and rotates it after success", async () => { + const intents: string[] = [] + let attempt = 0 + const fork = createForkAction({ + open: () => {}, + messages: () => [{ id: "msg_1" }, { id: "msg_2" }], + fork: (input) => { + intents.push(input.intentID ?? "") + attempt++ + if (attempt === 1) return Promise.reject(new Error("response lost")) + return Promise.resolve({ id: `ses_fork_${attempt}` }) + }, + navigate: () => {}, + onError: () => {}, + }) + + await fork({ sessionID: "ses_1", messageID: "msg_1" }) + await fork({ sessionID: "ses_1", messageID: "msg_1" }) + await fork({ sessionID: "ses_1", messageID: "msg_1" }) + + expect(intents[0]).toBeTruthy() + expect(intents[1]).toBe(intents[0]) + expect(intents[2]).not.toBe(intents[1]) + }) + + test("coalesces duplicate clicks while a fork request is in flight", async () => { + let calls = 0 + let complete: ((session: { id: string }) => void) | undefined + const fork = createForkAction({ + open: () => {}, + messages: () => [{ id: "msg_1" }], + fork: () => { + calls++ + return new Promise<{ id: string }>((resolve) => { + complete = resolve + }) + }, + navigate: () => {}, + }) + + const first = fork({ sessionID: "ses_1", messageID: "msg_1" }) + const second = fork({ sessionID: "ses_1", messageID: "msg_1" }) + expect(calls).toBe(1) + expect(second).toBe(first) + complete!({ id: "ses_fork" }) + await Promise.all([first, second]) + }) }) describe("forkCutoffMessageID", () => { diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts index 29736715..10027335 100644 --- a/packages/app/src/pages/session/helpers.ts +++ b/packages/app/src/pages/session/helpers.ts @@ -3,6 +3,7 @@ import { createStore } from "solid-js/store" import { makeEventListener } from "@solid-primitives/event-listener" import type { Part, UserMessage } from "@deepagent-code/sdk/v2" import { same } from "@/utils/same" +import { Identifier } from "@/utils/id" export type TurnPreview = { title?: string; body?: string } @@ -54,32 +55,45 @@ export const forkCutoffMessageID = (messages: { id: string }[], messageID: strin * `messageID` as the first message NOT copied, so the UI passes the next * message as the cutoff; no next message means a full-history fork. */ -export const createForkAction = - (deps: { - open: (component: DialogForkComponent) => void - loadDialog?: () => Promise<{ DialogFork: DialogForkComponent }> - messages?: (sessionID: string) => { id: string }[] - fork?: (input: { sessionID: string; messageID?: string }) => Promise<{ id: string } | undefined> - navigate?: (sessionID: string) => void - onError?: (error: unknown) => void - }) => - (input?: { sessionID: string; messageID: string }) => { - if (input && deps.messages && deps.fork && deps.navigate) { - const navigate = deps.navigate - return deps - .fork({ - sessionID: input.sessionID, - messageID: forkCutoffMessageID(deps.messages(input.sessionID), input.messageID), - }) - .then((session) => { - if (session) navigate(session.id) - }) - .catch((error: unknown) => deps.onError?.(error)) - } +export const createForkAction = (deps: { + open: (component: DialogForkComponent) => void + loadDialog?: () => Promise<{ DialogFork: DialogForkComponent }> + messages?: (sessionID: string) => { id: string }[] + fork?: (input: { sessionID: string; messageID?: string; intentID: string }) => Promise<{ id: string } | undefined> + navigate?: (sessionID: string) => void + onError?: (error: unknown) => void +}) => + (() => { + const pendingIntents = new Map() + const pendingRequests = new Map>() + return (input?: { sessionID: string; messageID: string }) => { + if (input && deps.messages && deps.fork && deps.navigate) { + const intentKey = `${input.sessionID}:${input.messageID}` + const pending = pendingRequests.get(intentKey) + if (pending) return pending + const intentID = pendingIntents.get(intentKey) ?? Identifier.ascending("fork") + pendingIntents.set(intentKey, intentID) + const request = deps + .fork({ + sessionID: input.sessionID, + messageID: forkCutoffMessageID(deps.messages(input.sessionID), input.messageID), + intentID, + }) + .then((session) => { + if (!session) return + pendingIntents.delete(intentKey) + deps.navigate!(session.id) + }) + .catch((error: unknown) => deps.onError?.(error)) + .finally(() => pendingRequests.delete(intentKey)) + pendingRequests.set(intentKey, request) + return request + } - const load = deps.loadDialog ?? (() => import("@/components/dialog-fork")) - return load().then((mod) => deps.open(mod.DialogFork)) - } + const load = deps.loadDialog ?? (() => import("@/components/dialog-fork")) + return load().then((mod) => deps.open(mod.DialogFork)) + } + })() type DialogForkComponent = Component diff --git a/packages/app/src/pages/session/message-timeline.data.test.ts b/packages/app/src/pages/session/message-timeline.data.test.ts new file mode 100644 index 00000000..60803334 --- /dev/null +++ b/packages/app/src/pages/session/message-timeline.data.test.ts @@ -0,0 +1,41 @@ +import { afterAll, describe, expect, mock, test } from "bun:test" +import type { Part, UserMessage } from "@deepagent-code/sdk/v2/client" + +mock.module("@deepagent-code/ui/message-part", () => ({ + groupParts: () => [], + renderable: () => false, +})) + +afterAll(() => mock.restore()) + +describe("message timeline compaction", () => { + test("renders a compaction divider for a manual compact marker", async () => { + const { Timeline } = await import("./message-timeline.data") + const message = { + id: "msg_compact", + sessionID: "ses_1", + role: "user", + agent: "build", + model: { providerID: "deepseek", modelID: "deepseek-chat" }, + time: { created: 1 }, + } as UserMessage + const part = { + id: "prt_compact", + sessionID: message.sessionID, + messageID: message.id, + type: "compaction", + auto: false, + context_tokens: 4_000, + } as Part + + const rows = Timeline.constructMessageRows(message, () => [part], [], 1, false, "idle", false) + + expect(rows).toContainEqual( + expect.objectContaining({ + _tag: "TurnDivider", + userMessageID: message.id, + label: "compaction", + }), + ) + }) +}) diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index 4eb4a98f..140adce3 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -18,6 +18,7 @@ import { extractPromptFromParts } from "@/utils/prompt" import { UserMessage } from "@deepagent-code/sdk/v2" import { useSessionLayout } from "@/pages/session/session-layout" import { errorMessage } from "@/pages/layout/helpers" +import { Identifier } from "@/utils/id" export type SessionCommandContext = { navigateMessageByOffset: (offset: number) => void @@ -46,6 +47,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const terminalHosts = useTerminalHosts() const layout = useLayout() const navigate = useNavigate() + const forkIntents = new Map() const { params, tabs, view } = useSessionLayout() const info = () => { @@ -362,9 +364,11 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const fork = async () => { const sessionID = params.id if (!sessionID) return + const intentID = forkIntents.get(sessionID) ?? Identifier.ascending("fork") + forkIntents.set(sessionID, intentID) const forked = await sdk.client.session - .fork({ sessionID }) + .fork({ sessionID, intentID }) .then((x) => x.data) .catch((err) => { showToast({ @@ -374,6 +378,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { return undefined }) if (!forked) return + forkIntents.delete(sessionID) local.session.promote(sdk.directory, forked.id) layout.handoff.setTabs(local.slug(), forked.id) @@ -578,8 +583,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { // Phase 3: terminal commands route to the bottom host. The side terminal is // opened from the right-panel rail (session-side-panel.tsx) rather than commands. - const bottomTerminalOpen = () => - view().panel.bottom.opened() && view().panel.bottom.activeView() === "terminal" + const bottomTerminalOpen = () => view().panel.bottom.opened() && view().panel.bottom.activeView() === "terminal" const sideTerminalOpen = () => view().rightPanel.mode() === "terminal" const terminalOpen = () => bottomTerminalOpen() || sideTerminalOpen() // Keybind commands operate on the bottom terminal session. diff --git a/packages/app/src/utils/id.ts b/packages/app/src/utils/id.ts index dba7a8d9..e1957061 100644 --- a/packages/app/src/utils/id.ts +++ b/packages/app/src/utils/id.ts @@ -5,6 +5,7 @@ const prefixes = { user: "usr", part: "prt", pty: "pty", + fork: "fork", } as const const LENGTH = 26 diff --git a/packages/core/migration/20260810150000_provider_receipt_authority/migration.sql b/packages/core/migration/20260810150000_provider_receipt_authority/migration.sql new file mode 100644 index 00000000..dc91c0f9 --- /dev/null +++ b/packages/core/migration/20260810150000_provider_receipt_authority/migration.sql @@ -0,0 +1,3 @@ +-- Schema snapshot only. Runtime changes are applied atomically by the +-- TypeScript migrations registered through 20260810150000. +SELECT 1; diff --git a/packages/core/migration/20260810150000_provider_receipt_authority/snapshot.json b/packages/core/migration/20260810150000_provider_receipt_authority/snapshot.json new file mode 100644 index 00000000..0614d8fd --- /dev/null +++ b/packages/core/migration/20260810150000_provider_receipt_authority/snapshot.json @@ -0,0 +1,6916 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "e9b23cb2-13f1-48e2-871c-2abd9740184c", + "prevIds": [ + "d1bfa125-b81e-4c61-9b6e-e74abf6e488f" + ], + "ddl": [ + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "data_migration", + "entityType": "tables" + }, + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "context_location_identity_alias", + "entityType": "tables" + }, + { + "name": "context_location_identity", + "entityType": "tables" + }, + { + "name": "location_index_coordination", + "entityType": "tables" + }, + { + "name": "context_project_scope_identity_alias", + "entityType": "tables" + }, + { + "name": "context_project_scope_identity", + "entityType": "tables" + }, + { + "name": "context_security_namespace", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "name": "im_attachments", + "entityType": "tables" + }, + { + "name": "im_groups", + "entityType": "tables" + }, + { + "name": "im_members", + "entityType": "tables" + }, + { + "name": "im_messages", + "entityType": "tables" + }, + { + "name": "location_change_event", + "entityType": "tables" + }, + { + "name": "location_projection_dirty_path", + "entityType": "tables" + }, + { + "name": "location_projection_registration", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "project_directory", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "session_context_epoch", + "entityType": "tables" + }, + { + "name": "session_fork_admission", + "entityType": "tables" + }, + { + "name": "session_fork_intent", + "entityType": "tables" + }, + { + "name": "session_history_state", + "entityType": "tables" + }, + { + "name": "session_input", + "entityType": "tables" + }, + { + "name": "session_intent", + "entityType": "tables" + }, + { + "name": "session_message", + "entityType": "tables" + }, + { + "name": "session_prompt_epoch_message", + "entityType": "tables" + }, + { + "name": "session_steer", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "session_world_state_baseline", + "entityType": "tables" + }, + { + "name": "task_admission", + "entityType": "tables" + }, + { + "name": "task_notification_outbox", + "entityType": "tables" + }, + { + "name": "task_run_event", + "entityType": "tables" + }, + { + "name": "task_run", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_used", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "data_migration" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_completed", + "entityType": "columns", + "table": "data_migration" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "security_namespace_id", + "entityType": "columns", + "table": "context_location_identity_alias" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "old_canonical_root", + "entityType": "columns", + "table": "context_location_identity_alias" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "location_key", + "entityType": "columns", + "table": "context_location_identity_alias" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "reason", + "entityType": "columns", + "table": "context_location_identity_alias" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "context_location_identity_alias" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "security_namespace_id", + "entityType": "columns", + "table": "context_location_identity" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "location_key", + "entityType": "columns", + "table": "context_location_identity" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_scope_key", + "entityType": "columns", + "table": "context_location_identity" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_binding", + "entityType": "columns", + "table": "context_location_identity" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "canonical_root", + "entityType": "columns", + "table": "context_location_identity" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "observed_project_id", + "entityType": "columns", + "table": "context_location_identity" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "context_location_identity" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "retired_at", + "entityType": "columns", + "table": "context_location_identity" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "security_namespace_id", + "entityType": "columns", + "table": "location_index_coordination" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "location_key", + "entityType": "columns", + "table": "location_index_coordination" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "index_space_id", + "entityType": "columns", + "table": "location_index_coordination" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "projection_kind", + "entityType": "columns", + "table": "location_index_coordination" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "index_incarnation", + "entityType": "columns", + "table": "location_index_coordination" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "db_locator", + "entityType": "columns", + "table": "location_index_coordination" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "owner_id", + "entityType": "columns", + "table": "location_index_coordination" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "fencing_token", + "entityType": "columns", + "table": "location_index_coordination" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expires_at", + "entityType": "columns", + "table": "location_index_coordination" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "replacement_state", + "entityType": "columns", + "table": "location_index_coordination" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "location_index_coordination" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "security_namespace_id", + "entityType": "columns", + "table": "context_project_scope_identity_alias" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "old_project_identity_hash", + "entityType": "columns", + "table": "context_project_scope_identity_alias" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_scope_key", + "entityType": "columns", + "table": "context_project_scope_identity_alias" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "reason", + "entityType": "columns", + "table": "context_project_scope_identity_alias" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "context_project_scope_identity_alias" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "security_namespace_id", + "entityType": "columns", + "table": "context_project_scope_identity" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_scope_key", + "entityType": "columns", + "table": "context_project_scope_identity" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_kind", + "entityType": "columns", + "table": "context_project_scope_identity" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_identity_hash", + "entityType": "columns", + "table": "context_project_scope_identity" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "observed_project_id", + "entityType": "columns", + "table": "context_project_scope_identity" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "context_project_scope_identity" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "retired_at", + "entityType": "columns", + "table": "context_project_scope_identity" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "context_security_namespace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "kind", + "entityType": "columns", + "table": "context_security_namespace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "binding_hash", + "entityType": "columns", + "table": "context_security_namespace" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "context_security_namespace" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "retired_at", + "entityType": "columns", + "table": "context_security_namespace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "owner_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "im_attachments" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "im_attachments" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "im_attachments" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "group_id", + "entityType": "columns", + "table": "im_attachments" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "im_attachments" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "uploaded_by", + "entityType": "columns", + "table": "im_attachments" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "storage_path", + "entityType": "columns", + "table": "im_attachments" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "filename", + "entityType": "columns", + "table": "im_attachments" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "mime", + "entityType": "columns", + "table": "im_attachments" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "size_bytes", + "entityType": "columns", + "table": "im_attachments" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "checksum", + "entityType": "columns", + "table": "im_attachments" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "im_attachments" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "deleted_at", + "entityType": "columns", + "table": "im_attachments" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "im_groups" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "im_groups" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "im_groups" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "im_groups" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "im_groups" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_by", + "entityType": "columns", + "table": "im_groups" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "im_groups" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "im_groups" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "deleted_at", + "entityType": "columns", + "table": "im_groups" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "group_id", + "entityType": "columns", + "table": "im_members" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "member_id", + "entityType": "columns", + "table": "im_members" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "member_type", + "entityType": "columns", + "table": "im_members" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "role", + "entityType": "columns", + "table": "im_members" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_read_at", + "entityType": "columns", + "table": "im_members" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "joined_at", + "entityType": "columns", + "table": "im_members" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "im_messages" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "group_id", + "entityType": "columns", + "table": "im_messages" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sender_id", + "entityType": "columns", + "table": "im_messages" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sender_type", + "entityType": "columns", + "table": "im_messages" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "im_messages" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "im_messages" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "mentions", + "entityType": "columns", + "table": "im_messages" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "metadata", + "entityType": "columns", + "table": "im_messages" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "reply_to_id", + "entityType": "columns", + "table": "im_messages" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "event_id", + "entityType": "columns", + "table": "im_messages" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "delivery_status", + "entityType": "columns", + "table": "im_messages" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "im_messages" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "im_messages" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "deleted_at", + "entityType": "columns", + "table": "im_messages" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": true, + "default": null, + "generated": null, + "name": "event_seq", + "entityType": "columns", + "table": "location_change_event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "index_space_id", + "entityType": "columns", + "table": "location_change_event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "path", + "entityType": "columns", + "table": "location_change_event" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "previous_path", + "entityType": "columns", + "table": "location_change_event" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "rename_correlation_id", + "entityType": "columns", + "table": "location_change_event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "change_kind", + "entityType": "columns", + "table": "location_change_event" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "observed_mtime_ns", + "entityType": "columns", + "table": "location_change_event" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "observed_sha", + "entityType": "columns", + "table": "location_change_event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "source", + "entityType": "columns", + "table": "location_change_event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "observed_at", + "entityType": "columns", + "table": "location_change_event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "index_space_id", + "entityType": "columns", + "table": "location_projection_dirty_path" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "projection_kind", + "entityType": "columns", + "table": "location_projection_dirty_path" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "path", + "entityType": "columns", + "table": "location_projection_dirty_path" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "latest_event_seq", + "entityType": "columns", + "table": "location_projection_dirty_path" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "previous_path", + "entityType": "columns", + "table": "location_projection_dirty_path" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "rename_correlation_id", + "entityType": "columns", + "table": "location_projection_dirty_path" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "change_kind", + "entityType": "columns", + "table": "location_projection_dirty_path" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "observed_mtime_ns", + "entityType": "columns", + "table": "location_projection_dirty_path" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "observed_sha", + "entityType": "columns", + "table": "location_projection_dirty_path" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "location_projection_dirty_path" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "index_space_id", + "entityType": "columns", + "table": "location_projection_registration" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "projection_kind", + "entityType": "columns", + "table": "location_projection_registration" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "registration_epoch", + "entityType": "columns", + "table": "location_projection_registration" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "state", + "entityType": "columns", + "table": "location_projection_registration" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "consumed_event_seq", + "entityType": "columns", + "table": "location_projection_registration" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "reconcile_required", + "entityType": "columns", + "table": "location_projection_registration" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "location_projection_registration" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "action", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "resource", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "project_directory" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "project_directory" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "project_directory" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project_directory" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url_override", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "provenance", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "baseline", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'auto'", + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "snapshot", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "baseline_seq", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "replacement_seq", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "revision", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "intent_id", + "entityType": "columns", + "table": "session_fork_admission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "request_hash", + "entityType": "columns", + "table": "session_fork_admission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "fork_mode", + "entityType": "columns", + "table": "session_fork_admission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "source_session_id", + "entityType": "columns", + "table": "session_fork_admission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "source_prompt_epoch", + "entityType": "columns", + "table": "session_fork_admission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "source_window_id", + "entityType": "columns", + "table": "session_fork_admission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "source_effective_history_hash", + "entityType": "columns", + "table": "session_fork_admission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "source_mutation_epoch", + "entityType": "columns", + "table": "session_fork_admission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "source_message_count", + "entityType": "columns", + "table": "session_fork_admission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "source_cutoff_message_id", + "entityType": "columns", + "table": "session_fork_admission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "projection_version", + "entityType": "columns", + "table": "session_fork_admission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sanitation_policy_version", + "entityType": "columns", + "table": "session_fork_admission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "requested_directory", + "entityType": "columns", + "table": "session_fork_admission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "isolation_mode", + "entityType": "columns", + "table": "session_fork_admission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "requested_target_session_id", + "entityType": "columns", + "table": "session_fork_admission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "target_session_id", + "entityType": "columns", + "table": "session_fork_admission" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "child_depth", + "entityType": "columns", + "table": "session_fork_admission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "task_request_hash", + "entityType": "columns", + "table": "session_fork_admission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree_directory", + "entityType": "columns", + "table": "session_fork_admission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree_branch", + "entityType": "columns", + "table": "session_fork_admission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree_base_commit", + "entityType": "columns", + "table": "session_fork_admission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "state", + "entityType": "columns", + "table": "session_fork_admission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "recovery_reason", + "entityType": "columns", + "table": "session_fork_admission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_fork_admission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_fork_admission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "intent_id", + "entityType": "columns", + "table": "session_fork_intent" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "request_hash", + "entityType": "columns", + "table": "session_fork_intent" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "fork_mode", + "entityType": "columns", + "table": "session_fork_intent" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "source_session_id", + "entityType": "columns", + "table": "session_fork_intent" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "source_prompt_epoch", + "entityType": "columns", + "table": "session_fork_intent" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "source_window_id", + "entityType": "columns", + "table": "session_fork_intent" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "source_effective_history_hash", + "entityType": "columns", + "table": "session_fork_intent" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "source_mutation_epoch", + "entityType": "columns", + "table": "session_fork_intent" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "source_message_count", + "entityType": "columns", + "table": "session_fork_intent" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "source_cutoff_message_id", + "entityType": "columns", + "table": "session_fork_intent" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "projection_version", + "entityType": "columns", + "table": "session_fork_intent" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sanitation_policy_version", + "entityType": "columns", + "table": "session_fork_intent" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "target_session_id", + "entityType": "columns", + "table": "session_fork_intent" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "target_prompt_epoch", + "entityType": "columns", + "table": "session_fork_intent" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "target_window_id", + "entityType": "columns", + "table": "session_fork_intent" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "target_effective_history_hash", + "entityType": "columns", + "table": "session_fork_intent" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "target_world_state_baseline_hash", + "entityType": "columns", + "table": "session_fork_intent" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "cloned_message_count", + "entityType": "columns", + "table": "session_fork_intent" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "cloned_part_count", + "entityType": "columns", + "table": "session_fork_intent" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "state", + "entityType": "columns", + "table": "session_fork_intent" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "event_cursor", + "entityType": "columns", + "table": "session_fork_intent" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "event_count", + "entityType": "columns", + "table": "session_fork_intent" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "delivery_owner", + "entityType": "columns", + "table": "session_fork_intent" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lease_expires_at", + "entityType": "columns", + "table": "session_fork_intent" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "delivery_attempts", + "entityType": "columns", + "table": "session_fork_intent" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "recovery_reason", + "entityType": "columns", + "table": "session_fork_intent" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_fork_intent" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_fork_intent" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_committed", + "entityType": "columns", + "table": "session_fork_intent" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_completed", + "entityType": "columns", + "table": "session_fork_intent" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "side_effects_completed_at", + "entityType": "columns", + "table": "session_fork_intent" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_history_state" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "state", + "entityType": "columns", + "table": "session_history_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "reason", + "entityType": "columns", + "table": "session_history_state" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_history_state" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_history_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "prompt", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "delivery", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "admitted_seq", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "promoted_seq", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "intent_id", + "entityType": "columns", + "table": "session_intent" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_intent" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "source", + "entityType": "columns", + "table": "session_intent" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "state", + "entityType": "columns", + "table": "session_intent" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "selected_variant", + "entityType": "columns", + "table": "session_intent" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "selected_payload_hash", + "entityType": "columns", + "table": "session_intent" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "delivery", + "entityType": "columns", + "table": "session_intent" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "admitted_message_id", + "entityType": "columns", + "table": "session_intent" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "correlation_id", + "entityType": "columns", + "table": "session_intent" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "owner_token", + "entityType": "columns", + "table": "session_intent" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lease_expires_at", + "entityType": "columns", + "table": "session_intent" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "mutation_epoch", + "entityType": "columns", + "table": "session_intent" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session_intent" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_intent" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_selected", + "entityType": "columns", + "table": "session_intent" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_admitted", + "entityType": "columns", + "table": "session_intent" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_intent" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_prompt_epoch_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "prompt_epoch", + "entityType": "columns", + "table": "session_prompt_epoch_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ordinal", + "entityType": "columns", + "table": "session_prompt_epoch_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "session_prompt_epoch_message" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": true, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "session_steer" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_steer" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_steer" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "correlation_id", + "entityType": "columns", + "table": "session_steer" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "prompt", + "entityType": "columns", + "table": "session_steer" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "delivery", + "entityType": "columns", + "table": "session_steer" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "mutation_epoch", + "entityType": "columns", + "table": "session_steer" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "consumed_seq", + "entityType": "columns", + "table": "session_steer" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "superseded_at", + "entityType": "columns", + "table": "session_steer" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_steer" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "materialized_at", + "entityType": "columns", + "table": "session_steer" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "path", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "metadata", + "entityType": "columns", + "table": "session" + }, + { + "type": "real", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "cost", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_input", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_output", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_reasoning", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_cache_read", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_cache_write", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "mutation_epoch", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_suspended", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "preview", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_world_state_baseline" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "prompt_epoch", + "entityType": "columns", + "table": "session_world_state_baseline" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "section_id", + "entityType": "columns", + "table": "session_world_state_baseline" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "snapshot", + "entityType": "columns", + "table": "session_world_state_baseline" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "fragment", + "entityType": "columns", + "table": "session_world_state_baseline" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "fragment_hash", + "entityType": "columns", + "table": "session_world_state_baseline" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "provenance", + "entityType": "columns", + "table": "session_world_state_baseline" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "session_world_state_baseline" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "admission_key", + "entityType": "columns", + "table": "task_admission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "request_hash", + "entityType": "columns", + "table": "task_admission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "run_id", + "entityType": "columns", + "table": "task_admission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_session_id", + "entityType": "columns", + "table": "task_admission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_message_id", + "entityType": "columns", + "table": "task_admission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "tool_call_id", + "entityType": "columns", + "table": "task_admission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "delivery_mode", + "entityType": "columns", + "table": "task_admission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "task_admission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "task_notification_outbox" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "run_id", + "entityType": "columns", + "table": "task_notification_outbox" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "task_notification_outbox" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_session_id", + "entityType": "columns", + "table": "task_notification_outbox" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "task_notification_outbox" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "payload", + "entityType": "columns", + "table": "task_notification_outbox" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "task_notification_outbox" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "attempts", + "entityType": "columns", + "table": "task_notification_outbox" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "available_at", + "entityType": "columns", + "table": "task_notification_outbox" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lease_owner", + "entityType": "columns", + "table": "task_notification_outbox" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lease_expires_at", + "entityType": "columns", + "table": "task_notification_outbox" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_error", + "entityType": "columns", + "table": "task_notification_outbox" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "task_notification_outbox" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "task_notification_outbox" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_delivered", + "entityType": "columns", + "table": "task_notification_outbox" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'terminal'", + "generated": null, + "name": "event_kind", + "entityType": "columns", + "table": "task_notification_outbox" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "correlation_id", + "entityType": "columns", + "table": "task_notification_outbox" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "payload_hash", + "entityType": "columns", + "table": "task_notification_outbox" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_input_message_id", + "entityType": "columns", + "table": "task_notification_outbox" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "response_message_id", + "entityType": "columns", + "table": "task_notification_outbox" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "response_started_at", + "entityType": "columns", + "table": "task_notification_outbox" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_admitted", + "entityType": "columns", + "table": "task_notification_outbox" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "event_id", + "entityType": "columns", + "table": "task_run_event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "run_id", + "entityType": "columns", + "table": "task_run_event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "task_run_event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "task_run_event" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "from_state", + "entityType": "columns", + "table": "task_run_event" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "to_state", + "entityType": "columns", + "table": "task_run_event" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "reason", + "entityType": "columns", + "table": "task_run_event" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "task_run_event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "task_run_event" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "run_id", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "root_run_id", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "request_hash", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_session_id", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_message_id", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "tool_call_id", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "child_session_id", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "generation", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "delivery_mode", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "phase", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "state", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "reason", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "attempts", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "execution_owner", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lease_expires_at", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "raw_result_message_id", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "structured_result_message_id", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "output", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "error", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_settled", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_run_id", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "continuation_of_run_id", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "1", + "generated": null, + "name": "depth", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'task_tool'", + "generated": null, + "name": "origin_kind", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "origin_key", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'foreground'", + "generated": null, + "name": "effective_delivery_mode", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "promoted_at", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'new'", + "generated": null, + "name": "session_mode", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'fresh'", + "generated": null, + "name": "context_mode", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "context_cutoff_message_id", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'write'", + "generated": null, + "name": "mutation_capability", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'legacy-unknown'", + "generated": null, + "name": "tool_capability_hash", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'shared'", + "generated": null, + "name": "workspace_mode", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'parent'", + "generated": null, + "name": "workspace_owner", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'live'", + "generated": null, + "name": "workspace_visibility", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'allow_live'", + "generated": null, + "name": "parent_dirty_policy", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_operation_key", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_revision", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "execution_spec", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "version", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'open'", + "generated": null, + "name": "control_state", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'legacy'", + "generated": null, + "name": "input_state", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "child_message_id", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "input_admission_started_at", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "child_input_materialized_hash", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "child_input_part_count", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "execution_started_at", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "finalizer_started_at", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "interrupt_requested_at", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "interrupt_reason", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "close_requested_at", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "close_reason", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "claim_generation", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "start_attempts", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "available_at", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "queue_reason", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'legacy'", + "generated": null, + "name": "workspace_preflight_state", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_preflight_at", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_repository_root", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_base_commit", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_parent_branch", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_target_branch", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_status_hash", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_preflight_error_code", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'none'", + "generated": null, + "name": "workspace_branch_state", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_branch_started_at", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree_directory", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree_branch", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'none'", + "generated": null, + "name": "worktree_state", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree_started_at", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "pr_operation_key", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "pr_started_at", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "pr_id", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "goal_id", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "goal_tick_seq", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "goal_role", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "goal_ordinal", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "result_hash", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "usage", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "progress_seq", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_progress_at", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "finalizer_input_message_id", + "entityType": "columns", + "table": "task_run" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": [ + "active_account_id" + ], + "tableTo": "account", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": [ + "security_namespace_id" + ], + "tableTo": "context_security_namespace", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "nameExplicit": false, + "name": "fk_context_location_identity_alias_security_namespace_id_context_security_namespace_id_fk", + "entityType": "fks", + "table": "context_location_identity_alias" + }, + { + "columns": [ + "security_namespace_id" + ], + "tableTo": "context_security_namespace", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "nameExplicit": false, + "name": "fk_context_location_identity_security_namespace_id_context_security_namespace_id_fk", + "entityType": "fks", + "table": "context_location_identity" + }, + { + "columns": [ + "security_namespace_id" + ], + "tableTo": "context_security_namespace", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "nameExplicit": false, + "name": "fk_context_project_scope_identity_alias_security_namespace_id_context_security_namespace_id_fk", + "entityType": "fks", + "table": "context_project_scope_identity_alias" + }, + { + "columns": [ + "security_namespace_id" + ], + "tableTo": "context_security_namespace", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "nameExplicit": false, + "name": "fk_context_project_scope_identity_security_namespace_id_context_security_namespace_id_fk", + "entityType": "fks", + "table": "context_project_scope_identity" + }, + { + "columns": [ + "aggregate_id" + ], + "tableTo": "event_sequence", + "columnsTo": [ + "aggregate_id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_im_attachments_project_id_project_id_fk", + "entityType": "fks", + "table": "im_attachments" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_im_groups_project_id_project_id_fk", + "entityType": "fks", + "table": "im_groups" + }, + { + "columns": [ + "group_id" + ], + "tableTo": "im_groups", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_im_members_group_id_im_groups_id_fk", + "entityType": "fks", + "table": "im_members" + }, + { + "columns": [ + "group_id" + ], + "tableTo": "im_groups", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_im_messages_group_id_im_groups_id_fk", + "entityType": "fks", + "table": "im_messages" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_project_directory_project_id_project_id_fk", + "entityType": "fks", + "table": "project_directory" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": [ + "message_id" + ], + "tableTo": "message", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_context_epoch_session_id_session_id_fk", + "entityType": "fks", + "table": "session_context_epoch" + }, + { + "columns": [ + "source_session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_fork_admission_source_session_id_session_id_fk", + "entityType": "fks", + "table": "session_fork_admission" + }, + { + "columns": [ + "source_session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_fork_intent_source_session_id_session_id_fk", + "entityType": "fks", + "table": "session_fork_intent" + }, + { + "columns": [ + "target_session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_fork_intent_target_session_id_session_id_fk", + "entityType": "fks", + "table": "session_fork_intent" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_history_state_session_id_session_id_fk", + "entityType": "fks", + "table": "session_history_state" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_input_session_id_session_id_fk", + "entityType": "fks", + "table": "session_input" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_intent_session_id_session_id_fk", + "entityType": "fks", + "table": "session_intent" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_message_session_id_session_id_fk", + "entityType": "fks", + "table": "session_message" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_prompt_epoch_message_session_id_session_id_fk", + "entityType": "fks", + "table": "session_prompt_epoch_message" + }, + { + "columns": [ + "message_id" + ], + "tableTo": "message", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_prompt_epoch_message_message_id_message_id_fk", + "entityType": "fks", + "table": "session_prompt_epoch_message" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_steer_session_id_session_id_fk", + "entityType": "fks", + "table": "session_steer" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_world_state_baseline_session_id_session_id_fk", + "entityType": "fks", + "table": "session_world_state_baseline" + }, + { + "columns": [ + "run_id" + ], + "tableTo": "task_run", + "columnsTo": [ + "run_id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_task_admission_run_id_task_run_run_id_fk", + "entityType": "fks", + "table": "task_admission" + }, + { + "columns": [ + "parent_session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_task_admission_parent_session_id_session_id_fk", + "entityType": "fks", + "table": "task_admission" + }, + { + "columns": [ + "run_id" + ], + "tableTo": "task_run", + "columnsTo": [ + "run_id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_task_notification_outbox_run_id_task_run_run_id_fk", + "entityType": "fks", + "table": "task_notification_outbox" + }, + { + "columns": [ + "parent_session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_task_notification_outbox_parent_session_id_session_id_fk", + "entityType": "fks", + "table": "task_notification_outbox" + }, + { + "columns": [ + "run_id" + ], + "tableTo": "task_run", + "columnsTo": [ + "run_id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_task_run_event_run_id_task_run_run_id_fk", + "entityType": "fks", + "table": "task_run_event" + }, + { + "columns": [ + "parent_session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_task_run_parent_session_id_session_id_fk", + "entityType": "fks", + "table": "task_run" + }, + { + "columns": [ + "parent_run_id" + ], + "tableTo": "task_run", + "columnsTo": [ + "run_id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_task_run_parent_run_id_task_run_run_id_fk", + "entityType": "fks", + "table": "task_run" + }, + { + "columns": [ + "continuation_of_run_id" + ], + "tableTo": "task_run", + "columnsTo": [ + "run_id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_task_run_continuation_of_run_id_task_run_run_id_fk", + "entityType": "fks", + "table": "task_run" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": [ + "email", + "url" + ], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": [ + "security_namespace_id", + "old_canonical_root" + ], + "nameExplicit": false, + "name": "context_location_identity_alias_pk", + "entityType": "pks", + "table": "context_location_identity_alias" + }, + { + "columns": [ + "security_namespace_id", + "location_key" + ], + "nameExplicit": false, + "name": "context_location_identity_pk", + "entityType": "pks", + "table": "context_location_identity" + }, + { + "columns": [ + "index_space_id", + "projection_kind" + ], + "nameExplicit": false, + "name": "location_index_coordination_pk", + "entityType": "pks", + "table": "location_index_coordination" + }, + { + "columns": [ + "security_namespace_id", + "old_project_identity_hash" + ], + "nameExplicit": false, + "name": "context_project_scope_identity_alias_pk", + "entityType": "pks", + "table": "context_project_scope_identity_alias" + }, + { + "columns": [ + "security_namespace_id", + "project_scope_key" + ], + "nameExplicit": false, + "name": "context_project_scope_identity_pk", + "entityType": "pks", + "table": "context_project_scope_identity" + }, + { + "columns": [ + "index_space_id", + "projection_kind", + "path" + ], + "nameExplicit": false, + "name": "location_projection_dirty_path_pk", + "entityType": "pks", + "table": "location_projection_dirty_path" + }, + { + "columns": [ + "index_space_id", + "projection_kind" + ], + "nameExplicit": false, + "name": "location_projection_registration_pk", + "entityType": "pks", + "table": "location_projection_registration" + }, + { + "columns": [ + "project_id", + "directory" + ], + "nameExplicit": false, + "name": "project_directory_pk", + "entityType": "pks", + "table": "project_directory" + }, + { + "columns": [ + "session_id", + "prompt_epoch", + "ordinal" + ], + "nameExplicit": false, + "name": "session_prompt_epoch_message_pk", + "entityType": "pks", + "table": "session_prompt_epoch_message" + }, + { + "columns": [ + "session_id", + "prompt_epoch", + "section_id" + ], + "nameExplicit": false, + "name": "session_world_state_baseline_pk", + "entityType": "pks", + "table": "session_world_state_baseline" + }, + { + "columns": [ + "session_id", + "position" + ], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + "name" + ], + "nameExplicit": false, + "name": "data_migration_pk", + "table": "data_migration", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "context_security_namespace_pk", + "table": "context_security_namespace", + "entityType": "pks" + }, + { + "columns": [ + "aggregate_id" + ], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "im_attachments_pk", + "table": "im_attachments", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "im_groups_pk", + "table": "im_groups", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "im_messages_pk", + "table": "im_messages", + "entityType": "pks" + }, + { + "columns": [ + "event_seq" + ], + "nameExplicit": false, + "name": "location_change_event_pk", + "table": "location_change_event", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": [ + "session_id" + ], + "nameExplicit": false, + "name": "session_context_epoch_pk", + "table": "session_context_epoch", + "entityType": "pks" + }, + { + "columns": [ + "intent_id" + ], + "nameExplicit": false, + "name": "session_fork_admission_pk", + "table": "session_fork_admission", + "entityType": "pks" + }, + { + "columns": [ + "intent_id" + ], + "nameExplicit": false, + "name": "session_fork_intent_pk", + "table": "session_fork_intent", + "entityType": "pks" + }, + { + "columns": [ + "session_id" + ], + "nameExplicit": false, + "name": "session_history_state_pk", + "table": "session_history_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_input_pk", + "table": "session_input", + "entityType": "pks" + }, + { + "columns": [ + "intent_id" + ], + "nameExplicit": false, + "name": "session_intent_pk", + "table": "session_intent", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_message_pk", + "table": "session_message", + "entityType": "pks" + }, + { + "columns": [ + "seq" + ], + "nameExplicit": false, + "name": "session_steer_pk", + "table": "session_steer", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": [ + "admission_key" + ], + "nameExplicit": false, + "name": "task_admission_pk", + "table": "task_admission", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "task_notification_outbox_pk", + "table": "task_notification_outbox", + "entityType": "pks" + }, + { + "columns": [ + "event_id" + ], + "nameExplicit": false, + "name": "task_run_event_pk", + "table": "task_run_event", + "entityType": "pks" + }, + { + "columns": [ + "run_id" + ], + "nameExplicit": false, + "name": "task_run_pk", + "table": "task_run", + "entityType": "pks" + }, + { + "columns": [ + "session_id" + ], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "security_namespace_id", + "isExpression": false + }, + { + "value": "canonical_root", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "context_location_identity_root_idx", + "entityType": "indexes", + "table": "context_location_identity" + }, + { + "columns": [ + { + "value": "security_namespace_id", + "isExpression": false + }, + { + "value": "location_key", + "isExpression": false + }, + { + "value": "projection_kind", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "location_index_coordination_location_idx", + "entityType": "indexes", + "table": "location_index_coordination" + }, + { + "columns": [ + { + "value": "security_namespace_id", + "isExpression": false + }, + { + "value": "project_identity_hash", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "context_project_scope_identity_value_idx", + "entityType": "indexes", + "table": "context_project_scope_identity" + }, + { + "columns": [ + { + "value": "kind", + "isExpression": false + }, + { + "value": "binding_hash", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "context_security_namespace_binding_idx", + "entityType": "indexes", + "table": "context_security_namespace" + }, + { + "columns": [ + { + "value": "aggregate_id", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "event_aggregate_seq_idx", + "entityType": "indexes", + "table": "event" + }, + { + "columns": [ + { + "value": "aggregate_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "event_aggregate_type_seq_idx", + "entityType": "indexes", + "table": "event" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "created_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "idx_im_attachments_workspace", + "entityType": "indexes", + "table": "im_attachments" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "idx_im_attachments_message", + "entityType": "indexes", + "table": "im_attachments" + }, + { + "columns": [ + { + "value": "group_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "idx_im_attachments_group", + "entityType": "indexes", + "table": "im_attachments" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "im_groups_workspace_idx", + "entityType": "indexes", + "table": "im_groups" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "im_groups_project_idx", + "entityType": "indexes", + "table": "im_groups" + }, + { + "columns": [ + { + "value": "group_id", + "isExpression": false + }, + { + "value": "member_id", + "isExpression": false + }, + { + "value": "member_type", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "im_members_unique_idx", + "entityType": "indexes", + "table": "im_members" + }, + { + "columns": [ + { + "value": "member_id", + "isExpression": false + }, + { + "value": "group_id", + "isExpression": false + }, + { + "value": "last_read_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "idx_im_members_unread", + "entityType": "indexes", + "table": "im_members" + }, + { + "columns": [ + { + "value": "group_id", + "isExpression": false + }, + { + "value": "created_at", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "idx_im_messages_active", + "entityType": "indexes", + "table": "im_messages" + }, + { + "columns": [ + { + "value": "group_id", + "isExpression": false + }, + { + "value": "reply_to_id", + "isExpression": false + }, + { + "value": "created_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "idx_im_messages_thread", + "entityType": "indexes", + "table": "im_messages" + }, + { + "columns": [ + { + "value": "event_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "idx_im_messages_event", + "entityType": "indexes", + "table": "im_messages" + }, + { + "columns": [ + { + "value": "index_space_id", + "isExpression": false + }, + { + "value": "event_seq", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "location_change_event_space_seq_idx", + "entityType": "indexes", + "table": "location_change_event" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + }, + { + "value": "action", + "isExpression": false + }, + { + "value": "resource", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "permission_project_action_resource_idx", + "entityType": "indexes", + "table": "permission" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "source_session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_fork_admission_source_idx", + "entityType": "indexes", + "table": "session_fork_admission" + }, + { + "columns": [ + { + "value": "state", + "isExpression": false + }, + { + "value": "time_updated", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_fork_admission_recovery_idx", + "entityType": "indexes", + "table": "session_fork_admission" + }, + { + "columns": [ + { + "value": "source_session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_fork_intent_source_idx", + "entityType": "indexes", + "table": "session_fork_intent" + }, + { + "columns": [ + { + "value": "state", + "isExpression": false + }, + { + "value": "time_updated", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_fork_intent_delivery_idx", + "entityType": "indexes", + "table": "session_fork_intent" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "promoted_seq", + "isExpression": false + }, + { + "value": "delivery", + "isExpression": false + }, + { + "value": "admitted_seq", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_input_session_pending_delivery_seq_idx", + "entityType": "indexes", + "table": "session_input" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "admitted_seq", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "session_input_session_admitted_seq_idx", + "entityType": "indexes", + "table": "session_input" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "promoted_seq", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "session_input_session_promoted_seq_idx", + "entityType": "indexes", + "table": "session_input" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "intent_id", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "session_intent_session_intent_idx", + "entityType": "indexes", + "table": "session_intent" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "state", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_intent_session_state_idx", + "entityType": "indexes", + "table": "session_intent" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "session_message_session_seq_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_type_seq_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_time_created_id_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_time_created_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "prompt_epoch", + "isExpression": false + }, + { + "value": "message_id", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "session_prompt_epoch_message_identity_idx", + "entityType": "indexes", + "table": "session_prompt_epoch_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "prompt_epoch", + "isExpression": false + }, + { + "value": "message_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_prompt_epoch_message_lookup_idx", + "entityType": "indexes", + "table": "session_prompt_epoch_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "consumed_seq", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_steer_session_pending_seq_idx", + "entityType": "indexes", + "table": "session_steer" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "correlation_id", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "session_steer_session_correlation_idx", + "entityType": "indexes", + "table": "session_steer" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "time_suspended", + "isExpression": false + } + ], + "isUnique": false, + "where": "\"session\".\"time_suspended\" is not null", + "origin": "manual", + "name": "session_time_suspended_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "prompt_epoch", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_world_state_baseline_epoch_idx", + "entityType": "indexes", + "table": "session_world_state_baseline" + }, + { + "columns": [ + { + "value": "run_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "task_admission_run_idx", + "entityType": "indexes", + "table": "task_admission" + }, + { + "columns": [ + { + "value": "status", + "isExpression": false + }, + { + "value": "available_at", + "isExpression": false + }, + { + "value": "lease_expires_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "task_notification_outbox_due_idx", + "entityType": "indexes", + "table": "task_notification_outbox" + }, + { + "columns": [ + { + "value": "parent_session_id", + "isExpression": false + } + ], + "isUnique": true, + "where": "\"task_notification_outbox\".\"status\" = 'processing'", + "origin": "manual", + "name": "task_notification_outbox_parent_processing_idx", + "entityType": "indexes", + "table": "task_notification_outbox" + }, + { + "columns": [ + { + "value": "run_id", + "isExpression": false + }, + { + "value": "version", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "task_run_event_run_version_idx", + "entityType": "indexes", + "table": "task_run_event" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + }, + { + "value": "event_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "task_run_event_time_idx", + "entityType": "indexes", + "table": "task_run_event" + }, + { + "columns": [ + { + "value": "child_session_id", + "isExpression": false + }, + { + "value": "generation", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "task_run_child_generation_idx", + "entityType": "indexes", + "table": "task_run" + }, + { + "columns": [ + { + "value": "child_session_id", + "isExpression": false + } + ], + "isUnique": true, + "where": "\"task_run\".\"state\" IN ('admitted', 'provisioning', 'running', 'researching', 'finalizing')", + "origin": "manual", + "name": "task_run_child_active_idx", + "entityType": "indexes", + "table": "task_run" + }, + { + "columns": [ + { + "value": "parent_session_id", + "isExpression": false + }, + { + "value": "state", + "isExpression": false + }, + { + "value": "time_updated", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "task_run_parent_state_idx", + "entityType": "indexes", + "table": "task_run" + }, + { + "columns": [ + { + "value": "root_run_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "task_run_root_idx", + "entityType": "indexes", + "table": "task_run" + }, + { + "columns": [ + { + "value": "state", + "isExpression": false + }, + { + "value": "available_at", + "isExpression": false + }, + { + "value": "priority", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "generation", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "task_run_queue_idx", + "entityType": "indexes", + "table": "task_run" + }, + { + "columns": [ + { + "value": "goal_id", + "isExpression": false + }, + { + "value": "goal_tick_seq", + "isExpression": false + }, + { + "value": "goal_role", + "isExpression": false + }, + { + "value": "goal_ordinal", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "task_run_goal_idx", + "entityType": "indexes", + "table": "task_run" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + }, + { + "columns": [ + "target_session_id" + ], + "nameExplicit": false, + "name": "session_fork_admission_target_session_id_unique", + "entityType": "uniques", + "table": "session_fork_admission" + }, + { + "columns": [ + "target_session_id" + ], + "nameExplicit": false, + "name": "session_fork_intent_target_session_id_unique", + "entityType": "uniques", + "table": "session_fork_intent" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_steer_id_unique", + "entityType": "uniques", + "table": "session_steer" + }, + { + "columns": [ + "run_id" + ], + "nameExplicit": false, + "name": "task_notification_outbox_run_id_unique", + "entityType": "uniques", + "table": "task_notification_outbox" + }, + { + "columns": [ + "message_id" + ], + "nameExplicit": false, + "name": "task_notification_outbox_message_id_unique", + "entityType": "uniques", + "table": "task_notification_outbox" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/packages/core/script/migration.ts b/packages/core/script/migration.ts index bacab1e7..313e1889 100644 --- a/packages/core/script/migration.ts +++ b/packages/core/script/migration.ts @@ -28,10 +28,7 @@ await $`bun drizzle-kit generate ${args.values.name ? ["--name", args.values.nam path.join(root, "packages/core"), ) -const sqlMigrations = (await Array.fromAsync(new Bun.Glob("*/migration.sql").scan({ cwd: sqlDir }))) - .map((file) => file.split("/")[0]) - .filter((name) => name !== undefined) - .sort() +const sqlMigrations = await sqlMigrationNames(sqlDir) for (const name of sqlMigrations) { if (await Bun.file(path.join(tsDir, `${name}.ts`)).exists()) continue @@ -41,7 +38,7 @@ for (const name of sqlMigrations) { ) } -await Bun.write(registry, renderRegistry(sqlMigrations)) +await Bun.write(registry, renderRegistry(await typescriptMigrationNames())) async function check() { const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "deepagent-code-core-migration-check-")) @@ -65,17 +62,14 @@ export default { ...config, out: ${JSON.stringify(output)} } ) } - const migrations = before - .map((entry) => entry.path.split("/")[0]) - .filter((name, index, all) => name !== undefined && all.indexOf(name) === index) - .sort() - for (const name of migrations) { + const sqlMigrations = await sqlMigrationNames(output) + for (const name of sqlMigrations) { if (await Bun.file(path.join(tsDir, `${name}.ts`)).exists()) continue throw new Error( `Database migration TypeScript wrapper is missing for ${name}. Run \`bun script/migration.ts\` from packages/core.`, ) } - if ((await Bun.file(registry).text()) !== renderRegistry(migrations)) { + if ((await Bun.file(registry).text()) !== renderRegistry(await typescriptMigrationNames())) { throw new Error("Database migration registry is stale. Run `bun script/migration.ts` from packages/core.") } } finally { @@ -83,6 +77,17 @@ export default { ...config, out: ${JSON.stringify(output)} } } } +async function sqlMigrationNames(directory: string) { + return (await Array.fromAsync(new Bun.Glob("*/migration.sql").scan({ cwd: directory }))) + .map((file) => file.split("/")[0]) + .filter((name) => name !== undefined) + .sort() +} + +async function typescriptMigrationNames() { + return (await Array.fromAsync(new Bun.Glob("*.ts").scan({ cwd: tsDir }))).map((file) => file.slice(0, -3)).sort() +} + async function snapshot(directory: string) { const files = await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: directory, onlyFiles: true })) return Promise.all( diff --git a/packages/core/src/agent-gateway.ts b/packages/core/src/agent-gateway.ts index f7266ec5..e1231389 100644 --- a/packages/core/src/agent-gateway.ts +++ b/packages/core/src/agent-gateway.ts @@ -400,7 +400,9 @@ export const isDeepAgentProvider = (providerID: string) => providerID === "deepa // V3.1 global runtime: activation is strength-driven and provider-agnostic. The runtime is // active for high/max on every provider; general (and a disabled/killed runtime) is passthrough. -export const isActiveDeepAgentRuntime = () => current.enabled && !current.killSwitch && current.agentMode !== "general" +export const isDeepAgentRuntimeEnabled = () => current.enabled && !current.killSwitch + +export const isActiveDeepAgentRuntime = () => isDeepAgentRuntimeEnabled() && current.agentMode !== "general" const isManagedDeepAgentRuntimeWith = (config: CurrentConfig) => config.enabled && !config.killSwitch && config.agentMode !== "general" @@ -408,6 +410,7 @@ const isManagedDeepAgentRuntimeWith = (config: CurrentConfig) => import { buildSystemPrompt, buildVolatileContinuationContext, + buildVolatilePlanContext, buildVolatileRoundContext, type KnowledgeRefProjection, type PromptContext, @@ -486,11 +489,13 @@ export const systemPrompt = (_providerID: string, context?: PromptContext) => // the cache breakpoint) so the model still sees round/stage/previous-results/budget without churning // the prefix. Returns "" when there is nothing round-specific (⇒ caller skips injection). Only emitted // when the DeepAgent runtime is active, matching systemPrompt(). -export const volatileRoundContext = (context: PromptContext): string => - isActiveDeepAgentRuntime() ? buildVolatileRoundContext(context) : "" +export const volatileRoundContext = (context: PromptContext, runtimeControl?: string): string => + isActiveDeepAgentRuntime() ? buildVolatileRoundContext(context, runtimeControl) : "" + +export const volatileContinuationContext = (runtimeControl?: string): string => + isActiveDeepAgentRuntime() ? buildVolatileContinuationContext(runtimeControl) : "" -export const volatileContinuationContext = (): string => - isActiveDeepAgentRuntime() ? buildVolatileContinuationContext() : "" +export const volatilePlanContext = (runtimeControl: string): string => buildVolatilePlanContext(runtimeControl) export const preflight = (input: RunInput): Effect.Effect => preflightWith(input, current) diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 1ba6a640..ebd248f1 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -69,5 +69,14 @@ export const migrations = ( import("./migration/20260806080000_session_tool_request_receipt"), import("./migration/20260807090000_session_tool_argument_receipt"), import("./migration/20260807123000_session_tool_argument_validation_outcome"), + import("./migration/20260809120000_session_history_authority"), + import("./migration/20260810100000_prompt_authority_receipt"), + import("./migration/20260810110000_fork_side_effect_receipt"), + import("./migration/20260810120000_prompt_authority_quarantine"), + import("./migration/20260810130000_bug_012_runtime_integrity"), + import("./migration/20260810140000_bug_012_compaction_cas"), + import("./migration/20260810150000_provider_receipt_authority"), + import("./migration/20260810160000_compaction_continuation_admission"), + import("./migration/20260810170000_part_integrity_backfill"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260809120000_session_history_authority.ts b/packages/core/src/database/migration/20260809120000_session_history_authority.ts new file mode 100644 index 00000000..02fe978b --- /dev/null +++ b/packages/core/src/database/migration/20260809120000_session_history_authority.ts @@ -0,0 +1,487 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260809120000_session_history_authority", + up(tx) { + return Effect.gen(function* () { + yield* tx.run("ALTER TABLE session_prompt_epoch ADD COLUMN projection_version INTEGER") + yield* tx.run("ALTER TABLE session_prompt_epoch ADD COLUMN canonicalization_version INTEGER") + yield* tx.run("ALTER TABLE session_prompt_epoch ADD COLUMN base_message_count INTEGER") + yield* tx.run("ALTER TABLE session_prompt_epoch ADD COLUMN effective_history_hash TEXT") + yield* tx.run("ALTER TABLE session_prompt_epoch ADD COLUMN first_window_id TEXT") + yield* tx.run("ALTER TABLE session_prompt_epoch ADD COLUMN previous_window_id TEXT") + yield* tx.run("ALTER TABLE session_prompt_epoch ADD COLUMN window_id TEXT") + yield* tx.run("ALTER TABLE session_prompt_epoch ADD COLUMN world_state_baseline_hash TEXT") + yield* tx.run(` + ALTER TABLE session_prompt_epoch ADD COLUMN authority_state TEXT + CHECK (authority_state IN ('legacy_pending', 'ready', 'recovery_required')) + `) + yield* tx.run("ALTER TABLE session_prompt_epoch ADD COLUMN recovery_reason TEXT") + yield* tx.run("ALTER TABLE compaction_run ADD COLUMN summary_text TEXT") + yield* tx.run("ALTER TABLE compaction_run ADD COLUMN recent_context TEXT") + yield* tx.run(` + ALTER TABLE compaction_run ADD COLUMN completion_reason TEXT + CHECK (completion_reason IN ('auto', 'manual')) + `) + yield* tx.run("ALTER TABLE compaction_run ADD COLUMN continuation_published_at INTEGER") + yield* tx.run("ALTER TABLE compaction_run ADD COLUMN terminal_events_published_at INTEGER") + yield* tx.run(` + UPDATE session_prompt_epoch + SET authority_state = 'legacy_pending' + WHERE authority_state IS NULL + `) + yield* tx.run(` + CREATE UNIQUE INDEX session_prompt_epoch_window_idx + ON session_prompt_epoch (window_id) + WHERE window_id IS NOT NULL + `) + + yield* tx.run(` + CREATE TABLE session_history_state ( + session_id TEXT NOT NULL PRIMARY KEY REFERENCES session(id) ON DELETE CASCADE, + state TEXT NOT NULL CHECK (state IN ('ready', 'provisioning', 'recovery_required')), + reason TEXT, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL + ) + `) + + yield* tx.run(` + CREATE TABLE session_prompt_epoch_message ( + session_id TEXT NOT NULL REFERENCES session(id) ON DELETE CASCADE, + prompt_epoch INTEGER NOT NULL, + ordinal INTEGER NOT NULL CHECK (ordinal >= 0), + message_id TEXT NOT NULL REFERENCES message(id) ON DELETE CASCADE, + PRIMARY KEY (session_id, prompt_epoch, ordinal), + UNIQUE (session_id, prompt_epoch, message_id) + ) + `) + yield* tx.run(` + CREATE INDEX session_prompt_epoch_message_lookup_idx + ON session_prompt_epoch_message (session_id, prompt_epoch, message_id) + `) + yield* tx.run(` + CREATE TRIGGER session_prompt_epoch_message_validate_insert + BEFORE INSERT ON session_prompt_epoch_message + BEGIN + SELECT CASE WHEN NOT EXISTS ( + SELECT 1 FROM session_prompt_epoch + WHERE session_id = NEW.session_id AND epoch = NEW.prompt_epoch + ) THEN RAISE(ABORT, 'prompt_epoch_message_epoch_missing') END; + SELECT CASE WHEN NOT EXISTS ( + SELECT 1 FROM message WHERE id = NEW.message_id AND session_id = NEW.session_id + ) THEN RAISE(ABORT, 'prompt_epoch_message_cross_session') END; + END + `) + yield* tx.run(` + CREATE TRIGGER session_prompt_epoch_message_validate_update + BEFORE UPDATE ON session_prompt_epoch_message + BEGIN + SELECT CASE WHEN NEW.session_id IS NOT OLD.session_id OR + NEW.prompt_epoch IS NOT OLD.prompt_epoch OR + NEW.ordinal IS NOT OLD.ordinal OR + NEW.message_id IS NOT OLD.message_id + THEN RAISE(ABORT, 'prompt_epoch_message_binding_immutable') END; + END + `) + + yield* tx.run(` + CREATE TABLE session_fork_intent ( + intent_id TEXT NOT NULL PRIMARY KEY, + request_hash TEXT NOT NULL, + fork_mode TEXT NOT NULL CHECK (fork_mode IN ('foreground', 'task')), + source_session_id TEXT NOT NULL REFERENCES session(id) ON DELETE CASCADE, + source_prompt_epoch INTEGER NOT NULL, + source_window_id TEXT NOT NULL, + source_effective_history_hash TEXT NOT NULL, + source_mutation_epoch INTEGER NOT NULL, + source_message_count INTEGER NOT NULL CHECK (source_message_count >= 0), + source_cutoff_message_id TEXT, + projection_version INTEGER NOT NULL, + sanitation_policy_version INTEGER NOT NULL, + target_session_id TEXT NOT NULL UNIQUE REFERENCES session(id) ON DELETE CASCADE, + target_prompt_epoch INTEGER NOT NULL, + target_window_id TEXT NOT NULL, + target_effective_history_hash TEXT NOT NULL, + target_world_state_baseline_hash TEXT NOT NULL, + cloned_message_count INTEGER NOT NULL CHECK (cloned_message_count >= 0), + cloned_part_count INTEGER NOT NULL CHECK (cloned_part_count >= 0), + state TEXT NOT NULL CHECK (state IN + ('prepared', 'committed', 'publishing', 'complete', 'recovery_required')), + event_cursor INTEGER NOT NULL DEFAULT 0 CHECK (event_cursor >= 0), + event_count INTEGER NOT NULL CHECK (event_count >= 0), + delivery_owner TEXT, + lease_expires_at INTEGER, + delivery_attempts INTEGER NOT NULL DEFAULT 0 CHECK (delivery_attempts >= 0), + recovery_reason TEXT, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + time_committed INTEGER, + time_completed INTEGER, + CHECK (event_cursor <= event_count) + ) + `) + yield* tx.run(` + CREATE INDEX session_fork_intent_source_idx + ON session_fork_intent (source_session_id, time_created) + `) + yield* tx.run(` + CREATE INDEX session_fork_intent_delivery_idx + ON session_fork_intent (state, time_updated) + `) + + yield* tx.run(` + CREATE TABLE session_world_state_baseline ( + session_id TEXT NOT NULL REFERENCES session(id) ON DELETE CASCADE, + prompt_epoch INTEGER NOT NULL, + section_id TEXT NOT NULL, + snapshot TEXT NOT NULL, + fragment TEXT NOT NULL, + fragment_hash TEXT NOT NULL, + provenance TEXT NOT NULL CHECK (provenance IN ('native', 'fork_rebuilt', 'legacy_migration')), + created_at INTEGER NOT NULL, + PRIMARY KEY (session_id, prompt_epoch, section_id), + FOREIGN KEY (session_id, prompt_epoch) + REFERENCES session_prompt_epoch(session_id, epoch) ON DELETE CASCADE + ) + `) + yield* tx.run(` + CREATE INDEX session_world_state_baseline_epoch_idx + ON session_world_state_baseline (session_id, prompt_epoch) + `) + + yield* tx.run(` + CREATE TABLE compaction_artifact ( + artifact_id TEXT NOT NULL PRIMARY KEY, + run_id TEXT NOT NULL REFERENCES compaction_run(run_id) ON DELETE CASCADE, + session_id TEXT NOT NULL REFERENCES session(id) ON DELETE CASCADE, + message_id TEXT NOT NULL REFERENCES message(id) ON DELETE CASCADE, + part_id TEXT REFERENCES part(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('marker', 'summary_attempt', 'replay', 'continue', 'world_state')), + state TEXT NOT NULL CHECK (state IN ('pending', 'committed', 'orphaned')), + created_at INTEGER NOT NULL, + committed_at INTEGER, + published_at INTEGER, + UNIQUE (run_id, message_id, part_id, kind) + ) + `) + yield* tx.run(` + CREATE INDEX compaction_artifact_session_message_idx + ON compaction_artifact (session_id, message_id) + `) + yield* tx.run(` + CREATE INDEX compaction_artifact_run_state_idx + ON compaction_artifact (run_id, state) + `) + yield* tx.run(` + CREATE TRIGGER compaction_artifact_validate_insert + BEFORE INSERT ON compaction_artifact + BEGIN + SELECT CASE WHEN NOT EXISTS ( + SELECT 1 FROM compaction_run WHERE run_id = NEW.run_id AND session_id = NEW.session_id + ) THEN RAISE(ABORT, 'compaction_artifact_run_cross_session') END; + SELECT CASE WHEN NOT EXISTS ( + SELECT 1 FROM message WHERE id = NEW.message_id AND session_id = NEW.session_id + ) THEN RAISE(ABORT, 'compaction_artifact_message_cross_session') END; + SELECT CASE WHEN NEW.part_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM part + WHERE id = NEW.part_id AND message_id = NEW.message_id AND session_id = NEW.session_id + ) THEN RAISE(ABORT, 'compaction_artifact_part_cross_session') END; + SELECT CASE WHEN NEW.state = 'pending' AND NOT EXISTS ( + SELECT 1 FROM compaction_run + WHERE run_id = NEW.run_id AND state IN ('requested', 'summarizing') + ) THEN RAISE(ABORT, 'compaction_artifact_pending_without_live_run') END; + SELECT CASE WHEN NEW.state = 'committed' AND NOT EXISTS ( + SELECT 1 FROM compaction_run WHERE run_id = NEW.run_id AND state = 'committed' + ) THEN RAISE(ABORT, 'compaction_artifact_committed_without_run') END; + SELECT CASE WHEN NEW.state = 'pending' AND (NEW.committed_at IS NOT NULL OR NEW.published_at IS NOT NULL) + THEN RAISE(ABORT, 'compaction_artifact_pending_terminal_fields') END; + SELECT CASE WHEN NEW.state = 'committed' AND NEW.committed_at IS NULL + THEN RAISE(ABORT, 'compaction_artifact_committed_without_timestamp') END; + SELECT CASE WHEN NEW.state = 'orphaned' AND (NEW.committed_at IS NOT NULL OR NEW.published_at IS NOT NULL) + THEN RAISE(ABORT, 'compaction_artifact_orphaned_terminal_fields') END; + SELECT CASE WHEN NEW.published_at IS NOT NULL AND NEW.state != 'committed' + THEN RAISE(ABORT, 'compaction_artifact_published_without_commit') END; + END + `) + yield* tx.run(` + CREATE TRIGGER compaction_artifact_validate_update + BEFORE UPDATE ON compaction_artifact + BEGIN + SELECT CASE WHEN NEW.run_id IS NOT OLD.run_id OR + NEW.session_id IS NOT OLD.session_id OR + NEW.message_id IS NOT OLD.message_id OR + NEW.part_id IS NOT OLD.part_id OR + NEW.kind IS NOT OLD.kind + THEN RAISE(ABORT, 'compaction_artifact_binding_immutable') END; + SELECT CASE WHEN NEW.state = 'committed' AND NOT EXISTS ( + SELECT 1 FROM compaction_run WHERE run_id = NEW.run_id AND state = 'committed' + ) THEN RAISE(ABORT, 'compaction_artifact_committed_without_run') END; + SELECT CASE WHEN NOT ( + NEW.state = OLD.state OR + (OLD.state = 'pending' AND NEW.state IN ('committed', 'orphaned')) + ) THEN RAISE(ABORT, 'compaction_artifact_invalid_state_transition') END; + SELECT CASE WHEN NEW.state = 'pending' AND (NEW.committed_at IS NOT NULL OR NEW.published_at IS NOT NULL) + THEN RAISE(ABORT, 'compaction_artifact_pending_terminal_fields') END; + SELECT CASE WHEN NEW.state = 'committed' AND NEW.committed_at IS NULL + THEN RAISE(ABORT, 'compaction_artifact_committed_without_timestamp') END; + SELECT CASE WHEN NEW.state = 'orphaned' AND (NEW.committed_at IS NOT NULL OR NEW.published_at IS NOT NULL) + THEN RAISE(ABORT, 'compaction_artifact_orphaned_terminal_fields') END; + SELECT CASE WHEN NEW.published_at IS NOT NULL AND NEW.state != 'committed' + THEN RAISE(ABORT, 'compaction_artifact_published_without_commit') END; + END + `) + yield* tx.run(` + CREATE TRIGGER compaction_run_authority_validate_update + BEFORE UPDATE ON compaction_run + BEGIN + SELECT CASE WHEN NOT ( + NEW.state = OLD.state OR + (OLD.state = 'requested' AND NEW.state IN ('summarizing', 'failed', 'indeterminate')) OR + (OLD.state = 'summarizing' AND NEW.state IN ('committed', 'failed', 'indeterminate')) + ) THEN RAISE(ABORT, 'compaction_run_invalid_state_transition') END; + SELECT CASE WHEN NEW.state IN ('failed', 'indeterminate') AND NEW.terminal_failure_kind IS NULL + THEN RAISE(ABORT, 'compaction_run_terminal_failure_without_reason') END; + SELECT CASE WHEN OLD.state != 'committed' AND NEW.state = 'committed' AND ( + NEW.target_prompt_epoch IS NULL OR NEW.committed_summary_message_id IS NULL OR + NEW.checkpoint_hash IS NULL OR NEW.summary_text IS NULL OR NEW.recent_context IS NULL OR + NEW.completion_reason IS NULL OR NEW.committed_at IS NULL + ) THEN RAISE(ABORT, 'compaction_run_commit_binding_incomplete') END; + END + `) + + yield* tx.run(` + CREATE TRIGGER session_fork_intent_validate_insert + BEFORE INSERT ON session_fork_intent + BEGIN + SELECT CASE WHEN NEW.source_session_id = NEW.target_session_id + THEN RAISE(ABORT, 'session_fork_intent_self_fork') END; + SELECT CASE WHEN NEW.state = 'publishing' AND + (NEW.delivery_owner IS NULL OR NEW.lease_expires_at IS NULL) + THEN RAISE(ABORT, 'session_fork_intent_publishing_without_lease') END; + SELECT CASE WHEN NEW.state = 'complete' AND + (NEW.event_cursor != NEW.event_count OR NEW.time_completed IS NULL) + THEN RAISE(ABORT, 'session_fork_intent_incomplete_delivery') END; + SELECT CASE WHEN NEW.state != 'publishing' AND + (NEW.delivery_owner IS NOT NULL OR NEW.lease_expires_at IS NOT NULL) + THEN RAISE(ABORT, 'session_fork_intent_nonpublishing_with_lease') END; + SELECT CASE WHEN NEW.state = 'recovery_required' AND NEW.recovery_reason IS NULL + THEN RAISE(ABORT, 'session_fork_intent_recovery_without_reason') END; + SELECT CASE WHEN NEW.state IN ('committed', 'publishing', 'complete') AND NEW.time_committed IS NULL + THEN RAISE(ABORT, 'session_fork_intent_committed_without_timestamp') END; + SELECT CASE WHEN NEW.state != 'complete' AND NEW.time_completed IS NOT NULL + THEN RAISE(ABORT, 'session_fork_intent_incomplete_timestamp') END; + END + `) + yield* tx.run(` + CREATE TRIGGER session_fork_intent_validate_update + BEFORE UPDATE ON session_fork_intent + BEGIN + SELECT CASE WHEN NEW.intent_id IS NOT OLD.intent_id OR + NEW.request_hash IS NOT OLD.request_hash OR + NEW.source_session_id IS NOT OLD.source_session_id OR + NEW.fork_mode IS NOT OLD.fork_mode OR + NEW.target_session_id IS NOT OLD.target_session_id OR + NEW.source_prompt_epoch IS NOT OLD.source_prompt_epoch OR + NEW.source_window_id IS NOT OLD.source_window_id OR + NEW.source_effective_history_hash IS NOT OLD.source_effective_history_hash OR + NEW.source_mutation_epoch IS NOT OLD.source_mutation_epoch OR + NEW.source_message_count IS NOT OLD.source_message_count OR + NEW.source_cutoff_message_id IS NOT OLD.source_cutoff_message_id OR + NEW.projection_version IS NOT OLD.projection_version OR + NEW.sanitation_policy_version IS NOT OLD.sanitation_policy_version OR + NEW.target_prompt_epoch IS NOT OLD.target_prompt_epoch OR + NEW.target_window_id IS NOT OLD.target_window_id OR + NEW.target_effective_history_hash IS NOT OLD.target_effective_history_hash OR + NEW.target_world_state_baseline_hash IS NOT OLD.target_world_state_baseline_hash OR + NEW.cloned_message_count IS NOT OLD.cloned_message_count OR + NEW.cloned_part_count IS NOT OLD.cloned_part_count OR + NEW.event_count IS NOT OLD.event_count + THEN RAISE(ABORT, 'session_fork_intent_binding_immutable') END; + SELECT CASE WHEN NOT ( + NEW.state = OLD.state OR + (OLD.state = 'prepared' AND NEW.state = 'recovery_required') OR + (OLD.state = 'committed' AND NEW.state = 'publishing') OR + (OLD.state = 'publishing' AND NEW.state IN ('committed', 'complete', 'recovery_required')) + ) THEN RAISE(ABORT, 'session_fork_intent_invalid_state_transition') END; + SELECT CASE WHEN NEW.state = 'publishing' AND + (NEW.delivery_owner IS NULL OR NEW.lease_expires_at IS NULL) + THEN RAISE(ABORT, 'session_fork_intent_publishing_without_lease') END; + SELECT CASE WHEN NEW.state != 'publishing' AND + (NEW.delivery_owner IS NOT NULL OR NEW.lease_expires_at IS NOT NULL) + THEN RAISE(ABORT, 'session_fork_intent_nonpublishing_with_lease') END; + SELECT CASE WHEN NEW.state = 'complete' AND + (NEW.event_cursor != NEW.event_count OR NEW.time_completed IS NULL) + THEN RAISE(ABORT, 'session_fork_intent_incomplete_delivery') END; + SELECT CASE WHEN NEW.state = 'recovery_required' AND NEW.recovery_reason IS NULL + THEN RAISE(ABORT, 'session_fork_intent_recovery_without_reason') END; + SELECT CASE WHEN NEW.state IN ('committed', 'publishing', 'complete') AND NEW.time_committed IS NULL + THEN RAISE(ABORT, 'session_fork_intent_committed_without_timestamp') END; + SELECT CASE WHEN NEW.state != 'complete' AND NEW.time_completed IS NOT NULL + THEN RAISE(ABORT, 'session_fork_intent_incomplete_timestamp') END; + SELECT CASE WHEN NEW.event_cursor < OLD.event_cursor OR NEW.event_cursor > NEW.event_count + THEN RAISE(ABORT, 'session_fork_intent_invalid_event_cursor') END; + END + `) + + yield* tx.run(` + CREATE TRIGGER session_prompt_epoch_validate_insert + BEFORE INSERT ON session_prompt_epoch + BEGIN + SELECT CASE WHEN NOT EXISTS ( + SELECT 1 FROM session WHERE id = NEW.session_id + ) THEN RAISE(ABORT, 'prompt_epoch_session_missing') END; + SELECT CASE WHEN NEW.checkpoint_user_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM message WHERE id = NEW.checkpoint_user_id AND session_id = NEW.session_id + ) THEN RAISE(ABORT, 'prompt_epoch_checkpoint_user_cross_session') END; + SELECT CASE WHEN NEW.checkpoint_assistant_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM message WHERE id = NEW.checkpoint_assistant_id AND session_id = NEW.session_id + ) THEN RAISE(ABORT, 'prompt_epoch_checkpoint_assistant_cross_session') END; + SELECT CASE WHEN NEW.retained_tail_start_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM message WHERE id = NEW.retained_tail_start_id AND session_id = NEW.session_id + ) THEN RAISE(ABORT, 'prompt_epoch_retained_tail_cross_session') END; + SELECT CASE WHEN NEW.source_end_message_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM message WHERE id = NEW.source_end_message_id AND session_id = NEW.session_id + ) THEN RAISE(ABORT, 'prompt_epoch_source_end_cross_session') END; + SELECT CASE WHEN NEW.previous_window_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM session_prompt_epoch + WHERE session_id = NEW.session_id AND window_id = NEW.previous_window_id + ) THEN RAISE(ABORT, 'prompt_epoch_previous_window_cross_session') END; + SELECT CASE WHEN NEW.authority_state = 'ready' AND ( + NEW.projection_version IS NULL OR NEW.canonicalization_version IS NULL OR + NEW.base_message_count IS NULL OR NEW.base_message_count < 0 OR + NEW.effective_history_hash IS NULL OR NEW.first_window_id IS NULL OR NEW.window_id IS NULL OR + (NEW.epoch > 0 AND ( + NEW.checkpoint_user_id IS NULL OR NEW.checkpoint_assistant_id IS NULL OR + NEW.checkpoint_hash IS NULL OR NEW.world_state_baseline_hash IS NULL + )) + ) THEN RAISE(ABORT, 'prompt_epoch_ready_binding_incomplete') END; + SELECT CASE WHEN NEW.authority_state = 'recovery_required' AND NEW.recovery_reason IS NULL + THEN RAISE(ABORT, 'prompt_epoch_recovery_without_reason') END; + SELECT CASE WHEN NEW.authority_state != 'recovery_required' AND NEW.recovery_reason IS NOT NULL + THEN RAISE(ABORT, 'prompt_epoch_nonrecovery_with_reason') END; + END + `) + yield* tx.run(` + CREATE TRIGGER session_prompt_epoch_validate_update + BEFORE UPDATE ON session_prompt_epoch + BEGIN + SELECT CASE WHEN NEW.checkpoint_user_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM message WHERE id = NEW.checkpoint_user_id AND session_id = NEW.session_id + ) THEN RAISE(ABORT, 'prompt_epoch_checkpoint_user_cross_session') END; + SELECT CASE WHEN NEW.checkpoint_assistant_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM message WHERE id = NEW.checkpoint_assistant_id AND session_id = NEW.session_id + ) THEN RAISE(ABORT, 'prompt_epoch_checkpoint_assistant_cross_session') END; + SELECT CASE WHEN NEW.retained_tail_start_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM message WHERE id = NEW.retained_tail_start_id AND session_id = NEW.session_id + ) THEN RAISE(ABORT, 'prompt_epoch_retained_tail_cross_session') END; + SELECT CASE WHEN NEW.source_end_message_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM message WHERE id = NEW.source_end_message_id AND session_id = NEW.session_id + ) THEN RAISE(ABORT, 'prompt_epoch_source_end_cross_session') END; + SELECT CASE WHEN NEW.previous_window_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM session_prompt_epoch + WHERE session_id = NEW.session_id AND window_id = NEW.previous_window_id + ) THEN RAISE(ABORT, 'prompt_epoch_previous_window_cross_session') END; + SELECT CASE WHEN OLD.authority_state = 'ready' AND ( + NEW.session_id IS NOT OLD.session_id OR + NEW.epoch IS NOT OLD.epoch OR + NEW.checkpoint_user_id IS NOT OLD.checkpoint_user_id OR + NEW.checkpoint_assistant_id IS NOT OLD.checkpoint_assistant_id OR + NEW.retained_tail_start_id IS NOT OLD.retained_tail_start_id OR + NEW.source_end_message_id IS NOT OLD.source_end_message_id OR + NEW.checkpoint_hash IS NOT OLD.checkpoint_hash OR + NEW.projection_version IS NOT OLD.projection_version OR + NEW.canonicalization_version IS NOT OLD.canonicalization_version OR + NEW.base_message_count IS NOT OLD.base_message_count OR + NEW.effective_history_hash IS NOT OLD.effective_history_hash OR + NEW.first_window_id IS NOT OLD.first_window_id OR + NEW.previous_window_id IS NOT OLD.previous_window_id OR + NEW.window_id IS NOT OLD.window_id OR + NEW.world_state_baseline_hash IS NOT OLD.world_state_baseline_hash OR + NEW.reason IS NOT OLD.reason + ) THEN RAISE(ABORT, 'prompt_epoch_ready_binding_immutable') END; + SELECT CASE WHEN OLD.authority_state = 'recovery_required' AND NEW.authority_state != 'recovery_required' + THEN RAISE(ABORT, 'prompt_epoch_recovery_state_immutable') END; + SELECT CASE WHEN OLD.authority_state = 'ready' AND NEW.authority_state NOT IN ('ready', 'recovery_required') + THEN RAISE(ABORT, 'prompt_epoch_invalid_authority_transition') END; + SELECT CASE WHEN NEW.authority_state = 'ready' AND ( + NEW.projection_version IS NULL OR NEW.canonicalization_version IS NULL OR + NEW.base_message_count IS NULL OR NEW.base_message_count < 0 OR + NEW.effective_history_hash IS NULL OR NEW.first_window_id IS NULL OR NEW.window_id IS NULL OR + (NEW.epoch > 0 AND ( + NEW.checkpoint_user_id IS NULL OR NEW.checkpoint_assistant_id IS NULL OR + NEW.checkpoint_hash IS NULL OR NEW.world_state_baseline_hash IS NULL + )) + ) THEN RAISE(ABORT, 'prompt_epoch_ready_binding_incomplete') END; + SELECT CASE WHEN NEW.authority_state = 'recovery_required' AND NEW.recovery_reason IS NULL + THEN RAISE(ABORT, 'prompt_epoch_recovery_without_reason') END; + SELECT CASE WHEN NEW.authority_state != 'recovery_required' AND NEW.recovery_reason IS NOT NULL + THEN RAISE(ABORT, 'prompt_epoch_nonrecovery_with_reason') END; + END + `) + yield* tx.run(` + CREATE TRIGGER session_prompt_epoch_message_owner_immutable + BEFORE UPDATE OF session_id ON message + WHEN EXISTS ( + SELECT 1 FROM session_prompt_epoch + WHERE checkpoint_user_id = OLD.id + OR checkpoint_assistant_id = OLD.id + OR retained_tail_start_id = OLD.id + OR source_end_message_id = OLD.id + ) + BEGIN + SELECT RAISE(ABORT, 'prompt_epoch_referenced_message_owner_immutable'); + END + `) + yield* tx.run(` + CREATE TRIGGER session_history_state_ready_validate_insert + BEFORE INSERT ON session_history_state + WHEN NEW.state = 'ready' + BEGIN + SELECT CASE WHEN NOT EXISTS ( + SELECT 1 FROM session_prompt_epoch + WHERE session_id = NEW.session_id AND state = 'active' AND authority_state = 'ready' + ) THEN RAISE(ABORT, 'session_history_ready_without_authority') END; + END + `) + yield* tx.run(` + CREATE TRIGGER session_history_state_ready_validate_update + BEFORE UPDATE ON session_history_state + WHEN NEW.state = 'ready' + BEGIN + SELECT CASE WHEN NOT EXISTS ( + SELECT 1 FROM session_prompt_epoch + WHERE session_id = NEW.session_id AND state = 'active' AND authority_state = 'ready' + ) THEN RAISE(ABORT, 'session_history_ready_without_authority') END; + END + `) + yield* tx.run(` + CREATE TRIGGER session_history_state_recovery_validate_insert + BEFORE INSERT ON session_history_state + WHEN NEW.state = 'recovery_required' + BEGIN + SELECT CASE WHEN NEW.reason IS NULL + THEN RAISE(ABORT, 'session_history_recovery_without_reason') END; + SELECT CASE WHEN EXISTS ( + SELECT 1 FROM session_prompt_epoch + WHERE session_id = NEW.session_id AND state = 'active' AND authority_state != 'recovery_required' + ) THEN RAISE(ABORT, 'session_history_recovery_without_quarantined_authority') END; + END + `) + yield* tx.run(` + CREATE TRIGGER session_history_state_recovery_validate_update + BEFORE UPDATE ON session_history_state + WHEN NEW.state = 'recovery_required' + BEGIN + SELECT CASE WHEN NEW.reason IS NULL + THEN RAISE(ABORT, 'session_history_recovery_without_reason') END; + SELECT CASE WHEN EXISTS ( + SELECT 1 FROM session_prompt_epoch + WHERE session_id = NEW.session_id AND state = 'active' AND authority_state != 'recovery_required' + ) THEN RAISE(ABORT, 'session_history_recovery_without_quarantined_authority') END; + END + `) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260810100000_prompt_authority_receipt.ts b/packages/core/src/database/migration/20260810100000_prompt_authority_receipt.ts new file mode 100644 index 00000000..d9694e91 --- /dev/null +++ b/packages/core/src/database/migration/20260810100000_prompt_authority_receipt.ts @@ -0,0 +1,22 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260810100000_prompt_authority_receipt", + up(tx) { + return Effect.gen(function* () { + yield* tx.run("ALTER TABLE session_tool_request_receipt ADD COLUMN prompt_epoch INTEGER") + yield* tx.run("ALTER TABLE session_tool_request_receipt ADD COLUMN prompt_window_id TEXT") + yield* tx.run("ALTER TABLE session_tool_request_receipt ADD COLUMN effective_history_hash TEXT") + yield* tx.run("ALTER TABLE session_tool_request_receipt ADD COLUMN world_state_baseline_hash TEXT") + yield* tx.run("ALTER TABLE session_tool_request_receipt ADD COLUMN prompt_cache_key TEXT") + yield* tx.run("ALTER TABLE session_tool_request_receipt ADD COLUMN provider_request_hash TEXT") + yield* tx.run("ALTER TABLE session_tool_request_receipt ADD COLUMN response_chain_reuse_decision TEXT") + yield* tx.run("ALTER TABLE session_tool_request_receipt ADD COLUMN response_chain_refusal_reason TEXT") + yield* tx.run(` + CREATE INDEX session_tool_request_receipt_prompt_window_idx + ON session_tool_request_receipt (session_id, prompt_epoch, prompt_window_id) + `) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260810110000_fork_side_effect_receipt.ts b/packages/core/src/database/migration/20260810110000_fork_side_effect_receipt.ts new file mode 100644 index 00000000..510948b1 --- /dev/null +++ b/packages/core/src/database/migration/20260810110000_fork_side_effect_receipt.ts @@ -0,0 +1,40 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260810110000_fork_side_effect_receipt", + up(tx) { + return Effect.gen(function* () { + yield* tx.run("ALTER TABLE session_fork_intent ADD COLUMN side_effects_completed_at INTEGER") + yield* tx.run(` + CREATE INDEX session_fork_intent_side_effects_idx + ON session_fork_intent (state, side_effects_completed_at) + `) + yield* tx.run(` + CREATE TRIGGER session_fork_intent_side_effects_insert_validate + BEFORE INSERT ON session_fork_intent + WHEN NEW.side_effects_completed_at IS NOT NULL + BEGIN + SELECT RAISE(ABORT, 'session_fork_intent_side_effects_insert_forbidden'); + END + `) + yield* tx.run(` + CREATE TRIGGER session_fork_intent_side_effects_validate + BEFORE UPDATE OF side_effects_completed_at ON session_fork_intent + WHEN NEW.side_effects_completed_at IS NOT NULL AND NEW.state != 'complete' + BEGIN + SELECT RAISE(ABORT, 'session_fork_intent_side_effects_before_complete'); + END + `) + yield* tx.run(` + CREATE TRIGGER session_fork_intent_side_effects_immutable + BEFORE UPDATE OF side_effects_completed_at ON session_fork_intent + WHEN OLD.side_effects_completed_at IS NOT NULL AND + NEW.side_effects_completed_at IS NOT OLD.side_effects_completed_at + BEGIN + SELECT RAISE(ABORT, 'session_fork_intent_side_effects_immutable'); + END + `) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260810120000_prompt_authority_quarantine.ts b/packages/core/src/database/migration/20260810120000_prompt_authority_quarantine.ts new file mode 100644 index 00000000..06b6a52e --- /dev/null +++ b/packages/core/src/database/migration/20260810120000_prompt_authority_quarantine.ts @@ -0,0 +1,119 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260810120000_prompt_authority_quarantine", + up(tx) { + return Effect.gen(function* () { + yield* tx.run("DROP TRIGGER IF EXISTS session_prompt_epoch_validate_insert") + yield* tx.run("DROP TRIGGER IF EXISTS session_prompt_epoch_validate_update") + + yield* tx.run(` + CREATE TRIGGER session_prompt_epoch_validate_insert + BEFORE INSERT ON session_prompt_epoch + BEGIN + SELECT CASE WHEN NOT EXISTS ( + SELECT 1 FROM session WHERE id = NEW.session_id + ) THEN RAISE(ABORT, 'prompt_epoch_session_missing') END; + SELECT CASE WHEN NEW.authority_state IS NOT 'recovery_required' AND + NEW.checkpoint_user_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM message WHERE id = NEW.checkpoint_user_id AND session_id = NEW.session_id + ) THEN RAISE(ABORT, 'prompt_epoch_checkpoint_user_cross_session') END; + SELECT CASE WHEN NEW.authority_state IS NOT 'recovery_required' AND + NEW.checkpoint_assistant_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM message WHERE id = NEW.checkpoint_assistant_id AND session_id = NEW.session_id + ) THEN RAISE(ABORT, 'prompt_epoch_checkpoint_assistant_cross_session') END; + SELECT CASE WHEN NEW.authority_state IS NOT 'recovery_required' AND + NEW.retained_tail_start_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM message WHERE id = NEW.retained_tail_start_id AND session_id = NEW.session_id + ) THEN RAISE(ABORT, 'prompt_epoch_retained_tail_cross_session') END; + SELECT CASE WHEN NEW.authority_state IS NOT 'recovery_required' AND + NEW.source_end_message_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM message WHERE id = NEW.source_end_message_id AND session_id = NEW.session_id + ) THEN RAISE(ABORT, 'prompt_epoch_source_end_cross_session') END; + SELECT CASE WHEN NEW.authority_state IS NOT 'recovery_required' AND + NEW.previous_window_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM session_prompt_epoch + WHERE session_id = NEW.session_id AND window_id = NEW.previous_window_id + ) THEN RAISE(ABORT, 'prompt_epoch_previous_window_cross_session') END; + SELECT CASE WHEN NEW.authority_state = 'ready' AND ( + NEW.projection_version IS NULL OR NEW.canonicalization_version IS NULL OR + NEW.base_message_count IS NULL OR NEW.base_message_count < 0 OR + NEW.effective_history_hash IS NULL OR NEW.first_window_id IS NULL OR NEW.window_id IS NULL OR + (NEW.epoch > 0 AND ( + NEW.checkpoint_user_id IS NULL OR NEW.checkpoint_assistant_id IS NULL OR + NEW.checkpoint_hash IS NULL OR NEW.world_state_baseline_hash IS NULL + )) + ) THEN RAISE(ABORT, 'prompt_epoch_ready_binding_incomplete') END; + SELECT CASE WHEN NEW.authority_state = 'recovery_required' AND NEW.recovery_reason IS NULL + THEN RAISE(ABORT, 'prompt_epoch_recovery_without_reason') END; + SELECT CASE WHEN NEW.authority_state != 'recovery_required' AND NEW.recovery_reason IS NOT NULL + THEN RAISE(ABORT, 'prompt_epoch_nonrecovery_with_reason') END; + END + `) + + yield* tx.run(` + CREATE TRIGGER session_prompt_epoch_validate_update + BEFORE UPDATE ON session_prompt_epoch + BEGIN + SELECT CASE WHEN NEW.authority_state IS NOT 'recovery_required' AND + NEW.checkpoint_user_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM message WHERE id = NEW.checkpoint_user_id AND session_id = NEW.session_id + ) THEN RAISE(ABORT, 'prompt_epoch_checkpoint_user_cross_session') END; + SELECT CASE WHEN NEW.authority_state IS NOT 'recovery_required' AND + NEW.checkpoint_assistant_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM message WHERE id = NEW.checkpoint_assistant_id AND session_id = NEW.session_id + ) THEN RAISE(ABORT, 'prompt_epoch_checkpoint_assistant_cross_session') END; + SELECT CASE WHEN NEW.authority_state IS NOT 'recovery_required' AND + NEW.retained_tail_start_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM message WHERE id = NEW.retained_tail_start_id AND session_id = NEW.session_id + ) THEN RAISE(ABORT, 'prompt_epoch_retained_tail_cross_session') END; + SELECT CASE WHEN NEW.authority_state IS NOT 'recovery_required' AND + NEW.source_end_message_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM message WHERE id = NEW.source_end_message_id AND session_id = NEW.session_id + ) THEN RAISE(ABORT, 'prompt_epoch_source_end_cross_session') END; + SELECT CASE WHEN NEW.authority_state IS NOT 'recovery_required' AND + NEW.previous_window_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM session_prompt_epoch + WHERE session_id = NEW.session_id AND window_id = NEW.previous_window_id + ) THEN RAISE(ABORT, 'prompt_epoch_previous_window_cross_session') END; + SELECT CASE WHEN OLD.authority_state = 'ready' AND ( + NEW.session_id IS NOT OLD.session_id OR + NEW.epoch IS NOT OLD.epoch OR + NEW.checkpoint_user_id IS NOT OLD.checkpoint_user_id OR + NEW.checkpoint_assistant_id IS NOT OLD.checkpoint_assistant_id OR + NEW.retained_tail_start_id IS NOT OLD.retained_tail_start_id OR + NEW.source_end_message_id IS NOT OLD.source_end_message_id OR + NEW.checkpoint_hash IS NOT OLD.checkpoint_hash OR + NEW.projection_version IS NOT OLD.projection_version OR + NEW.canonicalization_version IS NOT OLD.canonicalization_version OR + NEW.base_message_count IS NOT OLD.base_message_count OR + NEW.effective_history_hash IS NOT OLD.effective_history_hash OR + NEW.first_window_id IS NOT OLD.first_window_id OR + NEW.previous_window_id IS NOT OLD.previous_window_id OR + NEW.window_id IS NOT OLD.window_id OR + NEW.world_state_baseline_hash IS NOT OLD.world_state_baseline_hash OR + NEW.reason IS NOT OLD.reason + ) THEN RAISE(ABORT, 'prompt_epoch_ready_binding_immutable') END; + SELECT CASE WHEN OLD.authority_state = 'recovery_required' AND NEW.authority_state != 'recovery_required' + THEN RAISE(ABORT, 'prompt_epoch_recovery_state_immutable') END; + SELECT CASE WHEN OLD.authority_state = 'ready' AND NEW.authority_state NOT IN ('ready', 'recovery_required') + THEN RAISE(ABORT, 'prompt_epoch_invalid_authority_transition') END; + SELECT CASE WHEN NEW.authority_state = 'ready' AND ( + NEW.projection_version IS NULL OR NEW.canonicalization_version IS NULL OR + NEW.base_message_count IS NULL OR NEW.base_message_count < 0 OR + NEW.effective_history_hash IS NULL OR NEW.first_window_id IS NULL OR NEW.window_id IS NULL OR + (NEW.epoch > 0 AND ( + NEW.checkpoint_user_id IS NULL OR NEW.checkpoint_assistant_id IS NULL OR + NEW.checkpoint_hash IS NULL OR NEW.world_state_baseline_hash IS NULL + )) + ) THEN RAISE(ABORT, 'prompt_epoch_ready_binding_incomplete') END; + SELECT CASE WHEN NEW.authority_state = 'recovery_required' AND NEW.recovery_reason IS NULL + THEN RAISE(ABORT, 'prompt_epoch_recovery_without_reason') END; + SELECT CASE WHEN NEW.authority_state != 'recovery_required' AND NEW.recovery_reason IS NOT NULL + THEN RAISE(ABORT, 'prompt_epoch_nonrecovery_with_reason') END; + END + `) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260810130000_bug_012_runtime_integrity.ts b/packages/core/src/database/migration/20260810130000_bug_012_runtime_integrity.ts new file mode 100644 index 00000000..64347d34 --- /dev/null +++ b/packages/core/src/database/migration/20260810130000_bug_012_runtime_integrity.ts @@ -0,0 +1,200 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260810130000_bug_012_runtime_integrity", + up(tx) { + return Effect.gen(function* () { + yield* tx.run("ALTER TABLE session_steer ADD COLUMN materialized_at INTEGER") + + // Older releases allowed a Part to name a Message from one Session while storing a + // different session_id on the Part row. Keep the evidence, quarantine every affected + // Session before installing the new write-time triggers, and fail closed until an operator + // repairs the physical history. The production reader also joins Part to its parent Message, + // so a quarantined row can never be silently promoted into a new PromptEpoch. + yield* tx.run(` + CREATE TABLE session_part_integrity_quarantine ( + part_id TEXT NOT NULL PRIMARY KEY, + message_id TEXT NOT NULL, + part_session_id TEXT NOT NULL, + message_session_id TEXT NOT NULL, + reason TEXT NOT NULL, + quarantined_at INTEGER NOT NULL + ) + `) + yield* tx.run(` + INSERT OR IGNORE INTO session_part_integrity_quarantine + (part_id, message_id, part_session_id, message_session_id, reason, quarantined_at) + SELECT part.id, part.message_id, part.session_id, message.session_id, + 'part_parent_cross_session', ${Date.now()} + FROM part + JOIN message ON message.id = part.message_id + WHERE part.session_id IS NOT message.session_id + `) + yield* tx.run(` + UPDATE session_prompt_epoch + SET authority_state = 'recovery_required', + recovery_reason = 'legacy cross-session Part rows quarantined' + WHERE state = 'active' + AND session_id IN (SELECT part_session_id FROM session_part_integrity_quarantine + UNION SELECT message_session_id FROM session_part_integrity_quarantine) + `) + yield* tx.run(` + INSERT OR IGNORE INTO session_history_state + (session_id, state, reason, time_created, time_updated) + SELECT affected.session_id, 'recovery_required', + 'legacy cross-session Part rows quarantined', ${Date.now()}, ${Date.now()} + FROM ( + SELECT part_session_id AS session_id FROM session_part_integrity_quarantine + UNION + SELECT message_session_id AS session_id FROM session_part_integrity_quarantine + ) affected + JOIN session ON session.id = affected.session_id + `) + yield* tx.run(` + UPDATE session_history_state + SET state = 'recovery_required', + reason = 'legacy cross-session Part rows quarantined', + time_updated = ${Date.now()} + WHERE session_id IN ( + SELECT part_session_id FROM session_part_integrity_quarantine + UNION + SELECT message_session_id FROM session_part_integrity_quarantine + ) + `) + + yield* tx.run(` + CREATE TABLE session_fork_admission ( + intent_id TEXT NOT NULL PRIMARY KEY, + request_hash TEXT NOT NULL, + fork_mode TEXT NOT NULL CHECK (fork_mode IN ('foreground', 'task')), + source_session_id TEXT NOT NULL REFERENCES session(id) ON DELETE CASCADE, + source_prompt_epoch INTEGER NOT NULL, + source_window_id TEXT NOT NULL, + source_effective_history_hash TEXT NOT NULL, + source_mutation_epoch INTEGER NOT NULL, + source_message_count INTEGER NOT NULL CHECK (source_message_count >= 0), + source_cutoff_message_id TEXT, + projection_version INTEGER NOT NULL, + sanitation_policy_version INTEGER NOT NULL, + requested_directory TEXT, + isolation_mode TEXT NOT NULL CHECK (isolation_mode IN ('none', 'worktree')), + requested_target_session_id TEXT, + target_session_id TEXT NOT NULL UNIQUE, + child_depth INTEGER CHECK (child_depth IS NULL OR child_depth >= 0), + task_request_hash TEXT, + worktree_directory TEXT, + worktree_branch TEXT, + worktree_base_commit TEXT, + state TEXT NOT NULL CHECK (state IN + ('admitted', 'provisioning', 'ready', 'manifest_committed', 'recovery_required')), + recovery_reason TEXT, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL + ) + `) + yield* tx.run(` + CREATE INDEX session_fork_admission_source_idx + ON session_fork_admission (source_session_id, time_created) + `) + yield* tx.run(` + CREATE INDEX session_fork_admission_recovery_idx + ON session_fork_admission (state, time_updated) + `) + yield* tx.run(` + CREATE TRIGGER session_fork_admission_validate_insert + BEFORE INSERT ON session_fork_admission + BEGIN + SELECT CASE WHEN NEW.state = 'manifest_committed' + THEN RAISE(ABORT, 'fork_admission_cannot_start_committed') END; + SELECT CASE WHEN NEW.state = 'recovery_required' AND NEW.recovery_reason IS NULL + THEN RAISE(ABORT, 'fork_admission_recovery_without_reason') END; + SELECT CASE WHEN NEW.state != 'recovery_required' AND NEW.recovery_reason IS NOT NULL + THEN RAISE(ABORT, 'fork_admission_nonrecovery_with_reason') END; + SELECT CASE WHEN NEW.isolation_mode = 'worktree' AND ( + NEW.worktree_directory IS NULL OR NEW.worktree_branch IS NULL OR NEW.worktree_base_commit IS NULL + ) THEN RAISE(ABORT, 'fork_admission_worktree_plan_incomplete') END; + SELECT CASE WHEN NEW.isolation_mode = 'none' AND ( + NEW.worktree_directory IS NOT NULL OR NEW.worktree_branch IS NOT NULL OR NEW.worktree_base_commit IS NOT NULL + ) THEN RAISE(ABORT, 'fork_admission_worktree_plan_unexpected') END; + END + `) + yield* tx.run(` + CREATE TRIGGER session_fork_admission_validate_update + BEFORE UPDATE ON session_fork_admission + BEGIN + SELECT CASE WHEN + NEW.intent_id IS NOT OLD.intent_id OR + NEW.request_hash IS NOT OLD.request_hash OR + NEW.fork_mode IS NOT OLD.fork_mode OR + NEW.source_session_id IS NOT OLD.source_session_id OR + NEW.source_prompt_epoch IS NOT OLD.source_prompt_epoch OR + NEW.source_window_id IS NOT OLD.source_window_id OR + NEW.source_effective_history_hash IS NOT OLD.source_effective_history_hash OR + NEW.source_mutation_epoch IS NOT OLD.source_mutation_epoch OR + NEW.source_message_count IS NOT OLD.source_message_count OR + NEW.source_cutoff_message_id IS NOT OLD.source_cutoff_message_id OR + NEW.projection_version IS NOT OLD.projection_version OR + NEW.sanitation_policy_version IS NOT OLD.sanitation_policy_version OR + NEW.requested_directory IS NOT OLD.requested_directory OR + NEW.isolation_mode IS NOT OLD.isolation_mode OR + NEW.requested_target_session_id IS NOT OLD.requested_target_session_id OR + NEW.target_session_id IS NOT OLD.target_session_id OR + NEW.child_depth IS NOT OLD.child_depth OR + NEW.task_request_hash IS NOT OLD.task_request_hash OR + NEW.worktree_directory IS NOT OLD.worktree_directory OR + NEW.worktree_branch IS NOT OLD.worktree_branch OR + NEW.worktree_base_commit IS NOT OLD.worktree_base_commit OR + NEW.time_created IS NOT OLD.time_created + THEN RAISE(ABORT, 'fork_admission_binding_immutable') END; + SELECT CASE WHEN NOT ( + NEW.state = OLD.state OR + (OLD.state = 'admitted' AND NEW.state IN ('provisioning', 'ready', 'recovery_required')) OR + (OLD.state = 'provisioning' AND NEW.state IN ('ready', 'recovery_required')) OR + (OLD.state = 'ready' AND NEW.state IN ('manifest_committed', 'recovery_required')) OR + (OLD.state = 'manifest_committed' AND NEW.state = 'recovery_required') + ) THEN RAISE(ABORT, 'fork_admission_invalid_state_transition') END; + SELECT CASE WHEN NEW.state = 'recovery_required' AND NEW.recovery_reason IS NULL + THEN RAISE(ABORT, 'fork_admission_recovery_without_reason') END; + SELECT CASE WHEN NEW.state != 'recovery_required' AND NEW.recovery_reason IS NOT NULL + THEN RAISE(ABORT, 'fork_admission_nonrecovery_with_reason') END; + SELECT CASE WHEN NEW.state = 'manifest_committed' AND NOT EXISTS ( + SELECT 1 FROM session_fork_intent + WHERE intent_id = NEW.intent_id AND target_session_id = NEW.target_session_id + ) THEN RAISE(ABORT, 'fork_admission_manifest_missing') END; + END + `) + + yield* tx.run(` + CREATE TRIGGER message_binding_immutable + BEFORE UPDATE ON message + WHEN NEW.id IS NOT OLD.id OR NEW.session_id IS NOT OLD.session_id + BEGIN + SELECT RAISE(ABORT, 'message_binding_immutable'); + END + `) + yield* tx.run(` + CREATE TRIGGER part_parent_validate_insert + BEFORE INSERT ON part + WHEN NOT EXISTS ( + SELECT 1 FROM message WHERE id = NEW.message_id AND session_id = NEW.session_id + ) + BEGIN + SELECT RAISE(ABORT, 'part_parent_cross_session'); + END + `) + yield* tx.run(` + CREATE TRIGGER part_binding_validate_update + BEFORE UPDATE ON part + BEGIN + SELECT CASE WHEN + NEW.id IS NOT OLD.id OR NEW.message_id IS NOT OLD.message_id OR NEW.session_id IS NOT OLD.session_id + THEN RAISE(ABORT, 'part_binding_immutable') END; + SELECT CASE WHEN NOT EXISTS ( + SELECT 1 FROM message WHERE id = NEW.message_id AND session_id = NEW.session_id + ) THEN RAISE(ABORT, 'part_parent_cross_session') END; + END + `) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260810140000_bug_012_compaction_cas.ts b/packages/core/src/database/migration/20260810140000_bug_012_compaction_cas.ts new file mode 100644 index 00000000..4f448b81 --- /dev/null +++ b/packages/core/src/database/migration/20260810140000_bug_012_compaction_cas.ts @@ -0,0 +1,86 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260810140000_bug_012_compaction_cas", + up(tx) { + return Effect.gen(function* () { + yield* tx.run("ALTER TABLE compaction_run ADD COLUMN source_window_id TEXT") + yield* tx.run("ALTER TABLE compaction_run ADD COLUMN source_effective_history_hash TEXT") + yield* tx.run("ALTER TABLE compaction_run ADD COLUMN source_message_count INTEGER") + yield* tx.run("ALTER TABLE compaction_run ADD COLUMN source_projection_version INTEGER") + yield* tx.run("ALTER TABLE compaction_run ADD COLUMN context_ledger_required INTEGER NOT NULL DEFAULT 0") + yield* tx.run("ALTER TABLE compaction_run ADD COLUMN ledger_mirrored_at INTEGER") + yield* tx.run("ALTER TABLE compaction_run ADD COLUMN bridge_carried_at INTEGER") + yield* tx.run("ALTER TABLE compaction_run ADD COLUMN continuation_wakeup_at INTEGER") + yield* tx.run("ALTER TABLE part ADD COLUMN provenance TEXT") + yield* tx.run("DROP INDEX IF EXISTS compaction_run_session_active_idx") + yield* tx.run(` + CREATE UNIQUE INDEX compaction_run_session_active_idx + ON compaction_run (session_id) + WHERE state IN ('requested', 'summarizing') + `) + yield* tx.run(` + CREATE TRIGGER compaction_run_source_binding_validate + BEFORE INSERT ON compaction_run + WHEN NEW.source_window_id IS NULL OR NEW.source_effective_history_hash IS NULL OR + NEW.source_message_count IS NULL OR NEW.source_projection_version IS NULL + BEGIN + SELECT RAISE(ABORT, 'compaction_run_source_binding_incomplete'); + END + `) + yield* tx.run(` + CREATE TRIGGER compaction_run_source_binding_immutable + BEFORE UPDATE OF source_window_id, source_effective_history_hash, source_message_count, + source_projection_version ON compaction_run + WHEN OLD.source_window_id IS NOT NULL AND ( + NEW.source_window_id IS NOT OLD.source_window_id OR + NEW.source_effective_history_hash IS NOT OLD.source_effective_history_hash OR + NEW.source_message_count IS NOT OLD.source_message_count OR + NEW.source_projection_version IS NOT OLD.source_projection_version + ) + BEGIN + SELECT RAISE(ABORT, 'compaction_run_source_binding_immutable'); + END + `) + yield* tx.run(` + CREATE TRIGGER part_provenance_validate + BEFORE INSERT ON part + WHEN NEW.provenance IS NOT NULL AND ( + json_valid(NEW.provenance) = 0 OR + json_extract(NEW.provenance, '$.source') NOT IN ('compaction_marker', 'compaction_replay', 'compaction_continue') OR + typeof(json_extract(NEW.provenance, '$.owner_session_id')) != 'text' OR + typeof(json_extract(NEW.provenance, '$.owner_prompt_epoch')) != 'integer' OR + typeof(json_extract(NEW.provenance, '$.owner_run_id')) != 'text' OR + json_extract(NEW.provenance, '$.durable') != 1 + ) + BEGIN + SELECT RAISE(ABORT, 'part_provenance_invalid'); + END + `) + yield* tx.run(` + CREATE TRIGGER part_provenance_immutable + BEFORE UPDATE OF provenance ON part + WHEN OLD.provenance IS NOT NULL AND NEW.provenance IS NOT OLD.provenance + BEGIN + SELECT RAISE(ABORT, 'part_provenance_immutable'); + END + `) + yield* tx.run(` + CREATE TRIGGER part_provenance_validate_update + BEFORE UPDATE OF provenance ON part + WHEN NEW.provenance IS NOT NULL AND ( + json_valid(NEW.provenance) = 0 OR + json_extract(NEW.provenance, '$.source') NOT IN ('compaction_marker', 'compaction_replay', 'compaction_continue') OR + typeof(json_extract(NEW.provenance, '$.owner_session_id')) != 'text' OR + typeof(json_extract(NEW.provenance, '$.owner_prompt_epoch')) != 'integer' OR + typeof(json_extract(NEW.provenance, '$.owner_run_id')) != 'text' OR + json_extract(NEW.provenance, '$.durable') != 1 + ) + BEGIN + SELECT RAISE(ABORT, 'part_provenance_invalid'); + END + `) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260810150000_provider_receipt_authority.ts b/packages/core/src/database/migration/20260810150000_provider_receipt_authority.ts new file mode 100644 index 00000000..fe145316 --- /dev/null +++ b/packages/core/src/database/migration/20260810150000_provider_receipt_authority.ts @@ -0,0 +1,102 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260810150000_provider_receipt_authority", + up(tx) { + return Effect.gen(function* () { + yield* tx.run("ALTER TABLE session_tool_request_receipt ADD COLUMN request_input_hash TEXT") + yield* tx.run("ALTER TABLE session_tool_request_receipt ADD COLUMN final_request_hash TEXT") + yield* tx.run( + "ALTER TABLE session_tool_request_receipt ADD COLUMN provider_state TEXT NOT NULL DEFAULT 'preparing'", + ) + yield* tx.run("ALTER TABLE session_tool_request_receipt ADD COLUMN adapter_prepared_at INTEGER") + yield* tx.run("ALTER TABLE session_tool_request_receipt ADD COLUMN dispatching_at INTEGER") + yield* tx.run("ALTER TABLE session_tool_request_receipt ADD COLUMN streaming_at INTEGER") + yield* tx.run("ALTER TABLE session_tool_request_receipt ADD COLUMN terminal_at INTEGER") + yield* tx.run("ALTER TABLE session_tool_request_receipt ADD COLUMN response_fingerprint TEXT") + yield* tx.run("ALTER TABLE session_tool_request_receipt ADD COLUMN owner_token TEXT") + yield* tx.run(` + UPDATE session_tool_request_receipt + SET provider_state = CASE request_state + WHEN 'dispatched' THEN 'indeterminate_after_crash' + ELSE 'failed' + END, + terminal_at = created_at, + request_error_code = CASE + WHEN request_state = 'dispatched' THEN 'legacy_dispatch_outcome_unknown' + WHEN request_state = 'rejected' THEN COALESCE(request_error_code, 'request_rejected') + ELSE 'legacy_prepared_without_lifecycle' + END + `) + yield* tx.run(` + CREATE TRIGGER session_tool_request_receipt_provider_transition + BEFORE UPDATE OF provider_state ON session_tool_request_receipt + WHEN NEW.provider_state != OLD.provider_state AND NOT ( + (OLD.provider_state = 'preparing' AND NEW.provider_state IN ('prepared', 'failed')) OR + (OLD.provider_state = 'prepared' AND NEW.provider_state IN ('dispatching', 'failed')) OR + (OLD.provider_state = 'dispatching' AND NEW.provider_state IN ('streaming', 'settled', 'failed', 'indeterminate_after_crash')) OR + (OLD.provider_state = 'streaming' AND NEW.provider_state IN ('settled', 'failed', 'indeterminate_after_crash')) + ) + BEGIN + SELECT RAISE(ABORT, 'illegal provider receipt transition'); + END + `) + yield* tx.run(` + CREATE TRIGGER session_tool_request_receipt_dispatch_guard + BEFORE UPDATE OF provider_state ON session_tool_request_receipt + WHEN NEW.provider_state = 'dispatching' AND ( + NEW.final_request_hash IS NULL OR + NEW.adapter_prepared_at IS NULL OR + NEW.prompt_epoch IS NULL OR + NEW.prompt_window_id IS NULL OR + NEW.effective_history_hash IS NULL + ) + BEGIN + SELECT RAISE(ABORT, 'provider dispatch requires final request authority'); + END + `) + yield* tx.run(` + CREATE TRIGGER session_tool_request_receipt_binding_immutable + BEFORE UPDATE ON session_tool_request_receipt + WHEN NEW.receipt_id != OLD.receipt_id + OR NEW.request_ordinal != OLD.request_ordinal + OR NEW.session_id != OLD.session_id + OR NEW.user_message_id != OLD.user_message_id + OR NEW.assistant_message_id IS NOT OLD.assistant_message_id + OR NEW.provider_id != OLD.provider_id + OR NEW.model_id != OLD.model_id + OR NEW.protocol IS NOT OLD.protocol + OR NEW.prompt_epoch IS NOT OLD.prompt_epoch + OR NEW.prompt_window_id IS NOT OLD.prompt_window_id + OR NEW.effective_history_hash IS NOT OLD.effective_history_hash + OR NEW.world_state_baseline_hash IS NOT OLD.world_state_baseline_hash + OR NEW.request_input_hash IS NOT OLD.request_input_hash + OR NEW.owner_token IS NOT OLD.owner_token + OR NEW.created_at != OLD.created_at + OR (OLD.final_request_hash IS NOT NULL AND NEW.final_request_hash IS NOT OLD.final_request_hash) + OR (OLD.prompt_cache_key IS NOT NULL AND NEW.prompt_cache_key IS NOT OLD.prompt_cache_key) + OR (OLD.response_fingerprint IS NOT NULL AND NEW.response_fingerprint IS NOT OLD.response_fingerprint) + BEGIN + SELECT RAISE(ABORT, 'provider receipt binding is immutable'); + END + `) + yield* tx.run(` + CREATE TRIGGER session_tool_request_receipt_response_guard + BEFORE UPDATE OF response_fingerprint ON session_tool_request_receipt + WHEN NEW.response_fingerprint IS NOT OLD.response_fingerprint AND ( + OLD.response_fingerprint IS NOT NULL OR + NEW.response_fingerprint IS NULL OR + OLD.provider_state NOT IN ('settled', 'failed') + ) + BEGIN + SELECT RAISE(ABORT, 'provider response fingerprint requires terminal receipt'); + END + `) + yield* tx.run(` + CREATE INDEX session_tool_request_receipt_provider_state_idx + ON session_tool_request_receipt (session_id, provider_state, created_at) + `) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260810160000_compaction_continuation_admission.ts b/packages/core/src/database/migration/20260810160000_compaction_continuation_admission.ts new file mode 100644 index 00000000..ff37ea1c --- /dev/null +++ b/packages/core/src/database/migration/20260810160000_compaction_continuation_admission.ts @@ -0,0 +1,197 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260810160000_compaction_continuation_admission", + up(tx) { + return Effect.gen(function* () { + yield* tx.run("ALTER TABLE compaction_run ADD COLUMN continuation_state TEXT") + yield* tx.run("ALTER TABLE compaction_run ADD COLUMN continuation_receipt_id TEXT") + yield* tx.run("ALTER TABLE compaction_run ADD COLUMN continuation_admitted_at INTEGER") + yield* tx.run("ALTER TABLE compaction_run ADD COLUMN continuation_dispatching_at INTEGER") + yield* tx.run("ALTER TABLE compaction_run ADD COLUMN continuation_terminal_at INTEGER") + yield* tx.run("ALTER TABLE compaction_run ADD COLUMN continuation_error_code TEXT") + yield* tx.run("DROP TRIGGER IF EXISTS session_tool_request_receipt_response_guard") + yield* tx.run(` + CREATE TRIGGER session_tool_request_receipt_response_guard + BEFORE UPDATE OF response_fingerprint ON session_tool_request_receipt + WHEN NEW.response_fingerprint IS NOT OLD.response_fingerprint AND ( + OLD.response_fingerprint IS NOT NULL OR + NEW.response_fingerprint IS NULL OR + NEW.provider_state NOT IN ('settled', 'failed') + ) + BEGIN + SELECT RAISE(ABORT, 'provider response fingerprint requires terminal receipt'); + END + `) + + // Recover the strongest state available from the old wakeup timestamp and its physical + // continuation receipt. A wakeup without a receipt is the historical pre-admission crash gap + // and must become pending so the startup recovery loop can safely retry it. + yield* tx.run(` + UPDATE compaction_run + SET continuation_receipt_id = ( + SELECT receipt.receipt_id + FROM compaction_artifact artifact + JOIN session_tool_request_receipt receipt + ON receipt.session_id = artifact.session_id + AND receipt.user_message_id = artifact.message_id + WHERE artifact.run_id = compaction_run.run_id + AND artifact.state = 'committed' + AND artifact.kind IN ('replay', 'continue') + ORDER BY receipt.request_ordinal DESC + LIMIT 1 + ) + WHERE state = 'committed' + AND EXISTS ( + SELECT 1 FROM compaction_artifact artifact + WHERE artifact.run_id = compaction_run.run_id + AND artifact.state = 'committed' + AND artifact.kind IN ('replay', 'continue') + ) + `) + yield* tx.run(` + UPDATE compaction_run + SET continuation_state = CASE + WHEN continuation_receipt_id IS NULL THEN 'pending' + WHEN (SELECT provider_state FROM session_tool_request_receipt + WHERE receipt_id = continuation_receipt_id) IN ('preparing', 'prepared') THEN 'admitted' + WHEN (SELECT provider_state FROM session_tool_request_receipt + WHERE receipt_id = continuation_receipt_id) IN ('dispatching', 'streaming') THEN 'dispatching' + WHEN (SELECT provider_state FROM session_tool_request_receipt + WHERE receipt_id = continuation_receipt_id) = 'settled' AND + (SELECT response_fingerprint FROM session_tool_request_receipt + WHERE receipt_id = continuation_receipt_id) IS NOT NULL THEN 'settled' + WHEN (SELECT provider_state FROM session_tool_request_receipt + WHERE receipt_id = continuation_receipt_id) = 'failed' AND + (SELECT response_fingerprint FROM session_tool_request_receipt + WHERE receipt_id = continuation_receipt_id) IS NOT NULL THEN 'failed' + WHEN (SELECT provider_state FROM session_tool_request_receipt + WHERE receipt_id = continuation_receipt_id) = 'failed' AND + (SELECT dispatching_at FROM session_tool_request_receipt + WHERE receipt_id = continuation_receipt_id) IS NULL THEN 'pending' + WHEN (SELECT provider_state FROM session_tool_request_receipt + WHERE receipt_id = continuation_receipt_id) = 'indeterminate_after_crash' THEN 'indeterminate' + ELSE 'indeterminate' + END, + continuation_admitted_at = CASE + WHEN continuation_receipt_id IS NOT NULL THEN COALESCE( + continuation_wakeup_at, + (SELECT created_at FROM session_tool_request_receipt WHERE receipt_id = continuation_receipt_id) + ) + ELSE NULL + END, + continuation_dispatching_at = CASE + WHEN continuation_receipt_id IS NOT NULL AND + (SELECT provider_state FROM session_tool_request_receipt + WHERE receipt_id = continuation_receipt_id) IN + ('dispatching', 'streaming', 'settled', 'failed', 'indeterminate_after_crash') + THEN COALESCE( + (SELECT dispatching_at FROM session_tool_request_receipt + WHERE receipt_id = continuation_receipt_id), + (SELECT created_at FROM session_tool_request_receipt + WHERE receipt_id = continuation_receipt_id) + ) + ELSE NULL + END, + continuation_terminal_at = CASE + WHEN continuation_receipt_id IS NOT NULL AND + (SELECT provider_state FROM session_tool_request_receipt + WHERE receipt_id = continuation_receipt_id) IN + ('settled', 'failed', 'indeterminate_after_crash') + THEN COALESCE( + (SELECT terminal_at FROM session_tool_request_receipt + WHERE receipt_id = continuation_receipt_id), + (SELECT created_at FROM session_tool_request_receipt + WHERE receipt_id = continuation_receipt_id) + ) + ELSE NULL + END, + continuation_error_code = CASE + WHEN continuation_receipt_id IS NOT NULL + THEN (SELECT request_error_code FROM session_tool_request_receipt + WHERE receipt_id = continuation_receipt_id) + ELSE CASE WHEN continuation_wakeup_at IS NOT NULL + THEN 'legacy_wakeup_without_provider_admission' ELSE NULL END + END + WHERE continuation_state IS NULL + AND EXISTS ( + SELECT 1 FROM compaction_artifact artifact + WHERE artifact.run_id = compaction_run.run_id + AND artifact.state = 'committed' + AND artifact.kind IN ('replay', 'continue') + ) + `) + yield* tx.run(` + UPDATE compaction_run + SET continuation_receipt_id = NULL, + continuation_admitted_at = NULL, + continuation_dispatching_at = NULL, + continuation_terminal_at = NULL, + continuation_error_code = 'legacy_failed_without_provider_dispatch', + continuation_wakeup_at = NULL + WHERE continuation_state = 'pending' + AND continuation_receipt_id IS NOT NULL + `) + yield* tx.run(` + CREATE INDEX compaction_run_continuation_recovery_idx + ON compaction_run (continuation_state, session_id) + WHERE state = 'committed' AND continuation_state IS NOT NULL + `) + yield* tx.run(` + CREATE UNIQUE INDEX compaction_run_continuation_receipt_idx + ON compaction_run (continuation_receipt_id) + WHERE continuation_receipt_id IS NOT NULL + `) + yield* tx.run(` + CREATE TRIGGER compaction_run_continuation_state_validate + BEFORE UPDATE OF continuation_state ON compaction_run + WHEN NEW.continuation_state IS NOT NULL AND NEW.continuation_state NOT IN + ('pending', 'admitted', 'dispatching', 'settled', 'failed', 'indeterminate') + BEGIN + SELECT RAISE(ABORT, 'invalid compaction continuation state'); + END + `) + yield* tx.run(` + CREATE TRIGGER compaction_run_continuation_transition + BEFORE UPDATE OF continuation_state ON compaction_run + WHEN OLD.continuation_state IS NOT NEW.continuation_state AND NOT ( + (OLD.continuation_state IS NULL AND NEW.continuation_state = 'pending') OR + (OLD.continuation_state = 'pending' AND NEW.continuation_state = 'admitted') OR + (OLD.continuation_state = 'admitted' AND NEW.continuation_state IN ('pending', 'dispatching', 'failed')) OR + (OLD.continuation_state = 'dispatching' AND NEW.continuation_state IN ('settled', 'failed', 'indeterminate')) + ) + BEGIN + SELECT RAISE(ABORT, 'illegal compaction continuation transition'); + END + `) + yield* tx.run(` + CREATE TRIGGER compaction_run_continuation_binding_validate + BEFORE UPDATE ON compaction_run + WHEN + (NEW.continuation_state IN ('admitted', 'dispatching', 'settled', 'failed', 'indeterminate') AND + (NEW.continuation_receipt_id IS NULL OR NEW.continuation_admitted_at IS NULL)) OR + (NEW.continuation_state IN ('dispatching', 'settled', 'failed', 'indeterminate') AND + NEW.continuation_dispatching_at IS NULL AND NEW.continuation_state != 'failed') OR + (NEW.continuation_state IN ('settled', 'failed', 'indeterminate') AND + NEW.continuation_terminal_at IS NULL) + BEGIN + SELECT RAISE(ABORT, 'incomplete compaction continuation binding'); + END + `) + yield* tx.run(` + CREATE TRIGGER compaction_run_continuation_response_validate + BEFORE UPDATE OF continuation_state ON compaction_run + WHEN NEW.continuation_state IN ('settled', 'failed') AND NOT EXISTS ( + SELECT 1 + FROM session_tool_request_receipt receipt + WHERE receipt.receipt_id = NEW.continuation_receipt_id + AND receipt.response_fingerprint IS NOT NULL + ) + BEGIN + SELECT RAISE(ABORT, 'compaction continuation response is not durable'); + END + `) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260810170000_part_integrity_backfill.ts b/packages/core/src/database/migration/20260810170000_part_integrity_backfill.ts new file mode 100644 index 00000000..6c8eeb8c --- /dev/null +++ b/packages/core/src/database/migration/20260810170000_part_integrity_backfill.ts @@ -0,0 +1,64 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260810170000_part_integrity_backfill", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + CREATE TABLE IF NOT EXISTS session_part_integrity_quarantine ( + part_id TEXT NOT NULL PRIMARY KEY, + message_id TEXT NOT NULL, + part_session_id TEXT NOT NULL, + message_session_id TEXT NOT NULL, + reason TEXT NOT NULL, + quarantined_at INTEGER NOT NULL + ) + `) + const now = Date.now() + yield* tx.run(` + INSERT OR IGNORE INTO session_part_integrity_quarantine + (part_id, message_id, part_session_id, message_session_id, reason, quarantined_at) + SELECT part.id, part.message_id, part.session_id, message.session_id, + 'part_parent_cross_session', ${now} + FROM part + JOIN message ON message.id = part.message_id + WHERE part.session_id IS NOT message.session_id + `) + yield* tx.run(` + UPDATE session_prompt_epoch + SET authority_state = 'recovery_required', + recovery_reason = 'legacy cross-session Part rows quarantined' + WHERE state = 'active' + AND session_id IN ( + SELECT part_session_id FROM session_part_integrity_quarantine + UNION + SELECT message_session_id FROM session_part_integrity_quarantine + ) + `) + yield* tx.run(` + INSERT OR IGNORE INTO session_history_state + (session_id, state, reason, time_created, time_updated) + SELECT affected.session_id, 'recovery_required', + 'legacy cross-session Part rows quarantined', ${now}, ${now} + FROM ( + SELECT part_session_id AS session_id FROM session_part_integrity_quarantine + UNION + SELECT message_session_id AS session_id FROM session_part_integrity_quarantine + ) affected + JOIN session ON session.id = affected.session_id + `) + yield* tx.run(` + UPDATE session_history_state + SET state = 'recovery_required', + reason = 'legacy cross-session Part rows quarantined', + time_updated = ${now} + WHERE session_id IN ( + SELECT part_session_id FROM session_part_integrity_quarantine + UNION + SELECT message_session_id FROM session_part_integrity_quarantine + ) + `) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/deepagent/context/world-state.ts b/packages/core/src/deepagent/context/world-state.ts index 77d580ec..a201262b 100644 --- a/packages/core/src/deepagent/context/world-state.ts +++ b/packages/core/src/deepagent/context/world-state.ts @@ -44,6 +44,8 @@ const KIND_LABEL: Record = { env: "Environment", } +export const renderSlot = (slot: WorldStateSlot): string => `## ${KIND_LABEL[slot.kind]}\n${slot.value.trim()}` + const orderOf = (kind: WorldStateSlotKind): number => { const i = KIND_ORDER.indexOf(kind) return i < 0 ? KIND_ORDER.length : i @@ -103,7 +105,7 @@ export const collectSlots = ( export const renderWorldState = (ws: WorldState): string => { const present = sortSlots(ws.slots).filter((s) => s.value.trim().length > 0) if (present.length === 0) return "" - const sections = present.map((s) => `## ${KIND_LABEL[s.kind]}\n${s.value.trim()}`) + const sections = present.map(renderSlot) return [ "", "Current environment / file / diagnostics facts (latest values, re-injected — trust these over any", diff --git a/packages/core/src/deepagent/plan-controller.ts b/packages/core/src/deepagent/plan-controller.ts index c6d7edf1..e7f8cba8 100644 --- a/packages/core/src/deepagent/plan-controller.ts +++ b/packages/core/src/deepagent/plan-controller.ts @@ -222,7 +222,8 @@ export type PlanWriteInput = { readonly assigned_agent?: string | null readonly note?: string | null }[] - readonly active_step_id: string | null + /** Omit to derive from the single active step after missing step IDs are allocated. */ + readonly active_step_id?: string | null } const isRecord = (value: unknown): value is Record => @@ -243,7 +244,10 @@ export const decodePlanWriteInput = (value: unknown): PlanWriteInput | null => { ) return null if (typeof value.goal !== "string" || !Array.isArray(value.steps)) return null - if (!(value.active_step_id === null || typeof value.active_step_id === "string")) return null + if ( + !(value.active_step_id === undefined || value.active_step_id === null || typeof value.active_step_id === "string") + ) + return null if (value.replan_reason !== undefined && typeof value.replan_reason !== "string") return null if ( value.assumptions !== undefined && @@ -276,7 +280,7 @@ export const decodePlanWriteInput = (value: unknown): PlanWriteInput | null => { goal: value.goal, ...(Array.isArray(value.assumptions) ? { assumptions: value.assumptions as string[] } : {}), steps: steps.filter((step): step is NonNullable => step != null), - active_step_id: value.active_step_id, + ...(value.active_step_id !== undefined ? { active_step_id: value.active_step_id } : {}), } } @@ -330,7 +334,7 @@ export class PlanConflictError extends Error { } } -const normalizeStatus = (status: string): PlanStepStatus | undefined => { +export const normalizePlanStepStatus = (status: string): PlanStepStatus | undefined => { const normalized = status.trim().toLowerCase() if (STEP_STATUSES.has(normalized as PlanStepStatus)) return normalized as PlanStepStatus return STATUS_ALIASES[normalized] @@ -419,7 +423,11 @@ export const planProgressFingerprint = (plan: PlanDoc): string => })), }) -const requireExpected = (input: PlanWriteInput, previous: PlanDoc | null, ref: PlanExpected | null): void => { +export const requirePlanWriteExpected = ( + input: Pick, + previous: PlanDoc | null, + ref: PlanExpected | null, +): void => { if (input.operation === "create") { if (input.expected_plan_id !== null || input.expected_version !== null) { throw new PlanValidationError("invalid_precondition", [], previous?.plan_id ?? null, ref?.version ?? null) @@ -456,7 +464,7 @@ export const buildPlanFromWriteInput = ( if (!("create" === input.operation || "advance" === input.operation || "replan" === input.operation)) { throw new PlanValidationError("invalid_operation") } - requireExpected(input, previous, ref) + requirePlanWriteExpected(input, previous, ref) if (normalizedText(input.goal) === "") throw new PlanValidationError("empty_goal", [], previous?.plan_id ?? null, ref?.version ?? null) if (input.steps.length === 0) @@ -493,7 +501,7 @@ export const buildPlanFromWriteInput = ( if (normalizedText(step.title) === "") { throw new PlanValidationError("empty_title", [], previous?.plan_id ?? null, ref?.version ?? null) } - const status = normalizeStatus(step.status) + const status = normalizePlanStepStatus(step.status) if (!status) throw new PlanValidationError("invalid_status", [], previous?.plan_id ?? null, ref?.version ?? null) const suppliedID = normalizedText(step.step_id) if (input.operation === "advance" && suppliedID === "") { @@ -543,16 +551,14 @@ export const buildPlanFromWriteInput = ( "multiple_active_steps", active.map((step) => step.step_id), ) - if (input.active_step_id !== null && !used.has(input.active_step_id)) { - throw new PlanValidationError("invalid_active_step", [input.active_step_id]) + const activeStepID = input.active_step_id === undefined ? (active[0]?.step_id ?? null) : input.active_step_id + if (activeStepID !== null && !used.has(activeStepID)) { + throw new PlanValidationError("invalid_active_step", [activeStepID]) } - if ( - (input.active_step_id === null && active.length > 0) || - (input.active_step_id !== null && active[0]?.step_id !== input.active_step_id) - ) { + if ((activeStepID === null && active.length > 0) || (activeStepID !== null && active[0]?.step_id !== activeStepID)) { throw new PlanValidationError( "invalid_active_step", - input.active_step_id ? [input.active_step_id] : active.map((step) => step.step_id), + activeStepID ? [activeStepID] : active.map((step) => step.step_id), ) } const blocked = steps.filter((step) => step.status === "blocked" && normalizedText(step.note) === "") @@ -570,7 +576,7 @@ export const buildPlanFromWriteInput = ( (value) => value.trim(), ), steps, - active_step_id: input.active_step_id, + active_step_id: activeStepID, replan_reason: input.operation === "replan" ? normalizedText(input.replan_reason) : (previous?.replan_reason ?? null), last_write_activity_id: null, @@ -761,22 +767,41 @@ export const planStatusesChanged = (previous: PlanDoc | null | undefined, next: export const formatStepChange = (c: StepStatusChange): string => c.from === null ? `${c.title}: →${c.to}` : `${c.title}: ${c.from}→${c.to}` -// Compact, constant-size plan snapshot re-injected into context each turn (high+ only) so the model -// can SEE its own checklist and report against it. One line per step; the full form includes goal + -// progress, while tool continuations omit the already-adjacent goal. We deliberately omit -// acceptance/assumptions/evidence so it cannot grow with history. +// Compact, constant-size plan snapshot re-injected into context so the model can SEE its checklist +// and copy every model-owned identity parameter without consulting history. One line per step; the +// full form includes goal + progress, while tool continuations omit the already-adjacent goal. We +// deliberately omit acceptance/assumptions/evidence so it cannot grow with history. The model-facing +// plan tool treats advance as a server-merged status patch, so those omitted server-owned fields are +// recovered from the authoritative document rather than reconstructed from this compact snapshot. +const renderPlanContextValue = (value: string): string => + JSON.stringify(value).replaceAll("<", "\\u003c").replaceAll(">", "\\u003e").replaceAll("&", "\\u0026") + export const renderPlanSnapshot = (plan: PlanDoc, detail: "full" | "continuation" = "full"): string => { const { done, total } = planProgress(plan) const active = plan.steps.find((s) => s.step_id === plan.active_step_id) ?? null - const lines = plan.steps.map((s) => `[${STATUS_MARK[s.status]}] ${s.title}`) + const lines = plan.steps.map( + (s) => + `[${STATUS_MARK[s.status]}] step_id=${renderPlanContextValue(s.step_id)} status=${renderPlanContextValue(s.status)} title=${renderPlanContextValue(s.title)}${s.status === "blocked" && s.note != null ? ` note=${renderPlanContextValue(s.note)}` : ""}`, + ) const header = detail === "continuation" ? `Current plan (${done}/${total} done)` - : `Current plan (${done}/${total} done) — goal: ${plan.goal}` - const activeLine = active ? `Active step: ${active.title}` : "No step is marked active." + : `Current plan (${done}/${total} done) — goal: ${renderPlanContextValue(plan.goal)}` + const activeLine = active + ? `Active step: active_step_id=${renderPlanContextValue(active.step_id)} title=${renderPlanContextValue(active.title)}` + : "Active step: active_step_id=null" return `${header}\n${lines.join("\n")}\n${activeLine}` } +export const renderPlanWritePrecondition = (planID: string, version: number): string => + `Plan write precondition: expected_plan_id=${renderPlanContextValue(planID)} expected_version=${version}` + +export const renderPlanWriteContext = ( + plan: PlanDoc, + version: number, + detail: "full" | "continuation" = "full", +): string => `${renderPlanSnapshot(plan, detail)}\n${renderPlanWritePrecondition(plan.plan_id, version)}` + // Progress-nudge budget (the COUNT BACKSTOP of the hybrid trigger). This is deliberately NOT the // primary signal: raw edit count conflates "the step is genuinely large" with "the model forgot to // report". The count only guarantees the model is reminded eventually when no semantic boundary diff --git a/packages/core/src/deepagent/prompt-policy.ts b/packages/core/src/deepagent/prompt-policy.ts index c4c19a41..c0300fab 100644 --- a/packages/core/src/deepagent/prompt-policy.ts +++ b/packages/core/src/deepagent/prompt-policy.ts @@ -159,7 +159,7 @@ export const buildSystemPrompt = (ctx: PromptContext): string => { // Volatile per-turn state that must NOT enter the cached base system prompt. Rendered into a single // `` block that the caller appends after durable history. The stable system // prompt establishes this tag as trusted runtime control. Only buildSystemPrompt must stay stable. -export const buildVolatileRoundContext = (ctx: PromptContext): string => { +export const buildVolatileRoundContext = (ctx: PromptContext, runtimeControl?: string): string => { const sections: string[] = [] // Round + activation stage: the model's sense of "where am I in the loop". Was previously baked @@ -210,25 +210,41 @@ export const buildVolatileRoundContext = (ctx: PromptContext): string => { ) } - const body = sections.filter(Boolean).join("\n\n") - if (!body) return "" - return ["", body, ""].join("\n") + if (runtimeControl) sections.push(runtimeControl) + return wrapVolatileRoundContext(sections) } // Tool continuations already have the current user request, assistant decision, tool call, and tool // result in adjacent durable history. Repeating the full activation/task/previous-results block after // every tool result makes that control block look like a fresh user request and can induce semantic // restatement loops. Keep only an explicit, constant-size continuation directive in the volatile tail; -// live plan state is appended separately by the request layer. -export const buildVolatileContinuationContext = (): string => - [ - "", - "# Tool continuation", - "", - "Continue directly from the immediately preceding tool result.", - "Apply runtime and plan control state silently. Do not restate or re-summarize the user request, the current phase, or conclusions already established unless the tool result materially changes them.", - "", - ].join("\n") +// live plan state is included in the same trusted control block by the request layer. +export const buildVolatileContinuationContext = (runtimeControl?: string): string => + wrapVolatileRoundContext([ + [ + "# Tool continuation", + "", + "Continue directly from the immediately preceding tool result.", + "Apply runtime and plan control state silently. Do not restate or re-summarize the user request, the current phase, or conclusions already established unless the tool result materially changes them.", + ].join("\n"), + ...(runtimeControl ? [runtimeControl] : []), + ]) + +export const buildVolatilePlanContext = (runtimeControl: string): string => + wrapVolatileRoundContext([ + [ + "# Plan control", + "", + "Apply this runtime plan state silently. Copy required plan tool parameters exactly as shown; never infer identities or versions from conversation history, titles, or positions.", + ].join("\n"), + runtimeControl, + ]) + +const wrapVolatileRoundContext = (sections: string[]): string => { + const body = sections.filter(Boolean).join("\n\n") + if (!body) return "" + return ["", body, ""].join("\n") +} const identitySection = (mode: AgentMode): string => { // P2-1: ultra must not fall through to the High label. Each strength has its own label. diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index a2183d1d..30b14168 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -138,9 +138,15 @@ export function definitions() { export interface PublishOptions { readonly id?: ID + /** Accept an exact retry of a synchronized event with the same explicit ID without re-projecting or re-publishing it. */ + readonly idempotent?: boolean readonly metadata?: Record readonly location?: Location.Ref - /** Local operational projection committed atomically with a new synchronized event. Not replayed or serialized. */ + /** + * Local operational projection committed atomically with a synchronized event. Exact idempotent + * publish retries run this hook again so a caller can repair a missing local receipt; the hook must + * therefore use an idempotent write or CAS. It is not replayed from the serialized event log. + */ readonly commit?: (seq: number) => Effect.Effect } @@ -221,6 +227,7 @@ export const layerWith = (options?: LayerOptions) => readonly strictOwner?: boolean }, commit?: (seq: number) => Effect.Effect, + idempotent = false, ) { return Effect.gen(function* () { const definition = registry.get(event.type) @@ -318,11 +325,26 @@ export const layerWith = (options?: LayerOptions) => ) } const stored = yield* db - .select({ aggregateID: EventTable.aggregate_id, seq: EventTable.seq }) + .select({ + aggregateID: EventTable.aggregate_id, + seq: EventTable.seq, + type: EventTable.type, + data: EventTable.data, + }) .from(EventTable) .where(eq(EventTable.id, event.id)) .get() .pipe(Effect.orDie) + if ( + stored && + idempotent && + stored.aggregateID === aggregateID && + stored.type === versionedType(definition.type, sync.version) && + isDeepStrictEqual(stored.data, encoded) + ) { + if (commit) yield* commit(stored.seq) + return { aggregateID, seq: stored.seq, inserted: false } + } if (stored) yield* Effect.die( new InvalidSyncEventError({ @@ -362,12 +384,12 @@ export const layerWith = (options?: LayerOptions) => ]) .run() .pipe(Effect.orDie) - return { aggregateID, seq } + return { aggregateID, seq, inserted: true } }), { behavior: "immediate" }, ) .pipe(Effect.orDie) - if (committed) { + if (committed?.inserted) { yield* Effect.forEach( synchronized.get(committed.aggregateID) ?? [], (pubsub) => PubSub.publish(pubsub, undefined), @@ -382,7 +404,11 @@ export const layerWith = (options?: LayerOptions) => }) } - function publishEvent(event: Payload, commit?: PublishOptions["commit"]) { + function publishEvent( + event: Payload, + commit?: PublishOptions["commit"], + idempotent = false, + ) { return Effect.gen(function* () { const durable = registry.get(event.type)?.sync !== undefined if (!durable && commit) @@ -393,11 +419,15 @@ export const layerWith = (options?: LayerOptions) => }), ) if (durable) { - const committed = yield* commitSyncEvent(event as Payload, undefined, commit) + const committed = yield* commitSyncEvent(event as Payload, undefined, commit, idempotent) if (committed) { event = { ...event, seq: committed.seq } - yield* Effect.forEach(syncHandlers, (sync) => observe(event as Payload, "sync", sync), { discard: true }) - yield* notify(event as Payload, true) + if (committed.inserted) { + yield* Effect.forEach(syncHandlers, (sync) => observe(event as Payload, "sync", sync), { + discard: true, + }) + yield* notify(event as Payload, true) + } return event } } @@ -432,6 +462,14 @@ export const layerWith = (options?: LayerOptions) => function publish(definition: D, data: Data, options?: PublishOptions) { return Effect.gen(function* () { + if (options?.idempotent && (!options.id || definition.sync === undefined)) { + return yield* Effect.die( + new InvalidSyncEventError({ + type: definition.type, + message: "Idempotent publish requires a synchronized event and an explicit event ID", + }), + ) + } const serviceLocation = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service)) const location = options?.location ?? @@ -448,6 +486,7 @@ export const layerWith = (options?: LayerOptions) => data, } as Payload, options?.commit, + options?.idempotent, ) }) } diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index 0968d100..99765f73 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -111,6 +111,10 @@ type Input = { const estimate = (value: unknown) => Token.estimate(JSON.stringify(value)) +export const inputBudget = (context: number, buffer: number) => Math.max(0, context - buffer) + +const modelInputLimit = (model: Model) => model.route.defaults.limits?.input ?? model.route.defaults.limits?.context + const truncate = (value: string) => value.length <= TOOL_OUTPUT_MAX_CHARS ? value : `${value.slice(0, TOOL_OUTPUT_MAX_CHARS)}\n[truncated]` @@ -173,26 +177,33 @@ const select = ( .filter(Boolean) if (conversation.length === 0) return let total = 0 - let split = conversation.length - let splitPrefix = "" - let splitSuffix = "" for (let index = conversation.length - 1; index >= 0; index--) { const next = total + Token.estimate(conversation[index]) if (next > tokens) { const remaining = Math.max(0, tokens - total) * 4 - if (remaining > 0) { - splitPrefix = conversation[index].slice(0, -remaining) - splitSuffix = conversation[index].slice(-remaining) - split = index + 1 + if (remaining <= 0) + return { + head: conversation.slice(0, index + 1).join("\n\n"), + recent: conversation.slice(index + 1).join("\n\n"), + } + const boundary = conversation[index].length - remaining + const splitAt = + boundary > 0 && + boundary < conversation[index].length && + /[\uD800-\uDBFF]/.test(conversation[index][boundary - 1]) && + /[\uDC00-\uDFFF]/.test(conversation[index][boundary]) + ? boundary - 1 + : boundary + return { + head: [...conversation.slice(0, index), conversation[index].slice(0, splitAt)].filter(Boolean).join("\n\n"), + recent: [conversation[index].slice(splitAt), ...conversation.slice(index + 1)].filter(Boolean).join("\n\n"), } - break } total = next - split = index } return { - head: [...conversation.slice(0, split), splitPrefix].filter(Boolean).join("\n\n"), - recent: [splitSuffix, ...conversation.slice(split)].filter(Boolean).join("\n\n"), + head: "", + recent: conversation.join("\n\n"), } } @@ -214,7 +225,7 @@ export const buildPrompt = (input: { export const make = (dependencies: Dependencies) => { const config = settings(dependencies.config) const compactAfterOverflow = Effect.fn("SessionCompaction.compactAfterOverflow")(function* (input: Input) { - const context = input.model.route.defaults.limits?.context + const context = modelInputLimit(input.model) if (context === undefined || context <= 0) return false const output = input.request.generation?.maxTokens ?? input.model.route.defaults.limits?.output ?? 0 const selected = select(input.entries, config.tokens) @@ -225,7 +236,7 @@ export const make = (dependencies: Dependencies) => { context: [previousSummary?.type === "compaction" ? previousSummary.recent : "", selected.head].filter(Boolean), }) const summaryOutput = Math.min(output || SUMMARY_OUTPUT_TOKENS, SUMMARY_OUTPUT_TOKENS) - if (Token.estimate(summaryPrompt) > context - summaryOutput) return false + if (Token.estimate(summaryPrompt) > Math.max(0, context - summaryOutput)) return false const messageID = SessionMessage.ID.create() yield* dependencies.events.publish(SessionEvent.Compaction.Started, { sessionID: input.sessionID, @@ -268,12 +279,11 @@ export const make = (dependencies: Dependencies) => { }) const compactIfNeeded = Effect.fn("SessionCompaction.compactIfNeeded")(function* (input: Input) { if (!config.auto) return false - const context = input.model.route.defaults.limits?.context + const context = modelInputLimit(input.model) if (context === undefined || context <= 0) return false - const output = input.request.generation?.maxTokens ?? input.model.route.defaults.limits?.output ?? 0 if ( estimate({ system: input.request.system, messages: input.request.messages, tools: input.request.tools }) <= - context - Math.max(output, config.buffer) + inputBudget(context, config.buffer) ) return false return yield* compactAfterOverflow(input) diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 663be8b4..e0dcc4c7 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -268,6 +268,14 @@ export const layer = Layer.effectDiscard( const id = event.data.info.id const sessionID = event.data.info.sessionID const data = messageData(event.data.info) + const existing = yield* db + .select({ session_id: MessageTable.session_id }) + .from(MessageTable) + .where(eq(MessageTable.id, id)) + .get() + .pipe(Effect.orDie) + if (existing && existing.session_id !== sessionID) + return yield* Effect.die(`SessionProjector: message ${id} cannot move between sessions`) yield* db .insert(MessageTable) .values({ id, session_id: sessionID, time_created, data }) @@ -300,14 +308,26 @@ export const layer = Layer.effectDiscard( const row = yield* db .select() .from(PartTable) - .where(and(eq(PartTable.id, event.data.partID), eq(PartTable.session_id, event.data.sessionID))) + .where( + and( + eq(PartTable.id, event.data.partID), + eq(PartTable.message_id, event.data.messageID), + eq(PartTable.session_id, event.data.sessionID), + ), + ) .get() .pipe(Effect.orDie) const previous = row && usage(row.data) if (previous) yield* applyUsage(db, event.data.sessionID, previous, -1) yield* db .delete(PartTable) - .where(and(eq(PartTable.id, event.data.partID), eq(PartTable.session_id, event.data.sessionID))) + .where( + and( + eq(PartTable.id, event.data.partID), + eq(PartTable.message_id, event.data.messageID), + eq(PartTable.session_id, event.data.sessionID), + ), + ) .run() .pipe(Effect.orDie) }), @@ -318,7 +338,17 @@ export const layer = Layer.effectDiscard( const messageID = event.data.part.messageID const sessionID = event.data.part.sessionID const data = partData(event.data.part) + const parent = yield* db + .select({ session_id: MessageTable.session_id }) + .from(MessageTable) + .where(eq(MessageTable.id, messageID)) + .get() + .pipe(Effect.orDie) + if (!parent || parent.session_id !== sessionID) + return yield* Effect.die(`SessionProjector: part ${id} has a cross-session parent message`) const row = yield* db.select().from(PartTable).where(eq(PartTable.id, id)).get().pipe(Effect.orDie) + if (row && (row.message_id !== messageID || row.session_id !== sessionID)) + return yield* Effect.die(`SessionProjector: part ${id} cannot move between messages or sessions`) yield* db .insert(PartTable) .values({ id, message_id: messageID, session_id: sessionID, time_created: event.data.time, data }) diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index f3cbbcc1..7abe062d 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -65,7 +65,7 @@ const withDefaults = (model: ModelV2.Info, route: AnyRoute) => { generation: model.request.generation, providerOptions: namespace && Object.keys(options).length > 0 ? { [namespace]: options } : undefined, http: { body: httpBody }, - limits: { context: model.limit.context, output: model.limit.output }, + limits: { context: model.limit.context, input: model.limit.input, output: model.limit.output }, }) } diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index 260ed7b2..4f409e4c 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -106,6 +106,13 @@ export const PartTable = sqliteTable( .notNull() .references(() => MessageTable.id, { onDelete: "cascade" }), session_id: text().$type().notNull(), + provenance: text({ mode: "json" }).$type<{ + source: "compaction_marker" | "compaction_replay" | "compaction_continue" + owner_session_id: SessionSchema.ID + owner_prompt_epoch: number + owner_run_id: string + durable: true + }>(), ...Timestamps, data: text({ mode: "json" }).notNull().$type(), }, @@ -115,6 +122,15 @@ export const PartTable = sqliteTable( ], ) +export const SessionPartIntegrityQuarantineTable = sqliteTable("session_part_integrity_quarantine", { + part_id: text().$type().primaryKey(), + message_id: text().$type().notNull(), + part_session_id: text().$type().notNull(), + message_session_id: text().$type().notNull(), + reason: text().notNull(), + quarantined_at: integer().notNull(), +}) + // DEPRECATED (task-tracking unification): the `todowrite` tool that wrote this table was removed in // favor of the `plan` system. No LLM-facing tool writes here anymore. The table is retained (not // dropped) for migration safety and so the existing read/REST path keeps working for historical @@ -216,6 +232,7 @@ export const SessionSteerTable = sqliteTable( time_created: integer() .notNull() .$default(() => Date.now()), + materialized_at: integer(), }, (table) => [ index("session_steer_session_pending_seq_idx").on(table.session_id, table.consumed_seq, table.seq), @@ -223,6 +240,48 @@ export const SessionSteerTable = sqliteTable( ], ) +// Durable admission for a fork operation. Unlike SessionForkIntentTable, this row is created before +// any managed worktree or child Session exists, so retries can adopt the exact provisioned resources +// after a crash instead of allocating replacements. SessionForkIntentTable remains the committed +// child-history manifest and the only authority that makes the child runnable. +export const SessionForkAdmissionTable = sqliteTable( + "session_fork_admission", + { + intent_id: text().primaryKey(), + request_hash: text().notNull(), + fork_mode: text().$type<"foreground" | "task">().notNull(), + source_session_id: text() + .$type() + .notNull() + .references(() => SessionTable.id, { onDelete: "cascade" }), + source_prompt_epoch: integer().notNull(), + source_window_id: text().notNull(), + source_effective_history_hash: text().notNull(), + source_mutation_epoch: integer().notNull(), + source_message_count: integer().notNull(), + source_cutoff_message_id: text().$type(), + projection_version: integer().notNull(), + sanitation_policy_version: integer().notNull(), + requested_directory: text(), + isolation_mode: text().$type<"none" | "worktree">().notNull(), + requested_target_session_id: text().$type(), + target_session_id: text().$type().notNull().unique(), + child_depth: integer(), + task_request_hash: text(), + worktree_directory: text(), + worktree_branch: text(), + worktree_base_commit: text(), + state: text().$type<"admitted" | "provisioning" | "ready" | "manifest_committed" | "recovery_required">().notNull(), + recovery_reason: text(), + time_created: integer().notNull(), + time_updated: integer().notNull(), + }, + (table) => [ + index("session_fork_admission_source_idx").on(table.source_session_id, table.time_created), + index("session_fork_admission_recovery_idx").on(table.state, table.time_updated), + ], +) + export const SessionIntentTable = sqliteTable( "session_intent", { @@ -253,6 +312,110 @@ export const SessionIntentTable = sqliteTable( ], ) +export const SessionHistoryStateTable = sqliteTable("session_history_state", { + session_id: text() + .$type() + .primaryKey() + .references(() => SessionTable.id, { onDelete: "cascade" }), + state: text().$type<"ready" | "provisioning" | "recovery_required">().notNull(), + reason: text(), + time_created: integer().notNull(), + time_updated: integer().notNull(), +}) + +// Immutable ordered replacement membership for the legacy Session prompt authority. +// The row is the durable model-visible base; physical messages added after the +// epoch boundary are appended by the production projector. +export const SessionPromptEpochMessageTable = sqliteTable( + "session_prompt_epoch_message", + { + session_id: text() + .$type() + .notNull() + .references(() => SessionTable.id, { onDelete: "cascade" }), + prompt_epoch: integer().notNull(), + ordinal: integer().notNull(), + message_id: text() + .$type() + .notNull() + .references(() => MessageTable.id, { onDelete: "cascade" }), + }, + (table) => [ + primaryKey({ columns: [table.session_id, table.prompt_epoch, table.ordinal] }), + uniqueIndex("session_prompt_epoch_message_identity_idx").on(table.session_id, table.prompt_epoch, table.message_id), + index("session_prompt_epoch_message_lookup_idx").on(table.session_id, table.prompt_epoch, table.message_id), + ], +) + +export const SessionForkIntentTable = sqliteTable( + "session_fork_intent", + { + intent_id: text().primaryKey(), + request_hash: text().notNull(), + fork_mode: text().$type<"foreground" | "task">().notNull(), + source_session_id: text() + .$type() + .notNull() + .references(() => SessionTable.id, { onDelete: "cascade" }), + source_prompt_epoch: integer().notNull(), + source_window_id: text().notNull(), + source_effective_history_hash: text().notNull(), + source_mutation_epoch: integer().notNull(), + source_message_count: integer().notNull(), + source_cutoff_message_id: text().$type(), + projection_version: integer().notNull(), + sanitation_policy_version: integer().notNull(), + target_session_id: text() + .$type() + .notNull() + .unique() + .references(() => SessionTable.id, { onDelete: "cascade" }), + target_prompt_epoch: integer().notNull(), + target_window_id: text().notNull(), + target_effective_history_hash: text().notNull(), + target_world_state_baseline_hash: text().notNull(), + cloned_message_count: integer().notNull(), + cloned_part_count: integer().notNull(), + state: text().$type<"prepared" | "committed" | "publishing" | "complete" | "recovery_required">().notNull(), + event_cursor: integer().notNull().default(0), + event_count: integer().notNull(), + delivery_owner: text(), + lease_expires_at: integer(), + delivery_attempts: integer().notNull().default(0), + recovery_reason: text(), + time_created: integer().notNull(), + time_updated: integer().notNull(), + time_committed: integer(), + time_completed: integer(), + side_effects_completed_at: integer(), + }, + (table) => [ + index("session_fork_intent_source_idx").on(table.source_session_id, table.time_created), + index("session_fork_intent_delivery_idx").on(table.state, table.time_updated), + ], +) + +export const SessionWorldStateBaselineTable = sqliteTable( + "session_world_state_baseline", + { + session_id: text() + .$type() + .notNull() + .references(() => SessionTable.id, { onDelete: "cascade" }), + prompt_epoch: integer().notNull(), + section_id: text().notNull(), + snapshot: text({ mode: "json" }).$type().notNull(), + fragment: text().notNull(), + fragment_hash: text().notNull(), + provenance: text().$type<"native" | "fork_rebuilt" | "legacy_migration">().notNull(), + created_at: integer().notNull(), + }, + (table) => [ + primaryKey({ columns: [table.session_id, table.prompt_epoch, table.section_id] }), + index("session_world_state_baseline_epoch_idx").on(table.session_id, table.prompt_epoch), + ], +) + export const SessionContextEpochTable = sqliteTable("session_context_epoch", { session_id: text() .$type() diff --git a/packages/core/src/util/canonical-json.ts b/packages/core/src/util/canonical-json.ts new file mode 100644 index 00000000..67630302 --- /dev/null +++ b/packages/core/src/util/canonical-json.ts @@ -0,0 +1,27 @@ +export function stringify(input: unknown) { + return JSON.stringify(normalize(input, new WeakSet())) +} + +function normalize(input: unknown, ancestors: WeakSet): unknown { + if (input === undefined || input === null) return null + if (typeof input === "string" || typeof input === "boolean") return input + if (typeof input === "number") return Number.isFinite(input) ? input : null + if (typeof input === "bigint" || typeof input === "function" || typeof input === "symbol") { + throw new TypeError(`Unsupported canonical JSON value: ${typeof input}`) + } + if (ancestors.has(input)) throw new TypeError("Canonical JSON cannot encode cyclic values") + + ancestors.add(input) + const result = Array.isArray(input) + ? input.map((item) => normalize(item, ancestors)) + : Object.fromEntries( + Object.keys(input) + .sort() + .filter((key) => (input as Record)[key] !== undefined) + .map((key) => [key, normalize((input as Record)[key], ancestors)]), + ) + ancestors.delete(input) + return result +} + +export * as CanonicalJson from "./canonical-json" diff --git a/packages/core/src/v1/session.ts b/packages/core/src/v1/session.ts index 111dee0f..026af69f 100644 --- a/packages/core/src/v1/session.ts +++ b/packages/core/src/v1/session.ts @@ -213,6 +213,9 @@ export const CompactionPart = Schema.Struct({ auto: Schema.Boolean, overflow: Schema.optional(Schema.Boolean), tail_start_id: Schema.optional(MessageID), + // Active prompt-history estimate committed with the new epoch. The UI uses it until the next + // ordinary provider turn reports measured input usage. + context_tokens: Schema.optional(NonNegativeInt), }).annotate({ identifier: "CompactionPart" }) export type CompactionPart = Types.DeepMutable> diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index 6a1c8416..e410647a 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -24,6 +24,8 @@ import { AbsolutePath } from "@deepagent-code/core/schema" import { SessionSchema } from "@deepagent-code/core/session/schema" import { SessionTable } from "@deepagent-code/core/session/sql" import sessionMetadataMigration from "@deepagent-code/core/database/migration/20260511173437_session-metadata" +import compactionContinuationAdmissionMigration from "@deepagent-code/core/database/migration/20260810160000_compaction_continuation_admission" +import partIntegrityBackfillMigration from "@deepagent-code/core/database/migration/20260810170000_part_integrity_backfill" import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient" import { Database } from "@deepagent-code/core/database/database" import { tmpdir } from "./fixture/tmpdir" @@ -106,6 +108,25 @@ describe("DatabaseMigration", () => { sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_tool_argument_receipt'`, ), ).toEqual({ name: "session_tool_argument_receipt" }) + expect( + yield* db.get( + sql`SELECT name FROM pragma_table_info('session_fork_intent') WHERE name = 'side_effects_completed_at'`, + ), + ).toEqual({ name: "side_effects_completed_at" }) + expect( + yield* db.get( + sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'session_fork_intent_side_effects_idx'`, + ), + ).toEqual({ name: "session_fork_intent_side_effects_idx" }) + expect( + yield* db.all( + sql`SELECT name FROM pragma_table_info('session_tool_request_receipt') WHERE name IN ('provider_request_hash', 'response_chain_reuse_decision', 'response_chain_refusal_reason') ORDER BY name`, + ), + ).toEqual([ + { name: "provider_request_hash" }, + { name: "response_chain_refusal_reason" }, + { name: "response_chain_reuse_decision" }, + ]) expect( yield* db.all( sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('session_tool_argument_receipt_call_idx', 'session_tool_argument_receipt_created_idx') ORDER BY name`, @@ -176,6 +197,357 @@ describe("DatabaseMigration", () => { ) }) + test("enforces provider receipt lifecycle and compaction part provenance", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* DatabaseMigration.apply(db) + yield* db.run(sql` + INSERT INTO session_tool_request_receipt ( + receipt_id, request_ordinal, session_id, user_message_id, provider_id, model_id, + registry_tool_ids, permission_filtered_tool_ids, final_offered_tool_ids, call_ids, + request_state, provider_state, prompt_epoch, prompt_window_id, effective_history_hash, + request_input_hash, owner_token, created_at + ) VALUES ( + 'receipt-provider-lifecycle', 1, 'session-provider-lifecycle', 'message-provider-lifecycle', + 'provider-test', 'model-test', '[]', '[]', '[]', '[]', 'prepared', 'preparing', + 0, 'window-0', 'history-0', 'input-hash', 'owner-1', 1 + ) + `) + + expect( + Exit.isFailure( + yield* db + .run( + sql` + UPDATE session_tool_request_receipt + SET provider_state = 'dispatching' + WHERE receipt_id = 'receipt-provider-lifecycle' + `, + ) + .pipe(Effect.exit), + ), + ).toBe(true) + yield* db.run(sql` + UPDATE session_tool_request_receipt + SET provider_state = 'prepared', final_request_hash = 'final-hash', + adapter_prepared_at = 2, provider_request_hash = 'final-hash' + WHERE receipt_id = 'receipt-provider-lifecycle' + `) + yield* db.run(sql` + UPDATE session_tool_request_receipt + SET provider_state = 'dispatching', dispatching_at = 3, request_state = 'dispatched' + WHERE receipt_id = 'receipt-provider-lifecycle' + `) + expect( + Exit.isFailure( + yield* db + .run( + sql` + UPDATE session_tool_request_receipt + SET provider_state = 'prepared' + WHERE receipt_id = 'receipt-provider-lifecycle' + `, + ) + .pipe(Effect.exit), + ), + ).toBe(true) + yield* db.run(sql` + UPDATE session_tool_request_receipt + SET provider_state = 'streaming', streaming_at = 4 + WHERE receipt_id = 'receipt-provider-lifecycle' + `) + yield* db.run(sql` + UPDATE session_tool_request_receipt + SET provider_state = 'settled', terminal_at = 5 + WHERE receipt_id = 'receipt-provider-lifecycle' + `) + yield* db.run(sql` + UPDATE session_tool_request_receipt + SET response_fingerprint = 'response-hash' + WHERE receipt_id = 'receipt-provider-lifecycle' + `) + expect( + Exit.isFailure( + yield* db + .run( + sql` + UPDATE session_tool_request_receipt + SET final_request_hash = 'different-final-hash' + WHERE receipt_id = 'receipt-provider-lifecycle' + `, + ) + .pipe(Effect.exit), + ), + ).toBe(true) + + yield* db.run(sql` + INSERT INTO project (id, worktree, sandboxes, time_created, time_updated) + VALUES ('project-provenance', '/repo', '[]', 1, 1) + `) + yield* db.run(sql` + INSERT INTO session ( + id, project_id, slug, directory, title, version, time_created, time_updated + ) VALUES ( + 'session-provenance', 'project-provenance', 'provenance', '/repo', 'Provenance', '1', 1, 1 + ) + `) + yield* db.run(sql` + INSERT INTO message (id, session_id, time_created, time_updated, data) + VALUES ('message-provenance', 'session-provenance', 1, 1, '{}') + `) + yield* db.run(sql` + INSERT INTO session ( + id, project_id, slug, directory, title, version, time_created, time_updated + ) VALUES ( + 'session-provenance-other', 'project-provenance', 'provenance-other', '/repo', + 'Provenance other', '1', 1, 1 + ) + `) + yield* db.run(sql` + INSERT INTO message (id, session_id, time_created, time_updated, data) + VALUES ('message-provenance-other', 'session-provenance-other', 1, 1, '{}') + `) + expect( + Exit.isFailure( + yield* db + .run( + sql` + INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) + VALUES ( + 'part-cross-session', 'message-provenance', 'session-provenance-other', + 1, 1, '{"type":"text","text":"x"}' + ) + `, + ) + .pipe(Effect.exit), + ), + ).toBe(true) + expect( + Exit.isFailure( + yield* db + .run( + sql` + INSERT INTO part (id, message_id, session_id, provenance, time_created, time_updated, data) + VALUES ( + 'part-invalid-provenance', 'message-provenance', 'session-provenance', + '{"source":"unknown","durable":true}', 1, 1, '{"type":"text","text":"x"}' + ) + `, + ) + .pipe(Effect.exit), + ), + ).toBe(true) + yield* db.run(sql` + INSERT INTO part (id, message_id, session_id, provenance, time_created, time_updated, data) + VALUES ( + 'part-valid-provenance', 'message-provenance', 'session-provenance', + '{"source":"compaction_continue","owner_session_id":"session-provenance","owner_prompt_epoch":1,"owner_run_id":"run-1","durable":true}', + 1, 1, '{"type":"text","text":"x"}' + ) + `) + expect( + Exit.isFailure( + yield* db + .run( + sql` + UPDATE part + SET message_id = 'message-provenance-other', session_id = 'session-provenance-other' + WHERE id = 'part-valid-provenance' + `, + ) + .pipe(Effect.exit), + ), + ).toBe(true) + expect( + Exit.isFailure( + yield* db + .run( + sql` + UPDATE part + SET provenance = '{"source":"compaction_continue","owner_session_id":"session-provenance","owner_prompt_epoch":2,"owner_run_id":"run-1","durable":true}' + WHERE id = 'part-valid-provenance' + `, + ) + .pipe(Effect.exit), + ), + ).toBe(true) + }), + ) + }) + + test("enforces durable prompt history recovery state invariants", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* DatabaseMigration.apply(db) + yield* db.run(sql` + INSERT INTO project (id, worktree, sandboxes, time_created, time_updated) + VALUES ('project-history-authority', '/repo', '[]', 1, 1) + `) + yield* db.run(sql` + INSERT INTO session ( + id, project_id, slug, directory, title, version, time_created, time_updated + ) VALUES ( + 'session-history-authority', 'project-history-authority', 'history-authority', '/repo', + 'History authority', '1', 1, 1 + ) + `) + yield* db.run(sql` + INSERT INTO session_prompt_epoch ( + session_id, epoch, state, reason, created_at, authority_state, + projection_version, canonicalization_version, base_message_count, + effective_history_hash, first_window_id, window_id + ) VALUES ( + 'session-history-authority', 0, 'active', 'bootstrap', 1, 'ready', + 1, 1, 0, 'history-hash', 'window-0', 'window-0' + ) + `) + + expect( + Exit.isFailure( + yield* db + .run( + sql` + UPDATE session_prompt_epoch + SET authority_state = 'recovery_required' + WHERE session_id = 'session-history-authority' AND epoch = 0 + `, + ) + .pipe(Effect.exit), + ), + ).toBe(true) + expect( + Exit.isFailure( + yield* db + .run( + sql` + INSERT INTO session_history_state ( + session_id, state, reason, time_created, time_updated + ) VALUES ( + 'session-history-authority', 'recovery_required', 'corrupt history', 1, 1 + ) + `, + ) + .pipe(Effect.exit), + ), + ).toBe(true) + + yield* db.run(sql` + UPDATE session_prompt_epoch + SET authority_state = 'recovery_required', recovery_reason = 'corrupt history' + WHERE session_id = 'session-history-authority' AND epoch = 0 + `) + yield* db.run(sql` + INSERT INTO session_history_state ( + session_id, state, reason, time_created, time_updated + ) VALUES ( + 'session-history-authority', 'recovery_required', 'corrupt history', 1, 1 + ) + `) + + expect( + Exit.isFailure( + yield* db + .run( + sql` + UPDATE session_prompt_epoch + SET authority_state = 'ready', recovery_reason = NULL + WHERE session_id = 'session-history-authority' AND epoch = 0 + `, + ) + .pipe(Effect.exit), + ), + ).toBe(true) + expect( + Exit.isFailure( + yield* db + .run( + sql` + UPDATE session_history_state + SET reason = NULL + WHERE session_id = 'session-history-authority' + `, + ) + .pipe(Effect.exit), + ), + ).toBe(true) + expect( + yield* db.get( + sql` + SELECT authority_state, recovery_reason + FROM session_prompt_epoch + WHERE session_id = 'session-history-authority' AND epoch = 0 + `, + ), + ).toEqual({ authority_state: "recovery_required", recovery_reason: "corrupt history" }) + expect( + yield* db.get( + sql` + SELECT state, reason + FROM session_history_state + WHERE session_id = 'session-history-authority' + `, + ), + ).toEqual({ state: "recovery_required", reason: "corrupt history" }) + + yield* db.run(sql` + INSERT INTO session ( + id, project_id, slug, directory, title, version, time_created, time_updated + ) VALUES ( + 'session-missing-authority', 'project-history-authority', 'missing-authority', '/repo', + 'Missing authority', '1', 2, 2 + ) + `) + yield* db.run(sql` + INSERT INTO message (id, session_id, time_created, time_updated, data) + VALUES ('message-missing-authority', 'session-missing-authority', 2, 2, '{"role":"user"}') + `) + yield* db.run(sql` + INSERT INTO session_prompt_epoch ( + session_id, epoch, state, reason, created_at, authority_state, + projection_version, canonicalization_version, base_message_count, + effective_history_hash, first_window_id, window_id, source_end_message_id + ) VALUES ( + 'session-missing-authority', 0, 'active', 'bootstrap', 2, 'ready', + 1, 1, 0, 'missing-history-hash', 'missing-window-0', 'missing-window-0', + 'message-missing-authority' + ) + `) + yield* db.run(sql`DELETE FROM message WHERE id = 'message-missing-authority'`) + + expect( + Exit.isFailure( + yield* db + .run( + sql` + UPDATE session_prompt_epoch + SET recovery_reason = NULL + WHERE session_id = 'session-missing-authority' AND epoch = 0 + `, + ) + .pipe(Effect.exit), + ), + ).toBe(true) + yield* db.run(sql` + UPDATE session_prompt_epoch + SET authority_state = 'recovery_required', recovery_reason = 'referenced message missing' + WHERE session_id = 'session-missing-authority' AND epoch = 0 + `) + expect( + yield* db.get(sql` + SELECT authority_state, recovery_reason + FROM session_prompt_epoch + WHERE session_id = 'session-missing-authority' AND epoch = 0 + `), + ).toEqual({ + authority_state: "recovery_required", + recovery_reason: "referenced message missing", + }) + }), + ) + }) + test("adds nullable Session suspension without inferring historical recovery", async () => { await run( Effect.gen(function* () { @@ -827,4 +1199,233 @@ describe("DatabaseMigration", () => { }), ) }) + + test("compaction continuation admission migrates legacy wakeups from durable provider evidence", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* db.run(sql` + CREATE TABLE compaction_run ( + run_id text PRIMARY KEY NOT NULL, + session_id text NOT NULL, + state text NOT NULL, + continuation_wakeup_at integer + ) + `) + yield* db.run(sql` + CREATE TABLE compaction_artifact ( + run_id text NOT NULL, + session_id text NOT NULL, + message_id text NOT NULL, + state text NOT NULL, + kind text NOT NULL + ) + `) + yield* db.run(sql` + CREATE TABLE session_tool_request_receipt ( + receipt_id text PRIMARY KEY NOT NULL, + request_ordinal integer NOT NULL, + session_id text NOT NULL, + user_message_id text NOT NULL, + provider_state text NOT NULL, + dispatching_at integer, + terminal_at integer, + request_error_code text, + response_fingerprint text, + created_at integer NOT NULL + ) + `) + yield* db.run(sql` + INSERT INTO compaction_run (run_id, session_id, state, continuation_wakeup_at) VALUES + ('orphan-wakeup', 'ses_orphan', 'committed', 10), + ('prepared-wakeup', 'ses_prepared', 'committed', 20), + ('dispatching-wakeup', 'ses_dispatching', 'committed', 30), + ('settled-without-response', 'ses_incomplete', 'committed', 40) + `) + yield* db.run(sql` + INSERT INTO compaction_artifact (run_id, session_id, message_id, state, kind) VALUES + ('orphan-wakeup', 'ses_orphan', 'msg_orphan', 'committed', 'continue'), + ('prepared-wakeup', 'ses_prepared', 'msg_prepared', 'committed', 'continue'), + ('dispatching-wakeup', 'ses_dispatching', 'msg_dispatching', 'committed', 'continue'), + ('settled-without-response', 'ses_incomplete', 'msg_incomplete', 'committed', 'continue') + `) + yield* db.run(sql` + INSERT INTO session_tool_request_receipt + (receipt_id, request_ordinal, session_id, user_message_id, provider_state, + dispatching_at, terminal_at, request_error_code, created_at) + VALUES + ('receipt-prepared', 1, 'ses_prepared', 'msg_prepared', 'prepared', NULL, NULL, NULL, 21), + ('receipt-dispatching', 1, 'ses_dispatching', 'msg_dispatching', 'dispatching', 31, NULL, NULL, 31), + ('receipt-incomplete', 1, 'ses_incomplete', 'msg_incomplete', 'settled', 41, 42, NULL, 41) + `) + + yield* DatabaseMigration.applyOnly(db, [compactionContinuationAdmissionMigration]) + + expect( + yield* db.all(sql` + SELECT run_id, continuation_state, continuation_receipt_id, + continuation_admitted_at, continuation_dispatching_at, continuation_error_code + FROM compaction_run + ORDER BY run_id + `), + ).toEqual([ + { + run_id: "dispatching-wakeup", + continuation_state: "dispatching", + continuation_receipt_id: "receipt-dispatching", + continuation_admitted_at: 30, + continuation_dispatching_at: 31, + continuation_error_code: null, + }, + { + run_id: "orphan-wakeup", + continuation_state: "pending", + continuation_receipt_id: null, + continuation_admitted_at: null, + continuation_dispatching_at: null, + continuation_error_code: "legacy_wakeup_without_provider_admission", + }, + { + run_id: "prepared-wakeup", + continuation_state: "admitted", + continuation_receipt_id: "receipt-prepared", + continuation_admitted_at: 20, + continuation_dispatching_at: null, + continuation_error_code: null, + }, + { + run_id: "settled-without-response", + continuation_state: "indeterminate", + continuation_receipt_id: "receipt-incomplete", + continuation_admitted_at: 40, + continuation_dispatching_at: 41, + continuation_error_code: null, + }, + ]) + expect( + Exit.isFailure( + yield* db + .run( + sql` + UPDATE compaction_run + SET continuation_state = 'settled', continuation_terminal_at = 40 + WHERE run_id = 'orphan-wakeup' + `, + ) + .pipe(Effect.exit), + ), + ).toBe(true) + expect( + Exit.isFailure( + yield* db + .run( + sql` + UPDATE compaction_run + SET continuation_state = 'settled', continuation_terminal_at = 40 + WHERE run_id = 'dispatching-wakeup' + `, + ) + .pipe(Effect.exit), + ), + ).toBe(true) + yield* db.run(sql` + UPDATE session_tool_request_receipt + SET provider_state = 'settled', response_fingerprint = 'response-hash', terminal_at = 40 + WHERE receipt_id = 'receipt-dispatching' + `) + yield* db.run(sql` + UPDATE compaction_run + SET continuation_state = 'settled', continuation_terminal_at = 40 + WHERE run_id = 'dispatching-wakeup' + `) + expect( + yield* db.get(sql` + SELECT continuation_state, continuation_terminal_at + FROM compaction_run + WHERE run_id = 'dispatching-wakeup' + `), + ).toEqual({ continuation_state: "settled", continuation_terminal_at: 40 }) + }), + ) + }) + + test("part integrity backfill quarantines pre-existing cross-session rows and blocks history", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* db.run(sql` + CREATE TABLE project (id text PRIMARY KEY NOT NULL, worktree text, sandboxes text, + time_created integer NOT NULL, time_updated integer NOT NULL) + `) + yield* db.run(sql` + CREATE TABLE session (id text PRIMARY KEY NOT NULL, project_id text NOT NULL, + slug text NOT NULL, directory text NOT NULL, title text NOT NULL, version text NOT NULL, + time_created integer NOT NULL, time_updated integer NOT NULL) + `) + yield* db.run(sql` + CREATE TABLE message (id text PRIMARY KEY NOT NULL, session_id text NOT NULL, + time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL) + `) + yield* db.run(sql` + CREATE TABLE part (id text PRIMARY KEY NOT NULL, message_id text NOT NULL, + session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, + data text NOT NULL) + `) + yield* db.run(sql` + CREATE TABLE session_prompt_epoch (session_id text NOT NULL, epoch integer NOT NULL, + state text NOT NULL, authority_state text, recovery_reason text, + PRIMARY KEY (session_id, epoch)) + `) + yield* db.run(sql` + CREATE TABLE session_history_state (session_id text PRIMARY KEY NOT NULL, + state text NOT NULL, reason text, time_created integer NOT NULL, time_updated integer NOT NULL, + FOREIGN KEY (session_id) REFERENCES session(id) ON DELETE CASCADE) + `) + yield* db.run(sql`PRAGMA foreign_keys = ON`) + yield* db.run(sql`INSERT INTO project VALUES ('p', '/repo', '[]', 1, 1)`) + yield* db.run(sql`INSERT INTO session VALUES + ('session-a', 'p', 'a', '/repo', 'A', '1', 1, 1), + ('session-b', 'p', 'b', '/repo', 'B', '1', 1, 1)`) + yield* db.run(sql`INSERT INTO message VALUES ('message-a', 'session-a', 1, 1, '{}')`) + yield* db.run(sql`INSERT INTO part VALUES + ('part-bad', 'message-a', 'session-b', 1, 1, '{"type":"text","text":"secret"}'), + ('part-missing-session', 'message-a', 'session-missing', 1, 1, '{"type":"text","text":"orphan"}')`) + yield* db.run(sql`INSERT INTO session_prompt_epoch VALUES ('session-a', 0, 'active', 'legacy_pending', NULL)`) + yield* db.run(sql`INSERT INTO session_prompt_epoch VALUES ('session-b', 0, 'active', 'legacy_pending', NULL)`) + + yield* DatabaseMigration.applyOnly(db, [partIntegrityBackfillMigration]) + + expect( + yield* db.all( + sql`SELECT part_id, message_id, part_session_id, message_session_id, reason FROM session_part_integrity_quarantine ORDER BY part_id`, + ), + ).toEqual([ + { + part_id: "part-bad", + message_id: "message-a", + part_session_id: "session-b", + message_session_id: "session-a", + reason: "part_parent_cross_session", + }, + { + part_id: "part-missing-session", + message_id: "message-a", + part_session_id: "session-missing", + message_session_id: "session-a", + reason: "part_parent_cross_session", + }, + ]) + expect(yield* db.all(sql`SELECT session_id, state FROM session_history_state ORDER BY session_id`)).toEqual([ + { session_id: "session-a", state: "recovery_required" }, + { session_id: "session-b", state: "recovery_required" }, + ]) + expect( + yield* db.all(sql`SELECT session_id, authority_state FROM session_prompt_epoch ORDER BY session_id`), + ).toEqual([ + { session_id: "session-a", authority_state: "recovery_required" }, + { session_id: "session-b", authority_state: "recovery_required" }, + ]) + }), + ) + }) }) diff --git a/packages/core/test/deepagent/plan-controller.test.ts b/packages/core/test/deepagent/plan-controller.test.ts index db00d248..fd993181 100644 --- a/packages/core/test/deepagent/plan-controller.test.ts +++ b/packages/core/test/deepagent/plan-controller.test.ts @@ -22,6 +22,8 @@ import { planStatusesChanged, formatStepChange, renderPlanSnapshot, + renderPlanWriteContext, + renderPlanWritePrecondition, shouldNudgeReport, nudgeTrigger, nudgeMutationThreshold, @@ -277,6 +279,39 @@ describe("strict plan write admission", () => { expect(plan.active_step_id).toBe("s1") }) + test("derives active_step_id after allocating missing create step IDs", () => { + const write = input({ + steps: [ + { title: "implement", status: "active", acceptance: "tests pass" }, + { title: "verify", status: "pending", acceptance: "review complete" }, + ], + active_step_id: undefined, + }) + const decoded = decodePlanWriteInput({ + ...write, + active_step_id: undefined, + }) + const plan = buildPlanFromWriteInput("s1", decoded!, null, null) + + expect(decoded).not.toBeNull() + expect(plan.active_step_id).toBe(plan.steps[0]!.step_id) + expect(plan.steps[0]!.step_id).toStartWith("step_") + }) + + test("keeps explicit null distinct from omitted active-step derivation", () => { + expect(() => + buildPlanFromWriteInput( + "s1", + input({ + steps: [{ title: "implement", status: "active", acceptance: "tests pass" }], + active_step_id: null, + }), + null, + null, + ), + ).toThrow("invalid_active_step") + }) + test("decodes untrusted plan writes and hashes normalized semantics without leaking content", () => { const value = input({ goal: " ship a reliable change ", @@ -684,13 +719,48 @@ describe("plan snapshot render", () => { ) const out = renderPlanSnapshot(plan) expect(out).toContain("Current plan (1/4 done)") - expect(out).toContain("[x] build") - expect(out).toContain("[>] test") - expect(out).toContain("[!] deploy") - expect(out).toContain("[ ] docs") - expect(out).toContain("Active step: test") + expect(out).toContain('[x] step_id="s1" status="done" title="build"') + expect(out).toContain('[>] step_id="s2" status="active" title="test"') + expect(out).toContain('[!] step_id="s3" status="blocked" title="deploy" note="creds"') + expect(out).toContain('[ ] step_id="s4" status="pending" title="docs"') + expect(out).toContain('Active step: active_step_id="s2" title="test"') expect(out).toContain("goal:") expect(renderPlanSnapshot(plan, "continuation")).not.toContain("goal:") + expect(renderPlanWritePrecondition(plan.plan_id, 7)).toBe( + `Plan write precondition: expected_plan_id=${JSON.stringify(plan.plan_id)} expected_version=7`, + ) + expect(renderPlanWriteContext(plan, 7)).toContain( + `Plan write precondition: expected_plan_id=${JSON.stringify(plan.plan_id)} expected_version=7`, + ) + }) + + test("renders an explicit null active step instead of making the model infer it", () => { + expect(renderPlanSnapshot(mkPlan([{ step_id: "s1", title: "done", status: "done" }], null))).toContain( + "Active step: active_step_id=null", + ) + }) + + test("escapes Plan data so it cannot close trusted runtime-control tags", () => { + const plan = { + ...mkPlan( + [ + { + step_id: "s1", + title: "build ", + status: "blocked" as const, + note: "wait ", + }, + ], + "s1", + ), + goal: "ship \n", + } + const out = renderPlanSnapshot(plan) + + expect(out).not.toContain("") + expect(out).not.toContain("") + expect(out).toContain("\\u003c/plan-status\\u003e") + expect(out).toContain("\\u003c/deepagent-round-context\\u003e") }) }) diff --git a/packages/core/test/deepagent/prompt-policy.test.ts b/packages/core/test/deepagent/prompt-policy.test.ts index 0c5248ce..a5e4d280 100644 --- a/packages/core/test/deepagent/prompt-policy.test.ts +++ b/packages/core/test/deepagent/prompt-policy.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import { buildSystemPrompt, buildVolatileContinuationContext, + buildVolatilePlanContext, buildVolatileRoundContext, type PromptContext, } from "../../src/deepagent/prompt-policy" @@ -220,4 +221,24 @@ describe("buildVolatileRoundContext", () => { expect(vol).not.toContain("# Previous Round Results") expect(vol).not.toContain("Token budget remaining") }) + + test("keeps runtime control inside the round context marker", () => { + const vol = buildVolatileRoundContext(ctxAt(2, 80_000), "exact parameters") + expect(vol.indexOf("")).toBeGreaterThan(vol.indexOf("")) + expect(vol.indexOf("")).toBeLessThan(vol.indexOf("")) + }) + + test("keeps runtime control inside the tool continuation marker", () => { + const vol = buildVolatileContinuationContext("exact parameters") + expect(vol.indexOf("")).toBeGreaterThan(vol.indexOf("")) + expect(vol.indexOf("")).toBeLessThan(vol.indexOf("")) + }) + + test("wraps plan-only runtime control with explicit no-inference guidance", () => { + const vol = buildVolatilePlanContext("exact parameters") + expect(vol).toStartWith("") + expect(vol).toContain("Copy required plan tool parameters exactly as shown") + expect(vol).toContain("never infer identities or versions") + expect(vol).toEndWith("") + }) }) diff --git a/packages/core/test/event.test.ts b/packages/core/test/event.test.ts index 47bffa70..f64e502e 100644 --- a/packages/core/test/event.test.ts +++ b/packages/core/test/event.test.ts @@ -417,6 +417,59 @@ describe("EventV2", () => { }), ) + it.effect("accepts only exact idempotent retries and replays their local commit hook", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const { db } = yield* Database.Service + const aggregateID = EventV2.ID.create() + const eventID = EventV2.ID.create() + const received = new Array() + let commitCount = 0 + yield* events.listen((event) => Effect.sync(() => received.push(event))) + + const first = yield* events.publish( + SyncMessage, + { id: aggregateID, text: "durable" }, + { + id: eventID, + idempotent: true, + commit: () => Effect.sync(() => commitCount++), + }, + ) + const retry = yield* events.publish( + SyncMessage, + { id: aggregateID, text: "durable" }, + { + id: eventID, + idempotent: true, + commit: () => Effect.sync(() => commitCount++), + }, + ) + const divergent = yield* events + .publish(SyncMessage, { id: aggregateID, text: "different" }, { id: eventID, idempotent: true }) + .pipe(Effect.exit) + const rows = yield* db.select().from(EventTable).where(eq(EventTable.id, eventID)).all().pipe(Effect.orDie) + + expect(first.seq).toBe(0) + expect(retry.seq).toBe(0) + expect(rows).toHaveLength(1) + expect(received).toHaveLength(1) + expect(commitCount).toBe(2) + expect(String(divergent)).toContain(`Event ${eventID} already exists`) + }), + ) + + it.effect("rejects idempotent publish for local events", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const exit = yield* events + .publish(Message, { text: "local" }, { id: EventV2.ID.create(), idempotent: true }) + .pipe(Effect.exit) + + expect(String(exit)).toContain("Idempotent publish requires a synchronized event") + }), + ) + it.effect("replays durable aggregate events after a cursor and tails new events", () => Effect.gen(function* () { const events = yield* EventV2.Service diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index e0370d9a..298002cf 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -38,3 +38,8 @@ test("buildPrompt narrow omitted ⇒ legacy template (unchanged)", () => { expect(legacy).toContain("## Critical Context") expect(legacy).not.toContain("## Data References") }) + +test("inputBudget subtracts only the input-side compaction buffer", () => { + expect(SessionCompaction.inputBudget(1_048_576, 20_000)).toBe(1_028_576) + expect(SessionCompaction.inputBudget(200_000, 20_000)).toBe(180_000) +}) diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/session-runner-model.test.ts index 17951585..60f6cce2 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/session-runner-model.test.ts @@ -38,7 +38,7 @@ const model = (api: Api, variants: ModelV2.Info["variants"] = []) => cost: [], status: "active", enabled: true, - limit: { context: 100, output: 20 }, + limit: { context: 100, input: 80, output: 20 }, }) const provider = (api: ProviderV2.Info["api"]) => @@ -64,7 +64,7 @@ describe("SessionRunnerModel", () => { endpoint: { baseURL: "https://openai.example/v1" }, defaults: { headers: { "x-test": "header" }, - limits: { context: 100, output: 20 }, + limits: { context: 100, input: 80, output: 20 }, generation: { temperature: 0.7 }, providerOptions: { openai: { store: false, serviceTier: "priority" } }, http: { body: { custom_extension: { enabled: true } } }, diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 95699891..299ae2a5 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -1488,6 +1488,13 @@ describe("SessionRunnerLLM", () => { expect(requests).toHaveLength(2) expect(userTexts(requests[0])[0]).toContain("## Goal") + const earlierQuestionCount = + requests + .flatMap(userTexts) + .join("\n") + .match(/Earlier question/g)?.length ?? 0 + expect(earlierQuestionCount).toBeGreaterThanOrEqual(179) + expect(earlierQuestionCount).toBeLessThanOrEqual(180) expect(userTexts(requests[1])).toHaveLength(1) expect(userTexts(requests[1])[0]).toContain("\n## Goal\n- Preserve the task\n") expect(userTexts(requests[1])[0]).toContain(`[User]: ${"Recent exact request ".repeat(180)}`) diff --git a/packages/deepagent-code/package.json b/packages/deepagent-code/package.json index a7aaa824..b325b044 100644 --- a/packages/deepagent-code/package.json +++ b/packages/deepagent-code/package.json @@ -7,10 +7,10 @@ "private": true, "scripts": { "typecheck": "tsgo --noEmit", - "test": "bun test --timeout 30000", + "test": "bun test --timeout 30000 --max-concurrency 4", "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", "test:llm-routes": "bun test --timeout 30000 test/script/live-llm-routes.test.ts test/script/run-live-llm-all.test.ts", - "test:llm-det:contracts": "bun test --timeout 30000 test/tool/apply_patch_chunk.test.ts test/tool/task.test.ts test/tool/task-concurrency.test.ts test/tool/task-run.test.ts test/tool/registry.test.ts test/tool/truncation.test.ts test/tool/shell.test.ts test/mcp/adapter.test.ts test/mcp/lifecycle.test.ts test/session/structured-output.test.ts test/session/conversation-log-writer.test.ts test/session/tool-input-validation.test.ts test/cli/run/run-process.test.ts test/script/live-llm-eval-scoring.test.ts test/script/live-llm-goal-cli-oracle.test.ts test/script/live-llm-expert-panel-oracle.test.ts && bun test --timeout 30000 test/session/prompt.test.ts --test-name-pattern 'runs a prompt in the persisted session directory' && bun test --timeout 30000 test/session/prompt.test.ts --test-name-pattern 'World State'", + "test:llm-det:contracts": "bun test --timeout 30000 test/tool/apply_patch_chunk.test.ts test/tool/task.test.ts test/tool/task-concurrency.test.ts test/tool/task-run.test.ts test/tool/registry.test.ts test/tool/truncation.test.ts test/tool/shell.test.ts test/mcp/adapter.test.ts test/mcp/lifecycle.test.ts test/session/structured-output.test.ts test/session/conversation-log-writer.test.ts test/session/tool-input-validation.test.ts test/cli/run/run-process.test.ts test/script/live-llm-eval-scoring.test.ts test/script/live-llm-goal-cli-oracle.test.ts test/script/live-llm-expert-panel-oracle.test.ts test/script/live-llm-plan-advance-oracle.test.ts && bun test --timeout 30000 test/session/prompt.test.ts --test-name-pattern 'runs a prompt in the persisted session directory' && bun test --timeout 30000 test/session/prompt.test.ts --test-name-pattern 'World State'", "test:llm-live:cli-headless": "bun run script/live-llm/cli-headless.ts", "test:llm-ext:goal-cli": "bun run script/live-llm/cli-goal-loop.ts", "test:llm-live:structured-legacy": "bun run script/live-llm/structured-output-legacy.ts", @@ -27,6 +27,7 @@ "test:llm-live:stale-validation": "bun run script/live-llm/stale-validation.ts", "test:llm-live:continuation-repetition": "bun run script/live-llm/continuation-repetition.ts", "test:llm-live:degeneration": "bun run script/live-llm/degeneration.ts", + "test:llm-live:plan-advance": "bun run script/live-llm/plan-advance-contract.ts", "test:llm-ext:finalizer-isolation": "bun run script/live-llm/finalizer-isolation.ts", "test:llm-live:steer-boundary": "bun run script/live-llm/steer-boundary.ts", "test:llm-ext:subagent-worktree": "bun run script/live-llm/subagent-worktree.ts", @@ -43,12 +44,14 @@ "test:llm-ext:permissions-deny": "bun run script/live-llm/permissions-deny.ts", "test:llm-ext:long-session": "bun run script/live-llm/long-session.ts", "test:llm-ext:compaction-retention": "bun run script/live-llm/compaction-retention.ts", + "test:llm-ext:context-authority": "bun run script/live-llm/context-authority.ts", "test:llm-ext:expert-panel": "bun run script/live-llm/expert-panel.ts", "test:llm-ext:intelligence-draft": "bun run script/live-llm/cli-intelligence.ts", "test:llm-ext:prompt-intent-fencing": "bun run script/live-llm/prompt-intent-fencing.ts", "test:llm-live:subagent-control-plane": "bun run script/live-llm/subagent-control-plane.ts", "test:llm-eval:autonomous": "bun run script/live-llm/autonomous-eval.ts", "test:httpapi": "bun run script/httpapi-exercise.ts --mode coverage --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode auth --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode effect --fail-on-missing --fail-on-skip", + "test:packaged:fork": "bun run script/packaged-fork-smoke.ts", "bench:test": "bun run script/bench-test-suite.ts", "profile:test": "bun run script/profile-test-files.ts", "build": "bun run script/build.ts", diff --git a/packages/deepagent-code/script/build.ts b/packages/deepagent-code/script/build.ts index 25dbb935..e2dddcc4 100755 --- a/packages/deepagent-code/script/build.ts +++ b/packages/deepagent-code/script/build.ts @@ -176,7 +176,7 @@ for (const item of targets) { const bunfsRoot = item.os === "win32" ? "B:/~BUN/root/" : "/$bunfs/root/" const workerRelativePath = path.relative(dir, parserWorker).replaceAll("\\", "/") - await Bun.build({ + const build = await Bun.build({ conditions: ["node"], tsconfig: "./tsconfig.json", plugins: [plugin], @@ -213,10 +213,21 @@ for (const item of targets) { ...(item.os === "linux" ? { "process.env.OPENTUI_LIBC": JSON.stringify(item.abi ?? "glibc") } : {}), }, }) + if (!build.success) { + const diagnostics = build.logs.map((log) => log.message).join("\n") + throw new Error(`build failed for ${name}${diagnostics ? `:\n${diagnostics}` : ""}`) + } + + const binaryPath = `dist/${name}/bin/deepagent-code${item.os === "win32" ? ".exe" : ""}` + if (!(await Bun.file(binaryPath).exists())) { + const diagnostics = build.logs.map((log) => log.message).join("\n") + throw new Error( + `build reported success but produced no binary for ${name}${diagnostics ? `:\n${diagnostics}` : ""}`, + ) + } // Smoke test: only run if binary is for current platform if (item.os === process.platform && item.arch === process.arch && !item.abi) { - const binaryPath = `dist/${name}/bin/deepagent-code` console.log(`Running smoke test: ${binaryPath} --version`) try { const versionOutput = await $`${binaryPath} --version`.text() @@ -228,9 +239,7 @@ for (const item of targets) { } await $`rm -rf ./dist/${name}/bin/tui` - const binarySha256 = new Bun.CryptoHasher("sha256") - .update(await Bun.file(`dist/${name}/bin/deepagent-code`).bytes()) - .digest("hex") + const binarySha256 = new Bun.CryptoHasher("sha256").update(await Bun.file(binaryPath).bytes()).digest("hex") await Bun.file(`dist/${name}/package.json`).write( JSON.stringify( { diff --git a/packages/deepagent-code/script/live-llm/compaction-retention.ts b/packages/deepagent-code/script/live-llm/compaction-retention.ts index 77c29e19..afb7c49e 100644 --- a/packages/deepagent-code/script/live-llm/compaction-retention.ts +++ b/packages/deepagent-code/script/live-llm/compaction-retention.ts @@ -7,7 +7,6 @@ import { runLegacyLiveCases } from "./runtime" const markers = Array.from({ length: 3 }, () => `c1-${crypto.randomUUID()}`) const markerFiles = markers.map((marker) => `.deepagent-c1/${marker}.state`) const expectedOutput = `${markers.toSorted().join("\n")}\n` -const filler = Array.from({ length: 6_000 }, (_, index) => `context-padding-${index}`).join(" ") const outputCaseName = "recover-from-world-state" const artifact = await runLegacyLiveCases({ @@ -16,7 +15,7 @@ const artifact = await runLegacyLiveCases({ cases: [ { name: "fill-context-window", - prompt: `Retain no facts from this padding. Reply only READY.\n${filler}`, + prompt: "Reply only READY.", }, { name: outputCaseName, @@ -30,15 +29,28 @@ const artifact = await runLegacyLiveCases({ }, ], sharedSession: true, + overrideReportedInputTokensAfterCases: [{ caseName: "fill-context-window", inputTokens: 25_000 }], inspectDurability: true, beforeCase: async ({ caseName, directory }) => { if (caseName === "fill-context-window") { await Promise.all( markerFiles.map(async (file) => { await mkdir(path.dirname(path.join(directory, file)), { recursive: true }) - await Bun.write(path.join(directory, file), "world-state source\n") + await Bun.write(path.join(directory, file), "world-state baseline\n") }), ) + await git(directory, "add", "--", ...markerFiles) + await git( + directory, + "-c", + "user.name=DeepAgent Live", + "-c", + "user.email=live@deepagent.invalid", + "commit", + "-m", + "test: establish world-state baseline", + ) + await Promise.all(markerFiles.map((file) => Bun.write(path.join(directory, file), "world-state changed\n"))) return } if (caseName === outputCaseName && (await Bun.file(path.join(directory, "output.txt")).exists())) { @@ -60,7 +72,7 @@ const artifact = await runLegacyLiveCases({ primaryPrompt: "This is a constrained automatic-compaction contract. Follow the current user instruction exactly and use no tool except the one explicitly requested.", modelMaxTokens: 768, - modelContextTokens: 12_000, + modelContextTokens: 40_000, maxProviderTurns: 6, environment: { DEEPAGENT_CODE_SOFT_LANDING_COMPACTION: "false", @@ -78,32 +90,44 @@ if (!artifact.sandbox?.hostReadDenied || !artifact.sandbox.systemHostReadDenied const fill = requireCase(artifact.cases, "fill-context-window") const recovery = requireCase(artifact.cases, outputCaseName) -const automatic = fill.newCompactions.filter((compaction) => compaction.auto) +if ( + fill.tokenUsageOverride?.originalInputTokens === undefined || + fill.tokenUsageOverride.persistedInputTokens !== 25_000 +) { + throw new Error( + `C1 did not persist the deterministic input-token override: ${JSON.stringify(fill.tokenUsageOverride)}`, + ) +} +if (fill.usage.input < fill.tokenUsageOverride.persistedInputTokens) { + throw new Error(`C1 durable message reload lost the input-token override: ${JSON.stringify(fill.usage)}`) +} +const automatic = recovery.newCompactions.filter((compaction) => compaction.auto) if (automatic.length !== 1) { throw new Error(`Expected exactly one automatic compaction, received ${automatic.length}`) } -if (fill.compactionCount !== 1 || recovery.compactionCount !== 1 || recovery.newCompactions.length !== 0) { +if (fill.compactionCount !== 0 || recovery.compactionCount !== 1 || recovery.newCompactions.length !== 1) { throw new Error(`Unexpected compaction counts: fill=${fill.compactionCount}, recovery=${recovery.compactionCount}`) } -if (fill.summaryTexts.length !== 1 || fill.summaryTexts.some((text) => markers.some((marker) => text.includes(marker)))) { +if ( + recovery.summaryTexts.length !== 1 || + recovery.summaryTexts.some((text) => markers.some((marker) => text.includes(marker))) +) { throw new Error("C1 marker leaked into or was missing from the automatic compaction summary boundary") } -if (!fill.durability) throw new Error("C1 did not capture compaction durability evidence") -const committedRuns = fill.durability.compactionRuns.filter((run) => run.state === "committed") +if (!recovery.durability) throw new Error("C1 did not capture compaction durability evidence") +const committedRuns = recovery.durability.compactionRuns.filter((run) => run.state === "committed") if (committedRuns.length !== 1) { throw new Error(`Expected exactly one committed compaction run, received ${committedRuns.length}`) } const committedRun = committedRuns[0] -const summaryAttempts = fill.durability.summaryAttempts.filter( - (attempt) => attempt.run_id === committedRun.run_id, -) +const summaryAttempts = recovery.durability.summaryAttempts.filter((attempt) => attempt.run_id === committedRun.run_id) if (summaryAttempts.length < 1 || summaryAttempts.length > 2) { throw new Error(`Expected one or two durable summary attempts, received ${summaryAttempts.length}`) } if (summaryAttempts.filter((attempt) => attempt.state === "settled").length !== 1) { throw new Error(`Expected one settled summary attempt: ${JSON.stringify(summaryAttempts)}`) } -const activeEpochs = fill.durability.promptEpochs.filter((epoch) => epoch.state === "active") +const activeEpochs = recovery.durability.promptEpochs.filter((epoch) => epoch.state === "active") if ( activeEpochs.length !== 1 || activeEpochs[0].epoch <= 0 || @@ -113,9 +137,12 @@ if ( throw new Error(`C1 PromptEpoch did not bind the committed summary: ${JSON.stringify(activeEpochs)}`) } -const worldState = fill.users.map((user) => user.syntheticText).find((text) => text.includes("")) +const worldState = recovery.durability.worldStateBaselines + .filter((baseline) => baseline.prompt_epoch === activeEpochs[0].epoch) + .map((baseline) => baseline.fragment) + .join("\n") if (!worldState || markers.some((marker) => !worldState.includes(marker))) { - throw new Error("C1 markers were not re-injected through the World State tail") + throw new Error("C1 markers were not committed into the active World State baseline") } const ordinaryUserText = artifact.cases.flatMap((testCase) => testCase.users.map((user) => user.text)).join("\n") if (markers.some((marker) => ordinaryUserText.includes(marker))) { @@ -138,6 +165,9 @@ const writeReceipt = recovery.durability.requestReceipts.find( if ( !writeReceipt || writeReceipt.request_state !== "dispatched" || + writeReceipt.prompt_epoch !== activeEpochs[0].epoch || + writeReceipt.prompt_window_id !== activeEpochs[0].window_id || + writeReceipt.world_state_baseline_hash !== activeEpochs[0].world_state_baseline_hash || writeReceipt.adapter_lowering_outcome !== "ok" || !writeReceipt.registry_tool_ids.includes("write") || !writeReceipt.permission_filtered_tool_ids.includes("write") || @@ -186,15 +216,18 @@ await writeLiveArtifact( result, { redactions: [ - { - value: filler, - replacement: ``, - }, ...markers.map((marker) => ({ value: marker, replacement: ``, })), ], + harnessFiles: [ + "packages/deepagent-code/script/live-llm/compaction-retention.ts", + "packages/deepagent-code/script/live-llm/routes.ts", + "packages/deepagent-code/script/live-llm/runtime.ts", + "packages/llm/script/live-llm/config.ts", + ], + oracleVersion: "bug-012-compaction-retention-v2", }, ) console.log( @@ -228,10 +261,7 @@ async function evaluateFreshCopy(directory: string, sandbox?: { shell: string }) lstat(path.join(fresh, "output.txt")), ]) return { - passed: - exitCode === 0 && - stat.isFile() && - changedPaths.join("\0") === expectedPaths.join("\0"), + passed: exitCode === 0 && stat.isFile() && changedPaths.join("\0") === expectedPaths.join("\0"), sandboxed: true, freshCopy: true, exitCode, diff --git a/packages/deepagent-code/script/live-llm/context-authority.ts b/packages/deepagent-code/script/live-llm/context-authority.ts new file mode 100644 index 00000000..063bdf6a --- /dev/null +++ b/packages/deepagent-code/script/live-llm/context-authority.ts @@ -0,0 +1,19 @@ +#!/usr/bin/env bun + +import os from "node:os" +import path from "node:path" + +const result = Bun.spawnSync(["bun", "test", "--timeout", "600000", "test/cli/serve/live-context-authority.test.ts"], { + cwd: new URL("../..", import.meta.url).pathname, + env: { + ...process.env, + DEEPAGENT_CODE_LIVE_CONTEXT_AUTHORITY: "1", + DEEPAGENT_CODE_LIVE_LLM_API_KEY_FILE: + process.env.DEEPAGENT_CODE_LIVE_LLM_API_KEY_FILE?.trim() || + path.join(os.homedir(), ".deepagent", "code", "tmp", "live-llm-deepseek.key"), + }, + stdout: "inherit", + stderr: "inherit", +}) + +process.exit(result.exitCode) diff --git a/packages/deepagent-code/script/live-llm/dispatcher.ts b/packages/deepagent-code/script/live-llm/dispatcher.ts index 62e7e974..d19bbd65 100644 --- a/packages/deepagent-code/script/live-llm/dispatcher.ts +++ b/packages/deepagent-code/script/live-llm/dispatcher.ts @@ -195,6 +195,10 @@ const modelCommands = new Map([ command("packages/deepagent-code", "bun", "run", "test:llm-live:continuation-repetition"), ], ["live:legacy-session:degeneration", command("packages/deepagent-code", "bun", "run", "test:llm-live:degeneration")], + [ + "live:legacy-session:plan-advance-contract", + command("packages/deepagent-code", "bun", "run", "test:llm-live:plan-advance"), + ], [ "ext:legacy-session:subagent-finalizer-isolation", command("packages/deepagent-code", "bun", "run", "test:llm-ext:finalizer-isolation"), @@ -261,6 +265,10 @@ const modelCommands = new Map([ "ext:legacy-session:compaction-retention", command("packages/deepagent-code", "bun", "run", "test:llm-ext:compaction-retention"), ], + [ + "ext:cli-subprocess:context-authority", + command("packages/deepagent-code", "bun", "run", "test:llm-ext:context-authority"), + ], ["ext:legacy-session:expert-panel", command("packages/deepagent-code", "bun", "run", "test:llm-ext:expert-panel")], [ "ext:legacy-session:intelligence-draft-confirmation", diff --git a/packages/deepagent-code/script/live-llm/finalizer-isolation.ts b/packages/deepagent-code/script/live-llm/finalizer-isolation.ts index 3f393599..cc001afd 100644 --- a/packages/deepagent-code/script/live-llm/finalizer-isolation.ts +++ b/packages/deepagent-code/script/live-llm/finalizer-isolation.ts @@ -5,12 +5,12 @@ import { runLegacyLiveCases } from "./runtime" // Suite D1 (design/real-llm-testing.md) — subagent finalizer isolation. The researcher child runs two // phases in ONE child Session: a research turn with the normal read-only registry, then a bounded -// finalizer turn whose registry is emptied down to `StructuredOutput` alone (prompt.ts: `tools = -// finalizerMode ? {} : SessionTools.resolve(...)`). The regression this guards: research-phase tools +// finalizer turn whose registry is emptied down to `StructuredOutput` alone in strict mode (prompt.ts: +// `tools = finalizerMode ? {} : SessionTools.resolve(...)`); the text fallback has no tools. The regression this guards: research-phase tools // leaking into the finalizer turn, which produced empty/invalid structured results or let the model -// keep researching instead of finalizing. The finalizer turn is identified durably — it is the only -// child assistant carrying a non-null `structured` (the research prompt has no `format`, so -// `structured` stays undefined there). +// keep researching instead of finalizing. A compliant provider returns one StructuredOutput call; +// a format-weaker provider may use the bounded second text-only finalizer, whose JSON is still +// validated locally by the task controller. const markers = { module: `module-${crypto.randomUUID()}`, mechanism: `mechanism-${crypto.randomUUID()}`, @@ -81,19 +81,30 @@ if ( throw new Error("Child research phase did not read the fixture through a completed read tool") } const structuredCalls = childTools.filter((tool) => tool.name === "StructuredOutput" && tool.status === "completed") -if (structuredCalls.length !== 1) { +if (structuredCalls.length > 1) { throw new Error( - `Expected exactly one completed child StructuredOutput call, received ${structuredCalls.length}: ` + + `Expected at most one completed child StructuredOutput call, received ${structuredCalls.length}: ` + childTools.map((tool) => `${tool.name}:${tool.status}`).join(", "), ) } -const finalizers = child.assistants.filter((assistant) => assistant.structured !== undefined) -if (finalizers.length !== 1) { - throw new Error(`Expected exactly one child finalizer turn, received ${finalizers.length}`) +const strictFinalizer = child.assistants.find((assistant) => assistant.structured !== undefined) +const textFallbackUsers = child.users.filter( + (user) => nestedRecordOptional(user.metadata, ["deepagent", "structured_finalizer"])?.allow_text === true, +) +const textFinalizers = child.assistants.filter((assistant) => extractJson(assistant.text) !== undefined) +const finalizer = strictFinalizer ?? textFinalizers.at(-1) +if ( + !finalizer || + (strictFinalizer && structuredCalls.length !== 1) || + (!strictFinalizer && textFallbackUsers.length === 0) +) { + throw new Error( + `Expected one structured or validated text finalizer turn, received strict=${strictFinalizer ? 1 : 0}, ` + + `text=${textFinalizers.length}, text_fallback_users=${textFallbackUsers.length}`, + ) } // The finalizer's OWN tools array is the isolation oracle: a leaked research tool would land as a // completed part on this same assistant message, not on an earlier research turn. -const finalizer = finalizers[0] const foreignFinalizerTools = finalizer.tools.filter( (tool) => tool.status === "completed" && tool.name !== "StructuredOutput", ) @@ -102,14 +113,17 @@ if (foreignFinalizerTools.length > 0) { `Finalizer turn executed research-phase tools: ${foreignFinalizerTools.map((tool) => tool.name).join(", ")}`, ) } -if (!finalizer.tools.some((tool) => tool.name === "StructuredOutput" && tool.status === "completed")) { +if ( + strictFinalizer && + !finalizer.tools.some((tool) => tool.name === "StructuredOutput" && tool.status === "completed") +) { throw new Error("Finalizer turn carries a structured result without its own completed StructuredOutput call") } const subagent = nestedRecord(child.metadata, ["deepagent", "subagent"]) if (subagent.state !== "completed" || subagent.finished !== true || subagent.reason !== "structured_output_valid") { throw new Error(`Child durable metadata is not a valid completed structured result: ${JSON.stringify(subagent)}`) } -const result = record(finalizer.structured, "ResearchResult") +const result = record(finalizer.structured ?? extractJson(finalizer.text), "ResearchResult") if (result.module !== markers.module || result.mechanism !== markers.mechanism) { throw new Error("Child ResearchResult scalar fields are not byte-exact copies of the fixture") } @@ -150,13 +164,11 @@ if (parentRead) throw new Error("Parent read the fixture itself, so child isolat // Production guard: unexpected parent tool errors indicate the parent called a denied tool // (e.g., task_status or task_read). Deny decisions do NOT fire permission events so they // won't appear in permissionRequests — this explicit check catches them. -const unexpectedParentErrors = observation.tools.filter( - tool => tool.status === "error" && tool.name !== "task", -) +const unexpectedParentErrors = observation.tools.filter((tool) => tool.status === "error" && tool.name !== "task") if (unexpectedParentErrors.length > 0) { throw new Error( `Parent made ${unexpectedParentErrors.length} unexpected denied tool call(s): ` + - unexpectedParentErrors.map(t => t.name).join(", ") + + unexpectedParentErrors.map((t) => t.name).join(", ") + " — check primaryPermission matches the parent prompt", ) } @@ -168,6 +180,7 @@ const resultArtifact = { childSessionID: child.id, childAssistantTurns: child.assistants.length, structuredOutputCallCount: structuredCalls.length, + finalizerTransport: strictFinalizer ? "structured_tool" : "validated_text", finalizerTurnForeignToolCount: foreignFinalizerTools.length, researchToolNames: childTools.map((tool) => tool.name), parentReadOfFixture: parentRead !== undefined, @@ -192,13 +205,52 @@ function record(value: unknown, name: string): Record { return value as Record } +function extractJson(text: string): unknown { + const trimmed = text.trim() + const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1]?.trim() + const objectStart = trimmed.indexOf("{") + const objectEnd = trimmed.lastIndexOf("}") + const candidates = [ + trimmed, + fenced, + objectStart !== -1 && objectEnd > objectStart ? trimmed.slice(objectStart, objectEnd + 1) : undefined, + ].filter((candidate): candidate is string => candidate !== undefined) + for (const candidate of candidates) { + try { + return JSON.parse(candidate) + } catch { + continue + } + } + return undefined +} + +function nestedRecordOptional(value: unknown, keys: string[]) { + return keys.reduce | undefined>( + (current, key) => { + if (!current) return undefined + const next = current[key] + if (typeof next !== "object" || next === null || Array.isArray(next)) return undefined + return next as Record + }, + typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined, + ) +} + function nestedRecord(value: unknown, keys: string[]) { - const result = keys.reduce | undefined>((current, key) => { - if (!current) return undefined - const next = current[key] - if (typeof next !== "object" || next === null || Array.isArray(next)) return undefined - return next as Record - }, typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record) : undefined) + const result = keys.reduce | undefined>( + (current, key) => { + if (!current) return undefined + const next = current[key] + if (typeof next !== "object" || next === null || Array.isArray(next)) return undefined + return next as Record + }, + typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined, + ) if (!result) throw new Error(`Missing object path ${keys.join(".")}`) return result } diff --git a/packages/deepagent-code/script/live-llm/plan-advance-contract.ts b/packages/deepagent-code/script/live-llm/plan-advance-contract.ts new file mode 100644 index 00000000..e98c65e2 --- /dev/null +++ b/packages/deepagent-code/script/live-llm/plan-advance-contract.ts @@ -0,0 +1,226 @@ +import { AgentGateway } from "@deepagent-code/core/agent-gateway" +import type { PlanDoc } from "@deepagent-code/core/deepagent/plan-controller" +import { loadPlanLiveLLMConfig, writeLiveArtifact } from "../../../llm/script/live-llm/config" +import { finishLiveScript } from "./lifecycle" +import { assertPlanAdvanceObservation } from "./plan-advance-oracle" +import { runLegacyLiveCases } from "./runtime" + +const config = await loadPlanLiveLLMConfig() +const conflictCase = "retry-after-authority-race" +const concurrentNote = "concurrent authority update" +let immutable: PlanDoc | undefined +let conflictInjected = false + +const artifact = await runLegacyLiveCases({ + suite: "plan-advance-contract-legacy", + config, + permission: { "*": "deny" }, + primaryPermission: { "*": "deny", plan: "ask" }, + permissionReply: { reply: "once" }, + sharedSession: true, + inspectDurability: true, + inspectPlan: true, + observeAssembledRequestFingerprints: true, + environment: { DEEPAGENT_ENABLED: "true", DEEPAGENT_MODE: "high" }, + primaryPrompt: [ + "This is a Plan advance parameter-contract test in one durable Session.", + "Call only the plan tool requested by the current user.", + "For operation advance, copy expected_plan_id, expected_version, and step_id values exactly from the latest plan-status or plan result.", + "Send only operation, expected_plan_id, expected_version, active_step_id, and steps containing step_id plus status.", + "Never send goal, assumptions, replan_reason, title, acceptance, or assigned_agent on advance.", + "If the tool returns plan_protocol conflict, retry the same requested transition exactly once from the authoritative parameters in that result.", + ].join(" "), + modelMaxTokens: config.providerID === "kimi" ? 1536 : 768, + maxProviderTurns: 5, + cases: [ + { + name: "advance-first-boundary", + prompt: planPrompt("step_1", "step_2", false), + }, + { + name: "advance-second-boundary", + prompt: planPrompt("step_2", "step_3", false), + }, + { + name: conflictCase, + prompt: planPrompt("step_3", "step_4", true), + }, + ], + beforeCase: async ({ caseName, sessionID }) => { + if (caseName !== "advance-first-boundary") return + AgentGateway.DeepAgentSessionState.getOrCreate(sessionID, "high") + const plan = AgentGateway.DeepAgentPlanController.createPlanDoc( + sessionID, + "Verify model Plan advances preserve server-owned authority", + [ + { + step_id: "step_1", + title: "Inspect the authoritative Plan snapshot", + status: "active", + acceptance: "The model uses the supplied CAS precondition", + assigned_agent: "primary", + }, + { + step_id: "step_2", + title: "Advance with a minimal status patch", + status: "pending", + acceptance: "Server-owned identity remains unchanged", + assigned_agent: "primary", + }, + { + step_id: "step_3", + title: "Recover from an injected authority race", + status: "pending", + acceptance: "The retry uses the returned authoritative baseline", + assigned_agent: "primary", + }, + { + step_id: "step_4", + title: "Retain the concurrent authority update", + status: "pending", + acceptance: "Concurrent server-owned data survives the retry", + assigned_agent: "primary", + }, + ], + ["The Plan document is the only structural authority"], + ) + const committed = AgentGateway.DeepAgentPlanStore.compareAndCommitPlan({ + sessionId: sessionID, + expected: null, + candidate: plan, + origin: "runtime_goal_bridge", + }) + AgentGateway.DeepAgentSessionState.bindPlan(sessionID, committed.plan, null, committed.changed) + immutable = committed.plan + }, + beforePermissionReply: async ({ caseName, request }) => { + if (caseName !== conflictCase || request.permission !== "plan" || conflictInjected) return + const current = AgentGateway.DeepAgentPlanStore.getPlanDoc(request.sessionID) + const ref = AgentGateway.DeepAgentPlanStore.planDocRef(request.sessionID) + if (!current || !ref) throw new Error("Conflict injection could not read the Plan authority") + const committed = AgentGateway.DeepAgentPlanStore.compareAndCommitPlan({ + sessionId: request.sessionID, + expected: { plan_id: current.plan_id, doc_id: ref.id, version: ref.version }, + candidate: { + ...current, + steps: current.steps.map((step) => (step.step_id === "step_3" ? { ...step, note: concurrentNote } : step)), + }, + origin: "runtime_goal_bridge", + }) + AgentGateway.DeepAgentSessionState.bindPlan(request.sessionID, committed.plan, current, committed.changed) + conflictInjected = true + }, +}) + +await writeLiveArtifact(config, `${artifact.suite}-observed`, artifact) + +if (!immutable) throw new Error("Plan contract suite did not seed its authoritative Plan") +const authoritativePlan = immutable +if (!conflictInjected) throw new Error("Plan contract suite did not inject the authority race") +if (artifact.status !== "passed") + throw new Error(`Plan contract Provider run failed: ${JSON.stringify(artifact.error)}`) +if (new Set(artifact.cases.map((testCase) => testCase.sessionID)).size !== 1) { + throw new Error("Plan contract cases did not reuse one durable Session") +} + +const expectations = [ + { + caseName: "advance-first-boundary", + version: 2, + activeStepID: "step_2", + statuses: { step_1: "done", step_2: "active", step_3: "pending", step_4: "pending" }, + notes: { step_1: null, step_2: null, step_3: null, step_4: null }, + calls: [{ version: 1, protocol: "success" as const }], + }, + { + caseName: "advance-second-boundary", + version: 3, + activeStepID: "step_3", + statuses: { step_1: "done", step_2: "done", step_3: "active", step_4: "pending" }, + notes: { step_1: null, step_2: null, step_3: null, step_4: null }, + calls: [{ version: 2, protocol: "success" as const }], + }, + { + caseName: conflictCase, + version: 5, + activeStepID: "step_4", + statuses: { step_1: "done", step_2: "done", step_3: "done", step_4: "active" }, + notes: { step_1: null, step_2: null, step_3: concurrentNote, step_4: null }, + calls: [ + { version: 3, protocol: "conflict" as const }, + { version: 4, protocol: "success" as const }, + ], + }, +] + +expectations.forEach((expected) => { + const observation = artifact.cases.find((testCase) => testCase.name === expected.caseName) + if (!observation) throw new Error(`Missing Plan contract case ${expected.caseName}`) + const statusPatch: Record = + expected.caseName === "advance-first-boundary" + ? { step_1: "done", step_2: "active" } + : expected.caseName === "advance-second-boundary" + ? { step_2: "done", step_3: "active" } + : { step_3: "done", step_4: "active" } + assertPlanAdvanceObservation({ + caseName: expected.caseName, + observation, + immutable: authoritativePlan, + expectedVersion: expected.version, + expectedActiveStepID: expected.activeStepID, + expectedStatuses: expected.statuses, + expectedNotes: expected.notes, + expectedCalls: expected.calls.map((call) => ({ + ...call, + activeStepID: expected.activeStepID, + statuses: statusPatch, + })), + }) + if (observation.providerErrors.length > 0) { + throw new Error(`${expected.caseName} recorded Provider errors: ${JSON.stringify(observation.providerErrors)}`) + } + if (observation.assembledRequestFingerprints.length < expected.calls.length) { + throw new Error(`${expected.caseName} did not capture every Provider request boundary`) + } +}) + +const result = { + ...artifact, + evidence: { + provider: config.providerID, + durableSessionCount: new Set(artifact.cases.map((testCase) => testCase.sessionID)).size, + conflictInjected, + finalPlanVersion: artifact.cases.at(-1)?.plan?.ref?.version, + planCalls: artifact.cases.flatMap((testCase) => + testCase.newTools.map((tool) => ({ + caseName: testCase.name, + name: tool.name, + status: tool.status, + protocol: + typeof tool.metadata === "object" && tool.metadata !== null && "plan_protocol" in tool.metadata + ? tool.metadata.plan_protocol + : undefined, + })), + ), + }, +} +await writeLiveArtifact(config, result.suite, result) +console.log( + `${result.suite}: passed (${result.fingerprint.providerID}/${result.fingerprint.modelID}, ` + + `${result.evidence.planCalls.length} Plan calls, final version ${result.evidence.finalPlanVersion})`, +) + +finishLiveScript() + +function planPrompt(doneStepID: string, activeStepID: string, retryConflict: boolean) { + return [ + `Call plan to mark ${doneStepID} done and ${activeStepID} active.`, + "Use operation advance and copy the exact expected_plan_id and expected_version from the latest plan-status.", + `Set active_step_id to ${activeStepID}. Send exactly two steps: ${doneStepID} with status done, then ${activeStepID} with status active.`, + "Each step object must contain only step_id and status. Omit goal, assumptions, replan_reason, title, acceptance, assigned_agent, and note.", + retryConflict + ? "If the first result is a Plan conflict, retry this same transition exactly once with the authoritative expected_* values returned by the tool." + : "Call plan exactly once. No conflict is expected.", + "Do not call any other tool.", + ].join(" ") +} diff --git a/packages/deepagent-code/script/live-llm/plan-advance-oracle.ts b/packages/deepagent-code/script/live-llm/plan-advance-oracle.ts new file mode 100644 index 00000000..c249b82b --- /dev/null +++ b/packages/deepagent-code/script/live-llm/plan-advance-oracle.ts @@ -0,0 +1,226 @@ +type PlanStep = { + step_id: string + title: string + status: string + acceptance?: string | null + assigned_agent?: string | null + note?: string | null +} + +type PlanDocument = { + plan_id: string + goal: string + assumptions: readonly string[] + active_step_id: string | null + steps: readonly PlanStep[] +} + +type ToolCall = { + messageID: string + id: string + name: string + status: string + input: unknown + metadata?: unknown +} + +type RequestReceipt = { + receipt_id: string + assistant_message_id: string | null + request_state: string + final_offered_tool_ids: readonly string[] + call_ids: readonly string[] + tool_definition_hash: string | null +} + +type ArgumentReceipt = { + receipt_id: string + layer: string + call_id: string | null + tool_name: string | null + event_type: string + payload_hash: string | null + payload_length: number | null + payload_keys: readonly string[] + unavailable_reason: string | null + validation_outcome: string +} + +export function assertPlanAdvanceObservation(input: { + caseName: string + observation: { + newTools: readonly ToolCall[] + plan?: { document: PlanDocument | null; ref: { id: string; version: number } | null } + durability?: { + requestReceipts: readonly RequestReceipt[] + argumentReceipts: readonly ArgumentReceipt[] + } + } + immutable: PlanDocument + expectedVersion: number + expectedActiveStepID: string | null + expectedStatuses: Readonly> + expectedNotes?: Readonly> + expectedCalls: ReadonlyArray<{ + version: number + protocol: "success" | "conflict" + activeStepID: string | null + statuses: Readonly> + }> +}) { + const calls = input.observation.newTools.filter((tool) => tool.name === "plan") + if (calls.length !== input.expectedCalls.length || input.observation.newTools.length !== calls.length) { + throw new Error( + `${input.caseName} tool sequence mismatch: ${JSON.stringify( + input.observation.newTools.map((tool) => `${tool.name}:${tool.status}`), + )}`, + ) + } + + calls.forEach((call, index) => { + if (call.status !== "completed") throw new Error(`${input.caseName} plan call ${index + 1} did not complete`) + const args = record(call.input, `${input.caseName} plan input ${index + 1}`) + const metadata = record(call.metadata, `${input.caseName} plan metadata ${index + 1}`) + const expected = input.expectedCalls[index]! + if ( + args.operation !== "advance" || + args.expected_plan_id !== input.immutable.plan_id || + args.expected_version !== expected.version + ) { + throw new Error(`${input.caseName} plan precondition mismatch: ${JSON.stringify(args)}`) + } + const allowedKeys = new Set(["operation", "expected_plan_id", "expected_version", "steps", "active_step_id"]) + for (const key of Object.keys(args)) { + if (!allowedKeys.has(key)) throw new Error(`${input.caseName} plan input supplied non-patch field ${key}`) + } + if (args.active_step_id !== expected.activeStepID) { + throw new Error(`${input.caseName} plan call ${index + 1} supplied the wrong active_step_id`) + } + const steps = array(args.steps, `${input.caseName} plan steps ${index + 1}`).map((step) => + record(step, `${input.caseName} plan step ${index + 1}`), + ) + if (steps.length === 0) throw new Error(`${input.caseName} plan call ${index + 1} supplied no status patch`) + for (const step of steps) { + if (typeof step.step_id !== "string" || typeof step.status !== "string") { + throw new Error(`${input.caseName} plan call ${index + 1} omitted step_id/status`) + } + for (const key of Object.keys(step)) { + if (!new Set(["step_id", "status", "note"]).has(key)) { + throw new Error(`${input.caseName} plan input supplied non-patch step field ${key}`) + } + } + } + const statuses = Object.fromEntries(steps.map((step) => [step.step_id, step.status])) + if (JSON.stringify(statuses) !== JSON.stringify(expected.statuses)) { + throw new Error(`${input.caseName} plan call ${index + 1} supplied the wrong status patch`) + } + if (metadata.plan_protocol !== expected.protocol) { + throw new Error( + `${input.caseName} plan call ${index + 1} expected ${expected.protocol}, received ${String(metadata.plan_protocol)}`, + ) + } + assertArgumentReceipts(input.caseName, call, input.observation.durability, expected.protocol) + }) + + const plan = input.observation.plan?.document + const ref = input.observation.plan?.ref + if (!plan || !ref) throw new Error(`${input.caseName} did not capture the durable Plan authority`) + if (ref.version !== input.expectedVersion) { + throw new Error(`${input.caseName} expected Plan version ${input.expectedVersion}, received ${ref.version}`) + } + if ( + plan.plan_id !== input.immutable.plan_id || + plan.goal !== input.immutable.goal || + JSON.stringify(plan.assumptions) !== JSON.stringify(input.immutable.assumptions) || + plan.active_step_id !== input.expectedActiveStepID + ) { + throw new Error(`${input.caseName} changed authoritative Plan identity: ${JSON.stringify(plan)}`) + } + if (plan.steps.length !== input.immutable.steps.length) { + throw new Error(`${input.caseName} changed the authoritative Plan step count`) + } + plan.steps.forEach((step, index) => { + const immutable = input.immutable.steps[index] + if ( + !immutable || + step.step_id !== immutable.step_id || + step.title !== immutable.title || + (step.acceptance ?? null) !== (immutable.acceptance ?? null) || + (step.assigned_agent ?? null) !== (immutable.assigned_agent ?? null) + ) { + throw new Error(`${input.caseName} changed server-owned step identity at index ${index}`) + } + if (step.status !== input.expectedStatuses[step.step_id]) { + throw new Error(`${input.caseName} unexpected status for ${step.step_id}: ${step.status}`) + } + if (input.expectedNotes && (step.note ?? null) !== (input.expectedNotes[step.step_id] ?? null)) { + throw new Error(`${input.caseName} unexpected note for ${step.step_id}: ${String(step.note)}`) + } + }) +} + +function assertArgumentReceipts( + caseName: string, + call: ToolCall, + durability: + | { + requestReceipts: readonly RequestReceipt[] + argumentReceipts: readonly ArgumentReceipt[] + } + | undefined, + protocol: "success" | "conflict", +) { + if (!durability) throw new Error(`${caseName} did not capture request/argument receipts`) + const request = durability.requestReceipts.find( + (receipt) => receipt.assistant_message_id === call.messageID && receipt.call_ids.includes(call.id), + ) + if ( + !request || + request.request_state !== "dispatched" || + !request.final_offered_tool_ids.includes("plan") || + !request.tool_definition_hash + ) { + throw new Error(`${caseName} request receipt was incomplete: ${JSON.stringify(request)}`) + } + const receipts = durability.argumentReceipts.filter( + (receipt) => receipt.receipt_id === request.receipt_id && receipt.call_id === call.id, + ) + const aiSdkInput = receipts.find((receipt) => receipt.layer === "ai_sdk_input") + const adapter = receipts.find((receipt) => receipt.layer === "adapter_assembly" && receipt.event_type === "tool-call") + const decoded = receipts.find((receipt) => receipt.layer === "processor_decoded") + const rawFrame = durability.argumentReceipts.find( + (receipt) => receipt.receipt_id === request.receipt_id && receipt.layer === "raw_frame", + ) + if ( + !aiSdkInput?.payload_hash || + !adapter?.payload_hash || + !decoded?.payload_hash || + aiSdkInput.tool_name !== "plan" || + adapter.tool_name !== "plan" || + decoded.tool_name !== "plan" || + adapter.payload_hash !== decoded.payload_hash || + adapter.payload_length !== decoded.payload_length || + JSON.stringify(adapter.payload_keys) !== JSON.stringify(decoded.payload_keys) || + aiSdkInput.validation_outcome !== "schema_valid" || + adapter.validation_outcome !== "schema_valid" || + decoded.validation_outcome !== (protocol === "success" ? "semantic_valid" : "conflict") + ) { + throw new Error(`${caseName} argument receipt chain was incomplete: ${JSON.stringify(receipts)}`) + } + if ( + !rawFrame || + (rawFrame.payload_hash == null && rawFrame.unavailable_reason !== "provider_transport_did_not_expose_raw_frame") + ) { + throw new Error(`${caseName} raw-frame provenance was neither captured nor explicitly unavailable`) + } +} + +function record(value: unknown, label: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${label} is not an object`) + return value as Record +} + +function array(value: unknown, label: string): unknown[] { + if (!Array.isArray(value)) throw new Error(`${label} is not an array`) + return value +} diff --git a/packages/deepagent-code/script/live-llm/routes.ts b/packages/deepagent-code/script/live-llm/routes.ts index 4c48e5e6..b1f28a09 100644 --- a/packages/deepagent-code/script/live-llm/routes.ts +++ b/packages/deepagent-code/script/live-llm/routes.ts @@ -42,11 +42,13 @@ export const modelSuites = [ "subagent-resume", "subagent-takeover", "compaction-retention", + "context-authority", "expert-panel", "goal-grader-cli-entry", "intelligence-draft-confirmation", "prompt-intent-fencing", "subagent-control-plane", + "plan-advance-contract", ] as const export type ExecutionStack = (typeof executionStacks)[number] @@ -114,11 +116,13 @@ const subagentIntensity = modelRun("ext", "legacy-session", "subagent-intensity" const subagentResume = modelRun("ext", "legacy-session", "subagent-resume") const subagentTakeover = modelRun("ext", "legacy-session", "subagent-takeover") const compactionRetention = modelRun("ext", "legacy-session", "compaction-retention") +const contextAuthority = modelRun("ext", "cli-subprocess", "context-authority") const expertPanel = modelRun("ext", "legacy-session", "expert-panel") const goalGraderCliEntry = modelRun("ext", "cli-subprocess", "goal-grader-cli-entry") const intelligenceDraft = modelRun("ext", "legacy-session", "intelligence-draft-confirmation") const promptIntentFencing = modelRun("ext", "legacy-session", "prompt-intent-fencing") const subagentControlPlane = modelRun("live", "legacy-session", "subagent-control-plane") +const planAdvanceContract = modelRun("live", "legacy-session", "plan-advance-contract") const allHarnessRuns = [ adapterProvider, cliHeadless, @@ -151,11 +155,13 @@ const allHarnessRuns = [ permissionsDeny, mcpMarker, compactionRetention, + contextAuthority, expertPanel, goalGraderCliEntry, intelligenceDraft, promptIntentFencing, subagentControlPlane, + planAdvanceContract, ] export const routeManifest = [ @@ -269,6 +275,15 @@ export const routeManifest = [ checks: ["session-continuation"], runs: [continuationRepetition], }, + { + id: "live-llm-plan-advance-contract-harness", + paths: [ + "packages/deepagent-code/script/live-llm/plan-advance-contract.ts", + "packages/deepagent-code/script/live-llm/plan-advance-oracle.ts", + ], + checks: ["live-llm-routes", "llm-adapter"], + runs: [planAdvanceContract], + }, { id: "live-llm-degeneration-harness", paths: ["packages/deepagent-code/script/live-llm/degeneration.ts"], @@ -575,6 +590,18 @@ export const routeManifest = [ checks: ["live-llm-routes", "session-continuation"], runs: [continuationRepetition], }, + { + id: "plan-advance-contract-production", + paths: [ + "packages/core/src/deepagent/plan-controller.ts", + "packages/core/src/deepagent/prompt-policy.ts", + "packages/deepagent-code/src/session/llm/request.ts", + "packages/deepagent-code/src/session/reminders.ts", + "packages/deepagent-code/src/tool/plan*.{ts,txt}", + ], + checks: ["live-llm-routes", "llm-adapter", "permission"], + runs: [planAdvanceContract], + }, { id: "legacy-session-prompt", paths: [ @@ -604,12 +631,14 @@ export const routeManifest = [ "packages/deepagent-code/src/session/steer.ts", "packages/deepagent-code/src/session/compaction.ts", "packages/deepagent-code/src/session/compaction-sql.ts", + "packages/deepagent-code/src/session/context-ledger.ts", "packages/deepagent-code/src/session/prompt-epoch.ts", "packages/deepagent-code/src/session/prompt-epoch.sql.ts", "packages/deepagent-code/src/session/tool-argument-receipt.sql.ts", "packages/deepagent-code/src/session/tool-request-receipt.sql.ts", "packages/deepagent-code/src/session/message-v2.ts", "packages/deepagent-code/src/session/context-ledger.ts", + "packages/deepagent-code/src/session/history-authority.ts", "packages/deepagent-code/src/session/system.ts", "packages/core/src/system-context/**", ], @@ -940,11 +969,49 @@ export const routeManifest = [ "packages/deepagent-code/src/session/prompt-epoch.sql.ts", "packages/deepagent-code/src/session/tool-request-receipt.sql.ts", "packages/deepagent-code/src/session/message-v2.ts", + "packages/deepagent-code/src/session/history-authority.ts", "packages/deepagent-code/src/session/overflow.ts", ], checks: ["session-continuation"], runs: [compactionRetention], }, + { + id: "context-authority-suite", + paths: [ + "packages/core/src/database/migration/20260809120000_session_history_authority.ts", + "packages/core/src/database/migration/20260810100000_prompt_authority_receipt.ts", + "packages/core/src/database/migration/20260810110000_fork_side_effect_receipt.ts", + "packages/core/src/database/migration/20260810120000_prompt_authority_quarantine.ts", + "packages/core/src/database/migration/20260810140000_bug_012_compaction_cas.ts", + "packages/core/src/database/migration/20260810150000_provider_receipt_authority.ts", + "packages/core/src/database/migration/20260810160000_compaction_continuation_admission.ts", + "packages/core/src/deepagent/context/world-state.ts", + "packages/core/src/session/sql.ts", + "packages/app/src/components/session/session-context-metrics.ts", + "packages/deepagent-code/script/live-llm/context-authority.ts", + "packages/deepagent-code/script/live-llm/routes.ts", + "packages/deepagent-code/script/build.ts", + "packages/deepagent-code/src/server/routes/instance/httpapi/groups/session.ts", + "packages/deepagent-code/src/server/routes/instance/httpapi/handlers/session.ts", + "packages/deepagent-code/src/session/compaction-sql.ts", + "packages/deepagent-code/src/session/compaction.ts", + "packages/deepagent-code/src/session/context-ledger.ts", + "packages/deepagent-code/src/session/history-authority.ts", + "packages/deepagent-code/src/session/message-v2.ts", + "packages/deepagent-code/src/session/prompt-epoch.sql.ts", + "packages/deepagent-code/src/session/prompt-epoch.ts", + "packages/deepagent-code/src/session/prompt.ts", + "packages/deepagent-code/src/session/session.ts", + "packages/deepagent-code/src/session/tool-request-receipt.sql.ts", + "packages/deepagent-code/test/cli/serve/live-context-authority.test.ts", + "packages/deepagent-code/test/cli/serve/packaged-fork.test.ts", + "packages/deepagent-code/test/lib/cli-process.ts", + "packages/llm/script/live-llm/config.ts", + "packages/tui/src/util/session.ts", + ], + checks: ["live-llm-routes", "session-continuation"], + runs: [contextAuthority], + }, { id: "goal-loop-production", paths: [ diff --git a/packages/deepagent-code/script/live-llm/runtime.ts b/packages/deepagent-code/script/live-llm/runtime.ts index 8a521353..35b9a1b3 100644 --- a/packages/deepagent-code/script/live-llm/runtime.ts +++ b/packages/deepagent-code/script/live-llm/runtime.ts @@ -19,6 +19,10 @@ import { prepareToolSandbox, type ToolSandbox } from "../../../core/script/live- export const runtimeProviderID = "live-deepseek" +export function runtimeProviderIDFor(config: Pick) { + return config.providerID === "deepseek" ? runtimeProviderID : "live-kimi" +} + export async function directoryExists(directory: string): Promise { try { return (await stat(directory)).isDirectory() @@ -132,6 +136,7 @@ export type V4LiveEventCase = { export async function runLegacyLiveCases(input: { suite: string + config?: LiveLLMConfig cases: LegacyLiveCase[] permission: ConfigV1.Info["permission"] primaryPermission?: ConfigV1.Info["permission"] @@ -155,9 +160,20 @@ export async function runLegacyLiveCases(input: { maxProviderTurns?: number toolOutput?: ConfigV1.Info["tool_output"] evaluateWorkspace?: (directory: string, sandbox?: ToolSandbox) => Promise - beforeCase?: (input: { caseName: string; directory: string; sandbox?: ToolSandbox }) => Promise + beforeCase?: (input: { + caseName: string + directory: string + sessionID: string + sandbox?: ToolSandbox + }) => Promise + beforePermissionReply?: (input: { + caseName?: string + directory: string + request: PermissionV1.Request + }) => Promise sharedSession?: boolean compactAfterCases?: string[] + overrideReportedInputTokensAfterCases?: ReadonlyArray<{ caseName: string; inputTokens: number }> timeoutMs?: number // Inject a steering message through the production promptOrSteer ingress after the named case's // session runner reports an active turn. The original prompt remains in flight; the runtime records @@ -165,12 +181,14 @@ export async function runLegacyLiveCases(input: { steerDuringCases?: ReadonlyArray<{ duringCaseName: string; text: string }> observeAssembledRequestFingerprints?: boolean inspectDurability?: boolean + inspectPlan?: boolean subagentIntensity?: "inherit" | "downgrade" environment?: Readonly> panel?: LegacyPanelCase v4Event?: V4LiveEventCase }) { - const config = await loadLiveLLMConfig() + const config = input.config ?? (await loadLiveLLMConfig()) + const liveProviderID = runtimeProviderIDFor(config) if ( input.permissionBarrierCount !== undefined && (!Number.isSafeInteger(input.permissionBarrierCount) || input.permissionBarrierCount < 2) @@ -183,6 +201,25 @@ export async function runLegacyLiveCases(input: { if (!input.sharedSession && input.cases.some((testCase) => testCase.revertBefore)) { throw new Error("revertBefore requires sharedSession so the target and rewrite use one durable Session") } + const inputTokenOverrides = new Map( + (input.overrideReportedInputTokensAfterCases ?? []).map((override) => [override.caseName, override.inputTokens]), + ) + if (inputTokenOverrides.size !== (input.overrideReportedInputTokensAfterCases?.length ?? 0)) { + throw new Error("overrideReportedInputTokensAfterCases contains duplicate case names") + } + if (inputTokenOverrides.size > 0 && !input.sharedSession) { + throw new Error("overrideReportedInputTokensAfterCases requires sharedSession") + } + for (const [caseName, inputTokens] of inputTokenOverrides) { + const caseIndex = input.cases.findIndex((testCase) => testCase.name === caseName) + if (caseIndex < 0) throw new Error(`Unknown input-token override case ${caseName}`) + if (caseIndex === input.cases.length - 1) { + throw new Error(`Input-token override case ${caseName} has no subsequent turn`) + } + if (!Number.isSafeInteger(inputTokens) || inputTokens <= 0) { + throw new Error(`Input-token override for ${caseName} must be a positive safe integer`) + } + } const preflight = await preflightLiveLLM(config) const testRoot = await mkdtemp(path.join(os.tmpdir(), `deepagent-code-${input.suite}-`)) const isolatedHome = path.join(testRoot, "home") @@ -196,6 +233,7 @@ export async function runLegacyLiveCases(input: { await prepareIsolation(testRoot, isolatedHome, isolatedData, config, input.environment) const { ModelV2 } = await import("@deepagent-code/core/model") const { ProviderV2 } = await import("@deepagent-code/core/provider") + const { AgentGateway } = await import("@deepagent-code/core/agent-gateway") const { CrossSpawnSpawner } = await import("@deepagent-code/core/cross-spawn-spawner") const { EffectFlock } = await import("@deepagent-code/core/util/effect-flock") const { Context, Deferred, Effect, Fiber, Layer, Schedule } = await import("effect") @@ -218,6 +256,7 @@ export async function runLegacyLiveCases(input: { const { SessionCompaction } = await import("../../src/session/compaction") const { CompactionRunTable, CompactionSummaryAttemptTable } = await import("../../src/session/compaction-sql") const { SessionPromptEpochTable } = await import("../../src/session/prompt-epoch.sql") + const { SessionWorldStateBaselineTable } = await import("@deepagent-code/core/session/sql") const { SessionPromptIntent } = await import("../../src/session/prompt-intent") const { SessionPrompt } = await import("../../src/session/prompt") const { SessionRevert } = await import("../../src/session/revert") @@ -225,6 +264,7 @@ export async function runLegacyLiveCases(input: { const { MessageID } = await import("../../src/session/schema") const { SessionSteer } = await import("../../src/session/steer") const { Session } = await import("../../src/session/session") + const { SessionToolArgumentReceiptTable } = await import("../../src/session/tool-argument-receipt.sql") const { SessionToolRequestReceiptTable } = await import("../../src/session/tool-request-receipt.sql") const { SessionIntentTable } = await import("@deepagent-code/core/session/sql") const { EventDispatcher } = await import("../../src/session/event-dispatcher") @@ -239,7 +279,7 @@ export async function runLegacyLiveCases(input: { const { makeTaskSubagentRunner } = await import("../../src/session/goal-loop-wiring") const { TestInstance, testInstanceStoreLayer, tmpdirScoped } = await import("../../test/fixture/fixture") - const providerID = ProviderV2.ID.make(runtimeProviderID) + const providerID = ProviderV2.ID.make(liveProviderID) const modelID = ModelV2.ID.make(config.modelID) const startedAt = Date.now() let sandbox: ToolSandbox | undefined @@ -267,6 +307,7 @@ export async function runLegacyLiveCases(input: { const instances = input.v4Event ? yield* InstanceStore.Service : undefined const gitService = input.v4Event ? yield* Git.Service : undefined const prQueue = input.v4Event ? yield* PRQueue.Service : undefined + let activeCaseName: string | undefined const assembledRequestFingerprints: GlobalEvent[] = [] const requestFingerprintListener = (event: GlobalEvent) => { if (event.payload?.type !== "session.request.assembled-fingerprint") return @@ -297,6 +338,15 @@ export async function runLegacyLiveCases(input: { workspaceID: event.location?.workspaceID, }) return Effect.gen(function* () { + if (input.beforePermissionReply) { + yield* Effect.promise(() => + input.beforePermissionReply!({ + caseName: activeCaseName, + directory: instance.directory, + request, + }), + ) + } if (permissionBarrier && input.permissionBarrierCount) { if (permissionRequests.length === input.permissionBarrierCount) { permissionBarrierSnapshots.push( @@ -576,9 +626,15 @@ export async function runLegacyLiveCases(input: { const observations = yield* Effect.forEach(input.cases, (testCase) => Effect.gen(function* () { const session = sharedSession ?? (yield* sessions.create({ title: `Live ${input.suite}: ${testCase.name}` })) + activeCaseName = testCase.name if (input.beforeCase) { yield* Effect.promise(() => - input.beforeCase!({ caseName: testCase.name, directory: instance.directory, sandbox }), + input.beforeCase!({ + caseName: testCase.name, + directory: instance.directory, + sessionID: session.id, + sandbox, + }), ) } const revertEvidence = testCase.revertBefore @@ -832,10 +888,41 @@ export async function runLegacyLiveCases(input: { ) return result }) + const inputTokenOverride = inputTokenOverrides.get(testCase.name) + const tokenUsageOverride = + inputTokenOverride === undefined + ? undefined + : yield* Effect.gen(function* () { + if (result.info.role !== "assistant") { + return yield* Effect.die(new Error(`Input-token override target ${testCase.name} was not assistant`)) + } + const override = { + originalInputTokens: result.info.tokens.input, + originalTotalTokens: result.info.tokens.total, + persistedInputTokens: inputTokenOverride, + persistedTotalTokens: + inputTokenOverride + + result.info.tokens.output + + result.info.tokens.reasoning + + result.info.tokens.cache.read + + result.info.tokens.cache.write, + } + // Fault injection is persisted through the production message event path. The next turn then + // exercises the real overflow, compaction, PromptEpoch commit, and continuation lifecycle. + yield* sessions.updateMessage({ + ...result.info, + tokens: { + ...result.info.tokens, + total: override.persistedTotalTokens, + input: override.persistedInputTokens, + }, + }) + return override + }) const panelCase = input.panel?.afterCaseName === testCase.name ? input.panel : undefined if (panelCase && sharedSession && agents) { const opinions: unknown[] = [] - const model = { providerID: runtimeProviderID, modelID: config.modelID } + const model = { providerID: liveProviderID, modelID: config.modelID } const runTurn = makeTaskSubagentRunner({ sessions, agents, @@ -1046,35 +1133,58 @@ export async function runLegacyLiveCases(input: { .pipe(Effect.orDie) : undefined const durability = input.inspectDurability - ? { - promptEpochs: yield* database.db - .select() - .from(SessionPromptEpochTable) - .where(eq(SessionPromptEpochTable.session_id, session.id)) - .all() - .pipe(Effect.orDie), - compactionRuns: yield* database.db - .select() - .from(CompactionRunTable) - .where(eq(CompactionRunTable.session_id, session.id)) - .all() - .pipe(Effect.orDie), - summaryAttempts: yield* database.db - .select() - .from(CompactionSummaryAttemptTable) - .all() - .pipe(Effect.orDie), - requestReceipts: yield* database.db + ? yield* Effect.gen(function* () { + const requestReceipts = yield* database.db .select() .from(SessionToolRequestReceiptTable) .where(eq(SessionToolRequestReceiptTable.session_id, session.id)) .all() - .pipe(Effect.orDie), + .pipe(Effect.orDie) + const receiptIDs = new Set(requestReceipts.map((receipt) => receipt.receipt_id)) + return { + promptEpochs: yield* database.db + .select() + .from(SessionPromptEpochTable) + .where(eq(SessionPromptEpochTable.session_id, session.id)) + .all() + .pipe(Effect.orDie), + worldStateBaselines: yield* database.db + .select() + .from(SessionWorldStateBaselineTable) + .where(eq(SessionWorldStateBaselineTable.session_id, session.id)) + .all() + .pipe(Effect.orDie), + compactionRuns: yield* database.db + .select() + .from(CompactionRunTable) + .where(eq(CompactionRunTable.session_id, session.id)) + .all() + .pipe(Effect.orDie), + summaryAttempts: yield* database.db + .select() + .from(CompactionSummaryAttemptTable) + .all() + .pipe(Effect.orDie), + requestReceipts, + argumentReceipts: (yield* database.db + .select() + .from(SessionToolArgumentReceiptTable) + .all() + .pipe(Effect.orDie)).filter((receipt) => receiptIDs.has(receipt.receipt_id)), + } + }) + : undefined + const plan = input.inspectPlan + ? { + document: AgentGateway.DeepAgentPlanStore.getPlanDoc(session.id), + ref: AgentGateway.DeepAgentPlanStore.planDocRef(session.id), + root: AgentGateway.DeepAgentPlanStore.planStoreRoot(session.id), } : undefined return { name: testCase.name, sessionID: session.id, + plan, assembledRequestFingerprints: assembledRequestFingerprints .slice(requestFingerprintCountBefore) .filter((event) => event.payload?.properties?.sessionID === session.id) @@ -1096,6 +1206,7 @@ export async function runLegacyLiveCases(input: { retry: admissionRetryEvidence[0], } : undefined, + tokenUsageOverride, revert: revertEvidence, users: currentUsers.map((message) => ({ metadata: message.info.metadata, @@ -1361,7 +1472,7 @@ export async function runLegacyLiveCases(input: { stack: input.v4Event ? ("v4-event-runtime" as const) : ("legacy-session" as const), status: errors.length > 0 ? ("failed" as const) : ("passed" as const), error: errors.length > 0 ? errors : undefined, - fingerprint: { ...modelFingerprint(config), runtimeProviderID }, + fingerprint: { ...modelFingerprint(config), runtimeProviderID: liveProviderID }, preflight: { durationMs: preflight.durationMs }, sandbox: sandbox?.evidence, initialVerifier, @@ -1479,10 +1590,11 @@ export function liveWorkspaceConfig( subagentIntensity?: "inherit" | "downgrade" }, ): ConfigV1.Info { + const liveProviderID = runtimeProviderIDFor(config) return { snapshot: false, - enabled_providers: [runtimeProviderID], - model: `${runtimeProviderID}/${config.modelID}`, + enabled_providers: [liveProviderID], + model: `${liveProviderID}/${config.modelID}`, permission, mcp, tool_output: options?.toolOutput, @@ -1512,8 +1624,8 @@ export function liveWorkspaceConfig( }, } : {}), - [runtimeProviderID]: { - name: "DeepSeek legacy live test", + [liveProviderID]: { + name: `${config.providerID === "deepseek" ? "DeepSeek" : "Kimi"} legacy live test`, env: [], npm: "@ai-sdk/openai-compatible", api: config.baseURL, @@ -1526,15 +1638,18 @@ export function liveWorkspaceConfig( models: { [config.modelID]: { id: config.modelID, - name: "DeepSeek V4 Flash live test", - reasoning: false, - temperature: true, + name: `${config.modelID} live test`, + reasoning: config.providerID === "kimi", + temperature: config.providerID === "deepseek", tool_call: true, release_date: "2026-07-27", limit: { context: options?.modelContextTokens ?? 1_000_000, output: 2048 }, cost: { input: 0, output: 0 }, modalities: { input: ["text"], output: ["text"] }, - options: { thinking: { type: "disabled" }, maxTokens: options?.modelMaxTokens ?? 512, temperature: 0 }, + options: + config.providerID === "deepseek" + ? { thinking: { type: "disabled" }, maxTokens: options?.modelMaxTokens ?? 512, temperature: 0 } + : { reasoningEffort: "low", maxTokens: options?.modelMaxTokens ?? 1024 }, }, }, }, diff --git a/packages/deepagent-code/script/packaged-fork-smoke.ts b/packages/deepagent-code/script/packaged-fork-smoke.ts new file mode 100644 index 00000000..b008ad87 --- /dev/null +++ b/packages/deepagent-code/script/packaged-fork-smoke.ts @@ -0,0 +1,26 @@ +#!/usr/bin/env bun + +import path from "node:path" + +const packageRoot = path.resolve(import.meta.dirname, "..") +const name = process.platform === "win32" ? "windows" : process.platform +const binary = path.join( + packageRoot, + "dist", + `deepagent-code-${name}-${process.arch}`, + "bin", + `deepagent-code${process.platform === "win32" ? ".exe" : ""}`, +) +if (!(await Bun.file(binary).exists())) { + throw new Error( + `packaged binary is missing: ${binary}; run bun run build --single --skip-install --skip-embed-web-ui`, + ) +} + +const result = Bun.spawnSync(["bun", "test", "--timeout", "120000", "test/cli/serve/packaged-fork.test.ts"], { + cwd: packageRoot, + env: { ...process.env, DEEPAGENT_CODE_TEST_BINARY: binary }, + stdout: "inherit", + stderr: "inherit", +}) +process.exit(result.exitCode) diff --git a/packages/deepagent-code/src/acp/service.ts b/packages/deepagent-code/src/acp/service.ts index dcea467e..f03e8246 100644 --- a/packages/deepagent-code/src/acp/service.ts +++ b/packages/deepagent-code/src/acp/service.ts @@ -30,6 +30,7 @@ import { type SetSessionModeResponse, } from "@agentclientprotocol/sdk" import { InstallationVersion } from "@deepagent-code/core/installation/version" +import { Identifier } from "@deepagent-code/core/util/identifier" import * as Log from "@deepagent-code/core/util/log" import type { Message, OpencodeClient, SessionMessageResponse } from "@deepagent-code/sdk/v2" import { Context, Effect, Layer, ManagedRuntime } from "effect" @@ -85,6 +86,7 @@ export function make(input: { const directoryService = input.directory ?? makeDirectoryService(input.sdk) const registeredMcp = new Map>() const sessionSnapshots = new Map() + const forkIntents = new Map() const events = input.connection ? ACPEvent.start({ sdk: input.sdk, connection: input.connection, session }) : undefined @@ -356,6 +358,9 @@ export function make(input: { }) const forkSession = Effect.fn("ACP.forkSession")(function* (params: ForkSessionRequest) { + const intentKey = `${params.cwd}\0${params.sessionId}` + const intentID = forkIntents.get(intentKey) ?? `acp-fork:${Identifier.ascending()}` + forkIntents.set(intentKey, intentID) const snapshot = yield* directorySnapshot(params.cwd) const forked = yield* request( () => @@ -367,6 +372,7 @@ export function make(input: { // -directory feature). This ACP path only ever meant the workspace scope → query. query_directory: params.cwd, sessionID: params.sessionId, + intentID, }, { throwOnError: true }, ), @@ -392,6 +398,7 @@ export function make(input: { yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers ?? []) yield* sendAvailableCommands(input.connection, state.id, snapshot) yield* replayMessages(events, messages) + if (forkIntents.get(intentKey) === intentID) forkIntents.delete(intentKey) return { sessionId: state.id, @@ -645,13 +652,13 @@ function makeUsageService(sdk: OpencodeClient) { ) if (!messages) return - const message = UsageService.latestAssistantMessage(messages) - if (!message?.providerID || !message.modelID) return + const context = UsageService.retainedContext(messages) + if (!context?.providerID || !context.modelID) return const size = yield* contextLimit({ directory: params.directory, - providerID: ProviderV2.ID.make(message.providerID), - modelID: ModelV2.ID.make(message.modelID), + providerID: ProviderV2.ID.make(context.providerID), + modelID: ModelV2.ID.make(context.modelID), }) if (!size) return @@ -661,7 +668,7 @@ function makeUsageService(sdk: OpencodeClient) { sessionId: params.sessionID, update: { sessionUpdate: "usage_update", - used: message.tokens.input + message.tokens.cache.read, + used: context.used, size, cost: { amount: UsageService.totalSessionCost(messages), currency: "USD" }, }, @@ -801,8 +808,11 @@ function defaultModelFromConfig( // a default. Configured model, deepagent-code provider, then sorted best model keep // the protocol response deterministic without extra session/message reads. const deepagentCodeProvider = providers[ProviderV2.ID.make("deepagent-code")] - const deepagentCodeModel = deepagentCodeProvider ? Provider.sort(Object.values(deepagentCodeProvider.models))[0] : undefined - if (deepagentCodeProvider && deepagentCodeModel) return { providerID: deepagentCodeProvider.id, modelID: deepagentCodeModel.id } + const deepagentCodeModel = deepagentCodeProvider + ? Provider.sort(Object.values(deepagentCodeProvider.models))[0] + : undefined + if (deepagentCodeProvider && deepagentCodeModel) + return { providerID: deepagentCodeProvider.id, modelID: deepagentCodeModel.id } const best = Provider.sort(Object.values(providers).flatMap((provider) => Object.values(provider.models)))[0] if (best) return { providerID: best.providerID, modelID: best.id } diff --git a/packages/deepagent-code/src/acp/usage.ts b/packages/deepagent-code/src/acp/usage.ts index 89128c4a..3e7d6ac6 100644 --- a/packages/deepagent-code/src/acp/usage.ts +++ b/packages/deepagent-code/src/acp/usage.ts @@ -14,10 +14,19 @@ export type AssistantTokenCost = Pick & - Partial> + Partial< + Pick + > + +type UserMessage = { + readonly role: "user" + readonly id?: string + readonly model?: { readonly providerID: string; readonly modelID: string } +} export type SessionMessage = { - readonly info: { readonly role: Message["role"] } | AssistantMessage + readonly info: { readonly role: Message["role"] } | AssistantMessage | UserMessage + readonly parts?: readonly { readonly type: string; readonly context_tokens?: number }[] } export type MessagesInput = { @@ -104,6 +113,30 @@ export function latestAssistantMessage(messages: readonly SessionMessage[]): Ass .at(-1)?.info } +export function retainedContext(messages: readonly SessionMessage[]) { + const reversed = messages.toReversed() + for (const item of reversed) { + if (item.info.role !== "assistant") continue + const message = item.info as AssistantMessage + if (message.summary && message.finish && !message.error && message.parentID) { + const parent = messages.find((candidate) => "id" in candidate.info && candidate.info.id === message.parentID) + const marker = parent?.parts?.find((part) => part.type === "compaction" && part.context_tokens !== undefined) + if (marker?.context_tokens !== undefined) { + const parentInfo = parent?.info.role === "user" ? (parent.info as UserMessage) : undefined + return { + message, + used: marker.context_tokens, + providerID: parentInfo?.model?.providerID ?? message.providerID, + modelID: parentInfo?.model?.modelID ?? message.modelID, + } + } + } + const used = message.tokens.input + message.tokens.cache.read + message.tokens.cache.write + if (used <= 0) continue + return { message, used, providerID: message.providerID, modelID: message.modelID } + } +} + export function totalSessionCost(messages: readonly SessionMessage[]): number { return messages .filter((message): message is { readonly info: AssistantMessage } => message.info.role === "assistant") @@ -192,14 +225,13 @@ export const layer = Layer.effect( ) if (!messages) return - const message = latestAssistantMessage(messages) - if (!message) return - if (!message.providerID || !message.modelID) return + const context = retainedContext(messages) + if (!context?.providerID || !context.modelID) return const size = yield* contextLimit({ directory: input.directory, - providerID: ProviderV2.ID.make(message.providerID), - modelID: ModelV2.ID.make(message.modelID), + providerID: ProviderV2.ID.make(context.providerID), + modelID: ModelV2.ID.make(context.modelID), }) if (!size) return @@ -209,7 +241,7 @@ export const layer = Layer.effect( sessionId: input.sessionID, update: { sessionUpdate: "usage_update", - used: message.tokens.input + message.tokens.cache.read, + used: context.used, size, cost: { amount: totalSessionCost(messages), currency: "USD" }, }, diff --git a/packages/deepagent-code/src/cli/cmd/run.ts b/packages/deepagent-code/src/cli/cmd/run.ts index c3522eed..d9f85017 100644 --- a/packages/deepagent-code/src/cli/cmd/run.ts +++ b/packages/deepagent-code/src/cli/cmd/run.ts @@ -25,6 +25,7 @@ import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@deepa import { FormatError, FormatUnknownError } from "../error" import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./run/runtime.stdin" import { backgroundTask, createBackgroundSessions, createSessionTree, questionAnswers } from "./run/noninteractive" +import { Identifier } from "@deepagent-code/core/util/identifier" type ModelInput = Parameters[0]["model"] @@ -383,6 +384,7 @@ export const RunCommand = effectCmd({ const piped = process.stdin.isTTY ? undefined : await Bun.stdin.text() message = resolveRunInput(message, piped) ?? "" const initialInput = resolveRunInput(rawMessage, piped) + const forkIntentID = `fork_${Identifier.ascending()}` if (message.trim().length === 0 && !args.command && !args.interactive && !args.goal) { UI.error("You must provide a message or a command") @@ -436,6 +438,7 @@ export const RunCommand = effectCmd({ if (args.fork) { const forked = await sdk.session.fork({ sessionID: args.session, + intentID: forkIntentID, }) const id = forked.data?.id if (!id) { @@ -461,6 +464,7 @@ export const RunCommand = effectCmd({ if (base && args.fork) { const forked = await sdk.session.fork({ sessionID: base.id, + intentID: forkIntentID, }) const id = forked.data?.id if (!id) { diff --git a/packages/deepagent-code/src/cli/cmd/run/runtime.ts b/packages/deepagent-code/src/cli/cmd/run/runtime.ts index 9e38ddb3..eb5ca48a 100644 --- a/packages/deepagent-code/src/cli/cmd/run/runtime.ts +++ b/packages/deepagent-code/src/cli/cmd/run/runtime.ts @@ -14,6 +14,7 @@ // 4. runs the prompt queue until the footer closes. import { createOpencodeClient } from "@deepagent-code/sdk/v2" import { Flag } from "@deepagent-code/core/flag/flag" +import { Identifier } from "@deepagent-code/core/util/identifier" import { MessageID } from "@/session/schema" import { createRunDemo } from "./demo" import { resolveModelInfo, resolveRunTuiConfig, resolveSessionInfo } from "./runtime.boot" @@ -219,6 +220,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep sessionTitle: ctx.sessionTitle, agent: ctx.agent, } + let forkIntentID: string | undefined setRunSpanAttributes(span, { "deepagent-code.directory": ctx.directory, "deepagent-code.resume": ctx.resume === true, @@ -621,8 +623,11 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep return result.error ? "session unshare failed" : "session unshared" } if (command === "fork") { - const result = await ctx.sdk.session.fork({ sessionID: state.sessionID }) + const intentID = forkIntentID ?? `fork_${Identifier.ascending()}` + forkIntentID = intentID + const result = await ctx.sdk.session.fork({ sessionID: state.sessionID, intentID }) if (!result.data?.id) return "session fork failed" + forkIntentID = undefined await footer.idle().catch(() => {}) await state.stream?.then((item) => item.handle.close()).catch(() => {}) state.stream = undefined @@ -652,8 +657,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep ctx.sdk.session.messages({ sessionID: state.sessionID }), ]) const users = (history.data ?? []).filter( - (message) => - message.info.role === "user" && !message.parts.some((part) => part.type === "compaction"), + (message) => message.info.role === "user" && !message.parts.some((part) => part.type === "compaction"), ) const revert = current.data?.revert?.messageID if (command === "undo") { diff --git a/packages/deepagent-code/src/effect/runtime-flags.ts b/packages/deepagent-code/src/effect/runtime-flags.ts index 6805f5bf..60259cb4 100644 --- a/packages/deepagent-code/src/effect/runtime-flags.ts +++ b/packages/deepagent-code/src/effect/runtime-flags.ts @@ -75,7 +75,7 @@ export class Service extends ConfigService.Service()("@deepagent-code/R Config.map((value): "legacy" | "shadow" | "durable" => { if (value === "legacy" || value === "shadow" || value === "durable") return value throw new Error( - `Invalid DEEPAGENT_CODE_SUBAGENT_CONTROL_PLANE="${value}". Must be one of: legacy, shadow, durable. Refusing to start with unknown mode.` + `Invalid DEEPAGENT_CODE_SUBAGENT_CONTROL_PLANE="${value}". Must be one of: legacy, shadow, durable. Refusing to start with unknown mode.`, ) }), ), @@ -214,14 +214,14 @@ export class Service extends ConfigService.Service()("@deepagent-code/R // V4.0.1 P1: World State / summary responsibility separation. When on, the compaction summary is narrowed // to four buckets (progress+decisions / constraints+prefs / next steps / data references) and files / // env / diagnostics are carried by a snapshot-diff World State layer re-injected as a TAIL user block at - // tick start + after each hard compaction (never the static prefix). Also opens a "always load World + // tick start (never the static prefix). Every committed compaction window persists and projects its own + // full World State baseline regardless of this compatibility flag; allowing a baseline-free window would + // make PromptEpoch incomplete and reopen the manual-compaction information gap. Also opens an "always load World // State" path for the goal-worker (P3(d)), bypassing shouldLoadBridge's general short-circuit. Because it // alters context assembly. Promoted ON (V4.0.1): it is a pure tail-only re-injection (never the static // prefix) and the summary narrowing keeps the LLM summary focused, so it is safe as a default; with - // `=false` the summary keeps the legacy "record everything" template and nothing is re-injected (逐字节 - // equivalent to V4.1). The summary - // narrowing and the re-injection MUST be gated by this single flag together (splitting them would create a - // "summary drops files, nothing re-injects" information hole). + // `=false` the summary keeps the legacy "record everything" template and goal-loop tick injection remains + // disabled. The compaction baseline is an authority invariant rather than optional feature behavior. worldStateReinjection: stableOn("DEEPAGENT_CODE_WORLD_STATE_REINJECTION"), // T3 (S1-v3.4): how many narrowing attempts a 🟡 stall is given before it escalates to 🔴. // Default 1 (one focused retry, then hand off). `positiveInteger` → undefined when unset/invalid, diff --git a/packages/deepagent-code/src/provider/catalog-spec.ts b/packages/deepagent-code/src/provider/catalog-spec.ts index 445c18c6..29a0cbc1 100644 --- a/packages/deepagent-code/src/provider/catalog-spec.ts +++ b/packages/deepagent-code/src/provider/catalog-spec.ts @@ -31,7 +31,7 @@ export function normalizeModelID(id: string): string { // Strip a trailing date/version stamp so "claude-3-5-sonnet-20241022" can fall back to // "claude-3-5-sonnet". Only used as a secondary (loose) match after exact-normalized misses. export function stripDateSuffix(normalized: string): string { - return normalized.replace(/-(?:\d{6,8}|v\d+(?:-\d+)*|latest|preview)$/g, "") + return normalized.replace(/-(?:\d{4,8}|v\d+(?:-\d+)*|latest|preview)$/g, "") } export interface CatalogMatch { @@ -82,19 +82,21 @@ export function buildCatalogIndex(catalog: Record): return { exact, loose } } -// Look up catalog specs for a discovered/custom model. Tries the api id then the config id against the -// exact map, then the date-stripped loose map. Returns the matched catalog model or undefined. -export function catalogSpecFor(apiID: string, modelID: string, index: CatalogIndex): ModelsDev.Model | undefined { - const apiKey = normalizeModelID(apiID) - const idKey = normalizeModelID(modelID) - return ( - index.exact.get(apiKey)?.model ?? - index.exact.get(idKey)?.model ?? - index.loose.get(stripDateSuffix(apiKey))?.model ?? - index.loose.get(stripDateSuffix(idKey))?.model +function matchFor(apiID: string, modelID: string, index: CatalogIndex): CatalogMatch | undefined { + const keys = [normalizeModelID(apiID), normalizeModelID(modelID)] + const candidates = keys.flatMap((key) => [index.exact.get(key), index.loose.get(stripDateSuffix(key))]) + return candidates.filter((match): match is CatalogMatch => match !== undefined).reduce( + (best, candidate) => (best ? preferMatch(best, candidate) : candidate), + undefined, ) } +// Look up catalog specs for a discovered/custom model. Compare exact and date-stripped candidates +// together so an exact third-party dated alias cannot outrank the canonical official base model. +export function catalogSpecFor(apiID: string, modelID: string, index: CatalogIndex): ModelsDev.Model | undefined { + return matchFor(apiID, modelID, index)?.model +} + // Small projection of the fields the discover dialog surfaces so the user can preview (and then // override) the auto-filled specs. Undefined when there's no catalog match. export interface ProjectedSpec { @@ -118,12 +120,5 @@ export function projectSpec(match: CatalogMatch): ProjectedSpec { } export function specMatchFor(apiID: string, modelID: string, index: CatalogIndex): CatalogMatch | undefined { - const apiKey = normalizeModelID(apiID) - const idKey = normalizeModelID(modelID) - return ( - index.exact.get(apiKey) ?? - index.exact.get(idKey) ?? - index.loose.get(stripDateSuffix(apiKey)) ?? - index.loose.get(stripDateSuffix(idKey)) - ) + return matchFor(apiID, modelID, index) } diff --git a/packages/deepagent-code/src/server/mdns.ts b/packages/deepagent-code/src/server/mdns.ts index b782e518..b488accb 100644 --- a/packages/deepagent-code/src/server/mdns.ts +++ b/packages/deepagent-code/src/server/mdns.ts @@ -3,18 +3,13 @@ import { Bonjour } from "bonjour-service" const log = Log.create({ service: "mdns" }) -let bonjour: Bonjour | undefined -let currentPort: number | undefined - export function publish(port: number, domain?: string) { - if (currentPort === port) return - if (bonjour) unpublish() - + let bonjour: Bonjour | undefined try { + const instance = (bonjour = new Bonjour()) const host = domain ?? "deepagent-code.local" const name = `deepagent-code-${port}` - bonjour = new Bonjour() - const service = bonjour.publish({ + const service = instance.publish({ name, type: "http", host, @@ -30,30 +25,32 @@ export function publish(port: number, domain?: string) { log.error("mDNS service error", { error: err }) }) - currentPort = port + let unpublished = false + return { + unpublish() { + if (unpublished) return + unpublished = true + try { + instance.unpublishAll() + } catch (err) { + log.error("mDNS unpublish failed", { error: err }) + } + try { + instance.destroy() + } catch (err) { + log.error("mDNS destroy failed", { error: err }) + } + log.info("mDNS service unpublished") + }, + } } catch (err) { log.error("mDNS publish failed", { error: err }) - if (bonjour) { - try { - bonjour.destroy() - } catch {} - } - bonjour = undefined - currentPort = undefined - } -} - -export function unpublish() { - if (bonjour) { try { - bonjour.unpublishAll() - bonjour.destroy() - } catch (err) { - log.error("mDNS unpublish failed", { error: err }) + bonjour?.destroy() + } catch (destroyError) { + log.error("mDNS destroy failed", { error: destroyError }) } - bonjour = undefined - currentPort = undefined - log.info("mDNS service unpublished") + return { unpublish() {} } } } diff --git a/packages/deepagent-code/src/server/routes/instance/httpapi/groups/session.ts b/packages/deepagent-code/src/server/routes/instance/httpapi/groups/session.ts index ff35600c..72fc3a4b 100644 --- a/packages/deepagent-code/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/deepagent-code/src/server/routes/instance/httpapi/groups/session.ts @@ -66,6 +66,10 @@ export const UpdatePayload = Schema.Struct({ ), }) export const ForkPayload = Schema.Struct(Struct.omit(Session.ForkInput.fields, ["sessionID"])) +export const LegacyForkPayload = Schema.Struct({ + ...Struct.omit(Session.ForkInput.fields, ["sessionID", "intentID"]), + intentID: Schema.optional(Schema.NonEmptyString), +}) export const InitPayload = Schema.Struct({ modelID: ModelV2.ID, providerID: ProviderV2.ID, @@ -77,6 +81,10 @@ export const SummarizePayload = Schema.Struct({ auto: Schema.optional(Schema.Boolean), }) export const PromptPayload = Schema.Struct(Struct.omit(SessionPrompt.PromptInput.fields, ["sessionID"])) +export const PromptAsyncAccepted = Schema.Struct({ + messageID: MessageID, + delivery: Schema.Literals(["turn", "steer", "queue", "goal_steer"]), +}) export const PromptPreparePayload = Schema.Struct({ // Legacy-compat: "wish" is the pre-rename wire literal for "intelligence". The server accepts BOTH // so an older client sending "wish" still works while new clients send "intelligence"; the handler @@ -450,14 +458,15 @@ export const SessionApi = HttpApi.make("session") HttpApiEndpoint.post("fork", SessionPaths.fork, { params: { sessionID: SessionID }, query: WorkspaceRoutingQuery, - payload: [HttpApiSchema.NoContent, ForkPayload], + payload: ForkPayload, success: described(Session.Info, "200"), - error: [HttpApiError.BadRequest, ApiNotFoundError], + error: [HttpApiError.BadRequest, ConflictError, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "session.fork", summary: "Fork session", - description: "Create a new session by forking an existing session at a specific message point.", + description: + "Create a new session by forking an existing session at a specific message point. intentID is required so response-loss retries adopt the same child. Older bodyless HTTP clients remain supported by a compatibility parser.", }), ), HttpApiEndpoint.post("abort", SessionPaths.abort, { @@ -580,7 +589,7 @@ export const SessionApi = HttpApi.make("session") params: { sessionID: SessionID }, query: WorkspaceRoutingQuery, payload: PromptPayload, - success: described(HttpApiSchema.NoContent, "Prompt accepted"), + success: described(PromptAsyncAccepted, "Prompt durably admitted"), error: [HttpApiError.BadRequest, ConflictError, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ diff --git a/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/im-websocket.ts b/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/im-websocket.ts index c3f24a7c..921d01ec 100644 --- a/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/im-websocket.ts +++ b/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/im-websocket.ts @@ -88,19 +88,19 @@ export const imWebSocketHandlers = HttpApiBuilder.group(IMWebSocketApi, "im-webs close, } - // Register connection - broadcaster.register(connection) - - const registered = yield* WebSocketTracker.register( + const registration = yield* WebSocketTracker.register( Effect.sync(() => { close(1001, "server closing") }), ) - if (!registered) { + if (!registration.accepted) { close(1001, "server closing") return HttpServerResponse.empty() } + // Register connection only after the listener accepts ownership. + broadcaster.register(connection) + // Setup heartbeat check heartbeatTimer = setInterval(() => { const now = Date.now() @@ -179,7 +179,7 @@ export const imWebSocketHandlers = HttpApiBuilder.group(IMWebSocketApi, "im-webs ) // Run message handler - yield* messageHandler + yield* Effect.raceFirst(messageHandler, registration.shutdown) return HttpServerResponse.empty() }).pipe( diff --git a/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/pty.ts b/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/pty.ts index 6016559a..f37349c4 100644 --- a/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/pty.ts +++ b/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/pty.ts @@ -250,8 +250,8 @@ export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-conne Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void), Effect.catch(() => Effect.void), ) - const registered = yield* WebSocketTracker.register(write(WebSocketTracker.SERVER_CLOSING_EVENT())) - if (!registered) { + const registration = yield* WebSocketTracker.register(write(WebSocketTracker.SERVER_CLOSING_EVENT())) + if (!registration.accepted) { yield* closeAccepted(WebSocketTracker.SERVER_CLOSING_EVENT()) return HttpServerResponse.empty() } @@ -285,18 +285,19 @@ export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-conne // The handshake runs inside `socket.runRaw`, after the input callback is // registered, so the client cannot send frames before PTY input is wired. - yield* socket - .runRaw((message) => handlePtyInput(handler, message)) - .pipe( - Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void), - Effect.ensuring( - Effect.sync(() => { - closed = true - handler.onClose() - }), - ), - Effect.orDie, - ) + yield* Effect.raceFirst( + socket.runRaw((message) => handlePtyInput(handler, message)), + registration.shutdown, + ).pipe( + Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void), + Effect.ensuring( + Effect.sync(() => { + closed = true + handler.onClose() + }), + ), + Effect.orDie, + ) return HttpServerResponse.empty() }), ) diff --git a/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/session-errors.ts b/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/session-errors.ts index e297bdd0..eed6542a 100644 --- a/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/session-errors.ts +++ b/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/session-errors.ts @@ -1,5 +1,5 @@ import type { NotFoundError as StorageNotFoundError } from "@/storage/storage" -import type { Session } from "@/session/session" +import { Session } from "@/session/session" import { Effect } from "effect" import * as ApiError from "../errors" @@ -7,6 +7,16 @@ export function mapStorageNotFound(self: Effect.Effect ApiError.notFound(error.message))) } +export function mapFork(self: Effect.Effect) { + return self.pipe( + Effect.mapError((error) => + error instanceof Session.ForkConflict + ? new ApiError.ConflictError({ message: error.reason, resource: `fork_intent:${error.intentID}` }) + : ApiError.notFound(error.message), + ), + ) +} + export function mapBusy(self: Effect.Effect) { return self.pipe( Effect.catchTag("SessionBusyError", (error) => diff --git a/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/session.ts b/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/session.ts index 9ff25965..63dbd13b 100644 --- a/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/session.ts @@ -33,6 +33,7 @@ import { DiffQuery, ForkPayload, InitPayload, + LegacyForkPayload, ListQuery, MessagesQuery, PermissionResponsePayload, @@ -43,8 +44,9 @@ import { SummarizePayload, UpdatePayload, } from "../groups/session" -import { ConflictError, PermissionNotFoundError } from "../errors" +import { ConflictError, PermissionNotFoundError, notFound } from "../errors" import * as SessionError from "./session-errors" +import { randomUUID } from "node:crypto" const tryParseJson = (text: string) => Effect.try({ @@ -250,14 +252,15 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", const fork = Effect.fn("SessionHttpApi.fork")(function* (ctx: { params: { sessionID: SessionID } - payload?: typeof ForkPayload.Type + payload: typeof ForkPayload.Type }) { - return yield* SessionError.mapStorageNotFound( + return yield* SessionError.mapFork( session.fork({ sessionID: ctx.params.sessionID, - messageID: ctx.payload?.messageID, - directory: ctx.payload?.directory, - isolate: ctx.payload?.isolate, + intentID: ctx.payload.intentID ?? `legacy_fork_${randomUUID()}`, + messageID: ctx.payload.messageID, + directory: ctx.payload.directory, + isolate: ctx.payload.isolate, }), ) }) @@ -267,13 +270,14 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", request: HttpServerRequest.HttpServerRequest }) { const body = yield* Effect.orDie(ctx.request.text) - if (body.trim().length === 0) return yield* fork({ params: ctx.params }) - - const json = yield* tryParseJson(body) - const payload = yield* Schema.decodeUnknownEffect(ForkPayload)(json).pipe( + const json = body.trim().length === 0 ? {} : yield* tryParseJson(body) + const payload = yield* Schema.decodeUnknownEffect(LegacyForkPayload)(json).pipe( Effect.mapError(() => new HttpApiError.BadRequest({})), ) - return yield* fork({ params: ctx.params, payload }) + return yield* fork({ + params: ctx.params, + payload: { ...payload, intentID: payload.intentID ?? `legacy_fork_${randomUUID()}` }, + }) }) const abort = Effect.fn("SessionHttpApi.abort")(function* (ctx: { params: { sessionID: SessionID } }) { @@ -344,6 +348,13 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", payload: typeof PromptPayload.Type }) { yield* requireSession(ctx.params.sessionID) + yield* session + .assertRunnable(ctx.params.sessionID) + .pipe( + Effect.mapError( + (error) => new ConflictError({ message: error.reason, resource: `session:${error.sessionID}` }), + ), + ) // V4.1 §S1.2: route through promptOrSteer — if the session is mid-turn, the message is absorbed as // a steer (the running turn picks it up at its next boundary) instead of erroring/blocking; if idle, // it runs a normal turn. For a completed turn we stream the assistant message as before (unchanged @@ -500,9 +511,16 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", payload: typeof PromptPayload.Type }) { yield* requireSession(ctx.params.sessionID) + yield* session + .assertRunnable(ctx.params.sessionID) + .pipe( + Effect.mapError( + (error) => new ConflictError({ message: error.reason, resource: `session:${error.sessionID}` }), + ), + ) // Return only after the input has crossed its durable admission boundary. Model execution stays // asynchronous, but callers may safely serialize destructive actions after this acknowledgement. - yield* promptSvc.promptAsync({ ...ctx.payload, sessionID: ctx.params.sessionID }).pipe( + const receipt = yield* promptSvc.promptAsync({ ...ctx.payload, sessionID: ctx.params.sessionID }).pipe( Effect.mapError((error) => error instanceof SessionPromptIntent.Conflict ? new ConflictError({ message: error.reason, resource: `session_intent:${error.intentID}` }) @@ -519,7 +537,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", : new HttpApiError.BadRequest({}), ), ) - return HttpApiSchema.NoContent.make() + return receipt }) const command = Effect.fn("SessionHttpApi.command")(function* (ctx: { @@ -576,6 +594,9 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", }) { yield* requireSession(ctx.params.sessionID) yield* SessionError.mapBusy(runState.assertNotBusy(ctx.params.sessionID)) + const messages = yield* SessionError.mapStorageNotFound(session.messages({ sessionID: ctx.params.sessionID })) + if (!messages.some((message) => message.info.id === ctx.params.messageID)) + return yield* notFound(`Message not found: ${ctx.params.messageID}`) yield* session.removeMessage(ctx.params) return true }) @@ -584,6 +605,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID; messageID: MessageID; partID: PartID } }) { yield* requireSession(ctx.params.sessionID) + if (!(yield* session.getPart(ctx.params))) return yield* notFound(`Part not found: ${ctx.params.partID}`) yield* session.removePart(ctx.params) return true }) @@ -601,6 +623,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", ) { return yield* new HttpApiError.BadRequest({}) } + if (!(yield* session.getPart(ctx.params))) return yield* notFound(`Part not found: ${ctx.params.partID}`) return yield* session.updatePart(payload) }) diff --git a/packages/deepagent-code/src/server/routes/instance/httpapi/middleware/proxy.ts b/packages/deepagent-code/src/server/routes/instance/httpapi/middleware/proxy.ts index e5362f8c..8cc7ddca 100644 --- a/packages/deepagent-code/src/server/routes/instance/httpapi/middleware/proxy.ts +++ b/packages/deepagent-code/src/server/routes/instance/httpapi/middleware/proxy.ts @@ -37,7 +37,7 @@ export function websocket( concurrency: "unbounded", discard: true, }) - const registered = yield* WebSocketTracker.register( + const registration = yield* WebSocketTracker.register( Effect.all( [ writeInbound(WebSocketTracker.SERVER_CLOSING_EVENT()), @@ -46,7 +46,7 @@ export function websocket( { concurrency: "unbounded", discard: true }, ), ) - if (!registered) { + if (!registration.accepted) { yield* closeAccepted return HttpServerResponse.empty() } @@ -63,14 +63,15 @@ export function websocket( Effect.forkScoped, ) - yield* inbound - .runRaw((message) => { + yield* Effect.raceFirst( + inbound.runRaw((message) => { return writeOutbound(typeof message === "string" ? message : message.slice()) - }) - .pipe( - Effect.catch(() => Effect.void), - Effect.ensuring(writeOutbound(new Socket.CloseEvent()).pipe(Effect.catch(() => Effect.void))), - ) + }), + registration.shutdown, + ).pipe( + Effect.catch(() => Effect.void), + Effect.ensuring(writeOutbound(new Socket.CloseEvent()).pipe(Effect.catch(() => Effect.void))), + ) return HttpServerResponse.empty() }).pipe(Effect.orDie), ) diff --git a/packages/deepagent-code/src/server/routes/instance/httpapi/websocket-tracker.ts b/packages/deepagent-code/src/server/routes/instance/httpapi/websocket-tracker.ts index aa2417cc..5de9d2b3 100644 --- a/packages/deepagent-code/src/server/routes/instance/httpapi/websocket-tracker.ts +++ b/packages/deepagent-code/src/server/routes/instance/httpapi/websocket-tracker.ts @@ -1,45 +1,62 @@ -import { Context, Effect, Layer, Option } from "effect" +import { Context, Deferred, Effect, Layer, Option, Scope } from "effect" import * as Socket from "effect/unstable/socket/Socket" export const SERVER_CLOSING_EVENT = () => new Socket.CloseEvent(1001, "server closing") type Close = Effect.Effect +export interface Registration { + readonly accepted: boolean + readonly shutdown: Effect.Effect +} + export interface Interface { - readonly add: (close: Close) => Effect.Effect - readonly remove: (close: Close) => Effect.Effect + readonly register: (close: Close) => Effect.Effect readonly closeAll: Effect.Effect } export class Service extends Context.Service()("@deepagent-code/HttpApiWebSocketTracker") {} export const layer = Layer.sync(Service)(() => { - const sockets = new Set() + const sockets = new Set<{ close: Close; shutdown: Deferred.Deferred }>() + const closed = Deferred.makeUnsafe() let closing = false return Service.of({ - add: (close) => + register: (close) => Effect.gen(function* () { - if (closing) return false - sockets.add(close) - return true - }), - remove: (close) => - Effect.sync(() => { - sockets.delete(close) + if (closing) return { accepted: false, shutdown: Effect.void } + const entry = { close, shutdown: yield* Deferred.make() } + sockets.add(entry) + yield* Effect.addFinalizer(() => + Effect.sync(() => { + sockets.delete(entry) + }), + ) + return { accepted: true, shutdown: Deferred.await(entry.shutdown) } }), closeAll: Effect.gen(function* () { + if (closing) return yield* Deferred.await(closed) closing = true const active = Array.from(sockets) sockets.clear() yield* Effect.all( - active.map((close) => - close.pipe( - Effect.timeout("1 second"), - Effect.catch(() => Effect.void), - ), + active.map((entry) => + Effect.gen(function* () { + const done = Deferred.makeUnsafe() + const closeFiber = yield* Effect.forkDetach( + entry.close.pipe( + Effect.catchCause(() => Effect.void), + Effect.ensuring(Deferred.succeed(done, undefined).pipe(Effect.ignore)), + ), + ) + yield* Deferred.await(done).pipe(Effect.timeout("1 second"), Effect.ignore) + yield* Effect.sync(() => closeFiber.interruptUnsafe()) + yield* Deferred.succeed(entry.shutdown, undefined) + }), ), { concurrency: "unbounded", discard: true }, ) + yield* Deferred.succeed(closed, undefined) }), }) }) @@ -47,11 +64,8 @@ export const layer = Layer.sync(Service)(() => { export const register = (close: Close) => Effect.gen(function* () { const tracker = yield* Effect.serviceOption(Service) - if (Option.isNone(tracker)) return true - const registered = yield* tracker.value.add(close) - if (!registered) return false - yield* Effect.addFinalizer(() => tracker.value.remove(close)) - return true + if (Option.isNone(tracker)) return { accepted: true, shutdown: Effect.never } + return yield* tracker.value.register(close) }) export * as WebSocketTracker from "./websocket-tracker" diff --git a/packages/deepagent-code/src/server/server.ts b/packages/deepagent-code/src/server/server.ts index 0fe481d4..c7e2710b 100644 --- a/packages/deepagent-code/src/server/server.ts +++ b/packages/deepagent-code/src/server/server.ts @@ -2,10 +2,11 @@ import "./init-projectors" import { NodeHttpServer } from "@effect/platform-node" import * as Log from "@deepagent-code/core/util/log" -import { ConfigProvider, Context, Effect, Exit, Layer, Scope } from "effect" +import { Cause, ConfigProvider, Context, Effect, Exit, Layer, Option, Scope } from "effect" import { HttpRouter, HttpServer } from "effect/unstable/http" import { OpenApi } from "effect/unstable/httpapi" import { createServer } from "node:http" +import type { Duplex } from "node:stream" import { MDNS } from "./mdns" import { HttpApiApp } from "./routes/instance/httpapi/server" import { disposeMiddleware } from "./routes/instance/httpapi/lifecycle" @@ -47,12 +48,9 @@ type ListenerState = { http: ListenerServer websockets: WebSocketTracker.Interface } -type EffectListener = Omit & { - stop: (close?: boolean) => Effect.Effect -} - interface ListenerServer { readonly closeAll: Effect.Effect + readonly close: Effect.Effect } class ListenerServerService extends Context.Service()( @@ -77,42 +75,36 @@ export async function openapi() { export let url: URL export async function listen(opts: ListenOptions): Promise { - const listener = await Effect.runPromise(listenEffect(opts)) - return { - hostname: listener.hostname, - port: listener.port, - url: listener.url, - stop: (close?: boolean) => Effect.runPromiseExit(listener.stop(close)).then(() => undefined), - } + return Effect.runPromise(listenEffect(opts)) } -const listenEffect: (opts: ListenOptions) => Effect.Effect = Effect.fn("Server.listen")( - function* (opts: ListenOptions) { - const cold = !serverHasListened - const layerBuildT0 = yield* Effect.sync(() => Date.now()) - const state = yield* startWithPortFallback(opts) - yield* Effect.sync(() => { - log.info("startup", { - event: "server.layer_build", - durationMs: Date.now() - layerBuildT0, - cold, - }) - serverHasListened = true +const listenEffect: (opts: ListenOptions) => Effect.Effect = Effect.fn("Server.listen")(function* ( + opts: ListenOptions, +) { + const cold = !serverHasListened + const layerBuildT0 = yield* Effect.sync(() => Date.now()) + const state = yield* startWithPortFallback(opts) + yield* Effect.sync(() => { + log.info("startup", { + event: "server.layer_build", + durationMs: Date.now() - layerBuildT0, + cold, }) - const address = yield* tcpAddress(state) - const listenerUrl = makeURL(opts.hostname, address.port) - url = listenerUrl + serverHasListened = true + }) + const address = yield* tcpAddress(state) + const listenerUrl = makeURL(opts.hostname, address.port) + url = listenerUrl - const unpublishMdns = yield* setupMdns(opts, address.port, state.scope) + const unpublishMdns = yield* setupMdns(opts, address.port, state.scope) - return { - hostname: opts.hostname, - port: address.port, - url: listenerUrl, - stop: yield* makeStop(state, unpublishMdns), - } - }, -) + return { + hostname: opts.hostname, + port: address.port, + url: listenerUrl, + stop: makeStop(state, unpublishMdns), + } +}) function listenerLayer(opts: ListenOptions, port: number) { return HttpRouter.serve(HttpApiApp.createRoutes(opts), { @@ -174,8 +166,8 @@ function setupMdns(opts: ListenOptions, port: number, scope: Scope.Scope) { const publish = opts.mdns && port && opts.hostname !== "127.0.0.1" && opts.hostname !== "localhost" && opts.hostname !== "::1" if (publish) { - const unpublish = yield* Effect.cached(Effect.sync(() => MDNS.unpublish())) - yield* Effect.sync(() => MDNS.publish(port, opts.mdnsDomain)) + const advertisement = yield* Effect.sync(() => MDNS.publish(port, opts.mdnsDomain)) + const unpublish = Effect.sync(() => advertisement.unpublish()) yield* Scope.addFinalizer(scope, unpublish) return unpublish } @@ -185,34 +177,73 @@ function setupMdns(opts: ListenOptions, port: number, scope: Scope.Scope) { } function makeStop(state: ListenerState, unpublishMdns: Effect.Effect) { - return Effect.gen(function* () { - const forceCloseOnce = yield* Effect.cached(forceClose(state).pipe(Effect.ignore)) - const closeScopeOnce = yield* Effect.cached(Scope.close(state.scope, Exit.void).pipe(Effect.ignore)) - - return (close?: boolean) => - Effect.gen(function* () { - yield* unpublishMdns - if (close) yield* forceCloseOnce - yield* closeScopeOnce - }) - }) + const run = (effect: Effect.Effect) => Effect.runPromise(effect) + let unpublishPromise: Promise | undefined + let closeWebsocketsPromise: Promise | undefined + let forceClosePromise: Promise | undefined + let closeServerPromise: Promise | undefined + let closeScopePromise: Promise | undefined + let forceRequested = false + + return (close?: boolean) => { + if (close) forceRequested = true + unpublishPromise ??= run(unpublishMdns) + closeWebsocketsPromise ??= unpublishPromise.then(() => run(state.websockets.closeAll)) + if (close) forceClosePromise ??= closeWebsocketsPromise.then(() => run(forceClose(state))) + closeServerPromise ??= closeWebsocketsPromise.then(() => { + if (forceRequested) { + forceClosePromise ??= run(forceClose(state)) + return forceClosePromise.then(() => run(state.http.close)) + } + return run(state.http.close) + }) + closeScopePromise ??= closeServerPromise.then(() => + run( + Scope.close(state.scope, Exit.void).pipe( + Effect.timeoutOption("2 seconds"), + Effect.tap((result) => + Option.isNone(result) + ? Effect.sync(() => log.warn("listener scope close exceeded shutdown budget", { budgetMs: 2_000 })) + : Effect.void, + ), + Effect.asVoid, + Effect.catchCause((cause) => (Cause.hasInterruptsOnly(cause) ? Effect.void : Effect.failCause(cause))), + ), + ), + ) + + return Promise.all([closeScopePromise, close ? forceClosePromise : undefined]).then(() => undefined) + } } function forceClose(state: ListenerState) { - return Effect.all([state.http.closeAll, state.websockets.closeAll], { concurrency: "unbounded", discard: true }) + return state.http.closeAll } function serverLayer(opts: { port: number; hostname: string }) { const server = createServer() - const serverRef = { closeStarted: false, forceStop: false } + const upgradedSockets = new Set() + const serverRef = { forceStop: false } + let closePromise: Promise | undefined const close = server.close.bind(server) - // Keep shutdown owned by NodeHttpServer, but honor listener.stop(true) by - // force-closing active HTTP sockets when its finalizer calls server.close(). + // Node's closeAllConnections() deliberately excludes upgraded sockets. + // Keep explicit ownership so forced shutdown cannot wait on a peer's + // WebSocket close-handshake timeout. + const destroyConnections = () => { + server.closeAllConnections() + upgradedSockets.forEach((socket) => socket.destroy()) + upgradedSockets.clear() + } + server.on("upgrade", (_request, socket) => { + upgradedSockets.add(socket) + socket.once("close", () => upgradedSockets.delete(socket)) + }) + // Keep shutdown owned by NodeHttpServer. The wrapper covers a graceful stop + // that entered its finalizer immediately before a concurrent forced stop. // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Node's overloads don't preserve a monkey-patched method assignment. server.close = ((callback?: Parameters[0]) => { - serverRef.closeStarted = true const result = close(callback) - if (serverRef.forceStop) server.closeAllConnections() + if (serverRef.forceStop) destroyConnections() return result }) as typeof server.close @@ -222,7 +253,20 @@ function serverLayer(opts: { port: number; hostname: string }) { ListenerServerService.of({ closeAll: Effect.sync(() => { serverRef.forceStop = true - if (serverRef.closeStarted) server.closeAllConnections() + destroyConnections() + }), + close: Effect.promise(() => { + closePromise ??= new Promise((resolve, reject) => { + if (!server.listening) { + resolve() + return + } + server.close((error) => { + if (error) reject(error) + else resolve() + }) + }) + return closePromise }), }), ), diff --git a/packages/deepagent-code/src/session/compaction-sql.ts b/packages/deepagent-code/src/session/compaction-sql.ts index 21dc93f2..a98d5a24 100644 --- a/packages/deepagent-code/src/session/compaction-sql.ts +++ b/packages/deepagent-code/src/session/compaction-sql.ts @@ -1,7 +1,14 @@ // BUG-005: drizzle-orm type bindings for compaction_run and compaction_summary_attempt. -import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core" +import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core" export type CompactionRunState = "requested" | "summarizing" | "committed" | "failed" | "indeterminate" +export type CompactionContinuationState = + | "pending" + | "admitted" + | "dispatching" + | "settled" + | "failed" + | "indeterminate" export type CompactionRunTrigger = "turn_start" | "provider_overflow" | "manual" export type SummaryAttemptState = | "prepared" @@ -26,6 +33,25 @@ export const CompactionRunTable = sqliteTable("compaction_run", { terminal_failure_kind: text(), created_at: integer().notNull(), committed_at: integer(), + summary_text: text(), + recent_context: text(), + completion_reason: text().$type<"auto" | "manual">(), + continuation_published_at: integer(), + terminal_events_published_at: integer(), + source_window_id: text(), + source_effective_history_hash: text(), + source_message_count: integer(), + source_projection_version: integer(), + context_ledger_required: integer({ mode: "boolean" }).notNull().default(false), + ledger_mirrored_at: integer(), + bridge_carried_at: integer(), + continuation_wakeup_at: integer(), + continuation_state: text().$type(), + continuation_receipt_id: text(), + continuation_admitted_at: integer(), + continuation_dispatching_at: integer(), + continuation_terminal_at: integer(), + continuation_error_code: text(), }) export const CompactionSummaryAttemptTable = sqliteTable("compaction_summary_attempt", { @@ -47,3 +73,23 @@ export const CompactionSummaryAttemptTable = sqliteTable("compaction_summary_att dispatched_at: integer(), completed_at: integer(), }) + +export const CompactionArtifactTable = sqliteTable( + "compaction_artifact", + { + artifact_id: text().primaryKey(), + run_id: text().notNull(), + session_id: text().notNull(), + message_id: text().notNull(), + part_id: text(), + kind: text().$type<"marker" | "summary_attempt" | "replay" | "continue" | "world_state">().notNull(), + state: text().$type<"pending" | "committed" | "orphaned">().notNull(), + created_at: integer().notNull(), + committed_at: integer(), + published_at: integer(), + }, + (table) => [ + index("compaction_artifact_session_message_idx").on(table.session_id, table.message_id), + index("compaction_artifact_run_state_idx").on(table.run_id, table.state), + ], +) diff --git a/packages/deepagent-code/src/session/compaction.ts b/packages/deepagent-code/src/session/compaction.ts index 64f2c2bf..9e7780aa 100644 --- a/packages/deepagent-code/src/session/compaction.ts +++ b/packages/deepagent-code/src/session/compaction.ts @@ -12,10 +12,15 @@ import { Plugin } from "@/plugin" import { Config } from "@/config/config" import { NotFoundError } from "@/storage/storage" import { Database } from "@deepagent-code/core/database/database" -import { MessageTable, PartTable } from "@deepagent-code/core/session/sql" +import { MessageTable, PartTable, SessionTable, SessionWorldStateBaselineTable } from "@deepagent-code/core/session/sql" import { PromptEpoch } from "./prompt-epoch" -import { CompactionRunTable, CompactionSummaryAttemptTable, type SummaryAttemptState } from "./compaction-sql" -import { eq, and, inArray } from "drizzle-orm" +import { + CompactionArtifactTable, + CompactionRunTable, + CompactionSummaryAttemptTable, + type SummaryAttemptState, +} from "./compaction-sql" +import { eq, and, inArray, isNull } from "drizzle-orm" import { Cause, Effect, Exit, Layer, Context, Option } from "effect" import * as DateTime from "effect/DateTime" @@ -30,15 +35,24 @@ import { ProviderV2 } from "@deepagent-code/core/provider" import { ModelV2 } from "@deepagent-code/core/model" import { EventV2 } from "@deepagent-code/core/event" import { buildPrompt } from "@deepagent-code/core/session/compaction" -import { updateLedgerFromSummary, carryOverToBridge } from "./context-ledger" +import { + updateLedgerFromSummaryRequired, + carryOverToBridgeRequired, + collectSessionWorldStateBaseline, + type SessionWorldStateBaseline, +} from "./context-ledger" import { Hash } from "@deepagent-code/core/util/hash" import { LLM } from "./llm" +import { HistoryAuthority } from "./history-authority" +import { Identifier } from "@/id/id" +import { Project } from "@deepagent-code/core/project" const log = Log.create({ service: "session.compaction" }) export const Event = { Compacted: EventV2.define({ type: "session.compacted", + sync: { aggregate: "sessionID", version: 1 }, schema: { sessionID: SessionID, }, @@ -122,31 +136,6 @@ function turns(messages: SessionV1.WithParts[]) { return result } -function splitTurn(input: { - messages: SessionV1.WithParts[] - turn: Turn - model: Provider.Model - budget: number - estimate: (input: { messages: SessionV1.WithParts[]; model: Provider.Model }) => Effect.Effect -}) { - return Effect.gen(function* () { - if (input.budget <= 0) return undefined - if (input.turn.end - input.turn.start <= 1) return undefined - for (let start = input.turn.start + 1; start < input.turn.end; start++) { - const size = yield* input.estimate({ - messages: input.messages.slice(start, input.turn.end), - model: input.model, - }) - if (size > input.budget) continue - return { - start, - id: input.messages[start]!.info.id, - } satisfies Tail - } - return undefined - }) -} - export interface Interface { readonly isOverflow: (input: { tokens: SessionV1.Assistant["tokens"] @@ -166,9 +155,73 @@ export interface Interface { model: { providerID: ProviderV2.ID; modelID: ModelV2.ID } auto: boolean overflow?: boolean + activityID?: string }) => Effect.Effect + readonly recover: (sessionID: SessionID) => Effect.Effect + readonly recoverableContinuations: (projectID: Project.ID) => Effect.Effect< + readonly { + runID: string + sessionID: SessionID + messageID: MessageID + }[] + > + readonly hasPending: (sessionID: SessionID) => Effect.Effect } +export const validateReplacementTargetInTransaction = Effect.fn( + "SessionCompaction.validateReplacementTargetInTransaction", +)(function* (input: { + tx: Database.Interface["db"] + sessionID: SessionID + replacementMessageIDs: readonly MessageID[] + checkpointUserID: MessageID + checkpointAssistantID: MessageID + markerMessageID: MessageID + markerPartID: PartID + retainedTailStartID?: MessageID + contextTokens: number + checkpointHash: string + effectiveHistoryHash: string +}) { + const replacement = yield* MessageV2.messagesInTransaction(input.tx, input.sessionID, input.replacementMessageIDs) + if (!replacement) return false + const checkpointUser = replacement.find((message) => message.info.id === input.checkpointUserID) + const checkpointAssistant = replacement.find((message) => message.info.id === input.checkpointAssistantID) + if ( + checkpointUser?.info.role !== "user" || + checkpointAssistant?.info.role !== "assistant" || + checkpointAssistant.info.parentID !== checkpointUser.info.id || + !checkpointAssistant.info.summary || + !checkpointAssistant.info.finish || + checkpointAssistant.info.error + ) + return false + const target = replacement.map((message) => { + if (message.info.id !== input.markerMessageID) return message + const markerPart = message.parts.find( + (part): part is SessionV1.CompactionPart => part.id === input.markerPartID && part.type === "compaction", + ) + if (!markerPart) return message + return { + info: message.info, + parts: message.parts.map((part) => + part.id === markerPart.id + ? { + ...markerPart, + tail_start_id: input.retainedTailStartID, + context_tokens: input.contextTokens, + } + : part, + ), + } + }) + return ( + target.some((message) => message.parts.some((part) => part.id === input.markerPartID)) && + HistoryAuthority.hash(target) === input.effectiveHistoryHash && + input.checkpointHash === input.effectiveHistoryHash + ) +}) + export class Service extends Context.Service()("@deepagent-code/SessionCompaction") {} export const use = serviceUse(Service) @@ -186,8 +239,218 @@ export const layer = Layer.effect( const flags = yield* RuntimeFlags.Service const { db } = yield* Database.Service const promptEpoch = yield* PromptEpoch.Service + const activeCompactions = new Set() + + const registerArtifact = (input: { + runID: string + sessionID: SessionID + messageID: MessageID + partID?: PartID + kind: typeof CompactionArtifactTable.$inferInsert.kind + }) => + db + .insert(CompactionArtifactTable) + .values({ + artifact_id: Hash.sha256( + `compaction-artifact:v1:${input.runID}:${input.kind}:${input.messageID}:${input.partID ?? "message"}`, + ), + run_id: input.runID, + session_id: input.sessionID, + message_id: input.messageID, + part_id: input.partID ?? null, + kind: input.kind, + state: "pending", + created_at: Date.now(), + committed_at: null, + published_at: null, + }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + + const publishCommittedRun = Effect.fn("SessionCompaction.publishCommittedRun")(function* (runID: string) { + const run = yield* db + .select() + .from(CompactionRunTable) + .where(eq(CompactionRunTable.run_id, runID)) + .get() + .pipe(Effect.orDie) + if (!run || run.state !== "committed") return + + const artifacts = yield* db + .select() + .from(CompactionArtifactTable) + // Older builds may have committed replay artifacts. They remain publishable for recovery, + // but current compaction runs only create synthetic continuation artifacts. + .where( + and( + eq(CompactionArtifactTable.run_id, runID), + eq(CompactionArtifactTable.state, "committed"), + inArray(CompactionArtifactTable.kind, ["marker", "replay", "continue"] as const), + ), + ) + .all() + .pipe(Effect.orDie) + for (const artifact of artifacts) { + if (artifact.published_at) continue + const message = yield* MessageV2.get({ + sessionID: SessionID.make(run.session_id), + messageID: MessageID.make(artifact.message_id), + }).pipe(Effect.provideService(Database.Service, { db }), Effect.orDie) + const parts = message.parts.filter((part) => !artifact.part_id || artifact.part_id === part.id) + if (artifact.kind === "replay" || artifact.kind === "continue") { + const messageEventID = EventV2.ID.make( + `evt_${Hash.sha256(`compaction-artifact-event:v1:${runID}:message:${message.info.id}`).slice(0, 26)}`, + ) + yield* events.publish( + SessionV1.Event.MessageUpdated, + { sessionID: message.info.sessionID, info: message.info }, + { + id: messageEventID, + idempotent: true, + ...(parts.length === 0 + ? { + commit: () => + db + .update(CompactionArtifactTable) + .set({ published_at: Date.now() }) + .where( + and( + eq(CompactionArtifactTable.artifact_id, artifact.artifact_id), + isNull(CompactionArtifactTable.published_at), + ), + ) + .run() + .pipe(Effect.orDie, Effect.asVoid), + } + : {}), + }, + ) + } + for (const [index, part] of parts.entries()) { + const partEventID = EventV2.ID.make( + `evt_${Hash.sha256(`compaction-artifact-event:v1:${runID}:part:${part.id}`).slice(0, 26)}`, + ) + yield* events.publish( + SessionV1.Event.PartUpdated, + { + sessionID: part.sessionID, + part, + time: part.type === "text" ? (part.time?.start ?? message.info.time.created) : message.info.time.created, + }, + { + id: partEventID, + idempotent: true, + ...(index === parts.length - 1 + ? { + commit: () => + db + .update(CompactionArtifactTable) + .set({ published_at: Date.now() }) + .where( + and( + eq(CompactionArtifactTable.artifact_id, artifact.artifact_id), + isNull(CompactionArtifactTable.published_at), + ), + ) + .run() + .pipe(Effect.orDie, Effect.asVoid), + } + : {}), + }, + ) + } + if (artifact.kind === "marker" && parts.length === 0) + return yield* Effect.die(new Error(`compaction marker artifact is incomplete: ${artifact.artifact_id}`)) + } + yield* db + .update(CompactionRunTable) + .set({ continuation_published_at: Date.now() }) + .where(eq(CompactionRunTable.run_id, runID)) + .run() + .pipe(Effect.orDie) + + if (run.context_ledger_required && run.summary_text) { + if (!run.ledger_mirrored_at) { + yield* updateLedgerFromSummaryRequired({ + sessionID: SessionID.make(run.session_id), + summary: run.summary_text, + operationID: run.run_id, + }) + yield* db + .update(CompactionRunTable) + .set({ ledger_mirrored_at: Date.now() }) + .where(and(eq(CompactionRunTable.run_id, runID), isNull(CompactionRunTable.ledger_mirrored_at))) + .run() + .pipe(Effect.orDie) + } + if (!run.bridge_carried_at) { + const owner = yield* db + .select({ directory: SessionTable.directory }) + .from(SessionTable) + .where(eq(SessionTable.id, SessionID.make(run.session_id))) + .get() + .pipe(Effect.orDie) + if (!owner) return yield* Effect.die(new Error(`compaction session is missing: ${run.session_id}`)) + yield* carryOverToBridgeRequired({ + sessionID: SessionID.make(run.session_id), + workspacePath: owner.directory, + }) + yield* db + .update(CompactionRunTable) + .set({ bridge_carried_at: Date.now() }) + .where(and(eq(CompactionRunTable.run_id, runID), isNull(CompactionRunTable.bridge_carried_at))) + .run() + .pipe(Effect.orDie) + } + } + + if (!run.terminal_events_published_at && run.summary_text && run.marker_message_id && run.completion_reason) { + if (flags.experimentalEventSystem) { + const endedID = EventV2.ID.make(`evt_${Hash.sha256(`compaction-ended:v1:${runID}`).slice(0, 26)}`) + yield* events.publish( + SessionEvent.Compaction.Ended, + { + sessionID: SessionID.make(run.session_id), + messageID: SessionMessage.ID.make(run.marker_message_id), + timestamp: DateTime.makeUnsafe(run.committed_at ?? Date.now()), + reason: run.completion_reason, + text: run.summary_text, + recent: run.recent_context ?? "", + }, + { id: endedID, idempotent: true }, + ) + } + const compactedID = EventV2.ID.make(`evt_${Hash.sha256(`compaction-completed:v1:${runID}`).slice(0, 26)}`) + yield* events.publish( + Event.Compacted, + { sessionID: SessionID.make(run.session_id) }, + { + id: compactedID, + idempotent: true, + commit: () => + db + .update(CompactionRunTable) + .set({ terminal_events_published_at: Date.now() }) + .where( + and(eq(CompactionRunTable.run_id, runID), isNull(CompactionRunTable.terminal_events_published_at)), + ) + .run() + .pipe(Effect.orDie, Effect.asVoid), + }, + ) + } + }) const recover = Effect.fn("SessionCompaction.recover")(function* (sessionID: SessionID) { + const committed = yield* db + .select({ run_id: CompactionRunTable.run_id }) + .from(CompactionRunTable) + .where(and(eq(CompactionRunTable.session_id, sessionID), eq(CompactionRunTable.state, "committed"))) + .all() + .pipe(Effect.orDie) + yield* Effect.forEach(committed, (run) => publishCommittedRun(run.run_id), { discard: true }) + if (activeCompactions.has(sessionID)) return const requested = yield* db .select({ run_id: CompactionRunTable.run_id, @@ -229,36 +492,61 @@ export const layer = Layer.effect( .pipe(Effect.orDie) : undefined if (markerPart?.data.type === "compaction") return - yield* db - .update(CompactionRunTable) - .set({ state: "failed", terminal_failure_kind: "marker_write_incomplete" }) - .where(and(eq(CompactionRunTable.run_id, run.run_id), eq(CompactionRunTable.state, "requested"))) - .run() - .pipe(Effect.orDie) + yield* failRun(run.run_id, "marker_write_incomplete") }), ) yield* db - .update(CompactionSummaryAttemptTable) - .set({ state: "indeterminate_after_crash", failure_kind: "process_restart", completed_at: Date.now() }) - .where( - and( - inArray( - CompactionSummaryAttemptTable.run_id, - db + .transaction( + (tx) => + Effect.gen(function* () { + const sessionRuns = tx .select({ run_id: CompactionRunTable.run_id }) .from(CompactionRunTable) - .where(eq(CompactionRunTable.session_id, sessionID)), - ), - inArray(CompactionSummaryAttemptTable.state, ["dispatching", "streaming"] as const), - ), + .where(eq(CompactionRunTable.session_id, sessionID)) + yield* tx + .update(CompactionSummaryAttemptTable) + .set({ + state: "indeterminate_after_crash", + failure_kind: "process_restart", + completed_at: Date.now(), + }) + .where( + and( + inArray(CompactionSummaryAttemptTable.run_id, sessionRuns), + inArray(CompactionSummaryAttemptTable.state, ["dispatching", "streaming"] as const), + ), + ) + .run() + yield* tx + .update(CompactionRunTable) + .set({ state: "indeterminate", terminal_failure_kind: "process_restart" }) + .where(and(eq(CompactionRunTable.session_id, sessionID), eq(CompactionRunTable.state, "summarizing"))) + .run() + yield* tx + .update(CompactionArtifactTable) + .set({ state: "orphaned" }) + .where( + and( + eq(CompactionArtifactTable.session_id, sessionID), + eq(CompactionArtifactTable.state, "pending"), + inArray( + CompactionArtifactTable.run_id, + tx + .select({ run_id: CompactionRunTable.run_id }) + .from(CompactionRunTable) + .where( + and( + eq(CompactionRunTable.session_id, sessionID), + inArray(CompactionRunTable.state, ["failed", "indeterminate"] as const), + ), + ), + ), + ), + ) + .run() + }), + { behavior: "immediate" }, ) - .run() - .pipe(Effect.orDie) - yield* db - .update(CompactionRunTable) - .set({ state: "indeterminate", terminal_failure_kind: "process_restart" }) - .where(and(eq(CompactionRunTable.session_id, sessionID), eq(CompactionRunTable.state, "summarizing"))) - .run() .pipe(Effect.orDie) }) @@ -268,6 +556,10 @@ export const layer = Layer.effect( markerPartID?: PartID fromEpoch: number trigger: "turn_start" | "provider_overflow" | "manual" + sourceWindowID: string + sourceEffectiveHistoryHash: string + sourceMessageCount: number + sourceProjectionVersion: number }) { const existing = yield* db .select() @@ -275,22 +567,37 @@ export const layer = Layer.effect( .where( and( eq(CompactionRunTable.session_id, input.sessionID), - inArray(CompactionRunTable.state, ["requested", "summarizing", "indeterminate"] as const), + inArray(CompactionRunTable.state, ["requested", "summarizing"] as const), ), ) .get() .pipe(Effect.orDie) if (existing) { if (existing.marker_message_id !== input.markerMessageID) return undefined + if ( + existing.from_prompt_epoch !== input.fromEpoch || + existing.source_window_id !== input.sourceWindowID || + existing.source_effective_history_hash !== input.sourceEffectiveHistoryHash || + existing.source_message_count !== input.sourceMessageCount || + existing.source_projection_version !== input.sourceProjectionVersion + ) + return undefined return existing } const row = { - run_id: Hash.sha256(`compaction-run:${input.sessionID}:${input.markerMessageID}`), + run_id: Hash.sha256( + `compaction-run:v2:${input.sessionID}:${input.markerMessageID}:${Identifier.ascending("job")}`, + ), session_id: input.sessionID, from_prompt_epoch: input.fromEpoch, trigger: input.trigger, marker_message_id: input.markerMessageID, marker_part_id: input.markerPartID, + source_window_id: input.sourceWindowID, + source_effective_history_hash: input.sourceEffectiveHistoryHash, + source_message_count: input.sourceMessageCount, + source_projection_version: input.sourceProjectionVersion, + context_ledger_required: flags.experimentalContextLedger, state: "requested" as const, created_at: Date.now(), } @@ -405,31 +712,87 @@ export const layer = Layer.effect( const failRun = (runID: string, kind: string) => db - .update(CompactionRunTable) - .set({ state: "failed", terminal_failure_kind: kind }) - .where( - and( - eq(CompactionRunTable.run_id, runID), - inArray(CompactionRunTable.state, ["requested", "summarizing"] as const), - ), + .transaction( + (tx) => + Effect.gen(function* () { + yield* tx + .update(CompactionRunTable) + .set({ state: "failed", terminal_failure_kind: kind }) + .where( + and( + eq(CompactionRunTable.run_id, runID), + inArray(CompactionRunTable.state, ["requested", "summarizing"] as const), + ), + ) + .run() + yield* tx + .update(CompactionArtifactTable) + .set({ state: "orphaned" }) + .where(and(eq(CompactionArtifactTable.run_id, runID), eq(CompactionArtifactTable.state, "pending"))) + .run() + }), + { behavior: "immediate" }, ) - .run() .pipe(Effect.orDie) const commitRun = Effect.fn("SessionCompaction.commitRun")(function* (input: { runID: string sessionID: SessionID fromEpoch: number + markerMessageID: MessageID + markerPartID: PartID checkpointUserID: MessageID checkpointAssistantID: MessageID retainedTailStartID?: MessageID sourceEndMessageID?: MessageID checkpointHash: string + baseMessageCount: number + effectiveHistoryHash: string + replacementMessageIDs: readonly MessageID[] + contextTokens: number + summary: string + recent: string + reason: "auto" | "manual" + worldStateBaseline: SessionWorldStateBaseline + continuation?: { + readonly message: SessionV1.WithParts + readonly kind: "continue" + } }) { return yield* db .transaction( (tx) => Effect.gen(function* () { + const run = yield* tx + .select() + .from(CompactionRunTable) + .where(eq(CompactionRunTable.run_id, input.runID)) + .get() + if (!run || run.state !== "summarizing") return false + const currentSource = yield* MessageV2.promptHistoryProjectionInTransaction( + tx as unknown as Database.Interface["db"], + input.sessionID, + input.markerMessageID, + ) + if ( + !currentSource || + currentSource.epoch !== run.from_prompt_epoch || + currentSource.window.windowID !== run.source_window_id || + currentSource.effectiveHistoryHash !== run.source_effective_history_hash || + currentSource.messages.length !== run.source_message_count || + currentSource.projectionVersion !== run.source_projection_version + ) + return false + // The summary/checkpoint is assembled outside this transaction because it may require + // provider and filesystem work. Re-hydrate and hash the durable target under the commit + // lock so a concurrent Part mutation cannot legalize a stale PromptEpoch. + if ( + !(yield* validateReplacementTargetInTransaction({ + tx: tx as unknown as Database.Interface["db"], + ...input, + })) + ) + return false const settled = yield* tx .select({ id: CompactionSummaryAttemptTable.summary_attempt_id }) .from(CompactionSummaryAttemptTable) @@ -441,8 +804,58 @@ export const layer = Layer.effect( ) .get() if (!settled) return false - const epoch = yield* PromptEpoch.activateInTransaction(tx, input) + const epoch = yield* PromptEpoch.activateInTransaction(tx, { + ...input, + worldStateBaselineHash: input.worldStateBaseline.hash, + }) if (!epoch) return false + yield* tx + .insert(SessionWorldStateBaselineTable) + .values( + input.worldStateBaseline.sections.map((section) => ({ + session_id: input.sessionID, + prompt_epoch: epoch.epoch, + section_id: section.sectionID, + snapshot: section.snapshot, + fragment: section.fragment, + fragment_hash: section.fragmentHash, + provenance: "native" as const, + created_at: epoch.created_at, + })), + ) + .run() + const marker = yield* tx + .select({ data: PartTable.data }) + .from(PartTable) + .where( + and( + eq(PartTable.id, input.markerPartID), + eq(PartTable.message_id, input.markerMessageID), + eq(PartTable.session_id, input.sessionID), + ), + ) + .get() + if (!marker || marker.data.type !== "compaction") { + return yield* Effect.die(new Error(`compaction marker missing during commit: ${input.runID}`)) + } + yield* tx + .update(PartTable) + .set({ + data: { + ...marker.data, + tail_start_id: input.retainedTailStartID, + context_tokens: input.contextTokens, + } as typeof PartTable.$inferInsert.data, + provenance: { + source: "compaction_marker", + owner_session_id: input.sessionID, + owner_prompt_epoch: epoch.epoch, + owner_run_id: input.runID, + durable: true, + }, + }) + .where(eq(PartTable.id, input.markerPartID)) + .run() const committed = yield* tx .update(CompactionRunTable) .set({ @@ -450,12 +863,80 @@ export const layer = Layer.effect( committed_summary_message_id: input.checkpointAssistantID, checkpoint_hash: input.checkpointHash, target_prompt_epoch: epoch.epoch, + summary_text: input.summary, + recent_context: input.recent, + completion_reason: input.reason, committed_at: Date.now(), + continuation_state: input.continuation ? "pending" : null, }) .where(and(eq(CompactionRunTable.run_id, input.runID), eq(CompactionRunTable.state, "summarizing"))) .returning({ run_id: CompactionRunTable.run_id }) .get() if (!committed) return yield* Effect.die(new Error(`compaction commit CAS lost: ${input.runID}`)) + const continuation = input.continuation + if (continuation) { + const committedAt = Date.now() + yield* tx + .insert(MessageTable) + .values({ + id: continuation.message.info.id, + session_id: continuation.message.info.sessionID, + time_created: continuation.message.info.time.created, + time_updated: continuation.message.info.time.created, + data: Object.fromEntries( + Object.entries(continuation.message.info).filter(([key]) => key !== "id" && key !== "sessionID"), + ) as typeof MessageTable.$inferInsert.data, + }) + .run() + yield* tx + .insert(PartTable) + .values( + continuation.message.parts.map((part) => ({ + id: part.id, + message_id: continuation.message.info.id, + session_id: continuation.message.info.sessionID, + provenance: { + source: "compaction_continue" as const, + owner_session_id: input.sessionID, + owner_prompt_epoch: epoch.epoch, + owner_run_id: input.runID, + durable: true as const, + }, + time_created: continuation.message.info.time.created, + time_updated: continuation.message.info.time.created, + data: Object.fromEntries( + Object.entries(part).filter( + ([key]) => key !== "id" && key !== "messageID" && key !== "sessionID", + ), + ) as typeof PartTable.$inferInsert.data, + })), + ) + .run() + yield* tx + .insert(CompactionArtifactTable) + .values({ + artifact_id: Hash.sha256( + `compaction-artifact:v1:${input.runID}:${continuation.kind}:${continuation.message.info.id}:message`, + ), + run_id: input.runID, + session_id: continuation.message.info.sessionID, + message_id: continuation.message.info.id, + part_id: null, + kind: continuation.kind, + state: "committed", + created_at: committedAt, + committed_at: committedAt, + published_at: null, + }) + .run() + } + yield* tx + .update(CompactionArtifactTable) + .set({ state: "committed", committed_at: Date.now() }) + .where( + and(eq(CompactionArtifactTable.run_id, input.runID), eq(CompactionArtifactTable.state, "pending")), + ) + .run() return true }), { behavior: "immediate" }, @@ -514,16 +995,7 @@ export const layer = Layer.effect( keep = { start: turn.start, id: turn.id } continue } - const remaining = budget - total - const split = yield* splitTurn({ - messages: input.messages, - turn, - model: input.model, - budget: remaining, - estimate, - }) - if (split) keep = split - else if (!keep) log.info("tail fallback", { budget, size, total }) + if (!keep) log.info("tail fallback", { budget, size, total }) break } @@ -582,7 +1054,7 @@ export const layer = Layer.effect( } }) - const processCompaction = Effect.fn("SessionCompaction.process")(function* (input: { + const processCompactionAttempt = Effect.fn("SessionCompaction.processAttempt")(function* (input: { parentID: MessageID messages: SessionV1.WithParts[] sessionID: SessionID @@ -593,10 +1065,22 @@ export const layer = Layer.effect( if (!parent || parent.info.role !== "user") { throw new Error(`Compaction parent must be a user message: ${input.parentID}`) } - const userMessage = parent.info const existingCompactionPart = parent.parts.find( (part): part is SessionV1.CompactionPart => part.type === "compaction", ) + const projection = yield* ( + existingCompactionPart + ? MessageV2.promptHistoryBeforeCompactionEffect({ + sessionID: input.sessionID, + markerMessageID: input.parentID, + }) + : MessageV2.promptHistoryProjectionEffect(input.sessionID) + ).pipe(Effect.provideService(Database.Service, { db }), Effect.orDie) + const activeEpoch = yield* promptEpoch.getActive(input.sessionID) + if (!activeEpoch || activeEpoch.authority_state !== "ready" || activeEpoch.epoch !== projection.epoch) { + return yield* Effect.die(new Error(`compaction history authority is unavailable for ${input.sessionID}`)) + } + const userMessage = parent.info const compactionPart = existingCompactionPart ?? ({ @@ -607,43 +1091,60 @@ export const layer = Layer.effect( auto: input.auto, overflow: input.overflow, } satisfies SessionV1.CompactionPart) - if (!existingCompactionPart) yield* session.updatePart(compactionPart) - yield* recover(input.sessionID) - const activeEpoch = yield* promptEpoch.bootstrap(input.sessionID) + const authorityInput = existingCompactionPart + ? input.messages.flatMap((message) => { + if (message.info.id !== input.parentID) return [message] + const parts = message.parts.filter((part) => part.type !== "compaction") + return parts.length === 0 ? [] : [{ info: message.info, parts }] + }) + : input.messages + if (HistoryAuthority.hash(authorityInput) !== projection.effectiveHistoryHash) { + return yield* Effect.die(new Error(`compaction input does not match active history for ${input.sessionID}`)) + } const run = yield* ensureRun({ sessionID: input.sessionID, markerMessageID: input.parentID, markerPartID: compactionPart.id, fromEpoch: activeEpoch.epoch, trigger: input.overflow ? "provider_overflow" : input.auto ? "turn_start" : "manual", + sourceWindowID: projection.window.windowID, + sourceEffectiveHistoryHash: projection.effectiveHistoryHash, + sourceMessageCount: projection.messages.length, + sourceProjectionVersion: projection.projectionVersion, }) - if (!run || run.state === "indeterminate") return "stop" - - let messages = input.messages - let replay: - | { - info: SessionV1.User - parts: SessionV1.Part[] - } - | undefined - if (input.overflow) { - const idx = input.messages.findIndex((m) => m.info.id === input.parentID) - for (let i = idx - 1; i >= 0; i--) { - const msg = input.messages[i] - if (msg.info.role === "user" && !msg.parts.some((p) => p.type === "compaction")) { - replay = { info: msg.info, parts: msg.parts } - messages = input.messages.slice(0, i) - break - } - } - const hasContent = - replay && messages.some((m) => m.info.role === "user" && !m.parts.some((p) => p.type === "compaction")) - if (!hasContent) { - replay = undefined - messages = input.messages - } + if (!run) return "stop" + if (existingCompactionPart) { + yield* registerArtifact({ + runID: run.run_id, + sessionID: input.sessionID, + messageID: input.parentID, + partID: compactionPart.id, + kind: "marker", + }) + } + if (!existingCompactionPart) { + yield* events.publish( + SessionV1.Event.PartUpdated, + { sessionID: compactionPart.sessionID, part: compactionPart, time: Date.now() }, + { + commit: () => + registerArtifact({ + runID: run.run_id, + sessionID: input.sessionID, + messageID: input.parentID, + partID: compactionPart.id, + kind: "marker", + }), + }, + ) } + // Compaction is a history projection boundary, not a second user submission. + // Keep the original history for summarization and use the synthetic continuation below when + // the provider overflow was caused by media. This avoids durable `compaction_replay` user + // messages, which are indistinguishable from a real repeated prompt in the UI and history. + const messages = input.messages + const agent = yield* agents.get("compaction") const model = agent.model ? yield* provider.getModel(agent.model.providerID, agent.model.modelID).pipe(Effect.orDie) @@ -715,7 +1216,19 @@ export const layer = Layer.effect( created: Date.now(), }, } - yield* session.updateMessage(msg) + yield* events.publish( + SessionV1.Event.MessageUpdated, + { sessionID: input.sessionID, info: msg }, + { + commit: () => + registerArtifact({ + runID: run.run_id, + sessionID: input.sessionID, + messageID: msg.id, + kind: "summary_attempt", + }), + }, + ) // BUG-006 §5.1: establish the explicit summary request contract. // toolChoice:"none" tells the adapter the model must produce text only. @@ -778,15 +1291,25 @@ export const layer = Layer.effect( return "stop" } const retryMsg: SessionV1.Assistant = { ...msg, id: MessageID.ascending(), error: undefined, finish: undefined } - yield* session.updateMessage(retryMsg) + yield* events.publish( + SessionV1.Event.MessageUpdated, + { sessionID: input.sessionID, info: retryMsg }, + { + commit: () => + registerArtifact({ + runID: run.run_id, + sessionID: input.sessionID, + messageID: retryMsg.id, + kind: "summary_attempt", + }), + }, + ) currentProcessor = yield* processors.create({ assistantMessage: retryMsg, sessionID: input.sessionID, model }) } if (result === "compact") { currentProcessor.message.error = new SessionV1.ContextOverflowError({ - message: replay - ? "Conversation history too large to compact - exceeds model context limit" - : "Session too large to compact - context exceeds model limit even after stripping media", + message: "Session too large to compact - context exceeds model limit even after stripping media", }).toObject() currentProcessor.message.finish = "error" yield* session.updateMessage(currentProcessor.message) @@ -794,158 +1317,200 @@ export const layer = Layer.effect( return "stop" } - if (compactionPart && selected.tail_start_id && compactionPart.tail_start_id !== selected.tail_start_id) { - yield* session.updatePart({ - ...compactionPart, - tail_start_id: selected.tail_start_id, - }) - } - - if (result === "continue" && input.auto) { - if (replay) { - const original = replay.info - const replayMsg = yield* session.updateMessage({ - id: MessageID.ascending(), + const continuation = yield* Effect.gen(function* () { + if (result !== "continue" || !input.auto) return + const info = yield* provider.getProvider(userMessage.model.providerID) + if ( + (yield* plugin.trigger( + "experimental.compaction.autocontinue", + { + sessionID: input.sessionID, + agent: userMessage.agent, + model: yield* provider + .getModel(userMessage.model.providerID, userMessage.model.modelID) + .pipe(Effect.orDie), + provider: { + source: info.source, + info, + options: info.options, + }, + message: userMessage, + overflow: input.overflow === true, + }, + { enabled: true }, + )).enabled + ) { + const continueMsg: SessionV1.User = { + id: MessageID.make( + `${currentProcessor.message.id}_continue_${Hash.sha256(`compaction-continue:v2:${run.run_id}`).slice(0, 12)}`, + ), role: "user", sessionID: input.sessionID, time: { created: Date.now() }, - agent: original.agent, - model: original.model, - format: original.format, - tools: original.tools, - system: original.system, - }) - for (const part of replay.parts) { - if (part.type === "compaction") continue - const replayPart = - part.type === "file" && MessageV2.isMedia(part.mime) - ? { type: "text" as const, text: `[Attached ${part.mime}: ${part.filename ?? "file"}]` } - : part - yield* session.updatePart({ - ...replayPart, - id: PartID.ascending(), - messageID: replayMsg.id, - sessionID: input.sessionID, - }) + agent: userMessage.agent, + model: userMessage.model, + metadata: SessionProcessor.withPlanProtocolActivity( + { + deepagent: { + contextProvenance: { + source: "compaction_continue", + ownerSessionID: input.sessionID, + ownerPromptEpoch: activeEpoch.epoch + 1, + ownerRunID: run.run_id, + durable: true, + }, + }, + }, + SessionProcessor.planProtocolActivityID(userMessage.metadata) ?? userMessage.id, + ), } - } - - if (!replay) { - const info = yield* provider.getProvider(userMessage.model.providerID) - if ( - (yield* plugin.trigger( - "experimental.compaction.autocontinue", + const text = + (input.overflow + ? "The previous request exceeded the provider's size limit due to large media attachments. The conversation was compacted and media files were removed from context. If the user was asking about attached images or files, explain that the attachments were too large to process and suggest they try again with smaller or fewer files.\n\n" + : "") + + "Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed." + const continueMessage: SessionV1.WithParts = { + info: continueMsg, + parts: [ { + id: PartID.make(`prt_${Hash.sha256(`compaction-continue-part:v1:${run.run_id}`).slice(0, 26)}`), + messageID: continueMsg.id, sessionID: input.sessionID, - agent: userMessage.agent, - model: yield* provider - .getModel(userMessage.model.providerID, userMessage.model.modelID) - .pipe(Effect.orDie), - provider: { - source: info.source, - info, - options: info.options, + type: "text", + // Internal marker for auto-compaction followups so provider plugins + // can distinguish them from manual post-compaction user prompts. + // This is not a stable plugin contract and may change or disappear. + metadata: { compaction_continue: true }, + synthetic: true, + text, + time: { + start: Date.now(), + end: Date.now(), }, - message: userMessage, - overflow: input.overflow === true, - }, - { enabled: true }, - )).enabled - ) { - const continueMsg = yield* session.updateMessage({ - id: MessageID.ascending(), - role: "user", - sessionID: input.sessionID, - time: { created: Date.now() }, - agent: userMessage.agent, - model: userMessage.model, - }) - const text = - (input.overflow - ? "The previous request exceeded the provider's size limit due to large media attachments. The conversation was compacted and media files were removed from context. If the user was asking about attached images or files, explain that the attachments were too large to process and suggest they try again with smaller or fewer files.\n\n" - : "") + - "Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed." - yield* session.updatePart({ - id: PartID.ascending(), - messageID: continueMsg.id, - sessionID: input.sessionID, - type: "text", - // Internal marker for auto-compaction followups so provider plugins - // can distinguish them from manual post-compaction user prompts. - // This is not a stable plugin contract and may change or disappear. - metadata: { compaction_continue: true }, - synthetic: true, - text, - time: { - start: Date.now(), - end: Date.now(), }, - }) + ], } + return { message: continueMessage, kind: "continue" as const } } - } + }) if (currentProcessor.message.error) { yield* failRun(run.run_id, "summary_provider_error") return "stop" } if (result === "continue") { - const summary = summaryText( - (yield* session.messages({ sessionID: input.sessionID }).pipe(Effect.orDie)).find( - (item) => item.info.id === currentProcessor.message.id, - ) ?? { - info: msg, - parts: [], - }, - ) - if (flags.experimentalEventSystem) { - if (summary) - yield* events.publish(SessionEvent.Compaction.Ended, { - sessionID: input.sessionID, - messageID: SessionMessage.ID.make(input.parentID), - timestamp: DateTime.makeUnsafe(Date.now()), - reason: input.auto ? "auto" : "manual", - text: summary ?? "", - recent, - }) + const persisted = yield* session.messages({ sessionID: input.sessionID }).pipe(Effect.orDie) + const checkpointIndex = persisted.findIndex((item) => item.info.id === currentProcessor.message.id) + const checkpoint = persisted[checkpointIndex] ?? { + info: msg, + parts: [], + } + const summary = summaryText(checkpoint) + if (!summary) { + yield* failRun(run.run_id, "summary_text_missing") + return "stop" } - if (summary) { + const contextModel = yield* provider + .getModel(userMessage.model.providerID, userMessage.model.modelID) + .pipe(Effect.orDie) + const baselineExit = yield* Effect.exit(collectSessionWorldStateBaseline({ workspacePath: ctx.directory })) + if (Exit.isFailure(baselineExit)) { + yield* failRun(run.run_id, "world_state_baseline_failed") + return "stop" + } + const worldStateBaseline = baselineExit.value + const replacementParent = { + info: parent.info, + parts: [ + ...parent.parts.filter((part) => part.type !== "compaction"), + { ...compactionPart, tail_start_id: selected.tail_start_id }, + ], + } + const projected = yield* MessageV2.toModelMessagesEffect( + MessageV2.appendPromptWorldState({ + messages: [ + replacementParent, + checkpoint, + ...(tailIndex < 0 ? [] : history.slice(tailIndex)), + ...(checkpointIndex < 0 ? [] : persisted.slice(checkpointIndex + 1)), + ...(continuation ? [continuation.message] : []), + ], + sessionID: input.sessionID, + epoch: activeEpoch.epoch + 1, + baselineHash: worldStateBaseline.hash, + rendered: worldStateBaseline.rendered, + agent: userMessage.agent, + model: userMessage.model, + }), + contextModel, + ) + const estimated = Token.estimate(JSON.stringify(projected)) + const estimatedSummary = Token.estimate(summary) + const reportedSummary = currentProcessor.message.tokens.output + const contextTokens = Math.max( + 0, + estimated - estimatedSummary + (reportedSummary > 0 ? reportedSummary : estimatedSummary), + ) + const replacement = [ + { + info: replacementParent.info, + parts: [ + ...replacementParent.parts.filter((part) => part.type !== "compaction"), + { + ...compactionPart, + tail_start_id: selected.tail_start_id, + context_tokens: contextTokens, + }, + ], + }, + checkpoint, + ...(tailIndex < 0 ? [] : history.slice(tailIndex)), + ] + const effectiveHistoryHash = HistoryAuthority.hash(replacement) const committed = yield* commitRun({ runID: run.run_id, sessionID: input.sessionID, fromEpoch: run.from_prompt_epoch, + markerMessageID: input.parentID, + markerPartID: compactionPart.id, checkpointUserID: input.parentID, checkpointAssistantID: currentProcessor.message.id, - checkpointHash: Hash.sha256(`${run.run_id}:${msg.id}:${summary.slice(0, 256)}`), + checkpointHash: effectiveHistoryHash, + baseMessageCount: replacement.length, + effectiveHistoryHash, + replacementMessageIDs: replacement.map((message) => message.info.id), retainedTailStartID: selected.tail_start_id as MessageID | undefined, - sourceEndMessageID: selected.head.at(-1)?.info.id, + sourceEndMessageID: currentProcessor.message.id, + contextTokens, + summary, + recent, + reason: input.auto ? "auto" : "manual", + worldStateBaseline, + continuation, }) if (!committed) { yield* failRun(run.run_id, "compaction_commit_conflict") return "stop" } + yield* publishCommittedRun(run.run_id) } - - // V3.8 App-A Stage 1 (coexist, gated, default-safe): mirror the compaction summary into the - // structured Session Ledger. This does NOT change compaction behavior — it maintains the - // ledger as a structured-summary candidate for the Stage 2 Curator. updateLedgerFromSummary - // recovers the CAUSE internally and can never throw into this loop. - if (flags.experimentalContextLedger && summary) { - yield* updateLedgerFromSummary({ sessionID: input.sessionID, summary }) - // V3.8 App-A C3 (Stage 3): project the freshly-updated ledger into the project-level bridge - // so a future session in this workspace opens with the cross-session handoff. Same gate as - // the ledger mirror; carryOverToBridge recovers the CAUSE internally (never throws into this - // loop). ctx.directory is this session's workspace dir (the project-store key). - if (ctx.directory) { - yield* carryOverToBridge({ sessionID: input.sessionID, workspacePath: ctx.directory }) - } - } - yield* events.publish(Event.Compacted, { sessionID: input.sessionID }) } return result }) + const processCompaction = Effect.fn("SessionCompaction.process")(function* ( + input: Parameters[0], + ) { + if (activeCompactions.has(input.sessionID)) return "stop" as const + yield* recover(input.sessionID) + if (activeCompactions.has(input.sessionID)) return "stop" as const + activeCompactions.add(input.sessionID) + return yield* processCompactionAttempt(input).pipe( + Effect.ensuring(Effect.sync(() => activeCompactions.delete(input.sessionID))), + ) + }) + const create = Effect.fn("SessionCompaction.create")(function* (input: { sessionID: SessionID agent: string @@ -953,11 +1518,17 @@ export const layer = Layer.effect( auto: boolean overflow?: boolean trigger?: "turn_start" | "provider_overflow" | "manual" + activityID?: string }) { yield* recover(input.sessionID) - // BUG-005: ensure Epoch 0 exists before the first compaction so PromptEpoch is always - // the history authority even for sessions that were created before this migration. - const activeEpoch = yield* promptEpoch.bootstrap(input.sessionID) + const projection = yield* MessageV2.promptHistoryProjectionEffect(input.sessionID).pipe( + Effect.provideService(Database.Service, { db }), + Effect.orDie, + ) + const activeEpoch = yield* promptEpoch.getActive(input.sessionID) + if (!activeEpoch || activeEpoch.authority_state !== "ready" || activeEpoch.epoch !== projection.epoch) { + return yield* Effect.die(new Error(`compaction history authority is unavailable for ${input.sessionID}`)) + } const markerMessageID = MessageID.ascending() const markerPartID = PartID.ascending() @@ -967,19 +1538,37 @@ export const layer = Layer.effect( markerPartID, fromEpoch: activeEpoch.epoch, trigger: input.trigger ?? (input.overflow ? "provider_overflow" : input.auto ? "turn_start" : "manual"), + sourceWindowID: projection.window.windowID, + sourceEffectiveHistoryHash: projection.effectiveHistoryHash, + sourceMessageCount: projection.messages.length, + sourceProjectionVersion: projection.projectionVersion, }) - if (!run || run.state === "indeterminate") return + if (!run) return const marker = yield* Effect.exit( Effect.gen(function* () { - const msg = yield* session.updateMessage({ + const msg = { id: markerMessageID, role: "user", model: input.model, sessionID: input.sessionID, agent: input.agent, time: { created: Date.now() }, - }) + metadata: SessionProcessor.withPlanProtocolActivity(undefined, input.activityID ?? markerMessageID), + } satisfies SessionV1.User + yield* events.publish( + SessionV1.Event.MessageUpdated, + { sessionID: input.sessionID, info: msg }, + { + commit: () => + registerArtifact({ + runID: run.run_id, + sessionID: input.sessionID, + messageID: msg.id, + kind: "marker", + }), + }, + ) yield* session.updatePart({ id: markerPartID, messageID: msg.id, @@ -1006,11 +1595,59 @@ export const layer = Layer.effect( } }) + const hasPending = Effect.fn("SessionCompaction.hasPending")(function* (sessionID: SessionID) { + const row = yield* db + .select({ run_id: CompactionRunTable.run_id }) + .from(CompactionRunTable) + .where( + and( + eq(CompactionRunTable.session_id, sessionID), + inArray(CompactionRunTable.state, ["requested", "summarizing"] as const), + ), + ) + .get() + .pipe(Effect.orDie) + return row !== undefined + }) + + const recoverableContinuations = Effect.fn("SessionCompaction.recoverableContinuations")(function* ( + projectID: Project.ID, + ) { + const rows = yield* db + .select({ + runID: CompactionRunTable.run_id, + sessionID: CompactionRunTable.session_id, + messageID: CompactionArtifactTable.message_id, + }) + .from(CompactionRunTable) + .innerJoin(CompactionArtifactTable, eq(CompactionArtifactTable.run_id, CompactionRunTable.run_id)) + .innerJoin(SessionTable, eq(SessionTable.id, CompactionRunTable.session_id)) + .where( + and( + eq(SessionTable.project_id, projectID), + eq(CompactionRunTable.state, "committed"), + eq(CompactionRunTable.continuation_state, "pending"), + eq(CompactionArtifactTable.state, "committed"), + inArray(CompactionArtifactTable.kind, ["replay", "continue"] as const), + ), + ) + .all() + .pipe(Effect.orDie) + return rows.map((row) => ({ + runID: row.runID, + sessionID: SessionID.make(row.sessionID), + messageID: MessageID.make(row.messageID), + })) + }) + return Service.of({ isOverflow, prune, process: processCompaction, create, + recover, + recoverableContinuations, + hasPending, }) }), ) diff --git a/packages/deepagent-code/src/session/context-ledger.ts b/packages/deepagent-code/src/session/context-ledger.ts index a2b39795..a3a02941 100644 --- a/packages/deepagent-code/src/session/context-ledger.ts +++ b/packages/deepagent-code/src/session/context-ledger.ts @@ -1,9 +1,13 @@ import { Effect } from "effect" import path from "node:path" -import { mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs" +import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs" import { Global } from "@deepagent-code/core/global" import { AgentGateway } from "@deepagent-code/core/agent-gateway" -import { DeepAgentDocumentStore, DeepAgentContext, DeepAgentDurableKnowledgeStore } from "@deepagent-code/core/deepagent/index" +import { + DeepAgentDocumentStore, + DeepAgentContext, + DeepAgentDurableKnowledgeStore, +} from "@deepagent-code/core/deepagent/index" import type { SessionID } from "./schema" // V3.8 Appendix-A Stage 1 seam — the ONE bridge between the existing V1 compaction path and the new @@ -21,16 +25,17 @@ import type { SessionID } from "./schema" import os from "node:os" import { gitGroundTruth } from "../deepagent/git-groundtruth" -import type { WorldStateSlotKind } from "@deepagent-code/core/deepagent/context/world-state" +import { CanonicalJson } from "@deepagent-code/core/util/canonical-json" +import { Hash } from "@deepagent-code/core/util/hash" +import type { WorldStateSlot, WorldStateSlotKind } from "@deepagent-code/core/deepagent/context/world-state" const { SessionLedger, ProjectBridge, WorldState } = DeepAgentContext -const { DocumentStore } = DeepAgentDocumentStore +const { DocumentConflictError, DocumentStore } = DeepAgentDocumentStore // Run-scoped DocumentStore root for a session's context docs. Reuses the SAME storage base // (Global.Path.agent.data) all durable state uses; the ledger lives under state/context/ // so it is co-located with session-state and never collides with durable knowledge roots. -const contextStoreRoot = (sessionID: string): string => - path.join(Global.Path.agent.data, "state", "context", sessionID) +const contextStoreRoot = (sessionID: string): string => path.join(Global.Path.agent.data, "state", "context", sessionID) // Parse a compaction summary (the structured markdown the V1 compactor already emits — Goal / // Constraints / Progress / Key Decisions / Next Steps / ...) into ledger append entries. This is the @@ -71,15 +76,29 @@ export const parseSummaryToEntries = (summary: string): DeepAgentContext.Session // session ledger and persist. Default-safe: any failure (store construction defect, parse, IO) is // recovered from the CAUSE to a no-op. Returns the number of entries in the ledger after the merge, // or 0 on any failure. -export const updateLedgerFromSummary = (input: { sessionID: SessionID; summary: string }) => +export const updateLedgerFromSummaryRequired = (input: { + sessionID: SessionID + summary: string + operationID?: string +}) => Effect.sync(() => { const store = new DocumentStore(contextStoreRoot(input.sessionID)) const current = SessionLedger.loadLedger(store, input.sessionID) - const appended = parseSummaryToEntries(input.summary) + const appended = parseSummaryToEntries(input.summary).map((entry, index) => + input.operationID + ? { + ...entry, + id: `led_compaction_${Hash.sha256(`${input.operationID}:${index}:${entry.kind}:${entry.text}`).slice(0, 24)}`, + } + : entry, + ) const next = SessionLedger.applyUpdate(current, { append: appended }) SessionLedger.persistLedger(store, next) return next.entries.length - }).pipe( + }) + +export const updateLedgerFromSummary = (input: { sessionID: SessionID; summary: string }) => + updateLedgerFromSummaryRequired(input).pipe( Effect.matchCauseEffect({ onFailure: () => Effect.succeed(0), onSuccess: (n) => Effect.succeed(n), @@ -102,7 +121,7 @@ export const updateLedgerFromSummary = (input: { sessionID: SessionID; summary: // miss the defect — recover the CAUSE via Effect.matchCauseEffect. Any failure (store defect, empty // ledger, IO) degrades to a no-op (returns 0) and never throws into the compaction loop. Returns the // number of bridge entries after the carry-over. -export const carryOverToBridge = (input: { sessionID: SessionID; workspacePath: string }) => +export const carryOverToBridgeRequired = (input: { sessionID: SessionID; workspacePath: string }) => Effect.sync(() => { const ledgerStore = new DocumentStore(contextStoreRoot(input.sessionID)) const ledger = SessionLedger.loadLedger(ledgerStore, input.sessionID) @@ -123,7 +142,10 @@ export const carryOverToBridge = (input: { sessionID: SessionID; workspacePath: const projectId = DeepAgentDurableKnowledgeStore.projectIdForWorkspace(input.workspacePath) const bridge = ProjectBridge.carryOver(projectStore, projectId, ledger) return bridge.entries.length - }).pipe( + }) + +export const carryOverToBridge = (input: { sessionID: SessionID; workspacePath: string }) => + carryOverToBridgeRequired(input).pipe( Effect.matchCauseEffect({ onFailure: () => Effect.succeed(0), onSuccess: (n) => Effect.succeed(n), @@ -152,11 +174,10 @@ export const carryOverToBridge = (input: { sessionID: SessionID; workspacePath: // sessionId to the fork and persists it under the fork's own store root — parent and fork ledgers // stay fully independent afterwards (edits to one never touch the other). // -// Default-safe (Phase 3 lesson): DocumentStore construction throws SYNCHRONOUSLY, so a plain -// Effect.catch would MISS the defect — we recover from the CAUSE via Effect.matchCauseEffect. Any -// failure (store construction defect, IO) degrades to "fork has no forwarded ledger" (returns 0) -// rather than failing the fork. Returns the number of entries copied. -export const forwardLedgerOnFork = (input: { parentSessionID: SessionID; forkSessionID: SessionID }) => +// The required variant propagates store/IO defects so the fork side-effect receipt cannot be marked +// complete while durable memory is missing. The compatibility wrapper below is the explicitly +// best-effort API and converts defects to 0. Returns the number of entries copied. +export const forwardLedgerOnForkRequired = (input: { parentSessionID: SessionID; forkSessionID: SessionID }) => Effect.sync(() => { const parentStore = new DocumentStore(contextStoreRoot(input.parentSessionID)) const parentLedger = SessionLedger.loadLedger(parentStore, input.parentSessionID) @@ -165,7 +186,10 @@ export const forwardLedgerOnFork = (input: { parentSessionID: SessionID; forkSes const forkLedger = { ...parentLedger, sessionId: input.forkSessionID } const forkStore = new DocumentStore(contextStoreRoot(input.forkSessionID)) return SessionLedger.persistLedger(forkStore, forkLedger) - }).pipe( + }) + +export const forwardLedgerOnFork = (input: { parentSessionID: SessionID; forkSessionID: SessionID }) => + forwardLedgerOnForkRequired(input).pipe( Effect.matchCauseEffect({ onFailure: () => Effect.succeed(0), onSuccess: (n) => Effect.succeed(n), @@ -191,15 +215,25 @@ export type ForkOrigin = { const forkOriginFile = (sessionID: string): string => path.join(contextStoreRoot(sessionID), "fork-origin.json") -// Persist the fork divergence marker into the fork's context store. Default-safe: any IO failure is -// recovered from the CAUSE to a no-op (returns false) rather than failing the fork. -export const persistForkOrigin = (input: { forkSessionID: SessionID; origin: ForkOrigin }) => +// Persist the fork divergence marker into the fork's context store. The required variant propagates +// IO defects so recovery keeps retrying the incomplete fork side-effect receipt. The compatibility +// wrapper below remains best-effort and converts defects to false. +export const persistForkOriginRequired = (input: { forkSessionID: SessionID; origin: ForkOrigin }) => Effect.sync(() => { const file = forkOriginFile(input.forkSessionID) mkdirSync(path.dirname(file), { recursive: true }) - writeFileSync(file, JSON.stringify(input.origin, null, 2), "utf8") + const temporary = `${file}.${process.pid}.${Date.now()}.tmp` + try { + writeFileSync(temporary, JSON.stringify(input.origin, null, 2), { encoding: "utf8", mode: 0o600 }) + renameSync(temporary, file) + } finally { + if (existsSync(temporary)) unlinkSync(temporary) + } return true - }).pipe( + }) + +export const persistForkOrigin = (input: { forkSessionID: SessionID; origin: ForkOrigin }) => + persistForkOriginRequired(input).pipe( Effect.matchCauseEffect({ onFailure: () => Effect.succeed(false), onSuccess: () => Effect.succeed(true), @@ -241,7 +275,8 @@ const renderVcs = (git: { changed_files: readonly string[]; diff_stat: string | return lines.join("\n") } -const renderEnv = (): string => [`platform: ${process.platform}`, `node: ${process.version}`, `arch: ${os.arch()}`].join("\n") +const renderEnv = (): string => + [`platform: ${process.platform}`, `node: ${process.version}`, `arch: ${os.arch()}`].join("\n") // Collect the cheap volatile facts (git + env) as rendered slot values. Best-effort: a git failure just // omits the vcs slot (undefined ⇒ prior value preserved by collectSlots). NOT a heavy collector — plain @@ -254,7 +289,22 @@ export const collectVolatileFacts = ( const facts: Partial> = { env: renderEnv() } if (git) facts.vcs = renderVcs(git) return facts - }).pipe(Effect.catchCause(() => Effect.succeed({ env: renderEnv() } as Partial>))) + }).pipe( + Effect.catchCause(() => + Effect.succeed({ env: renderEnv() } as Partial>), + ), + ) + +const collectVolatileFactsStrict = ( + cwd: string, +): Effect.Effect>> => + Effect.promise(async () => { + const git = await gitGroundTruth(cwd) + return { + env: renderEnv(), + vcs: renderVcs(git), + } satisfies Partial> + }) // Snapshot-diff the collected facts into the project's World State doc, persist, and render the tail // block. Returns "" when there is nothing to inject or on ANY defect (default-safe — never throws into @@ -280,4 +330,114 @@ export const refreshWorldState = (input: { }), ) +export type SessionWorldStateBaselineSection = { + readonly sectionID: string + readonly snapshot: WorldStateSlot + readonly fragment: string + readonly fragmentHash: string +} + +export type SessionWorldStateBaseline = { + readonly projectId: string + readonly snapshot: DeepAgentContext.WorldState.WorldState + readonly sections: readonly SessionWorldStateBaselineSection[] + readonly rendered: string + readonly hash: string +} + +const WORLD_STATE_BASELINE_SECTION_ORDER = [ + "world_state:open_files", + "world_state:vcs", + "world_state:diagnostics", + "world_state:env", +] as const + +export const orderSessionWorldStateBaselineSections = ( + sections: readonly T[], +): T[] => + [...sections].sort((a, b) => { + const aIndex = WORLD_STATE_BASELINE_SECTION_ORDER.indexOf( + a.sectionID as (typeof WORLD_STATE_BASELINE_SECTION_ORDER)[number], + ) + const bIndex = WORLD_STATE_BASELINE_SECTION_ORDER.indexOf( + b.sectionID as (typeof WORLD_STATE_BASELINE_SECTION_ORDER)[number], + ) + if (aIndex < 0 && bIndex < 0) return a.sectionID.localeCompare(b.sectionID) + if (aIndex < 0) return 1 + if (bIndex < 0) return -1 + return aIndex - bIndex + }) + +export const sessionWorldStateBaselineHash = (input: { + readonly sections: readonly Pick[] + readonly rendered: string +}): string => + `wsb1_${Hash.sha256( + CanonicalJson.stringify({ + version: 1, + sections: orderSessionWorldStateBaselineSections(input.sections).map((section) => ({ + sectionID: section.sectionID, + snapshot: section.snapshot, + fragmentHash: section.fragmentHash, + })), + rendered: input.rendered, + }), + )}` + +// This is the strict Session/PromptEpoch boundary. Unlike refreshWorldState, defects are preserved so +// compaction cannot activate an epoch whose model-visible World State baseline was not constructed. +export const collectSessionWorldStateBaseline = (input: { + readonly workspacePath: string +}): Effect.Effect => + Effect.gen(function* () { + const facts = yield* collectVolatileFactsStrict(input.workspacePath) + const projectStore = AgentGateway.DeepAgentKnowledgeSource.isConfigured() + ? AgentGateway.DeepAgentKnowledgeSource.projectStoreFor(input.workspacePath).documentStore + : DeepAgentDurableKnowledgeStore.openProjectStore(Global.Path.agent.data, input.workspacePath).documentStore + const projectId = DeepAgentDurableKnowledgeStore.projectIdForWorkspace(input.workspacePath) + const next = yield* Effect.sync(() => { + const persist = (attemptsRemaining: number): DeepAgentContext.WorldState.WorldState => { + const current = ProjectBridge.loadWorldStateForGoalWorker(projectStore, projectId) + const candidate = WorldState.collectSlots(current, facts) + try { + ProjectBridge.persistWorldState(projectStore, candidate) + return candidate + } catch (error) { + if (!(error instanceof DocumentConflictError) || attemptsRemaining === 0) throw error + projectStore.rebuildIndex() + return persist(attemptsRemaining - 1) + } + } + return persist(4) + }) + const sections = next.slots.map((slot) => { + const sectionID = `world_state:${slot.kind}` + const fragment = WorldState.renderSlot(slot) + return { + sectionID, + snapshot: slot, + fragment, + fragmentHash: Hash.sha256(CanonicalJson.stringify({ sectionID, slot, fragment })), + } satisfies SessionWorldStateBaselineSection + }) + const rendered = renderSessionWorldStateBaseline(sections) + const hash = sessionWorldStateBaselineHash({ sections, rendered }) + return { projectId, snapshot: next, sections, rendered, hash } + }) + +export const renderSessionWorldStateBaseline = ( + sections: readonly Pick[], +): string => { + if (sections.length === 0) return "" + const ordered = orderSessionWorldStateBaselineSections(sections) + return [ + "", + "Current environment / file / diagnostics facts (latest values, re-injected — trust these over any", + "older values mentioned in the summary above):", + "", + ...ordered.map((section) => section.fragment), + "", + ].join("\n") +} + export { contextStoreRoot } diff --git a/packages/deepagent-code/src/session/history-authority.ts b/packages/deepagent-code/src/session/history-authority.ts new file mode 100644 index 00000000..997d6543 --- /dev/null +++ b/packages/deepagent-code/src/session/history-authority.ts @@ -0,0 +1,63 @@ +import { CanonicalJson } from "@deepagent-code/core/util/canonical-json" +import { Hash } from "@deepagent-code/core/util/hash" +import { Identifier } from "@deepagent-code/core/util/identifier" +import type { SessionV1 } from "@deepagent-code/core/v1/session" + +export const PROJECTION_VERSION = 1 +export const CANONICALIZATION_VERSION = 1 + +export function hash(messages: readonly SessionV1.WithParts[]) { + return `eh${CANONICALIZATION_VERSION}_${Hash.sha256( + CanonicalJson.stringify({ + canonicalizationVersion: CANONICALIZATION_VERSION, + projectionVersion: PROJECTION_VERSION, + messages: messages.map((message) => ({ + info: + message.info.role === "user" + ? { + id: message.info.id, + role: message.info.role, + format: message.info.format, + // SessionSummary updates UI diff metadata asynchronously after a turn settles. + // It is not provider prompt input and must not invalidate an immutable window. + agent: message.info.agent, + model: message.info.model, + system: message.info.system, + tools: message.info.tools, + metadata: message.info.metadata, + } + : { + id: message.info.id, + role: message.info.role, + error: message.info.error, + parentID: message.info.parentID, + modelID: message.info.modelID, + providerID: message.info.providerID, + providerAttemptID: message.info.providerAttemptID, + mode: message.info.mode, + agent: message.info.agent, + path: message.info.path, + summary: message.info.summary, + structured: message.info.structured, + variant: message.info.variant, + finish: message.info.finish, + }, + parts: message.parts.map((part) => + Object.fromEntries( + Object.entries(part).filter(([key]) => key !== "sessionID" && key !== "messageID" && key !== "time"), + ), + ), + })), + }), + )}` +} + +export function windowID() { + return `win_${Identifier.ascending()}` +} + +export function legacyWindowID(sessionID: string, epoch: number) { + return `win_${Hash.sha256(`legacy-window:v1:${sessionID}:${epoch}`).slice(0, 26)}` +} + +export * as HistoryAuthority from "./history-authority" diff --git a/packages/deepagent-code/src/session/llm.ts b/packages/deepagent-code/src/session/llm.ts index 38ab6350..723cdce5 100644 --- a/packages/deepagent-code/src/session/llm.ts +++ b/packages/deepagent-code/src/session/llm.ts @@ -107,6 +107,58 @@ function stableReceiptJson(value: unknown, ancestors = new WeakSet()): s return serialized } +function finalRequestFingerprint(value: unknown) { + return Hash.sha256(stableReceiptJson(value)) +} + +function finalToolDefinitions(tools: Readonly>) { + return Object.entries(tools) + .toSorted(([a], [b]) => a.localeCompare(b)) + .map(([name, definition]) => ({ + name, + description: definition.description, + inputSchema: "inputSchema" in definition ? definition.inputSchema : undefined, + })) +} + +function physicalToolDefinitions(value: unknown) { + const definitions = Array.isArray(value) + ? value.map((definition) => { + if (!isRecord(definition)) return definition + return Object.fromEntries( + Object.entries(definition) + .filter(([key]) => !["execute", "onInputStart", "onInputDelta", "onInputAvailable"].includes(key)) + .toSorted(([a], [b]) => a.localeCompare(b)), + ) + }) + : [] + return { + definitions, + ids: definitions.flatMap((definition) => { + if (!isRecord(definition)) return [] + const name = definition.name ?? definition.toolName + return typeof name === "string" ? [name] : [] + }), + } +} + +function physicalPromptCacheKey(value: unknown) { + const keys = new Set() + const visit = (current: unknown) => { + if (!isRecord(current)) return + for (const [key, child] of Object.entries(current)) { + if (["promptCacheKey", "prompt_cache_key", "cacheKey"].includes(key) && typeof child === "string") { + keys.add(child) + continue + } + visit(child) + } + } + visit(value) + if (keys.size > 1) throw new Error("Provider request contains conflicting prompt cache keys") + return keys.values().next().value +} + function adapterReceiptDetails(event: LLMEvent) { if (event.type === "tool-call") return { callID: event.id, toolName: event.name, payload: event.input } if (event.type === "tool-error") { @@ -300,7 +352,16 @@ export type StreamInput = { readonly adapterLoweringOutcome: "ok" | "schema_rejected" | "omitted_no_support" readonly budget: RequestBudgetStatus }) => Effect.Effect + readonly adapterPrepared: (input: { + readonly finalRequestHash: string + readonly promptCacheKey?: string + readonly finalOfferedToolIds: readonly string[] + readonly toolDefinitionHash: string + }) => Effect.Effect readonly dispatched: () => Effect.Effect + readonly streaming: () => Effect.Effect + readonly settled: () => Effect.Effect + readonly failed: (error: unknown) => Effect.Effect readonly rejected: (input: { readonly budget: RequestBudgetStatus; readonly reason: string }) => Effect.Effect readonly aiSdkInput: (input: { readonly ordinal: number @@ -648,7 +709,8 @@ const live: Layer.Layer< Object.keys(input.tools).length > 0 && Object.keys(runtimeTools).length === 0 ? "omitted_no_support" : "ok", budget, }) ?? Effect.void - yield* input.requestReceipt?.dispatched() ?? Effect.void + const physicalProviderOptions = ProviderTransform.providerOptions(input.model, effectiveOptions ?? {}) + const receiptBridge = yield* EffectBridge.make() const tracer = cfg.experimental?.openTelemetry ? Option.getOrUndefined(yield* Effect.serviceOption(OtelTracer.OtelTracer)) @@ -687,6 +749,26 @@ const live: Layer.Layer< metadata: prepared.metadata, }) if (native.type === "supported") { + const physicalTools = finalToolDefinitions(runtimeTools) + yield* input.requestReceipt?.adapterPrepared({ + finalRequestHash: finalRequestFingerprint({ + runtime: "native", + model: { providerID: input.model.providerID, modelID: input.model.id }, + messages: ProviderTransform.message(prepared.messages, input.model, effectiveOptions ?? {}), + tools: physicalTools, + toolChoice: effectiveToolChoice, + temperature: prepared.params.temperature, + topP: prepared.params.topP, + topK: prepared.params.topK, + maxOutputTokens: prepared.params.maxOutputTokens, + providerOptions: physicalProviderOptions, + headers: prepared.headers, + }), + promptCacheKey: physicalPromptCacheKey(physicalProviderOptions), + finalOfferedToolIds: physicalTools.map((tool) => tool.name), + toolDefinitionHash: Hash.sha256(stableReceiptJson(physicalTools)), + }) ?? Effect.void + yield* input.requestReceipt?.dispatched() ?? Effect.void yield* input.requestReceipt?.aiSdkInput({ ordinal: 0, eventType: "native-runtime", @@ -733,7 +815,7 @@ const live: Layer.Layer< result: streamText({ // Copilot returns the authoritative billed amount only in provider-specific response fields. includeRawChunks: input.model.providerID.includes("github-copilot"), - onError(error) { + onError({ error }) { // AI SDK's APICallError carries `requestBodyValues` = the ENTIRE request body (system // prompt + every message). Logging the raw error JSON.stringifies that into a single // multi-hundred-KB line. Log only the salient fields (and a truncated responseBody) so a @@ -814,7 +896,7 @@ const live: Layer.Layer< temperature: prepared.params.temperature, topP: prepared.params.topP, topK: prepared.params.topK, - providerOptions: ProviderTransform.providerOptions(input.model, effectiveOptions ?? {}), + providerOptions: physicalProviderOptions, activeTools: Object.keys(runtimeTools).filter((x) => x !== "invalid"), tools: runtimeTools, toolChoice: effectiveToolChoice, @@ -836,6 +918,30 @@ const live: Layer.Layer< input.model, prepared.messageTransformOptions, ) + if (input.requestReceipt) { + const physicalTools = physicalToolDefinitions(args.params.tools) + await receiptBridge.promise( + input.requestReceipt.adapterPrepared({ + finalRequestHash: finalRequestFingerprint({ + runtime: "ai-sdk", + model: { providerID: input.model.providerID, modelID: input.model.id }, + prompt: args.params.prompt, + tools: args.params.tools, + toolChoice: args.params.toolChoice, + temperature: args.params.temperature, + topP: args.params.topP, + topK: args.params.topK, + maxOutputTokens: args.params.maxOutputTokens, + providerOptions: args.params.providerOptions, + headers: prepared.headers, + }), + promptCacheKey: physicalPromptCacheKey(args.params.providerOptions), + finalOfferedToolIds: physicalTools.ids, + toolDefinitionHash: Hash.sha256(stableReceiptJson(physicalTools.definitions)), + }), + ) + await receiptBridge.promise(input.requestReceipt.dispatched()) + } } return args.params }, diff --git a/packages/deepagent-code/src/session/llm/request.ts b/packages/deepagent-code/src/session/llm/request.ts index 79da1ac9..eb345e6d 100644 --- a/packages/deepagent-code/src/session/llm/request.ts +++ b/packages/deepagent-code/src/session/llm/request.ts @@ -94,7 +94,8 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre // provider-scoped. It applies to every upstream provider; `general` keeps the inherited // (deepagent-code) baseline prompt untouched. const agentMode = deepAgentAgentModeOverride(input.user.metadata) ?? AgentGateway.snapshot().agentMode - const isDeepAgentActive = AgentGateway.snapshot().mode === "enabled" && agentMode !== "general" + const isDeepAgentEnabled = AgentGateway.isDeepAgentRuntimeEnabled() + const isDeepAgentActive = isDeepAgentEnabled && agentMode !== "general" let system: string[] // The DeepAgent base system prompt stays byte-stable across a session. Per-turn runtime state // (round, stage, previous results, token budget, fan-out verdict) is rendered separately and sent @@ -102,6 +103,7 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre // entire history on Anthropic-compatible APIs and invalidate that provider-cache prefix. let volatileRoundContext = "" let volatileContextKind: "none" | "round" | "continuation" = "none" + let workflowPlanStatus: string | null = null let validationCommands: readonly string[] = [] if (isDeepAgentActive) { @@ -115,20 +117,24 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre promptContext.context.previousResults !== null // Fold round context and plan status into one runtime update. The stable system prompt identifies // this tagged tail as trusted control and requires the model to apply it silently. `renderPlanStatus` - // returns null in lightweight mode / no plan. A first-round, non-orchestrated task gets no update. + // returns null in lightweight mode / no plan. An existing Plan always gets a tail, including a + // fresh non-orchestrated activity, because its next write parameters must never depend on history. const isToolContinuation = input.messages.at(-1)?.role === "tool" - volatileContextKind = isToolContinuation ? "continuation" : runtimeSystemRequired ? "round" : "none" - const roundCtx = - volatileContextKind === "continuation" - ? AgentGateway.volatileContinuationContext() - : volatileContextKind === "round" - ? AgentGateway.volatileRoundContext(promptContext.context) - : "" + const baseContextKind = isToolContinuation ? "continuation" : runtimeSystemRequired ? "round" : "none" const planStatus = - volatileContextKind === "none" + input.agent.name === "compaction" ? null : SessionReminders.renderPlanStatus(input.sessionID, isToolContinuation ? "continuation" : "full") - volatileRoundContext = [roundCtx, planStatus].filter((x) => x && x.length > 0).join("\n\n") + workflowPlanStatus = planStatus + volatileRoundContext = + baseContextKind === "continuation" + ? AgentGateway.volatileContinuationContext(planStatus ?? undefined) + : baseContextKind === "round" + ? AgentGateway.volatileRoundContext(promptContext.context, planStatus ?? undefined) + : planStatus + ? AgentGateway.volatilePlanContext(planStatus) + : "" + volatileContextKind = baseContextKind === "continuation" ? "continuation" : volatileRoundContext ? "round" : "none" logPrompt(input.sessionID, promptContext.context.round, system[0]).catch(() => {}) } else { const baseAgentSystem = input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model) @@ -157,9 +163,10 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre .filter((x) => x) .join("\n"), ] - if (input.agent.name === "goal-worker") { - volatileRoundContext = - SessionReminders.renderPlanStatus(input.sessionID, "full", { includeLightweight: true }) ?? "" + if (isDeepAgentEnabled && input.agent.name !== "compaction") { + const planStatus = SessionReminders.renderPlanStatus(input.sessionID, "full", { includeLightweight: true }) + workflowPlanStatus = planStatus + volatileRoundContext = planStatus ? AgentGateway.volatilePlanContext(planStatus) : "" volatileContextKind = volatileRoundContext ? "round" : "none" } } @@ -235,6 +242,10 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre ] .filter(Boolean) .join("\n\n") + // GitLab Workflow models receive `prepared.system` through their dedicated workflow protocol and + // intentionally do not receive synthetic user messages. Give them the exact Plan contract through + // that channel without moving unrelated volatile/reference context into the workflow system prompt. + if (workflowPlanStatus && input.isWorkflow) system.push(AgentGateway.volatilePlanContext(workflowPlanStatus)) const messages = runtimeTail && !input.isWorkflow ? [...baseMessages, { role: "user", content: runtimeTail } satisfies ModelMessage] diff --git a/packages/deepagent-code/src/session/message-v2.ts b/packages/deepagent-code/src/session/message-v2.ts index 2f145701..a4f9860d 100644 --- a/packages/deepagent-code/src/session/message-v2.ts +++ b/packages/deepagent-code/src/session/message-v2.ts @@ -33,15 +33,34 @@ import { eq } from "drizzle-orm" import { inArray } from "drizzle-orm" import { lt } from "drizzle-orm" import { or } from "drizzle-orm" -import { MessageTable, PartTable, SessionTable } from "@deepagent-code/core/session/sql" +import { sql } from "drizzle-orm" +import { + MessageTable, + PartTable, + SessionPromptEpochMessageTable, + SessionTable, + SessionWorldStateBaselineTable, +} from "@deepagent-code/core/session/sql" import { SessionPromptEpochTable } from "./prompt-epoch.sql" import { ProviderError } from "@/provider/error" import { iife } from "@/util/iife" import { errorMessage } from "@/util/error" import { isMedia } from "@/util/media" import type { Provider } from "@/provider/provider" -import { Effect, Exit, Schema } from "effect" +import { Data, Effect, Exit, Option, Schema } from "effect" import * as EffectLogger from "@deepagent-code/core/effect/logger" +import { SessionHistoryStateTable } from "@deepagent-code/core/session/sql" +import { CompactionArtifactTable, CompactionRunTable } from "./compaction-sql" +import { HistoryAuthority } from "./history-authority" +import { + collectSessionWorldStateBaseline, + renderSessionWorldStateBaseline, + sessionWorldStateBaselineHash, + type SessionWorldStateBaselineSection, +} from "./context-ledger" +import { CanonicalJson } from "@deepagent-code/core/util/canonical-json" +import { Hash } from "@deepagent-code/core/util/hash" +import { WorldStateSlot } from "@deepagent-code/core/deepagent/context/world-state" /** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */ interface FetchDecompressionError extends Error { @@ -68,6 +87,117 @@ const transientTransportCodes = new Set([ export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached media from tool result:" export { isMedia } +export class HistoryAuthorityError extends Data.TaggedError("SessionHistory.AuthorityError")<{ + readonly sessionID: SessionID + readonly reason: string +}> {} + +export type PromptHistoryProjection = { + readonly sessionID: SessionID + readonly epoch: number + readonly messages: WithParts[] + readonly orderedMessageIDs: MessageID[] + readonly effectiveHistoryHash: string + readonly projectionVersion: number + readonly canonicalizationVersion: number + readonly baseMessageCount: number + readonly window: { + readonly firstWindowID: string + readonly previousWindowID?: string + readonly windowID: string + } + readonly worldStateBaselineHash?: string +} + +export type PromptWorldStateProjection = { + readonly sessionID: SessionID + readonly epoch: number + readonly windowID: string + readonly effectiveHistoryHash: string + readonly hash: string + readonly rendered: string + readonly sections: readonly SessionWorldStateBaselineSection[] +} + +export function validateProviderPromptBoundary(input: { + readonly authority: PromptHistoryProjection + readonly dispatch: PromptHistoryProjection + readonly assistantMessageID: MessageID + readonly parentMessageID: MessageID +}) { + if (input.dispatch.epoch !== input.authority.epoch) return "prompt epoch changed" + if (input.dispatch.window.windowID !== input.authority.window.windowID) return "context window changed" + if (input.dispatch.messages.length !== input.authority.messages.length + 1) { + return "effective history changed outside the current assistant draft" + } + const draft = input.dispatch.messages.at(-1) + if (draft?.info.id !== input.assistantMessageID) return "current assistant draft is not the history tail" + if (draft.info.role !== "assistant") return "current assistant draft has an invalid role" + if (draft.info.parentID !== input.parentMessageID) return "current assistant draft has an invalid parent" + if ( + draft.parts.length !== 0 || + draft.info.error !== undefined || + draft.info.finish !== undefined || + draft.info.time.completed !== undefined || + draft.info.providerAttemptID !== undefined + ) { + return "current assistant draft is no longer pristine" + } + if (HistoryAuthority.hash(input.dispatch.messages.slice(0, -1)) !== input.authority.effectiveHistoryHash) { + return "effective history hash changed" + } +} + +export function appendPromptWorldState(input: { + readonly messages: readonly WithParts[] + readonly sessionID: SessionID + readonly epoch: number + readonly baselineHash: string + readonly rendered: string + readonly agent: string + readonly model: User["model"] +}): WithParts[] { + if (input.rendered.trim().length === 0) return [...input.messages] + const messageID = MessageID.make( + `msg_${Hash.sha256(`world-state-message:v1:${input.sessionID}:${input.epoch}:${input.baselineHash}`).slice(0, 26)}`, + ) + const partID = PartID.make( + `prt_${Hash.sha256(`world-state-part:v1:${input.sessionID}:${input.epoch}:${input.baselineHash}`).slice(0, 26)}`, + ) + const contextProvenance = { + source: "world_state", + ownerSessionID: input.sessionID, + ownerPromptEpoch: input.epoch, + snapshotHash: input.baselineHash, + durable: true, + } + return [ + ...input.messages, + { + info: { + id: messageID, + role: "user", + sessionID: input.sessionID, + time: { created: 0 }, + agent: input.agent, + model: input.model, + metadata: { deepagent: { contextProvenance } }, + }, + parts: [ + { + id: partID, + messageID, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: input.rendered, + metadata: { deepagent: { contextProvenance } }, + }, + ], + }, + ] +} + function truncateToolOutput(text: string, maxChars?: number) { if (!maxChars || text.length <= maxChars) return text const omitted = text.length - maxChars @@ -153,17 +283,21 @@ function hydrate(db: Database.Interface["db"], rows: (typeof MessageTable.$infer return Effect.gen(function* () { if (ids.length > 0) { const partRows = yield* db - .select() + .select({ part: PartTable }) .from(PartTable) + .innerJoin( + MessageTable, + and(eq(MessageTable.id, PartTable.message_id), eq(MessageTable.session_id, PartTable.session_id)), + ) .where(inArray(PartTable.message_id, ids)) .orderBy(PartTable.message_id, PartTable.id) .all() .pipe(Effect.orDie) for (const row of partRows) { - const next = part(row) - const list = partByMessage.get(row.message_id) + const next = part(row.part) + const list = partByMessage.get(row.part.message_id) if (list) list.push(next) - else partByMessage.set(row.message_id, [next]) + else partByMessage.set(row.part.message_id, [next]) } } @@ -174,6 +308,30 @@ function hydrate(db: Database.Interface["db"], rows: (typeof MessageTable.$infer }) } +export function messagesInTransaction( + tx: Database.Interface["db"], + sessionID: SessionID, + messageIDs: readonly MessageID[], +) { + return Effect.gen(function* () { + if (messageIDs.length === 0) return [] as WithParts[] + const rows = yield* tx + .select() + .from(MessageTable) + .where(and(eq(MessageTable.session_id, sessionID), inArray(MessageTable.id, [...messageIDs]))) + .all() + if (rows.length !== messageIDs.length) return + const hydrated = yield* hydrate(tx, rows) + const byID = new Map(hydrated.map((message) => [message.info.id, message])) + const ordered = messageIDs.flatMap((messageID) => { + const message = byID.get(messageID) + return message ? [message] : [] + }) + if (ordered.length !== messageIDs.length) return + return ordered + }) +} + function providerMeta(metadata: Record | undefined) { if (!metadata) return undefined const { providerExecuted: _, ...rest } = metadata @@ -566,13 +724,17 @@ export function parts(messageID: MessageID) { return Effect.gen(function* () { const { db } = yield* Database.Service const rows = yield* db - .select() + .select({ part: PartTable }) .from(PartTable) + .innerJoin( + MessageTable, + and(eq(MessageTable.id, PartTable.message_id), eq(MessageTable.session_id, PartTable.session_id)), + ) .where(eq(PartTable.message_id, messageID)) .orderBy(PartTable.id) .all() .pipe(Effect.orDie) - return rows.map(part) + return rows.map((row) => part(row.part)) }) } @@ -648,18 +810,329 @@ export const filterCompactedEffect = Effect.fnUntraced(function* (sessionID: Ses return filterCompacted(yield* stream(sessionID)) }) +const projectPromptHistory = Effect.fn("MessageV2.projectPromptHistory")(function* ( + sessionID: SessionID, + ignoredCompactionMarkerID?: MessageID, +) { + const { db } = yield* Database.Service + const initial = yield* readHistorySnapshot(db, sessionID, ignoredCompactionMarkerID) + const snapshot = + initial.epoch?.authority_state === "ready" + ? initial + : yield* migrateHistoryAuthority({ + sessionID, + chronological: initial.chronological, + existing: initial.epoch, + ignoredCompactionMarkerID, + }).pipe( + Effect.provideService(Database.Service, { db }), + Effect.andThen(readHistorySnapshot(db, sessionID, ignoredCompactionMarkerID)), + ) + const epoch = snapshot.epoch + const chronological = snapshot.chronological + + if (!epoch) { + return yield* failHistoryAuthority({ sessionID, reason: "active history authority is missing" }) + } + + if (epoch.authority_state === "recovery_required") { + return yield* new HistoryAuthorityError({ + sessionID, + reason: epoch.recovery_reason ?? "history authority recovery is required", + }) + } + if ( + epoch.authority_state !== "ready" || + epoch.projection_version !== HistoryAuthority.PROJECTION_VERSION || + epoch.canonicalization_version !== HistoryAuthority.CANONICALIZATION_VERSION || + epoch.base_message_count === null || + !epoch.effective_history_hash || + !epoch.first_window_id || + !epoch.window_id + ) { + return yield* failHistoryAuthority({ + sessionID, + epoch: epoch.epoch, + reason: `epoch ${epoch.epoch} authority is incomplete`, + }) + } + + const selected = selectEpochHistory(chronological, epoch, snapshot.replacementMessageIDs) + if (!selected.ok) return yield* failHistoryAuthority({ sessionID, epoch: epoch.epoch, reason: selected.reason }) + if (epoch.base_message_count < 0 || epoch.base_message_count > selected.messages.length) { + return yield* failHistoryAuthority({ + sessionID, + epoch: epoch.epoch, + reason: `epoch ${epoch.epoch} base message count is outside the effective projection`, + }) + } + if (HistoryAuthority.hash(selected.messages.slice(0, epoch.base_message_count)) !== epoch.effective_history_hash) { + return yield* failHistoryAuthority({ + sessionID, + epoch: epoch.epoch, + reason: `epoch ${epoch.epoch} immutable replacement history hash mismatch`, + }) + } + + return { + sessionID, + epoch: epoch.epoch, + messages: selected.messages, + orderedMessageIDs: selected.messages.map((message) => message.info.id), + effectiveHistoryHash: HistoryAuthority.hash(selected.messages), + projectionVersion: epoch.projection_version, + canonicalizationVersion: epoch.canonicalization_version, + baseMessageCount: epoch.base_message_count, + window: { + firstWindowID: epoch.first_window_id, + ...(epoch.previous_window_id ? { previousWindowID: epoch.previous_window_id } : {}), + windowID: epoch.window_id, + }, + ...(epoch.world_state_baseline_hash ? { worldStateBaselineHash: epoch.world_state_baseline_hash } : {}), + } satisfies PromptHistoryProjection +}) + +export const promptHistoryProjectionEffect = Effect.fn("MessageV2.promptHistoryProjection")(function* ( + sessionID: SessionID, +) { + return yield* projectPromptHistory(sessionID) +}) + +export const promptHistoryBeforeCompactionEffect = Effect.fn("MessageV2.promptHistoryBeforeCompaction")( + function* (input: { sessionID: SessionID; markerMessageID: MessageID }) { + return yield* projectPromptHistory(input.sessionID, input.markerMessageID) + }, +) + export const promptHistoryEffect = Effect.fn("MessageV2.promptHistory")(function* (sessionID: SessionID) { + return (yield* promptHistoryProjectionEffect(sessionID)).messages +}) + +export const promptWorldStateProjectionEffect = Effect.fn("MessageV2.promptWorldStateProjection")(function* ( + sessionID: SessionID, +) { + const projection = yield* promptHistoryProjectionEffect(sessionID) + if (!projection.worldStateBaselineHash) { + if (projection.epoch === 0) return undefined + return yield* failHistoryAuthority({ + sessionID, + epoch: projection.epoch, + reason: `epoch ${projection.epoch} has no World State baseline binding`, + }) + } + const { db } = yield* Database.Service - const epoch = yield* db + const rows = yield* db .select() - .from(SessionPromptEpochTable) - .where(and(eq(SessionPromptEpochTable.session_id, sessionID), eq(SessionPromptEpochTable.state, "active"))) - .get() + .from(SessionWorldStateBaselineTable) + .where( + and( + eq(SessionWorldStateBaselineTable.session_id, sessionID), + eq(SessionWorldStateBaselineTable.prompt_epoch, projection.epoch), + ), + ) + .all() + .pipe(Effect.orDie) + if (rows.length === 0) { + return yield* failHistoryAuthority({ + sessionID, + epoch: projection.epoch, + reason: `epoch ${projection.epoch} World State baseline rows are missing`, + }) + } + + const sections = yield* Effect.forEach( + rows.sort((a, b) => a.section_id.localeCompare(b.section_id)), + (row) => + Effect.gen(function* () { + const snapshot = Option.getOrUndefined(Schema.decodeUnknownOption(WorldStateSlot)(row.snapshot)) + if (!snapshot) { + return yield* failHistoryAuthority({ + sessionID, + epoch: projection.epoch, + reason: `invalid World State snapshot: ${row.section_id}`, + }) + } + const fragmentHash = Hash.sha256( + CanonicalJson.stringify({ sectionID: row.section_id, slot: snapshot, fragment: row.fragment }), + ) + if (fragmentHash !== row.fragment_hash) { + return yield* failHistoryAuthority({ + sessionID, + epoch: projection.epoch, + reason: `World State fragment hash mismatch: ${row.section_id}`, + }) + } + return { + sectionID: row.section_id, + snapshot, + fragment: row.fragment, + fragmentHash, + } satisfies SessionWorldStateBaselineSection + }), + ) + const rendered = renderSessionWorldStateBaseline(sections) + if (sessionWorldStateBaselineHash({ sections, rendered }) !== projection.worldStateBaselineHash) { + return yield* failHistoryAuthority({ + sessionID, + epoch: projection.epoch, + reason: `World State baseline hash mismatch for epoch ${projection.epoch}`, + }) + } + return { + sessionID, + epoch: projection.epoch, + windowID: projection.window.windowID, + effectiveHistoryHash: projection.effectiveHistoryHash, + hash: projection.worldStateBaselineHash, + rendered, + sections, + } satisfies PromptWorldStateProjection +}) + +export const promptControlHistoryEffect = Effect.fn("MessageV2.promptControlHistory")(function* (sessionID: SessionID) { + const history = yield* promptHistoryEffect(sessionID) + const { db } = yield* Database.Service + const markers = new Set( + (yield* db + .select({ message_id: CompactionArtifactTable.message_id }) + .from(CompactionArtifactTable) + .innerJoin(CompactionRunTable, eq(CompactionRunTable.run_id, CompactionArtifactTable.run_id)) + .where( + and( + eq(CompactionArtifactTable.session_id, sessionID), + eq(CompactionArtifactTable.kind, "marker"), + eq(CompactionArtifactTable.state, "pending"), + inArray(CompactionRunTable.state, ["requested", "summarizing"] as const), + ), + ) + .all() + .pipe(Effect.orDie)).map((row) => row.message_id), + ) + if (markers.size === 0) return history + const known = new Set(history.map((message) => message.info.id)) + return [ + ...history, + ...(yield* stream(sessionID)) + .reverse() + .filter((message) => markers.has(message.info.id) && !known.has(message.info.id)), + ] +}) + +type EpochRow = typeof SessionPromptEpochTable.$inferSelect +type EpochSelection = + | { readonly ok: true; readonly messages: WithParts[] } + | { readonly ok: false; readonly reason: string } + +function readHistorySnapshotInTransaction( + tx: Database.Interface["db"], + sessionID: SessionID, + ignoredCompactionMarkerID?: MessageID, +) { + return Effect.gen(function* () { + const hidden = new Set( + (yield* tx + .select({ message_id: CompactionArtifactTable.message_id, state: CompactionArtifactTable.state }) + .from(CompactionArtifactTable) + .where(eq(CompactionArtifactTable.session_id, sessionID)) + .all()) + .filter((artifact) => artifact.state !== "committed") + .map((artifact) => artifact.message_id), + ) + const physical = (yield* stream(sessionID).pipe(Effect.provideService(Database.Service, { db: tx }))).reverse() + const visible = physical.filter( + (message) => message.info.id === ignoredCompactionMarkerID || !hidden.has(message.info.id), + ) + const chronological = stripIgnoredCompactionMarker(visible, ignoredCompactionMarkerID) + const epoch = yield* tx + .select() + .from(SessionPromptEpochTable) + .where(and(eq(SessionPromptEpochTable.session_id, sessionID), eq(SessionPromptEpochTable.state, "active"))) + .get() + const replacementMessageIDs = epoch + ? (yield* tx + .select({ message_id: SessionPromptEpochMessageTable.message_id }) + .from(SessionPromptEpochMessageTable) + .where( + and( + eq(SessionPromptEpochMessageTable.session_id, sessionID), + eq(SessionPromptEpochMessageTable.prompt_epoch, epoch.epoch), + ), + ) + .orderBy(SessionPromptEpochMessageTable.ordinal) + .all()).map((row) => row.message_id) + : [] + return { chronological, epoch, replacementMessageIDs } + }) +} + +function readHistorySnapshot( + db: Database.Interface["db"], + sessionID: SessionID, + ignoredCompactionMarkerID?: MessageID, +) { + return db + .transaction((tx) => + readHistorySnapshotInTransaction(tx as unknown as Database.Interface["db"], sessionID, ignoredCompactionMarkerID), + ) .pipe(Effect.orDie) - const chronological = (yield* stream(sessionID)).reverse() - if (!epoch || epoch.epoch === 0) return chronological - if (!epoch.checkpoint_user_id || !epoch.checkpoint_assistant_id || !epoch.checkpoint_hash) { - return yield* Effect.die(new Error(`PromptEpoch ${sessionID}/${epoch.epoch} is missing checkpoint authority`)) +} + +function selectEpochHistory( + chronological: WithParts[], + epoch: EpochRow, + replacementMessageIDs: readonly MessageID[] = [], +): EpochSelection { + if (epoch.base_message_count !== null && epoch.base_message_count !== replacementMessageIDs.length) { + return { ok: false, reason: `epoch ${epoch.epoch} replacement membership is incomplete` } + } + if (replacementMessageIDs.length > 0) { + const messages = new Map(chronological.map((message) => [message.info.id, message])) + const replacement = replacementMessageIDs.flatMap((messageID) => { + const message = messages.get(messageID) + return message ? [message] : [] + }) + if (replacement.length !== replacementMessageIDs.length) { + return { ok: false, reason: `epoch ${epoch.epoch} replacement message is missing` } + } + if (epoch.epoch > 0) { + const user = replacement[0] + const assistant = replacement[1] + if ( + !epoch.checkpoint_user_id || + !epoch.checkpoint_assistant_id || + user?.info.id !== epoch.checkpoint_user_id || + user.info.role !== "user" || + !user.parts.some((part) => part.type === "compaction") || + assistant?.info.id !== epoch.checkpoint_assistant_id || + assistant.info.role !== "assistant" || + assistant.info.parentID !== user.info.id || + !assistant.info.summary || + !assistant.info.finish || + assistant.info.error + ) { + return { ok: false, reason: `epoch ${epoch.epoch} replacement checkpoint binding is invalid` } + } + } + const boundaryID = epoch.source_end_message_id ?? epoch.checkpoint_assistant_id + const boundaryIndex = boundaryID ? chronological.findIndex((message) => message.info.id === boundaryID) : -1 + if (boundaryID && boundaryIndex < 0) { + return { ok: false, reason: `epoch ${epoch.epoch} append boundary is missing` } + } + const replacementIDs = new Set(replacementMessageIDs) + return { + ok: true, + messages: [ + ...replacement, + ...(boundaryIndex < 0 + ? [] + : chronological.slice(boundaryIndex + 1).filter((message) => !replacementIDs.has(message.info.id))), + ], + } + } + if (epoch.epoch === 0) return { ok: true, messages: chronological } + if (!epoch.checkpoint_user_id || !epoch.checkpoint_assistant_id) { + return { ok: false, reason: `epoch ${epoch.epoch} is missing checkpoint authority` } } const userIndex = chronological.findIndex((message) => message.info.id === epoch.checkpoint_user_id) @@ -677,21 +1150,526 @@ export const promptHistoryEffect = Effect.fn("MessageV2.promptHistory")(function !assistant.info.finish || assistant.info.error ) { - return yield* Effect.die(new Error(`PromptEpoch ${sessionID}/${epoch.epoch} checkpoint binding is invalid`)) + return { ok: false, reason: `epoch ${epoch.epoch} checkpoint binding is invalid` } } const tailIndex = epoch.retained_tail_start_id ? chronological.findIndex((message) => message.info.id === epoch.retained_tail_start_id) : -1 if (epoch.retained_tail_start_id && (tailIndex < 0 || tailIndex >= userIndex)) { - return yield* Effect.die(new Error(`PromptEpoch ${sessionID}/${epoch.epoch} retained tail is invalid`)) + return { ok: false, reason: `epoch ${epoch.epoch} retained tail is invalid` } } - return [ - user, - assistant, - ...(tailIndex >= 0 ? chronological.slice(tailIndex, userIndex) : []), - ...chronological.slice(assistantIndex + 1), - ] + return { + ok: true, + messages: [ + user, + assistant, + ...(tailIndex >= 0 ? chronological.slice(tailIndex, userIndex) : []), + ...chronological.slice(assistantIndex + 1), + ], + } +} + +// Read-only production projection for callers that already hold the SQLite write transaction used +// for their CAS commit. It deliberately does not run legacy migration or quarantine mutations: a +// missing/incomplete authority simply fails the caller's CAS so no replacement window is committed +// against a different physical history. +export function promptHistoryProjectionInTransaction( + tx: Database.Interface["db"], + sessionID: SessionID, + ignoredCompactionMarkerID?: MessageID, +) { + return Effect.gen(function* () { + const snapshotExit = yield* Effect.exit(readHistorySnapshotInTransaction(tx, sessionID, ignoredCompactionMarkerID)) + if (Exit.isFailure(snapshotExit)) return + const snapshot = snapshotExit.value + const epoch = snapshot.epoch + if ( + !epoch || + epoch.authority_state !== "ready" || + epoch.projection_version !== HistoryAuthority.PROJECTION_VERSION || + epoch.canonicalization_version !== HistoryAuthority.CANONICALIZATION_VERSION || + epoch.base_message_count === null || + !epoch.effective_history_hash || + !epoch.first_window_id || + !epoch.window_id + ) + return + const selected = selectEpochHistory(snapshot.chronological, epoch, snapshot.replacementMessageIDs) + if (!selected.ok || epoch.base_message_count < 0 || epoch.base_message_count > selected.messages.length) return + if (HistoryAuthority.hash(selected.messages.slice(0, epoch.base_message_count)) !== epoch.effective_history_hash) + return + return { + sessionID, + epoch: epoch.epoch, + messages: selected.messages, + orderedMessageIDs: selected.messages.map((message) => message.info.id), + effectiveHistoryHash: HistoryAuthority.hash(selected.messages), + projectionVersion: epoch.projection_version, + canonicalizationVersion: epoch.canonicalization_version, + baseMessageCount: epoch.base_message_count, + window: { + firstWindowID: epoch.first_window_id, + ...(epoch.previous_window_id ? { previousWindowID: epoch.previous_window_id } : {}), + windowID: epoch.window_id, + }, + ...(epoch.world_state_baseline_hash ? { worldStateBaselineHash: epoch.world_state_baseline_hash } : {}), + } satisfies PromptHistoryProjection + }) +} + +const migrateHistoryAuthority = Effect.fn("MessageV2.migrateHistoryAuthority")(function* (input: { + sessionID: SessionID + chronological: WithParts[] + existing?: EpochRow + ignoredCompactionMarkerID?: MessageID +}) { + const { db } = yield* Database.Service + const session = yield* db + .select({ metadata: SessionTable.metadata, directory: SessionTable.directory }) + .from(SessionTable) + .where(eq(SessionTable.id, input.sessionID)) + .get() + .pipe(Effect.orDie) + if (!session) return yield* new NotFoundError({ message: `Session not found: ${input.sessionID}` }) + + const currentState = yield* db + .select() + .from(SessionHistoryStateTable) + .where(eq(SessionHistoryStateTable.session_id, input.sessionID)) + .get() + .pipe(Effect.orDie) + if (currentState?.state === "recovery_required") { + return yield* new HistoryAuthorityError({ + sessionID: input.sessionID, + reason: currentState.reason ?? "legacy history migration requires recovery", + }) + } + + const deepagent = session.metadata?.deepagent + const taskManifest = + deepagent && typeof deepagent === "object" + ? (deepagent as { task_fork_manifest?: unknown }).task_fork_manifest + : session.metadata?.task_fork_manifest + const foregroundManifest = session.metadata?.forkedFrom + if (taskManifest || foregroundManifest) { + const reason = taskManifest + ? "legacy task fork has no verifiable sanitation manifest" + : "legacy foreground fork has no verifiable source projection manifest" + yield* setHistoryRecoveryRequired(input.sessionID, reason) + return yield* new HistoryAuthorityError({ + sessionID: input.sessionID, + reason, + }) + } + + const needsWorldStateBaseline = + (input.existing?.epoch ?? 0) > 0 || + input.chronological.some( + (message) => message.info.role === "user" && message.parts.some((part) => part.type === "compaction"), + ) + const baselineExit = needsWorldStateBaseline + ? yield* Effect.exit(collectSessionWorldStateBaseline({ workspacePath: session.directory })) + : undefined + if (baselineExit && Exit.isFailure(baselineExit)) { + const reason = "legacy World State baseline collection failed" + yield* setHistoryRecoveryRequired(input.sessionID, reason) + return yield* new HistoryAuthorityError({ sessionID: input.sessionID, reason }) + } + const baseline = baselineExit && Exit.isSuccess(baselineExit) ? baselineExit.value : undefined + + const result = yield* db + .transaction( + (tx) => + Effect.gen(function* () { + const active = yield* tx + .select() + .from(SessionPromptEpochTable) + .where( + and(eq(SessionPromptEpochTable.session_id, input.sessionID), eq(SessionPromptEpochTable.state, "active")), + ) + .get() + if (active?.authority_state === "ready") return { row: active } as const + if (active && input.existing && active.epoch !== input.existing.epoch) { + return { error: "active epoch changed during migration" } as const + } + + const artifacts = yield* tx + .select({ message_id: CompactionArtifactTable.message_id, state: CompactionArtifactTable.state }) + .from(CompactionArtifactTable) + .where(eq(CompactionArtifactTable.session_id, input.sessionID)) + .all() + const hidden = new Set( + artifacts.filter((artifact) => artifact.state !== "committed").map((artifact) => artifact.message_id), + ) + const physical = (yield* stream(input.sessionID).pipe( + Effect.provideService(Database.Service, { + db: tx as unknown as Database.Interface["db"], + }), + )).reverse() + const visible = physical.filter( + (message) => message.info.id === input.ignoredCompactionMarkerID || !hidden.has(message.info.id), + ) + const live = stripIgnoredCompactionMarker(visible, input.ignoredCompactionMarkerID) + if (input.existing && !active) return { error: "active epoch disappeared during migration" } as const + const candidate = active ? { ok: true as const, row: active } : legacyEpochCandidate(input.sessionID, live) + if (!candidate.ok) return { error: candidate.reason } as const + const selected = + candidate.row.epoch === 0 + ? selectEpochHistory(live, candidate.row) + : { ok: true as const, messages: filterCompacted([...live].reverse()) } + if (!selected.ok) return { error: selected.reason } as const + if ( + candidate.row.epoch > 0 && + (selected.messages[0]?.info.id !== candidate.row.checkpoint_user_id || + selected.messages[1]?.info.id !== candidate.row.checkpoint_assistant_id) + ) { + return { error: "legacy compaction projection does not match its checkpoint binding" } as const + } + if (candidate.row.epoch > 0 && !baseline) { + return { error: "legacy compacted window has no World State baseline" } as const + } + const baseMessageCount = candidate.row.epoch === 0 ? 0 : selected.messages.length + const windowID = HistoryAuthority.legacyWindowID(input.sessionID, candidate.row.epoch) + const now = Date.now() + const replacementHistoryHash = HistoryAuthority.hash(selected.messages.slice(0, baseMessageCount)) + + const row = { + ...candidate.row, + checkpoint_hash: candidate.row.epoch > 0 ? replacementHistoryHash : null, + projection_version: HistoryAuthority.PROJECTION_VERSION, + canonicalization_version: HistoryAuthority.CANONICALIZATION_VERSION, + base_message_count: baseMessageCount, + effective_history_hash: replacementHistoryHash, + source_end_message_id: candidate.row.epoch > 0 ? (live.at(-1)?.info.id ?? null) : null, + first_window_id: windowID, + previous_window_id: null, + window_id: windowID, + world_state_baseline_hash: candidate.row.epoch > 0 ? baseline!.hash : null, + authority_state: "ready" as const, + recovery_reason: null, + } + if (active) { + yield* tx + .update(SessionPromptEpochTable) + .set(row) + .where( + and( + eq(SessionPromptEpochTable.session_id, input.sessionID), + eq(SessionPromptEpochTable.epoch, active.epoch), + eq(SessionPromptEpochTable.state, "active"), + ), + ) + .run() + } else { + yield* tx.insert(SessionPromptEpochTable).values(row).run() + } + yield* tx + .delete(SessionPromptEpochMessageTable) + .where( + and( + eq(SessionPromptEpochMessageTable.session_id, input.sessionID), + eq(SessionPromptEpochMessageTable.prompt_epoch, candidate.row.epoch), + ), + ) + .run() + if (baseMessageCount > 0) { + yield* tx + .insert(SessionPromptEpochMessageTable) + .values( + selected.messages.slice(0, baseMessageCount).map((message, ordinal) => ({ + session_id: input.sessionID, + prompt_epoch: candidate.row.epoch, + ordinal, + message_id: message.info.id, + })), + ) + .run() + } + if (candidate.row.epoch > 0) { + yield* tx + .delete(SessionWorldStateBaselineTable) + .where( + and( + eq(SessionWorldStateBaselineTable.session_id, input.sessionID), + eq(SessionWorldStateBaselineTable.prompt_epoch, candidate.row.epoch), + ), + ) + .run() + yield* tx + .insert(SessionWorldStateBaselineTable) + .values( + baseline!.sections.map((section) => ({ + session_id: input.sessionID, + prompt_epoch: candidate.row.epoch, + section_id: section.sectionID, + snapshot: section.snapshot, + fragment: section.fragment, + fragment_hash: section.fragmentHash, + provenance: "legacy_migration" as const, + created_at: now, + })), + ) + .run() + } + yield* tx + .insert(SessionHistoryStateTable) + .values({ + session_id: input.sessionID, + state: "ready", + reason: null, + time_created: now, + time_updated: now, + }) + .onConflictDoUpdate({ + target: SessionHistoryStateTable.session_id, + set: { state: "ready", reason: null, time_updated: now }, + }) + .run() + const migrated = yield* tx + .select() + .from(SessionPromptEpochTable) + .where( + and(eq(SessionPromptEpochTable.session_id, input.sessionID), eq(SessionPromptEpochTable.state, "active")), + ) + .get() + if (!migrated) return yield* Effect.die(new Error(`history migration failed for ${input.sessionID}`)) + return { row: migrated } as const + }), + { behavior: "immediate" }, + ) + .pipe(Effect.orDie) + if ("error" in result) { + const reason = result.error ?? "legacy history migration could not reconstruct the active projection" + const quarantined = yield* setHistoryRecoveryRequired(input.sessionID, reason) + if (!quarantined) { + const ready = yield* db + .select() + .from(SessionPromptEpochTable) + .where( + and(eq(SessionPromptEpochTable.session_id, input.sessionID), eq(SessionPromptEpochTable.state, "active")), + ) + .get() + .pipe(Effect.orDie) + if (ready?.authority_state === "ready") return ready + } + return yield* new HistoryAuthorityError({ sessionID: input.sessionID, reason }) + } + return result.row +}) + +function stripIgnoredCompactionMarker(messages: WithParts[], markerMessageID?: MessageID) { + if (!markerMessageID) return messages + const marker = messages.at(-1) + if ( + marker?.info.id !== markerMessageID || + marker.info.role !== "user" || + !marker.parts.some((part) => part.type === "compaction") + ) { + throw new Error(`ignored compaction marker must be the latest visible user message: ${markerMessageID}`) + } + const parts = marker.parts.filter((part) => part.type !== "compaction") + if (parts.length === 0) return messages.slice(0, -1) + return [...messages.slice(0, -1), { info: marker.info, parts }] +} + +function legacyEpochCandidate( + sessionID: SessionID, + chronological: WithParts[], +): { readonly ok: true; readonly row: EpochRow } | { readonly ok: false; readonly reason: string } { + const markerIndexes = chronological.flatMap((message, index) => + message.info.role === "user" && message.parts.some((part) => part.type === "compaction") ? [index] : [], + ) + if (markerIndexes.length === 0) { + const windowID = HistoryAuthority.legacyWindowID(sessionID, 0) + return { + ok: true, + row: { + session_id: sessionID, + epoch: 0, + state: "active", + checkpoint_user_id: null, + checkpoint_assistant_id: null, + retained_tail_start_id: null, + source_end_message_id: null, + checkpoint_hash: null, + projection_version: null, + canonicalization_version: null, + base_message_count: null, + effective_history_hash: null, + first_window_id: windowID, + previous_window_id: null, + window_id: windowID, + world_state_baseline_hash: null, + authority_state: "legacy_pending", + recovery_reason: null, + reason: "bootstrap", + created_at: Date.now(), + retired_at: null, + }, + } + } + + const markerIndex = markerIndexes.at(-1)! + const marker = chronological[markerIndex] + const summary = marker + ? chronological.find( + (message, index) => + index > markerIndex && + message.info.role === "assistant" && + message.info.parentID === marker.info.id && + message.info.summary && + message.info.finish && + !message.info.error, + ) + : undefined + const part = marker?.parts.find((item): item is CompactionPart => item.type === "compaction") + if (!marker || marker.info.role !== "user" || !summary || summary.info.role !== "assistant" || !part) { + return { ok: false, reason: "legacy compaction checkpoint cannot be reconstructed uniquely" } + } + const windowID = HistoryAuthority.legacyWindowID(sessionID, markerIndexes.length) + return { + ok: true, + row: { + session_id: sessionID, + epoch: markerIndexes.length, + state: "active", + checkpoint_user_id: marker.info.id, + checkpoint_assistant_id: summary.info.id, + retained_tail_start_id: part.tail_start_id ?? null, + source_end_message_id: null, + checkpoint_hash: null, + projection_version: null, + canonicalization_version: null, + base_message_count: null, + effective_history_hash: null, + first_window_id: windowID, + previous_window_id: null, + window_id: windowID, + world_state_baseline_hash: null, + authority_state: "legacy_pending", + recovery_reason: null, + reason: "compaction", + created_at: Date.now(), + retired_at: null, + }, + } +} + +const setHistoryRecoveryRequired = Effect.fn("MessageV2.setHistoryRecoveryRequired")(function* ( + sessionID: SessionID, + reason: string, +) { + const { db } = yield* Database.Service + return yield* db + .transaction( + (tx) => + Effect.gen(function* () { + const active = yield* tx + .select({ authority_state: SessionPromptEpochTable.authority_state }) + .from(SessionPromptEpochTable) + .where(and(eq(SessionPromptEpochTable.session_id, sessionID), eq(SessionPromptEpochTable.state, "active"))) + .get() + if (active?.authority_state === "ready") return false + + const now = Date.now() + yield* tx + .update(SessionPromptEpochTable) + .set({ authority_state: "recovery_required", recovery_reason: reason }) + .where( + and( + eq(SessionPromptEpochTable.session_id, sessionID), + eq(SessionPromptEpochTable.state, "active"), + sql`${SessionPromptEpochTable.authority_state} IS NOT 'ready'`, + ), + ) + .run() + yield* tx + .insert(SessionHistoryStateTable) + .values({ session_id: sessionID, state: "recovery_required", reason, time_created: now, time_updated: now }) + .onConflictDoUpdate({ + target: SessionHistoryStateTable.session_id, + set: { state: "recovery_required", reason, time_updated: now }, + }) + .run() + return true + }), + { behavior: "immediate" }, + ) + .pipe(Effect.orDie) +}) + +function failHistoryAuthority(input: { sessionID: SessionID; epoch?: number; reason: string }) { + return Effect.gen(function* () { + yield* quarantineHistoryAuthority(input) + return yield* new HistoryAuthorityError({ sessionID: input.sessionID, reason: input.reason }) + }) +} + +const quarantineHistoryAuthority = Effect.fn("MessageV2.quarantineHistoryAuthority")(function* (input: { + sessionID: SessionID + epoch?: number + reason: string +}) { + const { db } = yield* Database.Service + return yield* db + .transaction( + (tx) => + Effect.gen(function* () { + const active = yield* tx + .select({ + epoch: SessionPromptEpochTable.epoch, + authority_state: SessionPromptEpochTable.authority_state, + recovery_reason: SessionPromptEpochTable.recovery_reason, + }) + .from(SessionPromptEpochTable) + .where( + and(eq(SessionPromptEpochTable.session_id, input.sessionID), eq(SessionPromptEpochTable.state, "active")), + ) + .get() + if (input.epoch !== undefined && active?.epoch !== input.epoch) return false + + const reason = + active?.authority_state === "recovery_required" ? (active.recovery_reason ?? input.reason) : input.reason + if (active?.authority_state === "ready") { + const quarantined = yield* tx + .update(SessionPromptEpochTable) + .set({ authority_state: "recovery_required", recovery_reason: reason }) + .where( + and( + eq(SessionPromptEpochTable.session_id, input.sessionID), + eq(SessionPromptEpochTable.epoch, active.epoch), + eq(SessionPromptEpochTable.state, "active"), + eq(SessionPromptEpochTable.authority_state, "ready"), + ), + ) + .returning({ epoch: SessionPromptEpochTable.epoch }) + .get() + if (!quarantined) return false + } else if (active && active.authority_state !== "recovery_required") { + return false + } + + const now = Date.now() + yield* tx + .insert(SessionHistoryStateTable) + .values({ + session_id: input.sessionID, + state: "recovery_required", + reason, + time_created: now, + time_updated: now, + }) + .onConflictDoUpdate({ + target: SessionHistoryStateTable.session_id, + set: { state: "recovery_required", reason, time_updated: now }, + }) + .run() + return true + }), + { behavior: "immediate" }, + ) + .pipe(Effect.orDie) }) // filterCompacted reorders messages for model consumption diff --git a/packages/deepagent-code/src/session/overflow.ts b/packages/deepagent-code/src/session/overflow.ts index d7a5a853..f8f7755d 100644 --- a/packages/deepagent-code/src/session/overflow.ts +++ b/packages/deepagent-code/src/session/overflow.ts @@ -46,6 +46,7 @@ export interface RequestBudgetStatus { readonly reason?: "context_limit_unknown" | "context_limit_invalid" | "physical_budget_exceeded" readonly estimatedFullRequestTokens: number readonly physicalInputBudget: number + // Durable receipt compatibility: this is the independent generation ceiling, not an input deduction. readonly reservedOutputTokens: number readonly safetyMargin: number readonly provenance: "model_limit" | "host_guard" @@ -104,47 +105,52 @@ function positiveEnv(name: string, fallback: number) { return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback } +function physicalInputLimit(model: Provider.Model) { + return model.limit.input ?? model.limit.context +} + export function requestBudget(input: { model: Provider.Model estimatedFullRequestTokens: number outputTokenMax?: number }): RequestBudgetStatus { - const reservedOutputTokens = ProviderTransform.maxOutputTokens(input.model, input.outputTokenMax) + // Output capacity is an independent generation limit. Keep it in the receipt, but never + // subtract it from the provider's input window. + const maxOutputTokens = ProviderTransform.maxOutputTokens(input.model, input.outputTokenMax) const safetyMargin = positiveEnv("DEEPAGENT_CODE_CONTEXT_SAFETY_MARGIN", 1_024) - const context = input.model.limit.context - if (!Number.isFinite(context) || context < 0) { + const inputLimit = physicalInputLimit(input.model) + if (!Number.isFinite(inputLimit) || inputLimit < 0) { return { decision: "unavailable", reason: "context_limit_invalid", estimatedFullRequestTokens: input.estimatedFullRequestTokens, physicalInputBudget: 0, - reservedOutputTokens, + reservedOutputTokens: maxOutputTokens, safetyMargin, provenance: "model_limit", } } - if (context === 0) { + if (inputLimit === 0) { const hostGuard = positiveEnv("DEEPAGENT_CODE_UNKNOWN_CONTEXT_GUARD", 32_768) return { decision: input.estimatedFullRequestTokens < hostGuard ? "ok" : "unavailable", ...(input.estimatedFullRequestTokens >= hostGuard ? { reason: "context_limit_unknown" as const } : {}), estimatedFullRequestTokens: input.estimatedFullRequestTokens, physicalInputBudget: hostGuard, - reservedOutputTokens, + reservedOutputTokens: maxOutputTokens, safetyMargin, provenance: "host_guard", } } - const contextBudget = context - reservedOutputTokens - safetyMargin - const physicalInputBudget = input.model.limit.input ? Math.min(input.model.limit.input, contextBudget) : contextBudget + const physicalInputBudget = inputLimit - safetyMargin if (physicalInputBudget <= 0) { return { decision: "unavailable", reason: "context_limit_invalid", estimatedFullRequestTokens: input.estimatedFullRequestTokens, physicalInputBudget, - reservedOutputTokens, + reservedOutputTokens: maxOutputTokens, safetyMargin, provenance: "model_limit", } @@ -154,25 +160,22 @@ export function requestBudget(input: { ...(input.estimatedFullRequestTokens >= physicalInputBudget ? { reason: "physical_budget_exceeded" as const } : {}), estimatedFullRequestTokens: input.estimatedFullRequestTokens, physicalInputBudget, - reservedOutputTokens, + reservedOutputTokens: maxOutputTokens, safetyMargin, provenance: "model_limit", } } export function usable(input: { cfg: ConfigV1.Info; model: Provider.Model; outputTokenMax?: number }) { - const context = input.model.limit.context - // BUG-007: context=0 means "unknown" (resolver fallback). Return 0 so callers that only need the + const inputLimit = physicalInputLimit(input.model) + // BUG-007: a zero input/context fallback means "unknown". Return 0 so callers that only need the // numeric budget get a safe zero, but overflowStatus() uses its own typed path for the "unavailable" // phase rather than treating 0 the same as auto=false. - if (!context) return 0 - - const reserved = - input.cfg.compaction?.reserved ?? - Math.min(COMPACTION_BUFFER, ProviderTransform.maxOutputTokens(input.model, input.outputTokenMax)) - return input.model.limit.input - ? Math.max(0, input.model.limit.input - reserved) - : Math.max(0, context - ProviderTransform.maxOutputTokens(input.model, input.outputTokenMax)) + if (!inputLimit) return 0 + + // This is input-side room for one more turn and the compaction instruction. Output has its own + // provider limit and does not consume the input window. + return Math.max(0, inputLimit - (input.cfg.compaction?.reserved ?? COMPACTION_BUFFER)) } // Collapse an assistant token record to a single "used" count, matching the historical isOverflow math @@ -208,10 +211,10 @@ export function overflowStatus(input: { const softLine = hardLine * reminderFraction() const fallbackLine = Math.min(hardLine, Math.max(softLine, hardLine - fallbackBuffer())) - // BUG-007: context=0 is the resolver fallback for an *unknown* limit — it must NOT be treated the + // BUG-007: a zero input/context fallback is an *unknown* limit — it must NOT be treated the // same as the user explicitly disabling compaction (auto=false). Return a typed "unavailable" result // so the caller can show a meaningful degraded state / fail-closed guard. - if (!input.model.limit.context) { + if (!physicalInputLimit(input.model)) { return { phase: "unavailable", reason: "context_limit_unknown", diff --git a/packages/deepagent-code/src/session/processor.ts b/packages/deepagent-code/src/session/processor.ts index 261b4263..c6f850ca 100644 --- a/packages/deepagent-code/src/session/processor.ts +++ b/packages/deepagent-code/src/session/processor.ts @@ -230,6 +230,121 @@ export class ToolSequenceTracker { export type PlanProtocolOutcome = "success" | "progress" | "no_progress" | "invalid" | "conflict" | "schema" +type PlanProtocolHistoryMessage = { + readonly info: { + readonly id: string + readonly role: string + readonly parentID?: string + readonly metadata?: unknown + readonly time?: unknown + } + readonly parts: readonly { + readonly id: string + readonly type: string + readonly tool?: string + readonly callID?: string + readonly state?: { + readonly status: string + readonly metadata?: unknown + readonly time?: unknown + } + }[] +} + +export const planProtocolActivityID = (metadata: unknown): string | undefined => { + if (!isRecord(metadata) || !isRecord(metadata.deepagent)) return undefined + const activityID = metadata.deepagent.planProtocolActivityID + return typeof activityID === "string" && activityID.trim() !== "" ? activityID : undefined +} + +export const withPlanProtocolActivity = (metadata: unknown, activityID: string) => ({ + ...(isRecord(metadata) ? metadata : {}), + deepagent: { + ...(isRecord(metadata) && isRecord(metadata.deepagent) ? metadata.deepagent : {}), + planProtocolActivityID: activityID, + }, +}) + +// Rebuild the activity-scoped counter from durable tool parts. Every root prompt gets a fresh +// activity ID; steers and compaction continuations retain it. This keeps recovery independent of +// filtered provider history and prevents a process restart from silently restoring the full budget. +export const restorePlanProtocolFailures = (messages: readonly PlanProtocolHistoryMessage[]): number => { + const numericTime = (value: unknown, key: string) => { + if (!isRecord(value) || typeof value[key] !== "number") return 0 + return value[key] as number + } + const declaredActivityIDs = new Set( + messages + .filter((message) => message.info.role === "user") + .map((message) => planProtocolActivityID(message.info.metadata)) + .filter((activityID): activityID is string => activityID !== undefined), + ) + const users = messages + .filter((message) => message.info.role === "user") + .map((message) => ({ + messageID: message.info.id, + activityID: + planProtocolActivityID(message.info.metadata) ?? + (declaredActivityIDs.has(message.info.id) ? message.info.id : undefined), + created: numericTime(message.info.time, "created"), + })) + const latest = users + .toSorted((left, right) => left.created - right.created || left.messageID.localeCompare(right.messageID)) + .at(-1) + if (latest?.activityID === undefined) return 0 + const activities = new Map( + users + .filter((user): user is typeof user & { activityID: string } => user.activityID !== undefined) + .map((user) => [user.messageID, user.activityID] as const), + ) + const attempts = messages + .filter( + (message) => + message.info.role === "assistant" && + message.info.parentID !== undefined && + activities.get(message.info.parentID) === latest.activityID, + ) + .flatMap((message) => + message.parts + .filter( + (part) => + part.type === "tool" && + part.tool === "plan" && + (part.state?.status === "completed" || part.state?.status === "error"), + ) + .map((part) => ({ + messageID: message.info.id, + messageCreated: numericTime(message.info.time, "created"), + settled: numericTime(part.state?.time, "end") || numericTime(message.info.time, "created"), + part, + })), + ) + .toSorted( + (left, right) => + left.settled - right.settled || + left.messageCreated - right.messageCreated || + left.messageID.localeCompare(right.messageID) || + left.part.id.localeCompare(right.part.id), + ) + const uniqueAttempts = [ + ...new Map( + attempts.map((attempt) => [attempt.messageID + "\x00" + (attempt.part.callID ?? attempt.part.id), attempt] as const), + ).values(), + ] + return uniqueAttempts + .reduce((consecutive, item) => { + const metadata = item.part.state && isRecord(item.part.state.metadata) ? item.part.state.metadata : undefined + const protocol = metadata?.plan_protocol + if (protocol === "success" || protocol === "progress") return 0 + if (!(protocol === "invalid" || protocol === "conflict" || protocol === "schema" || protocol === "no_progress")) + return consecutive + const ordinal = metadata?.plan_attempt_ordinal + return typeof ordinal === "number" && Number.isSafeInteger(ordinal) && ordinal > 0 + ? Math.max(consecutive + 1, ordinal) + : consecutive + 1 + }, 0) +} + /** * Activity-scoped Plan Protocol budget. A malformed or stale model plan is * recoverable once; the second consecutive violation terminates the activity @@ -239,7 +354,11 @@ export type PlanProtocolOutcome = "success" | "progress" | "no_progress" | "inva export class PlanProtocolTracker { private readonly pending = new Set() private readonly settled = new Set() - private consecutiveViolations = 0 + private consecutiveViolations: number + + constructor(consecutiveViolations = 0) { + this.consecutiveViolations = Math.max(0, Math.floor(consecutiveViolations)) + } start(callID: string, toolName: string): void { if (toolName === "plan") this.pending.add(callID) @@ -529,6 +648,33 @@ export const layer = Layer.effect( ) } + const persistMissingPlanToolCall = Effect.fn("SessionProcessor.persistMissingPlanToolCall")(function* ( + toolCallID: string, + protocol: { readonly consecutive: number } | undefined, + error: string, + ) { + if (protocol === undefined) return + const now = Date.now() + yield* session.updatePart({ + id: PartID.ascending(), + messageID: ctx.assistantMessage.id, + sessionID: ctx.sessionID, + type: "tool", + tool: "plan", + callID: toolCallID, + state: { + status: "error", + input: {}, + error, + metadata: { + plan_protocol: "schema", + plan_attempt_ordinal: protocol.consecutive, + }, + time: { start: now, end: now }, + }, + } satisfies SessionV1.ToolPart) + }) + const recordProcessorInput = ( toolCallID: string, toolName: string, @@ -1192,12 +1338,21 @@ export const layer = Layer.effect( // tool-call part exists. Both belong to the activity-level plan protocol budget; // otherwise a malformed plan response silently escapes the terminal rule. yield* recordProcessorInput(value.id, value.name, "tool-result", "schema_invalid") - yield* settlePlanProtocol( - value.id, - value.name, - value.name === "plan" ? "schema" : "invalid", - "missing_tool_call", - ) + const protocol = + value.name === "plan" ? ctx.planTracker?.settle(planTrackerCallID(value.id), "schema") : undefined + if (protocol) { + yield* recordProcessorValidation(value.id, "schema_invalid") + yield* persistMissingPlanToolCall(value.id, protocol, "Plan result arrived without a durable tool call.") + if (protocol.terminal) + yield* Effect.fail( + new SessionV1.PlanProtocolViolationError({ + message: "Plan protocol violation budget exhausted after two consecutive model plan failures.", + sessionID: ctx.sessionID, + attemptOrdinal: protocol.consecutive, + code: "missing_tool_call", + }), + ) + } return } if (value.result.type === "error") { @@ -1338,6 +1493,12 @@ export const layer = Layer.effect( const protocol = value.name === "plan" ? ctx.planTracker?.settle(planTrackerCallID(value.id), protocolOutcome) : undefined if (protocol && !schemaInvalid) yield* recordProcessorValidation(value.id, "semantic_invalid") + if (protocol) + yield* persistMissingPlanToolCall( + value.id, + toolCall ? undefined : protocol, + schemaInvalid ? "Plan tool input failed schema validation before a durable tool call was written." : value.message, + ) // TODO(v2): Temporary dual-write while migrating session messages to v2 events. if (mirrorAssistant) { const assistantMessageID = toolCall @@ -1720,6 +1881,7 @@ export const layer = Layer.effect( if (firstEvent) { firstEvent = false yield* providerAttempt?.streaming ?? Effect.void + yield* streamInput.requestReceipt?.streaming() ?? Effect.void } yield* handleEvent(event) }), @@ -1728,47 +1890,49 @@ export const layer = Layer.effect( Stream.runDrain, ) }) - const dispatched = providerAttempt - ? streamed - : streamed.pipe( - Effect.retry( - SessionRetry.policy({ - provider: input.model.providerID, - parse, - set: (info) => { - // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - const event = mirrorAssistant - ? events.publish(SessionEvent.Retried, { - sessionID: ctx.sessionID, - attempt: info.attempt, - error: { + const dispatched = + providerAttempt || streamInput.durableAttempt + ? streamed + : streamed.pipe( + Effect.retry( + SessionRetry.policy({ + provider: input.model.providerID, + parse, + set: (info) => { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + const event = mirrorAssistant + ? events.publish(SessionEvent.Retried, { + sessionID: ctx.sessionID, + attempt: info.attempt, + error: { + message: info.message, + isRetryable: true, + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + : Effect.void + return flushV2Fragments().pipe( + Effect.andThen(event), + Effect.andThen( + status.set(ctx.sessionID, { + type: "retry", + attempt: info.attempt, message: info.message, - isRetryable: true, - }, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - : Effect.void - return flushV2Fragments().pipe( - Effect.andThen(event), - Effect.andThen( - status.set(ctx.sessionID, { - type: "retry", - attempt: info.attempt, - message: info.message, - action: info.action, - next: info.next, - }), - ), - ) - }, - }), - ), - ) + action: info.action, + next: info.next, + }), + ), + ) + }, + }), + ), + ) const completed = dispatched.pipe( Effect.onInterrupt(() => Effect.gen(function* () { aborted = true yield* providerAttempt?.failed(new DOMException("Aborted", "AbortError")) ?? Effect.void + yield* streamInput.requestReceipt?.failed(new DOMException("Aborted", "AbortError")) ?? Effect.void if (!ctx.assistantMessage.error) { yield* halt(new DOMException("Aborted", "AbortError")) } @@ -1789,8 +1953,20 @@ export const layer = Layer.effect( ) : Effect.void, ), - Effect.tapError((error) => providerAttempt?.failed(error) ?? Effect.void), - Effect.tap(() => (providerAttempt && !ctx.assistantMessage.error ? providerAttempt.settled : Effect.void)), + Effect.tapError((error) => + Effect.all([ + providerAttempt?.failed(error) ?? Effect.void, + streamInput.requestReceipt?.failed(error) ?? Effect.void, + ]).pipe(Effect.asVoid), + ), + Effect.tap(() => + ctx.assistantMessage.error + ? Effect.void + : Effect.all([ + providerAttempt?.settled ?? Effect.void, + streamInput.requestReceipt?.settled() ?? Effect.void, + ]).pipe(Effect.asVoid), + ), ) yield* ( propagateSummaryViolation diff --git a/packages/deepagent-code/src/session/prompt-epoch.sql.ts b/packages/deepagent-code/src/session/prompt-epoch.sql.ts index c335f048..b4452ae6 100644 --- a/packages/deepagent-code/src/session/prompt-epoch.sql.ts +++ b/packages/deepagent-code/src/session/prompt-epoch.sql.ts @@ -26,6 +26,16 @@ export const SessionPromptEpochTable = sqliteTable("session_prompt_epoch", { retained_tail_start_id: text(), source_end_message_id: text(), checkpoint_hash: text(), + projection_version: integer(), + canonicalization_version: integer(), + base_message_count: integer(), + effective_history_hash: text(), + first_window_id: text(), + previous_window_id: text(), + window_id: text(), + world_state_baseline_hash: text(), + authority_state: text().$type<"legacy_pending" | "ready" | "recovery_required">(), + recovery_reason: text(), reason: text().$type().notNull(), created_at: integer().notNull(), retired_at: integer(), diff --git a/packages/deepagent-code/src/session/prompt-epoch.ts b/packages/deepagent-code/src/session/prompt-epoch.ts index fa386f75..c18ed0f9 100644 --- a/packages/deepagent-code/src/session/prompt-epoch.ts +++ b/packages/deepagent-code/src/session/prompt-epoch.ts @@ -14,6 +14,8 @@ import { Effect, Layer, Context } from "effect" import { SessionID, MessageID } from "./schema" import { and, eq } from "drizzle-orm" import { SessionPromptEpochTable, type PromptEpochReason } from "./prompt-epoch.sql" +import { HistoryAuthority } from "./history-authority" +import { MessageTable, SessionPromptEpochMessageTable } from "@deepagent-code/core/session/sql" // ── Types ──────────────────────────────────────────────────────────────────── @@ -26,6 +28,16 @@ export interface PromptEpochRow { retained_tail_start_id: string | null source_end_message_id: string | null checkpoint_hash: string | null + projection_version: number | null + canonicalization_version: number | null + base_message_count: number | null + effective_history_hash: string | null + first_window_id: string | null + previous_window_id: string | null + window_id: string | null + world_state_baseline_hash: string | null + authority_state: "legacy_pending" | "ready" | "recovery_required" | null + recovery_reason: string | null reason: PromptEpochReason created_at: number retired_at: number | null @@ -59,8 +71,12 @@ export interface PromptEpochInterface { checkpointUserID: MessageID checkpointAssistantID: MessageID checkpointHash: string + baseMessageCount: number + effectiveHistoryHash: string retainedTailStartID?: MessageID sourceEndMessageID?: MessageID + worldStateBaselineHash?: string + replacementMessageIDs: readonly MessageID[] }) => Effect.Effect } @@ -81,6 +97,12 @@ function getActiveInTransaction(tx: Pick, sessionID: Sess export function activateInTransaction(tx: Transaction, input: Parameters[0]) { return Effect.gen(function* () { + if ( + input.replacementMessageIDs.length !== input.baseMessageCount || + new Set(input.replacementMessageIDs).size !== input.replacementMessageIDs.length + ) { + return yield* Effect.die(new Error(`invalid replacement membership for ${input.sessionID}`)) + } const current = yield* getActiveInTransaction(tx, input.sessionID) if (!current || current.epoch !== input.fromEpoch) return undefined @@ -108,11 +130,34 @@ export function activateInTransaction(tx: Transaction, input: Parameters 0) { + yield* tx + .insert(SessionPromptEpochMessageTable) + .values( + input.replacementMessageIDs.map((messageID, ordinal) => ({ + session_id: input.sessionID, + prompt_epoch: next.epoch, + ordinal, + message_id: messageID, + })), + ) + .run() + } return next }) } @@ -134,6 +179,21 @@ const layer = Layer.effect( const existing = yield* getActiveInTransaction(tx, sessionID) if (existing) return existing + const physicalMessage = yield* tx + .select({ id: MessageTable.id }) + .from(MessageTable) + .where(eq(MessageTable.session_id, sessionID)) + .limit(1) + .get() + if (physicalMessage) { + return yield* Effect.die( + new Error( + `PromptEpoch bootstrap cannot authorize non-empty session ${sessionID}; use prompt history migration`, + ), + ) + } + + const windowID = HistoryAuthority.windowID() const row: PromptEpochRow = { session_id: sessionID, epoch: 0, @@ -143,6 +203,16 @@ const layer = Layer.effect( retained_tail_start_id: null, source_end_message_id: null, checkpoint_hash: null, + projection_version: HistoryAuthority.PROJECTION_VERSION, + canonicalization_version: HistoryAuthority.CANONICALIZATION_VERSION, + base_message_count: 0, + effective_history_hash: HistoryAuthority.hash([]), + first_window_id: windowID, + previous_window_id: null, + window_id: windowID, + world_state_baseline_hash: null, + authority_state: "ready", + recovery_reason: null, reason: "bootstrap", created_at: Date.now(), retired_at: null, @@ -156,15 +226,8 @@ const layer = Layer.effect( ) .pipe(Effect.orDie) - const activate = (input: { - sessionID: SessionID - fromEpoch: number - checkpointUserID: MessageID - checkpointAssistantID: MessageID - checkpointHash: string - retainedTailStartID?: MessageID - sourceEndMessageID?: MessageID - }) => db.transaction((tx) => activateInTransaction(tx, input), { behavior: "immediate" }).pipe(Effect.orDie) + const activate = (input: Parameters[0]) => + db.transaction((tx) => activateInTransaction(tx, input), { behavior: "immediate" }).pipe(Effect.orDie) return Service.of({ getActive, bootstrap, activate }) }), diff --git a/packages/deepagent-code/src/session/prompt.ts b/packages/deepagent-code/src/session/prompt.ts index 23e5d8a2..a4fab3dd 100644 --- a/packages/deepagent-code/src/session/prompt.ts +++ b/packages/deepagent-code/src/session/prompt.ts @@ -5,6 +5,7 @@ import { SessionV1 } from "@deepagent-code/core/v1/session" import os from "os" import { SessionID, MessageID, PartID } from "./schema" import { MessageV2 } from "./message-v2" +import { HistoryAuthority } from "./history-authority" import { Log } from "@deepagent-code/core/util/log" import { Global } from "@deepagent-code/core/global" import { SessionRevert } from "./revert" @@ -95,6 +96,7 @@ import { archiveSessionOnCompletion } from "@/wiki/session-archive" import { EventV2Bridge } from "@/event-v2-bridge" import { Database } from "@deepagent-code/core/database/database" import { SessionToolRequestReceiptTable } from "./tool-request-receipt.sql" +import { CompactionArtifactTable, CompactionRunTable } from "./compaction-sql" import { SessionToolArgumentReceiptTable, type ToolArgumentReceiptLayer, @@ -119,14 +121,14 @@ import { } from "@deepagent-code/core/session/prompt" import { Reference } from "@/reference/reference" import * as DateTime from "effect/DateTime" -import { and, eq, max } from "drizzle-orm" -import { SessionTable, TaskRunTable } from "@deepagent-code/core/session/sql" +import { and, eq, inArray, isNull, max, ne, or } from "drizzle-orm" +import { SessionHistoryStateTable, SessionTable, TaskRunTable } from "@deepagent-code/core/session/sql" +import { SessionPromptEpochTable } from "./prompt-epoch.sql" import { referencePromptMetadata, referenceTextPart } from "./prompt/reference" import { SessionReminders } from "./reminders" import { SessionTools } from "./tools" import { LLMEvent } from "@deepagent-code/llm" import { ConversationLogWriter } from "./conversation-log-writer" -import { collectVolatileFacts, refreshWorldState } from "./context-ledger" import { ToolSemanticFingerprint } from "@/tool/semantic-fingerprint" import { deliverTaskNotifications, recoverExpiredTaskRuns, classifyOnStartup, orderedShutdown } from "@/tool/task-run" // L10: durable control plane daemons @@ -139,6 +141,7 @@ import { PRQueue } from "@/agent/pr-queue" import { registerDisposer, registerInitializer } from "@/effect/instance-registry" import { EventRouteRef, InstanceRef } from "@/effect/instance-ref" import { InstanceStore } from "@/project/instance-store" +import type { InstanceContext } from "@/project/instance-context" import { acquireDurableExecutorLease, releaseDurableExecutorLease, @@ -152,6 +155,268 @@ globalThis.AI_SDK_LOG_WARNINGS = false const decodeMessageInfo = Schema.decodeUnknownExit(SessionV1.Info) const decodeMessagePart = Schema.decodeUnknownExit(SessionV1.Part) +const providerReceiptOwner = `${process.pid}:${randomUUID()}` + +export const recoverProviderReceiptsOnStartup = Effect.fn("SessionPrompt.recoverProviderReceiptsOnStartup")( + function* () { + const sessions = yield* Session.Service + const { db } = yield* Database.Service + const staleOwner = or( + isNull(SessionToolRequestReceiptTable.owner_token), + ne(SessionToolRequestReceiptTable.owner_token, providerReceiptOwner), + ) + const lostUnadmittedContinuations = yield* db + .select({ + sessionID: CompactionRunTable.session_id, + messageID: CompactionArtifactTable.message_id, + }) + .from(CompactionRunTable) + .innerJoin(CompactionArtifactTable, eq(CompactionArtifactTable.run_id, CompactionRunTable.run_id)) + .where( + and( + eq(CompactionRunTable.state, "committed"), + eq(CompactionRunTable.continuation_state, "pending"), + eq(CompactionArtifactTable.state, "committed"), + inArray(CompactionArtifactTable.kind, ["replay", "continue"] as const), + ), + ) + .all() + .pipe(Effect.orDie) + const lostUndispatchedReceipts = yield* db + .select({ + receiptID: SessionToolRequestReceiptTable.receipt_id, + sessionID: SessionToolRequestReceiptTable.session_id, + assistantMessageID: SessionToolRequestReceiptTable.assistant_message_id, + }) + .from(SessionToolRequestReceiptTable) + .where( + and(inArray(SessionToolRequestReceiptTable.provider_state, ["preparing", "prepared"] as const), staleOwner), + ) + .all() + .pipe(Effect.orDie) + const lostStartedReceipts = yield* db + .select({ + receiptID: SessionToolRequestReceiptTable.receipt_id, + sessionID: SessionToolRequestReceiptTable.session_id, + assistantMessageID: SessionToolRequestReceiptTable.assistant_message_id, + }) + .from(SessionToolRequestReceiptTable) + .where( + and(inArray(SessionToolRequestReceiptTable.provider_state, ["dispatching", "streaming"] as const), staleOwner), + ) + .all() + .pipe(Effect.orDie) + const unresolvedContinuationReceipts = yield* db + .select({ + receiptID: SessionToolRequestReceiptTable.receipt_id, + sessionID: SessionToolRequestReceiptTable.session_id, + assistantMessageID: SessionToolRequestReceiptTable.assistant_message_id, + }) + .from(CompactionRunTable) + .innerJoin( + SessionToolRequestReceiptTable, + eq(SessionToolRequestReceiptTable.receipt_id, CompactionRunTable.continuation_receipt_id), + ) + .where( + and( + eq(CompactionRunTable.state, "committed"), + eq(CompactionRunTable.continuation_state, "indeterminate"), + isNull(SessionToolRequestReceiptTable.response_fingerprint), + ), + ) + .all() + .pipe(Effect.orDie) + const unresolvedContinuationSessions = yield* db + .select({ sessionID: CompactionRunTable.session_id }) + .from(CompactionRunTable) + .where(and(eq(CompactionRunTable.state, "committed"), eq(CompactionRunTable.continuation_state, "indeterminate"))) + .all() + .pipe(Effect.orDie) + yield* db + .transaction( + (tx) => + Effect.gen(function* () { + const undispatched = tx + .select({ receipt_id: SessionToolRequestReceiptTable.receipt_id }) + .from(SessionToolRequestReceiptTable) + .where( + and( + inArray(SessionToolRequestReceiptTable.provider_state, ["preparing", "prepared"] as const), + staleOwner, + ), + ) + const started = tx + .select({ receipt_id: SessionToolRequestReceiptTable.receipt_id }) + .from(SessionToolRequestReceiptTable) + .where( + and( + inArray(SessionToolRequestReceiptTable.provider_state, ["dispatching", "streaming"] as const), + staleOwner, + ), + ) + const now = Date.now() + yield* tx + .update(CompactionRunTable) + .set({ + continuation_state: "pending", + continuation_receipt_id: null, + continuation_admitted_at: null, + continuation_dispatching_at: null, + continuation_terminal_at: null, + continuation_error_code: "provider_not_dispatched_before_process_restart", + continuation_wakeup_at: null, + }) + .where( + and( + eq(CompactionRunTable.state, "committed"), + eq(CompactionRunTable.continuation_state, "admitted"), + inArray(CompactionRunTable.continuation_receipt_id, undispatched), + ), + ) + .run() + yield* tx + .update(CompactionRunTable) + .set({ + continuation_state: "indeterminate", + continuation_terminal_at: now, + continuation_error_code: "provider_started_outcome_unknown_after_process_restart", + }) + .where( + and( + eq(CompactionRunTable.state, "committed"), + eq(CompactionRunTable.continuation_state, "dispatching"), + inArray(CompactionRunTable.continuation_receipt_id, started), + ), + ) + .run() + yield* tx + .update(SessionToolRequestReceiptTable) + .set({ + provider_state: "indeterminate_after_crash", + terminal_at: now, + request_error_code: "provider_started_outcome_unknown_after_process_restart", + }) + .where( + and( + inArray(SessionToolRequestReceiptTable.provider_state, ["dispatching", "streaming"] as const), + staleOwner, + ), + ) + .run() + yield* tx + .update(SessionToolRequestReceiptTable) + .set({ + provider_state: "failed", + terminal_at: now, + request_error_code: "provider_not_dispatched_before_process_restart", + }) + .where( + and( + inArray(SessionToolRequestReceiptTable.provider_state, ["preparing", "prepared"] as const), + staleOwner, + ), + ) + .run() + yield* Effect.forEach( + [ + ...new Set([ + ...lostStartedReceipts.map((receipt) => receipt.sessionID), + ...unresolvedContinuationSessions.map((continuation) => continuation.sessionID), + ]), + ], + (sessionID) => + Effect.gen(function* () { + const reason = "provider outcome is unknown after process restart" + yield* tx + .update(SessionPromptEpochTable) + .set({ authority_state: "recovery_required", recovery_reason: reason }) + .where( + and( + eq(SessionPromptEpochTable.session_id, sessionID), + eq(SessionPromptEpochTable.state, "active"), + ), + ) + .run() + yield* tx + .insert(SessionHistoryStateTable) + .values([ + { + session_id: SessionID.make(sessionID), + state: "recovery_required", + reason, + time_created: now, + time_updated: now, + }, + ]) + .onConflictDoUpdate({ + target: SessionHistoryStateTable.session_id, + set: { state: "recovery_required", reason, time_updated: now }, + }) + .run() + }), + { discard: true }, + ) + }), + { behavior: "immediate" }, + ) + .pipe(Effect.orDie) + yield* Effect.forEach( + [...lostUndispatchedReceipts, ...lostStartedReceipts, ...unresolvedContinuationReceipts], + (receipt) => + Effect.gen(function* () { + if (!receipt.assistantMessageID) return + const messages = yield* sessions.messages({ sessionID: SessionID.make(receipt.sessionID) }).pipe(Effect.orDie) + const assistant = messages.find( + (message) => message.info.id === receipt.assistantMessageID && message.info.role === "assistant", + ) + if (!assistant || assistant.info.role !== "assistant" || assistant.info.time.completed) return + yield* sessions.updateMessage({ + ...assistant.info, + finish: "error", + error: new NamedError.Unknown({ + message: + lostStartedReceipts.some((started) => started.receiptID === receipt.receiptID) || + unresolvedContinuationReceipts.some((unresolved) => unresolved.receiptID === receipt.receiptID) + ? `Provider request ${receipt.receiptID} may have been dispatched before restart; explicit recovery is required.` + : `Provider request ${receipt.receiptID} was not dispatched before restart; the durable continuation will be retried.`, + }).toObject(), + time: { ...assistant.info.time, completed: Date.now() }, + }) + }), + { discard: true }, + ) + yield* Effect.forEach( + lostUnadmittedContinuations, + (continuation) => + Effect.gen(function* () { + const messages = yield* sessions + .messages({ sessionID: SessionID.make(continuation.sessionID) }) + .pipe(Effect.orDie) + yield* Effect.forEach( + messages.filter( + (message): message is SessionV1.WithParts & { info: SessionV1.Assistant } => + message.info.role === "assistant" && + message.info.parentID === continuation.messageID && + !message.info.time.completed, + ), + (assistant) => + sessions.updateMessage({ + ...assistant.info, + finish: "error", + error: new NamedError.Unknown({ + message: + "The continuation process stopped before durable provider admission; the pending continuation will be retried.", + }).toObject(), + time: { ...assistant.info.time, completed: Date.now() }, + }), + { discard: true }, + ) + }), + { discard: true }, + ) + }, +) + // Coerce a structurally-valid Format value into a Format INSTANCE (see the call site in prompt()). const decodeFormatSync = Schema.decodeUnknownSync(SessionV1.Format) @@ -175,7 +440,14 @@ function buildStructuredOutputSystemPrompt(schema: Record): string return `IMPORTANT: The user has requested structured output. You MUST use the StructuredOutput tool to provide your final response. Do NOT respond with plain text - you MUST call the StructuredOutput tool with your answer formatted according to the schema.${fieldHint}` } -function buildStructuredOutputRuntimeTail(format: SessionV1.OutputFormat, finalizerMode: boolean): string { +function buildStructuredOutputRuntimeTail( + format: SessionV1.OutputFormat, + finalizerMode: boolean, + finalizerAllowsText = false, +): string { + if (finalizerAllowsText) { + return "This is a bounded finalizer turn. Read the supplied research result and return exactly one JSON value. No research, Markdown, explanatory prose, or tool use is permitted." + } if (format.type !== "json_schema") return "" return [ buildStructuredOutputSystemPrompt(format.schema), @@ -209,6 +481,13 @@ function isStructuredFinalizer(metadata: unknown) { return isRecord(metadata.deepagent.structured_finalizer) } +function structuredFinalizerAllowsText(metadata: unknown) { + if (!isRecord(metadata)) return false + if (!isRecord(metadata.deepagent)) return false + if (!isRecord(metadata.deepagent.structured_finalizer)) return false + return metadata.deepagent.structured_finalizer.allow_text === true +} + function noninteractiveTaskActivity(metadata: unknown) { if (!isRecord(metadata)) return false if (!isRecord(metadata.deepagent)) return undefined @@ -282,7 +561,9 @@ const promptInputToPrompt = (parts: PromptInput["parts"]): Effect.Effect Effect.Effect readonly prompt: (input: PromptInput) => Effect.Effect - readonly promptAsync: (input: PromptInput) => Effect.Effect + readonly promptAsync: ( + input: PromptInput, + ) => Effect.Effect readonly prepareTaskInput: ( input: PromptInput, timeCreated: number, @@ -334,16 +615,18 @@ export interface Interface { export class Service extends Context.Service()("@deepagent-code/SessionPrompt") {} +export type PromptAdmissionReceipt = { + readonly messageID: MessageID + readonly delivery: SessionPromptIntent.Delivery +} + type PromptLifecycle = { readonly intent?: SessionPromptIntent.Receipt & { readonly state: "admitting" readonly ownerToken: string readonly messageID: MessageID } - readonly ready: (input: { - readonly messageID: MessageID - readonly delivery: SessionPromptIntent.Delivery - }) => Effect.Effect + readonly ready: (input: PromptAdmissionReceipt) => Effect.Effect } type ExecutePrompt = ( @@ -407,6 +690,7 @@ export const layer = Layer.effect( ) const database = yield* Database.Service const { db } = database + yield* recoverProviderReceiptsOnStartup() const activeFederatedContexts = new Map() const settleFederatedActivity = (sessionID: SessionID, state: "settled" | "failed" | "interrupted") => Effect.gen(function* () { @@ -794,6 +1078,10 @@ export const layer = Layer.effect( time: { created: Date.now() }, agent: lastUser.agent, model: lastUser.model, + metadata: SessionProcessor.withPlanProtocolActivity( + undefined, + SessionProcessor.planProtocolActivityID(lastUser.metadata) ?? lastUser.id, + ), } yield* sessions.updateMessage(summaryUserMsg) yield* sessions.updatePart({ @@ -1367,8 +1655,9 @@ export const layer = Layer.effect( : undefined const variant = input.variant ?? (ag.variant && full?.variants?.[ag.variant] ? ag.variant : undefined) + const messageID = input.messageID ?? MessageID.ascending() const info: SessionV1.User = { - id: input.messageID ?? MessageID.ascending(), + id: messageID, role: "user", sessionID: input.sessionID, time: { created: options?.timeCreated ?? Date.now() }, @@ -1389,7 +1678,7 @@ export const layer = Layer.effect( // is idempotent for callers that already pass an instance. `withDecodingDefault` also fills // retryCount. `format` is validated on the way in (PromptInput), so this decode never fails. format: input.format === undefined ? undefined : decodeFormatSync(input.format), - metadata: input.metadata, + metadata: SessionProcessor.withPlanProtocolActivity(input.metadata, messageID), } if (persist) yield* Effect.addFinalizer(() => instruction.clear(info.id)) @@ -1876,6 +2165,8 @@ export const layer = Layer.effect( input: PromptInput, lifecycle?: PromptLifecycle, ) { + yield* sessions.recoverForks() + yield* sessions.assertRunnable(input.sessionID).pipe(Effect.orDie) const notification = taskNotification(input.metadata) if (notification && input.messageID) { const existing = yield* MessageV2.get({ sessionID: input.sessionID, messageID: input.messageID }).pipe( @@ -2184,10 +2475,25 @@ export const layer = Layer.effect( // revert either follows the completed append or supersedes the steer before it can write anything. const steerPartID = (messageID: MessageID, suffix?: string) => PartID.make("prt_" + messageID.slice("msg_".length) + (suffix ?? "")) - const drainSteers = Effect.fn("SessionPrompt.drainSteers")(function* (sessionID: SessionID) { + const drainSteers = Effect.fn("SessionPrompt.drainSteers")(function* (sessionID: SessionID, startActivity = false) { if (!flags.v4Steering) return [] as SessionMessage.ID[] const pending = yield* steerBuffer.pending(sessionID) if (pending.length === 0) return [] as SessionMessage.ID[] + const activityID = + (startActivity + ? undefined + : (yield* MessageV2.stream(sessionID).pipe(Effect.provideService(Database.Service, database), Effect.orDie)) + .filter((message) => message.info.role === "user") + .toSorted( + (left, right) => + left.info.time.created - right.info.time.created || left.info.id.localeCompare(right.info.id), + ) + .map((message) => + SessionProcessor.planProtocolActivityID( + message.info.role === "user" ? message.info.metadata : undefined, + ), + ) + .findLast((value) => value !== undefined)) ?? pending[0]!.id const current = yield* db .select({ agent: SessionTable.agent, model: SessionTable.model }) .from(SessionTable) @@ -2205,18 +2511,34 @@ export const layer = Layer.effect( const variant = "variant" in resolved ? resolved.variant : undefined const persisted: SessionMessage.ID[] = [] for (const admitted of pending) { + const materializedAt = yield* steerBuffer + .materializationTime(admitted) + .pipe(Effect.catchTag("SessionMutationEpoch.Stale", () => Effect.succeed(undefined))) + if (materializedAt === undefined) continue const agentName = admitted.prompt.agents?.[0]?.name ?? defaultAgent const info: SessionV1.User = { id: MessageID.make(admitted.id), role: "user", sessionID, - time: { created: admitted.timeCreated }, + time: { created: materializedAt }, agent: agentName, model: { providerID: resolved.providerID, modelID: resolved.modelID, ...(variant ? { variant } : {}), }, + metadata: SessionProcessor.withPlanProtocolActivity( + admitted.correlationID + ? { + deepagent: { + promptAdmission: { + clientMessageID: admitted.correlationID, + }, + }, + } + : undefined, + activityID, + ), } const parts: SessionV1.Part[] = [] if (admitted.prompt.text.length > 0) @@ -2305,8 +2627,9 @@ export const layer = Layer.effect( text: string, model: { providerID: ProviderV2.ID; modelID: ModelV2.ID }, agentName: string, + activityID: string, ) => Effect.Effect = Effect.fn("SessionPrompt.injectTailReminder")( - function* (sessionID, text, model, agentName) { + function* (sessionID, text, model, agentName, activityID) { const msg = yield* sessions.updateMessage({ id: MessageID.ascending(), role: "user", @@ -2314,6 +2637,7 @@ export const layer = Layer.effect( agent: agentName, model, time: { created: Date.now() }, + metadata: SessionProcessor.withPlanProtocolActivity(undefined, activityID), }) yield* sessions.updatePart({ id: PartID.ascending(), @@ -2365,26 +2689,6 @@ export const layer = Layer.effect( "", ].join("\n") - // V4.0.1 P1 (§3.3) — post-hard-compaction World State re-injection. After a hard compaction the - // (now-narrowed) summary deliberately dropped file/env/diagnostics; this re-injects their LATEST - // values as a TAIL user block (reuses the SAME injectTailReminder primitive — never the static system - // prefix, so prompt cache is preserved) so the model sees current truth, not a stale summary value. - // Gated by worldStateReinjection (the same flag that narrowed the summary — no information hole). - // Bounded IO: git + env only, collected once per compaction. Default-safe: any defect ⇒ no-op. - const injectWorldStateTail: ( - sessionID: SessionID, - workspacePath: string | undefined, - model: { providerID: ProviderV2.ID; modelID: ModelV2.ID }, - agentName: string, - ) => Effect.Effect = Effect.fn("SessionPrompt.injectWorldStateTail")( - function* (sessionID, workspacePath, model, agentName) { - if (!workspacePath) return - const facts = yield* collectVolatileFacts(workspacePath) - const rendered = yield* refreshWorldState({ workspacePath, facts }) - if (rendered.trim().length > 0) yield* injectTailReminder(sessionID, rendered, model, agentName) - }, - ) - const runLoop: (sessionID: SessionID, drainFirst?: boolean) => Effect.Effect = Effect.fn( "SessionPrompt.run", )( @@ -2403,7 +2707,10 @@ export const layer = Layer.effect( // that occurs when the model repeatedly guesses wrong field names (e.g. "summary" instead // of "module" for ResearchResult) and the AI SDK silently rejects them before execute(). let structuredFailedAttempts = 0 + yield* sessions.recoverForks() + yield* sessions.assertRunnable(sessionID).pipe(Effect.orDie) const session = yield* sessions.get(sessionID).pipe(Effect.orDie) + yield* compaction.recover(sessionID) const sessionFederationEligibility = ContextFederationRollout.resolveProject( federationRollout, session.projectID, @@ -2427,12 +2734,23 @@ export const layer = Layer.effect( // F1: one tracker per durable user activity; shared by every provider step (processor // instance) created in this runLoop call so cross-message ABABAB/ABCABC/... patterns - // are detectable. Reset implicitly on the next runLoop invocation (new variable). + // are detectable. const toolSequenceTracker = new SessionProcessor.ToolSequenceTracker() - const planProtocolTracker = new SessionProcessor.PlanProtocolTracker() - const initialMessages = yield* MessageV2.promptHistoryEffect(sessionID).pipe( + const initialMessages = yield* MessageV2.promptControlHistoryEffect(sessionID).pipe( Effect.provideService(Database.Service, database), + Effect.orDie, + ) + // Plan failures are protocol state, not advisory loop state. Rebuild them from unfiltered + // durable parts so a committed compaction or process restart cannot restore the attempt budget. + const planProtocolTracker = new SessionProcessor.PlanProtocolTracker( + drainFirst + ? 0 + : yield* MessageV2.stream(sessionID).pipe( + Effect.provideService(Database.Service, database), + Effect.map(SessionProcessor.restorePlanProtocolFailures), + Effect.orDie, + ), ) const initialUser = MessageV2.latest(initialMessages).user const initialFinalizer = isStructuredFinalizer(initialUser?.metadata) @@ -2480,12 +2798,15 @@ export const layer = Layer.effect( // top-of-loop finish check (`lastUser.id < lastAssistant.id`) to keep looping — so a steer that // arrived after the model said "done" is naturally absorbed on this next pass. if (step > 0 || drainFirst) { - const absorbed = yield* drainSteers(sessionID) - pendingContextInputIds = [...pendingContextInputIds, ...absorbed] + if (!(yield* compaction.hasPending(sessionID))) { + const absorbed = yield* drainSteers(sessionID, drainFirst && step === 0) + pendingContextInputIds = [...pendingContextInputIds, ...absorbed] + } } - let msgs = yield* MessageV2.promptHistoryEffect(sessionID).pipe( + let msgs = yield* MessageV2.promptControlHistoryEffect(sessionID).pipe( Effect.provideService(Database.Service, database), + Effect.orDie, ) // Archive everything settled so far (user turn + any completed assistant/tool parts from the @@ -2497,6 +2818,7 @@ export const layer = Layer.effect( if (!lastUser) throw new Error("No user message found in stream. This should never happen.") const finalizerMode = isStructuredFinalizer(lastUser.metadata) + const finalizerAllowsText = structuredFinalizerAllowsText(lastUser.metadata) const lastAssistantMsg = msgs.findLast( (msg) => msg.info.role === "assistant" && msg.info.id === lastAssistant?.id, @@ -2542,6 +2864,7 @@ export const layer = Layer.effect( : OUTPUT_CONTINUE_TAIL_TEXT, lastUser.model, lastUser.agent, + SessionProcessor.planProtocolActivityID(lastUser.metadata) ?? lastUser.id, ) yield* slog.info("output soft-landing: continuing after length cutoff", { continuation: done + 1, @@ -2602,18 +2925,12 @@ export const layer = Layer.effect( if (task?.type === "compaction") { const result = yield* compaction.process({ messages: msgs, - parentID: lastUser.id, + parentID: MessageID.make(task.messageID), sessionID, auto: task.auto, overflow: task.overflow, }) if (result === "stop") break - // Inject volatile state only after the compaction summary is durable. Injecting it when the - // compaction marker is created makes this synthetic user message the next iteration's lastUser, - // so compaction.process pairs the summary with the wrong parent and the marker never becomes a - // completed compaction boundary. - if (task.auto && flags.worldStateReinjection) - yield* injectWorldStateTail(sessionID, ctx.directory, lastUser.model, lastUser.agent) continue } @@ -2634,7 +2951,13 @@ export const layer = Layer.effect( outputTokenMax: flags.outputTokenMax, }) ) { - yield* compaction.create({ sessionID, agent: lastUser.agent, model: lastUser.model, auto: true }) + yield* compaction.create({ + sessionID, + agent: lastUser.agent, + model: lastUser.model, + auto: true, + activityID: SessionProcessor.planProtocolActivityID(lastUser.metadata) ?? lastUser.id, + }) continue } } else { @@ -2664,13 +2987,25 @@ export const layer = Layer.effect( const { action, nextState } = softLandingDecision({ status, state: slState, step }) if (action === "reminder") { yield* writeSoftLandingState(sessionID, nextState) - yield* injectTailReminder(sessionID, REMINDER_TAIL_TEXT, lastUser.model, lastUser.agent) + yield* injectTailReminder( + sessionID, + REMINDER_TAIL_TEXT, + lastUser.model, + lastUser.agent, + SessionProcessor.planProtocolActivityID(lastUser.metadata) ?? lastUser.id, + ) yield* slog.info("soft-landing reminder injected", { used: status.used, softLine: status.softLine }) continue } if (action === "fallback") { yield* writeSoftLandingState(sessionID, nextState) - yield* injectTailReminder(sessionID, fallbackTailText(sessionID), lastUser.model, lastUser.agent) + yield* injectTailReminder( + sessionID, + fallbackTailText(sessionID), + lastUser.model, + lastUser.agent, + SessionProcessor.planProtocolActivityID(lastUser.metadata) ?? lastUser.id, + ) yield* slog.info("soft-landing fallback injected", { used: status.used, fallbackLine: status.fallbackLine, @@ -2684,7 +3019,13 @@ export const layer = Layer.effect( // history boundary marker — PromptEpoch is the sole history authority and is only // activated by compaction.process() on confirmed successful summary (CompactionCommitted). yield* writeSoftLandingState(sessionID, nextState) - yield* compaction.create({ sessionID, agent: lastUser.agent, model: lastUser.model, auto: true }) + yield* compaction.create({ + sessionID, + agent: lastUser.agent, + model: lastUser.model, + auto: true, + activityID: SessionProcessor.planProtocolActivityID(lastUser.metadata) ?? lastUser.id, + }) continue } if (action === "guard") { @@ -2708,6 +3049,39 @@ export const layer = Layer.effect( } const maxSteps = Math.min(agent.steps ?? Infinity, taskActivity?.maxSteps ?? Infinity) const isLastStep = step >= maxSteps + const promptAuthority = finalizerMode + ? undefined + : yield* MessageV2.promptHistoryProjectionEffect(sessionID).pipe( + Effect.provideService(Database.Service, database), + Effect.orDie, + ) + const receiptAuthority = + promptAuthority ?? + (yield* MessageV2.promptHistoryProjectionEffect(sessionID).pipe( + Effect.provideService(Database.Service, database), + Effect.orDie, + )) + yield* sessions.assertRunnable(sessionID).pipe(Effect.orDie) + if (promptAuthority && HistoryAuthority.hash(msgs) !== promptAuthority.effectiveHistoryHash) { + return yield* Effect.die(new Error(`Prompt history changed during provider request assembly: ${sessionID}`)) + } + const worldState = finalizerMode + ? undefined + : yield* MessageV2.promptWorldStateProjectionEffect(sessionID).pipe( + Effect.provideService(Database.Service, database), + Effect.orDie, + ) + if ( + worldState && + promptAuthority && + (worldState.epoch !== promptAuthority.epoch || + worldState.windowID !== promptAuthority.window.windowID || + worldState.effectiveHistoryHash !== promptAuthority.effectiveHistoryHash) + ) { + return yield* Effect.die( + new Error(`World State authority changed during provider request assembly: ${sessionID}`), + ) + } if (!finalizerMode) { msgs = yield* SessionReminders.apply({ messages: msgs, agent, session }).pipe( Effect.provideService(RuntimeFlags.Service, flags), @@ -2715,7 +3089,6 @@ export const layer = Layer.effect( Effect.provideService(Session.Service, sessions), ) } - const msg: SessionV1.Assistant = { id: MessageID.ascending(), parentID: lastUser.id, @@ -2733,7 +3106,7 @@ export const layer = Layer.effect( } yield* sessions.updateMessage(msg) - if (finalizerDecision?.capability === "unsupported") { + if (finalizerDecision?.capability === "unsupported" && !finalizerAllowsText) { msg.error = new NamedError.Unknown({ message: `[${finalizerDecision.reason}] Structured finalization requires tool-call capability.`, }).toObject() @@ -2757,6 +3130,18 @@ export const layer = Layer.effect( yield* sessions.updateMessage(msg) }) + const receiptTerminal: { + value?: { state: "settled" } | { state: "failed"; errorCode: string } + } = {} + const receiptFinalizer: { value?: () => Effect.Effect } = {} + const finalizeInterruptedTurn = Effect.uninterruptible( + Effect.gen(function* () { + yield* finalizeInterruptedAssistant + receiptTerminal.value ??= { state: "failed", errorCode: "AbortError" } + if (receiptFinalizer.value) yield* receiptFinalizer.value() + }), + ) + const handle = yield* processor .create({ assistantMessage: msg, @@ -2767,7 +3152,7 @@ export const layer = Layer.effect( loopPolicy: finalizerMode || taskActivity ? "error" : "ask", noProgressLimit: taskActivity?.maxNoProgress, }) - .pipe(Effect.onInterrupt(() => finalizeInterruptedAssistant)) + .pipe(Effect.onInterrupt(() => finalizeInterruptedTurn)) const outcome: "break" | "continue" = yield* Effect.gen(function* () { sessionFederationRollout = yield* activateFederation() @@ -2809,17 +3194,17 @@ export const layer = Layer.effect( ToolSemanticFingerprint.resolveResult(tools[toolName], result), ) - if (step === 1 && !finalizerMode) - yield* summary.summarize({ sessionID, messageID: lastUser.id }).pipe(Effect.ignore, Effect.forkIn(scope)) - - if (!finalizerMode) yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) + const providerHistory = finalizerMode + ? msgs + : (yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: structuredClone(msgs) })) + .messages // PR-1: Compute the terminal boundary for cross-model reasoning projection. // The most recent settled assistant message (has finish, no pending tool calls) // defines the boundary. Same-model reasoning remains append-only because removing // signed thinking after settlement rewrites the provider prefix and busts its cache. let terminalBoundaryID: MessageID | undefined - for (const msg of msgs) { + for (const msg of providerHistory) { if (msg.info.role !== "assistant") continue if (!msg.info.finish) continue const hasPendingToolCalls = msg.parts.some( @@ -2833,11 +3218,20 @@ export const layer = Layer.effect( } const format = lastUser.format ?? { type: "text" as const } - const modelMsgs = yield* MessageV2.toModelMessagesEffect( - finalizerMode ? msgs.filter((item) => item.info.id === lastUser.id) : msgs, - model, - { terminalBoundaryID }, - ) + const historyForProvider = finalizerMode + ? providerHistory.filter((item) => item.info.id === lastUser.id) + : worldState + ? MessageV2.appendPromptWorldState({ + messages: providerHistory, + sessionID, + epoch: worldState.epoch, + baselineHash: worldState.hash, + rendered: worldState.rendered, + agent: lastUser.agent, + model: lastUser.model, + }) + : providerHistory + const modelMsgs = yield* MessageV2.toModelMessagesEffect(historyForProvider, model, { terminalBoundaryID }) const system = yield* Effect.all([ sys.skills(agent), sys.environment(model), @@ -2879,8 +3273,9 @@ export const layer = Layer.effect( : "" // Schema/finalizer guidance changes per request and must not enter the provider-cached // system prefix. Keep it in the same ephemeral tail used for other volatile runtime - // context; the durable user prompt and the StructuredOutput tool remain the hard gates. - const structuredRuntimeTail = buildStructuredOutputRuntimeTail(format, finalizerMode) + // context; strict turns use StructuredOutput as the hard gate, while the bounded text + // fallback uses local JSON extraction plus the unchanged schema validator. + const structuredRuntimeTail = buildStructuredOutputRuntimeTail(format, finalizerMode, finalizerAllowsText) const baseStreamInput: LLM.StreamInput = { user: lastUser, agent, @@ -2891,7 +3286,10 @@ export const layer = Layer.effect( messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : [])], tools: isLastStep ? {} : tools, model, - toolChoice: finalizerDecision?.toolChoice ?? (format.type === "json_schema" ? "required" : undefined), + durableAttempt: true, + toolChoice: finalizerAllowsText + ? "none" + : (finalizerDecision?.toolChoice ?? (format.type === "json_schema" ? "required" : undefined)), reasoning: finalizerDecision?.reasoning, ...(structuredRuntimeTail ? { runtimeTail: structuredRuntimeTail } : {}), ...(!projectedContext && activeContext @@ -2945,8 +3343,29 @@ export const layer = Layer.effect( ? preparedProviderAttempt.value : undefined const registryToolIds = yield* registry.ids() - const fallbackReceiptID = Hash.sha256(`${sessionID}:provider-request:${handle.message.id}:${randomUUID()}`) - const receiptAdmission = yield* db + if (promptAuthority) { + const dispatchProjection = yield* MessageV2.promptHistoryProjectionEffect(sessionID).pipe( + Effect.provideService(Database.Service, database), + Effect.orDie, + ) + const boundaryError = MessageV2.validateProviderPromptBoundary({ + authority: promptAuthority, + dispatch: dispatchProjection, + assistantMessageID: msg.id, + parentMessageID: lastUser.id, + }) + if (boundaryError) { + const error = new NamedError.Unknown({ + message: `Prompt history changed before provider dispatch: ${sessionID}: ${boundaryError}`, + }) + msg.error = error.toObject() + msg.finish = "error" + msg.time.completed = Date.now() + yield* sessions.updateMessage(msg) + return yield* Effect.die(error) + } + } + const receiptID = yield* db .transaction( (tx) => Effect.gen(function* () { @@ -2959,6 +3378,32 @@ export const layer = Layer.effect( const receiptID = Hash.sha256( `${sessionID}:provider-request:${requestOrdinal}:${handle.message.id}`, ) + const continuation = yield* tx + .select({ + runID: CompactionRunTable.run_id, + state: CompactionRunTable.continuation_state, + }) + .from(CompactionRunTable) + .innerJoin(CompactionArtifactTable, eq(CompactionArtifactTable.run_id, CompactionRunTable.run_id)) + .where( + and( + eq(CompactionRunTable.session_id, sessionID), + eq(CompactionRunTable.state, "committed"), + eq(CompactionRunTable.continuation_state, "pending"), + eq(CompactionArtifactTable.session_id, sessionID), + eq(CompactionArtifactTable.message_id, lastUser.id), + eq(CompactionArtifactTable.state, "committed"), + inArray(CompactionArtifactTable.kind, ["replay", "continue"] as const), + ), + ) + .get() + if (continuation && continuation.state !== "pending") + return yield* Effect.die( + new Error( + `compaction continuation is not pending: ${continuation.runID}: ${continuation.state ?? "missing"}`, + ), + ) + const admittedAt = Date.now() yield* tx.insert(SessionToolRequestReceiptTable).values({ receipt_id: receiptID, request_ordinal: requestOrdinal, @@ -2977,46 +3422,64 @@ export const layer = Layer.effect( tool_choice_mode: streamInput.toolChoice, adapter_tool_capability: "unknown", adapter_lowering_outcome: null, + prompt_epoch: receiptAuthority.epoch, + prompt_window_id: receiptAuthority.window.windowID, + effective_history_hash: receiptAuthority.effectiveHistoryHash, + world_state_baseline_hash: worldState?.hash, + request_input_hash: providerRequestHash(streamInput), + response_chain_reuse_decision: "not_supported", + response_chain_refusal_reason: "provider_path_not_stateful", + provider_state: "preparing", + owner_token: providerReceiptOwner, request_state: "prepared", - created_at: Date.now(), + created_at: admittedAt, }) - return { receiptID, admitted: true as const } + if (continuation) { + const admitted = yield* tx + .update(CompactionRunTable) + .set({ + continuation_state: "admitted", + continuation_receipt_id: receiptID, + continuation_admitted_at: admittedAt, + continuation_dispatching_at: null, + continuation_terminal_at: null, + continuation_error_code: null, + continuation_wakeup_at: admittedAt, + }) + .where( + and( + eq(CompactionRunTable.run_id, continuation.runID), + eq(CompactionRunTable.state, "committed"), + eq(CompactionRunTable.continuation_state, "pending"), + ), + ) + .returning({ runID: CompactionRunTable.run_id }) + .get() + if (!admitted) + return yield* Effect.die( + new Error(`compaction continuation admission CAS lost: ${continuation.runID}`), + ) + } + return receiptID }), { behavior: "immediate" }, ) - .pipe( + .pipe(Effect.orDie) + const bestEffortReceiptWrite = (operation: string, write: Effect.Effect) => + write.pipe( + Effect.asVoid, Effect.catchCause((cause) => Effect.sync(() => { - slog.warn("provider request receipt admission failed", { + slog.warn("provider argument receipt write failed", { + operation, + receiptID, cause: Cause.pretty(cause), - metric: "provider_request_receipt_degraded_total", + metric: "provider_argument_receipt_degraded_total", increment: 1, }) - return { receiptID: fallbackReceiptID, admitted: false as const } }), ), ) - const receiptID = receiptAdmission.receiptID - const receiptWriteState = { available: receiptAdmission.admitted } - const bestEffortReceiptWrite = (operation: string, write: Effect.Effect) => - Effect.suspend(() => { - if (!receiptWriteState.available) return Effect.void - return write.pipe( - Effect.asVoid, - Effect.catchCause((cause) => - Effect.sync(() => { - receiptWriteState.available = false - slog.warn("provider request receipt write failed", { - operation, - receiptID, - cause: Cause.pretty(cause), - metric: "provider_request_receipt_degraded_total", - increment: 1, - }) - }), - ), - ) - }) const writeArgumentReceipt = (input: { layer: ToolArgumentReceiptLayer ordinal: number @@ -3049,125 +3512,351 @@ export const layer = Layer.effect( }) .run(), ) - const result = yield* handle.process( - { - ...streamInput, - requestReceipt: { - prepared: (prepared) => { - const finalOfferedToolIds = Object.keys(prepared.finalOfferedTools) - const definitions = Object.entries(prepared.finalOfferedTools) - .toSorted(([a], [b]) => a.localeCompare(b)) - .map(([name, definition]) => ({ - name, - description: definition.description, - inputSchema: "inputSchema" in definition ? definition.inputSchema : undefined, - })) - return bestEffortReceiptWrite( - "prepared", - db + const transitionReceipt = (input: { + from: readonly (typeof SessionToolRequestReceiptTable.$inferSelect.provider_state)[] + to: typeof SessionToolRequestReceiptTable.$inferSelect.provider_state + values?: Partial + }) => + db + .transaction( + (tx) => + Effect.gen(function* () { + const updated = yield* tx .update(SessionToolRequestReceiptTable) - .set({ - permission_filtered_tool_ids: [...prepared.permissionFilteredToolIds], - final_offered_tool_ids: finalOfferedToolIds, - tool_definition_hash: Hash.sha256(stableJson(definitions)), - adapter_tool_capability: prepared.adapterToolCapability, - adapter_lowering_outcome: prepared.adapterLoweringOutcome, - estimated_input_tokens: prepared.budget.estimatedFullRequestTokens, - physical_input_budget: prepared.budget.physicalInputBudget, - reserved_output_tokens: prepared.budget.reservedOutputTokens, - safety_margin_tokens: prepared.budget.safetyMargin, - context_limit_provenance: prepared.budget.provenance, - request_state: "prepared", - }) + .set({ ...input.values, provider_state: input.to }) .where( and( eq(SessionToolRequestReceiptTable.receipt_id, receiptID), - eq(SessionToolRequestReceiptTable.request_state, "prepared"), + inArray(SessionToolRequestReceiptTable.provider_state, [...input.from]), ), ) - .run(), - ) - }, - dispatched: () => - bestEffortReceiptWrite( - "dispatched", - db - .update(SessionToolRequestReceiptTable) - .set({ request_state: "dispatched" }) - .where( - and( - eq(SessionToolRequestReceiptTable.receipt_id, receiptID), - eq(SessionToolRequestReceiptTable.request_state, "prepared"), + .returning({ receiptID: SessionToolRequestReceiptTable.receipt_id }) + .get() + if (!updated) { + const current = yield* tx + .select({ state: SessionToolRequestReceiptTable.provider_state }) + .from(SessionToolRequestReceiptTable) + .where(eq(SessionToolRequestReceiptTable.receipt_id, receiptID)) + .get() + if (current?.state === input.to) return false + return yield* Effect.die( + new Error( + `provider receipt transition conflict: ${receiptID}: ${current?.state ?? "missing"} -> ${input.to}`, ), ) - .run(), - ), - rejected: ({ budget, reason }) => - bestEffortReceiptWrite( - "rejected", - db - .update(SessionToolRequestReceiptTable) + } + + const continuation = yield* tx + .select({ + runID: CompactionRunTable.run_id, + state: CompactionRunTable.continuation_state, + }) + .from(CompactionRunTable) + .where(eq(CompactionRunTable.continuation_receipt_id, receiptID)) + .get() + if (!continuation || input.to === "streaming") return true + const target = + input.to === "dispatching" + ? "dispatching" + : input.to === "settled" + ? "settled" + : input.to === "failed" + ? "failed" + : undefined + if (!target) return true + const expected: readonly ("admitted" | "dispatching")[] = + target === "dispatching" + ? ["admitted"] + : target === "settled" + ? ["dispatching"] + : ["admitted", "dispatching"] + if (continuation.state !== "admitted" && continuation.state !== "dispatching") + return yield* Effect.die( + new Error( + `compaction continuation transition conflict: ${continuation.runID}: ${continuation.state ?? "missing"} -> ${target}`, + ), + ) + const transitionedAt = Date.now() + const continuationUpdated = yield* tx + .update(CompactionRunTable) .set({ - estimated_input_tokens: budget.estimatedFullRequestTokens, - physical_input_budget: budget.physicalInputBudget, - reserved_output_tokens: budget.reservedOutputTokens, - safety_margin_tokens: budget.safetyMargin, - context_limit_provenance: budget.provenance, - request_state: "rejected", - request_error_code: reason, + continuation_state: target, + ...(target === "dispatching" + ? { + continuation_dispatching_at: + typeof input.values?.dispatching_at === "number" + ? input.values.dispatching_at + : transitionedAt, + } + : { + continuation_terminal_at: + typeof input.values?.terminal_at === "number" + ? input.values.terminal_at + : transitionedAt, + continuation_error_code: + target === "failed" ? (input.values?.request_error_code ?? "provider_error") : null, + }), }) .where( and( - eq(SessionToolRequestReceiptTable.receipt_id, receiptID), - eq(SessionToolRequestReceiptTable.request_state, "prepared"), + eq(CompactionRunTable.run_id, continuation.runID), + inArray(CompactionRunTable.continuation_state, expected), ), ) - .run(), - ), - aiSdkInput: (input) => writeArgumentReceipt({ layer: "ai_sdk_input", ...input }), - rawFrame: (input) => writeArgumentReceipt({ layer: "raw_frame", ...input }), - adapterAssembly: (input) => writeArgumentReceipt({ layer: "adapter_assembly", ...input }), - processorDecoded: (input) => writeArgumentReceipt({ layer: "processor_decoded", ...input }), - processorValidation: (input) => - bestEffortReceiptWrite( - "argument:processor_decoded:validation", - db - .update(SessionToolArgumentReceiptTable) - .set({ validation_outcome: input.validationOutcome }) + .returning({ runID: CompactionRunTable.run_id }) + .get() + if (!continuationUpdated) + return yield* Effect.die( + new Error(`compaction continuation transition CAS lost: ${continuation.runID}`), + ) + return true + }), + { behavior: "immediate" }, + ) + .pipe(Effect.orDie) + const finalizeReceipt = () => + Effect.gen(function* () { + const terminal = receiptTerminal.value + if (!terminal) + return yield* Effect.die(new Error(`provider response terminal intent is missing: ${receiptID}`)) + const finalResponse = (yield* sessions.messages({ sessionID }).pipe(Effect.orDie)).find( + (message) => message.info.id === handle.message.id, + ) + if (!finalResponse) + return yield* Effect.die(new Error(`provider response is missing: ${handle.message.id}`)) + const responseFingerprint = providerResponseFingerprint(finalResponse) + const finalized = yield* transitionReceipt({ + from: + terminal.state === "settled" + ? (["dispatching", "streaming"] as const) + : (["preparing", "prepared", "dispatching", "streaming"] as const), + to: terminal.state, + values: { + call_ids: finalResponse.parts.flatMap((part) => (part.type === "tool" ? [part.callID] : [])), + response_fingerprint: responseFingerprint, + terminal_at: Date.now(), + request_error_code: terminal.state === "failed" ? terminal.errorCode : null, + }, + }) + if (!finalized) { + const current = yield* db + .select({ + state: SessionToolRequestReceiptTable.provider_state, + responseFingerprint: SessionToolRequestReceiptTable.response_fingerprint, + }) + .from(SessionToolRequestReceiptTable) + .where(eq(SessionToolRequestReceiptTable.receipt_id, receiptID)) + .get() + .pipe(Effect.orDie) + if (current?.state !== terminal.state || current.responseFingerprint !== responseFingerprint) + return yield* Effect.die( + new Error(`provider response receipt terminal replay diverged: ${receiptID}`), + ) + } + receiptFinalizer.value = undefined + }) + receiptFinalizer.value = finalizeReceipt + const turnSettled = { value: false } + const settleProviderTurn = () => + Effect.gen(function* () { + if (turnSettled.value) return + yield* finalizeReceipt() + turnSettled.value = true + // Summary diffs mutate user-message metadata. Run them only after the Provider + // receipt is terminal so cancellation cannot strand an admitted request. + if (step === 1 && !finalizerMode) + yield* summary.summarize({ sessionID, messageID: lastUser.id }).pipe(Effect.ignore) + }) + const prepareAdapterReceipt = (input: { + finalRequestHash: string + promptCacheKey?: string + finalOfferedToolIds: readonly string[] + toolDefinitionHash: string + }) => + db + .transaction( + (tx) => + Effect.gen(function* () { + const current = yield* tx + .select() + .from(SessionToolRequestReceiptTable) + .where(eq(SessionToolRequestReceiptTable.receipt_id, receiptID)) + .get() + if (!current) return yield* Effect.die(new Error(`provider receipt is missing: ${receiptID}`)) + if (current.provider_state === "prepared") { + if ( + current.final_request_hash !== input.finalRequestHash || + (current.prompt_cache_key ?? undefined) !== input.promptCacheKey || + current.tool_definition_hash !== input.toolDefinitionHash || + stableJson(current.final_offered_tool_ids) !== stableJson(input.finalOfferedToolIds) + ) + return yield* Effect.die( + new Error(`provider adapter preparation diverged on retry: ${receiptID}`), + ) + return + } + if (current.provider_state !== "preparing") + return yield* Effect.die( + new Error( + `provider adapter preparation is too late: ${receiptID}: ${current.provider_state}`, + ), + ) + const updated = yield* tx + .update(SessionToolRequestReceiptTable) + .set({ + final_request_hash: input.finalRequestHash, + provider_request_hash: input.finalRequestHash, + prompt_cache_key: input.promptCacheKey ?? null, + final_offered_tool_ids: [...input.finalOfferedToolIds], + tool_definition_hash: input.toolDefinitionHash, + provider_state: "prepared", + adapter_prepared_at: Date.now(), + }) .where( and( - eq(SessionToolArgumentReceiptTable.receipt_id, receiptID), - eq(SessionToolArgumentReceiptTable.layer, "processor_decoded"), - eq(SessionToolArgumentReceiptTable.call_id, input.callID), + eq(SessionToolRequestReceiptTable.receipt_id, receiptID), + eq(SessionToolRequestReceiptTable.provider_state, "preparing"), ), ) - .run(), - ), + .returning({ receiptID: SessionToolRequestReceiptTable.receipt_id }) + .get() + if (!updated) + return yield* Effect.die(new Error(`provider adapter preparation CAS lost: ${receiptID}`)) + }), + { behavior: "immediate" }, + ) + .pipe(Effect.orDie) + if (providerAttempt && handle.message.providerAttemptID !== providerAttempt.attemptId) { + handle.message.providerAttemptID = providerAttempt.attemptId + yield* sessions.updateMessage(handle.message) + } + const result = yield* handle.process({ + ...streamInput, + requestReceipt: { + prepared: (prepared) => { + return db + .update(SessionToolRequestReceiptTable) + .set({ + permission_filtered_tool_ids: [...prepared.permissionFilteredToolIds], + adapter_tool_capability: prepared.adapterToolCapability, + adapter_lowering_outcome: prepared.adapterLoweringOutcome, + estimated_input_tokens: prepared.budget.estimatedFullRequestTokens, + physical_input_budget: prepared.budget.physicalInputBudget, + reserved_output_tokens: prepared.budget.reservedOutputTokens, + safety_margin_tokens: prepared.budget.safetyMargin, + context_limit_provenance: prepared.budget.provenance, + }) + .where( + and( + eq(SessionToolRequestReceiptTable.receipt_id, receiptID), + eq(SessionToolRequestReceiptTable.provider_state, "preparing"), + ), + ) + .run() + .pipe(Effect.orDie, Effect.asVoid) }, + adapterPrepared: prepareAdapterReceipt, + dispatched: () => + Effect.gen(function* () { + const transitioned = yield* transitionReceipt({ + from: ["prepared"], + to: "dispatching", + values: { request_state: "dispatched", dispatching_at: Date.now() }, + }) + if (transitioned && providerAttempt) yield* providerAttempt.dispatching.pipe(Effect.orDie) + }), + streaming: () => + Effect.gen(function* () { + const transitioned = yield* transitionReceipt({ + from: ["dispatching"], + to: "streaming", + values: { streaming_at: Date.now() }, + }) + if (transitioned && providerAttempt) yield* providerAttempt.streaming.pipe(Effect.orDie) + }), + // Processor cleanup durably completes the assistant after these callbacks. Keep the + // terminal intent in memory until that cleanup and the response fingerprint are ready, + // then commit the receipt and continuation terminal states together below. + settled: () => + Effect.gen(function* () { + receiptTerminal.value = { state: "settled" } + if (providerAttempt) yield* providerAttempt.settled.pipe(Effect.orDie) + }), + failed: (error) => + Effect.gen(function* () { + receiptTerminal.value ??= { + state: "failed", + errorCode: error instanceof Error ? error.name : "provider_error", + } + if (providerAttempt) yield* providerAttempt.failed(error).pipe(Effect.orDie) + }), + rejected: ({ budget, reason }) => + Effect.gen(function* () { + receiptTerminal.value = { state: "failed", errorCode: reason } + yield* db + .update(SessionToolRequestReceiptTable) + .set({ + estimated_input_tokens: budget.estimatedFullRequestTokens, + physical_input_budget: budget.physicalInputBudget, + reserved_output_tokens: budget.reservedOutputTokens, + safety_margin_tokens: budget.safetyMargin, + context_limit_provenance: budget.provenance, + request_state: "rejected", + request_error_code: reason, + }) + .where( + and( + eq(SessionToolRequestReceiptTable.receipt_id, receiptID), + eq(SessionToolRequestReceiptTable.provider_state, "preparing"), + ), + ) + .run() + .pipe(Effect.orDie) + }), + aiSdkInput: (input) => writeArgumentReceipt({ layer: "ai_sdk_input", ...input }), + rawFrame: (input) => writeArgumentReceipt({ layer: "raw_frame", ...input }), + adapterAssembly: (input) => writeArgumentReceipt({ layer: "adapter_assembly", ...input }), + processorDecoded: (input) => writeArgumentReceipt({ layer: "processor_decoded", ...input }), + processorValidation: (input) => + bestEffortReceiptWrite( + "argument:processor_decoded:validation", + db + .update(SessionToolArgumentReceiptTable) + .set({ validation_outcome: input.validationOutcome }) + .where( + and( + eq(SessionToolArgumentReceiptTable.receipt_id, receiptID), + eq(SessionToolArgumentReceiptTable.layer, "processor_decoded"), + eq(SessionToolArgumentReceiptTable.call_id, input.callID), + ), + ) + .run(), + ), }, - providerAttempt, - ) - const observedCallIds = - (yield* sessions.messages({ sessionID }).pipe(Effect.orDie)) - .find((message) => message.info.id === handle.message.id) - ?.parts.flatMap((part) => (part.type === "tool" ? [part.callID] : [])) ?? [] - yield* bestEffortReceiptWrite( - "observed_call_ids", - db - .update(SessionToolRequestReceiptTable) - .set({ call_ids: observedCallIds }) - .where(eq(SessionToolRequestReceiptTable.receipt_id, receiptID)) - .run(), + }) + const response = (yield* sessions.messages({ sessionID }).pipe(Effect.orDie)).find( + (message) => message.info.id === handle.message.id, ) + if (!response) return yield* Effect.die(new Error(`provider response is missing: ${handle.message.id}`)) if (structured !== undefined) { handle.message.structured = structured handle.message.finish = handle.message.finish ?? "stop" yield* sessions.updateMessage(handle.message) + yield* settleProviderTurn() return "break" as const } if (finalizerMode) { + const hasTextFallback = + finalizerAllowsText && + response.parts.some( + (part) => part.type === "text" && !part.synthetic && !part.ignored && part.text.trim() !== "", + ) + if (hasTextFallback) { + yield* settleProviderTurn() + return "break" as const + } if (!handle.message.error) { handle.message.error = new SessionV1.StructuredOutputError({ message: "Finalizer did not produce valid structured output", @@ -3175,6 +3864,7 @@ export const layer = Layer.effect( }).toObject() yield* sessions.updateMessage(handle.message) } + yield* settleProviderTurn() return "break" as const } @@ -3186,6 +3876,7 @@ export const layer = Layer.effect( retries: 0, }).toObject() yield* sessions.updateMessage(handle.message) + yield* settleProviderTurn() return "break" as const } } @@ -3206,6 +3897,7 @@ export const layer = Layer.effect( // this step. We check the CURRENT assistant message's parts (by handle.message.id). const latestMsgs = yield* MessageV2.promptHistoryEffect(sessionID).pipe( Effect.provideService(Database.Service, database), + Effect.orDie, ) const currentAssistantMsg = latestMsgs.findLast( (m) => m.info.role === "assistant" && m.info.id === handle.message.id, @@ -3224,6 +3916,7 @@ export const layer = Layer.effect( retries: structuredFailedAttempts, }).toObject() yield* sessions.updateMessage(handle.message) + yield* settleProviderTurn() yield* slog.warn("structured-output retry cap reached", { attempts: structuredFailedAttempts, retryMax, @@ -3235,16 +3928,19 @@ export const layer = Layer.effect( // correction text appears as a user instruction in the next model context — // not as assistant output (which the model treats with lower compliance). if (fields.length > 0) { + yield* settleProviderTurn() yield* injectTailReminder( sessionID, `[structured-output correction] Your StructuredOutput call did not match the required schema. Required top-level fields: ${fieldList}. Please call StructuredOutput again using EXACTLY these field names.`, lastUser.model, lastUser.agent, + SessionProcessor.planProtocolActivityID(lastUser.metadata) ?? lastUser.id, ) } } } + yield* settleProviderTurn() if (result === "stop") return "break" as const if (result === "compact") { // V4.0.1 P0 — a turn-internal hard compaction (the provider signalled overflow mid-stream). @@ -3263,12 +3959,13 @@ export const layer = Layer.effect( model: lastUser.model, auto: true, overflow: !handle.message.finish, + activityID: SessionProcessor.planProtocolActivityID(lastUser.metadata) ?? lastUser.id, }) } return "continue" as const }).pipe( Effect.ensuring(instruction.clear(handle.message.id)), - Effect.onInterrupt(() => finalizeInterruptedAssistant), + Effect.onInterrupt(() => finalizeInterruptedTurn), ) // V4.1 §S1.1 needsFollowUp: the model finished this step (outcome === "break"), but if a steer // arrived while it was running, do NOT exit — loop once more so the top-of-loop drain absorbs @@ -3408,7 +4105,9 @@ export const layer = Layer.effect( return { kind: "steer" as const, delivery: "steer" as const, admitted } }) - const promptAsync: (input: PromptInput) => Effect.Effect = Effect.fn( + const promptAsync: ( + input: PromptInput, + ) => Effect.Effect = Effect.fn( "SessionPrompt.promptAsync", )(function* (input: PromptInput) { const messageID = input.messageID ?? MessageID.ascending() @@ -3424,7 +4123,11 @@ export const layer = Layer.effect( messageID, }).pipe(Effect.provideService(Database.Service, database)) : undefined - if (claim?.kind === "admitted") return + if (claim?.kind === "admitted") + return { + messageID: claim.receipt.messageID, + delivery: claim.receipt.delivery ?? "turn", + } const claimed = claim?.receipt const admittedInput = claimed ? { @@ -3433,7 +4136,7 @@ export const layer = Layer.effect( parts: stableIntentParts(input.parts, claimed.intentID), } : { ...input, messageID } - const admission = yield* Deferred.make() + const admission = yield* Deferred.make() if (claimed) { yield* Effect.suspend(() => SessionPromptIntent.renew({ @@ -3460,7 +4163,7 @@ export const layer = Layer.effect( ).pipe( Effect.matchCauseEffect({ onFailure: (cause) => Deferred.failCause(admission, cause), - onSuccess: () => Deferred.succeed(admission, undefined), + onSuccess: () => Deferred.succeed(admission, receipt), }), Effect.asVoid, ), @@ -3485,7 +4188,7 @@ export const layer = Layer.effect( ), Effect.forkIn(scope, { startImmediately: true }), ) - yield* Deferred.await(admission) + return yield* Deferred.await(admission) }) const loop: (input: LoopInput, onRunning?: Effect.Effect) => Effect.Effect = Effect.fn( @@ -3524,6 +4227,36 @@ export const layer = Layer.effect( ) }) + const wakeCommittedContinuations = (ctx: InstanceContext) => + Effect.runPromise( + Effect.gen(function* () { + const pending = yield* compaction.recoverableContinuations(ctx.project.id) + yield* Effect.forEach( + pending, + (item) => + loop({ sessionID: item.sessionID }).pipe( + Effect.catchCause((cause) => + Effect.logError("committed compaction continuation recovery failed").pipe( + Effect.annotateLogs({ + runID: item.runID, + sessionID: item.sessionID, + messageID: item.messageID, + cause, + }), + ), + ), + Effect.forkIn(scope), + ), + { discard: true }, + ) + }).pipe(Effect.provideService(InstanceRef, ctx)), + ) + const unregisterCompactionRecovery = registerInitializer(wakeCommittedContinuations) + const currentInstance = yield* InstanceRef + if (currentInstance) { + yield* Effect.promise(() => wakeCommittedContinuations(currentInstance)).pipe(Effect.forkIn(scope)) + } + const shell: (input: ShellInput) => Effect.Effect = Effect.fn( "SessionPrompt.shell", )(function* (input: ShellInput) { @@ -3928,6 +4661,7 @@ export const layer = Layer.effect( notificationWorkers.clear() unregisterDurableInitializer() unregisterDurableDisposer() + unregisterCompactionRecovery() const directories = new Set([...durableWorkers.keys(), ...durableLeases.keys()]) yield* Effect.promise(() => Promise.all([...directories].map(disposeDurableWorkers))) }), @@ -4222,8 +4956,18 @@ function stableJson(value: unknown, seen = new WeakSet()): string { return result } +/** @internal Exported for deterministic receipt verification. */ +function providerResponseFingerprint(response: SessionV1.WithParts) { + return Hash.sha256(stableJson(response)) +} + /** @internal Exported for testing */ -export { buildStructuredOutputRuntimeTail, buildStructuredOutputSystemPrompt, extractSchemaTopLevelFields } +export { + buildStructuredOutputRuntimeTail, + buildStructuredOutputSystemPrompt, + extractSchemaTopLevelFields, + providerResponseFingerprint, +} export function createStructuredOutputTool(input: { schema: Record diff --git a/packages/deepagent-code/src/session/reminders.ts b/packages/deepagent-code/src/session/reminders.ts index 2d1c0374..8e0f2147 100644 --- a/packages/deepagent-code/src/session/reminders.ts +++ b/packages/deepagent-code/src/session/reminders.ts @@ -35,9 +35,10 @@ export const renderPlanStatus = ( const plan = AgentGateway.DeepAgentSessionState.getPlan(sessionID) if (!plan) return null - const snapshot = AgentGateway.DeepAgentPlanController.renderPlanSnapshot(plan, detail) const ref = AgentGateway.DeepAgentPlanStore.planDocRef(sessionID) - const precondition = ref ? `\nPlan precondition: plan_id=${plan.plan_id} plan_version=${ref.version}` : "" + const snapshot = ref + ? AgentGateway.DeepAgentPlanController.renderPlanWriteContext(plan, ref.version, detail) + : `${AgentGateway.DeepAgentPlanController.renderPlanSnapshot(plan, detail)}\nPlan write unavailable: expected_version is unavailable for expected_plan_id=${JSON.stringify(plan.plan_id)}. Do not guess or call advance/replan.` const mutations = AgentGateway.DeepAgentSessionState.mutationsSinceReport(sessionID) const validationPassedSinceReport = AgentGateway.DeepAgentSessionState.validationPassedSinceReport(sessionID) // U10 hybrid trigger: semantic (a validation just passed) is primary, mode-scaled count is the @@ -48,7 +49,7 @@ export const renderPlanStatus = ( mode: agentMode, }) const nudge = trigger ? `\n\n${AgentGateway.DeepAgentPlanController.PROGRESS_NUDGE(trigger, mutations)}` : "" - return `\n${snapshot}${precondition}${nudge}\n` + return `\n${snapshot}${nudge}\n` } export const apply = Effect.fn("SessionReminders.apply")(function* (input: { diff --git a/packages/deepagent-code/src/session/session.ts b/packages/deepagent-code/src/session/session.ts index 5544e167..a32b28d5 100644 --- a/packages/deepagent-code/src/session/session.ts +++ b/packages/deepagent-code/src/session/session.ts @@ -27,11 +27,27 @@ import { inArray } from "drizzle-orm" import { lt } from "drizzle-orm" import { or } from "drizzle-orm" import type { SQL } from "drizzle-orm" -import { PartTable, SessionIntentTable, SessionSteerTable, SessionTable } from "@deepagent-code/core/session/sql" +import { + MessageTable, + PartTable, + SessionForkAdmissionTable, + SessionForkIntentTable, + SessionHistoryStateTable, + SessionIntentTable, + SessionPartIntegrityQuarantineTable, + SessionPromptEpochMessageTable, + SessionSteerTable, + SessionTable, + SessionWorldStateBaselineTable, +} from "@deepagent-code/core/session/sql" import { ProjectTable } from "@deepagent-code/core/project/sql" import { Log } from "@deepagent-code/core/util/log" import { MessageV2 } from "./message-v2" -import { forwardLedgerOnFork, persistForkOrigin } from "./context-ledger" +import { + collectSessionWorldStateBaseline, + forwardLedgerOnForkRequired, + persistForkOriginRequired, +} from "./context-ledger" import { containsPath, type InstanceContext } from "../project/instance-context" import { InstanceState } from "@/effect/instance-state" import { Snapshot } from "@/snapshot" @@ -44,19 +60,62 @@ import { Identifier } from "@/id/id" import type { Provider } from "@/provider/provider" import { Permission } from "@/permission" import { Global } from "@deepagent-code/core/global" -import { DateTime, Effect, Layer, Option, Context, Schema, Types } from "effect" +import { DateTime, Effect, Exit, Layer, Option, Context, Schema, Types } from "effect" import { AbsolutePath, NonNegativeInt, optionalOmitUndefined } from "@deepagent-code/core/schema" import { RuntimeFlags } from "@/effect/runtime-flags" import { ProviderV2 } from "@deepagent-code/core/provider" import { ModelV2 } from "@deepagent-code/core/model" import { Location } from "@deepagent-code/core/location" import { SessionEvent } from "@deepagent-code/core/session/event" +import { Hash } from "@deepagent-code/core/util/hash" +import { CanonicalJson } from "@deepagent-code/core/util/canonical-json" +import { SessionPromptEpochTable } from "./prompt-epoch.sql" +import { HistoryAuthority } from "./history-authority" +import { Data } from "effect" +import { KeyedMutex } from "@deepagent-code/core/effect/keyed-mutex" const log = Log.create({ service: "session" }) const runtime = makeRuntime(Database.Service, Database.defaultLayer) +const forkLocks = KeyedMutex.makeUnsafe() const parentTitlePrefix = "New session - " const childTitlePrefix = "Child session - " +const TASK_DROPPED_CONTEXT_SOURCES = new Set(["world_state", "runtime_instruction", "compaction_continue", "fork_hint"]) + +const contextProvenanceSource = (value: unknown): string | undefined => { + if (!value || typeof value !== "object") return undefined + const deepagent = (value as Record).deepagent + if (!deepagent || typeof deepagent !== "object") return undefined + const provenance = (deepagent as Record).contextProvenance + if (!provenance || typeof provenance !== "object") return undefined + const source = (provenance as Record).source + return typeof source === "string" ? source : undefined +} + +const isTaskRuntimeMessage = (message: SessionV1.WithParts): boolean => { + const messageSource = contextProvenanceSource(message.info.role === "user" ? message.info.metadata : undefined) + return Boolean(messageSource && TASK_DROPPED_CONTEXT_SOURCES.has(messageSource)) +} + +const sanitizeTaskHistory = (messages: readonly SessionV1.WithParts[]): SessionV1.WithParts[] => { + const candidates = messages + .filter((message) => !isTaskRuntimeMessage(message)) + .map((message) => ({ + ...message, + parts: message.parts.filter((part) => { + const source = contextProvenanceSource("metadata" in part ? part.metadata : undefined) + return ( + !(source && TASK_DROPPED_CONTEXT_SOURCES.has(source)) && + !(part.type === "text" && (part.synthetic || part.metadata?.compaction_continue === true)) + ) + }), + })) + .filter((message) => message.parts.length > 0) + const keptIDs = new Set(candidates.map((message) => message.info.id)) + return candidates.filter( + (message) => message.info.role !== "assistant" || !message.info.parentID || keptIDs.has(message.info.parentID), + ) +} export function isDefaultTitle(title: string) { return new RegExp( @@ -278,6 +337,9 @@ export type CreateInput = Types.DeepMutable()("SessionBusy sessionID: SessionID, }) {} +export class ForkConflict extends Data.TaggedError("Session.ForkConflict")<{ + readonly intentID: string + readonly reason: string +}> {} + +export class UnavailableError extends Schema.TaggedErrorClass()("SessionUnavailableError", { + sessionID: SessionID, + reason: Schema.String, +}) {} + export type NotFound = NotFoundError export interface Interface { @@ -501,10 +573,17 @@ export interface Interface { }) => Effect.Effect readonly fork: (input: { sessionID: SessionID + intentID: string messageID?: MessageID directory?: string isolate?: "worktree" - }) => Effect.Effect + forkMode?: "foreground" | "task" + targetSessionID?: SessionID + childDepth?: number + taskRequestHash?: string + }) => Effect.Effect + readonly recoverForks: () => Effect.Effect + readonly assertRunnable: (sessionID: SessionID) => Effect.Effect readonly touch: (sessionID: SessionID) => Effect.Effect readonly get: (id: SessionID) => Effect.Effect readonly mutationEpoch: (sessionID: SessionID) => Effect.Effect @@ -731,12 +810,29 @@ export const layer: Layer.Layer< const updateMessage = (msg: T): Effect.Effect => Effect.gen(function* () { + const existing = yield* db + .select({ session_id: MessageTable.session_id }) + .from(MessageTable) + .where(eq(MessageTable.id, msg.id)) + .get() + .pipe(Effect.orDie) + if (existing && existing.session_id !== msg.sessionID) + return yield* Effect.die(`Session.updateMessage: message ${msg.id} belongs to another Session`) yield* events.publish(SessionV1.Event.MessageUpdated, { sessionID: msg.sessionID, info: msg }) return msg }).pipe(Effect.withSpan("Session.updateMessage")) const updatePart = (part: T): Effect.Effect => Effect.gen(function* () { + yield* requireMessageOwnership({ sessionID: part.sessionID, messageID: part.messageID }).pipe(Effect.orDie) + const existing = yield* db + .select({ message_id: PartTable.message_id, session_id: PartTable.session_id }) + .from(PartTable) + .where(eq(PartTable.id, part.id)) + .get() + .pipe(Effect.orDie) + if (existing && (existing.message_id !== part.messageID || existing.session_id !== part.sessionID)) + return yield* Effect.die(`Session.updatePart: part ${part.id} belongs to another message or Session`) yield* events.publish(SessionV1.Event.PartUpdated, { sessionID: part.sessionID, part: structuredClone(part), @@ -745,6 +841,39 @@ export const layer: Layer.Layer< return part }).pipe(Effect.withSpan("Session.updatePart")) + const requireMessageOwnership = Effect.fn("Session.requireMessageOwnership")(function* (input: { + sessionID: SessionID + messageID: MessageID + }) { + const row = yield* db + .select({ id: MessageTable.id }) + .from(MessageTable) + .where(and(eq(MessageTable.id, input.messageID), eq(MessageTable.session_id, input.sessionID))) + .get() + .pipe(Effect.orDie) + if (!row) return yield* new NotFoundError({ message: `Message not found: ${input.messageID}` }) + }) + + const requirePartOwnership = Effect.fn("Session.requirePartOwnership")(function* (input: { + sessionID: SessionID + messageID: MessageID + partID: PartID + }) { + const row = yield* db + .select({ id: PartTable.id }) + .from(PartTable) + .where( + and( + eq(PartTable.id, input.partID), + eq(PartTable.message_id, input.messageID), + eq(PartTable.session_id, input.sessionID), + ), + ) + .get() + .pipe(Effect.orDie) + if (!row) return yield* new NotFoundError({ message: `Part not found: ${input.partID}` }) + }) + const getPart: Interface["getPart"] = Effect.fn("Session.getPart")(function* (input) { const row = yield* db .select() @@ -798,15 +927,444 @@ export const layer: Layer.Layer< }) }) - const fork = Effect.fn("Session.fork")(function* (input: { + const deliverForkEvents = Effect.fn("Session.deliverForkEvents")(function* (intentID: string) { + const owner = `fork-delivery:${Identifier.ascending("event")}` + const waitDeadline = Date.now() + 35_000 + let claimed: typeof SessionForkIntentTable.$inferSelect | undefined + while (!claimed) { + const candidate = yield* db + .transaction( + (tx) => + Effect.gen(function* () { + const row = yield* tx + .select() + .from(SessionForkIntentTable) + .where(eq(SessionForkIntentTable.intent_id, intentID)) + .get() + if (!row) return yield* Effect.die(new Error(`fork intent missing after commit: ${intentID}`)) + if (row.state === "complete") return row + if (row.state === "recovery_required") { + return yield* Effect.fail( + new ForkConflict({ intentID, reason: row.recovery_reason ?? "fork delivery requires recovery" }), + ) + } + if (row.state === "publishing" && row.lease_expires_at && row.lease_expires_at > Date.now()) return row + return yield* tx + .update(SessionForkIntentTable) + .set({ + state: "publishing", + delivery_owner: owner, + lease_expires_at: Date.now() + 30_000, + delivery_attempts: sql`${SessionForkIntentTable.delivery_attempts} + 1`, + time_updated: Date.now(), + }) + .where(eq(SessionForkIntentTable.intent_id, intentID)) + .returning() + .get() + }), + { behavior: "immediate" }, + ) + .pipe(Effect.orDie) + if (candidate.state === "complete") return yield* get(candidate.target_session_id) + if (candidate.delivery_owner === owner) { + claimed = candidate + continue + } + if (Date.now() >= waitDeadline) { + return yield* Effect.fail( + new ForkConflict({ intentID, reason: "fork delivery did not settle before timeout" }), + ) + } + yield* Effect.sleep("25 millis") + } + + return yield* Effect.gen(function* () { + const target = yield* get(claimed.target_session_id) + const targetProjection = yield* MessageV2.promptHistoryProjectionEffect(target.id).pipe( + Effect.provideService(Database.Service, database), + Effect.mapError( + (error) => + new ForkConflict({ + intentID, + reason: `target history validation failed: ${ + error instanceof MessageV2.HistoryAuthorityError ? error.reason : error.message + }`, + }), + ), + ) + const targetWorldState = yield* MessageV2.promptWorldStateProjectionEffect(target.id).pipe( + Effect.provideService(Database.Service, database), + Effect.mapError( + (error) => + new ForkConflict({ + intentID, + reason: `target World State validation failed: ${ + error instanceof MessageV2.HistoryAuthorityError ? error.reason : error.message + }`, + }), + ), + ) + if ( + targetProjection.epoch !== claimed.target_prompt_epoch || + targetProjection.window.windowID !== claimed.target_window_id || + targetProjection.effectiveHistoryHash !== claimed.target_effective_history_hash || + targetWorldState?.hash !== claimed.target_world_state_baseline_hash + ) { + const quarantined = yield* db + .update(SessionForkIntentTable) + .set({ + state: "recovery_required", + recovery_reason: "target projection no longer matches committed fork manifest", + delivery_owner: null, + lease_expires_at: null, + time_updated: Date.now(), + }) + .where( + and( + eq(SessionForkIntentTable.intent_id, intentID), + eq(SessionForkIntentTable.delivery_owner, owner), + eq(SessionForkIntentTable.state, "publishing"), + ), + ) + .returning({ intent_id: SessionForkIntentTable.intent_id }) + .get() + .pipe(Effect.orDie) + if (!quarantined) { + return yield* Effect.fail(new ForkConflict({ intentID, reason: "fork delivery ownership was lost" })) + } + return yield* Effect.fail( + new ForkConflict({ intentID, reason: "target projection no longer matches committed fork manifest" }), + ) + } + + const rows = yield* messages({ sessionID: target.id }) + let cursor = claimed.event_cursor + let ordinal = 0 + const publishOnce = Effect.fn("Session.deliverForkEvent")(function* ( + publish: (commit: (seq: number) => Effect.Effect) => Effect.Effect, + ) { + const current = ordinal++ + if (current < cursor) return + if (current !== cursor || current >= claimed.event_count) { + return yield* Effect.die( + new Error(`fork event cursor is not contiguous for ${intentID}: ${current}/${cursor}`), + ) + } + const next = current + 1 + yield* publish(() => + db + .update(SessionForkIntentTable) + .set({ event_cursor: next, lease_expires_at: Date.now() + 30_000, time_updated: Date.now() }) + .where( + and( + eq(SessionForkIntentTable.intent_id, intentID), + eq(SessionForkIntentTable.delivery_owner, owner), + eq(SessionForkIntentTable.state, "publishing"), + eq(SessionForkIntentTable.event_cursor, current), + ), + ) + .returning({ intent_id: SessionForkIntentTable.intent_id }) + .get() + .pipe( + Effect.orDie, + Effect.flatMap((advanced) => + advanced + ? Effect.void + : Effect.die(new ForkConflict({ intentID, reason: "fork delivery ownership was lost" })), + ), + ), + ) + cursor = next + }) + + for (const row of rows) { + const messageEventID = EventV2.ID.make( + `evt_${Hash.sha256(`fork-event:v1:${intentID}:message:${row.info.id}`).slice(0, 26)}`, + ) + yield* publishOnce((commit) => + events.publish( + SessionV1.Event.MessageUpdated, + { sessionID: target.id, info: row.info }, + { id: messageEventID, idempotent: true, commit }, + ), + ) + for (const part of row.parts) { + const partEventID = EventV2.ID.make( + `evt_${Hash.sha256(`fork-event:v1:${intentID}:part:${part.id}`).slice(0, 26)}`, + ) + yield* publishOnce((commit) => + events.publish( + SessionV1.Event.PartUpdated, + { + sessionID: target.id, + part, + time: part.type === "text" ? (part.time?.start ?? row.info.time.created) : row.info.time.created, + }, + { id: partEventID, idempotent: true, commit }, + ), + ) + } + } + + const complete = { + ...target, + metadata: + claimed.fork_mode === "task" + ? { + ...target.metadata, + deepagent: { + ...(target.metadata?.deepagent as Record | undefined), + task_fork_manifest: { + ...((target.metadata?.deepagent as Record | undefined)?.task_fork_manifest as + | Record + | undefined), + manifestState: "complete", + }, + }, + } + : { + ...target.metadata, + forkedFrom: { + ...(target.metadata?.forkedFrom as Record), + manifestState: "complete", + }, + }, + time: { ...target.time, updated: claimed.time_committed ?? claimed.time_created }, + } + const completeEventID = EventV2.ID.make(`evt_${Hash.sha256(`fork-event:v1:${intentID}:complete`).slice(0, 26)}`) + yield* publishOnce((commit) => + events.publish( + SessionV1.Event.Updated, + { sessionID: target.id, info: complete }, + { id: completeEventID, idempotent: true, commit }, + ), + ) + if (cursor !== claimed.event_count) { + return yield* Effect.die( + new Error(`fork delivery count mismatch for ${intentID}: ${cursor}/${claimed.event_count}`), + ) + } + const completed = yield* db + .update(SessionForkIntentTable) + .set({ + state: "complete", + event_cursor: cursor, + delivery_owner: null, + lease_expires_at: null, + time_updated: Date.now(), + time_completed: Date.now(), + }) + .where( + and( + eq(SessionForkIntentTable.intent_id, intentID), + eq(SessionForkIntentTable.delivery_owner, owner), + eq(SessionForkIntentTable.state, "publishing"), + ), + ) + .returning({ intent_id: SessionForkIntentTable.intent_id }) + .get() + .pipe(Effect.orDie) + if (!completed) { + return yield* Effect.fail(new ForkConflict({ intentID, reason: "fork delivery ownership was lost" })) + } + return complete + }).pipe( + Effect.onError(() => + db + .update(SessionForkIntentTable) + .set({ state: "committed", delivery_owner: null, lease_expires_at: null, time_updated: Date.now() }) + .where( + and( + eq(SessionForkIntentTable.intent_id, intentID), + eq(SessionForkIntentTable.delivery_owner, owner), + eq(SessionForkIntentTable.state, "publishing"), + ), + ) + .run() + .pipe(Effect.orDie), + ), + ) + }) + + const completeForkSideEffects = Effect.fn("Session.completeForkSideEffects")(function* ( + intent: typeof SessionForkIntentTable.$inferSelect, + ) { + if (intent.side_effects_completed_at) return + if (intent.fork_mode === "foreground") { + yield* forwardLedgerOnForkRequired({ + parentSessionID: intent.source_session_id, + forkSessionID: intent.target_session_id, + }) + yield* persistForkOriginRequired({ + forkSessionID: intent.target_session_id, + origin: { + parentSessionID: intent.source_session_id, + ...(intent.source_cutoff_message_id ? { cutoffMessageID: intent.source_cutoff_message_id } : {}), + forkedAt: intent.time_created, + }, + }) + } + const completed = yield* db + .update(SessionForkIntentTable) + .set({ side_effects_completed_at: Date.now(), time_updated: Date.now() }) + .where( + and( + eq(SessionForkIntentTable.intent_id, intent.intent_id), + eq(SessionForkIntentTable.state, "complete"), + isNull(SessionForkIntentTable.side_effects_completed_at), + ), + ) + .returning({ intent_id: SessionForkIntentTable.intent_id }) + .get() + .pipe(Effect.orDie) + if (completed) return + const current = yield* db + .select({ side_effects_completed_at: SessionForkIntentTable.side_effects_completed_at }) + .from(SessionForkIntentTable) + .where(eq(SessionForkIntentTable.intent_id, intent.intent_id)) + .get() + .pipe(Effect.orDie) + if (current?.side_effects_completed_at) return + return yield* Effect.fail( + new ForkConflict({ intentID: intent.intent_id, reason: "fork side effects are not committable" }), + ) + }) + + const forkUnlocked = Effect.fn("Session.fork")(function* (input: { sessionID: SessionID + intentID: string messageID?: MessageID directory?: string isolate?: "worktree" + forkMode?: "foreground" | "task" + targetSessionID?: SessionID + childDepth?: number + taskRequestHash?: string }) { + const forkMode = input.forkMode ?? "foreground" + const intentID = input.intentID + const requestHash = Hash.sha256( + CanonicalJson.stringify({ + version: 2, + forkMode, + sourceSessionID: input.sessionID, + cutoffMessageID: input.messageID, + directory: input.directory, + isolate: input.isolate, + targetSessionID: input.targetSessionID, + childDepth: input.childDepth, + taskRequestHash: input.taskRequestHash, + }), + ) + const retry = yield* db + .select() + .from(SessionForkIntentTable) + .where(eq(SessionForkIntentTable.intent_id, intentID)) + .get() + .pipe(Effect.orDie) + if (retry) { + if (retry.request_hash !== requestHash) { + return yield* Effect.fail( + new ForkConflict({ intentID, reason: "fork intent was reused with different input" }), + ) + } + const delivered = yield* deliverForkEvents(intentID) + const committed = yield* db + .select() + .from(SessionForkIntentTable) + .where(eq(SessionForkIntentTable.intent_id, intentID)) + .get() + .pipe(Effect.orDie) + if (!committed) return yield* Effect.die(new Error(`fork intent disappeared during retry: ${intentID}`)) + yield* completeForkSideEffects(committed) + return delivered + } + const existingAdmission = yield* db + .select() + .from(SessionForkAdmissionTable) + .where(eq(SessionForkAdmissionTable.intent_id, intentID)) + .get() + .pipe(Effect.orDie) + if (existingAdmission?.request_hash !== undefined && existingAdmission.request_hash !== requestHash) + return yield* Effect.fail( + new ForkConflict({ intentID, reason: "fork admission was reused with different input" }), + ) + if (existingAdmission?.state === "recovery_required") + return yield* Effect.fail( + new ForkConflict({ + intentID, + reason: existingAdmission.recovery_reason ?? "fork admission requires recovery", + }), + ) + if (existingAdmission?.state === "manifest_committed") + return yield* Effect.fail( + new ForkConflict({ intentID, reason: "fork admission committed without a readable child manifest" }), + ) + if (input.targetSessionID && !existingAdmission) { + const target = yield* db + .select({ id: SessionTable.id }) + .from(SessionTable) + .where(eq(SessionTable.id, input.targetSessionID)) + .get() + .pipe(Effect.orDie) + if (target) { + return yield* Effect.fail( + new ForkConflict({ intentID, reason: "target session exists without the matching fork intent" }), + ) + } + } + const ctx = yield* InstanceState.context const original = yield* get(input.sessionID) const title = getForkedTitle(original.title) + const sourceProjection = yield* MessageV2.promptHistoryProjectionEffect(input.sessionID).pipe( + Effect.provideService(Database.Service, database), + Effect.mapError( + (error) => + new ForkConflict({ + intentID, + reason: `source history is unavailable: ${ + error instanceof MessageV2.HistoryAuthorityError ? error.reason : error.message + }`, + }), + ), + ) + const sourceSession = yield* db + .select({ mutation_epoch: SessionTable.mutation_epoch }) + .from(SessionTable) + .where(eq(SessionTable.id, input.sessionID)) + .get() + .pipe(Effect.orDie) + if (!sourceSession) return yield* new NotFoundError({ message: `Session not found: ${input.sessionID}` }) + const cutoffIndex = input.messageID + ? sourceProjection.messages.findIndex((message) => message.info.id === input.messageID) + : sourceProjection.messages.length + if (cutoffIndex < 0) { + return yield* Effect.fail( + new ForkConflict({ intentID, reason: "fork cutoff is not part of the active effective history" }), + ) + } + if (sourceProjection.epoch > 0 && cutoffIndex < 2) { + return yield* Effect.fail( + new ForkConflict({ intentID, reason: "fork cutoff splits the active checkpoint pair" }), + ) + } + const sourceMessages = + forkMode === "task" + ? sanitizeTaskHistory(sourceProjection.messages.slice(0, cutoffIndex)) + : sourceProjection.messages.slice(0, cutoffIndex) + if (sourceProjection.epoch > 0 && sourceMessages.length < 2) { + return yield* Effect.fail(new ForkConflict({ intentID, reason: "fork would omit the active checkpoint pair" })) + } + if ( + forkMode === "task" && + sourceProjection.epoch > 0 && + (sourceMessages[0]?.info.role !== "user" || + sourceMessages[1]?.info.role !== "assistant" || + !sourceMessages[1].info.summary) + ) { + return yield* Effect.fail(new ForkConflict({ intentID, reason: "task sanitation removed the checkpoint pair" })) + } // Depth guard (max 3 levels ⇒ at most 2 forks deep). A fork's lineage is recorded in // `metadata.forkedFrom.parentSessionID` (foreground-safe: unlike `parentID`, it does NOT @@ -834,113 +1392,814 @@ export const layer: Layer.Layer< ) } - // 附-D 阶段4: optionally allocate a dedicated worktree for this fork. Mirrors the race-safe, - // non-git-tolerant pattern in tool/task.ts (P5 C7): the Worktree service is resolved OPTIONALLY - // so fork never becomes a hard requirement, the worktree name is unique per invocation via a - // monotonic identifier so concurrent forks can't collide on one name, and ONLY the non-git - // degradation (WorktreeNotGitError) falls back to a same-directory fork. Any other worktree - // failure is a defect (orDie) so it fails loud rather than silently un-isolating the fork — - // while keeping fork's typed error channel as NotFound. + // Freeze the exact child/resource identity before provisioning anything external. The admission + // row has no FK to the not-yet-created child, which is intentional: it is the crash boundary that + // the committed child manifest cannot represent. const worktreeOpt = input.isolate === "worktree" ? yield* Effect.serviceOption(Worktree.Service) : Option.none() + if (input.directory !== undefined && !containsPath(input.directory, ctx)) { + return yield* Effect.die(new Error(`Fork directory escapes the project boundary: ${input.directory}`)) + } + const proposedTargetSessionID = existingAdmission + ? SessionID.make(existingAdmission.target_session_id) + : SessionID.descending(input.targetSessionID) + if (input.targetSessionID && proposedTargetSessionID !== input.targetSessionID) + return yield* Effect.fail(new ForkConflict({ intentID, reason: "fork target Session ID changed on retry" })) + const plannedWorktree = + existingAdmission?.isolation_mode === "worktree" + ? { + operationKey: intentID, + name: path.basename(existingAdmission.worktree_directory!), + worktreeBranch: existingAdmission.worktree_branch!, + directory: existingAdmission.worktree_directory!, + baseCommit: existingAdmission.worktree_base_commit!, + } + : input.isolate === "worktree" && Option.isSome(worktreeOpt) + ? yield* worktreeOpt.value + .planExact({ operationKey: intentID, name: `fork-${Hash.sha256(intentID).slice(0, 16)}` }) + .pipe( + Effect.catchTag("WorktreeNotGitError", () => Effect.succeed(undefined)), + Effect.mapError((error) => new ForkConflict({ intentID, reason: error.message })), + ) + : undefined + const now = Date.now() + const insertedAdmission = existingAdmission + ? undefined + : yield* db + .insert(SessionForkAdmissionTable) + .values({ + intent_id: intentID, + request_hash: requestHash, + fork_mode: forkMode, + source_session_id: input.sessionID, + source_prompt_epoch: sourceProjection.epoch, + source_window_id: sourceProjection.window.windowID, + source_effective_history_hash: sourceProjection.effectiveHistoryHash, + source_mutation_epoch: sourceSession.mutation_epoch, + source_message_count: sourceProjection.messages.length, + source_cutoff_message_id: input.messageID ?? null, + projection_version: sourceProjection.projectionVersion, + sanitation_policy_version: forkMode === "task" ? 3 : 1, + requested_directory: input.directory ?? null, + isolation_mode: plannedWorktree ? "worktree" : "none", + requested_target_session_id: input.targetSessionID ?? null, + target_session_id: proposedTargetSessionID, + child_depth: input.childDepth ?? null, + task_request_hash: input.taskRequestHash ?? null, + worktree_directory: plannedWorktree?.directory ?? null, + worktree_branch: plannedWorktree?.worktreeBranch ?? null, + worktree_base_commit: plannedWorktree?.baseCommit ?? null, + state: plannedWorktree ? "admitted" : "ready", + recovery_reason: null, + time_created: now, + time_updated: now, + }) + .onConflictDoNothing() + .returning() + .get() + .pipe(Effect.orDie) + const admission = + existingAdmission ?? + insertedAdmission ?? + (yield* db + .select() + .from(SessionForkAdmissionTable) + .where(eq(SessionForkAdmissionTable.intent_id, intentID)) + .get() + .pipe(Effect.orDie)) + if (!admission) { + const targetOwner = yield* db + .select({ intent_id: SessionForkAdmissionTable.intent_id }) + .from(SessionForkAdmissionTable) + .where(eq(SessionForkAdmissionTable.target_session_id, proposedTargetSessionID)) + .get() + .pipe(Effect.orDie) + return yield* Effect.fail( + new ForkConflict({ + intentID, + reason: targetOwner + ? `fork target Session is reserved by ${targetOwner.intent_id}` + : "fork admission conflict did not resolve to a durable authority", + }), + ) + } + if (admission.request_hash !== requestHash) + return yield* Effect.fail( + new ForkConflict({ intentID, reason: "fork admission was reused with different input" }), + ) + const targetSessionID = SessionID.make(admission.target_session_id) + if (input.targetSessionID && targetSessionID !== input.targetSessionID) + return yield* Effect.fail(new ForkConflict({ intentID, reason: "fork target Session ID changed on retry" })) + if ( + admission.source_prompt_epoch !== sourceProjection.epoch || + admission.source_window_id !== sourceProjection.window.windowID || + admission.source_effective_history_hash !== sourceProjection.effectiveHistoryHash || + admission.source_mutation_epoch !== sourceSession.mutation_epoch || + admission.source_message_count !== sourceProjection.messages.length || + admission.projection_version !== sourceProjection.projectionVersion + ) { + yield* db + .update(SessionForkAdmissionTable) + .set({ + state: "recovery_required", + recovery_reason: "source history changed after fork admission", + time_updated: now, + }) + .where(eq(SessionForkAdmissionTable.intent_id, intentID)) + .run() + .pipe(Effect.orDie) + return yield* Effect.fail(new ForkConflict({ intentID, reason: "source history changed after fork admission" })) + } + if (plannedWorktree && Option.isNone(worktreeOpt)) { + yield* db + .update(SessionForkAdmissionTable) + .set({ + state: "recovery_required", + recovery_reason: "managed worktree service is unavailable during fork recovery", + time_updated: Date.now(), + }) + .where(eq(SessionForkAdmissionTable.intent_id, intentID)) + .run() + .pipe(Effect.orDie) + return yield* Effect.fail( + new ForkConflict({ intentID, reason: "managed worktree service is unavailable during fork recovery" }), + ) + } const worktreeInfo = - input.isolate === "worktree" && Option.isSome(worktreeOpt) - ? yield* worktreeOpt.value.createReady({ name: `fork-${Identifier.ascending("session")}` }).pipe( - Effect.catchTag("WorktreeNotGitError", () => Effect.succeed(undefined)), - Effect.orDie, - ) + plannedWorktree && Option.isSome(worktreeOpt) + ? yield* Effect.gen(function* () { + yield* db + .update(SessionForkAdmissionTable) + .set({ state: "provisioning", time_updated: Date.now() }) + .where( + and( + eq(SessionForkAdmissionTable.intent_id, intentID), + eq(SessionForkAdmissionTable.state, "admitted"), + ), + ) + .run() + .pipe(Effect.orDie) + const provisioned = yield* worktreeOpt.value.ensureExact(plannedWorktree).pipe( + Effect.mapError( + (error) => + new ForkConflict({ + intentID, + reason: error instanceof Worktree.WorktreeExactConflictError ? error.reason : error.message, + }), + ), + Effect.tapError((error) => + db + .update(SessionForkAdmissionTable) + .set({ state: "recovery_required", recovery_reason: error.reason, time_updated: Date.now() }) + .where(eq(SessionForkAdmissionTable.intent_id, intentID)) + .run() + .pipe(Effect.orDie), + ), + ) + yield* db + .update(SessionForkAdmissionTable) + .set({ state: "ready", recovery_reason: null, time_updated: Date.now() }) + .where( + and( + eq(SessionForkAdmissionTable.intent_id, intentID), + eq(SessionForkAdmissionTable.state, "provisioning"), + ), + ) + .run() + .pipe(Effect.orDie) + return provisioned + }) : undefined - - // 附-D 阶段3: resolve the effective fork directory. Precedence: a fresh worktree (阶段4) > - // an explicit input.directory (阶段3) > the instance directory (today's behavior). - // - // Boundary guard (fail-closed): unlike create(), whose HTTP CreateInput schema does NOT expose a - // `directory` field (only trusted internal callers like tool/task.ts set it to a managed path), - // ForkInput DOES expose `directory` and it flows through the public ForkPayload/forkRaw HTTP - // route — so an untrusted client can pick the fork's cwd. sessionPath() only re-derives the - // stored `path` string; it does NOT stop the session's `directory` (the real cwd downstream - // tools operate in) from pointing outside the managed boundary. We therefore reject a - // client-supplied directory that escapes the instance boundary (ctx.directory OR the worktree - // root, per containsPath). The worktree-allocated path is trusted (Worktree.Service owns it and - // it can legitimately be a sibling of the checkout), and ctx.directory is trivially contained, - // so only an explicit input.directory is validated. - if (worktreeInfo === undefined && input.directory !== undefined && !containsPath(input.directory, ctx)) { - return yield* Effect.die(new Error(`Fork directory escapes the project boundary: ${input.directory}`)) + const directory = worktreeInfo?.directory ?? admission.requested_directory ?? ctx.directory + const worldStateBaselineExit = yield* Effect.exit(collectSessionWorldStateBaseline({ workspacePath: directory })) + if (Exit.isFailure(worldStateBaselineExit)) { + yield* db + .update(SessionForkAdmissionTable) + .set({ + state: "recovery_required", + recovery_reason: "world state baseline collection failed", + time_updated: Date.now(), + }) + .where(eq(SessionForkAdmissionTable.intent_id, intentID)) + .run() + .pipe(Effect.orDie) + return yield* Effect.failCause(worldStateBaselineExit.cause) } - const directory = worktreeInfo?.directory ?? input.directory ?? ctx.directory + const worldStateBaseline = worldStateBaselineExit.value // Record the fork lineage on the new session's metadata so the client can (a) render a // "derived from ‹parent›" banner at the top of the transcript and (b) nest the fork under its // parent in the session tree. This is carried in `metadata` (already DB-persisted + synced to // the client + cloned by fork) rather than `parentID` — a fork is a foreground session, and // `parentID` would misclassify it as a background subagent. It mirrors the ForkOrigin marker // persisted below (context store), but travels with Session.Info so no extra IO/route is needed. + const firstWindowID = HistoryAuthority.windowID() + const targetWindowID = sourceProjection.epoch === 0 ? firstWindowID : HistoryAuthority.windowID() + const messageIDMap = new Map( + sourceMessages.map((message) => [ + message.info.id, + MessageID.make( + `msg_${message.info.id.replace(/^msg_?/, "")}_${Hash.sha256(`fork-map:v1:${intentID}:${message.info.id}`).slice(0, 12)}`, + ), + ]), + ) + const cloned = sourceMessages.map((message) => { + const id = messageIDMap.get(message.info.id)! + const info: SessionV1.Info = + message.info.role === "assistant" + ? { + ...message.info, + id, + sessionID: targetSessionID, + parentID: messageIDMap.get(message.info.parentID)!, + } + : { ...message.info, id, sessionID: targetSessionID } + if (info.role === "assistant" && !info.parentID) { + throw new ForkConflict({ + intentID, + reason: `assistant ${message.info.id} has a parent outside the fork projection`, + }) + } + return { + info, + parts: message.parts.map((part) => ({ + ...part, + id: PartID.make( + `prt_${part.id.replace(/^prt_?/, "")}_${Hash.sha256(`fork-map:v1:${intentID}:${part.id}`).slice(0, 12)}`, + ), + messageID: id, + sessionID: targetSessionID, + ...(part.type === "compaction" + ? { tail_start_id: part.tail_start_id ? messageIDMap.get(part.tail_start_id) : undefined } + : {}), + })) as SessionV1.Part[], + } + }) + const targetEffectiveHistoryHash = HistoryAuthority.hash(cloned) const forkedFrom = { + manifestVersion: 1, + manifestState: "prepared", + forkIntentID: intentID, + forkMode, parentSessionID: input.sessionID, parentTitle: original.title, ...(input.messageID ? { cutoffMessageID: input.messageID } : {}), + sourcePromptEpoch: sourceProjection.epoch, + sourceWindowID: sourceProjection.window.windowID, + sourceEffectiveHistoryHash: sourceProjection.effectiveHistoryHash, + sourceMutationEpoch: sourceSession.mutation_epoch, + sourceMessageCount: sourceProjection.messages.length, + projectionVersion: sourceProjection.projectionVersion, + sanitationPolicyVersion: forkMode === "task" ? 3 : 1, + ...(input.taskRequestHash ? { taskRequestHash: input.taskRequestHash } : {}), + targetPromptEpoch: sourceProjection.epoch === 0 ? 0 : 1, + targetWindowID, + targetEffectiveHistoryHash, + targetWorldStateBaselineHash: worldStateBaseline.hash, forkedAt: Date.now(), } - const session = yield* createNext({ + const session: Info = { + id: targetSessionID, + slug: Slug.create(), + version: InstallationVersion, + projectID: ctx.project.id, directory, path: sessionPath(ctx.worktree, directory), + ...(forkMode === "task" ? { parentID: input.sessionID } : {}), workspaceID: original.workspaceID, title, - metadata: { ...structuredClone(original.metadata), forkedFrom }, - }) - const msgs = yield* messages({ sessionID: input.sessionID }) - const idMap = new Map() - - for (const msg of msgs) { - if (input.messageID && msg.info.id >= input.messageID) break - const newID = MessageID.ascending() - idMap.set(msg.info.id, newID) - - const parentID = msg.info.role === "assistant" && msg.info.parentID ? idMap.get(msg.info.parentID) : undefined - const cloned = yield* updateMessage({ - ...msg.info, - sessionID: session.id, - id: newID, - ...(parentID && { parentID }), + metadata: + forkMode === "task" + ? { + deepagent: { + task_fork_manifest: forkedFrom, + subagentDepth: input.childDepth ?? 0, + }, + } + : { ...structuredClone(original.metadata), forkedFrom }, + cost: 0, + tokens: EmptyTokens, + time: { created: Date.now(), updated: Date.now() }, + } + const sourceCheckpointUserID = sourceProjection.epoch > 0 ? sourceMessages[0]?.info.id : undefined + const sourceCheckpointAssistantID = sourceProjection.epoch > 0 ? sourceMessages[1]?.info.id : undefined + const sourceMarker = sourceProjection.epoch > 0 ? sourceMessages[0] : undefined + const sourceTailStartID = sourceMarker?.parts.find( + (part): part is SessionV1.CompactionPart => part.type === "compaction", + )?.tail_start_id + const targetCheckpointUserID = sourceCheckpointUserID ? messageIDMap.get(sourceCheckpointUserID) : undefined + const targetCheckpointAssistantID = sourceCheckpointAssistantID + ? messageIDMap.get(sourceCheckpointAssistantID) + : undefined + const targetTailStartID = sourceTailStartID ? messageIDMap.get(sourceTailStartID) : undefined + if (sourceProjection.epoch > 0 && (!targetCheckpointUserID || !targetCheckpointAssistantID)) { + return yield* Effect.fail( + new ForkConflict({ intentID, reason: "fork projection has an invalid checkpoint pair" }), + ) + } + + yield* events + .publish( + SessionV1.Event.Created, + { sessionID: session.id, info: session }, + { + commit: () => + Effect.gen(function* () { + const admitted = yield* db + .select({ + state: SessionForkAdmissionTable.state, + target_session_id: SessionForkAdmissionTable.target_session_id, + }) + .from(SessionForkAdmissionTable) + .where(eq(SessionForkAdmissionTable.intent_id, intentID)) + .get() + if (!admitted || admitted.state !== "ready" || admitted.target_session_id !== session.id) + return yield* Effect.die( + new ForkConflict({ intentID, reason: "fork admission is not ready for manifest commit" }), + ) + const currentSource = yield* MessageV2.promptHistoryProjectionEffect(input.sessionID).pipe( + Effect.provideService(Database.Service, database), + Effect.orDie, + ) + const currentSession = yield* db + .select({ mutation_epoch: SessionTable.mutation_epoch }) + .from(SessionTable) + .where(eq(SessionTable.id, input.sessionID)) + .get() + if ( + !currentSession || + currentSession.mutation_epoch !== sourceSession.mutation_epoch || + currentSource.epoch !== sourceProjection.epoch || + currentSource.window.windowID !== sourceProjection.window.windowID || + currentSource.effectiveHistoryHash !== sourceProjection.effectiveHistoryHash || + currentSource.messages.length !== sourceProjection.messages.length + ) { + return yield* Effect.die(new ForkConflict({ intentID, reason: "source history changed during fork" })) + } + const concurrent = yield* db + .select({ request_hash: SessionForkIntentTable.request_hash }) + .from(SessionForkIntentTable) + .where(eq(SessionForkIntentTable.intent_id, intentID)) + .get() + if (concurrent) { + return yield* Effect.die( + new ForkConflict({ intentID, reason: "fork intent was committed concurrently" }), + ) + } + + for (const message of cloned) { + yield* db + .insert(MessageTable) + .values({ + id: message.info.id, + session_id: session.id, + time_created: message.info.time.created, + time_updated: message.info.time.created, + data: Object.fromEntries( + Object.entries(message.info).filter(([key]) => key !== "id" && key !== "sessionID"), + ) as typeof MessageTable.$inferInsert.data, + }) + .run() + for (const part of message.parts) { + yield* db + .insert(PartTable) + .values({ + id: part.id, + message_id: message.info.id, + session_id: session.id, + time_created: message.info.time.created, + time_updated: message.info.time.created, + data: Object.fromEntries( + Object.entries(part).filter( + ([key]) => key !== "id" && key !== "messageID" && key !== "sessionID", + ), + ) as typeof PartTable.$inferInsert.data, + }) + .run() + } + } + + const now = Date.now() + if (sourceProjection.epoch === 0) { + yield* db + .insert(SessionPromptEpochTable) + .values({ + session_id: session.id, + epoch: 0, + state: "active", + checkpoint_user_id: null, + checkpoint_assistant_id: null, + retained_tail_start_id: null, + source_end_message_id: cloned.at(-1)?.info.id ?? null, + checkpoint_hash: targetEffectiveHistoryHash, + projection_version: HistoryAuthority.PROJECTION_VERSION, + canonicalization_version: HistoryAuthority.CANONICALIZATION_VERSION, + base_message_count: cloned.length, + effective_history_hash: targetEffectiveHistoryHash, + first_window_id: firstWindowID, + previous_window_id: null, + window_id: targetWindowID, + world_state_baseline_hash: worldStateBaseline.hash, + authority_state: "ready", + recovery_reason: null, + reason: "bootstrap", + created_at: now, + retired_at: null, + }) + .run() + } else { + yield* db + .insert(SessionPromptEpochTable) + .values([ + { + session_id: session.id, + epoch: 0, + state: "retired", + checkpoint_user_id: null, + checkpoint_assistant_id: null, + retained_tail_start_id: null, + source_end_message_id: null, + checkpoint_hash: HistoryAuthority.hash([]), + projection_version: HistoryAuthority.PROJECTION_VERSION, + canonicalization_version: HistoryAuthority.CANONICALIZATION_VERSION, + base_message_count: 0, + effective_history_hash: HistoryAuthority.hash([]), + first_window_id: firstWindowID, + previous_window_id: null, + window_id: firstWindowID, + world_state_baseline_hash: null, + authority_state: "ready", + recovery_reason: null, + reason: "bootstrap", + created_at: now, + retired_at: now, + }, + { + session_id: session.id, + epoch: 1, + state: "active", + checkpoint_user_id: targetCheckpointUserID!, + checkpoint_assistant_id: targetCheckpointAssistantID!, + retained_tail_start_id: targetTailStartID ?? null, + source_end_message_id: cloned.at(-1)?.info.id ?? null, + checkpoint_hash: targetEffectiveHistoryHash, + projection_version: HistoryAuthority.PROJECTION_VERSION, + canonicalization_version: HistoryAuthority.CANONICALIZATION_VERSION, + base_message_count: cloned.length, + effective_history_hash: targetEffectiveHistoryHash, + first_window_id: firstWindowID, + previous_window_id: firstWindowID, + window_id: targetWindowID, + world_state_baseline_hash: worldStateBaseline.hash, + authority_state: "ready", + recovery_reason: null, + reason: "compaction", + created_at: now, + retired_at: null, + }, + ]) + .run() + } + if (cloned.length > 0) { + yield* db + .insert(SessionPromptEpochMessageTable) + .values( + cloned.map((message, ordinal) => ({ + session_id: session.id, + prompt_epoch: sourceProjection.epoch === 0 ? 0 : 1, + ordinal, + message_id: message.info.id, + })), + ) + .run() + } + yield* db + .insert(SessionWorldStateBaselineTable) + .values( + worldStateBaseline.sections.map((section) => ({ + session_id: session.id, + prompt_epoch: sourceProjection.epoch === 0 ? 0 : 1, + section_id: section.sectionID, + snapshot: section.snapshot, + fragment: section.fragment, + fragment_hash: section.fragmentHash, + provenance: "fork_rebuilt" as const, + created_at: now, + })), + ) + .run() + yield* db + .insert(SessionHistoryStateTable) + .values({ + session_id: session.id, + state: "ready", + reason: null, + time_created: now, + time_updated: now, + }) + .run() + yield* db + .insert(SessionForkIntentTable) + .values({ + intent_id: intentID, + request_hash: requestHash, + fork_mode: forkMode, + source_session_id: input.sessionID, + source_prompt_epoch: sourceProjection.epoch, + source_window_id: sourceProjection.window.windowID, + source_effective_history_hash: sourceProjection.effectiveHistoryHash, + source_mutation_epoch: sourceSession.mutation_epoch, + source_message_count: sourceProjection.messages.length, + source_cutoff_message_id: input.messageID ?? null, + projection_version: sourceProjection.projectionVersion, + sanitation_policy_version: forkMode === "task" ? 3 : 1, + target_session_id: session.id, + target_prompt_epoch: sourceProjection.epoch === 0 ? 0 : 1, + target_window_id: targetWindowID, + target_effective_history_hash: targetEffectiveHistoryHash, + target_world_state_baseline_hash: worldStateBaseline.hash, + cloned_message_count: cloned.length, + cloned_part_count: cloned.reduce((total, message) => total + message.parts.length, 0), + state: "committed", + event_cursor: 0, + event_count: cloned.reduce((total, message) => total + message.parts.length + 1, 1), + delivery_owner: null, + lease_expires_at: null, + delivery_attempts: 0, + recovery_reason: null, + time_created: now, + time_updated: now, + time_committed: now, + time_completed: null, + side_effects_completed_at: null, + }) + .run() + const committedAdmission = yield* db + .update(SessionForkAdmissionTable) + .set({ state: "manifest_committed", recovery_reason: null, time_updated: now }) + .where( + and( + eq(SessionForkAdmissionTable.intent_id, intentID), + eq(SessionForkAdmissionTable.state, "ready"), + ), + ) + .returning({ intent_id: SessionForkAdmissionTable.intent_id }) + .get() + if (!committedAdmission) + return yield* Effect.die( + new ForkConflict({ intentID, reason: "fork admission ownership was lost during commit" }), + ) + }).pipe(Effect.orDie), + }, + ) + .pipe( + Effect.catchDefect((defect: unknown) => + defect instanceof ForkConflict ? Effect.fail(defect) : Effect.die(defect), + ), + Effect.onError(() => + db + .update(SessionForkAdmissionTable) + .set({ + state: "recovery_required", + recovery_reason: "fork manifest commit failed after durable admission", + time_updated: Date.now(), + }) + .where( + and( + eq(SessionForkAdmissionTable.intent_id, intentID), + inArray(SessionForkAdmissionTable.state, ["admitted", "provisioning", "ready"] as const), + ), + ) + .run() + .pipe(Effect.ignore), + ), + Effect.catchIf( + (error) => error instanceof ForkConflict && error.reason === "fork intent was committed concurrently", + () => + Effect.gen(function* () { + const concurrent = yield* db + .select({ request_hash: SessionForkIntentTable.request_hash }) + .from(SessionForkIntentTable) + .where(eq(SessionForkIntentTable.intent_id, intentID)) + .get() + .pipe(Effect.orDie) + if (!concurrent) { + return yield* Effect.die(new Error(`concurrent fork intent disappeared: ${intentID}`)) + } + if (concurrent.request_hash !== requestHash) { + return yield* Effect.fail( + new ForkConflict({ intentID, reason: "fork intent was reused with different input" }), + ) + } + }), + ), + ) + + const delivered = yield* deliverForkEvents(intentID) + const committedIntent = yield* db + .select() + .from(SessionForkIntentTable) + .where(eq(SessionForkIntentTable.intent_id, intentID)) + .get() + .pipe(Effect.orDie) + if (!committedIntent) return yield* Effect.die(new Error(`fork intent disappeared: ${intentID}`)) + yield* completeForkSideEffects(committedIntent) + return delivered + }) + + const fork: Interface["fork"] = (input) => { + return forkLocks.withLock(input.intentID)(forkUnlocked(input)) + } + + const recoverForks: Interface["recoverForks"] = Effect.fn("Session.recoverForks")(function* () { + const now = Date.now() + const ctx = yield* InstanceState.context + const recoverableAdmissions = yield* db + .select({ admission: SessionForkAdmissionTable }) + .from(SessionForkAdmissionTable) + .innerJoin(SessionTable, eq(SessionTable.id, SessionForkAdmissionTable.source_session_id)) + .where( + and( + eq(SessionTable.project_id, ctx.project.id), + inArray(SessionForkAdmissionTable.state, ["admitted", "provisioning", "ready"] as const), + ), + ) + .all() + .pipe(Effect.orDie) + yield* Effect.forEach( + recoverableAdmissions, + ({ admission }) => + fork({ + sessionID: SessionID.make(admission.source_session_id), + intentID: admission.intent_id, + messageID: admission.source_cutoff_message_id + ? MessageID.make(admission.source_cutoff_message_id) + : undefined, + directory: admission.requested_directory ?? undefined, + isolate: admission.isolation_mode === "worktree" ? "worktree" : undefined, + forkMode: admission.fork_mode, + targetSessionID: admission.requested_target_session_id + ? SessionID.make(admission.requested_target_session_id) + : undefined, + childDepth: admission.child_depth ?? undefined, + taskRequestHash: admission.task_request_hash ?? undefined, + }).pipe(Effect.catchCause(() => Effect.void)), + { discard: true }, + ) + const brokenAdmissions = yield* db + .select({ intent_id: SessionForkAdmissionTable.intent_id }) + .from(SessionForkAdmissionTable) + .leftJoin(SessionForkIntentTable, eq(SessionForkIntentTable.intent_id, SessionForkAdmissionTable.intent_id)) + .where(and(eq(SessionForkAdmissionTable.state, "manifest_committed"), isNull(SessionForkIntentTable.intent_id))) + .all() + .pipe(Effect.orDie) + if (brokenAdmissions.length > 0) + yield* db + .update(SessionForkAdmissionTable) + .set({ + state: "recovery_required", + recovery_reason: "fork admission committed without a child manifest", + time_updated: now, + }) + .where( + inArray( + SessionForkAdmissionTable.intent_id, + brokenAdmissions.map((row) => row.intent_id), + ), + ) + .run() + .pipe(Effect.orDie) + yield* db + .update(SessionForkIntentTable) + .set({ + state: "recovery_required", + recovery_reason: "fork preparation was committed without an atomic child manifest", + time_updated: now, }) + .where(eq(SessionForkIntentTable.state, "prepared")) + .run() + .pipe(Effect.orDie) + const due = (yield* db + .select() + .from(SessionForkIntentTable) + .where( + or( + inArray(SessionForkIntentTable.state, ["committed", "publishing"] as const), + and(eq(SessionForkIntentTable.state, "complete"), isNull(SessionForkIntentTable.side_effects_completed_at)), + ), + ) + .all() + .pipe(Effect.orDie)).filter( + (intent) => + intent.state === "committed" || + intent.state === "complete" || + !intent.lease_expires_at || + intent.lease_expires_at <= now, + ) + yield* Effect.forEach( + due, + (intent) => + Effect.gen(function* () { + yield* deliverForkEvents(intent.intent_id) + yield* completeForkSideEffects(intent) + }).pipe(Effect.catchCause(() => Effect.void)), + { discard: true }, + ) + }) - for (const part of msg.parts) { - const p: SessionV1.Part = { - ...part, - id: PartID.ascending(), - messageID: cloned.id, - sessionID: session.id, - } - if (p.type === "compaction" && p.tail_start_id) { - p.tail_start_id = idMap.get(p.tail_start_id) - } - yield* updatePart(p) - } + const assertRunnable: Interface["assertRunnable"] = Effect.fn("Session.assertRunnable")(function* (sessionID) { + const quarantinedPart = yield* db + .select({ part_id: SessionPartIntegrityQuarantineTable.part_id }) + .from(SessionPartIntegrityQuarantineTable) + .where( + or( + eq(SessionPartIntegrityQuarantineTable.part_session_id, sessionID), + eq(SessionPartIntegrityQuarantineTable.message_session_id, sessionID), + ), + ) + .get() + .pipe(Effect.orDie) + if (quarantinedPart) + return yield* new UnavailableError({ + sessionID, + reason: `legacy cross-session Part ${quarantinedPart.part_id} requires history repair`, + }) + const intent = yield* db + .select({ + intent_id: SessionForkIntentTable.intent_id, + state: SessionForkIntentTable.state, + recovery_reason: SessionForkIntentTable.recovery_reason, + side_effects_completed_at: SessionForkIntentTable.side_effects_completed_at, + }) + .from(SessionForkIntentTable) + .where(eq(SessionForkIntentTable.target_session_id, sessionID)) + .get() + .pipe(Effect.orDie) + if (intent && (intent.state !== "complete" || intent.side_effects_completed_at === null)) + return yield* new UnavailableError({ + sessionID, + reason: intent.recovery_reason ?? `fork manifest ${intent.intent_id} is ${intent.state}`, + }) + if (!intent) { + const admission = yield* db + .select({ intent_id: SessionForkAdmissionTable.intent_id, state: SessionForkAdmissionTable.state }) + .from(SessionForkAdmissionTable) + .where(eq(SessionForkAdmissionTable.target_session_id, sessionID)) + .get() + .pipe(Effect.orDie) + if (admission) + return yield* new UnavailableError({ + sessionID, + reason: `fork admission ${admission.intent_id} is ${admission.state}`, + }) + const session = yield* db + .select({ metadata: SessionTable.metadata }) + .from(SessionTable) + .where(eq(SessionTable.id, sessionID)) + .get() + .pipe(Effect.orDie) + const deepagent = + session?.metadata?.deepagent && typeof session.metadata.deepagent === "object" + ? (session.metadata.deepagent as Record) + : undefined + if (deepagent?.task_fork_manifest || session?.metadata?.task_fork_manifest) + return yield* new UnavailableError({ + sessionID, + reason: "legacy task fork has no verifiable sanitation manifest", + }) + if (session?.metadata?.forkedFrom) + return yield* new UnavailableError({ + sessionID, + reason: "legacy foreground fork has no verifiable source projection manifest", + }) } - - // 附-D fork memory completeness: fork now carries the parent's "memory", not just its - // messages/parts/metadata. (1) Forward the parent's Session Ledger (App-A §C2) into the fork's - // own ledger store so it opens with the parent's structured facts. (2) Persist an OBJECT cutoff - // marker (ForkOrigin) recording parent sessionID + the messageID the fork was cut at — the - // divergence point was previously only an imperative "skip messages" with no persisted record. - // Both are default-safe (recover from the CAUSE — DocumentStore construction throws - // synchronously; a copy/IO failure degrades to "fork without forwarded memory", never a failed - // fork), and both are keyed only by sessionID (context store root is under - // Global.Path.agent.data/state/context/) so they are independent of the fork's - // directory / dedicated worktree. - // - // SEAM — 附-D 阶段5 compare/merge (diff a fork's ledger against its parent, reconcile divergent - // branches) is a V4.0 parallel-exploration workflow and is NOT implemented here. The ForkOrigin - // marker written below is its future anchor: the cutoff point compare/merge will diff around. - yield* forwardLedgerOnFork({ parentSessionID: input.sessionID, forkSessionID: session.id }) - yield* persistForkOrigin({ - forkSessionID: session.id, - origin: { - parentSessionID: input.sessionID, - ...(input.messageID ? { cutoffMessageID: input.messageID } : {}), - forkedAt: Date.now(), - }, + const history = yield* db + .select({ + state: SessionHistoryStateTable.state, + reason: SessionHistoryStateTable.reason, + }) + .from(SessionHistoryStateTable) + .where(eq(SessionHistoryStateTable.session_id, sessionID)) + .get() + .pipe(Effect.orDie) + if (history?.state === "recovery_required") + return yield* new UnavailableError({ + sessionID, + reason: history.reason ?? "session history recovery is required", + }) + const epoch = yield* db + .select({ + authority_state: SessionPromptEpochTable.authority_state, + recovery_reason: SessionPromptEpochTable.recovery_reason, + }) + .from(SessionPromptEpochTable) + .where(and(eq(SessionPromptEpochTable.session_id, sessionID), eq(SessionPromptEpochTable.state, "active"))) + .get() + .pipe(Effect.orDie) + if (epoch?.authority_state !== "recovery_required") return + return yield* new UnavailableError({ + sessionID, + reason: epoch.recovery_reason ?? "session history recovery is required", }) - return session }) const patch = (sessionID: SessionID, info: Patch) => @@ -1172,6 +2431,7 @@ export const layer: Layer.Layer< sessionID: SessionID messageID: MessageID }) { + yield* requireMessageOwnership(input).pipe(Effect.orDie) yield* events.publish(SessionV1.Event.MessageRemoved, { sessionID: input.sessionID, messageID: input.messageID, @@ -1184,6 +2444,7 @@ export const layer: Layer.Layer< messageID: MessageID partID: PartID }) { + yield* requirePartOwnership(input).pipe(Effect.orDie) yield* events.publish(SessionV1.Event.PartRemoved, { sessionID: input.sessionID, messageID: input.messageID, @@ -1199,6 +2460,7 @@ export const layer: Layer.Layer< field: string delta: string }) { + yield* requirePartOwnership(input).pipe(Effect.orDie) yield* events.publish(MessageV2.Event.PartDelta, input) }) @@ -1226,6 +2488,8 @@ export const layer: Layer.Layer< listGlobal, create, fork, + recoverForks, + assertRunnable, touch, get, mutationEpoch, diff --git a/packages/deepagent-code/src/session/steer.ts b/packages/deepagent-code/src/session/steer.ts index 85f9fa41..b8396c20 100644 --- a/packages/deepagent-code/src/session/steer.ts +++ b/packages/deepagent-code/src/session/steer.ts @@ -15,6 +15,7 @@ import { SessionV1 } from "@deepagent-code/core/v1/session" import { MessageID, SessionID } from "./schema" import type { Receipt } from "./prompt-intent" import { SessionMutationEpoch } from "./mutation-epoch" +import { SessionPromptEpochTable } from "./prompt-epoch.sql" // V4.1 §S1.1 — the durable mid-turn STEER buffer. // @@ -50,6 +51,7 @@ export class Admitted extends Schema.Class("SessionSteer.Admitted")({ seq: Schema.Int, id: SessionMessage.ID, sessionID: SessionID, + correlationID: Schema.optional(Schema.String), prompt: Prompt, delivery: SessionInput.Delivery, mutationEpoch: Schema.Int, @@ -64,6 +66,7 @@ const fromRow = (row: typeof SessionSteerTable.$inferSelect): Admitted => seq: row.seq, id: SessionMessage.ID.make(row.id), sessionID: SessionID.make(row.session_id), + correlationID: row.correlation_id ?? undefined, prompt: decodePrompt(row.prompt), delivery: row.delivery, mutationEpoch: row.mutation_epoch, @@ -102,6 +105,10 @@ export interface Interface { ) => Effect.Effect // Non-consuming peek used by the loop's needsFollowUp decision. `delivery` (default "steer") scopes it. readonly hasPending: (sessionID: SessionID, delivery?: Delivery) => Effect.Effect + // Reserve a stable history timestamp immediately before V1 materialization. It is ordered strictly + // after the active PromptEpoch boundary so a steer admitted before compaction cannot sort into the + // retired physical prefix. The reservation is durable and reused by crash retries. + readonly materializationTime: (admitted: Admitted) => Effect.Effect readonly materialize: (input: { readonly admitted: Admitted readonly info: SessionV1.User @@ -315,6 +322,80 @@ export const layer = Layer.effect( return row !== undefined }) + const materializationTime: Interface["materializationTime"] = Effect.fn("SessionSteer.materializationTime")( + function* (admitted) { + return yield* db + .transaction( + (tx) => + Effect.gen(function* () { + const session = yield* tx + .select({ mutationEpoch: SessionTable.mutation_epoch }) + .from(SessionTable) + .where(eq(SessionTable.id, admitted.sessionID)) + .get() + .pipe(Effect.orDie) + if (!session) return yield* Effect.die(`Session not found: ${admitted.sessionID}`) + if (session.mutationEpoch !== admitted.mutationEpoch) + return yield* Effect.fail( + new SessionMutationEpoch.Stale({ + sessionID: admitted.sessionID, + observed: admitted.mutationEpoch, + current: session.mutationEpoch, + }), + ) + const steer = yield* tx + .select({ materialized_at: SessionSteerTable.materialized_at }) + .from(SessionSteerTable) + .where( + and( + eq(SessionSteerTable.id, admitted.id), + eq(SessionSteerTable.mutation_epoch, session.mutationEpoch), + isNull(SessionSteerTable.consumed_seq), + isNull(SessionSteerTable.superseded_at), + ), + ) + .get() + .pipe(Effect.orDie) + if (!steer) return yield* Effect.die(`Pending steer not found: ${admitted.id}`) + if (steer.materialized_at !== null) return steer.materialized_at + const epoch = yield* tx + .select({ source_end_message_id: SessionPromptEpochTable.source_end_message_id }) + .from(SessionPromptEpochTable) + .where( + and( + eq(SessionPromptEpochTable.session_id, admitted.sessionID), + eq(SessionPromptEpochTable.state, "active"), + ), + ) + .get() + .pipe(Effect.orDie) + const boundary = epoch?.source_end_message_id + ? yield* tx + .select({ time_created: MessageTable.time_created }) + .from(MessageTable) + .where(eq(MessageTable.id, MessageID.make(epoch.source_end_message_id))) + .get() + .pipe(Effect.orDie) + : undefined + const materializedAt = Math.max( + DateTime.toEpochMillis(yield* DateTime.now), + (boundary?.time_created ?? -1) + 1, + ) + const reserved = yield* tx + .update(SessionSteerTable) + .set({ materialized_at: materializedAt }) + .where(and(eq(SessionSteerTable.id, admitted.id), isNull(SessionSteerTable.materialized_at))) + .returning({ materialized_at: SessionSteerTable.materialized_at }) + .get() + .pipe(Effect.orDie) + return reserved?.materialized_at ?? materializedAt + }), + { behavior: "immediate" }, + ) + .pipe(Effect.catchTag("SqlError", Effect.die)) + }, + ) + const materialize: Interface["materialize"] = Effect.fn("SessionSteer.materialize")(function* (input) { if (String(input.info.id) !== String(input.admitted.id) || input.info.sessionID !== input.admitted.sessionID) return yield* Effect.die("SessionSteer.materialize: message identity does not match admitted steer") @@ -428,7 +509,7 @@ export const layer = Layer.effect( .pipe(Effect.catchTag("SqlError", Effect.die)) }) - return Service.of({ admit, pending, markConsumed, hasPending, materialize }) + return Service.of({ admit, pending, markConsumed, hasPending, materializationTime, materialize }) }), ) diff --git a/packages/deepagent-code/src/session/task-delivery.ts b/packages/deepagent-code/src/session/task-delivery.ts index 5d28916d..fc7ed040 100644 --- a/packages/deepagent-code/src/session/task-delivery.ts +++ b/packages/deepagent-code/src/session/task-delivery.ts @@ -227,6 +227,7 @@ export function admitParentInput(input: { }, metadata: { deepagent: { + planProtocolActivityID: input.item.messageID, task_notification: { run_id: input.item.runID, outbox_id: input.item.id, diff --git a/packages/deepagent-code/src/session/task-fork.ts b/packages/deepagent-code/src/session/task-fork.ts index 0461327b..39560c07 100644 --- a/packages/deepagent-code/src/session/task-fork.ts +++ b/packages/deepagent-code/src/session/task-fork.ts @@ -1,86 +1,36 @@ /** - * task-fork.ts — Session.forkForTask implementation. + * Task fork adapter. * - * Design: subagent-control-plane-design.zh-CN.md §3.2, §10.4 - * - * Extends the existing Session.fork primitive with: - * - caller-supplied deterministic child session ID - * - durable compact clone manifest written atomically on first insert - * - deterministic source→target message/part ID derivation via SHA-256 - * - crash recovery: re-read manifest and verify exact match on retry - * - * Invariants: - * #7 (design): task fork creates child Session identity on first insert; - * TaskProvisioner must not create an empty child first - * Crash recovery: target exists → verify manifest → adopt or conflict + * Task forks deliberately use the same Session.fork authority as foreground forks. The adapter only + * supplies task identity, child location and the versioned sanitation mode; it must not maintain a + * second raw MessageTable clone protocol. */ import { Data, Effect } from "effect" -import { Hash } from "@deepagent-code/core/util/hash" -import { Database } from "@deepagent-code/core/database/database" -import { MessageTable, PartTable } from "@deepagent-code/core/session/sql" -import { eq, and, asc } from "drizzle-orm" -import { MessageID, PartID, SessionID } from "@/session/schema" +import { MessageID, SessionID } from "@/session/schema" import { Session } from "./session" -// --------------------------------------------------------------------------- -// Errors -// --------------------------------------------------------------------------- - export class ForkManifestConflictError extends Data.TaggedError("TaskFork.ManifestConflict")<{ readonly childSessionID: SessionID readonly reason: string }> {} -// --------------------------------------------------------------------------- -// Deterministic ID derivation (design §10.4) -// IDs are derived per-run so two forks of the same source don't collide. -// --------------------------------------------------------------------------- - -const MAPPING_VERSION = 1 - -/** - * Derive a deterministic target MessageID from a source message ID and run ID. - * Uses SHA-256 to produce a collision-resistant mapping. - */ -function deriveMessageID(runID: string, sourceMsgID: string): MessageID { - const digest = Hash.sha256(`${MAPPING_VERSION}:msg:${runID}:${sourceMsgID}`) - return MessageID.make(`msg${digest.slice(0, 22)}`) -} - -/** - * Derive a deterministic target PartID from a source part ID and run ID. - */ -function derivePartID(runID: string, sourcePartID: string): PartID { - const digest = Hash.sha256(`${MAPPING_VERSION}:prt:${runID}:${sourcePartID}`) - return PartID.make(`prt${digest.slice(0, 22)}`) -} - -// --------------------------------------------------------------------------- -// ForkManifest — persisted in session metadata to enable crash recovery -// --------------------------------------------------------------------------- - export type ForkManifest = { - readonly mappingVersion: typeof MAPPING_VERSION - readonly runID: string + readonly manifestVersion: number + readonly forkIntentID: string + readonly forkMode: "task" readonly parentSessionID: SessionID - readonly cutoffMessageID: string - readonly requestHash: string - readonly sourceHistoryHash: string - readonly state: "prepared" | "complete" + readonly sourcePromptEpoch: number + readonly sourceWindowID: string + readonly sourceEffectiveHistoryHash: string + readonly targetPromptEpoch: number + readonly targetWindowID: string + readonly targetEffectiveHistoryHash: string + readonly targetWorldStateBaselineHash: string + readonly sanitationPolicyVersion: number + readonly manifestState: "prepared" | "complete" } -// --------------------------------------------------------------------------- -// forkForTask — deterministic task context fork -// Design §10.4 -// --------------------------------------------------------------------------- - -/** - * Create a context fork for a task run with deterministic IDs and a durable manifest. - * - * On first call: creates child session with manifest, clones messages up to cutoff. - * On retry (crash recovery): reads existing manifest, verifies, adopts if exact match. - */ export function forkForTask(input: { readonly runID: string readonly childSessionID: SessionID @@ -92,136 +42,34 @@ export function forkForTask(input: { }) { return Effect.gen(function* () { const sessions = yield* Session.Service - const { db } = yield* Database.Service - - // Check if child session already exists (crash recovery) - const existing = yield* sessions.get(input.childSessionID).pipe( - Effect.orElseSucceed(() => undefined as typeof result | undefined), - ) - const result = undefined as any - - if (existing) { - // Verify the existing manifest matches this fork request - const manifest = existing.metadata?.deepagent?.task_fork_manifest as ForkManifest | undefined - if (!manifest) { - return yield* Effect.fail( - new ForkManifestConflictError({ - childSessionID: input.childSessionID, - reason: "child session exists but has no task_fork_manifest", - }), - ) - } - if ( - manifest.runID !== input.runID || - manifest.parentSessionID !== input.parentSessionID || - manifest.cutoffMessageID !== input.cutoffMessageID || - manifest.requestHash !== input.requestHash - ) { - return yield* Effect.fail( - new ForkManifestConflictError({ - childSessionID: input.childSessionID, - reason: `manifest mismatch: existing run=${manifest.runID}, cutoff=${manifest.cutoffMessageID}`, - }), - ) - } - // Exact match — adopt existing child - return input.childSessionID - } - - // First call: get parent messages up to cutoff for hash computation - const parentMessages = yield* db - .select({ id: MessageTable.id, data: MessageTable.data, time_created: MessageTable.time_created }) - .from(MessageTable) - .where(eq(MessageTable.session_id, input.parentSessionID as any)) - .orderBy(asc(MessageTable.time_created)) - .all() - .pipe(Effect.orDie) - - const cutoffIndex = parentMessages.findIndex((m) => m.id === input.cutoffMessageID) - const messagesToClone = cutoffIndex >= 0 ? parentMessages.slice(0, cutoffIndex) : [] - - // Compute source history hash for crash recovery verification - const sourceHistoryHash = Hash.sha256( - JSON.stringify(messagesToClone.map((m) => ({ id: m.id, hash: Hash.sha256(JSON.stringify(m.data)) }))), - ) - - const manifest: ForkManifest = { - mappingVersion: MAPPING_VERSION, - runID: input.runID, - parentSessionID: input.parentSessionID, - cutoffMessageID: input.cutoffMessageID, - requestHash: input.requestHash, - sourceHistoryHash, - state: "prepared", + const child = yield* sessions + .fork({ + sessionID: input.parentSessionID, + intentID: `task-fork:${input.runID}`, + messageID: MessageID.make(input.cutoffMessageID), + directory: input.childDirectory, + forkMode: "task", + targetSessionID: input.childSessionID, + childDepth: input.childDepth, + taskRequestHash: input.requestHash, + }) + .pipe( + Effect.mapError( + (error) => + new ForkManifestConflictError({ + childSessionID: input.childSessionID, + reason: error instanceof Session.ForkConflict ? error.reason : error.message, + }), + ), + ) + if (child.id !== input.childSessionID) { + return yield* new ForkManifestConflictError({ + childSessionID: input.childSessionID, + reason: `fork authority returned unexpected child ${child.id}`, + }) } - - // Create child session with manifest (atomic — manifest is the crash recovery anchor) - yield* sessions.create({ - id: input.childSessionID, - parentID: input.parentSessionID, - directory: input.childDirectory, - title: `Fork of ${input.parentSessionID} (task run ${input.runID})`, - metadata: { - deepagent: { - task_fork_manifest: manifest, - [SUBAGENT_DEPTH_META_KEY]: input.childDepth, - }, - }, - }) - - // Clone messages and parts with deterministic IDs - for (const msg of messagesToClone) { - const targetMsgID = deriveMessageID(input.runID, msg.id) - - yield* db - .insert(MessageTable) - .values({ - id: targetMsgID, - session_id: input.childSessionID as any, - time_created: msg.time_created, - time_updated: msg.time_created, - data: msg.data, - }) - .onConflictDoNothing() - .run() - .pipe(Effect.orDie) - - // Clone parts for this message - const parts = yield* db - .select() - .from(PartTable) - .where(eq(PartTable.message_id, msg.id as any)) - .all() - .pipe(Effect.orDie) - - for (const part of parts) { - const targetPartID = derivePartID(input.runID, part.id) - yield* db - .insert(PartTable) - .values({ - id: targetPartID, - message_id: targetMsgID, - session_id: input.childSessionID as any, - time_created: part.time_created, - time_updated: part.time_created, - data: part.data, - }) - .onConflictDoNothing() - .run() - .pipe(Effect.orDie) - } - } - - // Mark manifest as complete - yield* sessions.setMetadata({ - sessionID: input.childSessionID, - metadata: { task_fork_manifest: { ...manifest, state: "complete" } }, - }).pipe(Effect.ignore) - - return input.childSessionID + return child.id }) } -const SUBAGENT_DEPTH_META_KEY = "subagentDepth" - export * as TaskFork from "./task-fork" diff --git a/packages/deepagent-code/src/session/tool-request-receipt.sql.ts b/packages/deepagent-code/src/session/tool-request-receipt.sql.ts index aeae2c0d..a789c70c 100644 --- a/packages/deepagent-code/src/session/tool-request-receipt.sql.ts +++ b/packages/deepagent-code/src/session/tool-request-receipt.sql.ts @@ -3,6 +3,14 @@ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core" export type RequestReceiptState = "prepared" | "dispatched" | "rejected" +export type ProviderReceiptState = + | "preparing" + | "prepared" + | "dispatching" + | "streaming" + | "settled" + | "failed" + | "indeterminate_after_crash" export type AdapterToolCapability = "supported" | "unsupported" | "unknown" export type AdapterLoweringOutcome = "ok" | "schema_rejected" | "omitted_no_support" @@ -29,6 +37,23 @@ export const SessionToolRequestReceiptTable = sqliteTable("session_tool_request_ reserved_output_tokens: integer(), safety_margin_tokens: integer(), context_limit_provenance: text().$type<"model_limit" | "host_guard">(), + prompt_epoch: integer(), + prompt_window_id: text(), + effective_history_hash: text(), + world_state_baseline_hash: text(), + prompt_cache_key: text(), + provider_request_hash: text(), + response_chain_reuse_decision: text().$type<"not_supported" | "refused" | "reused">(), + response_chain_refusal_reason: text(), + request_input_hash: text(), + final_request_hash: text(), + provider_state: text().$type().notNull().default("preparing"), + adapter_prepared_at: integer(), + dispatching_at: integer(), + streaming_at: integer(), + terminal_at: integer(), + response_fingerprint: text(), + owner_token: text(), request_state: text().$type().notNull(), request_error_code: text(), created_at: integer().notNull(), diff --git a/packages/deepagent-code/src/tool/plan-write.ts b/packages/deepagent-code/src/tool/plan-write.ts index de7c43b6..490243db 100644 --- a/packages/deepagent-code/src/tool/plan-write.ts +++ b/packages/deepagent-code/src/tool/plan-write.ts @@ -44,15 +44,24 @@ export const PlanEvent = { const PlanStep = Schema.Struct({ step_id: Schema.optional(Schema.String).annotate({ - description: "Stable id; required for advance, omit only when create/replan should allocate a new identity", + description: + "Stable id; required for advance, copy it for an unchanged replan step, and omit it for create or a genuinely new replan step so the server allocates it", + }), + title: Schema.optional(Schema.String).annotate({ + description: "What this step does; required for create/replan and ignored for advance", }), - title: Schema.String.annotate({ description: "What this step does" }), status: Schema.String.annotate({ description: "pending | active | done | cancelled | blocked" }), // No NullOr: a nested optional(NullOr(...)) emits a double-nested anyOf whose inner // {type:null} survives normalize() and is rejected by some third-party providers (no-reply). // Optional already covers "absent"; strict admission normalizes missing values to null. - acceptance: Schema.optional(Schema.String).annotate({ description: "How you know this step is done" }), - assigned_agent: Schema.optional(Schema.String).annotate({ description: "Subagent type to delegate to" }), + acceptance: Schema.optional(Schema.String).annotate({ + description: + "Acceptance criterion for create/replan; when retaining a replan step, omit to copy the authoritative value shown in the correction", + }), + assigned_agent: Schema.optional(Schema.String).annotate({ + description: + "Subagent type for create/replan; when retaining a replan step, omit to copy the authoritative value shown in the correction", + }), note: Schema.optional(Schema.String).annotate({ description: "Short note; REQUIRED when status is 'blocked' — say why you are stuck", }), @@ -62,15 +71,31 @@ export const Parameters = Schema.Struct({ operation: Schema.Literals(["create", "advance", "replan"]).annotate({ description: "create a plan, advance an existing plan, or replan with a reason", }), - expected_plan_id: Schema.NullOr(Schema.String), - expected_version: Schema.NullOr(NonNegativeInt), - replan_reason: Schema.optional(Schema.String), - goal: Schema.String.annotate({ description: "One sentence: what 'done' means for this task" }), - steps: Schema.mutable(Schema.Array(PlanStep)).annotate({ description: "Ordered plan steps" }), + expected_plan_id: Schema.NullOr(Schema.String).annotate({ + description: + "Use null for create; for advance/replan copy expected_plan_id exactly from the latest or plan result", + }), + expected_version: Schema.NullOr(NonNegativeInt).annotate({ + description: + "Use null for create; for advance/replan copy expected_version exactly from the latest or plan result", + }), + replan_reason: Schema.optional(Schema.String).annotate({ + description: "Required for replan; omit for create/advance", + }), + goal: Schema.optional(Schema.String).annotate({ + description: "One sentence: what 'done' means for this task; required for create/replan", + }), + steps: Schema.mutable(Schema.Array(PlanStep)).annotate({ + description: + "Ordered plan steps for create/replan; for advance copy existing step_id values from and send status/note updates", + }), assumptions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ - description: "Facts the plan relies on", + description: "Facts for create; for replan omit to retain the authoritative list, or send [] to clear it", + }), + active_step_id: Schema.optional(Schema.NullOr(Schema.String)).annotate({ + description: + "For create/replan, omit this field and mark at most one step active; the server derives its allocated ID. For advance, copy a visible step_id, omit to retain it, or use null to clear it", }), - active_step_id: Schema.NullOr(Schema.String).annotate({ description: "The step currently being worked on" }), }) export const PlanWriteParameters = Parameters @@ -84,6 +109,7 @@ type Metadata = { plan_version?: number plan_attempt_ordinal?: number plan_error_code?: string + plan_error_step_ids?: string[] challenge_id?: string } @@ -98,19 +124,25 @@ export const PlanTool = Tool.define) => ({ + // Advance is a status patch at this boundary. Identity fields are + // server-owned and intentionally excluded from its semantic proposal. + advance_patch: input.operation === "advance", operation: input.operation, expected_plan_id: input.expected_plan_id, expected_version: input.expected_version, - replan_reason: input.replan_reason ?? null, - goal: input.goal.trim(), - assumptions: (input.assumptions ?? []).map((value) => value.trim()), - active_step_id: input.active_step_id, + replan_reason: input.operation === "advance" ? null : (input.replan_reason ?? null), + goal: input.operation === "advance" ? null : (input.goal?.trim() ?? null), + assumptions: input.operation === "advance" ? [] : (input.assumptions ?? []).map((value) => value.trim()), + active_step_id: + input.operation === "advance" && input.active_step_id === undefined + ? "retain" + : (input.active_step_id ?? null), steps: input.steps.map((step) => ({ step_id: step.step_id ?? null, - title: step.title.trim(), + title: input.operation === "advance" ? null : (step.title?.trim() ?? null), status: step.status.trim().toLowerCase(), - acceptance: step.acceptance ?? null, - assigned_agent: step.assigned_agent ?? null, + acceptance: input.operation === "advance" ? null : (step.acceptance ?? null), + assigned_agent: input.operation === "advance" ? null : (step.assigned_agent ?? null), note: step.note ?? null, })), }), @@ -140,16 +172,7 @@ export const PlanTool = Tool.define { const built = AgentGateway.DeepAgentPlanController.buildPlanFromWriteInput( ctx.sessionID, - { - operation: params.operation, - expected_plan_id: params.expected_plan_id, - expected_version: params.expected_version, - replan_reason: params.replan_reason, - goal: params.goal, - steps: params.steps, - assumptions: params.assumptions, - active_step_id: params.active_step_id, - }, + normalizeModelPlanWrite(params, previous, expectedRef), previous, expectedRef, ) @@ -186,32 +209,49 @@ export const PlanTool = Tool.define + const current = AgentGateway.DeepAgentPlanStore.getPlanDoc(ctx.sessionID) + const currentRef = AgentGateway.DeepAgentPlanStore.planDocRef(ctx.sessionID) + const currentProgress = current + ? AgentGateway.DeepAgentPlanController.planProgress(current) + : { done: 0, total: 0 } return { title: "Plan conflict", - output: "The plan changed before this update was committed. Re-read the current plan and retry with its exact plan_id and version.", + output: + "The plan changed before this update was committed. Re-read the current plan and retry with its exact expected_plan_id and expected_version." + + renderPlanRetryBase(current, currentRef), metadata: { - plan_id: conflict.actual?.plan_id ?? previous?.plan_id ?? "", - goal: previous?.goal ?? params.goal, - done: previous ? AgentGateway.DeepAgentPlanController.planProgress(previous).done : 0, - total: previous ? AgentGateway.DeepAgentPlanController.planProgress(previous).total : 0, + plan_id: conflict.actual?.plan_id ?? current?.plan_id ?? previous?.plan_id ?? "", + goal: current?.goal ?? previous?.goal ?? params.goal ?? "", + done: currentProgress.done, + total: currentProgress.total, plan_protocol: "conflict", plan_error_code: "plan_conflict", - plan_version: conflict.actual?.version ?? ref?.version ?? 0, + plan_version: conflict.actual?.version ?? currentRef?.version ?? ref?.version ?? 0, }, } } if (error instanceof AgentGateway.DeepAgentPlanController.PlanValidationError) { const validation = error + const offending = validation.offending_step_ids + const offendingText = offending.length ? " Offending step IDs: " + offending.join(", ") + "." : "" + const validationOutput = [ + "The plan was not committed (" + validation.code + ").", + offendingText, + " Correct the plan payload and retry once.", + validation.challenge_id ? " Confirmation: " + validation.challenge_id : "", + renderModelPlanCorrection(params.operation, validation.code, previous, ref), + ].join("") return { title: "Plan needs correction", - output: `The plan was not committed (${validation.code}). Correct the plan payload and retry once.${validation.challenge_id ? ` Confirmation: ${validation.challenge_id}` : ""}`, + output: validationOutput, metadata: { plan_id: previous?.plan_id ?? "", - goal: previous?.goal ?? params.goal, + goal: previous?.goal ?? params.goal ?? "", done: previous ? AgentGateway.DeepAgentPlanController.planProgress(previous).done : 0, total: previous ? AgentGateway.DeepAgentPlanController.planProgress(previous).total : 0, plan_protocol: "invalid", plan_error_code: validation.code, + ...(offending.length ? { plan_error_step_ids: [...offending] } : {}), ...(validation.challenge_id ? { challenge_id: validation.challenge_id } : {}), ...(ref ? { plan_version: ref.version } : {}), }, @@ -262,33 +302,24 @@ export const PlanTool = Tool.define Effect.logWarning("plan.updated publication failed; snapshot remains authoritative").pipe( - Effect.annotateLogs({ sessionID: ctx.sessionID, plan_id: plan.plan_id, plan_version: version, cause }), + Effect.annotateLogs({ + sessionID: ctx.sessionID, + plan_id: plan.plan_id, + plan_version: version, + cause, + }), Effect.asVoid, ), ), ) } - const lines = plan.steps.map((s) => { - const mark = - s.status === "done" - ? "x" - : s.status === "cancelled" - ? "-" - : s.status === "blocked" - ? "!" - : s.status === "active" - ? ">" - : " " - const suffix = s.status === "blocked" && s.note ? ` — blocked: ${s.note}` : "" - return `[${mark}] ${s.title}${suffix}` - }) const changeSummary = changeLines.length > 0 ? `\n\nChanges: ${changeLines.join("; ")}` : "" const warnSummary = acceptanceWarnings.length > 0 ? `\n\n⚠ ${acceptanceWarnings.join("; ")}. Verify before finalizing.` : "" return { title: `Plan: ${done}/${total} steps`, - output: `Goal: ${plan.goal}\n${lines.join("\n")}${changeSummary}${warnSummary}`, + output: `${AgentGateway.DeepAgentPlanController.renderPlanWriteContext(plan, version)}${changeSummary}${warnSummary}`, metadata: { plan_id: plan.plan_id, goal: plan.goal, @@ -307,3 +338,188 @@ export const PlanTool = Tool.define }), ) + +// The core controller keeps the full-document contract for human and HTTP writes. Model advances +// use this adapter so compact snapshots and model restatements cannot mutate authoritative identity +// fields; only status, note, and active-step intent crosses the model boundary. +export const normalizeModelPlanWrite = ( + params: Schema.Schema.Type, + previous: ReturnType, + expected: AgentGateway.DeepAgentPlanController.PlanExpected | null, +) => { + // Stale writers are concurrency conflicts even when a concurrent replan also changed step IDs. + // Check the shared core precondition before interpreting the patch against current authority. + AgentGateway.DeepAgentPlanController.requirePlanWriteExpected(params, previous, expected) + const base = { + operation: params.operation, + expected_plan_id: params.expected_plan_id, + expected_version: params.expected_version, + ...(params.replan_reason !== undefined ? { replan_reason: params.replan_reason } : {}), + goal: params.goal ?? "", + } + + if (params.operation === "create" || previous == null) { + // Model-created IDs are never authoritative. Keep explicit null so a contradictory active status + // remains a validation error, and derive every non-null pointer after server allocation. + const deriveActive = params.active_step_id === undefined || params.active_step_id !== null + return { + ...base, + assumptions: params.assumptions, + ...(deriveActive ? {} : { active_step_id: null }), + steps: params.steps.map((step) => ({ ...step, step_id: undefined, title: step.title ?? "" })), + } + } + + if (params.operation === "advance") { + const suppliedIDs = params.steps.map((step) => step.step_id?.trim() ?? "") + if (suppliedIDs.some((stepID) => stepID === "")) { + throw new AgentGateway.DeepAgentPlanController.PlanValidationError("unsafe_step_identity", [], previous.plan_id) + } + const duplicateIDs = suppliedIDs.filter((stepID, index) => suppliedIDs.indexOf(stepID) !== index) + if (duplicateIDs.length > 0) { + throw new AgentGateway.DeepAgentPlanController.PlanValidationError( + "duplicate_step_id", + [...new Set(duplicateIDs)], + previous.plan_id, + ) + } + const knownIDs = new Set(previous.steps.map((step) => step.step_id)) + const unknownIDs = suppliedIDs.filter((stepID) => !knownIDs.has(stepID)) + if (unknownIDs.length > 0) { + throw new AgentGateway.DeepAgentPlanController.PlanValidationError( + "unsafe_step_identity", + unknownIDs, + previous.plan_id, + ) + } + const updates = new Map(params.steps.map((step, index) => [suppliedIDs[index], step] as const)) + return { + ...base, + goal: previous.goal, + assumptions: [...previous.assumptions], + active_step_id: params.active_step_id === undefined ? previous.active_step_id : params.active_step_id, + steps: previous.steps.map((step) => { + const update = updates.get(step.step_id) + return { + step_id: step.step_id, + title: step.title, + status: update?.status ?? step.status, + acceptance: step.acceptance ?? null, + assigned_agent: step.assigned_agent ?? null, + note: update?.note ?? step.note ?? null, + } + }), + } + } + + const suppliedIDs = params.steps.map((step) => step.step_id?.trim() ?? "") + const duplicateIDs = suppliedIDs.filter((stepID, index) => suppliedIDs.indexOf(stepID) !== index) + const duplicateKnownIDs = duplicateIDs.filter(Boolean) + if (duplicateKnownIDs.length > 0) { + throw new AgentGateway.DeepAgentPlanController.PlanValidationError( + "duplicate_step_id", + [...new Set(duplicateKnownIDs)], + previous.plan_id, + ) + } + const knownIDs = new Set(previous.steps.map((step) => step.step_id)) + const unknownIDs = suppliedIDs.filter((stepID) => stepID !== "" && !knownIDs.has(stepID)) + if (unknownIDs.length > 0) { + throw new AgentGateway.DeepAgentPlanController.PlanValidationError( + "unsafe_step_identity", + unknownIDs, + previous.plan_id, + ) + } + return { + ...base, + goal: previous.goal, + assumptions: params.assumptions === undefined ? [...previous.assumptions] : params.assumptions, + active_step_id: + params.active_step_id === undefined + ? undefined + : params.active_step_id !== null && + !params.steps.some((step) => step.step_id?.trim() === params.active_step_id?.trim()) && + params.steps.every((step) => (step.step_id?.trim() ?? "") === "") && + !knownIDs.has(params.active_step_id.trim()) && + params.steps.filter( + (step) => AgentGateway.DeepAgentPlanController.normalizePlanStepStatus(step.status) === "active", + ).length === 1 + ? undefined + : params.active_step_id, + steps: params.steps.map((update) => { + const stepID = update.step_id?.trim() ?? "" + const prior = stepID === "" ? undefined : previous.steps.find((step) => step.step_id === stepID) + return { + step_id: stepID === "" ? undefined : stepID, + title: update.title ?? prior?.title ?? "", + status: update.status, + acceptance: update.acceptance ?? prior?.acceptance ?? null, + assigned_agent: update.assigned_agent ?? prior?.assigned_agent ?? null, + note: update.note ?? prior?.note ?? null, + } + }), + } +} + +export const renderModelPlanCorrection = ( + operation: Schema.Schema.Type["operation"], + code: AgentGateway.DeepAgentPlanController.PlanValidationCode, + previous: ReturnType, + ref: ReturnType, +): string => { + if (operation === "advance") return renderPlanRetryBase(previous, ref) + if (code === "plan_already_exists") { + return ( + "\n\nCorrection protocol: create cannot replace an existing plan. Use advance for status/note changes or replan for structural changes, with the exact authoritative precondition below." + + renderPlanRetryBase(previous, ref) + ) + } + if (operation === "create") { + return ( + "\n\nCorrection protocol for create: use " + + JSON.stringify({ expected_plan_id: null, expected_version: null }) + + ". Omit active_step_id; mark at most one step status=active and the server will allocate missing step_id values, then derive active_step_id. Do not invent a future server ID." + ) + } + if (previous == null || ref == null) { + return "\n\nAuthoritative replan parameters are unavailable. Do not guess expected_plan_id, expected_version, step_id, or active_step_id. If no plan exists, use create with null expected values." + } + return ( + "\n\nCorrection protocol for replan: copy the exact precondition below. For a retained step, copy its exact step_id, title, acceptance, and assigned_agent; the server also fills acceptance/assigned_agent when omitted. Omit step_id for every new step so the server allocates it. Omit active_step_id and mark at most one step status=active; the server derives its ID after allocation. Omit assumptions to retain the authoritative list, or send [] only when you intentionally clear it.\n" + + JSON.stringify({ + expected_plan_id: previous.plan_id, + expected_version: ref.version, + assumptions: previous.assumptions, + existing_steps: previous.steps.map((step) => ({ + step_id: step.step_id, + title: step.title, + acceptance: step.acceptance, + assigned_agent: step.assigned_agent, + })), + }) + ) +} + +export const renderPlanRetryBase = ( + previous: ReturnType, + ref: ReturnType, +): string => { + if (previous == null) return "" + if (ref == null) { + return `\n\nAuthoritative plan parameters unavailable: expected_version is unavailable for expected_plan_id=${JSON.stringify(previous.plan_id)}. Do not guess or call advance/replan.` + } + return ( + "\n\nAuthoritative plan parameters (copy expected_* and step_id values exactly; do not infer them):\n" + + JSON.stringify({ + expected_plan_id: previous.plan_id, + expected_version: ref.version, + active_step_id: previous.active_step_id, + steps: previous.steps.map((step) => ({ + step_id: step.step_id, + status: step.status, + ...(step.note != null ? { note: step.note } : {}), + })), + }) + ) +} diff --git a/packages/deepagent-code/src/tool/plan-write.txt b/packages/deepagent-code/src/tool/plan-write.txt index 39e9104f..f5908c1e 100644 --- a/packages/deepagent-code/src/tool/plan-write.txt +++ b/packages/deepagent-code/src/tool/plan-write.txt @@ -8,22 +8,35 @@ the committed document as the authority. A rejected payload is never partially a Required protocol fields: - operation: exactly one of create, advance, or replan. - expected_plan_id and expected_version: both null for create; for advance/replan, copy the exact - plan_id and plan_version from the latest plan snapshot/event. -- goal, steps, and active_step_id: the complete proposed plan, not a patch. active_step_id is null - when no step is active. + expected_plan_id and expected_version values shown in the latest , successful plan + result, or authoritative correction result. Never infer either value from history. +- goal and steps: goal is required for create/replan. For create/replan, omit active_step_id and mark + at most one step status=active; after allocating missing step IDs, the server derives the active + ID. Advance may omit goal and active_step_id when the current authoritative values should be + retained. Otherwise copy a visible active_step_id and send one or more status/note updates. - replan_reason: required and specific for replan; omit it for create/advance. Operation rules: -- create is only for a session with no plan. Step IDs may be omitted and are assigned once. -- advance must preserve the existing plan_id, goal, assumptions, ordered step IDs, titles, - acceptance criteria, and assigned agents. It is for status, note, active-step, and evidence - progress within the existing contract. -- replan is an intentional structural revision and must explain why. Do not use it to bypass the - version precondition or to erase unresolved work. Suspicious quality regressions are rejected. +- create is only for a session with no plan. Omit all step IDs; the server assigns them once. +- advance is a status patch. Preserve the existing plan_id and exact version precondition. Step IDs + must be copied exactly from the latest or plan result; never infer them from titles or + array positions. Titles, acceptance criteria, assigned agents, goal, and assumptions are + server-owned and ignored from the advance payload. Send only status, note, and active-step changes. + The server keeps the authoritative step order and identity fields. +- replan is an intentional structural revision and must explain why. Copy an existing step_id only + when that step keeps the same title, acceptance, and assigned agent; the correction payload shows + those authoritative values, and omitted acceptance/assigned_agent fields are filled from it. Omit + step_id for every new step so the server allocates it. Omit active_step_id and use one active status + for server-side derivation. Omit assumptions to retain the current list; send [] only to clear it. + Do not use replan to bypass the version precondition or erase unresolved work. Suspicious regressions + are rejected. - status must be pending, active, done, cancelled, or blocked. A blocked step must include a note. - There may be at most one active step, and active_step_id must agree with the statuses. + There may be at most one active step. For create/replan the server derives active_step_id; for + advance an explicitly supplied active_step_id must agree with the statuses. - Do not provide evidence. Validation and runtime integrations attach evidence only after admission. After a step is finished, call this tool immediately with the next active step and the exact latest -plan identity/version. Keep the plan small and honest. If a write is rejected, inspect the current -snapshot and retry once with a corrected complete payload. +expected_plan_id, expected_version, and step_id values. If any required identity value is unavailable, +do not guess or call advance/replan. Keep the plan small and honest. If a write is rejected, inspect +the current snapshot or authoritative parameters in the tool error and retry once with corrected +status/note fields. Do not repeat an unchanged invalid payload. diff --git a/packages/deepagent-code/src/tool/task.ts b/packages/deepagent-code/src/tool/task.ts index f50a1676..cdbb622d 100644 --- a/packages/deepagent-code/src/tool/task.ts +++ b/packages/deepagent-code/src/tool/task.ts @@ -117,6 +117,7 @@ export function resolveOutputSchema( const FINALIZER_ATTEMPTS = 2 const FINALIZER_RAW_RESULT_MAX_CHARS = 80_000 +const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) // Token usage is provider- and cache-dependent, so it is deliberately not a hard task boundary. The // step, wall-time, no-progress, and output bounds remain the operational safety limits. export const DEFAULT_SUBAGENT_RESEARCH_BUDGET = { @@ -140,6 +141,8 @@ export type SubagentPromptInput = { agent: string agentModeOverride: AgentMode | undefined outputSchema: Record | undefined + /** Permit only the bounded second finalizer to return schema-validated JSON text. */ + allowTextFallback?: boolean directStructuredOutput?: boolean finalizerInstructions?: readonly string[] runID?: string @@ -510,6 +513,25 @@ function validateStructuredOutput(schema: Record, value: unknow ) } +function extractStructuredText(text: string) { + const trimmed = text.trim() + if (!trimmed) return undefined + const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1]?.trim() + const objectStart = trimmed.indexOf("{") + const objectEnd = trimmed.lastIndexOf("}") + const arrayStart = trimmed.indexOf("[") + const arrayEnd = trimmed.lastIndexOf("]") + return [ + trimmed, + fenced, + objectStart !== -1 && objectEnd > objectStart ? trimmed.slice(objectStart, objectEnd + 1) : undefined, + arrayStart !== -1 && arrayEnd > arrayStart ? trimmed.slice(arrayStart, arrayEnd + 1) : undefined, + ] + .filter((candidate): candidate is string => candidate !== undefined) + .map((candidate) => Option.getOrUndefined(decodeJson(candidate))) + .find((candidate) => candidate !== undefined) +} + export function runSubagentPrompt(input: SubagentPromptInput): Effect.Effect { return Effect.gen(function* () { const parts = yield* input.ops.resolvePromptParts(input.prompt) @@ -734,6 +756,7 @@ export function runSubagentPrompt(input: SubagentPromptInput): Effect.Effect${JSON.stringify(input.outputSchema)}` : "", "", boundedRaw, "", @@ -779,9 +810,8 @@ export function runSubagentPrompt(input: SubagentPromptInput): Effect.Effect