From fec6814a01fc9df9f5dd21a9de1497bc44a93ea4 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 02:30:52 +0800 Subject: [PATCH 1/2] chore: retire the Task Ledger domain and drop its storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #4351 removed the Task Ledger protocol operation and coordinator but left the layers above and below it standing: a 970-line core module, a closed three-file storage island, a legacy bootstrap path, and the SQLite table that path read from. Nothing on `main` writes that table any more, and nothing outside the island reads the modules. BREAKING: upgrading a workspace last opened by v0.2.0-incubating-rc1 or earlier discards its unfinished Tasks. Those releases shipped a live Task Ledger, and `bootstrapLegacyTasks` — the path that would have carried those rows into SessionTodo on first read — has never shipped in any release. Rather than keep an unshipped bridge alive for a pre-1.0 surface, workflow schema 12 drops `workflow_task_ledger_events` outright and the bridge goes with it. Tasks are not imported; SessionTodo starts empty. Completed and cancelled Tasks were never going to be imported anyway, and the affected surface is one RC and a 0.1.x line. The drop follows the `workflow_plan_reminders` idiom directly above it. Two mechanisms that name Task Ledger are deliberately kept: `RELEASED_WORKFLOW_PROJECTION_TABLES` still retires `workflow_task_ledger_projections`, which released databases really carry, and the released-cutover validation contract in `operational-state-store.ts` still records what a released build wrote to `cutover_journal` — dropping that entry would fail a workspace closed rather than clean it. `readOrBootstrap` now persists an empty document, and `initializeCopy` no longer writes a placeholder row into an uninitialized copy source; both are observationally unchanged and now have tests that pin the stored rows rather than only the returned snapshot. Tests that only needed some store to open the workflow database move to the Plan store, and the schema-10 migration case asserts the new drop. `docs/session-todo-lifecycle.md` no longer describes the removed bootstrap. `apps/desktop/src/renderer/styles/task-ledger.css` is residue from the same retirement and is tracked separately in #4394. Closes #4399 Generated-by: Claude Code --- .../stories/session-workbar.stories.tsx | 79 +- docs/session-todo-lifecycle.md | 58 +- packages/core/package.json | 1 - .../core/src/__tests__/task-ledger.test.ts | 621 ----------- packages/core/src/foreign-session.ts | 4 +- packages/core/src/task-ledger.ts | 970 ------------------ .../__tests__/execution-host-message.test.ts | 1 - .../__tests__/execution-host-queue.test.ts | 1 - .../__tests__/execution-host-recovery.test.ts | 1 - .../fixtures/execution-host-suite.ts | 1 - .../session-catalog-two-client-uds.test.ts | 8 - .../__tests__/operational-state-store.test.ts | 4 +- .../src/__tests__/session-todo-store.test.ts | 188 +--- .../__tests__/sqlite-workflow-store.test.ts | 141 +-- .../__tests__/task-ledger-authority.test.ts | 256 ----- packages/storage/src/session-todo-store.ts | 91 +- .../storage/src/sqlite-workflow-schema.ts | 12 +- packages/storage/src/task-ledger-authority.ts | 165 --- .../storage/src/task-ledger-store-internal.ts | 44 - packages/storage/src/task-ledger-store.ts | 865 ---------------- 20 files changed, 133 insertions(+), 3378 deletions(-) delete mode 100644 packages/core/src/__tests__/task-ledger.test.ts delete mode 100644 packages/core/src/task-ledger.ts delete mode 100644 packages/storage/src/__tests__/task-ledger-authority.test.ts delete mode 100644 packages/storage/src/task-ledger-authority.ts delete mode 100644 packages/storage/src/task-ledger-store-internal.ts delete mode 100644 packages/storage/src/task-ledger-store.ts diff --git a/apps/desktop/stories/session-workbar.stories.tsx b/apps/desktop/stories/session-workbar.stories.tsx index 79c7415189..30b6387120 100644 --- a/apps/desktop/stories/session-workbar.stories.tsx +++ b/apps/desktop/stories/session-workbar.stories.tsx @@ -24,7 +24,7 @@ import type { ArtifactRecord } from '@maka/core/artifacts'; import type { BrowserState } from '@maka/core/browser'; import type { GitReviewReadResult, GitReviewSnapshot } from '@maka/core/git-review'; import type { SessionSummary } from '@maka/core/session'; -import type { Task } from '@maka/core/task-ledger'; +import type { SessionTodoItem } from '@maka/core/session-todo'; import type { SessionTrace } from '@maka/core/session-trace'; import type { ContextDiagnosticsResult } from '@maka/runtime-host/protocol'; import { ToastProvider } from '@maka/ui'; @@ -147,57 +147,19 @@ const RICH_TERMINAL_BUFFER = [ // ---- ledgers ------------------------------------------------------------- -// Mirrors the `task-ledger` e2e fixture (apps/desktop/src/main/e2e-fixture/ -// scenarios-chat.ts), which builds this tree through the SQLite store; that -// store cannot run in a browser, so the shapes are restated rather than -// imported. The long subject is deliberate: it is what proves a deep indent -// still wraps instead of pushing owner and reason off the panel. -function task(input: Partial & Pick): Task { - return { status: 'pending', createdAt: NOW, updatedAt: NOW, ...input }; -} - -const tasks: Task[] = [ - task({ - id: 'task-1', - key: 'T1', - subject: '完成会话任务台账升级', - status: 'in_progress', - owner: { actor: 'main_agent', runId: 'run-task-parent' }, - }), - task({ - id: 'task-2', - key: 'T1.1', - subject: '验证 SQLite authority 与并发短 key 分配', - parentId: 'task-1', - status: 'completed', - completionEvidence: 'Core 与 Storage 定向测试全部通过。', - endedAt: NOW - 120_000, - updatedAt: NOW - 120_000, - }), - task({ - id: 'task-3', - key: 'T1.2', - subject: '检查窄窗口下的任务树布局', - parentId: 'task-1', - status: 'blocked', - blockedReason: '等待视觉回归截图确认 990px 视口没有文字重叠。', - owner: { actor: 'child_agent', agentId: 'local-read' }, - }), - task({ - id: 'task-4', - key: 'T1.2.1', - subject: '核对深层缩进、超长任务描述、owner 与阻塞原因在窄窗口中仍可完整换行且不遮挡后续内容', - parentId: 'task-3', - }), - task({ id: 'task-5', key: 'T2', subject: '同步生命周期文档与边界说明' }), - task({ - id: 'task-6', - key: 'T3', - subject: '验证 Goal 一次提醒门禁', - status: 'completed', - endedAt: NOW - 60_000, - updatedAt: NOW - 60_000, - }), +// The long item is deliberate: it is what proves a long subject still wraps +// instead of pushing the panel sideways. +const tasks: SessionTodoItem[] = [ + { content: '完成会话任务台账升级', status: 'in_progress' }, + { content: '验证 SQLite authority 与并发短 key 分配', status: 'completed' }, + { content: '检查窄窗口下的任务树布局', status: 'pending' }, + { + content: + '核对深层缩进、超长任务描述、owner 与阻塞原因在窄窗口中仍可完整换行且不遮挡后续内容', + status: 'pending', + }, + { content: '同步生命周期文档与边界说明', status: 'pending' }, + { content: '验证 Goal 一次提醒门禁', status: 'completed' }, ]; const artifacts: ArtifactRecord[] = [ @@ -812,7 +774,7 @@ const unsubscribe = () => () => undefined; * varies, and everything else stays on the populated default. */ function bridge(options: { - tasks?: Task[]; + tasks?: SessionTodoItem[]; tasksFail?: boolean; trace?: SessionTrace; traceNextCursor?: string; @@ -838,13 +800,7 @@ function bridge(options: { todo: { read: async () => { if (options.tasksFail) throw new Error('读取任务失败'); - return (options.tasks ?? tasks).map((task) => ({ - content: task.subject, - status: - task.status === 'in_progress' || task.status === 'completed' - ? task.status - : 'pending' as const, - })); + return options.tasks ?? tasks; }, subscribeChanges: unsubscribe, }, @@ -1442,8 +1398,7 @@ export const TraceCompositionUnrecorded: Story = { render: () => , }; -// Real path: 任务工作栏 → 追踪 on a session that has not run a turn yet — the -// state the task-ledger e2e fixture opens on. +// Real path: 任务工作栏 → 追踪 on a session that has not run a turn yet. export const TraceEmpty: Story = { decorators: [bridge()], render: () => , diff --git a/docs/session-todo-lifecycle.md b/docs/session-todo-lifecycle.md index eb043d7a96..382468afd3 100644 --- a/docs/session-todo-lifecycle.md +++ b/docs/session-todo-lifecycle.md @@ -19,8 +19,9 @@ # SessionTodo Lifecycle -Status: **Current**. The former Session Task Ledger is **Deprecated** and is -retained only as a one-time migration input and rollback-era storage format. +Status: **Current**. The former Session Task Ledger is **Removed**: its module, +storage, and `workflow_task_ledger_events` table are gone as of workflow schema +12. SessionTodo is the only Session task surface. This document answers one question for Runtime, Runtime Host, CLI, and Desktop contributors: who owns a Session's current Todo list, and what must happen to @@ -70,8 +71,9 @@ model todo_read / todo_write Desktop read-only panel `todo_write` is an internal Host tool port rather than a public Client mutation operation. Desktop reads through `session.todo.query`. A successful replacement -commits before the Host publishes a `todo` domain invalidation. Reads and lazy -bootstrap are silent because they do not change the effective current list. +commits before the Host publishes a `todo` domain invalidation. Reads and +first-read initialization are silent because they do not change the effective +current list. The stored document is canonical product state. Before model or Desktop display, content passes through the shared Unicode sanitization, secret @@ -90,25 +92,13 @@ These bounds keep the complete snapshot below the Runtime Host frame budget, so the operation needs no paging contract. An initialized empty list is different from no SessionTodo row. That distinction -is what makes one-time migration and explicit clearing deterministic. +is what makes explicit clearing deterministic. -## One-time legacy bootstrap +## First read The first Host read of an uninitialized Session, through either `todo_read` or -`session.todo.query`, keeps canonical `pending` and `in_progress` Tasks at their -current status. A canonical `blocked` Task is imported as `pending` with the -same subject so unfinished work remains visible for replanning. The Host then -persists the result even when it is empty. Workflow-only blocked reasons, -ownership, evidence, hierarchy, and terminal `completed`, `failed`, or -`cancelled` Tasks are not imported. - -The first explicit `todo_write` never reads or merges legacy Tasks. It writes -the requested complete list directly. Once a SessionTodo row exists, no later -read consults the legacy Task Ledger again. - -Malformed legacy events fail closed without creating the initialized marker. -An explicit whole-document write can recover from malformed legacy input -because it does not decode it. +`session.todo.query`, persists an empty document and returns it. The first +explicit `todo_write` writes the requested complete list directly. ## Copy and branch semantics @@ -120,8 +110,9 @@ lifecycle, before the target Session is published: - a historical cut, before-revision, or side conversation initializes an explicit empty Todo document. -Initialization is one SQLite write transaction. The source is read or lazily -bootstrapped and the absent target is inserted together. Retrying an identical +Initialization is one SQLite write transaction. The source's current document +is read — an uninitialized source reads as empty, without being written — and +the absent target is inserted in the same transaction. Retrying an identical initialization is idempotent; a different or corrupt existing target fails closed instead of being overwritten. @@ -132,15 +123,18 @@ the preparing Session. ## Archive, removal, backup, and rollback - Archive retains the current Todo document. -- Remove and incomplete-copy discard purge both the Todo document and legacy - Task rows in one lifecycle operation, so a deleted Session cannot bootstrap - stale work if its identifier is observed again. +- Remove and incomplete-copy discard purge the Todo document, so a deleted + Session cannot show stale work if its identifier is observed again. - Backup and restore preserve both non-empty and initialized-empty documents. -There is no dual write to the legacy Task Ledger. Rollback across the cutover -therefore means restoring a database backup taken before the upgrade. That -loses Todo edits made after the backup; running an old binary directly against -the upgraded live database is not a supported rollback guarantee. +Rollback across the cutover means restoring a database backup taken before the +upgrade. That loses Todo edits made after the backup; running an old binary +directly against the upgraded live database is not a supported rollback +guarantee. + +Upgrading to workflow schema 12 drops `workflow_task_ledger_events` without +migrating it. A workspace last opened by v0.2.0-incubating-rc1 or earlier loses +its unfinished Tasks; they are not imported into SessionTodo. ## Surface behavior @@ -171,6 +165,6 @@ prompt. The model reads it on demand with `todo_read`. - `apps/desktop/src/main/runtime-host-client.ts`: Desktop query adapter and display-safe projection. -The legacy Task codecs, replay, and tables remain only for bootstrap and the -bounded migration/rollback window. Their eventual deletion must not recreate a -second product surface or change this current-document contract. +The legacy Task codecs, replay, and tables are deleted. Nothing may recreate a +second product surface for Session tasks or change this current-document +contract. diff --git a/packages/core/package.json b/packages/core/package.json index 5dd68a2c16..6cb4fc542e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -69,7 +69,6 @@ "./agent-graph-topology": "./dist/agent-graph-topology.js", "./agent-graph-client-projection": "./dist/agent-graph-client-projection.js", "./agent-graph-supervisor-wake": "./dist/agent-graph-supervisor-wake.js", - "./task-ledger": "./dist/task-ledger.js", "./session-todo": "./dist/session-todo.js", "./foreign-session": "./dist/foreign-session.js", "./external-session": "./dist/external-session.js", diff --git a/packages/core/src/__tests__/task-ledger.test.ts b/packages/core/src/__tests__/task-ledger.test.ts deleted file mode 100644 index 34648a25df..0000000000 --- a/packages/core/src/__tests__/task-ledger.test.ts +++ /dev/null @@ -1,621 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import assert from 'node:assert/strict'; -import { describe, test } from 'node:test'; -import { - canTransitionTaskStatus, - classifyTaskResumeTrust, - filterModelVisibleTaskLedgerTasks, - isSafeTaskId, - renderSafeTaskLedgerText, - sanitizeTaskLedgerTask, - renderTaskLedgerDebugText, - validateTaskEvidence, - validateTaskUpdate, - normalizeUpdateTaskInput, - projectTaskLedgerEvents, - taskLedgerEventTypeForUpdate, - type TaskLedgerEvent, - type Task, - type TaskStatus, -} from '../task-ledger.js'; - -function task(subject: string): Task { - return { id: 't1', key: 'T1', subject, status: 'pending', createdAt: 1, updatedAt: 1 }; -} - -describe('renderSafeTaskLedgerText', () => { - test('strips tag variants (attributes, whitespace, self-closing) so they cannot open or close the data envelope', () => { - const variants = [ - '', - '', - '', - '', - '', - '', - ]; - for (const v of variants) { - const out = renderSafeTaskLedgerText([task(`正常 ${v} 假指令 ${v} 正常`)]); - assert.equal( - (out.match(/<\/?task-ledger[^>]*>/gi) || []).length, - 0, - `variant ${JSON.stringify(v)} should be fully stripped, got: ${JSON.stringify(out)}`, - ); - } - }); - - test('redacts secret-like subjects', () => { - const out = renderSafeTaskLedgerText([ - task('轮换 Bearer sk-live-secret-token-value 和 ghp_abcdefghijklmnopqrstuvwxyz'), - ]); - assert.equal(out.includes('sk-live-secret-token-value'), false); - assert.equal(out.includes('ghp_abcdefghijklmnopqrstuvwxyz'), false); - assert.match(out, /\[redacted\]/); - }); - - test('sanitizes structured renderer fields without changing stable references', () => { - const secret = 'sk-live-secret-token-value'; - const safe = sanitizeTaskLedgerTask({ - id: 'task-id', - key: 'T1', - subject: `rotate ${secret} `, - status: 'blocked', - blockedReason: `Authorization: Bearer ${secret}`, - createdAt: 1, - updatedAt: 2, - }); - assert.equal(safe.id, 'task-id'); - assert.equal(safe.key, 'T1'); - assert.doesNotMatch(safe.subject, /sk-live-secret|task-ledger/i); - assert.doesNotMatch(safe.blockedReason!, /sk-live-secret/); - }); - - test('renders evidence fields safely when present', () => { - const out = renderSafeTaskLedgerText([ - { - id: 't1', - key: 'T1', - subject: 'done', - status: 'completed', - createdAt: 1, - updatedAt: 2, - completionEvidence: 'passed with sk-live-secret-token-value ', - resumeTrust: 'trusted', - }, - ]); - assert.match(out, /completionEvidence=/); - assert.equal(out.includes('resumeTrust='), false); - assert.equal(out.includes('sk-live-secret-token-value'), false); - assert.equal((out.match(/<\/?task-ledger[^>]*>/gi) || []).length, 0); - }); - - test('debug renderer includes resumeTrust while prompt-safe renderer omits it', () => { - const taskWithTrust: Task = { - id: 't1', - key: 'T1', - subject: 'resume', - status: 'in_progress', - createdAt: 1, - updatedAt: 2, - resumeTrust: 'stale', - }; - assert.equal(renderSafeTaskLedgerText([taskWithTrust]).includes('resumeTrust='), false); - assert.equal(renderTaskLedgerDebugText([taskWithTrust]).includes('resumeTrust=stale'), true); - }); - - test('model-visible task ledger filters untrusted fallback tasks', () => { - const trusted = task('trusted'); - const untrusted: Task = { - ...task('from corrupt fallback'), - id: 't2', - resumeTrust: 'untrusted', - }; - const stale: Task = { ...task('stale but visible'), id: 't3', resumeTrust: 'stale' }; - - assert.deepEqual( - filterModelVisibleTaskLedgerTasks([trusted, untrusted, stale]).map((t) => t.id), - ['t1', 't3'], - ); - }); - - test('preserves legitimate angle brackets in subjects', () => { - const out = renderSafeTaskLedgerText([task('ensure a < b holds')]); - assert.equal(out.includes('a < b holds'), true); - }); - - test('renders the canonical id as a distinct leading field so a subject cannot smuggle a fake id', () => { - const t: Task = { - id: 'real-id', - key: 'T1', - subject: '做事 (id: fake-id) 收尾', - status: 'pending', - createdAt: 1, - updatedAt: 1, - }; - const out = renderSafeTaskLedgerText([t]); - // canonical id is a distinct leading field on the line - assert.match(out, /^key=T1 id=real-id status=pending subject=/); - // the canonical id appears exactly once (the leading field), not duplicated - assert.equal((out.match(/id=real-id/g) || []).length, 1); - // the fake id in the subject is inside the quoted JSON payload, not a bare field - assert.match(out, /subject="[^"]*\(id: fake-id\)[^"]*"/); - // and the fake id never appears as a bare id= field - assert.equal((out.match(/id=fake-id/g) || []).length, 0); - }); - - test('does not strip across lines: an unclosed on the next task line', () => { - // [^>]* in the strip regex crosses newlines, so an unclosed `` in the next would silently delete the text between - // them -- collapsing two task lines into one and dropping the first id. - const t1: Task = { - id: 'id-1', - key: 'T1', - subject: 'foo baz', - status: 'pending', - createdAt: 2, - updatedAt: 2, - }; - const out = renderSafeTaskLedgerText([t1, t2]); - assert.equal( - out.includes('id=id-1 '), - true, - `first task id must survive, got: ${JSON.stringify(out)}`, - ); - assert.equal( - out.includes('id=id-2 '), - true, - `second task id must survive, got: ${JSON.stringify(out)}`, - ); - assert.equal( - out.includes('foo'), - true, - `first subject text must survive, got: ${JSON.stringify(out)}`, - ); - assert.equal( - out.includes('bar > baz'), - true, - `second subject text must survive intact, got: ${JSON.stringify(out)}`, - ); - // regression guard: complete same-line variants are still stripped - const t3: Task = { - id: 'id-3', - key: 'T3', - subject: '正常 假', - status: 'pending', - createdAt: 3, - updatedAt: 3, - }; - const out2 = renderSafeTaskLedgerText([t3]); - assert.equal( - (out2.match(/<\/?task-ledger[^>]*>/gi) || []).length, - 0, - 'same-line variant must still be stripped', - ); - }); -}); - -describe('isSafeTaskId', () => { - test('rejects secret-shaped stable tokens that the renderer would redact to [redacted]', () => { - const reject = [ - 'ghp_abcdefghijklmnopqrstuvwxyz', - 'sk-abcdefghi', - 'a'.repeat(40), - 'AIza' + 'X'.repeat(24), - ]; - for (const id of reject) { - assert.equal( - isSafeTaskId(id), - false, - `id ${JSON.stringify(id.slice(0, 24))} must be rejected (renderer would redact it)`, - ); - } - }); - - test('accepts UUID-shaped and simple stable tokens that survive redaction', () => { - const accept = ['123e4567-e89b-12d3-a456-426614174000', 'good-id_1:2', 'id-1']; - for (const id of accept) { - assert.equal(isSafeTaskId(id), true, `id ${id} must pass`); - } - }); -}); - -describe('task lifecycle validators', () => { - test('allows the documented status transitions and rejects invalid jumps', () => { - const allowed: Array<[TaskStatus, TaskStatus]> = [ - ['pending', 'in_progress'], - ['pending', 'cancelled'], - ['in_progress', 'blocked'], - ['in_progress', 'completed'], - ['in_progress', 'failed'], - ['in_progress', 'cancelled'], - ['blocked', 'in_progress'], - ['blocked', 'cancelled'], - ['blocked', 'failed'], - ['failed', 'pending'], - ['failed', 'cancelled'], - ]; - for (const [from, to] of allowed) { - assert.equal(canTransitionTaskStatus(from, to), true, `${from} -> ${to} should be allowed`); - } - assert.equal(canTransitionTaskStatus('pending', 'blocked'), false); - assert.equal(canTransitionTaskStatus('pending', 'completed'), false); - assert.equal(canTransitionTaskStatus('pending', 'failed'), false); - assert.equal(canTransitionTaskStatus('completed', 'in_progress'), false); - assert.equal( - canTransitionTaskStatus('completed', 'in_progress', { explicitReopen: true }), - true, - ); - assert.equal(canTransitionTaskStatus('cancelled', 'pending'), false); - assert.equal(canTransitionTaskStatus('cancelled', 'pending', { explicitReopen: true }), true); - }); - - test('requires evidence for blocked, failed, and completed states', () => { - assert.equal(validateTaskEvidence({ status: 'blocked' }).ok, false); - assert.equal(validateTaskEvidence({ status: 'failed' }).ok, false); - assert.equal(validateTaskEvidence({ status: 'completed' }).ok, false); - assert.equal( - validateTaskEvidence({ status: 'blocked', blockedReason: 'waiting for approval' }).ok, - true, - ); - assert.equal( - validateTaskEvidence({ status: 'failed', failureReason: 'tests cannot pass' }).ok, - true, - ); - assert.equal( - validateTaskEvidence({ status: 'completed', completionEvidence: 'npm test passed' }).ok, - true, - ); - }); - - test('validates task updates against transition and evidence rules', () => { - const current: Task = { - id: 't1', - key: 'T1', - subject: 'x', - status: 'in_progress', - createdAt: 1, - updatedAt: 1, - }; - assert.equal(validateTaskUpdate(current, { status: 'blocked' }).ok, false); - assert.equal( - validateTaskUpdate(current, { status: 'blocked', blockedReason: 'needs user input' }).ok, - true, - ); - assert.equal(validateTaskUpdate(current, { status: 'completed' }).ok, false); - assert.equal( - validateTaskUpdate(current, { status: 'completed', completionEvidence: 'test passed' }).ok, - true, - ); - assert.equal( - validateTaskUpdate( - { ...current, status: 'completed', completionEvidence: 'old evidence' }, - { status: 'in_progress' }, - ).ok, - false, - ); - assert.equal( - validateTaskUpdate( - { ...current, status: 'completed', completionEvidence: 'old evidence' }, - { status: 'in_progress' }, - { explicitReopen: true }, - ).ok, - true, - ); - }); - - test('names the recovery calls for pending tasks completed out of order', () => { - const result = validateTaskUpdate( - { - id: 't1', - key: 'T1', - subject: 'x', - status: 'pending', - createdAt: 1, - updatedAt: 1, - }, - { status: 'completed', completionEvidence: 'test passed' }, - ); - - assert.deepEqual(result, { - ok: false, - reason: 'invalid_transition', - message: - 'Invalid task status transition from pending to completed. Call task_update with status "in_progress" first, then call task_update with status "completed" and completionEvidence.', - }); - }); - - test('normalizes explicit reopen as a one-shot update option', () => { - assert.deepEqual(normalizeUpdateTaskInput({ explicitReopen: true }), { - ok: true, - value: { explicitReopen: true }, - }); - assert.deepEqual(normalizeUpdateTaskInput({ explicitReopen: false }), { - ok: true, - value: { explicitReopen: false }, - }); - assert.equal( - validateTaskUpdate( - { id: 't1', key: 'T1', subject: 'x', status: 'cancelled', createdAt: 1, updatedAt: 1 }, - { status: 'pending', explicitReopen: true }, - ).ok, - true, - ); - assert.equal( - validateTaskUpdate( - { id: 't1', key: 'T1', subject: 'x', status: 'cancelled', createdAt: 1, updatedAt: 1 }, - { status: 'pending', explicitReopen: false }, - ).ok, - false, - ); - }); - - test('classifies resume trust conservatively', () => { - assert.equal(classifyTaskResumeTrust({ status: 'in_progress' }), 'stale'); - assert.equal(classifyTaskResumeTrust({ status: 'completed' }), 'needs_revalidation'); - assert.equal( - classifyTaskResumeTrust({ status: 'completed', completionEvidence: 'passed' }), - 'trusted', - ); - assert.equal( - classifyTaskResumeTrust( - { status: 'completed', completionEvidence: 'passed' }, - { missingReferences: true }, - ), - 'untrusted', - ); - assert.equal(classifyTaskResumeTrust({ status: 'pending' }, { repaired: true }), 'repaired'); - }); -}); - -describe('task ledger events', () => { - test('diagnoses duplicate and structurally invalid hierarchy keys', () => { - const root: Task = { - id: 'root', - key: 'T1', - subject: 'root', - status: 'pending', - createdAt: 1, - updatedAt: 1, - }; - const wrongChild: Task = { - id: 'child', - key: 'T2.1', - parentId: root.id, - subject: 'child', - status: 'pending', - createdAt: 2, - updatedAt: 2, - }; - const skippedLevel: Task = { - id: 'skipped-level', - key: 'T1.1.1', - parentId: root.id, - subject: 'skipped', - status: 'pending', - createdAt: 3, - updatedAt: 3, - }; - const duplicate: Task = { - id: 'duplicate', - key: 'T1', - subject: 'duplicate', - status: 'pending', - createdAt: 4, - updatedAt: 4, - }; - const projection = projectTaskLedgerEvents([ - event('task_created', root, undefined), - event('task_created', wrongChild, undefined), - event('task_created', skippedLevel, undefined), - event('task_created', duplicate, undefined), - ]); - assert.equal( - projection.diagnostics.some((line) => line.includes('duplicate task key T1')), - true, - ); - assert.equal( - projection.diagnostics.some((line) => line.includes('does not belong under parent key T1')), - true, - ); - assert.equal( - projection.diagnostics.some((line) => line.includes('task skipped-level key T1.1.1')), - true, - ); - }); - - test('projects task events into latest task state and records diagnostics', () => { - const created: Task = { - id: 't1', - key: 'T1', - subject: 'x', - status: 'pending', - createdAt: 1, - updatedAt: 1, - }; - const started: Task = { ...created, status: 'in_progress', updatedAt: 2 }; - const completed: Task = { - ...created, - status: 'completed', - completionEvidence: 'done', - updatedAt: 3, - }; - const events: TaskLedgerEvent[] = [ - event('task_created', created, undefined), - event('task_started', started, created), - event('task_completed', completed, started), - ]; - const projection = projectTaskLedgerEvents(events); - assert.equal(projection.diagnostics.length, 0); - assert.equal(projection.tasks[0]?.status, 'completed'); - assert.equal(projection.tasks[0]?.completionEvidence, 'done'); - }); - - test('detects duplicate creates and unknown task updates', () => { - const task: Task = { - id: 't1', - key: 'T1', - subject: 'x', - status: 'pending', - createdAt: 1, - updatedAt: 1, - }; - const projection = projectTaskLedgerEvents([ - event('task_created', task, undefined), - event('task_created', task, undefined), - event( - 'task_completed', - { ...task, id: 'missing', status: 'completed', completionEvidence: 'done' }, - undefined, - ), - ]); - assert.equal( - projection.diagnostics.some((d) => d.includes('duplicate')), - true, - ); - assert.equal( - projection.diagnostics.some((d) => d.includes('unknown task')), - true, - ); - }); - - test('detects task event type and status mismatches', () => { - const created: Task = { - id: 't1', - key: 'T1', - subject: 'x', - status: 'pending', - createdAt: 1, - updatedAt: 1, - }; - const started: Task = { ...created, status: 'in_progress', updatedAt: 2 }; - const projection = projectTaskLedgerEvents([ - event('task_created', created, undefined), - event('task_completed', created, created), - event('task_updated', started, created), - event('task_reopened', started, created), - ]); - assert.equal( - projection.diagnostics.some((d) => d.includes('task_completed') && d.includes('pending')), - true, - ); - assert.equal( - projection.diagnostics.some( - (d) => d.includes('task_updated') && d.includes('changed status'), - ), - true, - ); - assert.equal( - projection.diagnostics.some( - (d) => d.includes('task_reopened') && d.includes('completed -> in_progress'), - ), - true, - ); - }); - - test('maps status updates to event types', () => { - const task: Task = { - id: 't1', - key: 'T1', - subject: 'x', - status: 'pending', - createdAt: 1, - updatedAt: 1, - }; - assert.equal( - taskLedgerEventTypeForUpdate(task, { ...task, status: 'in_progress' }), - 'task_started', - ); - assert.equal( - taskLedgerEventTypeForUpdate(task, { - ...task, - status: 'completed', - completionEvidence: 'done', - }), - 'task_completed', - ); - assert.equal( - taskLedgerEventTypeForUpdate( - { ...task, status: 'completed' }, - { ...task, status: 'in_progress' }, - ), - 'task_reopened', - ); - assert.equal( - taskLedgerEventTypeForUpdate( - { ...task, status: 'failed', failureReason: 'blocked by tests' }, - task, - ), - 'task_reopened', - ); - }); - - test('projects failed -> pending reopen events without diagnostics', () => { - const created: Task = { - id: 't1', - key: 'T1', - subject: 'x', - status: 'pending', - createdAt: 1, - updatedAt: 1, - }; - const started: Task = { ...created, status: 'in_progress', updatedAt: 2 }; - const failed: Task = { - ...created, - status: 'failed', - failureReason: 'tests failed', - updatedAt: 3, - }; - const retried: Task = { ...created, status: 'pending', updatedAt: 4 }; - const projection = projectTaskLedgerEvents([ - event('task_created', created, undefined), - event('task_started', started, created), - event('task_failed', failed, started), - event('task_reopened', retried, failed), - ]); - assert.deepEqual(projection.diagnostics, []); - assert.equal(projection.tasks[0]?.status, 'pending'); - assert.equal(projection.tasks[0]?.failureReason, undefined); - }); -}); - -function event( - type: TaskLedgerEvent['type'], - task: Task, - previous: Task | undefined, -): TaskLedgerEvent { - return { - eventId: `event-${type}-${task.id}`, - type, - ts: task.updatedAt, - sessionId: 'session-1', - taskId: task.id, - ...(previous ? { previousStatus: previous.status } : {}), - nextStatus: task.status, - task, - }; -} diff --git a/packages/core/src/foreign-session.ts b/packages/core/src/foreign-session.ts index 8a7669163b..a355d3b230 100644 --- a/packages/core/src/foreign-session.ts +++ b/packages/core/src/foreign-session.ts @@ -650,8 +650,8 @@ export function stripEnvelopeTags(text: string): string { * regardless of how the digest was built — sanitizing (NFC, control/bidi/ * zero-width) and redacting secrets, then stripping envelope tags (to a * fixpoint) and JSON-stringifying so the value stays a quoted, break-out-proof - * scalar (cf. renderSafeTaskLedgerText). This covers the fields that reach the - * digest less filtered than messages do — `cwd`, `gitBranch`, and file paths. + * scalar. This covers the fields that reach the digest less filtered than + * messages do — `cwd`, `gitBranch`, and file paths. * `source` and `updated_at` are the only unquoted fields; both are * Maka-controlled enums/timestamps. */ diff --git a/packages/core/src/task-ledger.ts b/packages/core/src/task-ledger.ts deleted file mode 100644 index bbd5ba17cc..0000000000 --- a/packages/core/src/task-ledger.ts +++ /dev/null @@ -1,970 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -// Session-scoped task ledger primitive for the main agent. The model manages a -// flat task list via task_create/task_update and reads it through task_list/task_get. -// The durable contract is intentionally narrow: task status, -// compact evidence/reason fields, append-only task events, and conservative -// resume trust diagnostics. Priority, dependencies, and assignee fields remain -// out of scope. - -import { redactSecrets } from './redaction.js'; - -export const TASK_SUBJECT_MAX_CHARS = 200; -export const TASK_EVIDENCE_MAX_CHARS = 1000; -/** Hard cap on total tasks per session ledger (any status). */ -export const TASK_LEDGER_MAX_TASKS = 200; -export const TASK_ARCHIVE_AFTER_MS = 7 * 24 * 60 * 60 * 1000; - -/** - * Max length of a task id accepted on both the write and read paths. The write - * path generates randomUUID (36 chars); the bound leaves headroom for a future - * id format while keeping model-visible fielded renders bounded. - */ -export const TASK_ID_MAX_CHARS = 64; -export const TASK_KEY_MAX_CHARS = 64; - -export const TASK_STATUSES = [ - 'pending', - 'in_progress', - 'blocked', - 'completed', - 'failed', - 'cancelled', -] as const; -export type TaskStatus = (typeof TASK_STATUSES)[number]; - -export const TASK_TERMINAL_STATUSES = ['completed', 'failed', 'cancelled'] as const; - -export function isTerminalTaskStatus(status: TaskStatus): boolean { - return (TASK_TERMINAL_STATUSES as readonly TaskStatus[]).includes(status); -} - -export const TASK_RESUME_TRUST_LEVELS = [ - 'trusted', - 'needs_revalidation', - 'stale', - 'repaired', - 'untrusted', -] as const; -export type ResumeTrust = (typeof TASK_RESUME_TRUST_LEVELS)[number]; - -export interface TaskOwner { - actor: 'main_agent' | 'child_agent'; - /** Owning child Session for durable subagent work; absent on legacy child AgentRuns. */ - sessionId?: string; - agentId?: string; - runId?: string; - turnId?: string; -} - -export interface Task { - id: string; - key: string; - subject: string; - status: TaskStatus; - createdAt: number; - updatedAt: number; - parentId?: string; - owner?: TaskOwner; - endedAt?: number; - blockedReason?: string; - failureReason?: string; - completionEvidence?: string; - resumeTrust?: ResumeTrust; -} - -export interface TaskLedgerMutationContext { - runId?: string; - turnId?: string; - toolCallId?: string; - source?: 'tool' | 'system' | 'recovery' | 'import'; - actor?: 'main_agent' | 'child_agent' | 'user' | 'system'; - reason?: string; -} - -export interface TaskLedgerChangedEvent { - sessionId: string; - taskIds: string[]; - at: number; -} - -export interface TaskAgentOutcome { - status: 'completed' | 'failed' | 'cancelled' | 'running' | 'waiting_for_user'; - owner: TaskOwner; - reason?: string; -} - -export interface TaskAvailableClaimScope { - /** Main AgentRun that made this task available to its child team. */ - parentRunId: string; -} - -/** - * Store contract shared by the storage implementation and the runtime tools. - * Mutations return the changed task(s) and the new total, computed inside the - * store's serialized write section, so callers render exactly the state their - * mutation produced instead of re-reading outside the write queue. The full - * ledger never leaves the store through the mutation result. - */ -export interface TaskLedgerStore { - list(sessionId: string, options?: TaskLedgerListOptions): Promise; - get(sessionId: string, id: string, options?: TaskLedgerListOptions): Promise; - create( - sessionId: string, - drafts: unknown, - context?: TaskLedgerMutationContext, - ): Promise<{ created: Task[]; total: number }>; - update( - sessionId: string, - id: string, - patch: unknown, - context?: TaskLedgerMutationContext, - ): Promise<{ updated: Task; total: number }>; - claim( - sessionId: string, - id: string, - owner: TaskOwner, - context?: TaskLedgerMutationContext, - ): Promise<{ updated: Task; total: number }>; - claimAvailable( - sessionId: string, - id: string, - owner: TaskOwner, - scope: TaskAvailableClaimScope, - context?: TaskLedgerMutationContext, - ): Promise<{ updated: Task; total: number }>; - settleAgentOutcome( - sessionId: string, - id: string, - outcome: TaskAgentOutcome, - context?: TaskLedgerMutationContext, - ): Promise<{ updated: Task; total: number }>; - subscribe(listener: (event: TaskLedgerChangedEvent) => void): () => void; -} - -export interface TaskLedgerListOptions { - classifyResumeTrust?: boolean; - status?: TaskStatus; - includeTerminal?: boolean; - includeArchived?: boolean; - now?: number; -} - -export interface CreateTaskInput { - subject: unknown; - parentId?: unknown; -} - -export interface UpdateTaskInput { - subject?: unknown; - status?: unknown; - blockedReason?: unknown; - failureReason?: unknown; - completionEvidence?: unknown; - explicitReopen?: unknown; -} - -export type TaskLedgerNormalizeResult = - | { ok: true; value: T } - | { - ok: false; - reason: - | 'invalid_subject' - | 'invalid_status' - | 'invalid_blocked_reason' - | 'invalid_failure_reason' - | 'invalid_completion_evidence' - | 'invalid_resume_trust' - | 'invalid_transition' - | 'empty_patch'; - message: string; - }; - -type TaskLedgerNormalizeErrorReason = Extract< - TaskLedgerNormalizeResult, - { ok: false } ->['reason']; - -export function isTaskStatus(value: unknown): value is TaskStatus { - return typeof value === 'string' && (TASK_STATUSES as readonly string[]).includes(value); -} - -export function isResumeTrust(value: unknown): value is ResumeTrust { - return ( - typeof value === 'string' && (TASK_RESUME_TRUST_LEVELS as readonly string[]).includes(value) - ); -} - -/** - * Stable-token id contract shared by the runtime tool schema (front-door) and - * the storage read path. The id is rendered verbatim (see - * renderSafeTaskLedgerText), so it must not be deformable by any face that has - * ever rendered it: no angle brackets/slashes/quotes/parens/equals (a past - * whole-string tag strip would have eaten them; even the fielded renderer - * emits the id bare), no whitespace (would break the list-line structure), no - * huge length (would bloat model-visible results), and redaction-stable (a renderer - * that runs redactSecrets must not turn the id into [redacted] while the store - * keeps the real id -- a later task_update would miss). The whitelist - * (alphanumeric plus . _ : -, 1-64 chars) plus redactSecrets(id) === id enforces - * these constraints without coupling to the UUID format. - */ -export function isSafeTaskId(value: unknown): value is string { - // Stable token (alphanumeric plus . _ : -, 1-64 chars) AND redaction-stable: - // the id is rendered verbatim, so a secret-shaped id (ghp_..., sk-..., a - // 40-char hex, AIza...) must be rejected -- otherwise a renderer that does - // run redactSecrets would turn it into [redacted] while the store keeps the - // real id, and a later task_update would miss. - return ( - typeof value === 'string' && - /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/.test(value) && - redactSecrets(value) === value - ); -} - -export function isTaskKey(value: unknown): value is string { - return ( - typeof value === 'string' && - value.length <= TASK_KEY_MAX_CHARS && - /^T[1-9]\d*(?:\.[1-9]\d*)*$/.test(value) - ); -} - -export function findTaskByRef(tasks: readonly Task[], ref: string): Task | undefined { - return tasks.find((task) => task.id === ref || task.key === ref); -} - -export function normalizeTaskSubject(input: unknown): TaskLedgerNormalizeResult { - if (typeof input !== 'string') { - return invalid('invalid_subject', 'Task subject must be a string'); - } - const subject = input.normalize('NFC').replace(/\s+/g, ' ').trim(); - if (subject.length === 0) { - return invalid('invalid_subject', 'Task subject cannot be empty'); - } - if (Array.from(subject).length > TASK_SUBJECT_MAX_CHARS) { - return invalid( - 'invalid_subject', - `Task subject must be ${TASK_SUBJECT_MAX_CHARS} characters or fewer`, - ); - } - return { ok: true, value: subject }; -} - -export function normalizeTaskStatus(input: unknown): TaskLedgerNormalizeResult { - if (!isTaskStatus(input)) { - return invalid('invalid_status', `Task status must be one of ${TASK_STATUSES.join(', ')}`); - } - return { ok: true, value: input }; -} - -export function normalizeResumeTrust(input: unknown): TaskLedgerNormalizeResult { - if (!isResumeTrust(input)) { - return invalid( - 'invalid_resume_trust', - `Task resumeTrust must be one of ${TASK_RESUME_TRUST_LEVELS.join(', ')}`, - ); - } - return { ok: true, value: input }; -} - -export function normalizeTaskEvidenceText( - input: unknown, - field: 'blockedReason' | 'failureReason' | 'completionEvidence', -): TaskLedgerNormalizeResult { - if (typeof input !== 'string') { - return invalid(evidenceReason(field), `${field} must be a string`); - } - const value = input.normalize('NFC').replace(/\s+/g, ' ').trim(); - if (value.length === 0) { - return invalid(evidenceReason(field), `${field} cannot be empty`); - } - if (Array.from(value).length > TASK_EVIDENCE_MAX_CHARS) { - return invalid( - evidenceReason(field), - `${field} must be ${TASK_EVIDENCE_MAX_CHARS} characters or fewer`, - ); - } - return { ok: true, value }; -} - -export function normalizeCreateTaskInput( - input: unknown, -): TaskLedgerNormalizeResult<{ subject: string; parentId?: string }> { - if (typeof input !== 'object' || input === null || Array.isArray(input)) { - return invalid('invalid_subject', 'Task input must be an object'); - } - const record = input as CreateTaskInput; - const subject = normalizeTaskSubject(record.subject); - if (!subject.ok) return subject; - if (record.parentId !== undefined && !isSafeTaskId(record.parentId)) { - return invalid('invalid_subject', 'Task parentId must be a stable task id or key'); - } - return { - ok: true, - value: { subject: subject.value, ...(record.parentId ? { parentId: record.parentId } : {}) }, - }; -} - -export function normalizeUpdateTaskInput(input: unknown): TaskLedgerNormalizeResult<{ - subject?: string; - status?: TaskStatus; - blockedReason?: string; - failureReason?: string; - completionEvidence?: string; - explicitReopen?: boolean; -}> { - if (typeof input !== 'object' || input === null || Array.isArray(input)) { - return invalid('empty_patch', 'Task update must be an object'); - } - const record = input as UpdateTaskInput; - const patch: { - subject?: string; - status?: TaskStatus; - blockedReason?: string; - failureReason?: string; - completionEvidence?: string; - explicitReopen?: boolean; - } = {}; - if (record.subject !== undefined) { - const subject = normalizeTaskSubject(record.subject); - if (!subject.ok) return subject; - patch.subject = subject.value; - } - if (record.status !== undefined) { - const status = normalizeTaskStatus(record.status); - if (!status.ok) return status; - patch.status = status.value; - } - if (record.blockedReason !== undefined) { - const blockedReason = normalizeTaskEvidenceText(record.blockedReason, 'blockedReason'); - if (!blockedReason.ok) return blockedReason; - patch.blockedReason = blockedReason.value; - } - if (record.failureReason !== undefined) { - const failureReason = normalizeTaskEvidenceText(record.failureReason, 'failureReason'); - if (!failureReason.ok) return failureReason; - patch.failureReason = failureReason.value; - } - if (record.completionEvidence !== undefined) { - const completionEvidence = normalizeTaskEvidenceText( - record.completionEvidence, - 'completionEvidence', - ); - if (!completionEvidence.ok) return completionEvidence; - patch.completionEvidence = completionEvidence.value; - } - if (record.explicitReopen !== undefined) { - patch.explicitReopen = record.explicitReopen === true; - } - if ( - patch.subject === undefined && - patch.status === undefined && - patch.blockedReason === undefined && - patch.failureReason === undefined && - patch.completionEvidence === undefined && - patch.explicitReopen === undefined - ) { - return invalid('empty_patch', 'Task update must change at least one field'); - } - return { ok: true, value: patch }; -} - -export interface TaskStatusTransitionOptions { - explicitReopen?: boolean; -} - -export function canTransitionTaskStatus( - from: TaskStatus, - to: TaskStatus, - options: TaskStatusTransitionOptions = {}, -): boolean { - if (from === to) return true; - switch (from) { - case 'pending': - return to === 'in_progress' || to === 'cancelled'; - case 'in_progress': - return to === 'blocked' || to === 'completed' || to === 'failed' || to === 'cancelled'; - case 'blocked': - return to === 'in_progress' || to === 'cancelled' || to === 'failed'; - case 'failed': - return to === 'pending' || to === 'cancelled'; - case 'completed': - return options.explicitReopen === true && to === 'in_progress'; - case 'cancelled': - return options.explicitReopen === true && to === 'pending'; - } -} - -export function validateTaskEvidence( - task: Pick, -): TaskLedgerNormalizeResult { - if (task.status === 'blocked' && !task.blockedReason) { - return invalid('invalid_blocked_reason', 'Blocked tasks require blockedReason'); - } - if (task.status === 'failed' && !task.failureReason) { - return invalid('invalid_failure_reason', 'Failed tasks require failureReason'); - } - if (task.status === 'completed' && !task.completionEvidence) { - return invalid('invalid_completion_evidence', 'Completed tasks require completionEvidence'); - } - return { ok: true, value: undefined }; -} - -export interface ValidateTaskUpdateOptions extends TaskStatusTransitionOptions {} - -export function validateTaskUpdate( - previousTask: Task, - input: unknown, - options: ValidateTaskUpdateOptions = {}, -): TaskLedgerNormalizeResult<{ - subject?: string; - status?: TaskStatus; - blockedReason?: string; - failureReason?: string; - completionEvidence?: string; - explicitReopen?: boolean; -}> { - const normalized = normalizeUpdateTaskInput(input); - if (!normalized.ok) return normalized; - const { explicitReopen: patchExplicitReopen, ...taskPatch } = normalized.value; - const nextTask: Task = { - ...previousTask, - ...taskPatch, - }; - const explicitReopen = options.explicitReopen === true || patchExplicitReopen === true; - if ( - normalized.value.status !== undefined && - !canTransitionTaskStatus(previousTask.status, normalized.value.status, { - ...options, - explicitReopen, - }) - ) { - const recovery = - previousTask.status === 'pending' && normalized.value.status === 'completed' - ? '. Call task_update with status "in_progress" first, then call task_update with status "completed" and completionEvidence.' - : ''; - return invalid( - 'invalid_transition', - `Invalid task status transition from ${previousTask.status} to ${normalized.value.status}${recovery}`, - ); - } - const evidence = validateTaskEvidence(nextTask); - if (!evidence.ok) return evidence; - return normalized; -} - -export interface TaskResumeTrustRefs { - corruptLedger?: boolean; - missingReferences?: boolean; - interrupted?: boolean; - repaired?: boolean; - needsRevalidation?: boolean; -} - -export function classifyTaskResumeTrust( - task: Pick, - refs: TaskResumeTrustRefs = {}, -): ResumeTrust { - if (refs.corruptLedger || refs.missingReferences) return 'untrusted'; - if (refs.repaired) return 'repaired'; - if (refs.interrupted || task.status === 'in_progress') return 'stale'; - if (refs.needsRevalidation) return 'needs_revalidation'; - if (!validateTaskEvidence(task).ok) return 'needs_revalidation'; - return 'trusted'; -} - -export const TASK_LEDGER_EVENT_TYPES = [ - 'task_created', - 'task_updated', - 'task_started', - 'task_blocked', - 'task_completed', - 'task_failed', - 'task_cancelled', - 'task_reopened', - 'task_resume_classified', - 'task_repaired', - 'task_imported', -] as const; -export type TaskLedgerEventType = (typeof TASK_LEDGER_EVENT_TYPES)[number]; - -export interface TaskLedgerEventRefs { - runId?: string; - turnId?: string; - toolCallId?: string; -} - -/** Persisted snapshots from before task-ledger v2 legitimately lack these fields. */ -export type TaskLedgerEventTaskSnapshot = Omit & { - key?: string; -}; - -export interface TaskLedgerEvent { - eventId: string; - type: TaskLedgerEventType; - ts: number; - sessionId: string; - taskId: string; - previousStatus?: TaskStatus; - nextStatus: TaskStatus; - task: TaskLedgerEventTaskSnapshot; - reason?: string; - evidence?: string; - refs?: TaskLedgerEventRefs; - source?: TaskLedgerMutationContext['source']; - actor?: TaskLedgerMutationContext['actor']; -} - -export interface TaskLedgerProjection { - tasks: Task[]; - diagnostics: string[]; - backfilledTaskIds: string[]; -} - -export function taskLedgerEventTypeForCreate(task: Task): TaskLedgerEventType { - return taskLedgerEventTypeForStatus(task.status, true); -} - -export function taskLedgerEventTypeForUpdate(previous: Task, next: Task): TaskLedgerEventType { - if (previous.status === next.status) return 'task_updated'; - if ( - (previous.status === 'completed' && next.status === 'in_progress') || - (previous.status === 'cancelled' && next.status === 'pending') || - (previous.status === 'failed' && next.status === 'pending') - ) { - return 'task_reopened'; - } - return taskLedgerEventTypeForStatus(next.status, false); -} - -export function projectTaskLedgerEvents(events: readonly TaskLedgerEvent[]): TaskLedgerProjection { - const tasks = new Map(); - const firstSeen = new Map(); - const diagnostics: string[] = []; - for (let eventIndex = 0; eventIndex < events.length; eventIndex += 1) { - const event = events[eventIndex]!; - if (!isTaskLedgerEvent(event)) { - diagnostics.push('invalid task ledger event shape'); - continue; - } - const current = tasks.get(event.taskId); - const typeDiagnostic = validateTaskLedgerEventType(event, current); - if (typeDiagnostic) diagnostics.push(typeDiagnostic); - if (event.type === 'task_created' || event.type === 'task_imported') { - if (current) diagnostics.push(`duplicate ${event.type} for ${event.taskId}`); - if (!firstSeen.has(event.taskId)) firstSeen.set(event.taskId, eventIndex); - tasks.set(event.taskId, { ...event.task }); - continue; - } - if (!current) { - diagnostics.push(`task event ${event.type} references unknown task ${event.taskId}`); - if (!firstSeen.has(event.taskId)) firstSeen.set(event.taskId, eventIndex); - tasks.set(event.taskId, { ...event.task }); - continue; - } - if (event.previousStatus !== undefined && current.status !== event.previousStatus) { - diagnostics.push( - `task event ${event.type} for ${event.taskId} expected previous status ${event.previousStatus} but saw ${current.status}`, - ); - } - if ( - !canTransitionTaskStatus(current.status, event.nextStatus, { - explicitReopen: event.type === 'task_reopened', - }) - ) { - diagnostics.push( - `invalid task transition ${current.status} -> ${event.nextStatus} for ${event.taskId}`, - ); - } - tasks.set(event.taskId, { ...event.task }); - } - const hydrated = hydrateTaskLedger([...tasks.values()], firstSeen, diagnostics); - return { tasks: hydrated.tasks, diagnostics, backfilledTaskIds: hydrated.backfilledTaskIds }; -} - -function validateTaskLedgerEventType( - event: TaskLedgerEvent, - current: TaskLedgerEventTaskSnapshot | undefined, -): string | undefined { - switch (event.type) { - case 'task_created': - return event.nextStatus === 'pending' - ? undefined - : `task_created for ${event.taskId} must create a pending task, saw ${event.nextStatus}`; - case 'task_updated': - if (!current) return undefined; - return current.status === event.nextStatus - ? undefined - : `task_updated for ${event.taskId} changed status ${current.status} -> ${event.nextStatus}`; - case 'task_started': - return event.nextStatus === 'in_progress' - ? undefined - : `task_started for ${event.taskId} must set status in_progress, saw ${event.nextStatus}`; - case 'task_blocked': - return event.nextStatus === 'blocked' - ? undefined - : `task_blocked for ${event.taskId} must set status blocked, saw ${event.nextStatus}`; - case 'task_completed': - return event.nextStatus === 'completed' - ? undefined - : `task_completed for ${event.taskId} must set status completed, saw ${event.nextStatus}`; - case 'task_failed': - return event.nextStatus === 'failed' - ? undefined - : `task_failed for ${event.taskId} must set status failed, saw ${event.nextStatus}`; - case 'task_cancelled': - return event.nextStatus === 'cancelled' - ? undefined - : `task_cancelled for ${event.taskId} must set status cancelled, saw ${event.nextStatus}`; - case 'task_reopened': - if (!current) return undefined; - return (current.status === 'completed' && event.nextStatus === 'in_progress') || - (current.status === 'cancelled' && event.nextStatus === 'pending') || - (current.status === 'failed' && event.nextStatus === 'pending') - ? undefined - : `task_reopened for ${event.taskId} must reopen completed -> in_progress, cancelled -> pending, or failed -> pending, saw ${current.status} -> ${event.nextStatus}`; - case 'task_resume_classified': - case 'task_repaired': - case 'task_imported': - return undefined; - } -} - -/** - * Safe-render the task ledger for any face that persists into history or is - * included in a model-visible tool result. Two invariants: - * - the canonical id is rendered verbatim, and the subject is a safe - * (redacted, tag-stripped) rendered payload of what the store holds; and - * - the model can unambiguously recover each task's id from what it sees, so - * a later task_update hits the right task. - * - * Rendering is per-task and fielded, not a free-text bullet: each line is - * `id= status= subject=`. The - * canonical id is a distinct leading field, so a subject cannot smuggle a fake - * `id=` field or any other id-like span past it -- any id-like text in the subject stays - * inside the quoted JSON payload. The id is emitted verbatim: it is a - * redaction-stable stable token validated on write and read, so scrubbing it - * could only deform it (and break task_update); it must not be redacted or - * tag-stripped. Each subject is redacted (secrets) and tag-stripped (complete - * `` / `` tags on a single line, so a - * model-authored subject cannot open or close the data envelope) - * independently -- a subject on one task can never eat or deform text on - * another task's line. Other angle brackets (e.g. `a < b`) are left intact. - * Returns '' for an empty ledger. - */ -export function renderSafeTaskLedgerText(tasks: readonly Task[]): string { - if (tasks.length === 0) return ''; - return tasks - .map((rawTask) => { - const task = sanitizeTaskLedgerTask(rawTask); - const fields = [ - `key=${task.key}`, - `id=${task.id}`, - `status=${task.status}`, - `subject=${JSON.stringify(task.subject)}`, - ]; - if (task.parentId) fields.push(`parentId=${task.parentId}`); - if (task.owner) fields.push(`owner=${JSON.stringify(task.owner)}`); - if (task.blockedReason) - fields.push(`blockedReason=${JSON.stringify(safeTaskLedgerField(task.blockedReason))}`); - if (task.failureReason) - fields.push(`failureReason=${JSON.stringify(safeTaskLedgerField(task.failureReason))}`); - if (task.completionEvidence) - fields.push( - `completionEvidence=${JSON.stringify(safeTaskLedgerField(task.completionEvidence))}`, - ); - return fields.join(' '); - }) - .join('\n'); -} - -/** Safe structured DTO for renderer and diagnostic faces. */ -export function sanitizeTaskLedgerTask(task: Task): Task { - return { - ...task, - subject: safeTaskLedgerField(task.subject), - ...(task.blockedReason ? { blockedReason: safeTaskLedgerField(task.blockedReason) } : {}), - ...(task.failureReason ? { failureReason: safeTaskLedgerField(task.failureReason) } : {}), - ...(task.completionEvidence - ? { completionEvidence: safeTaskLedgerField(task.completionEvidence) } - : {}), - }; -} - -export function renderTaskLedgerDebugText(tasks: readonly Task[]): string { - if (tasks.length === 0) return ''; - return tasks - .map((task) => { - const fields = [renderSafeTaskLedgerText([task])]; - if (task.resumeTrust) fields.push(`resumeTrust=${task.resumeTrust}`); - return fields.join(' '); - }) - .join('\n'); -} - -export function filterModelVisibleTaskLedgerTasks(tasks: readonly Task[]): Task[] { - return tasks.filter((task) => task.resumeTrust !== 'untrusted'); -} - -export function isTaskLedgerEvent(value: unknown): value is TaskLedgerEvent { - if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; - const record = value as Partial; - if ( - !isTaskLedgerEventTask(record.task, { - allowLegacyMissingEvidence: record.type === 'task_imported', - }) - ) - return false; - return ( - typeof record.eventId === 'string' && - (TASK_LEDGER_EVENT_TYPES as readonly string[]).includes(String(record.type)) && - typeof record.ts === 'number' && - Number.isFinite(record.ts) && - typeof record.sessionId === 'string' && - typeof record.taskId === 'string' && - record.taskId === record.task.id && - isTaskStatus(record.nextStatus) && - record.nextStatus === record.task.status && - (record.previousStatus === undefined || isTaskStatus(record.previousStatus)) && - (record.reason === undefined || typeof record.reason === 'string') && - (record.evidence === undefined || typeof record.evidence === 'string') && - (record.refs === undefined || isTaskLedgerEventRefs(record.refs)) && - (record.source === undefined || - record.source === 'tool' || - record.source === 'system' || - record.source === 'recovery' || - record.source === 'import') && - (record.actor === undefined || - record.actor === 'main_agent' || - record.actor === 'child_agent' || - record.actor === 'user' || - record.actor === 'system') - ); -} - -function taskLedgerEventTypeForStatus(status: TaskStatus, create: boolean): TaskLedgerEventType { - if (create) return 'task_created'; - switch (status) { - case 'pending': - return 'task_updated'; - case 'in_progress': - return 'task_started'; - case 'blocked': - return 'task_blocked'; - case 'completed': - return 'task_completed'; - case 'failed': - return 'task_failed'; - case 'cancelled': - return 'task_cancelled'; - } -} - -function isTaskLedgerEventTask( - value: unknown, - options: { allowLegacyMissingEvidence?: boolean } = {}, -): value is TaskLedgerEventTaskSnapshot { - if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; - const task = value as Partial; - if ( - !isSafeTaskId(task.id) || - !normalizeTaskSubject(task.subject).ok || - !isTaskStatus(task.status) || - typeof task.createdAt !== 'number' || - !Number.isFinite(task.createdAt) || - typeof task.updatedAt !== 'number' || - !Number.isFinite(task.updatedAt) || - (task.blockedReason !== undefined && - !normalizeTaskEvidenceText(task.blockedReason, 'blockedReason').ok) || - (task.failureReason !== undefined && - !normalizeTaskEvidenceText(task.failureReason, 'failureReason').ok) || - (task.completionEvidence !== undefined && - !normalizeTaskEvidenceText(task.completionEvidence, 'completionEvidence').ok) || - (task.resumeTrust !== undefined && !isResumeTrust(task.resumeTrust)) || - (task.key !== undefined && !isTaskKey(task.key)) || - (task.parentId !== undefined && !isSafeTaskId(task.parentId)) || - (task.owner !== undefined && !isTaskOwner(task.owner)) || - (task.endedAt !== undefined && - (typeof task.endedAt !== 'number' || !Number.isFinite(task.endedAt))) - ) { - return false; - } - if (options.allowLegacyMissingEvidence === true) return true; - return validateTaskEvidence({ - status: task.status, - blockedReason: task.blockedReason, - failureReason: task.failureReason, - completionEvidence: task.completionEvidence, - }).ok; -} - -export function isTaskOwner(value: unknown): value is TaskOwner { - if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; - const owner = value as Partial; - return ( - (owner.actor === 'main_agent' || owner.actor === 'child_agent') && - (owner.sessionId === undefined || isSafeTaskId(owner.sessionId)) && - (owner.agentId === undefined || isSafeTaskId(owner.agentId)) && - (owner.runId === undefined || isSafeTaskId(owner.runId)) && - (owner.turnId === undefined || isSafeTaskId(owner.turnId)) - ); -} - -function hydrateTaskLedger( - snapshots: readonly TaskLedgerEventTaskSnapshot[], - firstSeen: ReadonlyMap, - diagnostics: string[], -): { tasks: Task[]; backfilledTaskIds: string[] } { - const byId = new Map(snapshots.map((task) => [task.id, task])); - const used = new Set(); - const backfilled = new Set(); - - for (const task of snapshots) { - if (!task.key) continue; - if (used.has(task.key)) diagnostics.push(`duplicate task key ${task.key}`); - used.add(task.key); - } - for (const task of snapshots) { - if (task.parentId && !byId.has(task.parentId)) - diagnostics.push(`task ${task.id} references missing parent ${task.parentId}`); - if (task.parentId && task.key) { - const parent = byId.get(task.parentId); - if (parent?.key && !isDirectChildTaskKey(parent.key, task.key)) { - diagnostics.push( - `task ${task.id} key ${task.key} does not belong under parent key ${parent.key}`, - ); - } - } else if (!task.parentId && task.key && task.key.includes('.')) { - diagnostics.push(`root task ${task.id} cannot use child key ${task.key}`); - } - } - - const visiting = new Set(); - const visited = new Set(); - const visit = (task: TaskLedgerEventTaskSnapshot): void => { - if (visited.has(task.id)) return; - if (visiting.has(task.id)) { - diagnostics.push(`task hierarchy cycle includes ${task.id}`); - return; - } - visiting.add(task.id); - if (task.parentId) { - const parent = byId.get(task.parentId); - if (parent) visit(parent); - } - visiting.delete(task.id); - visited.add(task.id); - }; - for (const task of snapshots) visit(task); - - const stable = [...snapshots].sort( - (a, b) => - (firstSeen.get(a.id) ?? Number.MAX_SAFE_INTEGER) - - (firstSeen.get(b.id) ?? Number.MAX_SAFE_INTEGER) || - a.createdAt - b.createdAt || - a.id.localeCompare(b.id), - ); - const children = new Map(); - for (const task of stable) { - const bucket = children.get(task.parentId) ?? []; - bucket.push(task); - children.set(task.parentId, bucket); - } - - const assign = (parentId: string | undefined, parentKey: string | undefined): void => { - const siblings = children.get(parentId) ?? []; - let next = 1; - for (const task of siblings) { - if (!task.key) { - let candidate = parentKey ? `${parentKey}.${next}` : `T${next}`; - while (used.has(candidate)) { - next += 1; - candidate = parentKey ? `${parentKey}.${next}` : `T${next}`; - } - task.key = candidate; - used.add(candidate); - backfilled.add(task.id); - } - if (isTerminalTaskStatus(task.status) && task.endedAt === undefined) { - task.endedAt = task.updatedAt; - backfilled.add(task.id); - } - const tail = Number(task.key.split('.').at(-1)?.replace(/^T/, '')); - if (Number.isFinite(tail)) next = Math.max(next, tail + 1); - assign(task.id, task.key); - } - }; - assign(undefined, undefined); - - for (const task of snapshots) { - if (!task.parentId || !task.key) continue; - const parent = byId.get(task.parentId); - if (parent?.key && !isDirectChildTaskKey(parent.key, task.key)) { - const diagnostic = `task ${task.id} key ${task.key} does not belong under parent key ${parent.key}`; - if (!diagnostics.includes(diagnostic)) diagnostics.push(diagnostic); - } - } - - const tasks = stable.flatMap((task): Task[] => (task.key ? [{ ...task, key: task.key }] : [])); - if (tasks.length !== snapshots.length) - diagnostics.push('task hierarchy could not assign keys to every task'); - return { tasks, backfilledTaskIds: [...backfilled] }; -} - -function isDirectChildTaskKey(parentKey: string, childKey: string): boolean { - return ( - childKey.startsWith(`${parentKey}.`) && - childKey.split('.').length === parentKey.split('.').length + 1 - ); -} - -function isTaskLedgerEventRefs(value: unknown): value is TaskLedgerEventRefs { - if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; - const refs = value as Partial; - return ( - (refs.runId === undefined || typeof refs.runId === 'string') && - (refs.turnId === undefined || typeof refs.turnId === 'string') && - (refs.toolCallId === undefined || typeof refs.toolCallId === 'string') - ); -} - -function safeTaskLedgerField(value: string): string { - return redactSecrets(value).replace(/<\/?task-ledger[^\n>]*>/gi, ''); -} - -function evidenceReason( - field: 'blockedReason' | 'failureReason' | 'completionEvidence', -): TaskLedgerNormalizeErrorReason { - switch (field) { - case 'blockedReason': - return 'invalid_blocked_reason'; - case 'failureReason': - return 'invalid_failure_reason'; - case 'completionEvidence': - return 'invalid_completion_evidence'; - } -} - -function invalid( - reason: T, - message: string, -): Extract, { ok: false }> { - return { ok: false, reason, message }; -} diff --git a/packages/runtime-host/src/__tests__/execution-host-message.test.ts b/packages/runtime-host/src/__tests__/execution-host-message.test.ts index 5ecbfee7e0..76f171a765 100644 --- a/packages/runtime-host/src/__tests__/execution-host-message.test.ts +++ b/packages/runtime-host/src/__tests__/execution-host-message.test.ts @@ -42,7 +42,6 @@ import type { AgentRunHeader } from '@maka/core/agent-run'; import type { MessageContent } from '@maka/core/events'; import type { ConnectionCatalogEntry } from '@maka/core/runtime-policy'; import type { StoredMessage } from '@maka/core/session'; -import type { Task } from '@maka/core/task-ledger'; import { isTerminalRuntimeEvent } from '@maka/core/runtime-event'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { diff --git a/packages/runtime-host/src/__tests__/execution-host-queue.test.ts b/packages/runtime-host/src/__tests__/execution-host-queue.test.ts index f5186466d4..b67513f8dd 100644 --- a/packages/runtime-host/src/__tests__/execution-host-queue.test.ts +++ b/packages/runtime-host/src/__tests__/execution-host-queue.test.ts @@ -42,7 +42,6 @@ import type { AgentRunHeader } from '@maka/core/agent-run'; import type { MessageContent } from '@maka/core/events'; import type { ConnectionCatalogEntry } from '@maka/core/runtime-policy'; import type { StoredMessage } from '@maka/core/session'; -import type { Task } from '@maka/core/task-ledger'; import { isTerminalRuntimeEvent } from '@maka/core/runtime-event'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { diff --git a/packages/runtime-host/src/__tests__/execution-host-recovery.test.ts b/packages/runtime-host/src/__tests__/execution-host-recovery.test.ts index ae20e64aee..c06127a15b 100644 --- a/packages/runtime-host/src/__tests__/execution-host-recovery.test.ts +++ b/packages/runtime-host/src/__tests__/execution-host-recovery.test.ts @@ -33,7 +33,6 @@ import type { AgentRunHeader } from '@maka/core/agent-run'; import type { MessageContent } from '@maka/core/events'; import type { ConnectionCatalogEntry } from '@maka/core/runtime-policy'; import type { StoredMessage } from '@maka/core/session'; -import type { Task } from '@maka/core/task-ledger'; import { isTerminalRuntimeEvent } from '@maka/core/runtime-event'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { diff --git a/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts b/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts index 975b130a78..1cdf00afd2 100644 --- a/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts +++ b/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts @@ -48,7 +48,6 @@ import { } from '@maka/core/events'; import type { ConnectionCatalogEntry } from '@maka/core/runtime-policy'; import type { StoredMessage } from '@maka/core/session'; -import type { Task } from '@maka/core/task-ledger'; import { isTerminalRuntimeEvent } from '@maka/core/runtime-event'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { BackendRegistry, SessionManager } from '@maka/runtime/session-manager'; diff --git a/packages/runtime-host/src/__tests__/session-catalog-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/session-catalog-two-client-uds.test.ts index aa3e5c6784..9f4415e2df 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-two-client-uds.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-two-client-uds.test.ts @@ -1010,14 +1010,6 @@ async function assertRetirementCleanup( .get(sessionId)!.count, 0, ); - assert.equal( - database - .prepare( - 'SELECT COUNT(*) AS count FROM workflow_task_ledger_events WHERE session_id = ?', - ) - .get(sessionId)!.count, - 0, - ); } finally { database.close(); } diff --git a/packages/storage/src/__tests__/operational-state-store.test.ts b/packages/storage/src/__tests__/operational-state-store.test.ts index a9243a1140..bea433ada7 100644 --- a/packages/storage/src/__tests__/operational-state-store.test.ts +++ b/packages/storage/src/__tests__/operational-state-store.test.ts @@ -996,13 +996,13 @@ test('rejects a nonempty database with no operational registry', async () => { 'missing-registry', (database) => database.exec( - 'DROP TABLE operational_schema_migrations; DROP TABLE workflow_task_ledger_events', + 'DROP TABLE operational_schema_migrations; DROP TABLE workflow_session_todo_documents', ), /registry is missing from a nonempty database/, (database) => assert.equal( database - .prepare("SELECT 1 FROM sqlite_schema WHERE name = 'workflow_task_ledger_events'") + .prepare("SELECT 1 FROM sqlite_schema WHERE name = 'workflow_session_todo_documents'") .get(), undefined, ), diff --git a/packages/storage/src/__tests__/session-todo-store.test.ts b/packages/storage/src/__tests__/session-todo-store.test.ts index e1ecc82fac..0780f8c34d 100644 --- a/packages/storage/src/__tests__/session-todo-store.test.ts +++ b/packages/storage/src/__tests__/session-todo-store.test.ts @@ -24,29 +24,15 @@ import { join } from 'node:path'; import { describe, test } from 'node:test'; import { DatabaseSync } from 'node:sqlite'; import { createSqliteSessionTodoStore } from '../session-todo-store.js'; -import { createSqliteTaskLedgerStore } from '../task-ledger-store.js'; const SESSION_ID = 'session-todo'; describe('SQLite SessionTodo store', () => { - test('bootstraps active legacy Tasks once and persists the document', async () => { + test('persists an initialized-empty document on the first read', async () => { await withRoot(async (root) => { - const tasks = createSqliteTaskLedgerStore(root); - const created = await tasks.create(SESSION_ID, [ - { subject: 'pending legacy work' }, - { subject: 'completed legacy work' }, - ]); - await tasks.update(SESSION_ID, created.created[1]!.id, { status: 'in_progress' }); - await tasks.update(SESSION_ID, created.created[1]!.id, { - status: 'completed', - completionEvidence: 'done', - }); - tasks.close(); - const todos = createSqliteSessionTodoStore(root); - assert.deepEqual(await todos.readOrBootstrap(SESSION_ID), { - items: [{ content: 'pending legacy work', status: 'pending' }], - }); + assert.deepEqual(await todos.readOrBootstrap(SESSION_ID), { items: [] }); + assert.deepEqual(await todos.readOrBootstrap(SESSION_ID), { items: [] }); todos.close(); const database = new DatabaseSync(join(root, 'runtime.sqlite'), { readOnly: true }); @@ -65,58 +51,8 @@ describe('SQLite SessionTodo store', () => { }); }); - test('keeps a blocked legacy Task visible as pending without importing workflow metadata', async () => { - await withRoot(async (root) => { - const tasks = createSqliteTaskLedgerStore(root); - const created = await tasks.create(SESSION_ID, [{ subject: 'waiting for approval' }]); - const taskId = created.created[0]!.id; - await tasks.update(SESSION_ID, taskId, { status: 'in_progress' }); - await tasks.update(SESSION_ID, taskId, { - status: 'blocked', - blockedReason: 'approval has not arrived', - }); - tasks.close(); - - const todos = createSqliteSessionTodoStore(root); - assert.deepEqual(await todos.readOrBootstrap(SESSION_ID), { - items: [{ content: 'waiting for approval', status: 'pending' }], - }); - todos.close(); - }); - }); - - test('persists initialized-empty and never revives later legacy Tasks', async () => { - await withRoot(async (root) => { - const todos = createSqliteSessionTodoStore(root); - assert.deepEqual(await todos.readOrBootstrap(SESSION_ID), { items: [] }); - - const tasks = createSqliteTaskLedgerStore(root); - await tasks.create(SESSION_ID, [{ subject: 'too late for bootstrap' }]); - assert.deepEqual(await todos.readOrBootstrap(SESSION_ID), { items: [] }); - tasks.close(); - todos.close(); - }); - }); - - test('lets the first explicit replacement win without reading legacy state', async () => { - await withRoot(async (root) => { - const tasks = createSqliteTaskLedgerStore(root); - await tasks.create(SESSION_ID, [{ subject: 'legacy work' }]); - tasks.close(); - - const todos = createSqliteSessionTodoStore(root); - assert.deepEqual(await todos.replaceAll(SESSION_ID, []), { items: [] }); - assert.deepEqual(await todos.readOrBootstrap(SESSION_ID), { items: [] }); - todos.close(); - }); - }); - test('serializes a first explicit replacement ahead of a following bootstrap read', async () => { await withRoot(async (root) => { - const tasks = createSqliteTaskLedgerStore(root); - await tasks.create(SESSION_ID, [{ subject: 'legacy work' }]); - tasks.close(); - const todos = createSqliteSessionTodoStore(root); const [written, read] = await Promise.all([ todos.replaceAll(SESSION_ID, [{ content: 'explicit work', status: 'in_progress' }]), @@ -152,71 +88,6 @@ describe('SQLite SessionTodo store', () => { }); }); - test('fails closed on corrupt legacy events without writing an initialized marker', async () => { - await withRoot(async (root) => { - createSqliteTaskLedgerStore(root).close(); - const database = new DatabaseSync(join(root, 'runtime.sqlite')); - try { - database - .prepare(` - INSERT INTO workflow_task_ledger_events(session_id, sequence, event_id, record_json) - VALUES (?, 0, 'bad-event', '{not-json') - `) - .run(SESSION_ID); - } finally { - database.close(); - } - - const todos = createSqliteSessionTodoStore(root); - await assert.rejects( - () => todos.readOrBootstrap(SESSION_ID), - /Invalid legacy Task event JSON/, - ); - todos.close(); - - const verified = new DatabaseSync(join(root, 'runtime.sqlite'), { readOnly: true }); - try { - assert.equal( - verified - .prepare( - 'SELECT COUNT(*) AS count FROM workflow_session_todo_documents WHERE session_id = ?', - ) - .get(SESSION_ID)!.count, - 0, - ); - } finally { - verified.close(); - } - }); - }); - - test('explicit replacement recovers without decoding corrupt legacy state', async () => { - await withRoot(async (root) => { - createSqliteTaskLedgerStore(root).close(); - const database = new DatabaseSync(join(root, 'runtime.sqlite')); - try { - database - .prepare(` - INSERT INTO workflow_task_ledger_events(session_id, sequence, event_id, record_json) - VALUES (?, 0, 'bad-event', '{not-json') - `) - .run(SESSION_ID); - } finally { - database.close(); - } - - const todos = createSqliteSessionTodoStore(root); - assert.deepEqual( - await todos.replaceAll(SESSION_ID, [{ content: 'explicit recovery', status: 'pending' }]), - { items: [{ content: 'explicit recovery', status: 'pending' }] }, - ); - assert.deepEqual(await todos.readOrBootstrap(SESSION_ID), { - items: [{ content: 'explicit recovery', status: 'pending' }], - }); - todos.close(); - }); - }); - test('fails closed on a corrupt current document but permits explicit replacement recovery', async () => { await withRoot(async (root) => { const initialized = createSqliteSessionTodoStore(root); @@ -325,7 +196,7 @@ describe('SQLite SessionTodo store', () => { }); }); - test('writes an explicit empty copy marker that later legacy events cannot revive', async () => { + test('writes an explicit empty copy marker when the copy skips current state', async () => { await withRoot(async (root) => { const todos = createSqliteSessionTodoStore(root); assert.deepEqual( @@ -336,22 +207,11 @@ describe('SQLite SessionTodo store', () => { }), { items: [] }, ); - const tasks = createSqliteTaskLedgerStore(root); - await tasks.create('historical-target', [{ subject: 'must not revive' }]); - tasks.close(); - assert.deepEqual(await todos.readOrBootstrap('historical-target'), { items: [] }); todos.close(); - }); - }); - test('purges Todo and legacy bootstrap rows in one lifecycle operation', async () => { - await withRoot(async (root) => { - const tasks = createSqliteTaskLedgerStore(root); - await tasks.create(SESSION_ID, [{ subject: 'legacy' }]); - tasks.close(); - const todos = createSqliteSessionTodoStore(root); - await todos.replaceAll(SESSION_ID, [{ content: 'current', status: 'pending' }]); - await todos.purgeSessionState(SESSION_ID); + // The copy must persist the marker itself: reading it back would return an + // empty document either way, so only the stored row separates "wrote an + // explicit empty copy" from "wrote nothing at all". const database = new DatabaseSync(join(root, 'runtime.sqlite'), { readOnly: true }); try { assert.equal( @@ -359,21 +219,35 @@ describe('SQLite SessionTodo store', () => { .prepare( 'SELECT COUNT(*) AS count FROM workflow_session_todo_documents WHERE session_id = ?', ) - .get(SESSION_ID)!.count, - 0, - ); - assert.equal( - database - .prepare( - 'SELECT COUNT(*) AS count FROM workflow_task_ledger_events WHERE session_id = ?', - ) - .get(SESSION_ID)!.count, - 0, + .get('historical-target')!.count, + 1, ); } finally { database.close(); } + }); + }); + + test('copies an uninitialized source as empty without initializing the source', async () => { + await withRoot(async (root) => { + const todos = createSqliteSessionTodoStore(root); + const input = { sourceSessionId: 'source', targetSessionId: 'target', copyCurrent: true }; + assert.deepEqual(await todos.initializeCopy(input), { items: [] }); + assert.deepEqual(await todos.initializeCopy(input), { items: [] }); todos.close(); + + const database = new DatabaseSync(join(root, 'runtime.sqlite'), { readOnly: true }); + try { + const rows = database + .prepare('SELECT session_id FROM workflow_session_todo_documents ORDER BY session_id') + .all() as Array<{ session_id: string }>; + assert.deepEqual( + rows.map((row) => row.session_id), + ['target'], + ); + } finally { + database.close(); + } }); }); diff --git a/packages/storage/src/__tests__/sqlite-workflow-store.test.ts b/packages/storage/src/__tests__/sqlite-workflow-store.test.ts index 6489c1493e..4d512c0dd4 100644 --- a/packages/storage/src/__tests__/sqlite-workflow-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-workflow-store.test.ts @@ -30,7 +30,6 @@ import { } from '../operational-state-backup.js'; import { openInteractiveScheduledTaskStoreForWrite } from '../scheduled-task-store.js'; import { createSqlitePlanStore } from '../plan-store.js'; -import { createSqliteTaskLedgerStore } from '../task-ledger-store.js'; import { createSqliteSessionTodoStore } from '../session-todo-store.js'; import { SQLITE_WORKFLOW_SCHEMA_VERSION } from '../sqlite-workflow-schema.js'; import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '../root-authority.js'; @@ -46,30 +45,6 @@ after(removeTrackedControlDirectories); const SESSION_ID = 'session-workflow'; describe('SQLite workflow stores', () => { - test('persists Task Ledger exclusively through events', async () => { - await withRoot(async (root) => { - const store = createSqliteTaskLedgerStore(root); - const { created } = await store.create(SESSION_ID, [{ subject: 'Implement SQLite' }]); - assert.equal(created[0]?.status, 'pending'); - store.close(); - - const reopened = createSqliteTaskLedgerStore(root); - try { - assert.equal((await reopened.list(SESSION_ID))[0]?.subject, 'Implement SQLite'); - } finally { - reopened.close(); - } - - const database = new DatabaseSync(join(root, 'runtime.sqlite'), { readOnly: true }); - try { - assert.equal(rowCount(database, 'workflow_task_ledger_events'), 1); - assert.equal(tableExists(database, 'workflow_task_ledger_projections'), false); - } finally { - database.close(); - } - }); - }); - test('persists Plan exclusively through events', async () => { await withRoot(async (root) => { const store = createSqlitePlanStore(root, { @@ -109,10 +84,6 @@ describe('SQLite workflow stores', () => { test('migrates released workflow schema 9 projections to the current workflow schema', async () => { await withRoot(async (root) => { - const taskStore = createSqliteTaskLedgerStore(root); - await taskStore.create(SESSION_ID, [{ subject: 'Preserve event authority' }]); - taskStore.close(); - const planStore = createSqlitePlanStore(root, { newId: () => 'proposal-1', now: () => 100 }); const submitted = await planStore.submitProposal({ sessionId: SESSION_ID, @@ -140,15 +111,6 @@ describe('SQLite workflow stores', () => { released.close(); } - const migratedTasks = createSqliteTaskLedgerStore(root); - try { - assert.equal( - (await migratedTasks.list(SESSION_ID))[0]?.subject, - 'Preserve event authority', - ); - } finally { - migratedTasks.close(); - } const migratedPlan = createSqlitePlanStore(root); try { assert.equal( @@ -164,7 +126,6 @@ describe('SQLite workflow stores', () => { assert.equal(workflowSchemaVersion(verified), SQLITE_WORKFLOW_SCHEMA_VERSION); assert.equal(tableExists(verified, 'workflow_task_ledger_projections'), false); assert.equal(tableExists(verified, 'workflow_plan_projections'), false); - assert.equal(rowCount(verified, 'workflow_task_ledger_events'), 1); assert.equal(rowCount(verified, 'workflow_plan_events'), 1); } finally { verified.close(); @@ -172,20 +133,30 @@ describe('SQLite workflow stores', () => { }); }); - test('adds SessionTodo storage to workflow schema 10 without changing Task events', async () => { + test('restores SessionTodo storage and drops Task Ledger events from workflow schema 10', async () => { await withRoot(async (root) => { - const tasks = createSqliteTaskLedgerStore(root); - const created = await tasks.create(SESSION_ID, [{ subject: 'Preserve schema 10 Task' }]); - const taskId = created.created[0]!.id; - await tasks.update(SESSION_ID, taskId, { status: 'in_progress' }); - await tasks.update(SESSION_ID, taskId, { - status: 'blocked', - blockedReason: 'waiting across the upgrade', - }); - tasks.close(); + createSqliteSessionTodoStore(root).close(); + // Recreate the schema-10 shape: SessionTodo storage did not exist yet and + // Task Ledger events did, so the migration has to add one and drop the other. const released = new DatabaseSync(join(root, 'runtime.sqlite')); try { + released.exec(` + CREATE TABLE workflow_task_ledger_events ( + session_id TEXT NOT NULL, + sequence INTEGER NOT NULL CHECK (sequence >= 0), + event_id TEXT NOT NULL, + record_json TEXT NOT NULL, + PRIMARY KEY (session_id, sequence), + UNIQUE (session_id, event_id) + ); + `); + released + .prepare(` + INSERT INTO workflow_task_ledger_events(session_id, sequence, event_id, record_json) + VALUES (?, 0, 'retired-event', '{}') + `) + .run(SESSION_ID); released.exec('DROP TABLE workflow_session_todo_documents'); setWorkflowSchemaVersion(released, 10); } finally { @@ -194,9 +165,7 @@ describe('SQLite workflow stores', () => { const migrated = createSqliteSessionTodoStore(root); try { - assert.deepEqual(await migrated.readOrBootstrap(SESSION_ID), { - items: [{ content: 'Preserve schema 10 Task', status: 'pending' }], - }); + assert.deepEqual(await migrated.readOrBootstrap(SESSION_ID), { items: [] }); } finally { migrated.close(); } @@ -204,7 +173,7 @@ describe('SQLite workflow stores', () => { const verified = new DatabaseSync(join(root, 'runtime.sqlite'), { readOnly: true }); try { assert.equal(workflowSchemaVersion(verified), SQLITE_WORKFLOW_SCHEMA_VERSION); - assert.equal(rowCount(verified, 'workflow_task_ledger_events'), 3); + assert.equal(tableExists(verified, 'workflow_task_ledger_events'), false); assert.equal(rowCount(verified, 'workflow_session_todo_documents'), 1); } finally { verified.close(); @@ -214,7 +183,7 @@ describe('SQLite workflow stores', () => { test('preserves every released projection when one table has unfamiliar DDL', async () => { await withRoot(async (root) => { - createSqliteTaskLedgerStore(root).close(); + createSqlitePlanStore(root).close(); const released = new DatabaseSync(join(root, 'runtime.sqlite')); try { installReleasedProjectionTables(released, { planVersionFloor: -1 }); @@ -234,7 +203,7 @@ describe('SQLite workflow stores', () => { } assert.throws( - () => createSqliteTaskLedgerStore(root), + () => createSqlitePlanStore(root), (error: unknown) => error instanceof Error && (error as { code?: unknown }).code === 'operational_state_migration_blocked' && @@ -247,7 +216,7 @@ describe('SQLite workflow stores', () => { test('preserves every released projection when one table carries an extra trigger', async () => { await withRoot(async (root) => { - createSqliteTaskLedgerStore(root).close(); + createSqlitePlanStore(root).close(); const released = new DatabaseSync(join(root, 'runtime.sqlite')); try { installReleasedProjectionTables(released); @@ -274,7 +243,7 @@ describe('SQLite workflow stores', () => { } assert.throws( - () => createSqliteTaskLedgerStore(root), + () => createSqlitePlanStore(root), (error: unknown) => error instanceof Error && (error as { code?: unknown }).code === 'operational_state_migration_blocked' && @@ -287,7 +256,7 @@ describe('SQLite workflow stores', () => { test('preserves an unfamiliar projection whose table name differs only by case', async () => { await withRoot(async (root) => { - createSqliteTaskLedgerStore(root).close(); + createSqlitePlanStore(root).close(); const released = new DatabaseSync(join(root, 'runtime.sqlite')); try { installReleasedProjectionTables(released, { @@ -309,7 +278,7 @@ describe('SQLite workflow stores', () => { } assert.throws( - () => createSqliteTaskLedgerStore(root), + () => createSqlitePlanStore(root), (error: unknown) => error instanceof Error && (error as { code?: unknown }).code === 'operational_state_migration_blocked' && @@ -322,7 +291,7 @@ describe('SQLite workflow stores', () => { test('preserves released projections with a sqliteX-prefixed trigger', async () => { await withRoot(async (root) => { - createSqliteTaskLedgerStore(root).close(); + createSqlitePlanStore(root).close(); const released = new DatabaseSync(join(root, 'runtime.sqlite')); try { installReleasedProjectionTables(released); @@ -349,7 +318,7 @@ describe('SQLite workflow stores', () => { } assert.throws( - () => createSqliteTaskLedgerStore(root), + () => createSqlitePlanStore(root), (error: unknown) => error instanceof Error && (error as { code?: unknown }).code === 'operational_state_migration_blocked' && @@ -362,7 +331,7 @@ describe('SQLite workflow stores', () => { test('preserves released projections with a sqliteX-prefixed dependent view', async () => { await withRoot(async (root) => { - createSqliteTaskLedgerStore(root).close(); + createSqlitePlanStore(root).close(); const released = new DatabaseSync(join(root, 'runtime.sqlite')); try { installReleasedProjectionTables(released); @@ -387,7 +356,7 @@ describe('SQLite workflow stores', () => { } assert.throws( - () => createSqliteTaskLedgerStore(root), + () => createSqlitePlanStore(root), (error: unknown) => error instanceof Error && (error as { code?: unknown }).code === 'operational_state_migration_blocked' && @@ -406,8 +375,13 @@ describe('SQLite workflow stores', () => { test('an older workflow reader rejects the newer schema without changing it', async () => { await withRoot(async (root) => { - const store = createSqliteTaskLedgerStore(root); - await store.create(SESSION_ID, [{ subject: 'Preserve newer workflow state' }]); + const store = createSqlitePlanStore(root, { newId: () => 'proposal-1', now: () => 100 }); + await store.submitProposal({ + sessionId: SESSION_ID, + turnId: 'turn-1', + title: 'Preserve newer workflow state', + steps: [{ id: 'one', title: 'Persist', description: 'Write one event' }], + }); store.close(); const newer = new DatabaseSync(join(root, 'runtime.sqlite')); @@ -422,14 +396,14 @@ describe('SQLite workflow stores', () => { } assert.throws( - () => createSqliteTaskLedgerStore(root), + () => createSqlitePlanStore(root), /Operational schema workflow is newer than supported/u, ); const preserved = new DatabaseSync(join(root, 'runtime.sqlite'), { readOnly: true }); try { assert.equal(workflowSchemaVersion(preserved), SQLITE_WORKFLOW_SCHEMA_VERSION + 1); - assert.equal(rowCount(preserved, 'workflow_task_ledger_events'), 1); + assert.equal(rowCount(preserved, 'workflow_plan_events'), 1); assert.equal( ( preserved.prepare('SELECT value FROM workflow_future_sentinel').get() as { @@ -444,17 +418,13 @@ describe('SQLite workflow stores', () => { }); }); - test('backs up and restores Task, Plan, and initialized SessionTodo state', async () => { + test('backs up and restores Plan and initialized SessionTodo state', async () => { const base = await mkdtemp(join(tmpdir(), 'maka-workflow-backup-')); const stateRoot = join(base, 'state'); const backupRoot = join(base, 'backup'); const restoreRoot = join(base, 'restore'); await mkdir(stateRoot); try { - const taskStore = createSqliteTaskLedgerStore(stateRoot); - await taskStore.create(SESSION_ID, [{ subject: 'Restore Task event' }]); - taskStore.close(); - const planStore = createSqlitePlanStore(stateRoot, { newId: () => 'backup-proposal', now: () => 100, @@ -477,12 +447,6 @@ describe('SQLite workflow stores', () => { await createOperationalStateBackup({ stateRoot, destinationRoot: backupRoot, now: () => 10 }); await restoreOperationalStateBackup({ backupRoot, destinationRoot: restoreRoot }); - const restoredTasks = createSqliteTaskLedgerStore(restoreRoot); - try { - assert.equal((await restoredTasks.list(SESSION_ID))[0]?.subject, 'Restore Task event'); - } finally { - restoredTasks.close(); - } const restoredPlan = createSqlitePlanStore(restoreRoot); try { assert.equal( @@ -506,7 +470,6 @@ describe('SQLite workflow stores', () => { try { assert.equal(tableExists(restored, 'workflow_task_ledger_projections'), false); assert.equal(tableExists(restored, 'workflow_plan_projections'), false); - assert.equal(rowCount(restored, 'workflow_task_ledger_events'), 1); assert.equal(rowCount(restored, 'workflow_plan_events'), 1); assert.equal(rowCount(restored, 'workflow_session_todo_documents'), 2); } finally { @@ -688,26 +651,6 @@ describe('SQLite workflow stores', () => { }); }); - test('purges Task Ledger events for retired Sessions', async () => { - await withRoot(async (root) => { - const store = createSqliteTaskLedgerStore(root); - try { - await store.create(SESSION_ID, [{ subject: 'Disposable task' }]); - await store.purgeConversationTaskLedger(SESSION_ID); - assert.deepEqual(await store.list(SESSION_ID), []); - } finally { - store.close(); - } - - const database = new DatabaseSync(join(root, 'runtime.sqlite'), { readOnly: true }); - try { - assert.equal(rowCount(database, 'workflow_task_ledger_events'), 0); - } finally { - database.close(); - } - }); - }); - test('purges Plan events for retired Sessions', async () => { await withRoot(async (root) => { const store = createSqlitePlanStore(root); diff --git a/packages/storage/src/__tests__/task-ledger-authority.test.ts b/packages/storage/src/__tests__/task-ledger-authority.test.ts deleted file mode 100644 index d0ca59dc87..0000000000 --- a/packages/storage/src/__tests__/task-ledger-authority.test.ts +++ /dev/null @@ -1,256 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import assert from 'node:assert/strict'; -import { mkdtemp, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { after, describe, test } from 'node:test'; -import { - authenticateInteractiveTaskLedgerWriter, - openInteractiveTaskLedgerStoreForWrite, - type InteractiveTaskLedgerWriter, -} from '../task-ledger-authority.js'; -import { - resolveStorageRoot, - StorageRootAuthorityError, - tryAcquireInteractiveRootOwner, - type StorageRootLease, -} from '../root-authority.js'; -import { - removeTrackedControlDirectories, - trackControlDirectory, -} from './fixtures/control-directory-hygiene.js'; - -// The control directory of each resolved root lives outside that root, so a -// temporary root's removal leaves it behind; reclaim the recorded rootIds here. -after(removeTrackedControlDirectories); - -const SESSION_ID = 'authority-session'; - -describe('interactive task ledger authority', () => { - test('single-flights concurrent opens and uses one local observer surface', async () => { - await withInteractiveRoot(async ({ capability }) => { - const owner = await tryAcquireInteractiveRootOwner(capability); - assert.ok(owner); - if (!owner) return; - try { - const [first, second] = await Promise.all([ - openInteractiveTaskLedgerStoreForWrite(owner.lease), - openInteractiveTaskLedgerStoreForWrite(owner.lease), - ]); - assert.equal(first, second); - assert.equal(authenticateInteractiveTaskLedgerWriter(first), first); - - const changes: string[][] = []; - const unsubscribe = first.subscribe((event) => changes.push(event.taskIds)); - const result = await second.create(SESSION_ID, [{ subject: 'single writer' }]); - unsubscribe(); - - assert.equal(changes.length, 1); - assert.deepEqual(changes[0], [result.created[0]?.id]); - - first.close(); - assert.throws(() => authenticateInteractiveTaskLedgerWriter(first), isInvalidLease); - const reopened = await openInteractiveTaskLedgerStoreForWrite(owner.lease); - assert.notEqual(reopened, first); - assert.equal((await reopened.list(SESSION_ID)).length, 1); - reopened.close(); - } finally { - if (!owner.closed) await owner.close(); - } - }); - }); - - test('rejects canonical reads and mutations after the owner releases its lease', async () => { - await withInteractiveRoot(async ({ capability }) => { - const owner = await tryAcquireInteractiveRootOwner(capability); - assert.ok(owner); - if (!owner) return; - const writer = await openInteractiveTaskLedgerStoreForWrite(owner.lease); - await writer.create(SESSION_ID, [{ subject: 'before close' }]); - await owner.close(); - - await assert.rejects(() => writer.list(SESSION_ID), isInvalidLease); - await assert.rejects( - () => writer.create(SESSION_ID, [{ subject: 'after close' }]), - isInvalidLease, - ); - writer.close(); - }); - }); - - test('rejects forged leases and forged writer facades', async () => { - await assert.rejects( - () => openInteractiveTaskLedgerStoreForWrite({} as StorageRootLease<'interactive', 'write'>), - isInvalidLease, - ); - - await withInteractiveOwner(async ({ writer }) => { - assert.throws( - () => - authenticateInteractiveTaskLedgerWriter({ - ...writer, - } as InteractiveTaskLedgerWriter), - isInvalidLease, - ); - }); - }); - - test('copies child-owned tasks into an ownership-free Side Conversation snapshot', async () => { - await withInteractiveOwner(async ({ writer }) => { - const sourceSessionId = 'source-session'; - const targetSessionId = 'side-conversation'; - const created = await writer.create(sourceSessionId, [{ subject: 'Delegated review' }], { - turnId: 'root-turn', - source: 'tool', - actor: 'main_agent', - }); - const taskId = created.created[0]!.id; - await writer.claim( - sourceSessionId, - taskId, - { - actor: 'child_agent', - sessionId: 'child-session', - agentId: 'reviewer', - runId: 'child-run', - turnId: 'child-turn', - }, - { - turnId: 'root-turn', - runId: 'child-run', - source: 'tool', - actor: 'child_agent', - }, - ); - const legacy = await writer.create(sourceSessionId, [{ subject: 'Legacy child note' }], { - turnId: 'root-turn', - runId: 'legacy-child-run', - source: 'tool', - actor: 'child_agent', - }); - - await writer.copyConversationTaskLedger({ - sourceSessionId, - targetSessionId, - turnIds: ['root-turn'], - runIdMap: [], - linkedChildren: 'snapshot', - }); - - const copied = await writer.list(targetSessionId); - assert.deepEqual( - copied.map((task) => ({ id: task.id, subject: task.subject, status: task.status })), - [ - { id: taskId, subject: 'Delegated review', status: 'in_progress' }, - { id: legacy.created[0]!.id, subject: 'Legacy child note', status: 'pending' }, - ], - ); - assert.ok(copied.every((task) => task.owner === undefined)); - assert.equal((await writer.get(sourceSessionId, taskId))?.owner?.sessionId, 'child-session'); - assert.equal((await writer.get(sourceSessionId, taskId))?.owner?.runId, 'child-run'); - }); - }); - - test('preserves main-agent ownership on child-authored snapshot events', async () => { - await withInteractiveOwner(async ({ writer }) => { - const sourceSessionId = 'source-main-owned'; - const targetSessionId = 'side-main-owned'; - const created = await writer.create(sourceSessionId, [{ subject: 'Parent-owned work' }], { - turnId: 'root-turn', - runId: 'root-run', - source: 'tool', - actor: 'main_agent', - }); - await writer.update( - sourceSessionId, - created.created[0]!.id, - { subject: 'Child reported progress' }, - { - turnId: 'root-turn', - runId: 'child-run', - source: 'tool', - actor: 'child_agent', - }, - ); - - await writer.copyConversationTaskLedger({ - sourceSessionId, - targetSessionId, - turnIds: ['root-turn'], - runIdMap: [{ sourceRunId: 'root-run', targetRunId: 'copied-root-run' }], - linkedChildren: 'snapshot', - }); - - const [copied] = await writer.list(targetSessionId); - assert.equal(copied?.owner?.actor, 'main_agent'); - assert.equal(copied?.owner?.runId, 'copied-root-run'); - }); - }); -}); - -async function withInteractiveOwner( - run: (input: { root: string; writer: InteractiveTaskLedgerWriter }) => Promise, -): Promise { - await withInteractiveRoot(async ({ root, capability }) => { - const owner = await tryAcquireInteractiveRootOwner(capability); - assert.ok(owner); - if (!owner) return; - let writer: InteractiveTaskLedgerWriter | undefined; - try { - writer = await openInteractiveTaskLedgerStoreForWrite(owner.lease); - await run({ - root, - writer, - }); - } finally { - writer?.close(); - if (!owner.closed) await owner.close(); - } - }); -} - -async function withInteractiveRoot( - run: (input: { - root: string; - capability: Awaited>>; - }) => Promise, -): Promise { - await withTempDir(async (base) => { - const root = join(base, 'interactive'); - const capability = trackControlDirectory( - await resolveStorageRoot({ path: root, kind: 'interactive' }), - ); - await run({ root, capability }); - }); -} - -async function withTempDir(run: (base: string) => Promise): Promise { - const base = await mkdtemp(join(tmpdir(), 'maka-task-ledger-authority-')); - try { - await run(base); - } finally { - await rm(base, { recursive: true, force: true }); - } -} - -function isInvalidLease(error: unknown): boolean { - return error instanceof StorageRootAuthorityError && error.code === 'invalid_lease'; -} diff --git a/packages/storage/src/session-todo-store.ts b/packages/storage/src/session-todo-store.ts index 9f99deb3ec..b3b0a9353c 100644 --- a/packages/storage/src/session-todo-store.ts +++ b/packages/storage/src/session-todo-store.ts @@ -19,11 +19,6 @@ import { resolve } from 'node:path'; import type { DatabaseSync } from 'node:sqlite'; -import { - isTaskLedgerEvent, - projectTaskLedgerEvents, - type TaskLedgerEvent, -} from '@maka/core/task-ledger'; import { normalizeSessionTodoItems, type SessionTodoItem, @@ -45,12 +40,11 @@ interface StoredSessionTodoDocument { export interface SessionTodoStore { /** - * Return the initialized current document. On the first read only, bootstrap - * pending/in-progress legacy Tasks, map blocked Tasks to pending, and persist - * even an empty result. + * Return the initialized current document, persisting an empty one on the + * first read so later reads and copies see the same row. */ readOrBootstrap(sessionId: string): Promise; - /** Replace the complete document without consulting legacy Task state. */ + /** Replace the complete document. */ replaceAll(sessionId: string, items: unknown): Promise; /** Initialize one conversation-copy target without overwriting conflicting state. */ initializeCopy(input: { @@ -58,7 +52,7 @@ export interface SessionTodoStore { targetSessionId: string; copyCurrent: boolean; }): Promise; - /** Purge current state and the legacy events that could bootstrap it. */ + /** Purge current state. */ purgeSessionState(sessionId: string): Promise; } @@ -95,9 +89,9 @@ class SqliteSessionTodoStoreImpl implements SqliteSessionTodoStore { const existing = readStoredDocument(this.#lease.database, sessionId); if (existing) return snapshotFromDocument(existing); - const bootstrapped = bootstrapLegacyTasks(this.#lease.database, sessionId); - insertDocument(this.#lease.database, sessionId, bootstrapped); - return snapshotFromDocument(bootstrapped); + const initial = emptyDocument(); + insertDocument(this.#lease.database, sessionId, initial); + return snapshotFromDocument(initial); }); }); return snapshot!; @@ -132,10 +126,10 @@ class SqliteSessionTodoStoreImpl implements SqliteSessionTodoStore { let snapshot: SessionTodoSnapshot | undefined; await this.#write(async () => { snapshot = this.#lease.transaction('write', () => { - const source = input.copyCurrent - ? (readStoredDocument(this.#lease.database, input.sourceSessionId) ?? - bootstrapAndInsert(this.#lease.database, input.sourceSessionId)) - : emptyDocument(); + const source = + (input.copyCurrent + ? readStoredDocument(this.#lease.database, input.sourceSessionId) + : undefined) ?? emptyDocument(); const existing = readStoredDocument(this.#lease.database, input.targetSessionId); if (existing) { if (!sameDocument(existing, source)) { @@ -157,9 +151,6 @@ class SqliteSessionTodoStoreImpl implements SqliteSessionTodoStore { this.#lease.database .prepare('DELETE FROM workflow_session_todo_documents WHERE session_id = ?') .run(sessionId); - this.#lease.database - .prepare('DELETE FROM workflow_task_ledger_events WHERE session_id = ?') - .run(sessionId); }); }); } @@ -171,12 +162,6 @@ class SqliteSessionTodoStoreImpl implements SqliteSessionTodoStore { } } -function bootstrapAndInsert(database: DatabaseSync, sessionId: string): StoredSessionTodoDocument { - const document = bootstrapLegacyTasks(database, sessionId); - insertDocument(database, sessionId, document); - return document; -} - function emptyDocument(): StoredSessionTodoDocument { return { schemaVersion: SESSION_TODO_DOCUMENT_SCHEMA_VERSION, items: [] }; } @@ -219,60 +204,6 @@ function readStoredDocument( }; } -function bootstrapLegacyTasks( - database: DatabaseSync, - sessionId: string, -): StoredSessionTodoDocument { - const rows = database - .prepare(` - SELECT record_json - FROM workflow_task_ledger_events - WHERE session_id = ? - ORDER BY sequence - `) - .all(sessionId) as Array<{ record_json?: unknown }>; - const events = rows.map((row, index) => { - if (typeof row.record_json !== 'string') { - throw new Error(`Invalid legacy Task event at sequence ${index}`); - } - let parsed: unknown; - try { - parsed = JSON.parse(row.record_json); - } catch { - throw new Error(`Invalid legacy Task event JSON at sequence ${index}`); - } - if (!isTaskLedgerEvent(parsed) || parsed.sessionId !== sessionId) { - throw new Error(`Invalid legacy Task event at sequence ${index}`); - } - return parsed; - }) as TaskLedgerEvent[]; - const projected = projectTaskLedgerEvents(events); - if (projected.diagnostics.length > 0) { - throw new Error(`Legacy Task ledger is not projectable: ${projected.diagnostics.join('; ')}`); - } - const items = projected.tasks.flatMap((task) => { - switch (task.status) { - case 'pending': - case 'in_progress': - return [{ content: task.subject, status: task.status }]; - case 'blocked': - // SessionTodo deliberately has no workflow-specific blocked state or - // reason field. Keep the unfinished subject visible for replanning. - return [{ content: task.subject, status: 'pending' as const }]; - case 'completed': - case 'failed': - case 'cancelled': - return []; - } - }); - const normalized = normalizeSessionTodoItems(items); - if (!normalized.ok) throw new Error(`Legacy Task bootstrap failed: ${normalized.message}`); - return { - schemaVersion: SESSION_TODO_DOCUMENT_SCHEMA_VERSION, - items: normalized.value.items, - }; -} - function insertDocument( database: DatabaseSync, sessionId: string, diff --git a/packages/storage/src/sqlite-workflow-schema.ts b/packages/storage/src/sqlite-workflow-schema.ts index 545ad69619..d0e1f27812 100644 --- a/packages/storage/src/sqlite-workflow-schema.ts +++ b/packages/storage/src/sqlite-workflow-schema.ts @@ -19,7 +19,7 @@ import type { DatabaseSync } from 'node:sqlite'; -export const SQLITE_WORKFLOW_SCHEMA_VERSION = 11; +export const SQLITE_WORKFLOW_SCHEMA_VERSION = 12; const RELEASED_WORKFLOW_PROJECTION_TABLES = [ { @@ -44,15 +44,7 @@ export function migrateSqliteWorkflowDatabase(db: DatabaseSync): void { db.exec(` DROP INDEX IF EXISTS workflow_plan_reminders_order; DROP TABLE IF EXISTS workflow_plan_reminders; - - CREATE TABLE IF NOT EXISTS workflow_task_ledger_events ( - session_id TEXT NOT NULL, - sequence INTEGER NOT NULL CHECK (sequence >= 0), - event_id TEXT NOT NULL, - record_json TEXT NOT NULL, - PRIMARY KEY (session_id, sequence), - UNIQUE (session_id, event_id) - ); + DROP TABLE IF EXISTS workflow_task_ledger_events; CREATE TABLE IF NOT EXISTS workflow_session_todo_documents ( session_id TEXT PRIMARY KEY, diff --git a/packages/storage/src/task-ledger-authority.ts b/packages/storage/src/task-ledger-authority.ts deleted file mode 100644 index b63328b706..0000000000 --- a/packages/storage/src/task-ledger-authority.ts +++ /dev/null @@ -1,165 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import type { TaskLedgerStore } from '@maka/core/task-ledger'; -import { - assertStorageRootLease, - runWithStorageRootLease, - StorageRootAuthorityError, - type StorageRootLease, -} from './root-authority.js'; -import { - createSqliteTaskLedgerStore, - type ConversationTaskLedgerCopyInput, - type SqliteTaskLedgerStore, -} from './task-ledger-store.js'; -import { - getTaskLedgerCanonicalReader, - type TaskLedgerCanonicalReader, -} from './task-ledger-store-internal.js'; - -export type { TaskLedgerCanonicalReader } from './task-ledger-store-internal.js'; -export type { ConversationTaskLedgerCopyInput } from './task-ledger-store.js'; - -const writerBrand: unique symbol = Symbol('InteractiveTaskLedgerWriter'); -const writers = new WeakSet(); -const writerByLease = new WeakMap(); -const writerOpeningByLease = new WeakMap>(); - -export interface InteractiveTaskLedgerWriter extends TaskLedgerStore, TaskLedgerCanonicalReader { - readonly kind: 'interactive'; - readonly access: 'write'; - readonly [writerBrand]: true; - close(): void; - copyConversationTaskLedger(input: ConversationTaskLedgerCopyInput): Promise; - purgeConversationTaskLedger(sessionId: string): Promise; -} - -export function authenticateInteractiveTaskLedgerWriter( - writer: InteractiveTaskLedgerWriter, -): InteractiveTaskLedgerWriter { - if (!writers.has(writer)) { - throw new StorageRootAuthorityError( - 'invalid_lease', - 'Expected an authentic interactive task ledger writer', - ); - } - return writer; -} - -export async function openInteractiveTaskLedgerStoreForWrite( - lease: StorageRootLease<'interactive', 'write'>, -): Promise { - await assertStorageRootLease(lease, 'interactive', 'write'); - const existing = writerByLease.get(lease); - if (existing) return existing; - const opening = writerOpeningByLease.get(lease); - if (opening) return opening; - - const pending = Promise.resolve().then(async () => { - let store: SqliteTaskLedgerStore | undefined; - try { - store = await runWithStorageRootLease(lease, 'interactive', 'write', async (root) => { - const opened = createSqliteTaskLedgerStore(root); - try { - await opened.ready(); - return opened; - } catch (error) { - opened.close(); - throw error; - } - }); - await assertStorageRootLease(lease, 'interactive', 'write'); - const recoveredExisting = writerByLease.get(lease); - if (recoveredExisting) { - store.close(); - return recoveredExisting; - } - const writer = createInteractiveWriterFacade( - lease, - store, - getTaskLedgerCanonicalReader(store), - ); - writers.add(writer); - writerByLease.set(lease, writer); - return writer; - } catch (error) { - store?.close(); - throw error; - } - }); - writerOpeningByLease.set(lease, pending); - try { - return await pending; - } finally { - if (writerOpeningByLease.get(lease) === pending) writerOpeningByLease.delete(lease); - } -} - -function createInteractiveWriterFacade( - lease: StorageRootLease<'interactive', 'write'>, - store: SqliteTaskLedgerStore, - canonicalReader: TaskLedgerCanonicalReader, -): InteractiveTaskLedgerWriter { - let closed = false; - const run = (operation: () => Promise) => { - if (closed) { - return Promise.reject( - new StorageRootAuthorityError('invalid_lease', 'Task ledger writer is closed'), - ); - } - return runWithStorageRootLease(lease, 'interactive', 'write', async () => operation()); - }; - const writer: InteractiveTaskLedgerWriter = { - kind: 'interactive', - access: 'write', - [writerBrand]: true, - list: (sessionId, options) => run(() => canonicalReader.list(sessionId, options)), - get: (sessionId, id, options) => run(() => canonicalReader.get(sessionId, id, options)), - create: (sessionId, drafts, context) => run(() => store.create(sessionId, drafts, context)), - update: (sessionId, id, patch, context) => - run(() => store.update(sessionId, id, patch, context)), - claim: (sessionId, id, owner, context) => run(() => store.claim(sessionId, id, owner, context)), - claimAvailable: (sessionId, id, owner, scope, context) => - run(() => store.claimAvailable(sessionId, id, owner, scope, context)), - settleAgentOutcome: (sessionId, id, outcome, context) => - run(() => store.settleAgentOutcome(sessionId, id, outcome, context)), - copyConversationTaskLedger: (input) => { - const acceptedInput: ConversationTaskLedgerCopyInput = Object.freeze({ - ...input, - turnIds: Object.freeze([...input.turnIds]), - runIdMap: Object.freeze(input.runIdMap.map((entry) => Object.freeze({ ...entry }))), - ...(input.linkedChildren ? { linkedChildren: input.linkedChildren } : {}), - }); - return run(() => store.copyConversationTaskLedger(acceptedInput)); - }, - purgeConversationTaskLedger: (sessionId) => - run(() => store.purgeConversationTaskLedger(sessionId)), - subscribe: (listener) => store.subscribe(listener), - close: () => { - if (closed) return; - closed = true; - if (writerByLease.get(lease) === writer) writerByLease.delete(lease); - writers.delete(writer); - store.close(); - }, - }; - Object.freeze(writer); - return writer; -} diff --git a/packages/storage/src/task-ledger-store-internal.ts b/packages/storage/src/task-ledger-store-internal.ts deleted file mode 100644 index 03340d10c3..0000000000 --- a/packages/storage/src/task-ledger-store-internal.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import type { Task, TaskLedgerListOptions, TaskLedgerStore } from '@maka/core/task-ledger'; - -export interface TaskLedgerCanonicalReader { - list(sessionId: string, options?: TaskLedgerListOptions): Promise; - get(sessionId: string, id: string, options?: TaskLedgerListOptions): Promise; -} - -// Package-private bridge: this module must stay outside both the root barrel and package exports. -const canonicalReaderByStore = new WeakMap(); - -export function registerTaskLedgerCanonicalReader( - store: TaskLedgerStore, - reader: TaskLedgerCanonicalReader, -): void { - if (canonicalReaderByStore.has(store)) { - throw new Error('Task ledger store already has a canonical reader'); - } - canonicalReaderByStore.set(store, Object.freeze(reader)); -} - -export function getTaskLedgerCanonicalReader(store: TaskLedgerStore): TaskLedgerCanonicalReader { - const reader = canonicalReaderByStore.get(store); - if (!reader) throw new Error('Task ledger store is missing its internal canonical reader'); - return reader; -} diff --git a/packages/storage/src/task-ledger-store.ts b/packages/storage/src/task-ledger-store.ts deleted file mode 100644 index 0b2c6a485b..0000000000 --- a/packages/storage/src/task-ledger-store.ts +++ /dev/null @@ -1,865 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { resolve } from 'node:path'; -import { createHash, randomUUID } from 'node:crypto'; -import type { DatabaseSync } from 'node:sqlite'; -import { - TASK_LEDGER_MAX_TASKS, - TASK_ARCHIVE_AFTER_MS, - findTaskByRef, - isSafeTaskId, - isTaskKey, - isTaskOwner, - isTerminalTaskStatus, - isTaskLedgerEvent, - normalizeUpdateTaskInput, - normalizeCreateTaskInput, - projectTaskLedgerEvents, - taskLedgerEventTypeForCreate, - taskLedgerEventTypeForUpdate, - validateTaskUpdate, - classifyTaskResumeTrust, - type Task, - type TaskAgentOutcome, - type TaskAvailableClaimScope, - type TaskLedgerChangedEvent, - type TaskLedgerEvent, - type TaskLedgerListOptions, - type TaskLedgerMutationContext, - type TaskLedgerStore, - type TaskOwner, -} from '@maka/core/task-ledger'; -import { chainWrite } from './write-queue.js'; -import { assertSafeSessionId } from './session-store.js'; -import { registerTaskLedgerCanonicalReader } from './task-ledger-store-internal.js'; -import { - acquireOperationalStateDatabase, - type OperationalStateDatabaseLease, -} from './operational-state-store.js'; - -export type { TaskLedgerStore } from '@maka/core/task-ledger'; - -export interface ConversationTaskLedgerCopyInput { - readonly sourceSessionId: string; - readonly targetSessionId: string; - readonly turnIds: readonly string[]; - readonly beforeTs?: number; - readonly runIdMap: readonly { - readonly sourceRunId: string; - readonly targetRunId: string; - }[]; - readonly linkedChildren?: 'preserve' | 'snapshot'; -} - -export interface TaskLedgerAuthorityStore extends TaskLedgerStore { - copyConversationTaskLedger(input: ConversationTaskLedgerCopyInput): Promise; - purgeConversationTaskLedger(sessionId: string): Promise; -} - -export interface SqliteTaskLedgerStore extends TaskLedgerAuthorityStore { - ready(): Promise; - close(): void; -} - -export function createSqliteTaskLedgerStore(workspaceRoot: string): SqliteTaskLedgerStore { - return new SqliteTaskLedgerStoreImpl(workspaceRoot); -} - -class SqliteTaskLedgerStoreImpl implements SqliteTaskLedgerStore { - readonly #lease: OperationalStateDatabaseLease; - private readonly writeQueues = new Map>(); - private readonly listeners = new Set<(event: TaskLedgerChangedEvent) => void>(); - - constructor(workspaceRoot: string) { - this.#lease = acquireOperationalStateDatabase(resolve(workspaceRoot)); - registerTaskLedgerCanonicalReader(this, { - list: (sessionId, options) => this.#listCanonical(sessionId, options), - get: (sessionId, id, options) => this.#getCanonical(sessionId, id, options), - }); - } - - ready(): Promise { - return Promise.resolve(); - } - - close(): void { - this.#lease.close(); - } - - async list(sessionId: string, options: TaskLedgerListOptions = {}): Promise { - assertSafeSessionId(sessionId); - return this.applyListOptions(await this.readForRender(sessionId), options); - } - - async get( - sessionId: string, - id: string, - options: TaskLedgerListOptions = {}, - ): Promise { - assertSafeSessionId(sessionId); - if (!isSafeTaskId(id)) - throw new Error('Task id must be a stable token (alphanumeric plus . _ : -, max 64 chars)'); - const tasks = await this.list(sessionId, options); - return findTaskByRef(tasks, id); - } - - async #listCanonical(sessionId: string, options: TaskLedgerListOptions = {}): Promise { - assertSafeSessionId(sessionId); - const { tasks } = await this.readForMutateWithSource(sessionId); - return this.applyListOptions(tasks, options); - } - - async #getCanonical( - sessionId: string, - id: string, - options: TaskLedgerListOptions = {}, - ): Promise { - assertSafeSessionId(sessionId); - if (!isSafeTaskId(id)) - throw new Error('Task id must be a stable token (alphanumeric plus . _ : -, max 64 chars)'); - return findTaskByRef(await this.#listCanonical(sessionId, options), id); - } - - subscribe(listener: (event: TaskLedgerChangedEvent) => void): () => void { - this.listeners.add(listener); - return () => this.listeners.delete(listener); - } - - async copyConversationTaskLedger(input: ConversationTaskLedgerCopyInput): Promise { - assertSafeSessionId(input.sourceSessionId); - assertSafeSessionId(input.targetSessionId); - if (input.sourceSessionId === input.targetSessionId) { - throw new Error('Task Ledger conversation copy requires distinct Sessions'); - } - if ( - input.beforeTs !== undefined && - (!Number.isSafeInteger(input.beforeTs) || input.beforeTs < 0) - ) { - throw new Error('Task Ledger conversation-copy boundary is invalid'); - } - const turnIds = new Set(input.turnIds); - const runIds = new Map( - input.runIdMap.map(({ sourceRunId, targetRunId }) => [sourceRunId, targetRunId]), - ); - const source = await this.readConversationCopyEvents(input.sourceSessionId); - const selected: TaskLedgerEvent[] = []; - let crossedBoundary = false; - for (const event of source) { - const eventTurnId = event.refs?.turnId; - const retained = - eventTurnId !== undefined - ? turnIds.has(eventTurnId) - : input.beforeTs === undefined || event.ts < input.beforeTs; - if (!retained) { - crossedBoundary = true; - continue; - } - if (crossedBoundary) { - throw new Error('Task Ledger events cross the conversation-copy boundary'); - } - selected.push( - rewriteConversationTaskEvent( - event, - input.sourceSessionId, - input.targetSessionId, - runIds, - input.linkedChildren ?? 'preserve', - ), - ); - } - if (selected.length === 0) return; - const projection = projectTaskLedgerEvents(selected); - if (projection.diagnostics.length > 0) { - throw new Error( - `Task Ledger conversation copy is not projectable: ${projection.diagnostics.join('; ')}`, - ); - } - - await chainWrite(this.writeQueues, input.targetSessionId, async () => { - this.copyConversationLedger(input.targetSessionId, selected); - }); - } - - async purgeConversationTaskLedger(sessionId: string): Promise { - assertSafeSessionId(sessionId); - await chainWrite(this.writeQueues, sessionId, async () => { - this.#lease.transaction('write', () => { - this.#lease.database - .prepare('DELETE FROM workflow_task_ledger_events WHERE session_id = ?') - .run(sessionId); - }); - }); - } - - async create( - sessionId: string, - drafts: unknown, - context: TaskLedgerMutationContext = {}, - ): Promise<{ created: Task[]; total: number }> { - assertSafeSessionId(sessionId); - if (!Array.isArray(drafts) || drafts.length === 0) { - throw new Error('Task creation requires at least one task draft'); - } - // Front-door the per-batch cap before generating ids or normalizing drafts: - // a single call can never add more than the absolute ledger cap, and rejecting - // here avoids generating N uuids for a batch the write-queue total check - // would refuse anyway. The total (existing + new) cap is still enforced - // inside the serialized mutate callback below. - if (drafts.length > TASK_LEDGER_MAX_TASKS) { - throw new Error( - `Task creation batch of ${drafts.length} tasks exceeds the ${TASK_LEDGER_MAX_TASKS}-task per-batch cap; split the work into smaller calls.`, - ); - } - const normalizedDrafts = drafts.map((draft) => { - const normalized = normalizeCreateTaskInput(draft); - if (!normalized.ok) throw new Error(normalized.message); - return normalized.value; - }); - const created: Task[] = []; - // Cap check runs inside the serialized mutate callback (after reading the - // current ledger) so concurrent creates cannot race past the limit, and a - // rejected create never touches the file. - const all = await this.mutate( - sessionId, - (tasks) => { - if (tasks.length + normalizedDrafts.length > TASK_LEDGER_MAX_TASKS) { - throw new Error( - `Task ledger is limited to ${TASK_LEDGER_MAX_TASKS} tasks total per session ` + - `(currently ${tasks.length}, adding ${normalizedDrafts.length}). This is a hard runaway guard on the ` + - 'total count — completed or cancelled tasks still count, so batch related work into fewer, ' + - 'coarser tasks instead.', - ); - } - const now = Date.now(); - for (const draft of normalizedDrafts) { - const parent = draft.parentId ? findTaskByRef(tasks, draft.parentId) : undefined; - if (draft.parentId && !parent) throw new Error(`No such parent task: ${draft.parentId}`); - if (parent && isTerminalTaskStatus(parent.status)) { - throw new Error(`Cannot create a child under terminal task ${parent.key}`); - } - const task: Task = { - id: randomUUID(), - key: nextTaskKey([...tasks, ...created], parent), - subject: draft.subject, - status: 'pending', - createdAt: now, - updatedAt: now, - ...(parent ? { parentId: parent.id } : {}), - ...(ownerFromContext(context) ? { owner: ownerFromContext(context) } : {}), - }; - created.push(task); - } - return [...tasks, ...created]; - }, - (next) => - created.map((task) => - buildTaskLedgerEvent({ - type: taskLedgerEventTypeForCreate(task), - sessionId, - task, - context, - }), - ), - ); - return { created, total: all.length }; - } - - async update( - sessionId: string, - id: string, - patch: unknown, - context: TaskLedgerMutationContext = {}, - ): Promise<{ updated: Task; total: number }> { - assertSafeSessionId(sessionId); - const now = Date.now(); - let updated: Task | undefined; - let previous: Task | undefined; - const all = await this.mutate( - sessionId, - (tasks) => { - // Locate the target before producing a new list: an unknown id must - // fail inside the callback without rewriting an identical file. - const resolved = findTaskByRef(tasks, id); - const index = resolved ? tasks.findIndex((task) => task.id === resolved.id) : -1; - const current = index === -1 ? undefined : tasks[index]; - if (!current) throw new Error(`No such task: ${id}`); - previous = current; - const normalizedPatch = normalizeUpdateTaskInput(patch); - if (!normalizedPatch.ok) throw new Error(normalizedPatch.message); - const normalized = validateTaskUpdate(current, normalizedPatch.value, { - explicitReopen: normalizedPatch.value.explicitReopen === true, - }); - if (!normalized.ok) throw new Error(normalized.message); - const { explicitReopen: _explicitReopen, ...taskPatch } = normalized.value; - void _explicitReopen; - updated = { - ...current, - ...(taskPatch.subject !== undefined ? { subject: taskPatch.subject } : {}), - ...(taskPatch.status !== undefined ? { status: taskPatch.status } : {}), - ...(taskPatch.blockedReason !== undefined - ? { blockedReason: taskPatch.blockedReason } - : {}), - ...(taskPatch.failureReason !== undefined - ? { failureReason: taskPatch.failureReason } - : {}), - ...(taskPatch.completionEvidence !== undefined - ? { completionEvidence: taskPatch.completionEvidence } - : {}), - ...(taskPatch.status === 'in_progress' && context.actor === 'main_agent' - ? { owner: ownerFromContext(context) } - : {}), - updatedAt: now, - }; - if (taskPatch.status !== undefined && isTerminalTaskStatus(taskPatch.status)) { - if (taskPatch.status === 'completed') assertDescendantsTerminal(tasks, current.id); - updated.endedAt = now; - } else if (taskPatch.status === 'pending' || taskPatch.status === 'in_progress') { - delete updated.endedAt; - } - if (taskPatch.status === 'pending') delete updated.owner; - updated = clearStaleTaskEvidence(updated); - const next = [...tasks]; - next[index] = updated; - return next; - }, - () => { - if (!previous || !updated) return []; - return [ - buildTaskLedgerEvent({ - type: taskLedgerEventTypeForUpdate(previous, updated), - sessionId, - task: updated, - previous, - context, - }), - ]; - }, - ); - if (!updated) throw new Error(`No such task: ${id}`); - return { updated, total: all.length }; - } - - async claim( - sessionId: string, - id: string, - owner: TaskOwner, - context: TaskLedgerMutationContext = {}, - ): Promise<{ updated: Task; total: number }> { - assertSafeSessionId(sessionId); - assertChildTaskOwner(owner); - let updated: Task | undefined; - let previous: Task | undefined; - const all = await this.mutate( - sessionId, - (tasks) => { - const current = findTaskByRef(tasks, id); - if (!current) throw new Error(`No such task: ${id}`); - if (isTerminalTaskStatus(current.status)) - throw new Error(`Cannot claim terminal task ${current.key}`); - if ( - current.status === 'in_progress' && - current.owner?.actor === 'child_agent' && - current.owner.turnId !== owner.turnId - ) { - throw new Error(`Task ${current.key} is already claimed by another child agent`); - } - previous = current; - updated = clearStaleTaskEvidence({ - ...current, - status: 'in_progress', - owner, - updatedAt: Date.now(), - }); - return tasks.map((task) => (task.id === current.id ? updated! : task)); - }, - () => - previous && updated - ? [ - buildTaskLedgerEvent({ - type: taskLedgerEventTypeForUpdate(previous, updated), - sessionId, - task: updated, - previous, - context, - }), - ] - : [], - ); - if (!updated) throw new Error(`No such task: ${id}`); - return { updated, total: all.length }; - } - - async claimAvailable( - sessionId: string, - id: string, - owner: TaskOwner, - scope: TaskAvailableClaimScope, - context: TaskLedgerMutationContext = {}, - ): Promise<{ updated: Task; total: number }> { - assertSafeSessionId(sessionId); - assertChildTaskOwner(owner); - if (!isSafeTaskId(scope.parentRunId)) - throw new Error('Available task claim requires a stable parent AgentRun id'); - let updated: Task | undefined; - let previous: Task | undefined; - const all = await this.mutate( - sessionId, - (tasks) => { - const current = findTaskByRef(tasks, id); - if (!current) throw new Error(`No such task: ${id}`); - if (isTerminalTaskStatus(current.status)) - throw new Error(`Cannot claim terminal task ${current.key}`); - - const alreadyClaimed = tasks.find( - (task) => - task.id !== current.id && - !isTerminalTaskStatus(task.status) && - task.owner?.actor === 'child_agent' && - task.owner.turnId === owner.turnId, - ); - if (alreadyClaimed) { - throw new Error( - `Child agent already owns task ${alreadyClaimed.key}; one shared task may be claimed per child turn`, - ); - } - - const sameOwner = - current.owner?.actor === 'child_agent' && current.owner.turnId === owner.turnId; - if ( - !sameOwner && - (current.owner?.actor !== 'main_agent' || current.owner.runId !== scope.parentRunId) - ) { - throw new Error(`Task ${current.key} is not shared by parent run ${scope.parentRunId}`); - } - if (current.status === 'in_progress' && !sameOwner) { - throw new Error( - `Task ${current.key} is already in progress and is not available for self-claim`, - ); - } - if (current.owner?.actor === 'child_agent' && !sameOwner) { - throw new Error(`Task ${current.key} is already claimed by another child agent`); - } - - previous = current; - updated = - sameOwner && current.status === 'in_progress' - ? current - : clearStaleTaskEvidence({ - ...current, - status: 'in_progress', - owner, - updatedAt: Date.now(), - }); - return updated === current - ? tasks - : tasks.map((task) => (task.id === current.id ? updated! : task)); - }, - () => - previous && updated && previous !== updated - ? [ - buildTaskLedgerEvent({ - type: taskLedgerEventTypeForUpdate(previous, updated), - sessionId, - task: updated, - previous, - context, - }), - ] - : [], - ); - if (!updated) throw new Error(`No such task: ${id}`); - return { updated, total: all.length }; - } - - async settleAgentOutcome( - sessionId: string, - id: string, - outcome: TaskAgentOutcome, - context: TaskLedgerMutationContext = {}, - ): Promise<{ updated: Task; total: number }> { - assertSafeSessionId(sessionId); - assertChildTaskOwner(outcome.owner); - let updated: Task | undefined; - let previous: Task | undefined; - const all = await this.mutate( - sessionId, - (tasks) => { - const current = findTaskByRef(tasks, id); - if (!current) throw new Error(`No such task: ${id}`); - if ( - current.owner?.actor === 'child_agent' && - current.owner.turnId && - current.owner.turnId !== outcome.owner.turnId - ) { - throw new Error(`Task ${current.key} is owned by a different child agent`); - } - previous = current; - const now = Date.now(); - updated = { ...current, owner: outcome.owner, updatedAt: now }; - if (!isTerminalTaskStatus(current.status)) { - if (outcome.status === 'failed') { - updated.status = 'failed'; - updated.failureReason = normalizeOutcomeReason(outcome.reason, 'Child agent failed'); - updated.endedAt = now; - } else if (outcome.status === 'cancelled') { - updated.status = 'cancelled'; - updated.endedAt = now; - } else if (outcome.status === 'waiting_for_user') { - updated.status = 'blocked'; - updated.blockedReason = normalizeOutcomeReason( - outcome.reason, - 'Child agent is waiting for user input', - ); - } - } - updated = clearStaleTaskEvidence(updated); - return tasks.map((task) => (task.id === current.id ? updated! : task)); - }, - () => - previous && updated - ? [ - buildTaskLedgerEvent({ - type: taskLedgerEventTypeForUpdate(previous, updated), - sessionId, - task: updated, - previous, - context: { ...context, reason: outcome.reason ?? context.reason }, - }), - ] - : [], - ); - if (!updated) throw new Error(`No such task: ${id}`); - return { updated, total: all.length }; - } - - private async readForRender(sessionId: string): Promise { - return (await this.readProjected(sessionId)).tasks; - } - - private async readForMutateWithSource(sessionId: string): Promise<{ tasks: Task[] }> { - return this.readProjected(sessionId); - } - - private async readProjected(sessionId: string): Promise<{ tasks: Task[] }> { - const events = await this.readTaskEvents(sessionId); - const projection = projectTaskLedgerEvents(events); - if (projection.diagnostics.length > 0) { - throw new Error( - `task event ledger has projection diagnostics: ${projection.diagnostics.join('; ')}`, - ); - } - if (projection.tasks.length > TASK_LEDGER_MAX_TASKS) { - throw new Error( - `task event ledger has ${projection.tasks.length} tasks, exceeding the ${TASK_LEDGER_MAX_TASKS}-task cap; refusing to load an unbounded ledger`, - ); - } - return { tasks: projection.tasks }; - } - - private async readTaskEvents(sessionId: string): Promise { - return readSqliteTaskLedgerEvents(this.#lease.database, sessionId); - } - - private async readConversationCopyEvents(sessionId: string): Promise { - return this.readTaskEvents(sessionId); - } - - private async mutate( - sessionId: string, - fn: (tasks: Task[]) => Task[], - eventsForMutation: (next: Task[]) => TaskLedgerEvent[], - ): Promise { - let next: Task[] = []; - await chainWrite(this.writeQueues, sessionId, async () => { - const currentRead = await this.readForMutateWithSource(sessionId); - const current = currentRead.tasks; - next = fn(current); - const mutationEvents = eventsForMutation(next); - await this.appendEvents(sessionId, mutationEvents); - this.emitChanged({ - sessionId, - taskIds: [...new Set(mutationEvents.map((event) => event.taskId))], - at: Date.now(), - }); - }); - return next; - } - - private async appendEvents(sessionId: string, events: TaskLedgerEvent[]): Promise { - if (events.length === 0) return; - this.#lease.transaction('write', () => { - for (const event of events) insertTaskLedgerEvent(this.#lease.database, sessionId, event); - }); - } - - private copyConversationLedger(sessionId: string, events: readonly TaskLedgerEvent[]): void { - this.#lease.transaction('write', () => { - const existing = this.#lease.database - .prepare('SELECT COUNT(*) AS count FROM workflow_task_ledger_events WHERE session_id = ?') - .get(sessionId) as { count?: unknown }; - if (existing.count !== 0) { - throw new Error('Task Ledger conversation-copy target already exists'); - } - for (const event of events) insertTaskLedgerEvent(this.#lease.database, sessionId, event); - }); - } - - private applyListOptions(tasks: Task[], options: TaskLedgerListOptions): Task[] { - const now = options.now ?? Date.now(); - const filtered = tasks.filter((task) => { - if (options.status && task.status !== options.status) return false; - if (options.includeTerminal === false && isTerminalTaskStatus(task.status)) return false; - if ( - options.includeArchived === false && - isTerminalTaskStatus(task.status) && - task.endedAt !== undefined && - task.endedAt <= now - TASK_ARCHIVE_AFTER_MS - ) - return false; - return true; - }); - if (options.classifyResumeTrust !== true) return filtered; - return filtered.map((task) => ({ - ...task, - resumeTrust: task.resumeTrust ?? classifyTaskResumeTrust(task), - })); - } - - private emitChanged(event: TaskLedgerChangedEvent): void { - for (const listener of this.listeners) { - try { - listener(event); - } catch { - /* observers cannot perturb the ledger */ - } - } - } -} - -function readSqliteTaskLedgerEvents(database: DatabaseSync, sessionId: string): TaskLedgerEvent[] { - assertSafeSessionId(sessionId); - const rows = database - .prepare(` - SELECT record_json - FROM workflow_task_ledger_events - WHERE session_id = ? - ORDER BY sequence - `) - .all(sessionId) as Array<{ record_json?: unknown }>; - return rows.map((row, index) => { - if (typeof row.record_json !== 'string') { - throw new Error(`Invalid SQLite task event at sequence ${index}`); - } - const parsed = JSON.parse(row.record_json); - if (!isTaskLedgerEvent(parsed) || parsed.sessionId !== sessionId) { - throw new Error(`Invalid SQLite task event at sequence ${index}`); - } - return parsed; - }); -} - -function insertTaskLedgerEvent( - database: DatabaseSync, - sessionId: string, - event: TaskLedgerEvent, -): void { - const row = database - .prepare(` - SELECT COALESCE(MAX(sequence), -1) + 1 AS sequence - FROM workflow_task_ledger_events - WHERE session_id = ? - `) - .get(sessionId) as { sequence?: unknown }; - if (typeof row.sequence !== 'number' || !Number.isSafeInteger(row.sequence)) { - throw new Error('Invalid next task event sequence'); - } - database - .prepare(` - INSERT INTO workflow_task_ledger_events( - session_id, sequence, event_id, record_json - ) VALUES (?, ?, ?, ?) - `) - .run(sessionId, row.sequence, event.eventId, JSON.stringify(event)); -} - -function nextTaskKey(tasks: readonly Task[], parent: Task | undefined): string { - const siblings = tasks.filter((task) => task.parentId === parent?.id); - const prefix = parent ? `${parent.key}.` : 'T'; - const used = new Set(siblings.map((task) => task.key)); - let index = 1; - while (used.has(`${prefix}${index}`)) index += 1; - const key = `${prefix}${index}`; - if (!isTaskKey(key)) - throw new Error( - `Task hierarchy is too deep to allocate a stable key under ${parent?.key ?? 'root'}`, - ); - return key; -} - -function assertChildTaskOwner( - owner: TaskOwner, -): asserts owner is TaskOwner & { actor: 'child_agent'; agentId: string; turnId: string } { - if (owner.actor !== 'child_agent' || !owner.agentId || !owner.turnId || !isTaskOwner(owner)) { - throw new Error( - 'Child task ownership requires stable child_agent agentId and turnId references', - ); - } -} - -function ownerFromContext(context: TaskLedgerMutationContext): TaskOwner | undefined { - if (context.actor !== 'main_agent') return undefined; - return { - actor: 'main_agent', - ...(context.runId ? { runId: context.runId } : {}), - ...(context.turnId ? { turnId: context.turnId } : {}), - }; -} - -function assertDescendantsTerminal(tasks: readonly Task[], parentId: string): void { - const pending = [parentId]; - while (pending.length > 0) { - const current = pending.shift()!; - for (const child of tasks.filter((task) => task.parentId === current)) { - if (!isTerminalTaskStatus(child.status)) { - throw new Error( - `Cannot complete a parent while descendant ${child.key} is ${child.status}`, - ); - } - pending.push(child.id); - } - } -} - -function normalizeOutcomeReason(value: string | undefined, fallback: string): string { - const normalized = (value ?? fallback).normalize('NFC').replace(/\s+/g, ' ').trim(); - return Array.from(normalized).slice(0, 1000).join(''); -} - -function clearStaleTaskEvidence(task: Task): Task { - const next: Task = { ...task }; - if (next.status !== 'blocked') delete next.blockedReason; - if (next.status !== 'failed') delete next.failureReason; - if (next.status !== 'completed') delete next.completionEvidence; - return next; -} - -function rewriteConversationTaskEvent( - event: TaskLedgerEvent, - sourceSessionId: string, - targetSessionId: string, - runIds: ReadonlyMap, - linkedChildren: 'preserve' | 'snapshot', -): TaskLedgerEvent { - const { owner, ...task } = event.task; - const snapshotChildOwner = linkedChildren === 'snapshot' && owner?.actor === 'child_agent'; - const snapshotChildRun = linkedChildren === 'snapshot' && event.actor === 'child_agent'; - const rewrittenOwner = - owner === undefined - ? undefined - : snapshotChildOwner - ? undefined - : { - ...owner, - ...(owner.sessionId === sourceSessionId ? { sessionId: targetSessionId } : {}), - ...(owner.runId - ? { - runId: requiredConversationCopyRunId(runIds, owner.runId), - } - : {}), - }; - const refs = - event.refs === undefined - ? undefined - : (() => { - const { runId: sourceRunId, ...preserved } = event.refs; - return { - ...preserved, - ...(sourceRunId && !snapshotChildRun - ? { runId: requiredConversationCopyRunId(runIds, sourceRunId) } - : {}), - }; - })(); - return { - ...event, - eventId: `task-copy-${createHash('sha256') - .update(JSON.stringify([targetSessionId, event.eventId])) - .digest('hex')}`, - sessionId: targetSessionId, - task: { - ...task, - ...(rewrittenOwner ? { owner: rewrittenOwner } : {}), - }, - ...(refs ? { refs } : {}), - }; -} - -function requiredConversationCopyRunId( - runIds: ReadonlyMap, - sourceRunId: string, -): string { - const targetRunId = runIds.get(sourceRunId); - if (!targetRunId) { - throw new Error(`Conversation copy is missing AgentRun ${sourceRunId}`); - } - return targetRunId; -} - -function buildTaskLedgerEvent(input: { - type: TaskLedgerEvent['type']; - sessionId: string; - task: Task; - previous?: Task; - context: TaskLedgerMutationContext; -}): TaskLedgerEvent { - return { - eventId: `task-event-${randomUUID()}`, - type: input.type, - ts: Date.now(), - sessionId: input.sessionId, - taskId: input.task.id, - ...(input.previous ? { previousStatus: input.previous.status } : {}), - nextStatus: input.task.status, - task: input.task, - ...((input.context.reason ?? eventReason(input.task)) - ? { reason: input.context.reason ?? eventReason(input.task) } - : {}), - ...(eventEvidence(input.task) ? { evidence: eventEvidence(input.task) } : {}), - ...(eventRefs(input.context) ? { refs: eventRefs(input.context) } : {}), - ...(input.context.source ? { source: input.context.source } : {}), - ...(input.context.actor ? { actor: input.context.actor } : {}), - }; -} - -function eventReason(task: Task): string | undefined { - return task.blockedReason ?? task.failureReason; -} - -function eventEvidence(task: Task): string | undefined { - return task.completionEvidence; -} - -function eventRefs(context: TaskLedgerMutationContext): TaskLedgerEvent['refs'] | undefined { - const refs = { - ...(context.runId ? { runId: context.runId } : {}), - ...(context.turnId ? { turnId: context.turnId } : {}), - ...(context.toolCallId ? { toolCallId: context.toolCallId } : {}), - }; - return Object.keys(refs).length === 0 ? undefined : refs; -} From c1e0d4033260d9393ad3eb65b35b3856cf7aac91 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 12:01:25 +0800 Subject: [PATCH 2/2] docs: warn that upgrading deletes unfinished Task Ledger work Operational-state schema 12 drops `workflow_task_ledger_events` on first open. Workspaces last opened by a released build that still wrote Tasks lose that work permanently, and no shipped build ever bridged those rows into SessionTodo, so the migration removes the only live copy. Record the affected releases and the instruction to finish, export, or back up outstanding Tasks before first opening this build, since after the migration recovery needs a backup made in advance. Generated-by: Claude Code --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 668c6883a4..acffc8fe45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ terminal coalescing, stop/drain, and durable continuation admission now have one production owner, immutable request snapshots remain enforced at AgentRun acceptance and backend dispatch, and SessionEvent-to-RuntimeEvent conversion remains a pure mapper. +- Retired the Task Ledger domain: SessionTodo is now the sole authority for in-session work items, and the operational-state schema drops the `workflow_task_ledger_events` table on first open. **Unfinished Tasks are not migrated and are permanently deleted.** This affects workspaces last opened by `v0.1.0` through `v0.1.11`, `cli-v0.1.0-beta.1`, `v0.2.0-incubating-rc1`, or a `v0.2.0-dev` build; those releases wrote Tasks to a table that no shipped build ever bridged into SessionTodo. Before opening such a workspace with this build, finish or export the Tasks you still need, or copy the workspace's `runtime.sqlite` aside — the migration removes the only live copy, so afterwards recovery requires a backup made in advance. - Unified context management under one Runtime-owned policy. `MAKA_CONTEXT_*` environment overrides no longer tune or disable compaction and Tool Result pruning; model-visible archive placeholders are read on demand through bounded `ArchiveRead` calls instead of eager hydration. Previously supported overrides are ignored on upgrade: if Tool Result pruning was set to `off`, pruning is re-enabled, and there is currently no supported replacement opt-out. ## 0.1.11 - 2026-08-18