From 0a61ef8833f7990fade360de6b1cf988ae37aa58 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Mon, 31 Aug 2026 19:05:34 +0800 Subject: [PATCH 1/4] feat(runtime): cut over session todos to host authority Replace the retired Task Ledger demand chain with one Host-owned current SessionTodo document across Runtime, CLI, and Desktop. Preserve one-time legacy bootstrap, exact copy and retirement semantics, and publish the protocol change at compatibility epoch 80. Refs #4338 Generated-by: OpenAI Codex --- apps/desktop/renderer-architecture.json | 2 +- .../runtime-host-client-operations.test.ts | 79 +-- .../__tests__/runtime-host-client-uds.test.ts | 20 +- ...time-host-session-domains-ipc-main.test.ts | 16 +- .../workbar-services-adapter.test.ts | 12 +- apps/desktop/src/main/e2e-fixture.ts | 40 +- apps/desktop/src/main/runtime-host-client.ts | 38 +- .../runtime-host-session-domains-ipc-main.ts | 13 +- apps/desktop/src/preload/bridge-contract.d.ts | 8 +- apps/desktop/src/preload/preload.ts | 12 +- .../src/renderer/features/workbar/ports.ts | 10 +- .../src/renderer/features/workbar/testing.ts | 4 +- ...e-session-tasks.ts => use-session-todo.ts} | 60 +- .../features/workbar/ui/workbar-surface.tsx | 20 +- .../desktop/create-workbar-services.ts | 8 +- .../stories/session-workbar.stories.tsx | 12 +- docs/README.md | 2 +- docs/astryx-surface-file-inventory.md | 2 +- docs/astryx-surface-file-inventory.paths | 2 +- docs/deep-research-durable-workspace.md | 7 +- docs/session-task-ledger-lifecycle.md | 236 ------- docs/session-todo-lifecycle.md | 174 ++++++ .../cli/src/__tests__/pi-transcript.test.ts | 96 +++ .../core/src/__tests__/session-todo.test.ts | 13 + .../src/__tests__/tool-activity-args.test.ts | 9 + .../src/__tests__/tool-quiet-preview.test.ts | 9 +- packages/core/src/session-todo.ts | 27 + packages/core/src/tool-activity-args.ts | 3 + packages/core/src/tool-quiet-preview.ts | 10 +- .../src/__tests__/connection-session.test.ts | 4 +- .../__tests__/execution-host-message.test.ts | 6 - .../__tests__/execution-host-queue.test.ts | 6 - .../__tests__/execution-host-recovery.test.ts | 6 - .../src/__tests__/execution-host.test.ts | 252 ++------ .../execution-model-composition.test.ts | 28 +- .../fixtures/execution-host-suite.ts | 6 - .../__tests__/fixtures/uncooperative-host.ts | 2 +- .../src/__tests__/goal-coordinator.test.ts | 11 - .../src/__tests__/goal-root-authority.test.ts | 1 - .../interactive-run-composer.test.ts | 4 +- .../session-catalog-two-client-uds.test.ts | 63 +- .../session-continuity-coordinator.test.ts | 6 +- .../session-retirement-coordinator.test.ts | 4 +- .../session-revision-two-client-uds.test.ts | 80 +-- .../session-todo-coordinator.test.ts | 96 +++ .../__tests__/session-todo-protocol.test.ts | 50 ++ .../__tests__/task-ledger-protocol.test.ts | 225 ------- packages/runtime-host/src/protocol/index.ts | 7 +- .../runtime-host/src/protocol/operations.ts | 7 +- .../src/protocol/session-continuity.ts | 2 +- .../runtime-host/src/protocol/session-todo.ts | 70 +++ .../runtime-host/src/protocol/task-ledger.ts | 425 ------------- .../src/server/child-agent-composition.ts | 6 +- .../src/server/execution-composition.ts | 43 +- .../src/server/goal-coordinator.ts | 28 - .../src/server/interactive-run-composer.ts | 20 +- .../src/server/operation-dispatcher.ts | 5 +- .../server/session-retirement-coordinator.ts | 10 +- .../server/session-revision-coordinator.ts | 21 +- .../src/server/session-sidecar-purge.ts | 6 +- .../src/server/session-todo-coordinator.ts | 94 +++ .../src/server/task-ledger-coordinator.ts | 249 -------- packages/runtime/package.json | 2 +- .../src/__tests__/session-todo-tools.test.ts | 116 ++++ .../src/__tests__/subagent-tools.test.ts | 301 +-------- .../src/__tests__/task-ledger-tools.test.ts | 574 ------------------ packages/runtime/src/deep-research-tools.ts | 6 +- packages/runtime/src/session-todo-tools.ts | 102 ++++ packages/runtime/src/subagent-tools.ts | 100 +-- packages/runtime/src/task-ledger-tools.ts | 323 ---------- packages/storage/package.json | 2 +- .../src/__tests__/public-entrypoints.test.ts | 2 +- .../src/__tests__/session-todo-store.test.ts | 146 ++++- .../storage/src/session-todo-authority.ts | 3 +- packages/storage/src/session-todo-store.ts | 71 ++- .../storage/src/storage-writer-composition.ts | 10 +- .../src/__tests__/session-todo-panel.test.tsx | 43 ++ .../src/__tests__/task-ledger-panel.test.ts | 59 -- packages/ui/src/index.ts | 2 +- packages/ui/src/session-todo-panel.tsx | 90 +++ packages/ui/src/shared-ui-copy.ts | 23 +- packages/ui/src/task-ledger-panel.tsx | 236 ------- 82 files changed, 1581 insertions(+), 3417 deletions(-) rename apps/desktop/src/renderer/features/workbar/tools/tasks/{use-session-tasks.ts => use-session-todo.ts} (55%) delete mode 100644 docs/session-task-ledger-lifecycle.md create mode 100644 docs/session-todo-lifecycle.md create mode 100644 packages/runtime-host/src/__tests__/session-todo-coordinator.test.ts create mode 100644 packages/runtime-host/src/__tests__/session-todo-protocol.test.ts delete mode 100644 packages/runtime-host/src/__tests__/task-ledger-protocol.test.ts create mode 100644 packages/runtime-host/src/protocol/session-todo.ts delete mode 100644 packages/runtime-host/src/protocol/task-ledger.ts create mode 100644 packages/runtime-host/src/server/session-todo-coordinator.ts delete mode 100644 packages/runtime-host/src/server/task-ledger-coordinator.ts create mode 100644 packages/runtime/src/__tests__/session-todo-tools.test.ts delete mode 100644 packages/runtime/src/__tests__/task-ledger-tools.test.ts create mode 100644 packages/runtime/src/session-todo-tools.ts delete mode 100644 packages/runtime/src/task-ledger-tools.ts create mode 100644 packages/ui/src/__tests__/session-todo-panel.test.tsx delete mode 100644 packages/ui/src/__tests__/task-ledger-panel.test.ts create mode 100644 packages/ui/src/session-todo-panel.tsx delete mode 100644 packages/ui/src/task-ledger-panel.tsx diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 3cad453abf..4e65d58d7a 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -313,7 +313,7 @@ "src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx -> src/renderer/use-composer-attachments", "src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts -> src/renderer/locales/conversation-copy", "src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts -> src/renderer/settled-message-merge", - "src/renderer/features/workbar/tools/tasks/use-session-tasks.ts -> src/renderer/locales/shell-remaining-copy", + "src/renderer/features/workbar/tools/tasks/use-session-todo.ts -> src/renderer/locales/shell-remaining-copy", "src/renderer/features/workbar/tools/terminal/session-terminal-panel.tsx -> src/renderer/locales/conversation-copy", "src/renderer/features/workbar/tools/terminal/session-terminal-panel.tsx -> src/renderer/theme", "src/renderer/features/workbar/ui/side-chat-close-confirmation.tsx -> src/renderer/locales/conversation-copy", diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts index b318d3b464..7c20c3d9c5 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts @@ -704,26 +704,9 @@ test('streams Artifact content without mixing chunk offsets or totals', async () }); test('restarts Session sidecar reads when a paginated revision changes', async () => { - const taskRevisionOne = catalogRevision('3'); - const taskRevisionTwo = catalogRevision('4'); const resourceRevisionOne = catalogRevision('5'); const resourceRevisionTwo = catalogRevision('6'); const { client, requests } = clientWithResponses([ - { - kind: 'page', - sessionId: 'session-1', - revision: taskRevisionOne, - tasks: [{ id: 'stale' }], - nextCursor: 'task-stale', - }, - { kind: 'revision_changed', expected: taskRevisionOne, actual: taskRevisionTwo }, - { - kind: 'page', - sessionId: 'session-1', - revision: taskRevisionTwo, - tasks: [{ id: 'fresh' }], - nextCursor: null, - }, { kind: 'page', sessionId: 'session-1', @@ -760,28 +743,11 @@ test('restarts Session sidecar reads when a paginated revision changes', async ( }, ]); - assert.deepEqual( - (await client.listTasks('session-1')).map((task) => task.id), - ['fresh'], - ); const plan = await client.getPlanState('session-1'); assert.equal(plan.storeVersion, 8); assert.equal(plan.latestProposalId, 'proposal-1'); assert.equal(plan.proposals[0]?.proposalId, 'proposal-1'); assert.equal((await client.listRuntimeResources('session-1'))[0]?.result.ref, 'shell:1'); - assert.deepEqual( - requests.slice(0, 3).map(({ input }) => input), - [ - { kind: 'list_start', sessionId: 'session-1' }, - { - kind: 'list_continue', - sessionId: 'session-1', - revision: taskRevisionOne, - cursor: 'task-stale', - }, - { kind: 'list_start', sessionId: 'session-1' }, - ], - ); }); test('retries Goal clear only while the same Goal generation remains active', async () => { @@ -905,31 +871,40 @@ test('arms a Goal in one request and reports a conflicting Goal instead of retry ); }); -test('rejects an invalid sidecar continuation without misclassifying it as revision churn', async () => { - const revision = catalogRevision('7'); +test('rejects a SessionTodo projection for a different Session', async () => { const { client, requests } = clientWithResponses([ - { - kind: 'page', - sessionId: 'session-1', - revision, - tasks: [], - nextCursor: 'next', - }, - { - kind: 'page', - sessionId: 'session-other', - revision, - tasks: [], - nextCursor: null, - }, + { sessionId: 'session-other', items: [] }, ]); await assert.rejects( - () => client.listTasks('session-1'), + () => client.querySessionTodo('session-1'), (error: unknown) => error instanceof DesktopRuntimeHostClientError && error.code === 'projection_unstable', ); - assert.equal(requests.length, 2); + assert.equal(requests.length, 1); +}); + +test('projects SessionTodo content through the shared Desktop display boundary', async () => { + const { client } = clientWithResponses([ + { + sessionId: 'session-1', + items: [ + { + content: + 'deploy\u001b[31m \u001b]0;spoofed\u0007 \u202ereversed\u202c zero\u200bwidth sk-live-secret-token ', + status: 'pending', + }, + ], + }, + ]); + + const items = await client.querySessionTodo('session-1'); + assert.equal(items.length, 1); + assert.doesNotMatch( + items[0]!.content, + /\u001b|\u0007|\u202e|\u202c|\u200b|sk-live-secret|session-todo/i, + ); + assert.match(items[0]!.content, /|\[redacted\]/); }); interface RecordedRequest { diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts index ed11340b31..0eaf7775b2 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts @@ -451,23 +451,13 @@ test('drives bounded Session domain projections through real UDS framing', async idleGraceMs: 10_000, composition: defineInteractiveRuntimeHostComposition(async () => ({ handlers: handlers({ - 'task.ledger.query': async (input) => ({ + 'session.todo.query': async (input) => ({ ok: true, result: { - kind: 'page', sessionId: input.sessionId, - revision: catalogRevision('6'), - tasks: [ - { - id: 'task-1', - key: 'T1', - subject: 'Verify the Desktop adapter', - status: 'in_progress', - createdAt: 1, - updatedAt: 2, - }, + items: [ + { content: 'Verify the Desktop adapter', status: 'in_progress' }, ], - nextCursor: null, }, }), 'plan.query': async (input) => ({ @@ -523,8 +513,8 @@ test('drives bounded Session domain projections through real UDS framing', async ); assert.equal( - ((await ipc.invoke('tasks:list', 'session-1')) as Array<{ id: string }>)[0]?.id, - 'task-1', + ((await ipc.invoke('todo:read', 'session-1')) as Array<{ content: string }>)[0]?.content, + 'Verify the Desktop adapter', ); assert.deepEqual(await ipc.invoke('plan-mode:getState', 'session-1'), { schemaVersion: 1, diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-domains-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-domains-ipc-main.test.ts index ee817a5027..0188653324 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-domains-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-domains-ipc-main.test.ts @@ -452,7 +452,7 @@ test('goal:arm takes the Session from the scoped channel and refuses any other k test('adapts Host Goal, Task, Deep Research, and Resource projections', async () => { const controls: unknown[] = []; const client = domainClient({ - listTasks: async () => [{ id: 'task-1' }] as never, + querySessionTodo: async () => [{ content: 'todo-1', status: 'pending' }] as never, listRuntimeResources: async () => [{ sessionId: 'session-1', result: { ref: 'shell:1' } }] as never, queryGoal: async () => ({ sessionId: 'session-1', @@ -469,7 +469,7 @@ test('adapts Host Goal, Task, Deep Research, and Resource projections', async () const ipc = ipcHarness(); registerDomainsIpc({ client, emitModeChanged() {} }, ipc); - assert.equal(((await ipc.invoke('tasks:list', 'session-1')) as Array<{ id: string }>)[0]?.id, 'task-1'); + assert.equal(((await ipc.invoke('todo:read', 'session-1')) as Array<{ content: string }>)[0]?.content, 'todo-1'); assert.equal( ((await ipc.invoke('shell-runs:list', 'session-1')) as Array<{ result: { ref: string } }>)[0] ?.result.ref, @@ -1004,7 +1004,7 @@ test('publishes typed invalidations and refreshes only changed Runtime Resources ipc, ); - handle.sessionDomainChanged({ sessionId: 'session-1', domain: 'task' }); + handle.sessionDomainChanged({ sessionId: 'session-1', domain: 'todo' }); handle.sessionDomainChanged({ sessionId: 'session-1', domain: 'deep_research' }); handle.sessionDomainChanged({ sessionId: 'session-1', domain: 'plan' }); handle.sessionDomainChanged({ sessionId: 'session-1', domain: 'usage' }); @@ -1030,8 +1030,8 @@ test('publishes typed invalidations and refreshes only changed Runtime Resources assert.deepEqual(gets, [{ sessionId: 'session-1', ref: update.result.ref }]); assert.deepEqual(sent, [ { - channel: 'tasks:changed', - payload: { sessionId: 'session-1', taskIds: [], at: 12 }, + channel: 'todo:changed', + payload: { sessionId: 'session-1', at: 12 }, }, { channel: 'deepResearch:changed', @@ -1073,8 +1073,8 @@ test('publishes typed invalidations and refreshes only changed Runtime Resources handle.sessionSubscriptionRecovered('session-1'); assert.deepEqual(sent, [ { - channel: 'tasks:changed', - payload: { sessionId: 'session-1', taskIds: [], at: 12 }, + channel: 'todo:changed', + payload: { sessionId: 'session-1', at: 12 }, }, { channel: 'deepResearch:changed', @@ -1181,7 +1181,7 @@ function domainClient(overrides: Partial): DomainClient { listRuntimeResources: unavailable, listAgentGraphEpochs: unavailable, listCurrentAgentGraphEpochs: unavailable, - listTasks: unavailable, + querySessionTodo: unavailable, queryAgentGraph: unavailable, queryAgentGraphOperator: unavailable, queryDeepResearch: unavailable, diff --git a/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts b/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts index 2240b5febd..88a3d42449 100644 --- a/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts @@ -33,7 +33,7 @@ function createBridgeRecorder(): { 'sessions.subscribeEvents', 'shellRuns.subscribePtyData', 'shellRuns.subscribeResync', - 'tasks.subscribeChanges', + 'todo.subscribeChanges', 'browser.setActiveSession', 'browser.setViewport', 'browser.onState', @@ -70,7 +70,7 @@ function createBridgeRecorder(): { gitReview: domain('gitReview'), sessions: domain('sessions'), shellRuns: domain('shellRuns'), - tasks: domain('tasks'), + todo: domain('todo'), browser: domain('browser'), artifacts: domain('artifacts'), app: domain('app'), @@ -122,8 +122,8 @@ describe('createDesktopWorkbarServices', () => { services.terminal.subscribePtyData(eventHandler)(); services.terminal.subscribeResync(eventHandler)(); - await services.tasks.list('s'); - services.tasks.subscribeChanges(eventHandler)(); + await services.todo.read('s'); + services.todo.subscribeChanges(eventHandler)(); services.browser.setActiveSession('s'); services.browser.setViewport({ sessionId: 's', rect: null }); @@ -194,8 +194,8 @@ describe('createDesktopWorkbarServices', () => { 'shellRuns.write', 'shellRuns.subscribePtyData', 'shellRuns.subscribeResync', - 'tasks.list', - 'tasks.subscribeChanges', + 'todo.read', + 'todo.subscribeChanges', 'browser.setActiveSession', 'browser.setViewport', 'browser.navigate', diff --git a/apps/desktop/src/main/e2e-fixture.ts b/apps/desktop/src/main/e2e-fixture.ts index 4f06653bb2..e61e89f1c7 100644 --- a/apps/desktop/src/main/e2e-fixture.ts +++ b/apps/desktop/src/main/e2e-fixture.ts @@ -28,7 +28,7 @@ import { resolveStorageRoot, tryAcquireInteractiveRootOwner, } from '@maka/storage/root-authority'; -import { openInteractiveTaskLedgerStoreForWrite } from '@maka/storage/task-ledger-authority'; +import { openInteractiveSessionTodoStoreForWrite } from '@maka/storage/session-todo-authority'; import { openInteractiveUsageStoresForWrite } from '@maka/storage/usage-stores'; import { E2E_FIXTURE_NOW, @@ -231,39 +231,17 @@ export async function seedE2eFixture(input: { if (scenario === 'turn-narrative' || scenario === 'turn-narrative-browser') { const owner = await tryAcquireInteractiveRootOwner(storageRoot); - if (!owner) throw new Error('Unable to acquire the E2E fixture task-ledger root'); + if (!owner) throw new Error('Unable to acquire the E2E fixture SessionTodo root'); try { - const tasks = await openInteractiveTaskLedgerStoreForWrite(owner.lease); + const todos = await openInteractiveSessionTodoStoreForWrite(owner.lease); try { - const created = await tasks.create( - TURN_SESSION_ID, - [ - { subject: '补齐桌面端无障碍覆盖' }, - { subject: '核对模型选择器的键盘路径' }, - { subject: '确认工具结果可以展开阅读' }, - ], - { source: 'import', actor: 'system' }, - ); - await tasks.update( - TURN_SESSION_ID, - created.created[0]!.id, - { status: 'in_progress' }, - { source: 'import', actor: 'system' }, - ); - await tasks.update( - TURN_SESSION_ID, - created.created[2]!.id, - { status: 'in_progress' }, - { source: 'import', actor: 'system' }, - ); - await tasks.update( - TURN_SESSION_ID, - created.created[2]!.id, - { status: 'completed', completionEvidence: '工具输出已成功显示。' }, - { source: 'import', actor: 'system' }, - ); + await todos.replaceAll(TURN_SESSION_ID, [ + { content: '补齐桌面端无障碍覆盖', status: 'in_progress' }, + { content: '核对模型选择器的键盘路径', status: 'pending' }, + { content: '确认工具结果可以展开阅读', status: 'completed' }, + ]); } finally { - tasks.close(); + todos.close(); } } finally { await owner.close(); diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index e2ffc31762..c95f8ac0fa 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -26,7 +26,10 @@ import { type TurnRecord, } from "@maka/core/session"; import { markPersisted } from "@maka/core/persisted-value"; -import type { Task } from "@maka/core/task-ledger"; +import { + projectSessionTodoItemsForDisplay, + type SessionTodoItem, +} from "@maka/core/session-todo"; import type { ConnectionCatalogSnapshot, @@ -1283,35 +1286,10 @@ export class DesktopRuntimeHostClient { return this.request("context.compact", input); } - async listTasks(sessionId: string): Promise { - const projection = await collectStableProjection({ - name: "Task ledger", - sessionId, - start: () => - this.request("task.ledger.query", { kind: "list_start", sessionId }), - continue: (first, cursor) => - this.request("task.ledger.query", { - kind: "list_continue", - sessionId, - revision: first.revision, - cursor, - }), - page(result, first) { - if ( - result.kind !== "page" || - result.sessionId !== sessionId || - (first !== undefined && result.revision !== first.revision) - ) { - throw invalidProjection("Task ledger"); - } - return { - source: result, - items: result.tasks, - nextCursor: result.nextCursor, - }; - }, - }); - return projection.items; + async querySessionTodo(sessionId: string): Promise { + const result = await this.request("session.todo.query", { sessionId }); + if (result.sessionId !== sessionId) throw invalidProjection("SessionTodo"); + return projectSessionTodoItemsForDisplay(result.items); } queryUsage( diff --git a/apps/desktop/src/main/runtime-host-session-domains-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-domains-ipc-main.ts index e08afc2397..a68664aac7 100644 --- a/apps/desktop/src/main/runtime-host-session-domains-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-domains-ipc-main.ts @@ -63,7 +63,7 @@ type RuntimeHostSessionDomainClient = RuntimeHostShellRunsClient & | 'listRuntimeResources' | 'listAgentGraphEpochs' | 'listCurrentAgentGraphEpochs' - | 'listTasks' + | 'querySessionTodo' | 'queryAgentGraph' | 'queryAgentGraphOperator' | 'queryDeepResearch' @@ -114,8 +114,8 @@ export function registerRuntimeHostSessionDomainsIpc( ipcMain, ); - handleReconnectableRead(ipcMain, 'tasks:list', (_event, sessionId: unknown) => - deps.client.listTasks(requiredId(sessionId, 'Session')), + handleReconnectableRead(ipcMain, 'todo:read', (_event, sessionId: unknown) => + deps.client.querySessionTodo(requiredId(sessionId, 'Session')), ); handleReconnectableRead(ipcMain, 'deepResearch:get', async (_event, sessionId: unknown) => projectHostedDeepResearch( @@ -329,10 +329,9 @@ export function registerRuntimeHostSessionDomainsIpc( const sessionDomainChanged = (change: SessionDomainChange): void => { switch (change.domain) { - case 'task': - deps.sendToRenderer?.('tasks:changed', { + case 'todo': + deps.sendToRenderer?.('todo:changed', { sessionId: change.sessionId, - taskIds: [], at: now(), }); break; @@ -363,7 +362,7 @@ export function registerRuntimeHostSessionDomainsIpc( deps.sendToRenderer?.('graphs:changed', event); }, sessionSubscriptionRecovered(sessionId) { - sessionDomainChanged({ sessionId, domain: 'task' }); + sessionDomainChanged({ sessionId, domain: 'todo' }); sessionDomainChanged({ sessionId, domain: 'deep_research' }); sessionDomainChanged({ sessionId, domain: 'plan' }); sessionDomainChanged({ sessionId, domain: 'usage' }); diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 1525a4c34e..f8fbf7dcc3 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -95,7 +95,7 @@ import type { } from '@maka/core/daily-review'; import type { WebSearchProvider, WebSearchResponse } from '@maka/core/web-search'; import type { BrowserState, BrowserViewRect } from '@maka/core/browser'; -import type { Task, TaskLedgerChangedEvent } from '@maka/core/task-ledger'; +import type { SessionTodoItem } from '@maka/core/session-todo'; import type { DeepResearchChangedEvent, DeepResearchClientProgress } from '@maka/core/deep-research-run'; import type { DesktopTranscriptBatch, @@ -961,9 +961,9 @@ export interface MakaBridge { subscribeChanges(handler: (event: WorkBoardChangedEvent) => void): () => void; }; - tasks: { - list(sessionId: string): Promise; - subscribeChanges(handler: (event: TaskLedgerChangedEvent) => void): () => void; + todo: { + read(sessionId: string): Promise; + subscribeChanges(handler: (event: { sessionId: string; at: number }) => void): () => void; }; deepResearch: { get(sessionId: string): Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 5a64dd317a..49227d46e9 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -174,7 +174,7 @@ import type { import type { WebSearchProvider, WebSearchResponse } from '@maka/core/web-search'; import type { BrowserState, BrowserViewRect } from '@maka/core/browser'; import { createBrowserSelectionCoordinator } from './browser-selection.js'; -import type { Task, TaskLedgerChangedEvent } from '@maka/core/task-ledger'; +import type { SessionTodoItem } from '@maka/core/session-todo'; import type { DeepResearchChangedEvent, DeepResearchClientProgress } from '@maka/core/deep-research-run'; import { isWebSearchProvider, @@ -1724,12 +1724,12 @@ const makaBridge = { return () => ipcRenderer.off('workBoard:changed', listener); }, }, - tasks: { - list(sessionId: string): Promise { - return invokeProjectedSessionRuntimeHost('tasks:list', sessionId); + todo: { + read(sessionId: string): Promise { + return invokeProjectedSessionRuntimeHost('todo:read', sessionId); }, - subscribeChanges(handler: (event: TaskLedgerChangedEvent) => void): () => void { - return subscribeEveryRuntimeHostEvent('tasks:changed', (scope, event: TaskLedgerChangedEvent) => + subscribeChanges(handler: (event: { sessionId: string; at: number }) => void): () => void { + return subscribeEveryRuntimeHostEvent('todo:changed', (scope, event: { sessionId: string; at: number }) => handler({ ...event, sessionId: recordRuntimeHostSessionScope(scope, event.sessionId), diff --git a/apps/desktop/src/renderer/features/workbar/ports.ts b/apps/desktop/src/renderer/features/workbar/ports.ts index 8efcf71ace..e5ec333f16 100644 --- a/apps/desktop/src/renderer/features/workbar/ports.ts +++ b/apps/desktop/src/renderer/features/workbar/ports.ts @@ -41,7 +41,7 @@ import type { TurnRecord, } from '@maka/core/session'; import type { SessionTrace } from '@maka/core/session-trace'; -import type { Task, TaskLedgerChangedEvent } from '@maka/core/task-ledger'; +import type { SessionTodoItem } from '@maka/core/session-todo'; import type { UserQuestionResponse } from '@maka/core/user-question'; import type { Result } from '@maka/core/result'; import type { ContextDiagnosticsResult } from '@maka/runtime-host/protocol'; @@ -91,10 +91,10 @@ export interface WorkbarTerminalService { ): WorkbarUnsubscribe; } -export interface WorkbarTasksService { - list(sessionId: string): Promise; +export interface WorkbarTodoService { + read(sessionId: string): Promise; subscribeChanges( - handler: (event: TaskLedgerChangedEvent) => void, + handler: (event: { sessionId: string; at: number }) => void, ): WorkbarUnsubscribe; } @@ -272,7 +272,7 @@ export interface SideChatSessionPort { export interface WorkbarServices { readonly review: WorkbarReviewService; readonly terminal: WorkbarTerminalService; - readonly tasks: WorkbarTasksService; + readonly todo: WorkbarTodoService; readonly browser: WorkbarBrowserService; readonly artifacts: WorkbarArtifactsService; readonly inspector: WorkbarInspectorService; diff --git a/apps/desktop/src/renderer/features/workbar/testing.ts b/apps/desktop/src/renderer/features/workbar/testing.ts index 9ecf0928f5..29284f6050 100644 --- a/apps/desktop/src/renderer/features/workbar/testing.ts +++ b/apps/desktop/src/renderer/features/workbar/testing.ts @@ -73,8 +73,8 @@ export function createFakeWorkbarServices( subscribePtyData: noopSubscription, subscribeResync: noopSubscription, }, - tasks: { - list: async () => [], + todo: { + read: async () => [], subscribeChanges: noopSubscription, }, browser: { diff --git a/apps/desktop/src/renderer/features/workbar/tools/tasks/use-session-tasks.ts b/apps/desktop/src/renderer/features/workbar/tools/tasks/use-session-todo.ts similarity index 55% rename from apps/desktop/src/renderer/features/workbar/tools/tasks/use-session-tasks.ts rename to apps/desktop/src/renderer/features/workbar/tools/tasks/use-session-todo.ts index 853a04a424..0b55bb46a9 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/tasks/use-session-tasks.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/tasks/use-session-todo.ts @@ -19,78 +19,74 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { generalizedErrorMessage, generalizedErrorMessageChinese } from '@maka/core/redaction'; -import { type Task } from '@maka/core/task-ledger'; +import type { SessionTodoItem } from '@maka/core/session-todo'; import { useUiLocale } from '@maka/ui'; import { getShellRemainingCopy } from '../../../../locales/shell-remaining-copy.js'; import { useWorkbarServices } from '../../services-context.js'; -interface SessionTaskSnapshot { +interface SessionTodoView { sessionId?: string; - tasks: Task[]; + items: SessionTodoItem[]; loading: boolean; error?: string; } -const EMPTY_SNAPSHOT: SessionTaskSnapshot = { - tasks: [], - loading: false, -}; +const EMPTY: SessionTodoView = { items: [], loading: false }; -export function useSessionTasks(sessionId: string | undefined): SessionTaskSnapshot & { retry: () => void } { - const { tasks: tasksService } = useWorkbarServices(); +export function useSessionTodo(sessionId: string | undefined): SessionTodoView & { retry: () => void } { + const { todo } = useWorkbarServices(); const locale = useUiLocale(); const copy = getShellRemainingCopy(locale).tasks; - const revisionRef = useRef(0); - const [snapshot, setSnapshot] = useState(EMPTY_SNAPSHOT); + const generation = useRef(0); + const [snapshot, setSnapshot] = useState(EMPTY); - const load = useCallback((targetSessionId: string, preserveTasks: boolean) => { - const revision = ++revisionRef.current; + const load = useCallback((targetSessionId: string, preserve: boolean) => { + const requestGeneration = ++generation.current; setSnapshot((current) => ({ sessionId: targetSessionId, - tasks: preserveTasks && current.sessionId === targetSessionId ? current.tasks : [], + items: preserve && current.sessionId === targetSessionId ? current.items : [], loading: true, })); - void tasksService.list(targetSessionId).then( - (tasks) => { - if (revision !== revisionRef.current) return; - setSnapshot({ sessionId: targetSessionId, tasks, loading: false }); + void todo.read(targetSessionId).then( + (items) => { + if (requestGeneration !== generation.current) return; + setSnapshot({ sessionId: targetSessionId, items, loading: false }); }, (error: unknown) => { - if (revision !== revisionRef.current) return; + if (requestGeneration !== generation.current) return; setSnapshot((current) => ({ sessionId: targetSessionId, - tasks: current.sessionId === targetSessionId ? current.tasks : [], + items: current.sessionId === targetSessionId ? current.items : [], loading: false, - error: locale === 'zh' - ? generalizedErrorMessageChinese(error, copy.loadFailed) - : generalizedErrorMessage(error, copy.loadFailed), + error: + locale === 'zh' + ? generalizedErrorMessageChinese(error, copy.loadFailed) + : generalizedErrorMessage(error, copy.loadFailed), })); }, ); - }, [copy.loadFailed, locale, tasksService]); + }, [copy.loadFailed, locale, todo]); useEffect(() => { - revisionRef.current += 1; + generation.current += 1; if (!sessionId) { - setSnapshot(EMPTY_SNAPSHOT); + setSnapshot(EMPTY); return; } - const unsubscribe = tasksService.subscribeChanges((event) => { + const unsubscribe = todo.subscribeChanges((event) => { if (event.sessionId === sessionId) load(sessionId, true); }); load(sessionId, false); return () => { - revisionRef.current += 1; + generation.current += 1; unsubscribe(); }; - }, [load, sessionId, tasksService]); + }, [load, sessionId, todo]); const retry = useCallback(() => { if (sessionId) load(sessionId, true); }, [load, sessionId]); - if (snapshot.sessionId !== sessionId) { - return { ...EMPTY_SNAPSHOT, loading: Boolean(sessionId), retry }; - } + if (snapshot.sessionId !== sessionId) return { ...EMPTY, loading: Boolean(sessionId), retry }; return { ...snapshot, retry }; } diff --git a/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx b/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx index 43d2bfac56..04ad6603d8 100644 --- a/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx +++ b/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx @@ -42,8 +42,8 @@ import { useSortable, } from '@dnd-kit/sortable'; import { - TaskLedgerPanel, - deriveTaskLedgerPanelModel, + SessionTodoPanel, + sessionTodoActiveCount, IconButton, Composer, useUiLocale, @@ -86,7 +86,7 @@ import { sessionWorkbarTabsToRight, terminalRefFromWorkbarTab, } from '../model/workbar-tabs'; -import { useSessionTasks } from '../tools/tasks/use-session-tasks'; +import { useSessionTodo } from '../tools/tasks/use-session-todo'; import { WorkbarToggle } from './workbar-toggle'; import { WorkBoardPanel } from '../../../work-board-panel.js'; import { getDesktopConversationCopy } from '../../../locales/conversation-copy.js'; @@ -719,8 +719,8 @@ export function WorkbarSurface(props: { modelChoices?: readonly ChatModelChoice[]; }) { const copy = getDesktopConversationCopy(useUiLocale()).workbar; - const sessionTasks = useSessionTasks(props.sessionId); - const taskCount = deriveTaskLedgerPanelModel(sessionTasks.tasks).activeCount; + const sessionTodo = useSessionTodo(props.sessionId); + const taskCount = sessionTodoActiveCount(sessionTodo.items); const [artifactCount, setArtifactCount] = useState(0); const placements: SessionWorkbarPlacement[] = ['right', 'bottom']; const positionedTabs = placements.flatMap((placement) => @@ -821,11 +821,11 @@ export function WorkbarSurface(props: { ); } else if (tab.kind === 'tasks') { content = ( - ); } else if (tab.kind === 'work-board') { diff --git a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts index 2a741425c7..78cf43f8cb 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts @@ -31,7 +31,7 @@ export type DesktopWorkbarBridge = Pick< | 'inspector' | 'sessions' | 'shellRuns' - | 'tasks' + | 'todo' | 'transcripts' >; @@ -63,9 +63,9 @@ export function createDesktopWorkbarServices( subscribePtyData: (handler) => bridge.shellRuns.subscribePtyData(handler), subscribeResync: (handler) => bridge.shellRuns.subscribeResync(handler), }, - tasks: { - list: (sessionId) => bridge.tasks.list(sessionId), - subscribeChanges: (handler) => bridge.tasks.subscribeChanges(handler), + todo: { + read: (sessionId) => bridge.todo.read(sessionId), + subscribeChanges: (handler) => bridge.todo.subscribeChanges(handler), }, browser: { setActiveSession: (sessionId) => bridge.browser.setActiveSession(sessionId), diff --git a/apps/desktop/stories/session-workbar.stories.tsx b/apps/desktop/stories/session-workbar.stories.tsx index 8df2bef6ba..79c7415189 100644 --- a/apps/desktop/stories/session-workbar.stories.tsx +++ b/apps/desktop/stories/session-workbar.stories.tsx @@ -835,10 +835,16 @@ function bridge(options: { } = {}): Decorator { const browserState = options.browserState ?? EMPTY_BROWSER_STATE; const services = createFakeWorkbarServices({ - tasks: { - list: async () => { + todo: { + read: async () => { if (options.tasksFail) throw new Error('读取任务失败'); - return options.tasks ?? tasks; + return (options.tasks ?? tasks).map((task) => ({ + content: task.subject, + status: + task.status === 'in_progress' || task.status === 'completed' + ? task.status + : 'pending' as const, + })); }, subscribeChanges: unsubscribe, }, diff --git a/docs/README.md b/docs/README.md index 369fc2af1b..cd8d6bc4f3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -44,7 +44,7 @@ This page is the authority map for Maka documentation. Code and contract tests r ### Runtime and Eval - [Deep Research durable workspace](./deep-research-durable-workspace.md) -- [Session task ledger lifecycle](./session-task-ledger-lifecycle.md) +- [SessionTodo lifecycle](./session-todo-lifecycle.md) - [Work Board contract](./work-board-contract.md) - [Work Board Phase 1 surface](./work-board-phase1.md) - [WorkHub domain language](./workhub-domain-language.md) diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index a0e33292c6..b1e9f656c0 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -244,10 +244,10 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `packages/ui/src/session-rail-context.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/session-rename-dialog.tsx` | dialog-overlay | Button, Dialog, DialogHeader, HStack, Layout, LayoutContent, LayoutFooter, TextInput | aligned — uses Astryx (Button, Dialog, DialogHeader, HStack, Layout, LayoutContent, LayoutFooter, TextInput) | aligned | | `packages/ui/src/session-sidebar-nav.tsx` | shell-chrome-or-panel | Icon, IconButton, SideNavItem, SideNavSection, Tooltip | aligned — uses Astryx (Icon, IconButton, SideNavItem, SideNavSection, Tooltip) | aligned | +| `packages/ui/src/session-todo-panel.tsx` | shell-chrome-or-panel | Banner, EmptyState, IconButton, Spinner | aligned — uses Astryx (Banner, EmptyState, IconButton, Spinner) | aligned | | `packages/ui/src/skill-inspector.tsx` | shell-chrome-or-panel | Button, Divider, HStack, Heading, MetadataList, MetadataListItem, StackItem, StatusDot, Switch, Text, VStack | aligned — uses Astryx (Button, Divider, HStack, Heading, MetadataList, MetadataListItem, StackItem, StatusDot) | aligned | | `packages/ui/src/skills-panel.tsx` | module-hub | Button, DropdownMenu, DropdownMenuItem, EmptyState, IconButton, List, ListItem, SegmentedControl, SegmentedControlItem, Selector, StatusDot, Text, TextInput, Toolbar | aligned — uses Astryx (Button, DropdownMenu, DropdownMenuItem, EmptyState, IconButton, List, ListItem, SegmentedControl) | aligned | | `packages/ui/src/styles.css` | ui-composition | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | -| `packages/ui/src/task-ledger-panel.tsx` | shell-chrome-or-panel | Banner, Collapsible, EmptyState, IconButton, Spinner | aligned — uses Astryx (Banner, Collapsible, EmptyState, IconButton, Spinner) | aligned | | `packages/ui/src/titlebar-session-identity.tsx` | shell-chrome-or-panel | BreadcrumbItem, Breadcrumbs, Icon, IconButton, Tooltip | aligned — uses Astryx (BreadcrumbItem, Breadcrumbs, Icon, IconButton, Tooltip) | aligned | | `packages/ui/src/toast.tsx` | ui-composition | AlertDialog, Button, HStack, LayerProvider, Text, VStack | aligned — uses Astryx (AlertDialog, Button, HStack, LayerProvider, Text, VStack) | aligned | | `packages/ui/src/tool-activity.tsx` | ui-composition | Banner, Button, ChatToolCalls, List, ListItem, StatusDot, Text, VisuallyHidden | aligned — uses Astryx (Banner, Button, ChatToolCalls, List, ListItem, StatusDot, Text, VisuallyHidden) | aligned | diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index 027fe3cb0b..dbde3ed403 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -215,10 +215,10 @@ packages/ui/src/session-list-panel.tsx packages/ui/src/session-rail-context.tsx packages/ui/src/session-rename-dialog.tsx packages/ui/src/session-sidebar-nav.tsx +packages/ui/src/session-todo-panel.tsx packages/ui/src/skill-inspector.tsx packages/ui/src/skills-panel.tsx packages/ui/src/styles.css -packages/ui/src/task-ledger-panel.tsx packages/ui/src/titlebar-session-identity.tsx packages/ui/src/toast.tsx packages/ui/src/tool-activity.tsx diff --git a/docs/deep-research-durable-workspace.md b/docs/deep-research-durable-workspace.md index 263296a07f..3d72f2853f 100644 --- a/docs/deep-research-durable-workspace.md +++ b/docs/deep-research-durable-workspace.md @@ -114,9 +114,10 @@ records local and web substeps but does not silently broaden their permissions. ## Authority and data flow The research event ledger is the authority for workflow state and -relationships. The Artifact Store is the authority for large bodies. The -existing Task Ledger remains the authority for tasks; checkpoints only link to -task ids. +relationships. The Artifact Store is the authority for large bodies. +Deep Research no longer accepts Task Ledger ids from production tools; +the persisted checkpoint `taskIds` field remains decode-compatible and is +written as empty during the migration window. ```text Deep Research root session diff --git a/docs/session-task-ledger-lifecycle.md b/docs/session-task-ledger-lifecycle.md deleted file mode 100644 index c12154a0aa..0000000000 --- a/docs/session-task-ledger-lifecycle.md +++ /dev/null @@ -1,236 +0,0 @@ - - -# Session Task Ledger Lifecycle - -This document defines the lifecycle and persistence contract for the task ledger -attached to an interactive session. The ledger tracks model-visible work items -inside a Runtime Host Session; it is not an Eval experiment or cell ledger. - -## Scope - -Maka has a session-scoped task ledger with `task_create`, `task_update`, -`task_list`, `task_get`, `task-events.jsonl`, and `tasks.json`. The implementation -keeps lifecycle validation, event replay, storage -projection, tool access, and recovery classification on one contract. - -Non-goals: - -- no workflow engine; -- no cron or automation scheduling; -- no project-management editing, dependency graph, drag-and-drop, or bulk - scheduling UI; -- no replacement for `AgentRun`, `RuntimeEvent`, filesystem, git, test, or tool - evidence. - -Task status is advisory control state. It must not override real filesystem, -git, test, verifier, scorer, or tool evidence. - -## Identity and Hierarchy - -Every current task has two identifiers: - -- `id` is the durable UUID primary key. It is never rewritten. -- `key` is the session-local short reference (`T1`, `T1.1`, and deeper forms) - used in model-visible tool results, tools, and UI. - -Read and update operations accept either form. Keys are allocated inside the -per-session serialized write queue. A child stores its parent's UUID in -`parentId`; its short key is allocated under the parent's key. Children cannot -be created under terminal parents, and a parent cannot become `completed` -while any descendant remains non-terminal. A parent/child edge must advance the -short key by exactly one segment (`T1` -> `T1.1`); skipped levels such as a -direct `T1` -> `T1.1.1` edge invalidate the projection and fail closed. - -Old `tasks.json` snapshots and JSONL events without `key` or `endedAt` remain -readable. Projection assigns stable keys in first-seen creation-event order -(falling back to timestamps only when event order is unavailable) and derives -missing terminal timestamps from `updatedAt`. The first later mutation appends -compatibility events before the new mutation so the derived fields become -durable without changing UUIDs. - -## Task Status - -Task statuses are: - -- `pending`: declared but not started. -- `in_progress`: actively being worked on. -- `blocked`: cannot continue without external input, dependency, permission, or - prerequisite repair. -- `completed`: finished with evidence. -- `failed`: attempted and ended unsuccessfully with a reason. -- `cancelled`: intentionally stopped and should not resume automatically. - -Allowed transitions: - -```text -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 - -completed -> in_progress only with explicitReopen: true -cancelled -> pending only with explicitReopen: true -``` - -## Evidence - -New updates into these states require evidence: - -- `blocked` requires `blockedReason`. -- `failed` requires `failureReason`. -- `completed` requires `completionEvidence`. - -Evidence is compact text. Later work can replace or supplement it -with first-class run, tool-call, artifact, verifier, or scorer references. - -Legacy completed or cancelled tasks that predate this contract may still be -read from `tasks.json`. New updates must satisfy the evidence rules. - -## Resume Trust - -The source-backed type includes a conservative `resumeTrust` classifier: - -- `trusted`: durable evidence is intact. -- `needs_revalidation`: state may still be correct, but related external truth - should be checked again. -- `stale`: task was active when the session or run was interrupted. -- `repaired`: recovery logic changed the projected state. -- `untrusted`: ledger, references, or state are corrupt or missing. - -The type and pure classifier are source-backed. `resumeTrust` is a system -diagnostic; untrusted tasks are excluded from model-visible tool results. - -Recovery/read-model classification uses the conservative classifier: - -- `in_progress` tasks are `stale`. -- tasks with missing required evidence are `needs_revalidation`. -- corrupt ledgers, invalid projections, or missing references are `untrusted`. -- repaired projections are `repaired`. - -## Tool Surface - -The model-facing tools are: - -- `task_create` -- `task_update` -- `task_list` -- `task_get` - -The four ledger tools only mutate/read local session state; they do not dispatch -work themselves. `task_create.tasks[].parent_id`, all task reference inputs, -and `agent_spawn.task_id` accept UUIDs or short keys. `task_list` supports exact -`status`, `include_terminal`, and `include_archived` filters; its no-argument -behavior remains compatible with the original full-list behavior. - -### Runtime Host Authority - -The non-serving Runtime Host composition opens the interactive Task Ledger -writer under its Storage root owner lease. One per-Session coordinator -implements the Runtime `TaskLedgerStore` port and serves the read-only -`task.ledger.query` Client operation. Reads, mutations, claims, and child -outcomes invoked through that port therefore share the same Session admission -boundary instead of creating a second Task Ledger authority. - -The Host binds this port into the real-model task and child-agent tool -composition. Desktop and CLI consume the same Client projection and do not -open an interactive Task Ledger writer. - -Client queries return the canonical, sanitized projection in item- and -byte-bounded pages. A content revision pins each traversal; a continuation from -an older projection returns `revision_changed` rather than mixing snapshots -across Host epochs. The authority preserves the existing `task-events.jsonl`, -`tasks.json`, legacy-read, and backfill behavior. Runtime Host is the sole -interactive writer after production activation. - -## Child Agent Ownership - -`agent_spawn(task_id=...)` resolves the task in the current session and claims -it only after the runtime has allocated the real child turn. The claim sets the -task to `in_progress` and records a `child_agent` owner. Once the child settles, -the owner is enriched with the real run and turn references. - -A successful child does not complete the task. The parent agent must verify the -result and supply `completionEvidence`. A failed or cancelled child records the -truthful task outcome; a child waiting for permission leaves the task blocked. -An active task already owned by another child turn cannot be stolen. - -## Model-visible Reads and Archive - -The task ledger is not injected into every model turn. The model reads it on -demand through `task_list` and `task_get`; results render short keys and safe -fielded text rather than copying internal diagnostics. - -Terminal tasks receive `endedAt`. They become logically archived after seven -days: storage remains append-only and no task is deleted. Callers choose whether -archived terminal tasks are included in a read. - -Secret redaction, task-ledger tag stripping, evidence validation, and exclusion -of `resumeTrust=untrusted` tasks apply before model-visible rendering. - -## Goal Completion Gate - -Ordinary interactive turns never trigger an extra model call because tasks are -unfinished. - -When an autonomous Goal is active, its external evaluator still decides first. -If the evaluator says achieved or impossible, that terminal decision wins. If -the Goal continues and pending or in-progress task keys remain, the continuation -text includes one task reminder per Goal id. Blocked, failed, cancelled, and -completed tasks do not trigger the reminder. The reminder is consumed only -after the final idle check and synchronous turn injection, so a concurrent user -turn cannot spend it without showing it. Later continuations are allowed without -another task-specific reminder. Every injected decision is recorded as a -`task_gate_decided` AgentRun event with the Goal id, decision, and task keys. -When iteration, no-progress, or token caps stop a Goal, the stop event records -the remaining actionable task keys as well. - -## Debug and Desktop Read Model - -Model-visible `task_list` / `task_get` results omit `resumeTrust`. Debug, export, -and trace/read-model surfaces may include task summaries with `resumeTrust`, -reasons, evidence, and refs. - -Desktop reads the same `Task[]` projection through `tasks:list`. Store changes -emit a signal-only `tasks:changed` event; the renderer reloads instead of -merging event payloads into a second projection. Before crossing IPC, every -structured Task DTO is sanitized with the same secret and task-tag redaction -rules used by model-visible text. The chat workspace shows a full-width, -collapsible, read-only task band with the active hierarchy, short keys, status, -owner, reason/evidence summary, and three recent terminal tasks. Session -switches clear the old snapshot and revision guards discard late IPC responses. -The panel provides loading, empty, error, and retry states, but no workflow -editing controls. - -This interactive task ledger remains separate from: - -- Eval experiments, cells, and attempts; -- Goal state, which owns bounded autonomous continuation; -- ScheduledTask, which owns scheduled execution; -- `AgentRun` / `RuntimeEvent`, which own actual runtime and evidence history. diff --git a/docs/session-todo-lifecycle.md b/docs/session-todo-lifecycle.md new file mode 100644 index 0000000000..2b34bb9394 --- /dev/null +++ b/docs/session-todo-lifecycle.md @@ -0,0 +1,174 @@ + + +# 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. + +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 +that list as the Session is read, copied, archived, or removed? + +## Mental model + +SessionTodo is one small current-state document attached to one Session. It is +closer to replacing a whiteboard checklist than appending to an audit log. +`todo_read` returns the whole ordered list; `todo_write` atomically replaces the +whole ordered list. + +For example, this write: + +```json +{ + "todos": [ + { "content": "inspect the owner", "status": "completed" }, + { "content": "run focused tests", "status": "in_progress" } + ] +} +``` + +commits exactly those two items in that order. A later write containing only +the second item removes the first. There is no item identity, patch operation, +revision, history, hierarchy, owner, evidence, cursor, or watermark. + +`completed` is model-reported progress. It is not proof that a command ran, a +test passed, or a file changed; AgentRun, RuntimeEvent, tool results, filesystem, +and git remain the authorities for those facts. + +## Authority and data flow + +The Runtime Host is the sole interactive authority. Storage owns the durable +document and migration transaction; Runtime exposes the model tools; CLI and +Desktop only render Host-owned results. + +```text +model todo_read / todo_write Desktop read-only panel + \ / + Runtime Host SessionTodo coordinator + | admission + Session presence + | commit, then signal-only invalidation + v + SQLite SessionTodo current document +``` + +`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. + +The stored document is canonical product state. Before model or Desktop +display, content passes through the shared Unicode sanitization, secret +redaction, and `` tag-neutralization projection. Display safety +does not rewrite the stored document. + +## Document contract + +Each item contains only: + +- `content`: non-empty normalized text, at most 200 Unicode code points; +- `status`: `pending`, `in_progress`, or `completed`. + +The document contains at most 200 items and at most 256 KiB of encoded JSON. +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. + +## One-time legacy bootstrap + +The first Host read of an uninitialized Session, through either `todo_read` or +`session.todo.query`, projects only canonical legacy Tasks whose status is +`pending` or `in_progress`, then persists the result even when it is empty. +Terminal, blocked, failed, cancelled, ownership, evidence, and hierarchy fields +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. + +## Copy and branch semantics + +Conversation copy initializes the target Todo inside the Host-owned copy +lifecycle, before the target Session is published: + +- an ordinary branch whose selected cut includes the latest committed turn + copies the source's current Todo; +- 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 idempotent; a different or corrupt existing target fails +closed instead of being overwritten. + +The Runtime Host holds the source and target Session admission lanes during the +copy. A failed copy purges the incomplete target's Todo state before discarding +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. +- 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. + +## Surface behavior + +CLI/TUI renders the settled semantic `todo_read` or `todo_write` tool result. +It does not present `todo_write` arguments as committed state, including when a +durable transcript is reconstructed after restart. + +Desktop renders the same current ordered snapshot as a flat read-only list. +Session and request-generation fences reject late responses after navigation; +signal-only invalidations trigger a fresh full read rather than client-side +merging. + +No SessionTodo content is appended to a turn-tail prompt or dynamic system +prompt. The model reads it on demand with `todo_read`. + +## Code map + +- `packages/core/src/session-todo.ts`: document validation, bounds, and shared + display projection. +- `packages/storage/src/session-todo-store.ts`: SQLite persistence, migration, + copy initialization, and purge. +- `packages/runtime-host/src/server/session-todo-coordinator.ts`: Session + admission, presence checks, commit, and invalidation. +- `packages/runtime/src/session-todo-tools.ts`: model-facing read and + whole-document write tools. +- `packages/runtime-host/src/server/session-revision-coordinator.ts` and + `session-retirement-coordinator.ts`: copy and lifecycle integration. +- `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. diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index b74ed7bdd2..1330205b82 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -4286,6 +4286,102 @@ describe('Maka Pi TUI transcript', () => { assert.doesNotMatch(rendered, /\(no output\)/); }); + test('keeps todo_write arguments quiet and shows only its settled snapshot', () => { + const state = createMakaPiTranscriptState(); + applyMakaSessionEventToTranscript( + state, + event({ + type: 'tool_start', + toolUseId: 'todo-write', + toolName: 'todo_write', + displayName: 'Todo Write', + args: undefined, + argsPreview: undefined, + }), + ); + + const running = renderMakaPiTranscript(state, meta(), 80).map(stripAnsi).join('\n'); + assert.match(running, /Todo Write/); + assert.doesNotMatch(running, /uncommitted item/); + + applyMakaSessionEventToTranscript( + state, + event({ + type: 'tool_result', + toolUseId: 'todo-write', + isError: false, + content: { + kind: 'text', + text: 'Todo list updated.\n1. [in_progress] committed item', + }, + }), + ); + + const settled = renderMakaPiTranscript(state, meta(), 80).map(stripAnsi).join('\n'); + assert.match(settled, /Todo Write/); + assert.match(settled, /2 lines/); + assert.equal(toggleAllToolExpansion(state), true); + assert.match( + renderMakaPiTranscript(state, meta(), 80).map(stripAnsi).join('\n'), + /committed item/, + ); + }); + + test('never restores todo_write arguments from durable transcript reconciliation', () => { + const messages = [ + { + type: 'tool_call', + id: 'todo-write', + turnId: 'turn-1', + ts: 1, + toolName: 'todo_write', + displayName: 'Todo Write', + args: { todos: [{ content: 'uncommitted item', status: 'pending' }] }, + }, + { + type: 'tool_result', + id: 'todo-result', + turnId: 'turn-1', + ts: 2, + toolUseId: 'todo-write', + isError: false, + content: { + kind: 'text', + text: 'Todo list updated (1 items):\n1. [in_progress] "committed item"', + }, + }, + ] satisfies StoredMessage[]; + + for (const reconcile of [ + (state: ReturnType) => + replaceTranscriptWithStoredMessages(state, messages), + (state: ReturnType) => { + applyMakaSessionEventToTranscript( + state, + event({ + type: 'tool_start', + toolUseId: 'todo-write', + toolName: 'todo_write', + displayName: 'Todo Write', + args: undefined, + }), + ); + hydrateToolsWithStoredMessages(state, 'turn-1', messages); + }, + ]) { + const state = createMakaPiTranscriptState(); + reconcile(state); + const tool = state.entries.find( + (entry) => entry.kind === 'tool' && entry.toolUseId === 'todo-write', + ); + assert.deepEqual(tool?.kind === 'tool' ? tool.input : undefined, {}); + assert.equal(toggleAllToolExpansion(state), true); + const rendered = renderMakaPiTranscript(state, meta(), 80).map(stripAnsi).join('\n'); + assert.match(rendered, /committed item/); + assert.doesNotMatch(rendered, /uncommitted item/); + } + }); + test('prefers a redacted runtime intent for a live compact row', () => { const state = createMakaPiTranscriptState(); applyMakaSessionEventToTranscript( diff --git a/packages/core/src/__tests__/session-todo.test.ts b/packages/core/src/__tests__/session-todo.test.ts index cbc5900ce1..29354cc657 100644 --- a/packages/core/src/__tests__/session-todo.test.ts +++ b/packages/core/src/__tests__/session-todo.test.ts @@ -23,6 +23,7 @@ import { SESSION_TODO_CONTENT_MAX_CHARS, SESSION_TODO_MAX_ITEMS, normalizeSessionTodoItems, + sessionTodoContentForDisplay, } from '../session-todo.js'; describe('SessionTodo document', () => { @@ -79,4 +80,16 @@ describe('SessionTodo document', () => { false, ); }); + + test('projects one shared display-safe value without changing stored normalization', () => { + const displayed = sessionTodoContentForDisplay( + 'deploy\u001b[31m \u001b]0;spoofed\u0007 \u202ereversed\u202c zero\u200bwidth sk-live-secret-token ', + ); + assert.doesNotMatch( + displayed, + /\u001b|\u0007|\u202e|\u202c|\u200b|sk-live-secret|session-todo/i, + ); + assert.match(displayed, /deploy/); + assert.match(displayed, /|\[redacted\]/); + }); }); diff --git a/packages/core/src/__tests__/tool-activity-args.test.ts b/packages/core/src/__tests__/tool-activity-args.test.ts index 1383e66a75..63cb3cb161 100644 --- a/packages/core/src/__tests__/tool-activity-args.test.ts +++ b/packages/core/src/__tests__/tool-activity-args.test.ts @@ -95,6 +95,15 @@ it('projects WriteStdin activity to a bounded human-readable input preview', () assert.equal(projectWriteStdinPermissionSummary(invalidSize).size, undefined); }); +it('never projects uncommitted todo_write arguments into activity history', () => { + assert.deepEqual( + projectToolActivityArgs('todo_write', { + todos: [{ content: 'uncommitted item', status: 'pending' }], + }), + {}, + ); +}); + it('projects ordered terminal actions without exposing encoded control bytes', () => { const args = { ref: 'maka://runtime/background-tasks/one', diff --git a/packages/core/src/__tests__/tool-quiet-preview.test.ts b/packages/core/src/__tests__/tool-quiet-preview.test.ts index a64ff365d9..49037bf1e1 100644 --- a/packages/core/src/__tests__/tool-quiet-preview.test.ts +++ b/packages/core/src/__tests__/tool-quiet-preview.test.ts @@ -145,14 +145,13 @@ describe('projectToolArgsPreview', () => { assert.ok(JSON.stringify(preview).length <= 2048); }); - it('excludes Task Ledger tools until their durable semantic projection owns identity', () => { - assert.equal(projectToolArgsPreview('task_create', { tasks: [{ subject: 'one' }] }), undefined); + it('never previews an uncommitted Todo replacement as current state', () => { assert.equal( - projectToolArgsPreview('task_update', { id: 'T1', status: 'completed' }), + projectToolArgsPreview('todo_write', { + todos: [{ content: 'one', status: 'pending' }], + }), undefined, ); - assert.equal(projectToolArgsPreview('task_list', { status: 'pending' }), undefined); - assert.equal(projectToolArgsPreview('task_get', { id: 'T1' }), undefined); }); it('does not accept forged question payloads from third-party tools', () => { diff --git a/packages/core/src/session-todo.ts b/packages/core/src/session-todo.ts index 0a2176c551..41eb6cdb8f 100644 --- a/packages/core/src/session-todo.ts +++ b/packages/core/src/session-todo.ts @@ -18,6 +18,8 @@ */ import { serializedByteLength } from './serialized-byte-length.js'; +import { redactSecrets } from './display-redaction.js'; +import { sanitizeUnicodeText } from './text-sanitize.js'; /** * SessionTodo is a current-state document, not an event ledger. Its bounds @@ -90,6 +92,31 @@ export function isSessionTodoStatus(value: unknown): value is SessionTodoStatus return typeof value === 'string' && (SESSION_TODO_STATUSES as readonly string[]).includes(value); } +/** Project stored Todo text into a shared display-safe surface value. */ +export function sessionTodoContentForDisplay(content: string): string { + let current = redactSecrets( + sanitizeUnicodeText(content, { + maxCodePoints: SESSION_TODO_CONTENT_MAX_CHARS, + truncatedSuffix: '', + }), + ); + const tag = /<\/?session-todo\b[^>]*>/gi; + for (;;) { + const next = current.replace(tag, ''); + if (next === current) return current.trim(); + current = next; + } +} + +export function projectSessionTodoItemsForDisplay( + items: readonly SessionTodoItem[], +): SessionTodoItem[] { + return items.map((item) => ({ + content: sessionTodoContentForDisplay(item.content), + status: item.status, + })); +} + function invalid(message: string): SessionTodoNormalizeResult { return { ok: false, message }; } diff --git a/packages/core/src/tool-activity-args.ts b/packages/core/src/tool-activity-args.ts index 42e4bd98df..d1d71f79a4 100644 --- a/packages/core/src/tool-activity-args.ts +++ b/packages/core/src/tool-activity-args.ts @@ -180,6 +180,9 @@ function isSafeProjectedInputText(text: string): boolean { } export function projectToolActivityArgs(toolName: string, args: unknown): unknown { + // A Todo replacement is only a proposal until its tool result settles. The + // durable transcript must not resurrect those args as committed state. + if (toolName === 'todo_write') return {}; if (toolName !== 'WriteStdin') return args; const parsed = readWriteStdinArgs(args); if (!parsed) return {}; diff --git a/packages/core/src/tool-quiet-preview.ts b/packages/core/src/tool-quiet-preview.ts index f3474dfb78..e2adb0d7b6 100644 --- a/packages/core/src/tool-quiet-preview.ts +++ b/packages/core/src/tool-quiet-preview.ts @@ -328,7 +328,7 @@ const ARGS_PREVIEW_MAX_CHARS = 2048; /** Question lists keep only their leading entries. */ const ARGS_PREVIEW_LIST_MAX_ITEMS = 4; -const TASK_LEDGER_TOOL_NAMES = new Set(['task_create', 'task_update', 'task_list', 'task_get']); +const COMMIT_RESULT_ONLY_TOOL_NAMES = new Set(['todo_write']); /** * Whitelist of scalar args keys {@link formatToolInvocationLine} can read, in @@ -435,10 +435,10 @@ export function projectToolArgsPreview( ): Record | undefined { const record = asRecord(args); if (!record) return undefined; - // Task rows need committed IDs and the exact Task Ledger mutation snapshot; - // args alone cannot identify the user-facing task reliably. Keep them out of - // the generic live preview until the Host-owned semantic timeline (#4179). - if (TASK_LEDGER_TOOL_NAMES.has(toolName)) return undefined; + // A Todo write's args are only a proposal. Showing them while the call is + // live would present uncommitted state as fact; the settled tool_result owns + // the complete committed snapshot. + if (COMMIT_RESULT_ONLY_TOOL_NAMES.has(toolName)) return undefined; // Apply the canonical activity projection first so WriteStdin's inputPreview // shape (bounded, display-safe) is what the whitelist picks up. diff --git a/packages/runtime-host/src/__tests__/connection-session.test.ts b/packages/runtime-host/src/__tests__/connection-session.test.ts index 648ff53acb..ff6b96769b 100644 --- a/packages/runtime-host/src/__tests__/connection-session.test.ts +++ b/packages/runtime-host/src/__tests__/connection-session.test.ts @@ -1493,7 +1493,7 @@ function createHandlers(queryTurn: TurnQueryHandler): RuntimeHostComposition['ha message: 'not available in this test composition', }, } as const; - const taskLedgerUnavailable: Awaited> = { + const sessionTodoUnavailable: Awaited> = { ok: false, error: { code: 'operation_unavailable', @@ -1529,7 +1529,7 @@ function createHandlers(queryTurn: TurnQueryHandler): RuntimeHostComposition['ha 'interaction.answer': async () => interactionUnavailable, 'subscription.open': async () => subscriptionUnavailable, 'subscription.close': async () => subscriptionUnavailable, - 'task.ledger.query': async () => taskLedgerUnavailable, + 'session.todo.query': async () => sessionTodoUnavailable, }; } 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 114f59d393..5ecbfee7e0 100644 --- a/packages/runtime-host/src/__tests__/execution-host-message.test.ts +++ b/packages/runtime-host/src/__tests__/execution-host-message.test.ts @@ -45,7 +45,6 @@ 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 { buildTaskLedgerTools } from '@maka/runtime/task-ledger-tools'; import { buildRecoveredTerminalRuntimeEvent, classifyTerminalRuntimeLedger, @@ -68,7 +67,6 @@ import { tryAcquireInteractiveRootReader, type StorageRootCapability, } from '@maka/storage/root-authority'; -import { openInteractiveTaskLedgerStoreForWrite } from '@maka/storage/task-ledger-authority'; import { connectRuntimeHost, RuntimeHostOperationError, @@ -79,17 +77,13 @@ import { import { decodeHostFrame, RUNTIME_HOST_PROTOCOL_VERSION, - TASK_LEDGER_PAGE_MAX_ITEMS, type ConnectionCatalogQueryResult, type InteractionPendingSnapshot, type SubscriptionFrame, - type TaskLedgerQueryResult, - type TaskLedgerRevision, type TurnMessageSubmitInput, type TurnSnapshot, } from '../protocol/index.js'; import { SessionAdmissionGate } from '../server/session-admission-gate.js'; -import { HostTaskLedgerCoordinator } from '../server/task-ledger-coordinator.js'; import { FramedTransport } from '../transport/framed-transport.js'; 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 b1174307fb..f5186466d4 100644 --- a/packages/runtime-host/src/__tests__/execution-host-queue.test.ts +++ b/packages/runtime-host/src/__tests__/execution-host-queue.test.ts @@ -45,7 +45,6 @@ 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 { buildTaskLedgerTools } from '@maka/runtime/task-ledger-tools'; import { buildRecoveredTerminalRuntimeEvent, classifyTerminalRuntimeLedger, @@ -69,7 +68,6 @@ import { tryAcquireInteractiveRootReader, type StorageRootCapability, } from '@maka/storage/root-authority'; -import { openInteractiveTaskLedgerStoreForWrite } from '@maka/storage/task-ledger-authority'; import { connectRuntimeHost, RuntimeHostOperationError, @@ -80,18 +78,14 @@ import { import { decodeHostFrame, RUNTIME_HOST_PROTOCOL_VERSION, - TASK_LEDGER_PAGE_MAX_ITEMS, type ConnectionCatalogQueryResult, type InteractionPendingSnapshot, type SubscriptionFrame, - type TaskLedgerQueryResult, - type TaskLedgerRevision, type TurnMessageSubmitInput, type TurnSnapshot, type TurnStartResult, } from '../protocol/index.js'; import { SessionAdmissionGate } from '../server/session-admission-gate.js'; -import { HostTaskLedgerCoordinator } from '../server/task-ledger-coordinator.js'; import { FramedTransport } from '../transport/framed-transport.js'; 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 15be5e471f..ae20e64aee 100644 --- a/packages/runtime-host/src/__tests__/execution-host-recovery.test.ts +++ b/packages/runtime-host/src/__tests__/execution-host-recovery.test.ts @@ -36,7 +36,6 @@ 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 { buildTaskLedgerTools } from '@maka/runtime/task-ledger-tools'; import { buildRecoveredTerminalRuntimeEvent, classifyTerminalRuntimeLedger, @@ -59,7 +58,6 @@ import { tryAcquireInteractiveRootReader, type StorageRootCapability, } from '@maka/storage/root-authority'; -import { openInteractiveTaskLedgerStoreForWrite } from '@maka/storage/task-ledger-authority'; import { connectRuntimeHost, RuntimeHostOperationError, @@ -70,17 +68,13 @@ import { import { decodeHostFrame, RUNTIME_HOST_PROTOCOL_VERSION, - TASK_LEDGER_PAGE_MAX_ITEMS, type ConnectionCatalogQueryResult, type InteractionPendingSnapshot, type SubscriptionFrame, - type TaskLedgerQueryResult, - type TaskLedgerRevision, type TurnMessageSubmitInput, type TurnSnapshot, } from '../protocol/index.js'; import { SessionAdmissionGate } from '../server/session-admission-gate.js'; -import { HostTaskLedgerCoordinator } from '../server/task-ledger-coordinator.js'; import { FramedTransport } from '../transport/framed-transport.js'; import { diff --git a/packages/runtime-host/src/__tests__/execution-host.test.ts b/packages/runtime-host/src/__tests__/execution-host.test.ts index ba1cf2dc2b..9e3705c332 100644 --- a/packages/runtime-host/src/__tests__/execution-host.test.ts +++ b/packages/runtime-host/src/__tests__/execution-host.test.ts @@ -46,11 +46,11 @@ import { type StoredMessage, } from '@maka/core/session'; import { markPersisted } from '@maka/core/persisted-value'; -import type { Task } from '@maka/core/task-ledger'; +import type { SessionTodoItem } from '@maka/core/session-todo'; import type { ScheduledTask } from '@maka/core/scheduled-task'; import { isTerminalRuntimeEvent } from '@maka/core/runtime-event'; import type { RuntimeEvent } from '@maka/core/runtime-event'; -import { buildTaskLedgerTools } from '@maka/runtime/task-ledger-tools'; +import { buildSessionTodoTools } from '@maka/runtime/session-todo-tools'; import { buildRecoveredTerminalRuntimeEvent, classifyTerminalRuntimeLedger, @@ -74,7 +74,7 @@ import { tryAcquireInteractiveRootReader, type StorageRootCapability, } from '@maka/storage/root-authority'; -import { openInteractiveTaskLedgerStoreForWrite } from '@maka/storage/task-ledger-authority'; +import { openInteractiveSessionTodoStoreForWrite } from '@maka/storage/session-todo-authority'; import { connectRuntimeHost, RuntimeHostOperationError, @@ -85,17 +85,14 @@ import { import { decodeHostFrame, RUNTIME_HOST_PROTOCOL_VERSION, - TASK_LEDGER_PAGE_MAX_ITEMS, type ConnectionCatalogQueryResult, type InteractionPendingSnapshot, type SubscriptionFrame, - type TaskLedgerQueryResult, - type TaskLedgerRevision, type TurnMessageSubmitInput, type TurnSnapshot, } from '../protocol/index.js'; import { SessionAdmissionGate } from '../server/session-admission-gate.js'; -import { HostTaskLedgerCoordinator } from '../server/task-ledger-coordinator.js'; +import { HostSessionTodoCoordinator } from '../server/session-todo-coordinator.js'; import { FramedTransport } from '../transport/framed-transport.js'; import { @@ -240,137 +237,50 @@ test('production Host settles dispatched Client Capabilities before publishing R }); }); -test('dual UDS Clients query persisted Task Ledger tool-port mutations across Host restart', async () => { +test('dual UDS Clients query the same persisted SessionTodo snapshot across Host restart', async () => { await withExecutionRoot(async (fixture) => { - const initialRunId = randomUUID(); - const initialTurnId = randomUUID(); - // Exercise the Runtime-facing port before Host startup; Hosted tool composition is separate. - const toolPortProjection = await withOwnedTaskLedgerToolPort( - fixture, - async (coordinator, tools) => { - const context = taskLedgerToolContext(fixture, { - runId: initialRunId, - turnId: initialTurnId, - toolCallId: randomUUID(), - }); - const create = requireTaskLedgerTool(tools, 'task_create'); - const createInput = create.parameters.parse({ - tasks: Array.from({ length: TASK_LEDGER_PAGE_MAX_ITEMS + 1 }, (_, index) => ({ - subject: `Authority acceptance task ${index + 1}`, - })), - }); - await create.impl(createInput, context); - - const update = requireTaskLedgerTool(tools, 'task_update'); - const updateInput = update.parameters.parse({ id: 'T1', status: 'in_progress' }); - await update.impl(updateInput, { - ...context, - toolCallId: randomUUID(), - }); - return coordinator.list(fixture.sessionId, { - includeTerminal: true, - includeArchived: false, - classifyResumeTrust: true, - }); - }, - ); - assert.equal(toolPortProjection.length, TASK_LEDGER_PAGE_MAX_ITEMS + 1); - assert.deepEqual(toolPortProjection[0]?.owner, { - actor: 'main_agent', - runId: initialRunId, - turnId: initialTurnId, + const initial: SessionTodoItem[] = Array.from({ length: 129 }, (_, index) => ({ + content: `Authority acceptance todo ${index + 1}`, + status: index === 0 ? 'in_progress' : 'pending', + })); + await withOwnedSessionTodoToolPort(fixture, async (_coordinator, tools) => { + const write = requireSessionTodoWriteTool(tools); + await write.impl(write.parameters.parse({ todos: initial }), sessionTodoToolContext(fixture)); }); const host = await fixture.startHost(); const desktop = await connectClient(fixture.root); const tui = await connectClient(fixture.root); - let staleContinuation: - | { - revision: TaskLedgerRevision; - cursor: string; - task: Task; - } - | undefined; try { - const desktopProjection = await collectTaskLedgerProjection(desktop, fixture.sessionId); - const tuiProjection = await collectTaskLedgerProjection(tui, fixture.sessionId); - assert.deepEqual( - desktopProjection.pages.map((page) => page.tasks.length), - [TASK_LEDGER_PAGE_MAX_ITEMS, 1], - ); + const [desktopProjection, tuiProjection] = await Promise.all([ + desktop.request('session.todo.query', { sessionId: fixture.sessionId }), + tui.request('session.todo.query', { sessionId: fixture.sessionId }), + ]); + assert.deepEqual(desktopProjection, { sessionId: fixture.sessionId, items: initial }); assert.deepEqual(tuiProjection, desktopProjection); - assert.deepEqual(desktopProjection.tasks, toolPortProjection); - - const byKey = await tui.request('task.ledger.query', { - kind: 'get', - sessionId: fixture.sessionId, - taskRef: 'T1', - }); - assert.equal(byKey.kind, 'task'); - if (byKey.kind !== 'task') throw new Error('Expected Task Ledger get result'); - assert.equal(byKey.sessionId, fixture.sessionId); - assert.deepEqual(byKey.task, desktopProjection.tasks[0]); - assert.equal(byKey.task?.owner?.runId, initialRunId); - assert.equal(byKey.task?.owner?.turnId, initialTurnId); - - const firstPage = desktopProjection.pages[0]; - assert.ok(firstPage?.nextCursor); - staleContinuation = { - revision: firstPage.revision, - cursor: firstPage.nextCursor, - task: desktopProjection.tasks[1]!, - }; } finally { await Promise.allSettled([desktop.close(), tui.close()]); await fixture.stopHost(host); } - assert.ok(staleContinuation); - const { revision: staleRevision, cursor: staleCursor, task: taskToChange } = staleContinuation; - const successorTurnId = randomUUID(); - const changedSubject = `${taskToChange.subject} after authority reacquisition`; - await withOwnedTaskLedgerToolPort(fixture, async (_coordinator, tools) => { - const update = requireTaskLedgerTool(tools, 'task_update'); - const input = update.parameters.parse({ - id: taskToChange.key, - subject: changedSubject, - }); - await update.impl( - input, - taskLedgerToolContext(fixture, { - runId: randomUUID(), - turnId: successorTurnId, - toolCallId: randomUUID(), - }), - ); + const changed = [ + { content: 'Changed after authority reacquisition', status: 'completed' }, + ] as const; + await withOwnedSessionTodoToolPort(fixture, async (_coordinator, tools) => { + const write = requireSessionTodoWriteTool(tools); + await write.impl(write.parameters.parse({ todos: changed }), sessionTodoToolContext(fixture)); }); const successorHost = await fixture.startHost(); const successor = await connectClient(fixture.root); try { - const continued = await successor.request('task.ledger.query', { - kind: 'list_continue', - sessionId: fixture.sessionId, - revision: staleRevision, - cursor: staleCursor, - }); - assert.equal(continued.kind, 'revision_changed'); - if (continued.kind !== 'revision_changed') { - throw new Error('Expected stale Task Ledger continuation to report revision_changed'); - } - assert.equal(continued.expected, staleRevision); - assert.notEqual(continued.actual, staleRevision); - - const changed = await successor.request('task.ledger.query', { - kind: 'get', - sessionId: fixture.sessionId, - taskRef: taskToChange.key, - }); - assert.equal(changed.kind, 'task'); - if (changed.kind !== 'task') throw new Error('Expected changed Task Ledger task result'); - assert.equal(changed.sessionId, fixture.sessionId); - assert.equal(changed.task?.subject, changedSubject); - assert.equal(changed.revision, continued.actual); + assert.deepEqual( + await successor.request('session.todo.query', { sessionId: fixture.sessionId }), + { + sessionId: fixture.sessionId, + items: changed, + }, + ); } finally { await successor.close(); await fixture.stopHost(successorHost); @@ -1213,108 +1123,52 @@ test('two UDS Clients settle one hosted sandbox boundary and resume its exact Ru }); }); -interface TaskCreateInput { - tasks: Array<{ subject: string; parent_id?: string }>; -} - -interface TaskUpdateInput { - id: string; - status?: 'pending' | 'in_progress' | 'blocked' | 'completed' | 'failed' | 'cancelled'; - subject?: string; - blockedReason?: string; - failureReason?: string; - completionEvidence?: string; - explicitReopen?: boolean; -} - -type TaskLedgerPage = Extract; -type TaskLedgerTool = MakaTool & { - parameters: { parse(value: unknown): Input }; +type SessionTodoWriteTool = MakaTool<{ todos: SessionTodoItem[] }, string> & { + parameters: { parse(value: unknown): { todos: SessionTodoItem[] } }; }; -async function withOwnedTaskLedgerToolPort( +async function withOwnedSessionTodoToolPort( fixture: ExecutionFixture, - run: (coordinator: HostTaskLedgerCoordinator, tools: MakaTool[]) => Promise, + run: (coordinator: HostSessionTodoCoordinator, tools: MakaTool[]) => Promise, ): Promise { const owner = await tryAcquireInteractiveRootOwner(fixture.capability); assert.ok(owner); - if (!owner) throw new Error('Unable to acquire the interactive Task Ledger tool port'); - let writer: Awaited> | undefined; + if (!owner) throw new Error('Unable to acquire the interactive SessionTodo tool port'); + let writer: Awaited> | undefined; try { - writer = await openInteractiveTaskLedgerStoreForWrite(owner.lease); - const coordinator = new HostTaskLedgerCoordinator(writer, new SessionAdmissionGate(), { - probeSessionRemoval: async () => ({ kind: 'present' }), - }); - return await run(coordinator, buildTaskLedgerTools({ store: coordinator })); + writer = await openInteractiveSessionTodoStoreForWrite(owner.lease); + const coordinator = new HostSessionTodoCoordinator( + writer, + new SessionAdmissionGate(), + { probeSessionRemoval: async () => ({ kind: 'present' }) }, + () => {}, + () => {}, + ); + return await run(coordinator, buildSessionTodoTools(coordinator)); } finally { writer?.close(); await owner.close(); } } -function requireTaskLedgerTool( - tools: readonly MakaTool[], - name: 'task_create' | 'task_update', -): TaskLedgerTool { - const tool = tools.find((candidate) => candidate.name === name); - assert.ok(tool, `Expected ${name} Runtime tool`); - return tool as TaskLedgerTool; +function requireSessionTodoWriteTool(tools: readonly MakaTool[]): SessionTodoWriteTool { + const tool = tools.find((candidate) => candidate.name === 'todo_write'); + assert.ok(tool, 'Expected todo_write Runtime tool'); + return tool as SessionTodoWriteTool; } -function taskLedgerToolContext( - fixture: ExecutionFixture, - identity: Pick, -): MakaToolContext { +function sessionTodoToolContext(fixture: ExecutionFixture): MakaToolContext { return { sessionId: fixture.sessionId, cwd: fixture.root, - ...identity, + runId: randomUUID(), + turnId: randomUUID(), + toolCallId: randomUUID(), abortSignal: new AbortController().signal, emitOutput: () => {}, }; } -async function collectTaskLedgerProjection( - client: RuntimeHostConnection, - sessionId: string, -): Promise<{ - revision: TaskLedgerRevision; - pages: TaskLedgerPage[]; - tasks: Task[]; -}> { - const pages: TaskLedgerPage[] = []; - let result = await client.request('task.ledger.query', { - kind: 'list_start', - sessionId, - }); - assert.equal(result.kind, 'page'); - if (result.kind !== 'page') throw new Error('Expected initial Task Ledger page'); - const revision = result.revision; - - while (true) { - assert.equal(result.sessionId, sessionId); - assert.equal(result.revision, revision); - pages.push(result); - if (result.nextCursor === null) break; - result = await client.request('task.ledger.query', { - kind: 'list_continue', - sessionId, - revision, - cursor: result.nextCursor, - }); - assert.equal(result.kind, 'page'); - if (result.kind !== 'page') { - throw new Error('Task Ledger changed while collecting a stable projection'); - } - } - - return { - revision, - pages, - tasks: pages.flatMap((page) => page.tasks), - }; -} - async function waitForScheduledTaskCompletion( client: RuntimeHostConnection, taskId: string, diff --git a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts index 5d7b8eeaeb..a687759586 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -44,7 +44,7 @@ import { type ModelCallAttempt, type ModelCallKind } from '@maka/core/model-call import { type RuntimeEvent } from '@maka/core/runtime-event'; import { createDefaultRuntimePolicy } from '@maka/core/runtime-policy'; import type { PlanSessionState, PlanStore } from '@maka/core/plan'; -import type { TaskLedgerStore } from '@maka/core/task-ledger'; +import type { SessionTodoToolStore } from '@maka/runtime/session-todo-tools'; import { serializeOAuthSubscriptionTokens, type OAuthSubscriptionTokens, @@ -75,7 +75,7 @@ import { type RuntimePolicyStoresWriter, } from '@maka/storage/runtime-policy-stores'; import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '@maka/storage/root-authority'; -import { openInteractiveTaskLedgerStoreForWrite } from '@maka/storage/task-ledger-authority'; +import { openInteractiveSessionTodoStoreForWrite } from '@maka/storage/session-todo-authority'; import { openInteractiveUsageStoresForWrite, type InteractiveUsageStoresWriter, @@ -1806,8 +1806,10 @@ test('production Host executes a canonical ai-sdk Session against a real provide model: MODEL_ID, permissionMode: 'ask', }); - const taskLedger = await openInteractiveTaskLedgerStoreForWrite(owner.lease); - await taskLedger.create(session.id, [{ subject: 'HOSTED_TASK_LEDGER_SENTINEL' }]); + const sessionTodo = await openInteractiveSessionTodoStoreForWrite(owner.lease); + await sessionTodo.replaceAll(session.id, [ + { content: 'HOSTED_SESSION_TODO_SENTINEL', status: 'pending' }, + ]); composition = await createExecutionRuntimeHostComposition( { @@ -1907,7 +1909,7 @@ test('production Host executes a canonical ai-sdk Session against a real provide assert.match(requestText, /HOSTED_SKILL_DESCRIPTION_SENTINEL/); assert.doesNotMatch(requestText, /HOSTED_SKILL_BODY_MUST_STAY_LAZY/); assert.match(requestText, /HOSTED_WORKSPACE_SENTINEL/); - assert.doesNotMatch(requestText, /HOSTED_TASK_LEDGER_SENTINEL/); + assert.doesNotMatch(requestText, /HOSTED_SESSION_TODO_SENTINEL/); assert.match(requestText, /HOSTED_PERSONALIZATION_SENTINEL/); assert.match(requestText, /HOSTED_MEMORY_SENTINEL/); assert.match(JSON.stringify(mainRequests[1]?.body), /HOSTED_SKILL_BODY_MUST_STAY_LAZY/); @@ -3282,7 +3284,7 @@ test('one turn shares one canonical Skill inventory across prompt and lazy tools runtimePolicy: policy, skills, memory, - taskLedger: {} as TaskLedgerStore, + sessionTodo: {} as SessionTodoToolStore, }); const firstContext = { sessionId: 'session', @@ -3368,7 +3370,7 @@ test('one composer freezes Runtime Policy while each Run freezes its remaining p body: memoryBody, }), } as unknown as HostMemoryCoordinator, - taskLedger: {} as TaskLedgerStore, + sessionTodo: {} as SessionTodoToolStore, }); const context = { sessionId: 'session', @@ -3419,7 +3421,7 @@ test('one composer freezes Runtime Policy while each Run freezes its remaining p body: memoryBody, }), } as unknown as HostMemoryCoordinator, - taskLedger: {} as TaskLedgerStore, + sessionTodo: {} as SessionTodoToolStore, }); assert.deepEqual( (await nextComposition.resolveSystemPrompt({ ...context, turnId: 'turn-3' })).sourceRevisions, @@ -3469,7 +3471,7 @@ test('backend composition survives a moved saved Git Bash executable while Bash body: '', }), } as unknown as HostMemoryCoordinator, - taskLedger: { list: async () => [] } as unknown as TaskLedgerStore, + sessionTodo: {} as SessionTodoToolStore, clientCapabilities: { snapshotForSession: () => undefined, } as unknown as HostClientCapabilityCoordinator, @@ -3536,7 +3538,6 @@ test('backend composition survives a moved saved Git Bash executable while Bash }, }; const capturedChildTools = createHostChildAgentToolComposition({ - taskLedger: {} as TaskLedgerStore, builtinTools: { shell: capturedChildShell }, hostTools: [], worktreePatchWriteBackAvailable: true, @@ -3572,7 +3573,6 @@ test('child execution Bash carries the configured shell guidance and spawn plan' }, }; const composition = createHostChildAgentToolComposition({ - taskLedger: {} as TaskLedgerStore, builtinTools: { shell, shellRuns: { @@ -3648,7 +3648,7 @@ test('a bound tool ceiling excludes dynamic Client Capability tools', () => { readCanonicalModelInventory: async () => ({ inventory: [] }), } as unknown as HostSkillCatalogCoordinator, memory: {} as HostMemoryCoordinator, - taskLedger: {} as TaskLedgerStore, + sessionTodo: {} as SessionTodoToolStore, boundTools: [boundTool], parentAgentTools: buildParentAgentTools(), scheduledTaskTool, @@ -3685,7 +3685,7 @@ test('the headless coding profile freezes the Eval prompt and tool ceiling', asy throw new Error('Profiled prompt must not read product Memory'); }, } as unknown as HostMemoryCoordinator, - taskLedger: {} as TaskLedgerStore, + sessionTodo: {} as SessionTodoToolStore, builtinTools: {}, toolProfile: 'headless-coding-v1', parentAgentTools: buildParentAgentTools(), @@ -3962,7 +3962,7 @@ function backendCreationFixture(input: { body: '', }), } as unknown as HostMemoryCoordinator, - taskLedger: { list: async () => [] } as unknown as TaskLedgerStore, + sessionTodo: {} as SessionTodoToolStore, clientCapabilities: { snapshotForSession: input.snapshotClientCapabilities ?? (() => undefined), } as unknown as HostClientCapabilityCoordinator, 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 641299cfb6..975b130a78 100644 --- a/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts +++ b/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts @@ -52,7 +52,6 @@ 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'; -import { buildTaskLedgerTools } from '@maka/runtime/task-ledger-tools'; import { buildRecoveredTerminalRuntimeEvent, classifyTerminalRuntimeLedger, @@ -77,7 +76,6 @@ import { tryAcquireInteractiveRootReader, type StorageRootCapability, } from '@maka/storage/root-authority'; -import { openInteractiveTaskLedgerStoreForWrite } from '@maka/storage/task-ledger-authority'; import { resolveWorkspaceIdentity } from '@maka/storage/workspace-identity'; import { connectRuntimeHost, @@ -93,19 +91,15 @@ import { encodeProtocolMessage, RUNTIME_HOST_COMPATIBILITY_EPOCH, RUNTIME_HOST_PROTOCOL_VERSION, - TASK_LEDGER_PAGE_MAX_ITEMS, type ClientFrame, type ConnectionCatalogQueryResult, type InteractionPendingSnapshot, type SubscriptionFrame, - type TaskLedgerQueryResult, - type TaskLedgerRevision, type TurnMessageSubmitInput, type TurnSnapshot, type TurnStartResult, } from '../../protocol/index.js'; import { SessionAdmissionGate } from '../../server/session-admission-gate.js'; -import { HostTaskLedgerCoordinator } from '../../server/task-ledger-coordinator.js'; import { continuationSafetyDigest } from '../../server/root-turn-coordinator.js'; import { FramedTransport } from '../../transport/framed-transport.js'; import { removePosixEndpointDirectories } from './endpoint-hygiene.js'; diff --git a/packages/runtime-host/src/__tests__/fixtures/uncooperative-host.ts b/packages/runtime-host/src/__tests__/fixtures/uncooperative-host.ts index efd8be6bcb..0b1c2ea486 100644 --- a/packages/runtime-host/src/__tests__/fixtures/uncooperative-host.ts +++ b/packages/runtime-host/src/__tests__/fixtures/uncooperative-host.ts @@ -92,7 +92,7 @@ const host = await RuntimeHostKernel.start({ ok: false, error: { code: 'operation_unavailable', message: 'Operation unavailable in test Host' }, }), - 'task.ledger.query': async () => ({ + 'session.todo.query': async () => ({ ok: false, error: { code: 'operation_unavailable', message: 'Operation unavailable in test Host' }, }), diff --git a/packages/runtime-host/src/__tests__/goal-coordinator.test.ts b/packages/runtime-host/src/__tests__/goal-coordinator.test.ts index e57d82ad12..09b7ea9953 100644 --- a/packages/runtime-host/src/__tests__/goal-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/goal-coordinator.test.ts @@ -85,7 +85,6 @@ test('one Host Goal is shared across clients with CAS control and crash-clear re start: () => goalTurn, }; }, - listActionableTaskKeys: async () => [], acquireResidency: () => { acquired++; return { release: () => released++ }; @@ -215,7 +214,6 @@ test('one Host Goal is shared across clients with CAS control and crash-clear re kind: 'unavailable', reason: 'Recovery assertion only', }), - listActionableTaskKeys: async () => [], acquireResidency: () => ({ release() {} }), onProjectionChanged: () => {}, requestDrain: () => {}, @@ -286,7 +284,6 @@ test('session retirement forgets a terminal Goal without recreating deleted auth close: async () => {}, }, admitTurn: () => assert.fail('A terminal Goal must not admit a continuation'), - listActionableTaskKeys: async () => [], acquireResidency: () => assert.fail('A terminal Goal must not retain Host residency'), onProjectionChanged: (sessionId) => projectionChanges.push(sessionId), requestDrain: () => drainRequests++, @@ -408,7 +405,6 @@ test('restart settles the durable current Goal execution through Hosted Executio close: async () => {}, }, admitTurn: () => assert.fail('A terminal recovered execution must not be admitted again'), - listActionableTaskKeys: async () => [], acquireResidency: () => ({ release() {} }), onProjectionChanged: () => {}, requestDrain: () => { @@ -498,7 +494,6 @@ test('restart replaces a stale current execution with the current durable Goal i recoveredIntent = true; return { kind: 'unavailable', reason: 'Recovery assertion only' }; }, - listActionableTaskKeys: async () => [], acquireResidency: () => ({ release() {} }), onProjectionChanged: () => {}, requestDrain: () => {}, @@ -584,7 +579,6 @@ test('goal.arm creates one Goal per Session and refuses a second while it is unf }, // Arming schedules nothing: the Goal takes hold on the next Turn. admitTurn: () => assert.fail('Arming must not admit a continuation Turn'), - listActionableTaskKeys: async () => [], acquireResidency: () => ({ release: () => {} }), onProjectionChanged: () => {}, requestDrain: () => {}, @@ -698,7 +692,6 @@ test('a Goal armed but never carried by a Turn does not start itself after a res close: async () => {}, }, admitTurn: () => assert.fail('Arming must not admit a continuation Turn'), - listActionableTaskKeys: async () => [], acquireResidency: () => ({ release: () => {} }), onProjectionChanged: () => {}, requestDrain: () => {}, @@ -744,7 +737,6 @@ test('a Goal armed but never carried by a Turn does not start itself after a res admitted = true; return { kind: 'unavailable', reason: 'Recovery assertion only' }; }, - listActionableTaskKeys: async () => [], acquireResidency: () => ({ release: () => {} }), onProjectionChanged: () => {}, requestDrain: () => {}, @@ -811,7 +803,6 @@ test('resuming an armed Goal drives it, and a restart puts that drive back', asy admitted += 1; return { kind: 'busy', whenIdle: new Promise(() => {}) }; }, - listActionableTaskKeys: async () => [], acquireResidency: () => ({ release: () => {} }), onProjectionChanged: () => {}, requestDrain: () => {}, @@ -895,7 +886,6 @@ test('resuming an armed Goal drives it, and a restart puts that drive back', asy admittedAfterRestart += 1; return { kind: 'busy', whenIdle: new Promise(() => {}) }; }, - listActionableTaskKeys: async () => [], acquireResidency: () => ({ release: () => {} }), onProjectionChanged: () => {}, requestDrain: () => {}, @@ -947,7 +937,6 @@ test('an arm admitted before the drain creates no Goal after it', async () => { close: async () => {}, }, admitTurn: () => assert.fail('A refused arm must not admit a Turn'), - listActionableTaskKeys: async () => [], acquireResidency: () => ({ release: () => {} }), onProjectionChanged: () => {}, requestDrain: () => {}, diff --git a/packages/runtime-host/src/__tests__/goal-root-authority.test.ts b/packages/runtime-host/src/__tests__/goal-root-authority.test.ts index 48b47811e4..fcbc66a1a3 100644 --- a/packages/runtime-host/src/__tests__/goal-root-authority.test.ts +++ b/packages/runtime-host/src/__tests__/goal-root-authority.test.ts @@ -675,7 +675,6 @@ async function createFixture(options: { recoverAdmissions?: boolean } = {}): Pro }, admitTurn: (sessionId, text, checkpoint, controlLease) => goalExecutions.admitTurn(sessionId, text, checkpoint, controlLease), - listActionableTaskKeys: async () => [], acquireResidency, onProjectionChanged: (sessionId) => { requireContinuity(continuity).enqueueCanonicalRefresh(sessionId); diff --git a/packages/runtime-host/src/__tests__/interactive-run-composer.test.ts b/packages/runtime-host/src/__tests__/interactive-run-composer.test.ts index 6c1338603b..c2bac3e5bc 100644 --- a/packages/runtime-host/src/__tests__/interactive-run-composer.test.ts +++ b/packages/runtime-host/src/__tests__/interactive-run-composer.test.ts @@ -20,7 +20,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { createDefaultRuntimePolicy } from '@maka/core/runtime-policy'; -import type { TaskLedgerStore } from '@maka/core/task-ledger'; +import type { SessionTodoToolStore } from '@maka/runtime/session-todo-tools'; import type { MakaTool } from '@maka/runtime/tool-runtime'; import { createInteractiveRunComposer } from '../server/interactive-run-composer.js'; import type { HostMemoryCoordinator } from '../server/memory-coordinator.js'; @@ -65,7 +65,7 @@ function createFixtureComposer( readCanonicalModelInventory: async () => ({ inventory: [] }), } as unknown as HostSkillCatalogCoordinator, memory: {} as HostMemoryCoordinator, - taskLedger: {} as TaskLedgerStore, + sessionTodo: {} as SessionTodoToolStore, builtinTools: {}, ...overrides, }); 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 e80ebd5f0a..aa3e5c6784 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 @@ -27,6 +27,7 @@ import { connect, type Socket } from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; +import { DatabaseSync } from 'node:sqlite'; import { DEEP_RESEARCH_SESSION_LABEL, DEEP_RESEARCH_SESSION_NAME } from '@maka/core/deep-research'; import { openInteractiveArtifactStoreForWrite } from '@maka/storage/artifact-stores'; import { openInteractiveExecutionStoresForWrite } from '@maka/storage/execution-stores'; @@ -37,7 +38,7 @@ import { tryAcquireInteractiveRootOwner, type StorageRootCapability, } from '@maka/storage/root-authority'; -import { openInteractiveTaskLedgerStoreForWrite } from '@maka/storage/task-ledger-authority'; +import { openInteractiveSessionTodoStoreForWrite } from '@maka/storage/session-todo-authority'; import { connectRuntimeHost, readRuntimeHostConnectionCatalog, @@ -511,6 +512,10 @@ test('two Clients share stable Session creation, CAS configuration, and catalog ); assert.equal(archived.isArchived, true); assert.equal(archived.status, beforeArchive.status); + assert.deepEqual( + (await desktop.request('session.todo.query', { sessionId: created.id })).items, + [{ content: 'Retain archived task', status: 'in_progress' }], + ); assert.equal((await querySession(tui, created.id)).isArchived, true); const archivedContinuity = await nextProjection(retirementIterator); assert.equal(archivedContinuity.snapshot.session.isArchived, true); @@ -525,6 +530,9 @@ test('two Clients share stable Session creation, CAS configuration, and catalog ); assert.equal(restored.isArchived, false); assert.equal(restored.status, beforeArchive.status); + assert.deepEqual((await tui.request('session.todo.query', { sessionId: created.id })).items, [ + { content: 'Retain archived task', status: 'in_progress' }, + ]); const restoredContinuity = await nextProjection(retirementIterator); assert.equal(restoredContinuity.snapshot.session.isArchived, false); assert.equal(restoredContinuity.snapshot.session.status, beforeArchive.status); @@ -579,15 +587,10 @@ test('two Clients share stable Session creation, CAS configuration, and catalog assert.fail('Retirement Artifact must be readable before Session removal'); } assert.equal(artifactBeforeRemoval.artifact?.id, 'retirement-artifact'); - const tasksBeforeRemoval = await tui.request('task.ledger.query', { - kind: 'list_start', + const todoBeforeRemoval = await tui.request('session.todo.query', { sessionId: retirementSessionId, }); - assert.equal(tasksBeforeRemoval.kind, 'page'); - if (tasksBeforeRemoval.kind !== 'page') { - assert.fail('Retirement Task Ledger must be readable before Session removal'); - } - assert.equal(tasksBeforeRemoval.tasks.length, 1); + assert.equal(todoBeforeRemoval.items.length, 1); assert.deepEqual( await desktop.request('session.remove', { @@ -606,10 +609,7 @@ test('two Clients share stable Session creation, CAS configuration, and catalog operationError('not_found'), ); await assert.rejects( - connection.request('task.ledger.query', { - kind: 'list_start', - sessionId: retirementSessionId, - }), + connection.request('session.todo.query', { sessionId: retirementSessionId }), operationError('not_found'), ); } @@ -622,10 +622,7 @@ test('two Clients share stable Session creation, CAS configuration, and catalog operationError('not_found'), ); await assert.rejects( - tui.request('task.ledger.query', { - kind: 'list_start', - sessionId: recoverySessionId, - }), + tui.request('session.todo.query', { sessionId: recoverySessionId }), operationError('not_found'), ); } finally { @@ -889,7 +886,7 @@ async function seedAuthority( permissionMode: 'ask', }); const artifacts = await openInteractiveArtifactStoreForWrite(owner.lease); - const tasks = await openInteractiveTaskLedgerStoreForWrite(owner.lease); + const todos = await openInteractiveSessionTodoStoreForWrite(owner.lease); await artifacts.recover(); await Promise.all([ artifacts.create({ @@ -914,8 +911,13 @@ async function seedAuthority( source: 'fixture', now: 2, }), - tasks.create(retirement.id, [{ subject: 'Remove retirement task' }]), - tasks.create(recovery.id, [{ subject: 'Recover retirement task cleanup' }]), + todos.replaceAll(retirement.id, [{ content: 'Remove retirement task', status: 'pending' }]), + todos.replaceAll('stable-session', [ + { content: 'Retain archived task', status: 'in_progress' }, + ]), + todos.replaceAll(recovery.id, [ + { content: 'Recover retirement task cleanup', status: 'pending' }, + ]), ]); const retirementSnapshot = await execution.sessionStore.readHeaderRecordSnapshot(retirement.id); await execution.sessionStore.remove(recovery.id); @@ -994,12 +996,31 @@ async function assertRetirementCleanup( try { const execution = await openInteractiveExecutionStoresForWrite(owner.lease); const artifacts = await openInteractiveArtifactStoreForWrite(owner.lease); - const tasks = await openInteractiveTaskLedgerStoreForWrite(owner.lease); await artifacts.recover(); assert.deepEqual(await execution.sessionStore.listPendingSessionRetirementCleanupIds(), []); for (const sessionId of sessionIds) { assert.equal((await artifacts.listPage(sessionId, { offset: 0, limit: 1 })).total, 0); - assert.deepEqual(await tasks.list(sessionId, { includeTerminal: true }), []); + const database = new DatabaseSync(join(root, 'runtime.sqlite'), { readOnly: true }); + try { + assert.equal( + database + .prepare( + 'SELECT COUNT(*) AS count FROM workflow_session_todo_documents WHERE session_id = ?', + ) + .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(); + } } } finally { await owner.close(); diff --git a/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts index 1b87a72379..8d53de77e2 100644 --- a/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts @@ -713,8 +713,8 @@ test('coalesces typed domain invalidations without publishing continuity project const opened = await open(coordinator, 'connection-1'); connection.activate(opened.subscriptionId); - coordinator.enqueueSessionDomainChanged(SESSION_ID, 'task'); - coordinator.enqueueSessionDomainChanged(SESSION_ID, 'task'); + coordinator.enqueueSessionDomainChanged(SESSION_ID, 'todo'); + coordinator.enqueueSessionDomainChanged(SESSION_ID, 'todo'); coordinator.enqueueSessionDomainChanged(SESSION_ID, 'plan'); coordinator.enqueueSessionDomainChanged(SESSION_ID, 'usage'); await waitFor(() => sink.frames.length === 3); @@ -726,7 +726,7 @@ test('coalesces typed domain invalidations without publishing continuity project : frame.kind, ), [ - { kind: 'subscription.session_domain_changed', sequence: 1, domain: 'task' }, + { kind: 'subscription.session_domain_changed', sequence: 1, domain: 'todo' }, { kind: 'subscription.session_domain_changed', sequence: 2, domain: 'plan' }, { kind: 'subscription.session_domain_changed', sequence: 3, domain: 'usage' }, ], diff --git a/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts index feb4a50b4c..be4674ea93 100644 --- a/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts @@ -1210,8 +1210,8 @@ async function withHarness( actions.purgedArtifacts.push(sessionId); }, }, - taskLedger: { - purgeConversationTaskLedger: async (sessionId) => { + sessionTodo: { + purgeSessionState: async (sessionId) => { actions.purgedTasks.push(sessionId); }, }, diff --git a/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts index ea8b2527ee..f978d1a02e 100644 --- a/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts +++ b/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts @@ -41,7 +41,7 @@ import { tryAcquireInteractiveRootOwner, type StorageRootCapability, } from '@maka/storage/root-authority'; -import { openInteractiveTaskLedgerStoreForWrite } from '@maka/storage/task-ledger-authority'; +import { openInteractiveSessionTodoStoreForWrite } from '@maka/storage/session-todo-authority'; import { removePosixEndpointDirectories } from './fixtures/endpoint-hygiene.js'; import { requireStartedTurn } from './fixtures/execution-host-suite.js'; import { @@ -330,6 +330,14 @@ async function verifyConcurrentRevisionAuthority( intent: 'side_conversation', }); assert.equal(archivedSideConversation.kind, 'committed'); + assert.deepEqual( + ( + await tui.request('session.todo.query', { + sessionId: ARCHIVED_SIDE_CONVERSATION_TARGET_ID, + }) + ).items, + [], + ); for (const sessionId of ['metadata-linked-copy-target', 'archived-owned-copy-target']) { assert.deepEqual( await tui.request('session.catalog.query', { @@ -366,16 +374,25 @@ async function verifyConcurrentRevisionAuthority( if (artifactPage.kind !== 'page') assert.fail('Branch Artifact query must return a page'); assert.equal(artifactPage.artifacts.length, 3); assert.notEqual(artifactPage.artifacts[0]?.id, 'source-artifact'); - const taskPage = await tui.request('task.ledger.query', { - kind: 'list_start', - sessionId: branch.id, + const todo = await tui.request('session.todo.query', { sessionId: branch.id }); + assert.deepEqual(todo.items, []); + + const latestBranch = await desktop.request('session.branch.create', { + ...branchInput, + targetSessionId: 'latest-branch-target', + sourceTurnId: 'turn-2', }); - assert.equal(taskPage.kind, 'page'); - if (taskPage.kind !== 'page') assert.fail('Branch Task Ledger query must return a page'); - assert.deepEqual(taskPage.tasks.map((task) => task.subject).sort(), [ - 'Legacy child task', - 'Retained task', - ]); + assert.equal(latestBranch.kind, 'committed'); + assert.deepEqual( + ( + await tui.request('session.todo.query', { + sessionId: 'latest-branch-target', + }) + ).items + .map((item) => item.content) + .sort(), + ['Legacy child task', 'Retained task'], + ); const renamed = await desktop.request('session.metadata.update', { sessionId: sourceSessionId, @@ -750,7 +767,7 @@ async function seedSource( try { const execution = await openInteractiveExecutionStoresForWrite(owner.lease); const artifacts = await openInteractiveArtifactStoreForWrite(owner.lease); - const tasks = await openInteractiveTaskLedgerStoreForWrite(owner.lease); + const todos = await openInteractiveSessionTodoStoreForWrite(owner.lease); await artifacts.recover(); const source = await execution.sessionStore.create({ cwd: root, @@ -1533,27 +1550,10 @@ async function seedSource( for (const event of archivedOwnedRuntimeEvents) { await execution.runtimeEventStore.appendRuntimeEvent(event.sessionId, event.runId, event); } - await tasks.create(source.id, [{ subject: 'Retained task' }], { - turnId: 'turn-1', - source: 'tool', - actor: 'main_agent', - }); - await tasks.create(source.id, [{ subject: 'Legacy child task' }], { - turnId: 'legacy-child-turn', - runId: 'legacy-child-run', - source: 'tool', - actor: 'child_agent', - }); - await tasks.update( - source.id, - (await tasks.list(source.id))[0]!.id, - { status: 'in_progress' }, - { - turnId: 'turn-2', - source: 'tool', - actor: 'main_agent', - }, - ); + await todos.replaceAll(source.id, [ + { content: 'Retained task', status: 'in_progress' }, + { content: 'Legacy child task', status: 'pending' }, + ]); return { sourceSessionId: source.id, busySessionId: busy.id, @@ -1589,7 +1589,7 @@ async function verifyDurableBranch( try { const execution = await openInteractiveExecutionStoresForWrite(owner.lease); const artifacts = await openInteractiveArtifactStoreForWrite(owner.lease); - const tasks = await openInteractiveTaskLedgerStoreForWrite(owner.lease); + const todos = await openInteractiveSessionTodoStoreForWrite(owner.lease); await artifacts.recover(); // Every copy kind that retains the upload turn must carry a rewritten, // readable copy of the user-uploaded attachment (regression guard for the @@ -1627,11 +1627,13 @@ async function verifyDurableBranch( ok: true, text: 'retained bytes', }); - assert.deepEqual((await tasks.list(branchSessionId)).map((task) => task.subject).sort(), [ - 'Legacy child task', - 'Retained task', - ]); - assert.ok((await tasks.list(branchSessionId)).every((task) => task.status === 'pending')); + assert.deepEqual(await todos.readOrBootstrap(branchSessionId), { items: [] }); + assert.deepEqual( + (await todos.readOrBootstrap('latest-branch-target')).items + .map((item) => item.content) + .sort(), + ['Legacy child task', 'Retained task'], + ); const copiedRuns = await execution.agentRunStore.listSessionRuns(branchSessionId); assert.equal(copiedRuns.length, 2); const copiedChild = copiedRuns.find((run) => run.turnId === 'legacy-child-turn'); @@ -1667,7 +1669,7 @@ async function verifyDurableBranch( assert.ok(copiedProjectionArtifact); assert.equal(copiedProjectionPart.ref.relativePath, copiedProjectionArtifact.id); assert.equal((await artifacts.listPage('revision-target', { offset: 0, limit: 10 })).total, 0); - assert.deepEqual(await tasks.list('revision-target'), []); + assert.deepEqual(await todos.readOrBootstrap('revision-target'), { items: [] }); assert.deepEqual(await execution.agentRunStore.listSessionRuns('revision-target'), []); await assert.rejects( () => execution.sessionStore.readHeaderSnapshot('revision-target'), diff --git a/packages/runtime-host/src/__tests__/session-todo-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-todo-coordinator.test.ts new file mode 100644 index 0000000000..470175cbb8 --- /dev/null +++ b/packages/runtime-host/src/__tests__/session-todo-coordinator.test.ts @@ -0,0 +1,96 @@ +/* + * 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 { test } from 'node:test'; +import { tryAcquireInteractiveRootOwner, resolveStorageRoot } from '@maka/storage/root-authority'; +import { openInteractiveSessionTodoStoreForWrite } from '@maka/storage/session-todo-authority'; +import { SessionAdmissionGate } from '../server/session-admission-gate.js'; +import { HostSessionTodoCoordinator } from '../server/session-todo-coordinator.js'; + +test('read/bootstrap is silent and each committed replace publishes once', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-host-session-todo-')); + try { + const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + const writer = await openInteractiveSessionTodoStoreForWrite(owner.lease); + const changed: string[] = []; + const coordinator = new HostSessionTodoCoordinator( + writer, + new SessionAdmissionGate(), + { probeSessionRemoval: async () => ({ kind: 'present' }) }, + (sessionId) => changed.push(sessionId), + () => assert.fail('publication should not drain'), + ); + assert.deepEqual(await coordinator.read('session-1'), { items: [] }); + assert.deepEqual(changed, []); + await assert.rejects( + () => coordinator.replace('session-1', [{ content: '', status: 'pending' }]), + /cannot be empty/, + ); + assert.deepEqual(changed, []); + await coordinator.replace('session-1', [{ content: 'one', status: 'pending' }]); + assert.deepEqual(changed, ['session-1']); + await coordinator.read('session-1'); + assert.deepEqual(changed, ['session-1']); + writer.close(); + await owner.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('committed replacement survives a synchronous publication failure and requests drain', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-host-session-todo-publication-')); + try { + const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + const writer = await openInteractiveSessionTodoStoreForWrite(owner.lease); + let drains = 0; + const coordinator = new HostSessionTodoCoordinator( + writer, + new SessionAdmissionGate(), + { probeSessionRemoval: async () => ({ kind: 'present' }) }, + () => { + throw new Error('projection failed'); + }, + () => { + drains += 1; + }, + ); + const committed = await coordinator.replace('session-1', [ + { content: 'committed before publication', status: 'completed' }, + ]); + assert.deepEqual(committed, { + items: [{ content: 'committed before publication', status: 'completed' }], + }); + assert.equal(drains, 1); + assert.deepEqual(await coordinator.read('session-1'), committed); + writer.close(); + await owner.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/runtime-host/src/__tests__/session-todo-protocol.test.ts b/packages/runtime-host/src/__tests__/session-todo-protocol.test.ts new file mode 100644 index 0000000000..635356a730 --- /dev/null +++ b/packages/runtime-host/src/__tests__/session-todo-protocol.test.ts @@ -0,0 +1,50 @@ +/* + * 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 { test } from 'node:test'; +import { + decodeSessionTodoQueryInput, + decodeSessionTodoQueryResult, + HOST_OPERATION_SPECS, +} from '../protocol/index.js'; + +test('SessionTodo protocol accepts one whole bounded snapshot', () => { + assert.deepEqual(decodeSessionTodoQueryInput({ sessionId: 'session-1' }), { + sessionId: 'session-1', + }); + assert.deepEqual( + decodeSessionTodoQueryResult({ + sessionId: 'session-1', + items: [{ content: 'one', status: 'pending' }], + }), + { sessionId: 'session-1', items: [{ content: 'one', status: 'pending' }] }, + ); + assert.ok(HOST_OPERATION_SPECS['session.todo.query']); + assert.equal(Object.hasOwn(HOST_OPERATION_SPECS, 'task.ledger.query'), false); +}); + +test('SessionTodo protocol rejects extra fields and invalid items', () => { + assert.throws(() => decodeSessionTodoQueryInput({ sessionId: 'session-1', cursor: 'x' })); + assert.throws(() => + decodeSessionTodoQueryResult({ + sessionId: 'session-1', + items: [{ content: 'one', status: 'blocked' }], + }), + ); +}); diff --git a/packages/runtime-host/src/__tests__/task-ledger-protocol.test.ts b/packages/runtime-host/src/__tests__/task-ledger-protocol.test.ts deleted file mode 100644 index 14fb1536c2..0000000000 --- a/packages/runtime-host/src/__tests__/task-ledger-protocol.test.ts +++ /dev/null @@ -1,225 +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 type { Task } from '@maka/core/task-ledger'; -import { RuntimeHostProtocolError } from '../protocol/errors.js'; -import { - decodeTaskLedgerQueryInput, - decodeTaskLedgerQueryResult, - encodeTaskLedgerTask, - encodeTaskLedgerQueryResult, - TASK_LEDGER_CURSOR_MAX_BYTES, - TASK_LEDGER_PAGE_MAX_BYTES, - TASK_LEDGER_PAGE_MAX_ITEMS, - type TaskLedgerQueryResult, -} from '../protocol/task-ledger.js'; - -const revision = `sha256:${'a'.repeat(64)}` as const; -const nextRevision = `sha256:${'b'.repeat(64)}` as const; - -describe('Task Ledger protocol', () => { - test('rejects unknown fields and invalid current Task DTO values', () => { - const task = validTask(); - for (const invalid of [ - { ...task, rawEventPath: '/private/task-events.jsonl' }, - { ...task, id: '../task' }, - { ...task, key: 'task-1' }, - { ...task, subject: ' Task 0 ' }, - { ...task, status: 'done' }, - { ...task, blockedReason: ' blocked ' }, - { ...task, resumeTrust: 'probably_ok' }, - { ...task, createdAt: Number.NaN }, - { ...task, updatedAt: Number.POSITIVE_INFINITY }, - { - ...task, - owner: { actor: 'child_agent', sessionId: 'child-session-1', socketPath: '/tmp/a' }, - }, - ]) { - assertInvalid(() => - decodeTaskLedgerQueryResult({ - kind: 'task', - sessionId: 'session-1', - revision, - task: invalid, - }), - ); - } - - assertInvalid(() => - decodeTaskLedgerQueryInput({ kind: 'list_start', sessionId: 'session-1', cursor: '0' }), - ); - assertInvalid(() => - decodeTaskLedgerQueryResult({ - kind: 'revision_changed', - expected: revision, - actual: nextRevision, - cursor: '0', - }), - ); - }); - - test('projects producer text once and accepts only wire-canonical DTOs', () => { - const producerTasks = [ - validTask(0, { subject: 'A B' }), - validTask(1, { - subject: '', - status: 'completed', - completionEvidence: 'Verified ghp_abcdefghijklmnopqrstuvwxyz123456', - resumeTrust: 'trusted', - }), - validTask(2, { - status: 'failed', - failureReason: '', - resumeTrust: 'trusted', - }), - validTask(3, { - status: 'blocked', - blockedReason: '', - resumeTrust: 'untrusted', - }), - ]; - const encoded = encodeTaskLedgerQueryResult({ - kind: 'page', - sessionId: 'session-1', - revision, - tasks: producerTasks, - nextCursor: null, - }); - assert.equal(encoded.kind, 'page'); - assert.equal(encoded.kind === 'page' && encoded.tasks[0]?.subject, 'A B'); - assert.equal(encoded.kind === 'page' && encoded.tasks[1]?.subject, '[redacted]'); - assert.equal( - encoded.kind === 'page' && encoded.tasks[1]?.completionEvidence, - 'Verified [redacted]', - ); - assert.equal(encoded.kind === 'page' && encoded.tasks[2]?.failureReason, undefined); - assert.equal(encoded.kind === 'page' && encoded.tasks[2]?.resumeTrust, 'needs_revalidation'); - assert.equal(encoded.kind === 'page' && encoded.tasks[3]?.blockedReason, undefined); - assert.equal(encoded.kind === 'page' && encoded.tasks[3]?.resumeTrust, 'untrusted'); - assert.deepEqual( - encoded.kind === 'page' ? encoded.tasks : [], - producerTasks.map(encodeTaskLedgerTask), - ); - assert.deepEqual(decodeTaskLedgerQueryResult(encoded), encoded); - - for (const task of producerTasks) { - assertInvalid(() => - decodeTaskLedgerQueryResult({ - kind: 'task', - sessionId: 'session-1', - revision, - task, - }), - ); - } - }); - - test('enforces revision and UTF-8 cursor bounds', () => { - for (const invalidRevision of [ - 'a'.repeat(64), - `sha256:${'A'.repeat(64)}`, - `sha256:${'a'.repeat(63)}`, - ]) { - assertInvalid(() => - decodeTaskLedgerQueryInput({ - kind: 'list_continue', - sessionId: 'session-1', - revision: invalidRevision, - cursor: 'opaque', - }), - ); - } - - for (const cursor of ['', '界'.repeat(Math.floor(TASK_LEDGER_CURSOR_MAX_BYTES / 3) + 1)]) { - assertInvalid(() => - decodeTaskLedgerQueryInput({ - kind: 'list_continue', - sessionId: 'session-1', - revision, - cursor, - }), - ); - assertInvalid(() => - decodeTaskLedgerQueryResult({ - kind: 'page', - sessionId: 'session-1', - revision, - tasks: [], - nextCursor: cursor, - }), - ); - } - }); - - test('enforces item and encoded UTF-8 page bounds in both codec directions', () => { - const tooMany = Array.from({ length: TASK_LEDGER_PAGE_MAX_ITEMS + 1 }, (_, index) => - validTask(index), - ); - const byteHeavy = Array.from({ length: 48 }, (_, index) => - validTask(index, { - status: 'completed', - subject: 'subject '.repeat(25).trim(), - completionEvidence: 'evidence '.repeat(125).trim(), - endedAt: 3, - }), - ); - const oversizedByItems = page(tooMany); - const oversizedByBytes = page(byteHeavy); - assert.ok( - Buffer.byteLength(JSON.stringify(oversizedByBytes), 'utf8') > TASK_LEDGER_PAGE_MAX_BYTES, - ); - - for (const result of [oversizedByItems, oversizedByBytes]) { - assertInvalid(() => encodeTaskLedgerQueryResult(result)); - assertInvalid(() => decodeTaskLedgerQueryResult(result)); - } - }); -}); - -function validTask(index = 0, overrides: Partial = {}): Task { - return { - id: `task-${index}`, - key: `T${index + 1}`, - subject: `Task ${index}`, - status: 'in_progress', - createdAt: 1, - updatedAt: 2, - owner: { actor: 'main_agent', runId: 'run-1' }, - ...overrides, - }; -} - -function page(tasks: readonly unknown[]) { - return { - kind: 'page', - sessionId: 'session-1', - revision, - tasks, - nextCursor: null, - }; -} - -function assertInvalid(action: () => unknown): void { - assert.throws( - action, - (error: unknown) => error instanceof RuntimeHostProtocolError && error.code === 'invalid_frame', - ); -} diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index d98e3c21e9..69a7b92fe7 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -86,7 +86,7 @@ export * from './scheduled-task-change.js'; export * from './session-retirement.js'; export * from './session-transcript.js'; export * from './session-turns.js'; -export * from './task-ledger.js'; +export * from './session-todo.js'; export * from './workspace.js'; export * from './workhub-coordination.js'; export * from './websocket-path.js'; @@ -95,7 +95,10 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 80 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 81 as const; +// 81: SessionTodo replaces the Task Ledger protocol and continuity domain with +// one bounded current-state snapshot. Older peers cannot decode the operation +// or preserve the new invalidation vocabulary. // 80: Runtime Policy catalog models gained validated user-overridden fact // provenance. Older peers reject this projected model shape, so they must be // refused during the handshake before catalog admission. diff --git a/packages/runtime-host/src/protocol/operations.ts b/packages/runtime-host/src/protocol/operations.ts index 8476927962..61211f4147 100644 --- a/packages/runtime-host/src/protocol/operations.ts +++ b/packages/runtime-host/src/protocol/operations.ts @@ -54,12 +54,12 @@ import { SESSION_CATALOG_OPERATION_SPECS } from './session-catalog.js'; import { SESSION_CONTINUITY_OPERATION_SPECS } from './session-continuity.js'; import { SESSION_TRANSCRIPT_OPERATION_SPECS } from './session-transcript.js'; import { SESSION_TURNS_OPERATION_SPECS } from './session-turns.js'; +import { SESSION_TODO_OPERATION_SPECS } from './session-todo.js'; import { SESSION_COLLABORATION_OPERATION_SPECS } from './session-collaboration.js'; import { SESSION_REVISION_OPERATION_SPECS } from './session-revision.js'; import { SESSION_RETIREMENT_OPERATION_SPECS } from './session-retirement.js'; import { SESSION_EFFECT_OPERATION_SPECS } from './session-effects.js'; import { SKILL_CATALOG_OPERATION_SPECS } from './skill-catalog.js'; -import { TASK_LEDGER_OPERATION_SPECS } from './task-ledger.js'; import { TURN_OPERATION_SPECS } from './turn.js'; import { USAGE_PRICING_OPERATION_SPECS } from './usage-pricing.js'; import { WEB_SEARCH_OPERATION_SPECS } from './web-search.js'; @@ -172,6 +172,7 @@ export * from './session-revision.js'; export * from './session-retirement.js'; export * from './session-transcript.js'; export * from './session-turns.js'; +export * from './session-todo.js'; export * from './session-effects.js'; export * from './skill-catalog.js'; export * from './usage-pricing.js'; @@ -199,7 +200,7 @@ export const HOST_OPERATION_SPECS = composeOperationSpecMaps( PLAN_OPERATION_SPECS, PROJECT_CATALOG_OPERATION_SPECS, MESSAGE_OPERATION_SPECS, - TASK_LEDGER_OPERATION_SPECS, + SESSION_TODO_OPERATION_SPECS, INTERACTION_OPERATION_SPECS, SESSION_CONTINUITY_OPERATION_SPECS, SESSION_TRANSCRIPT_OPERATION_SPECS, @@ -325,7 +326,7 @@ export const REMOTE_OWNER_OPERATION_GRANTS = Object.freeze([ 'skill.catalog.query', 'subscription.close', 'subscription.open', - 'task.ledger.query', + 'session.todo.query', 'turn.interrupt', 'turn.message.execution.query', 'turn.message.query', diff --git a/packages/runtime-host/src/protocol/session-continuity.ts b/packages/runtime-host/src/protocol/session-continuity.ts index 5cd42d7927..4219b6ab34 100644 --- a/packages/runtime-host/src/protocol/session-continuity.ts +++ b/packages/runtime-host/src/protocol/session-continuity.ts @@ -244,7 +244,7 @@ export interface SessionTranscriptAdvancedFrame extends SubscriptionEnvelope { } export const SESSION_DOMAINS = [ - 'task', + 'todo', 'plan', 'deep_research', 'usage', diff --git a/packages/runtime-host/src/protocol/session-todo.ts b/packages/runtime-host/src/protocol/session-todo.ts new file mode 100644 index 0000000000..1f4c623abc --- /dev/null +++ b/packages/runtime-host/src/protocol/session-todo.ts @@ -0,0 +1,70 @@ +/* + * 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 { normalizeSessionTodoItems, type SessionTodoSnapshot } from '@maka/core/session-todo'; +import { requireEntityId, requireExactRecord } from './codec.js'; +import { invalidProtocolFrame } from './errors.js'; +import { defineOperation } from './operation-spec.js'; + +const QUERY_ERRORS = [ + 'host_not_ready', + 'host_draining', + 'operation_unavailable', + 'invalid_request', + 'not_found', + 'internal_failure', +] as const; + +export interface SessionTodoQueryInput { + readonly sessionId: string; +} + +export interface SessionTodoQueryResult extends SessionTodoSnapshot { + readonly sessionId: string; +} + +export const SESSION_TODO_OPERATION_SPECS = { + 'session.todo.query': defineOperation< + SessionTodoQueryInput, + SessionTodoQueryResult, + (typeof QUERY_ERRORS)[number] + >({ + mode: 'query', + availability: 'ready', + errors: QUERY_ERRORS, + decodeInput: decodeSessionTodoQueryInput, + decodeOutput: decodeSessionTodoQueryResult, + }), +} as const; + +export function decodeSessionTodoQueryInput(value: unknown): SessionTodoQueryInput { + const input = requireExactRecord(value, 'SessionTodo query input', ['sessionId']); + return { sessionId: requireEntityId(input.sessionId, 'sessionId') }; +} + +export function decodeSessionTodoQueryResult(value: unknown): SessionTodoQueryResult { + const result = requireExactRecord(value, 'SessionTodo query result', ['sessionId', 'items']); + const normalized = normalizeSessionTodoItems(result.items); + if (!normalized.ok) + throw invalidProtocolFrame(`Invalid SessionTodo result: ${normalized.message}`); + return { + sessionId: requireEntityId(result.sessionId, 'sessionId'), + items: normalized.value.items, + }; +} diff --git a/packages/runtime-host/src/protocol/task-ledger.ts b/packages/runtime-host/src/protocol/task-ledger.ts deleted file mode 100644 index 284fd6e164..0000000000 --- a/packages/runtime-host/src/protocol/task-ledger.ts +++ /dev/null @@ -1,425 +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 { - TASK_EVIDENCE_MAX_CHARS, - TASK_SUBJECT_MAX_CHARS, - isResumeTrust, - isSafeTaskId, - isTaskKey, - isTaskOwner, - isTaskStatus, - normalizeTaskEvidenceText, - normalizeTaskSubject, - sanitizeTaskLedgerTask, - type Task, - type TaskOwner, - validateTaskEvidence, -} from '@maka/core/task-ledger'; -import { requireEntityId, requireExactRecord, requireRecord } from './codec.js'; -import { invalidProtocolFrame } from './errors.js'; -import { defineOperation } from './operation-spec.js'; - -export const TASK_LEDGER_PAGE_MAX_ITEMS = 128; -export const TASK_LEDGER_PAGE_MAX_BYTES = 48 * 1024; -export const TASK_LEDGER_CURSOR_MAX_BYTES = 512; - -const TASK_REQUIRED_FIELDS = ['id', 'key', 'subject', 'status', 'createdAt', 'updatedAt'] as const; -const TASK_FIELDS = [ - ...TASK_REQUIRED_FIELDS, - 'parentId', - 'owner', - 'endedAt', - 'blockedReason', - 'failureReason', - 'completionEvidence', - 'resumeTrust', -] as const; -const TASK_OWNER_FIELDS = ['actor', 'sessionId', 'agentId', 'runId', 'turnId'] as const; -const REDACTED_TASK_TEXT = '[redacted]'; - -const QUERY_ERRORS = [ - 'host_not_ready', - 'host_draining', - 'operation_unavailable', - 'invalid_request', - 'not_found', - 'internal_failure', -] as const; - -export type TaskLedgerRevision = `sha256:${string}`; -export type TaskLedgerTask = Readonly; - -export type TaskLedgerQueryInput = - | { readonly kind: 'list_start'; readonly sessionId: string } - | { - readonly kind: 'list_continue'; - readonly sessionId: string; - readonly revision: TaskLedgerRevision; - readonly cursor: string; - } - | { readonly kind: 'get'; readonly sessionId: string; readonly taskRef: string }; - -export type TaskLedgerQueryResult = - | { - readonly kind: 'page'; - readonly sessionId: string; - readonly revision: TaskLedgerRevision; - readonly tasks: readonly TaskLedgerTask[]; - readonly nextCursor: string | null; - } - | { - readonly kind: 'revision_changed'; - readonly expected: TaskLedgerRevision; - readonly actual: TaskLedgerRevision; - } - | { - readonly kind: 'task'; - readonly sessionId: string; - readonly revision: TaskLedgerRevision; - readonly task: TaskLedgerTask | null; - }; - -export const TASK_LEDGER_OPERATION_SPECS = { - 'task.ledger.query': defineOperation< - TaskLedgerQueryInput, - TaskLedgerQueryResult, - (typeof QUERY_ERRORS)[number] - >({ - mode: 'query', - availability: 'ready', - errors: QUERY_ERRORS, - decodeInput: decodeTaskLedgerQueryInput, - decodeOutput: decodeTaskLedgerQueryResult, - }), -} as const; - -export function decodeTaskLedgerQueryInput(value: unknown): TaskLedgerQueryInput { - const record = requireRecord(value, 'task ledger query input'); - if (record.kind === 'list_start') { - const input = requireExactRecord(record, 'task ledger list start input', ['kind', 'sessionId']); - return { kind: 'list_start', sessionId: requireEntityId(input.sessionId, 'sessionId') }; - } - if (record.kind === 'list_continue') { - const input = requireExactRecord(record, 'task ledger list continuation input', [ - 'kind', - 'sessionId', - 'revision', - 'cursor', - ]); - return { - kind: 'list_continue', - sessionId: requireEntityId(input.sessionId, 'sessionId'), - revision: taskLedgerRevision(input.revision, 'task ledger revision'), - cursor: taskLedgerCursor(input.cursor, 'task ledger cursor'), - }; - } - if (record.kind === 'get') { - const input = requireExactRecord(record, 'task ledger get input', [ - 'kind', - 'sessionId', - 'taskRef', - ]); - return { - kind: 'get', - sessionId: requireEntityId(input.sessionId, 'sessionId'), - taskRef: taskReference(input.taskRef), - }; - } - throw invalidProtocolFrame('Invalid task ledger query kind'); -} - -export function decodeTaskLedgerQueryResult(value: unknown): TaskLedgerQueryResult { - return taskLedgerQueryResult(value, 'decode'); -} - -export function encodeTaskLedgerQueryResult(value: unknown): TaskLedgerQueryResult { - return taskLedgerQueryResult(value, 'encode'); -} - -export function encodeTaskLedgerTask(value: unknown): TaskLedgerTask { - return taskLedgerTask(value, 'encode'); -} - -function taskLedgerQueryResult( - value: unknown, - direction: 'encode' | 'decode', -): TaskLedgerQueryResult { - const record = requireRecord(value, 'task ledger query result'); - if (record.kind === 'revision_changed') { - const changed = requireExactRecord(record, 'task ledger revision changed result', [ - 'kind', - 'expected', - 'actual', - ]); - return { - kind: 'revision_changed', - expected: taskLedgerRevision(changed.expected, 'expected task ledger revision'), - actual: taskLedgerRevision(changed.actual, 'actual task ledger revision'), - }; - } - if (record.kind === 'task') { - const result = requireExactRecord(record, 'task ledger task result', [ - 'kind', - 'sessionId', - 'revision', - 'task', - ]); - return { - kind: 'task', - sessionId: requireEntityId(result.sessionId, 'sessionId'), - revision: taskLedgerRevision(result.revision, 'task ledger revision'), - task: result.task === null ? null : taskLedgerTask(result.task, direction), - }; - } - if (record.kind !== 'page') throw invalidProtocolFrame('Invalid task ledger query result kind'); - - const page = requireExactRecord(record, 'task ledger page result', [ - 'kind', - 'sessionId', - 'revision', - 'tasks', - 'nextCursor', - ]); - if (!Array.isArray(page.tasks) || page.tasks.length > TASK_LEDGER_PAGE_MAX_ITEMS) { - throw invalidProtocolFrame('Task ledger page exceeds item limit'); - } - const decoded: TaskLedgerQueryResult = { - kind: 'page', - sessionId: requireEntityId(page.sessionId, 'sessionId'), - revision: taskLedgerRevision(page.revision, 'task ledger revision'), - tasks: page.tasks.map((task) => taskLedgerTask(task, direction)), - nextCursor: - page.nextCursor === null - ? null - : taskLedgerCursor(page.nextCursor, 'task ledger next cursor'), - }; - if (jsonByteLength(decoded) > TASK_LEDGER_PAGE_MAX_BYTES) { - throw invalidProtocolFrame('Task ledger page exceeds byte limit'); - } - return decoded; -} - -function taskLedgerTask(value: unknown, direction: 'encode' | 'decode'): TaskLedgerTask { - const record = requireRecord(value, 'task ledger task'); - assertAllowedKeys(record, 'task ledger task', TASK_FIELDS); - if (TASK_REQUIRED_FIELDS.some((field) => !Object.hasOwn(record, field))) { - throw invalidProtocolFrame('Invalid task ledger task fields'); - } - - const task: Task = { - id: stableTaskId(record.id, 'task id'), - key: taskKey(record.key), - subject: boundedTaskText(record.subject, 'subject'), - status: taskStatus(record.status), - createdAt: timestamp(record.createdAt, 'task createdAt'), - updatedAt: timestamp(record.updatedAt, 'task updatedAt'), - ...optionalStableTaskId(record, 'parentId'), - ...optionalOwner(record), - ...optionalTimestamp(record, 'endedAt'), - ...optionalTaskText(record, 'blockedReason'), - ...optionalTaskText(record, 'failureReason'), - ...optionalTaskText(record, 'completionEvidence'), - ...optionalResumeTrust(record), - }; - const projected = projectTaskForWire(task); - if (direction === 'decode' && !hasCanonicalWireProjection(task, projected)) { - throw invalidProtocolFrame('Task ledger task is not sanitized'); - } - return projected; -} - -function optionalStableTaskId( - record: Record, - field: 'parentId', -): Pick | Record { - return Object.hasOwn(record, field) - ? { [field]: stableTaskId(record[field], `task ${field}`) } - : {}; -} - -function optionalOwner( - record: Record, -): Pick | Record { - if (!Object.hasOwn(record, 'owner')) return {}; - return { owner: taskOwner(record.owner) }; -} - -function taskOwner(value: unknown): TaskOwner { - const record = requireRecord(value, 'task owner'); - assertAllowedKeys(record, 'task owner', TASK_OWNER_FIELDS); - if (!Object.hasOwn(record, 'actor') || !isTaskOwner(record)) { - throw invalidProtocolFrame('Invalid task owner'); - } - return { - actor: record.actor as TaskOwner['actor'], - ...optionalOwnerId(record, 'sessionId'), - ...optionalOwnerId(record, 'agentId'), - ...optionalOwnerId(record, 'runId'), - ...optionalOwnerId(record, 'turnId'), - }; -} - -function optionalOwnerId>( - record: Record, - field: Field, -): Pick | Record { - return Object.hasOwn(record, field) - ? ({ [field]: stableTaskId(record[field], `task owner ${field}`) } as Pick) - : {}; -} - -function optionalTimestamp( - record: Record, - field: 'endedAt', -): Pick | Record { - return Object.hasOwn(record, field) ? { [field]: timestamp(record[field], `task ${field}`) } : {}; -} - -function optionalTaskText( - record: Record, - field: Field, -): Pick | Record { - return Object.hasOwn(record, field) - ? ({ [field]: boundedTaskText(record[field], field) } as Pick) - : {}; -} - -function optionalResumeTrust( - record: Record, -): Pick | Record { - if (!Object.hasOwn(record, 'resumeTrust')) return {}; - if (!isResumeTrust(record.resumeTrust)) throw invalidProtocolFrame('Invalid task resumeTrust'); - return { resumeTrust: record.resumeTrust }; -} - -function boundedTaskText( - value: unknown, - field: 'subject' | 'blockedReason' | 'failureReason' | 'completionEvidence', -): string { - const maxCharacters = field === 'subject' ? TASK_SUBJECT_MAX_CHARS : TASK_EVIDENCE_MAX_CHARS; - if (typeof value !== 'string' || value.length === 0 || Array.from(value).length > maxCharacters) { - throw invalidProtocolFrame(`Invalid task ${field}`); - } - return value; -} - -function projectTaskForWire(task: Task): Task { - const sanitized = sanitizeTaskLedgerTask(task); - const { blockedReason, failureReason, completionEvidence, ...identity } = sanitized; - const projected: Task = { - ...identity, - subject: canonicalWireSubject(sanitized.subject), - ...canonicalWireEvidence(blockedReason, 'blockedReason'), - ...canonicalWireEvidence(failureReason, 'failureReason'), - ...canonicalWireEvidence(completionEvidence, 'completionEvidence'), - }; - if (validateTaskEvidence(projected).ok) return projected; - if (projected.resumeTrust !== undefined && projected.resumeTrust !== 'trusted') return projected; - return { ...projected, resumeTrust: 'needs_revalidation' }; -} - -function hasCanonicalWireProjection(task: Task, projected: Task): boolean { - return ( - task.subject === projected.subject && - task.blockedReason === projected.blockedReason && - task.failureReason === projected.failureReason && - task.completionEvidence === projected.completionEvidence && - task.resumeTrust === projected.resumeTrust - ); -} - -function canonicalWireSubject(value: string): string { - const normalized = normalizeTaskSubject(value); - if (normalized.ok) return normalized.value; - if (value.trim().length === 0) return REDACTED_TASK_TEXT; - throw invalidProtocolFrame('Invalid task subject'); -} - -function canonicalWireEvidence< - Field extends 'blockedReason' | 'failureReason' | 'completionEvidence', ->(value: string | undefined, field: Field): Pick | Record { - if (value === undefined) return {}; - const normalized = normalizeTaskEvidenceText(value, field); - if (normalized.ok) return { [field]: normalized.value } as Pick; - if (value.trim().length === 0) return {}; - throw invalidProtocolFrame(`Invalid task ${field}`); -} - -function stableTaskId(value: unknown, label: string): string { - if (!isSafeTaskId(value)) throw invalidProtocolFrame(`Invalid ${label}`); - return value; -} - -function taskKey(value: unknown): string { - if (!isTaskKey(value)) throw invalidProtocolFrame('Invalid task key'); - return value; -} - -function taskReference(value: unknown): string { - if (!isSafeTaskId(value) && !isTaskKey(value)) { - throw invalidProtocolFrame('Invalid task reference'); - } - return value; -} - -function taskStatus(value: unknown): Task['status'] { - if (!isTaskStatus(value)) throw invalidProtocolFrame('Invalid task status'); - return value; -} - -function timestamp(value: unknown, label: string): number { - if (typeof value !== 'number' || !Number.isFinite(value)) { - throw invalidProtocolFrame(`Invalid ${label}`); - } - return value; -} - -function taskLedgerRevision(value: unknown, label: string): TaskLedgerRevision { - if (typeof value !== 'string' || !/^sha256:[a-f0-9]{64}$/.test(value)) { - throw invalidProtocolFrame(`Invalid ${label}`); - } - return value as TaskLedgerRevision; -} - -function taskLedgerCursor(value: unknown, label: string): string { - if ( - typeof value !== 'string' || - value.length === 0 || - Buffer.byteLength(value, 'utf8') > TASK_LEDGER_CURSOR_MAX_BYTES - ) { - throw invalidProtocolFrame(`Invalid ${label}`); - } - return value; -} - -function assertAllowedKeys( - record: Record, - label: string, - keys: readonly string[], -): void { - const allowed = new Set(keys); - if (Object.keys(record).some((key) => !allowed.has(key))) { - throw invalidProtocolFrame(`Unknown ${label} field`); - } -} - -function jsonByteLength(value: unknown): number { - return Buffer.byteLength(JSON.stringify(value), 'utf8'); -} diff --git a/packages/runtime-host/src/server/child-agent-composition.ts b/packages/runtime-host/src/server/child-agent-composition.ts index 2bf61000aa..461da00d2d 100644 --- a/packages/runtime-host/src/server/child-agent-composition.ts +++ b/packages/runtime-host/src/server/child-agent-composition.ts @@ -17,7 +17,6 @@ * under the License. */ -import type { TaskLedgerStore } from '@maka/core/task-ledger'; import { AiSdkBackend } from '@maka/runtime/ai-sdk-backend'; import { buildBuiltinTools, type BuildBuiltinToolsOptions } from '@maka/runtime/builtin-tools'; @@ -47,7 +46,6 @@ export interface HostChildAgentToolComposition { /** Composes the parent control tools and the exact catalog-child capability union. */ export function createHostChildAgentToolComposition(input: { - readonly taskLedger: TaskLedgerStore; readonly builtinTools: BuildBuiltinToolsOptions; readonly hostTools?: readonly MakaTool[]; readonly worktreePatchWriteBackAvailable?: boolean; @@ -59,9 +57,7 @@ export function createHostChildAgentToolComposition(input: { worktreeChildExecutorAvailable: input.worktreePatchWriteBackAvailable, }); return Object.freeze({ - parentTools: Object.freeze( - buildParentAgentTools({ taskLedger: input.taskLedger, definitions }), - ), + parentTools: Object.freeze(buildParentAgentTools({ definitions })), childTools: Object.freeze(childTools), }); } diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index ee36b4ac2a..66fb16892e 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -34,7 +34,6 @@ import { type SessionHeader, WORKHUB_COORDINATION_SESSION_ID, } from '@maka/core/session'; -import { filterModelVisibleTaskLedgerTasks } from '@maka/core/task-ledger'; import { AgentGraphCoordinator } from '@maka/runtime/stream-graph-coordinator'; import { AgentGraphSupervisorWakeCoordinator } from '@maka/runtime/agent-graph-supervisor-wake'; import { @@ -174,7 +173,7 @@ import { SessionContinuityCoordinator } from './session-continuity-coordinator.j import { createSessionTranscriptReader } from './session-transcript-reader.js'; import { HostSkillCatalogCoordinator } from './skill-catalog-coordinator.js'; import { SkillCatalogRepository } from './skill-catalog-repository.js'; -import { HostTaskLedgerCoordinator } from './task-ledger-coordinator.js'; +import { HostSessionTodoCoordinator } from './session-todo-coordinator.js'; import { HostTurnControlCoordinator } from './turn-control-coordinator.js'; import { HostUsagePricingCoordinator } from './usage-pricing-coordinator.js'; import { HostWebSearchCoordinator } from './web-search-coordinator.js'; @@ -264,7 +263,6 @@ export async function createExecutionRuntimeHostComposition( let graphClient: HostAgentGraphCoordinator | undefined; let sessionEffects: HostSessionEffectCoordinator | undefined; let memoryExtraction: HostMemoryExtractionCoordinator | undefined; - let unsubscribeTaskLedger: (() => void) | undefined; let unsubscribeTranscriptChanges: (() => void) | undefined; let unsubscribeUsageChanges: (() => void) | undefined; let workspaceExecution: RuntimeHostWorkspaceExecutionComposition | undefined; @@ -280,7 +278,7 @@ export async function createExecutionRuntimeHostComposition( const openedGoalStore = storage.goal; const memoryStore = storage.memoryBundle; const longTermMemoryStore = storage.longTermMemory; - const taskLedgerStore = storage.taskLedger; + const sessionTodoStore = storage.sessionTodo; const openedArtifactStore = storage.artifacts; const openedContextOffloadStore = storage.contextOffload; const openedContextOffloadReader = openedContextOffloadStore @@ -356,10 +354,12 @@ export async function createExecutionRuntimeHostComposition( workspaceExecution = createRuntimeHostWorkspaceExecutionComposition({ ...(workspaceFilesystemWorker ? { filesystemWorker: workspaceFilesystemWorker } : {}), }); - const taskLedger = new HostTaskLedgerCoordinator( - taskLedgerStore, + const sessionTodo = new HostSessionTodoCoordinator( + sessionTodoStore, sessionAdmission, stores.sessionStore, + (sessionId) => requireContinuity(continuity).enqueueSessionDomainChanged(sessionId, 'todo'), + context.requestDrain, ); runtimeResources = new HostRuntimeResourceCoordinator({ manager: shellRuns, @@ -424,7 +424,6 @@ export async function createExecutionRuntimeHostComposition( ]; const hostTools = [...childHostTools, ...historyTools]; const childAgentTools = createHostChildAgentToolComposition({ - taskLedger, builtinTools, hostTools: childHostTools, worktreePatchWriteBackAvailable: true, @@ -565,9 +564,6 @@ export async function createExecutionRuntimeHostComposition( unsubscribeUsageChanges = openedUsageStores.subscribeSessionUsageChanges((sessionId) => continuityCoordinator.enqueueSessionDomainChanged(sessionId, 'usage'), ); - unsubscribeTaskLedger = taskLedger.subscribe(({ sessionId }) => - continuityCoordinator.enqueueSessionDomainChanged(sessionId, 'task'), - ); deepResearch = new HostDeepResearchCoordinator({ store: openedDeepResearchStore, artifacts: openedArtifactStore, @@ -672,7 +668,7 @@ export async function createExecutionRuntimeHostComposition( createRunComposer: createInteractiveRunComposerFactory({ skills, memory: requireMemory(memory), - taskLedger, + sessionTodo, clientCapabilities: requireClientCapabilities(clientCapabilities), resolveTavilyWebSearchReadiness: () => resolveHostTavilyWebSearchReadiness(runtimePolicyStores.operations), @@ -757,7 +753,6 @@ export async function createExecutionRuntimeHostComposition( ...(input.boundTools ? { boundTools: input.boundTools } : {}), ...(input.childTools ? { childTools: input.childTools } : {}), ...(input.parentAgentTools ? { parentAgentTools: input.parentAgentTools } : {}), - taskLedger, worktreePatchWriteBackAvailable: true, tavilyReady, }), @@ -808,7 +803,7 @@ export async function createExecutionRuntimeHostComposition( shell: resolveTurnShellPlan(runtimePolicy.policy.shell), skills, memory: requireMemory(memory), - taskLedger, + sessionTodo, ...(runProfile ? { toolProfile: header.toolProfile } : {}), ...(capabilitySnapshot ? { clientCapabilities: capabilitySnapshot } : {}), builtinTools, @@ -873,7 +868,7 @@ export async function createExecutionRuntimeHostComposition( shell: resolveTurnShellPlan(runtimePolicy.policy.shell), skills, memory: requireMemory(memory), - taskLedger, + sessionTodo, ...(capabilitySnapshot ? { clientCapabilities: capabilitySnapshot } : {}), builtinTools, hostTools: surface.hostTools, @@ -924,7 +919,6 @@ export async function createExecutionRuntimeHostComposition( (await runtimePolicyStores.runtimePolicy.getSnapshot()).policy.shell, ); const childTools = createHostChildAgentToolComposition({ - taskLedger, builtinTools: { ...builtinTools, shell }, hostTools, worktreePatchWriteBackAvailable: true, @@ -1249,16 +1243,6 @@ export async function createExecutionRuntimeHostComposition( }), admitTurn: (sessionId, text, checkpoint, controlLease) => goalExecutionCoordinator.admitTurn(sessionId, text, checkpoint, controlLease), - listActionableTaskKeys: async (sessionId) => { - const tasks = await taskLedger.list(sessionId, { - includeTerminal: false, - includeArchived: false, - classifyResumeTrust: true, - }); - return filterModelVisibleTaskLedgerTasks(tasks) - .filter((task) => task.status === 'pending' || task.status === 'in_progress') - .map((task) => task.key); - }, acquireResidency: () => context.acquireResidency('goal'), onProjectionChanged: (sessionId) => continuityCoordinator.enqueueCanonicalRefresh(sessionId), requestDrain: context.requestDrain, @@ -1432,6 +1416,7 @@ export async function createExecutionRuntimeHostComposition( discardImportedSession: async (sessionId) => { const outcomes = await Promise.allSettled([ stores.purgeConversationOperationalState(sessionId), + sessionTodoStore.purgeSessionState(sessionId), stores.sessionStore.remove(sessionId), ]); for (const outcome of outcomes) { @@ -1457,7 +1442,7 @@ export async function createExecutionRuntimeHostComposition( const sessionRevisions = new HostSessionRevisionCoordinator({ stores, artifacts: openedArtifactStore, - taskLedger: taskLedgerStore, + sessionTodo: sessionTodoStore, manager, admission: sessionAdmission, continuity: continuityCoordinator, @@ -1481,7 +1466,7 @@ export async function createExecutionRuntimeHostComposition( capabilities: clientCapabilities, continuity: continuityCoordinator, artifacts: openedArtifactStore, - taskLedger: taskLedgerStore, + sessionTodo: sessionTodoStore, assertNoContextOffloadReferences: async (sessionIds) => { if (!openedContextOffloadStore) { throw new Error('Context-offload reader is unavailable during Session removal', { @@ -1597,7 +1582,7 @@ export async function createExecutionRuntimeHostComposition( handlers: [ runtimePolicy.handlers, connectionEffects.handlers, - taskLedger.handlers, + sessionTodo.handlers, artifacts.handlers, skills.handlers, usagePricing.handlers, @@ -1625,7 +1610,6 @@ export async function createExecutionRuntimeHostComposition( () => { unsubscribeTranscriptChanges?.(); unsubscribeUsageChanges?.(); - unsubscribeTaskLedger?.(); }, ], releaseConnection: [(connectionId) => artifacts.releaseConnection(connectionId)], @@ -1841,7 +1825,6 @@ export async function createExecutionRuntimeHostComposition( try { unsubscribeTranscriptChanges?.(); unsubscribeUsageChanges?.(); - unsubscribeTaskLedger?.(); } catch (closeError) { errors.push(closeError); } diff --git a/packages/runtime-host/src/server/goal-coordinator.ts b/packages/runtime-host/src/server/goal-coordinator.ts index 9949bae516..7104a83076 100644 --- a/packages/runtime-host/src/server/goal-coordinator.ts +++ b/packages/runtime-host/src/server/goal-coordinator.ts @@ -30,7 +30,6 @@ import { GoalContinuationCoordinator, type GoalSessionCloseOperation, type GoalObservedTurnStart, - type GoalTaskGateTrace, type GoalTurnAdmission, type GoalTurnOutcome, } from '@maka/runtime/goal-continuation'; @@ -85,7 +84,6 @@ export interface HostGoalCoordinatorOptions { checkpoint: GoalCheckpoint, controlLease: GoalControlLease, ) => GoalTurnAdmission; - readonly listActionableTaskKeys: (sessionId: string) => Promise; readonly acquireResidency: () => RuntimeHostResidency; readonly onProjectionChanged: (sessionId: string) => void; readonly requestDrain: () => void; @@ -164,10 +162,6 @@ export class HostGoalCoordinator { }, getTokenCount: (sessionId) => tokenCache.get(sessionId) ?? 0, admitTurn: options.admitTurn, - taskGate: { - listActionableTaskKeys: options.listActionableTaskKeys, - recordDecision: (trace) => this.#recordTaskGateDecision(trace, now), - }, durability: { flush: (sessionId) => this.#flushGoalState(sessionId), recordCurrentExecution: (current) => this.#recordCurrentExecution(current), @@ -599,28 +593,6 @@ export class HostGoalCoordinator { retained?.release(); this.#residencies.delete(goal.sessionId); } - - async #recordTaskGateDecision(trace: GoalTaskGateTrace, now: () => number): Promise { - const admission = await this.#stores.agentRunStore.readRootTurnAdmission( - trace.sessionId, - trace.turnId, - ); - if (!admission) return; - await this.#stores.agentRunStore.appendEvent(trace.sessionId, admission.runId, { - type: 'task_gate_decided', - id: this.#newId(), - runId: admission.runId, - sessionId: trace.sessionId, - turnId: trace.turnId, - ts: now(), - message: `Task gate: ${trace.decision}`, - data: { - goalId: trace.goalId, - decision: trace.decision, - taskKeys: trace.taskKeys, - }, - }); - } } function recentContext(messages: readonly StoredMessage[]): string { diff --git a/packages/runtime-host/src/server/interactive-run-composer.ts b/packages/runtime-host/src/server/interactive-run-composer.ts index 71004bf7bf..dc35d96779 100644 --- a/packages/runtime-host/src/server/interactive-run-composer.ts +++ b/packages/runtime-host/src/server/interactive-run-composer.ts @@ -31,7 +31,6 @@ import type { PermissionMode } from '@maka/core/permission'; import type { RuntimeExecutionConnection } from '@maka/core/llm-connections'; import type { RuntimePolicySnapshot } from '@maka/core/runtime-policy'; import type { SessionToolProfile } from '@maka/core/session'; -import { type TaskLedgerStore } from '@maka/core/task-ledger'; import { assembleMainSessionSystemPrompt } from '@maka/runtime/system-prompt/main-session-prompt'; import { buildAskUserQuestionTool } from '@maka/runtime/ask-user-question-tool'; import { buildBuiltinTools, type BuildBuiltinToolsOptions } from '@maka/runtime/builtin-tools'; @@ -52,7 +51,7 @@ import { type SkillCatalogBudgetOptions, type SkillInventoryResolver, } from '@maka/runtime/skills'; -import { buildTaskLedgerTools } from '@maka/runtime/task-ledger-tools'; +import { buildSessionTodoTools, type SessionTodoToolStore } from '@maka/runtime/session-todo-tools'; import { buildWorkspaceInstructionsPromptFragment } from '@maka/runtime/system-prompt/workspace-instructions'; import { isDeepResearchToolAllowed } from '@maka/runtime/deep-research-tools'; import { listRunnableBuiltinAgentDefinitions } from '@maka/runtime/agent-catalog'; @@ -94,7 +93,7 @@ export interface InteractiveRunComposerInput { readonly runtimePolicy: RuntimePolicySnapshot; readonly skills: HostSkillCatalogCoordinator; readonly memory: HostMemoryCoordinator; - readonly taskLedger: TaskLedgerStore; + readonly sessionTodo: SessionTodoToolStore; readonly childInstruction?: string; readonly sideConversation?: boolean; readonly boundTools?: readonly MakaTool[]; @@ -136,7 +135,7 @@ export function createInteractiveRunComposer(input: InteractiveRunComposerInput) const defaultTools = input.boundTools ? input.boundTools : buildDefaultHostTools( - input.taskLedger, + input.sessionTodo, inventoryFor, builtinTools, input.hostTools, @@ -287,7 +286,6 @@ export interface InteractiveRunToolSurfaceInput { readonly boundTools?: readonly MakaTool[]; readonly childTools?: readonly MakaTool[]; readonly parentAgentTools?: readonly MakaTool[]; - readonly taskLedger: TaskLedgerStore; readonly worktreePatchWriteBackAvailable?: boolean; readonly tavilyReady: boolean; } @@ -321,7 +319,6 @@ export function routeInteractiveRunToolSurface(input: InteractiveRunToolSurfaceI ...(childTools ? { parentAgentTools: buildParentAgentTools({ - taskLedger: input.taskLedger, definitions: listRunnableBuiltinAgentDefinitions({ tools: childTools, worktreeChildExecutorAvailable: input.worktreePatchWriteBackAvailable, @@ -377,7 +374,6 @@ export function createInteractiveRunComposerFactory( ...(backendContext.tools ? { boundTools: backendContext.tools } : {}), ...(input.childTools ? { childTools: input.childTools } : {}), ...(input.parentAgentTools ? { parentAgentTools: input.parentAgentTools } : {}), - taskLedger: input.taskLedger, worktreePatchWriteBackAvailable: input.worktreePatchWriteBackAvailable, tavilyReady, }); @@ -386,7 +382,7 @@ export function createInteractiveRunComposerFactory( runtimePolicy, skills: input.skills, memory: input.memory, - taskLedger: input.taskLedger, + sessionTodo: input.sessionTodo, ...(backendContext.systemPrompt ? { childInstruction: backendContext.systemPrompt } : {}), ...(isSideConversationSession(backendContext.header.labels) ? { sideConversation: true } @@ -447,7 +443,7 @@ function assertUniqueToolNames(tools: readonly MakaTool[]): void { } function buildDefaultHostTools( - taskLedger: TaskLedgerStore, + sessionTodo: SessionTodoToolStore, inventoryFor: SkillInventoryResolver, builtinOptions?: BuildBuiltinToolsOptions, hostTools: readonly MakaTool[] = [], @@ -460,7 +456,7 @@ function buildDefaultHostTools( const builtins = builtinOptions ? buildBuiltinTools(builtinOptions) : []; const question = buildAskUserQuestionTool(); const sandboxBoundary = buildRequestSandboxBoundaryTool(); - const taskTools = buildTaskLedgerTools({ store: taskLedger }); + const todoTools = buildSessionTodoTools(sessionTodo); const activeExecution = plan ? activePlanExecution(plan.state) : undefined; const interruptedExecution = plan ? [...plan.state.executions].reverse().find((execution) => execution.status === 'interrupted') @@ -482,7 +478,7 @@ function buildDefaultHostTools( sandboxBoundary.name, 'Skill', 'SkillSearch', - ...taskTools.map((tool) => tool.name), + ...todoTools.map((tool) => tool.name), ...(scheduledTaskTool ? [scheduledTaskTool.name] : []), ...goalTools.map((tool) => tool.name), ...parentAgentTools.map((tool) => tool.name), @@ -498,7 +494,7 @@ function buildDefaultHostTools( sandboxBoundary, buildSkillAgentToolFromInventory(inventoryFor, skillHost, { shadowTracker }), buildSkillSearchAgentToolFromInventory(inventoryFor, skillHost, { shadowTracker }), - ...taskTools, + ...todoTools, ...(scheduledTaskTool ? [scheduledTaskTool] : []), ...goalTools, ...parentAgentTools, diff --git a/packages/runtime-host/src/server/operation-dispatcher.ts b/packages/runtime-host/src/server/operation-dispatcher.ts index 18bf74b3c1..a75524acb9 100644 --- a/packages/runtime-host/src/server/operation-dispatcher.ts +++ b/packages/runtime-host/src/server/operation-dispatcher.ts @@ -128,14 +128,15 @@ export type SessionRetirementOperationKey = Extract< 'session.lifecycle.set' | 'session.remove' >; export type SessionEffectOperationKey = Extract; +export type SessionTodoOperationKey = Extract; export type SessionCatalogOperationKey = Exclude< Extract, | SessionContinuityOperationKey | SessionRevisionOperationKey | SessionRetirementOperationKey | SessionEffectOperationKey + | SessionTodoOperationKey >; -export type TaskLedgerOperationKey = Extract; export type ArtifactOperationKey = Extract; export type SkillCatalogOperationKey = Extract; export type UsagePricingOperationKey = Extract; @@ -193,7 +194,7 @@ export type SessionRetirementOperationHandlerMap = Pick< SessionRetirementOperationKey >; export type SessionEffectOperationHandlerMap = Pick; -export type TaskLedgerOperationHandlerMap = Pick; +export type SessionTodoOperationHandlerMap = Pick; export type ArtifactOperationHandlerMap = Pick; export type SkillCatalogOperationHandlerMap = Pick; export type UsagePricingOperationHandlerMap = Pick; diff --git a/packages/runtime-host/src/server/session-retirement-coordinator.ts b/packages/runtime-host/src/server/session-retirement-coordinator.ts index c3cb224fe7..22233de377 100644 --- a/packages/runtime-host/src/server/session-retirement-coordinator.ts +++ b/packages/runtime-host/src/server/session-retirement-coordinator.ts @@ -31,7 +31,7 @@ import { type SessionHeaderSnapshot, } from '@maka/storage/execution-stores'; import { type SessionManager } from '@maka/runtime/session-manager'; -import type { InteractiveTaskLedgerWriter } from '@maka/storage/task-ledger-authority'; +import type { InteractiveSessionTodoWriter } from '@maka/storage/session-todo-authority'; import { type OperationOutcome, type SessionCatalogItem, @@ -120,7 +120,7 @@ export interface HostSessionRetirementCoordinatorOptions { readonly capabilities: RetirementCapabilities; readonly continuity: RetirementContinuity; readonly artifacts: Pick; - readonly taskLedger: Pick; + readonly sessionTodo: Pick; readonly assertNoContextOffloadReferences?: (sessionIds: readonly string[]) => Promise; readonly purgeOperationalState: (sessionId: string) => Promise; readonly purgeAgentGraphState: (sessionId: string) => Promise; @@ -191,7 +191,7 @@ export class HostSessionRetirementCoordinator { readonly #capabilities: RetirementCapabilities; readonly #continuity: RetirementContinuity; readonly #artifacts: HostSessionRetirementCoordinatorOptions['artifacts']; - readonly #taskLedger: HostSessionRetirementCoordinatorOptions['taskLedger']; + readonly #sessionTodo: HostSessionRetirementCoordinatorOptions['sessionTodo']; readonly #assertNoContextOffloadReferences: HostSessionRetirementCoordinatorOptions['assertNoContextOffloadReferences']; readonly #purgeOperationalState: HostSessionRetirementCoordinatorOptions['purgeOperationalState']; readonly #purgeAgentGraphState: HostSessionRetirementCoordinatorOptions['purgeAgentGraphState']; @@ -219,7 +219,7 @@ export class HostSessionRetirementCoordinator { this.#capabilities = options.capabilities; this.#continuity = options.continuity; this.#artifacts = options.artifacts; - this.#taskLedger = options.taskLedger; + this.#sessionTodo = options.sessionTodo; this.#assertNoContextOffloadReferences = options.assertNoContextOffloadReferences; this.#purgeOperationalState = options.purgeOperationalState; this.#purgeAgentGraphState = options.purgeAgentGraphState; @@ -671,7 +671,7 @@ export class HostSessionRetirementCoordinator { purgeSessionSidecars( { artifacts: this.#artifacts, - taskLedger: this.#taskLedger, + sessionTodo: this.#sessionTodo, purgeOperationalState: this.#purgeOperationalState, }, sessionId, diff --git a/packages/runtime-host/src/server/session-revision-coordinator.ts b/packages/runtime-host/src/server/session-revision-coordinator.ts index 29c11860fc..16c918fff4 100644 --- a/packages/runtime-host/src/server/session-revision-coordinator.ts +++ b/packages/runtime-host/src/server/session-revision-coordinator.ts @@ -58,9 +58,9 @@ import { type ExecutionStoresWriter, } from '@maka/storage/execution-stores'; import { - authenticateInteractiveTaskLedgerWriter, - type InteractiveTaskLedgerWriter, -} from '@maka/storage/task-ledger-authority'; + authenticateInteractiveSessionTodoWriter, + type InteractiveSessionTodoWriter, +} from '@maka/storage/session-todo-authority'; import type { OperationOutcome, SessionConversationCopyInput, @@ -105,7 +105,7 @@ type ConversationCopyCreateInput = CreateSessionInput & { export interface HostSessionRevisionCoordinatorOptions { readonly stores: ExecutionStoresWriter<'interactive'>; readonly artifacts: InteractiveArtifactStoreWriter; - readonly taskLedger: InteractiveTaskLedgerWriter; + readonly sessionTodo: InteractiveSessionTodoWriter; readonly manager: SessionManager; readonly admission: SessionAdmissionGate; readonly continuity: SessionContinuityCoordinator; @@ -127,12 +127,12 @@ export class HostSessionRevisionCoordinator { readonly #stores: ExecutionStoresWriter<'interactive'>; readonly #artifacts: InteractiveArtifactStoreWriter; - readonly #taskLedger: InteractiveTaskLedgerWriter; + readonly #sessionTodo: InteractiveSessionTodoWriter; constructor(private readonly options: HostSessionRevisionCoordinatorOptions) { this.#stores = authenticateExecutionStoresWriter(options.stores, 'interactive'); this.#artifacts = authenticateInteractiveArtifactStoreWriter(options.artifacts); - this.#taskLedger = authenticateInteractiveTaskLedgerWriter(options.taskLedger); + this.#sessionTodo = authenticateInteractiveSessionTodoWriter(options.sessionTodo); } async recover(): Promise { @@ -550,13 +550,10 @@ export class HostSessionRevisionCoordinator { newId: randomUUID, }); const copiedMessages = runtimeCopy.copiedMessages; - await this.#taskLedger.copyConversationTaskLedger({ + await this.#sessionTodo.initializeCopy({ sourceSessionId: input.sourceSessionId, targetSessionId: input.targetSessionId, - turnIds: copyTurnIds, - ...(slice.beforeTs === undefined ? {} : { beforeTs: slice.beforeTs }), - runIdMap: runtimeCopy.runIdMap, - ...(kind === 'side_conversation' ? { linkedChildren: 'snapshot' as const } : {}), + copyCurrent: kind === 'branch' && slice.beforeTs === undefined, }); if (copiedMessages.length > 0) { await this.#stores.sessionStore.appendMessages(input.targetSessionId, [...copiedMessages]); @@ -821,7 +818,7 @@ export class HostSessionRevisionCoordinator { await purgeSessionSidecars( { artifacts: this.#artifacts, - taskLedger: this.#taskLedger, + sessionTodo: this.#sessionTodo, purgeOperationalState: (sessionId) => this.#stores.purgeConversationOperationalState(sessionId), }, diff --git a/packages/runtime-host/src/server/session-sidecar-purge.ts b/packages/runtime-host/src/server/session-sidecar-purge.ts index b1cdbcf2e2..54a09e4676 100644 --- a/packages/runtime-host/src/server/session-sidecar-purge.ts +++ b/packages/runtime-host/src/server/session-sidecar-purge.ts @@ -18,11 +18,11 @@ */ import type { InteractiveArtifactStoreWriter } from '@maka/storage/artifact-stores'; -import type { InteractiveTaskLedgerWriter } from '@maka/storage/task-ledger-authority'; +import type { InteractiveSessionTodoWriter } from '@maka/storage/session-todo-authority'; export interface SessionSidecarPurgeAuthority { readonly artifacts: Pick; - readonly taskLedger: Pick; + readonly sessionTodo: Pick; readonly purgeOperationalState: (sessionId: string) => Promise; } @@ -32,7 +32,7 @@ export async function purgeSessionSidecars( ): Promise { const outcomes = await Promise.allSettled([ authority.artifacts.purgeSessionArtifacts(sessionId), - authority.taskLedger.purgeConversationTaskLedger(sessionId), + authority.sessionTodo.purgeSessionState(sessionId), authority.purgeOperationalState(sessionId), ]); const failures = outcomes.flatMap((outcome) => diff --git a/packages/runtime-host/src/server/session-todo-coordinator.ts b/packages/runtime-host/src/server/session-todo-coordinator.ts new file mode 100644 index 0000000000..f46ee073a7 --- /dev/null +++ b/packages/runtime-host/src/server/session-todo-coordinator.ts @@ -0,0 +1,94 @@ +/* + * 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 { SessionTodoSnapshot } from '@maka/core/session-todo'; +import { + authenticateInteractiveSessionTodoWriter, + type InteractiveSessionTodoWriter, +} from '@maka/storage/session-todo-authority'; +import type { OperationOutcome, SessionTodoQueryResult } from '../protocol/index.js'; +import type { SessionTodoOperationHandlerMap } from './operation-dispatcher.js'; +import { SessionAdmissionGate } from './session-admission-gate.js'; +import type { SessionPresenceReader } from './session-presence.js'; + +export interface SessionTodoPort { + read(sessionId: string): Promise; + replace(sessionId: string, items: unknown): Promise; +} + +/** Host-owned admission and publication boundary for the SessionTodo document. */ +export class HostSessionTodoCoordinator implements SessionTodoPort { + readonly handlers: SessionTodoOperationHandlerMap = { + 'session.todo.query': (input) => this.#query(input.sessionId), + }; + + readonly #writer: InteractiveSessionTodoWriter; + + constructor( + writer: InteractiveSessionTodoWriter, + private readonly sessionAdmission: SessionAdmissionGate, + private readonly sessions: SessionPresenceReader, + private readonly onChanged: (sessionId: string) => void, + private readonly requestDrain: () => void, + ) { + this.#writer = authenticateInteractiveSessionTodoWriter(writer); + } + + read(sessionId: string): Promise { + return this.sessionAdmission.run(sessionId, async () => { + await this.#requirePresent(sessionId); + return this.#writer.readOrBootstrap(sessionId); + }); + } + + replace(sessionId: string, items: unknown): Promise { + return this.sessionAdmission.run(sessionId, async () => { + await this.#requirePresent(sessionId); + const snapshot = await this.#writer.replaceAll(sessionId, items); + try { + this.onChanged(sessionId); + } catch { + // The document is already committed. A projection failure drains the + // Host but must never turn a successful whole-document write into an + // ambiguous retry that could overwrite a later writer. + this.requestDrain(); + } + return snapshot; + }); + } + + async #query(sessionId: string): Promise> { + try { + const snapshot = await this.read(sessionId); + const result: SessionTodoQueryResult = { sessionId, items: snapshot.items }; + return { ok: true, result }; + } catch (error) { + if ((await this.sessions.probeSessionRemoval(sessionId)).kind !== 'present') { + return { ok: false, error: { code: 'not_found', message: 'Session was not found' } }; + } + throw error; + } + } + + async #requirePresent(sessionId: string): Promise { + if ((await this.sessions.probeSessionRemoval(sessionId)).kind !== 'present') { + throw new Error(`Session was not found: ${sessionId}`); + } + } +} diff --git a/packages/runtime-host/src/server/task-ledger-coordinator.ts b/packages/runtime-host/src/server/task-ledger-coordinator.ts deleted file mode 100644 index e447fd995a..0000000000 --- a/packages/runtime-host/src/server/task-ledger-coordinator.ts +++ /dev/null @@ -1,249 +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 { createHash } from 'node:crypto'; -import { - findTaskByRef, - type Task, - type TaskAgentOutcome, - type TaskAvailableClaimScope, - type TaskLedgerChangedEvent, - type TaskLedgerListOptions, - type TaskLedgerMutationContext, - type TaskLedgerStore, - type TaskOwner, -} from '@maka/core/task-ledger'; -import { - authenticateInteractiveTaskLedgerWriter, - type InteractiveTaskLedgerWriter, -} from '@maka/storage/task-ledger-authority'; -import { - encodeTaskLedgerTask, - encodeTaskLedgerQueryResult, - TASK_LEDGER_PAGE_MAX_BYTES, - TASK_LEDGER_PAGE_MAX_ITEMS, - type OperationOutcome, - type TaskLedgerQueryInput, - type TaskLedgerQueryResult, - type TaskLedgerRevision, - type TaskLedgerTask, -} from '../protocol/index.js'; -import type { TaskLedgerOperationHandlerMap } from './operation-dispatcher.js'; -import { SessionAdmissionGate } from './session-admission-gate.js'; -import type { SessionPresenceReader } from './session-presence.js'; - -const CANONICAL_LIST_OPTIONS = Object.freeze({ - includeTerminal: true, - includeArchived: false, - classifyResumeTrust: true, -}); - -/** The Host-owned Task Ledger authority shared by Client queries and Runtime tools. */ -export class HostTaskLedgerCoordinator implements TaskLedgerStore { - readonly handlers: TaskLedgerOperationHandlerMap = { - 'task.ledger.query': (input) => this.#query(input), - }; - - readonly #writer: InteractiveTaskLedgerWriter; - - constructor( - writer: InteractiveTaskLedgerWriter, - private readonly sessionAdmission: SessionAdmissionGate, - private readonly sessions: SessionPresenceReader, - ) { - this.#writer = authenticateInteractiveTaskLedgerWriter(writer); - } - - list(sessionId: string, options?: TaskLedgerListOptions): Promise { - return this.sessionAdmission.run(sessionId, () => this.#writer.list(sessionId, options)); - } - - get(sessionId: string, id: string, options?: TaskLedgerListOptions): Promise { - return this.sessionAdmission.run(sessionId, () => this.#writer.get(sessionId, id, options)); - } - - create( - sessionId: string, - drafts: unknown, - context?: TaskLedgerMutationContext, - ): Promise<{ created: Task[]; total: number }> { - return this.sessionAdmission.run(sessionId, () => - this.#writer.create(sessionId, drafts, context), - ); - } - - update( - sessionId: string, - id: string, - patch: unknown, - context?: TaskLedgerMutationContext, - ): Promise<{ updated: Task; total: number }> { - return this.sessionAdmission.run(sessionId, () => - this.#writer.update(sessionId, id, patch, context), - ); - } - - claim( - sessionId: string, - id: string, - owner: TaskOwner, - context?: TaskLedgerMutationContext, - ): Promise<{ updated: Task; total: number }> { - return this.sessionAdmission.run(sessionId, () => - this.#writer.claim(sessionId, id, owner, context), - ); - } - - claimAvailable( - sessionId: string, - id: string, - owner: TaskOwner, - scope: TaskAvailableClaimScope, - context?: TaskLedgerMutationContext, - ): Promise<{ updated: Task; total: number }> { - return this.sessionAdmission.run(sessionId, () => - this.#writer.claimAvailable(sessionId, id, owner, scope, context), - ); - } - - settleAgentOutcome( - sessionId: string, - id: string, - outcome: TaskAgentOutcome, - context?: TaskLedgerMutationContext, - ): Promise<{ updated: Task; total: number }> { - return this.sessionAdmission.run(sessionId, () => - this.#writer.settleAgentOutcome(sessionId, id, outcome, context), - ); - } - - subscribe(listener: (event: TaskLedgerChangedEvent) => void): () => void { - return this.#writer.subscribe(listener); - } - - #query(input: TaskLedgerQueryInput): Promise> { - return this.sessionAdmission.run(input.sessionId, async () => { - if ((await this.sessions.probeSessionRemoval(input.sessionId)).kind !== 'present') { - return notFound('Session was not found'); - } - const tasks = (await this.#writer.list(input.sessionId, CANONICAL_LIST_OPTIONS)).map( - encodeTaskLedgerTask, - ); - const revision = taskLedgerRevision(tasks); - - if (input.kind === 'get') { - return success( - encodeTaskLedgerQueryResult({ - kind: 'task', - sessionId: input.sessionId, - revision, - task: findTaskByRef(tasks, input.taskRef) ?? null, - }), - ); - } - - if (input.kind === 'list_continue' && input.revision !== revision) { - return success({ - kind: 'revision_changed', - expected: input.revision, - actual: revision, - }); - } - - const offset = input.kind === 'list_start' ? 0 : decodeCursor(input.cursor); - if ( - offset === undefined || - offset > tasks.length || - (input.kind === 'list_continue' && offset === tasks.length) - ) { - return invalidRequest('Task ledger cursor is invalid'); - } - return success(createPage(input.sessionId, revision, tasks, offset)); - }); - } -} - -function taskLedgerRevision(tasks: readonly TaskLedgerTask[]): TaskLedgerRevision { - return `sha256:${createHash('sha256').update(JSON.stringify(tasks)).digest('hex')}`; -} - -function createPage( - sessionId: string, - revision: TaskLedgerRevision, - tasks: readonly TaskLedgerTask[], - offset: number, -): TaskLedgerQueryResult { - const pageTasks: TaskLedgerTask[] = []; - for (let index = offset; index < tasks.length; index += 1) { - if (pageTasks.length >= TASK_LEDGER_PAGE_MAX_ITEMS) break; - const task = tasks[index]; - if (!task) throw invariantFailure('Task projection index was out of bounds'); - const candidateTasks = [...pageTasks, task]; - const nextOffset = index + 1; - const candidate = { - kind: 'page' as const, - sessionId, - revision, - tasks: candidateTasks, - nextCursor: nextOffset < tasks.length ? encodeCursor(nextOffset) : null, - }; - if (Buffer.byteLength(JSON.stringify(candidate), 'utf8') > TASK_LEDGER_PAGE_MAX_BYTES) { - break; - } - pageTasks.push(task); - } - - if (pageTasks.length === 0 && offset < tasks.length) { - throw invariantFailure('A canonical Task exceeded the page result byte limit'); - } - const nextOffset = offset + pageTasks.length; - return encodeTaskLedgerQueryResult({ - kind: 'page', - sessionId, - revision, - tasks: pageTasks, - nextCursor: nextOffset < tasks.length ? encodeCursor(nextOffset) : null, - }); -} - -function encodeCursor(offset: number): string { - return String(offset); -} - -function decodeCursor(cursor: string): number | undefined { - if (!/^(?:0|[1-9]\d*)$/.test(cursor)) return undefined; - const offset = Number(cursor); - return Number.isSafeInteger(offset) ? offset : undefined; -} - -function success(result: TaskLedgerQueryResult): OperationOutcome<'task.ledger.query'> { - return { ok: true, result }; -} - -function invalidRequest(message: string): OperationOutcome<'task.ledger.query'> { - return { ok: false, error: { code: 'invalid_request', message } }; -} - -function notFound(message: string): OperationOutcome<'task.ledger.query'> { - return { ok: false, error: { code: 'not_found', message } }; -} - -function invariantFailure(message: string): Error { - return new Error(`Task ledger coordinator invariant failed: ${message}`); -} diff --git a/packages/runtime/package.json b/packages/runtime/package.json index a5ac78b2e7..5ed51233f8 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -21,6 +21,7 @@ "./test-connection": "./dist/test-connection.js", "./model-fetcher": "./dist/model-fetcher.js", "./session-manager": "./dist/session-manager.js", + "./session-todo-tools": "./dist/session-todo-tools.js", "./test-only/fake-backend": "./dist/test-only/fake-backend.js", "./test-only/observation-text-reader": "./dist/__tests__/observation-text-reader.js", "./filesystem-worker": "./dist/filesystem-worker/index.js", @@ -101,7 +102,6 @@ "./system-prompt/personalization-prompt": "./dist/system-prompt/personalization-prompt.js", "./system-prompt/project-context": "./dist/system-prompt/project-context.js", "./system-prompt/workspace-instructions": "./dist/system-prompt/workspace-instructions.js", - "./task-ledger-tools": "./dist/task-ledger-tools.js", "./tavily-search": "./dist/tavily-search.js", "./terminal-run-commit": "./dist/terminal-run-commit.js", "./tool-availability": "./dist/tool-availability.js", diff --git a/packages/runtime/src/__tests__/session-todo-tools.test.ts b/packages/runtime/src/__tests__/session-todo-tools.test.ts new file mode 100644 index 0000000000..cefd048c25 --- /dev/null +++ b/packages/runtime/src/__tests__/session-todo-tools.test.ts @@ -0,0 +1,116 @@ +/* + * 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 { test } from 'node:test'; +import { SESSION_TODO_CONTENT_MAX_CHARS } from '@maka/core/session-todo'; +import { z } from 'zod'; +import { buildSessionTodoTools, type SessionTodoToolStore } from '../session-todo-tools.js'; + +const context = { + sessionId: 'session-1', + turnId: 'turn-1', + cwd: '/tmp', + toolCallId: 'tool-1', + abortSignal: new AbortController().signal, + emitOutput: () => {}, +}; + +test('todo_read returns the complete committed snapshot and names empty distinctly', async () => { + const store: SessionTodoToolStore = { + read: async () => ({ items: [] }), + replace: async () => assert.fail('read must not write'), + }; + const read = buildSessionTodoTools(store).find((tool) => tool.name === 'todo_read')!; + assert.equal(await read.impl({}, context), 'Todo list is empty.'); +}); + +test('todo_write renders only the store-returned committed snapshot', async () => { + const store: SessionTodoToolStore = { + read: async () => ({ items: [] }), + replace: async () => ({ + items: [{ content: 'committed result', status: 'in_progress' }], + }), + }; + const write = buildSessionTodoTools(store).find((tool) => tool.name === 'todo_write')!; + const result = await write.impl( + { todos: [{ content: 'uncommitted args', status: 'pending' }] }, + context, + ); + assert.match(String(result), /committed result/); + assert.doesNotMatch(String(result), /uncommitted args/); +}); + +test('todo_write names a successful clear and propagates failure', async () => { + let fail = false; + const store: SessionTodoToolStore = { + read: async () => ({ items: [] }), + replace: async () => { + if (fail) throw new Error('write failed'); + return { items: [] }; + }, + }; + const write = buildSessionTodoTools(store).find((tool) => tool.name === 'todo_write')!; + assert.equal(await write.impl({ todos: [] }, context), 'Todo list cleared.'); + fail = true; + await assert.rejects(() => Promise.resolve(write.impl({ todos: [] }, context)), /write failed/); +}); + +test('tool schemas enforce the exact bounded document while counting Unicode characters', () => { + const store: SessionTodoToolStore = { + read: async () => ({ items: [] }), + replace: async () => ({ items: [] }), + }; + const tools = buildSessionTodoTools(store); + const read = tools.find((tool) => tool.name === 'todo_read')!; + const write = tools.find((tool) => tool.name === 'todo_write')!; + const readParameters = read.parameters as z.ZodType; + const writeParameters = write.parameters as z.ZodType; + assert.equal(readParameters.safeParse({ revision: 1 }).success, false); + assert.equal( + writeParameters.safeParse({ + todos: [{ content: '😀'.repeat(SESSION_TODO_CONTENT_MAX_CHARS), status: 'pending' }], + }).success, + true, + ); + assert.equal( + writeParameters.safeParse({ + todos: [{ content: 'one', status: 'pending', id: 'legacy-task-id' }], + }).success, + false, + ); +}); + +test('todo tool results use the shared display-safe content projection', async () => { + const store: SessionTodoToolStore = { + read: async () => ({ + items: [ + { + content: + 'deploy\u001b[31m \u001b]0;spoofed\u0007 \u202ereversed\u202c zero\u200bwidth sk-live-secret-token ', + status: 'pending', + }, + ], + }), + replace: async () => ({ items: [] }), + }; + const read = buildSessionTodoTools(store).find((tool) => tool.name === 'todo_read')!; + const rendered = String(await read.impl({}, context)); + assert.doesNotMatch(rendered, /\u001b|\u0007|\u202e|\u202c|\u200b|sk-live-secret|session-todo/i); + assert.match(rendered, /|\[redacted\]/); +}); diff --git a/packages/runtime/src/__tests__/subagent-tools.test.ts b/packages/runtime/src/__tests__/subagent-tools.test.ts index f306e22220..3eb0cdaa18 100644 --- a/packages/runtime/src/__tests__/subagent-tools.test.ts +++ b/packages/runtime/src/__tests__/subagent-tools.test.ts @@ -26,7 +26,6 @@ import { join } from 'node:path'; import { describe, test } from 'node:test'; import type { LlmConnection } from '@maka/core/llm-connections'; import type { SessionHeader } from '@maka/core/session'; -import type { Task, TaskAgentOutcome, TaskLedgerStore, TaskOwner } from '@maka/core/task-ledger'; import type { SessionEvent } from '@maka/core/events'; import { zodSchema } from 'ai'; import { buildBuiltinTools } from '../builtin-tools.js'; @@ -101,7 +100,7 @@ describe('subagent tools', () => { ); }); - test('agent_spawn advertises task_id only when task binding is available', async () => { + test('agent_spawn does not advertise retired task binding', async () => { const advertisedProperties = async (tool: MakaTool) => { const schema = (await zodSchema(tool.parameters as never).jsonSchema) as { properties?: Record; @@ -116,14 +115,6 @@ describe('subagent tools', () => { 'write_back', 'isolation', ]); - assert.deepStrictEqual( - Object.keys( - await advertisedProperties( - buildSubagentSpawnTool({ taskLedger: taskLedgerStub(undefined, []) }), - ), - ), - ['profile', 'subagent_id', 'task', 'write_back', 'isolation', 'task_id'], - ); }); test('agent_spawn strips task_id when task binding is unavailable', () => { @@ -422,7 +413,6 @@ describe('subagent tools', () => { { profile: LOCAL_READ_AGENT_PROFILE, task: 'Inspect the runtime tests.', - task_id: 'ignored-without-task-binding', }, { sessionId: 'session-1', @@ -632,258 +622,6 @@ describe('subagent tools', () => { assert.strictEqual((output[1]?.length ?? Number.POSITIVE_INFINITY) < 1_100, true); }); - test('agent_spawn binds a current-session task and records real child refs without auto-completing', async () => { - const task: Task = { - id: 'task-uuid', - key: 'T1', - subject: 'inspect runtime', - status: 'pending', - createdAt: 1, - updatedAt: 1, - }; - const calls: string[] = []; - const ledger = taskLedgerStub(task, calls); - const tool = buildSubagentSpawnTool({ taskLedger: ledger }); - const result = await tool.impl( - { - profile: LOCAL_READ_AGENT_PROFILE, - task: 'Inspect the runtime tests.', - task_id: 'T1', - }, - { - sessionId: 'session-1', - turnId: 'parent-turn', - cwd: '/tmp/cwd', - toolCallId: 'tool-1', - abortSignal: new AbortController().signal, - emitOutput: () => {}, - spawnChildSession: async (input) => { - await input.onReady?.({ - childSessionId: 'child-session', - runId: 'child-run', - turnId: 'child-turn', - agentId: requireBuiltinAgentDefinitionByProfile(input.agentProfile).id, - agentName: requireBuiltinAgentDefinitionByProfile(input.agentProfile).name, - permissionMode: 'explore', - }); - return { - agentId: requireBuiltinAgentDefinitionByProfile(input.agentProfile).id, - agentName: requireBuiltinAgentDefinitionByProfile(input.agentProfile).name, - runId: 'child-run', - turnId: 'child-turn', - status: 'completed', - permissionMode: 'explore', - summary: 'inspection complete', - artifactIds: [], - }; - }, - }, - ); - assert.deepStrictEqual(calls, [ - 'get:session-1:T1', - 'claim:child-turn', - 'settle:completed:child-run', - ]); - assert.strictEqual(task.status, 'in_progress'); - assert.deepStrictEqual(task.owner, { - actor: 'child_agent', - sessionId: 'child-session', - agentId: LOCAL_READ_AGENT_ID, - runId: 'child-run', - turnId: 'child-turn', - }); - assert.partialDeepStrictEqual(result, { - kind: 'subagent', - runId: 'child-run', - status: 'completed', - }); - }); - - test('agent_spawn rejects a forged task reference before starting a child', async () => { - let spawned = false; - const ledger = taskLedgerStub(undefined, []); - const tool = buildSubagentSpawnTool({ taskLedger: ledger }); - await expectRejects( - Promise.resolve( - tool.impl( - { - profile: LOCAL_READ_AGENT_PROFILE, - task: 'Inspect.', - task_id: 'T99', - }, - { - sessionId: 'session-1', - turnId: 'parent-turn', - cwd: '/tmp', - toolCallId: 'tool-1', - abortSignal: new AbortController().signal, - emitOutput: () => {}, - spawnChildSession: async () => { - spawned = true; - return {}; - }, - }, - ), - ), - /No such task in this session/, - ); - assert.strictEqual(spawned, false); - }); - - test('agent_spawn records failed and cancelled child outcomes with real refs', async () => { - for (const status of ['failed', 'cancelled'] as const) { - const task: Task = { - id: `task-${status}`, - key: 'T1', - subject: status, - status: 'pending', - createdAt: 1, - updatedAt: 1, - }; - const calls: string[] = []; - const tool = buildSubagentSpawnTool({ taskLedger: taskLedgerStub(task, calls) }); - const result = await tool.impl( - { - profile: LOCAL_READ_AGENT_PROFILE, - task: `Run child that becomes ${status}.`, - task_id: task.key, - }, - { - sessionId: 'session-1', - turnId: 'parent-turn', - cwd: '/tmp', - toolCallId: 'tool-1', - abortSignal: new AbortController().signal, - emitOutput: () => {}, - spawnChildSession: async (input) => { - await input.onReady?.({ - childSessionId: 'child-session', - runId: 'child-run', - turnId: `child-${status}`, - agentId: requireBuiltinAgentDefinitionByProfile(input.agentProfile).id, - agentName: requireBuiltinAgentDefinitionByProfile(input.agentProfile).name, - permissionMode: 'explore', - }); - return { - agentId: requireBuiltinAgentDefinitionByProfile(input.agentProfile).id, - agentName: requireBuiltinAgentDefinitionByProfile(input.agentProfile).name, - runId: `run-${status}`, - turnId: `child-${status}`, - status, - permissionMode: 'explore', - summary: `${status} summary`, - artifactIds: [], - }; - }, - }, - ); - assert.deepStrictEqual(calls, [ - 'get:session-1:T1', - `claim:child-${status}`, - `settle:${status}:run-${status}`, - ]); - assert.strictEqual(task.status, status); - assert.deepStrictEqual(task.owner, { - actor: 'child_agent', - sessionId: 'child-session', - agentId: LOCAL_READ_AGENT_ID, - runId: `run-${status}`, - turnId: `child-${status}`, - }); - assert.partialDeepStrictEqual(result, { kind: 'subagent', status, runId: `run-${status}` }); - } - }); - - test('agent_spawn marks a claimed task failed when child startup throws', async () => { - const task: Task = { - id: 'task-startup-failure', - key: 'T1', - subject: 'startup', - status: 'pending', - createdAt: 1, - updatedAt: 1, - }; - const calls: string[] = []; - const tool = buildSubagentSpawnTool({ taskLedger: taskLedgerStub(task, calls) }); - await expectRejects( - Promise.resolve( - tool.impl( - { - profile: LOCAL_READ_AGENT_PROFILE, - task: 'Fail after allocating the child turn.', - task_id: task.key, - }, - { - sessionId: 'session-1', - turnId: 'parent-turn', - cwd: '/tmp', - toolCallId: 'tool-1', - abortSignal: new AbortController().signal, - emitOutput: () => {}, - spawnChildSession: async (input) => { - await input.onReady?.({ - childSessionId: 'child-session', - runId: 'child-run', - turnId: 'child-turn', - agentId: requireBuiltinAgentDefinitionByProfile(input.agentProfile).id, - agentName: requireBuiltinAgentDefinitionByProfile(input.agentProfile).name, - permissionMode: 'explore', - }); - throw new Error('child startup failed'); - }, - }, - ), - ), - /child startup failed/, - ); - assert.deepStrictEqual(calls, [ - 'get:session-1:T1', - 'claim:child-turn', - 'settle:failed:undefined', - ]); - assert.strictEqual(task.status, 'failed'); - }); - - test('agent_spawn rejects a task reference that only exists in another session', async () => { - const task: Task = { - id: 'other-task', - key: 'T1', - subject: 'other', - status: 'pending', - createdAt: 1, - updatedAt: 1, - }; - const ledger = taskLedgerStub(task, []); - ledger.get = async (sessionId) => (sessionId === 'session-2' ? task : undefined); - let spawned = false; - const tool = buildSubagentSpawnTool({ taskLedger: ledger }); - await expectRejects( - Promise.resolve( - tool.impl( - { - profile: LOCAL_READ_AGENT_PROFILE, - task: 'Inspect.', - task_id: task.key, - }, - { - sessionId: 'session-1', - turnId: 'parent-turn', - cwd: '/tmp', - toolCallId: 'tool-1', - abortSignal: new AbortController().signal, - emitOutput: () => {}, - spawnChildSession: async () => { - spawned = true; - return {}; - }, - }, - ), - ), - /No such task in this session/, - ); - assert.strictEqual(spawned, false); - }); - test('agent_spawn validates profile contracts and delegates worktree availability to runtime', async () => { const tool = buildSubagentSpawnTool(); const schema = tool.parameters as { @@ -1379,40 +1117,3 @@ function testConnection(): LlmConnection { updatedAt: 1, }; } -function taskLedgerStub(task: Task | undefined, calls: string[]): TaskLedgerStore { - return { - list: async () => (task ? [task] : []), - get: async (sessionId, id) => { - calls.push(`get:${sessionId}:${id}`); - return task && (task.id === id || task.key === id) ? task : undefined; - }, - create: async () => ({ created: [], total: task ? 1 : 0 }), - update: async () => { - if (!task) throw new Error('No such task'); - return { updated: task, total: 1 }; - }, - claim: async (_sessionId, _id, owner: TaskOwner) => { - if (!task) throw new Error('No such task'); - calls.push(`claim:${owner.turnId}`); - task.status = 'in_progress'; - task.owner = owner; - return { updated: task, total: 1 }; - }, - claimAvailable: async (_sessionId, _id, owner: TaskOwner) => { - if (!task) throw new Error('No such task'); - calls.push(`claimAvailable:${owner.turnId}`); - task.status = 'in_progress'; - task.owner = owner; - return { updated: task, total: 1 }; - }, - settleAgentOutcome: async (_sessionId, _id, outcome: TaskAgentOutcome) => { - if (!task) throw new Error('No such task'); - calls.push(`settle:${outcome.status}:${outcome.owner.runId}`); - task.owner = outcome.owner; - if (outcome.status === 'failed') task.status = 'failed'; - if (outcome.status === 'cancelled') task.status = 'cancelled'; - return { updated: task, total: 1 }; - }, - subscribe: () => () => {}, - }; -} diff --git a/packages/runtime/src/__tests__/task-ledger-tools.test.ts b/packages/runtime/src/__tests__/task-ledger-tools.test.ts deleted file mode 100644 index c86d0c4439..0000000000 --- a/packages/runtime/src/__tests__/task-ledger-tools.test.ts +++ /dev/null @@ -1,574 +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 { describe, test } from 'node:test'; -import assert from 'node:assert/strict'; -import { z } from 'zod'; -import { - TASK_EVIDENCE_MAX_CHARS, - TASK_LEDGER_MAX_TASKS, - TASK_SUBJECT_MAX_CHARS, - type Task, - type TaskAgentOutcome, - type TaskLedgerListOptions, - type TaskLedgerMutationContext, - type TaskLedgerStore, - type TaskOwner, -} from '@maka/core/task-ledger'; -import { - TASK_CREATE_TOOL_NAME, - TASK_GET_TOOL_NAME, - TASK_LIST_TOOL_NAME, - TASK_UPDATE_TOOL_NAME, - buildTaskLedgerTools, -} from '../task-ledger-tools.js'; -import type { MakaTool, MakaToolContext } from '../tool-runtime.js'; - -const SESSION_ID = 'sess-1'; - -class FakeTaskLedgerStore implements TaskLedgerStore { - private tasks: Task[] = []; - public createCalls: Array<{ - sessionId: string; - drafts: unknown; - context?: TaskLedgerMutationContext; - }> = []; - public updateCalls: Array<{ - sessionId: string; - id: string; - patch: unknown; - context?: TaskLedgerMutationContext; - }> = []; - public listCalls: Array<{ sessionId: string; options?: TaskLedgerListOptions }> = []; - - seed(tasks: Task[]): void { - this.tasks = tasks.map((task) => ({ ...task })); - } - - async list(sessionId: string, options?: TaskLedgerListOptions): Promise { - this.listCalls.push({ sessionId, options }); - return this.tasks - .filter((task) => { - if (options?.status && task.status !== options.status) return false; - if ( - options?.includeTerminal === false && - ['completed', 'failed', 'cancelled'].includes(task.status) - ) - return false; - return true; - }) - .map((t) => ({ - ...t, - ...(options?.classifyResumeTrust === true && t.status === 'in_progress' - ? { resumeTrust: 'stale' as const } - : {}), - })); - } - - async get( - _sessionId: string, - id: string, - options?: TaskLedgerListOptions, - ): Promise { - const task = this.tasks.find((t) => t.id === id || t.key === id); - return task - ? { - ...task, - ...(options?.classifyResumeTrust === true && task.status === 'in_progress' - ? { resumeTrust: 'stale' as const } - : {}), - } - : undefined; - } - - async create( - sessionId: string, - drafts: unknown, - context?: TaskLedgerMutationContext, - ): Promise<{ created: Task[]; total: number }> { - this.createCalls.push({ sessionId, drafts, context }); - const now = Date.now(); - const created = (drafts as Array<{ subject: string }>).map((d, i) => ({ - id: `id-${this.tasks.length + i}`, - key: `T${this.tasks.length + i + 1}`, - subject: d.subject, - status: 'pending' as const, - createdAt: now, - updatedAt: now, - })); - this.tasks.push(...created); - return { created, total: this.tasks.length }; - } - - async update( - sessionId: string, - id: string, - patch: unknown, - context?: TaskLedgerMutationContext, - ): Promise<{ updated: Task; total: number }> { - this.updateCalls.push({ sessionId, id, patch, context }); - const task = this.tasks.find((t) => t.id === id); - if (!task) throw new Error(`No such task: ${id}`); - Object.assign(task, patch, { updatedAt: Date.now() }); - return { updated: { ...task }, total: this.tasks.length }; - } - - async claim( - _sessionId: string, - id: string, - owner: TaskOwner, - ): Promise<{ updated: Task; total: number }> { - const task = this.tasks.find((item) => item.id === id || item.key === id); - if (!task) throw new Error(`No such task: ${id}`); - Object.assign(task, { status: 'in_progress', owner }); - return { updated: { ...task }, total: this.tasks.length }; - } - - async claimAvailable( - sessionId: string, - id: string, - owner: TaskOwner, - ): Promise<{ updated: Task; total: number }> { - return this.claim(sessionId, id, owner); - } - - async settleAgentOutcome( - _sessionId: string, - id: string, - outcome: TaskAgentOutcome, - ): Promise<{ updated: Task; total: number }> { - const task = this.tasks.find((item) => item.id === id || item.key === id); - if (!task) throw new Error(`No such task: ${id}`); - Object.assign(task, { owner: outcome.owner }); - return { updated: { ...task }, total: this.tasks.length }; - } - - subscribe(): () => void { - return () => {}; - } -} - -function fakeContext(sessionId: string, runId?: string): MakaToolContext { - return { - sessionId, - turnId: 'turn-1', - ...(runId ? { runId } : {}), - cwd: '/tmp', - toolCallId: 'call-1', - abortSignal: new AbortController().signal, - emitOutput: () => {}, - }; -} - -function findTool(tools: MakaTool[], name: string): MakaTool { - const tool = tools.find((t) => t.name === name); - assert.ok(tool, `expected tool ${name}`); - return tool; -} - -describe('task ledger tools', () => { - test('task_create schema rejects a batch larger than the ledger cap and accepts the cap boundary', () => { - const create = findTool( - buildTaskLedgerTools({ store: new FakeTaskLedgerStore() }), - TASK_CREATE_TOOL_NAME, - ); - const params = create.parameters as z.ZodType; - const atCap = { - tasks: Array.from({ length: TASK_LEDGER_MAX_TASKS }, () => ({ subject: 'x' })), - }; - assert.equal( - params.safeParse(atCap).success, - true, - `${TASK_LEDGER_MAX_TASKS} tasks (cap) must pass`, - ); - const overCap = { - tasks: Array.from({ length: TASK_LEDGER_MAX_TASKS + 1 }, () => ({ subject: 'x' })), - }; - assert.equal( - params.safeParse(overCap).success, - false, - `${TASK_LEDGER_MAX_TASKS + 1} tasks must be rejected at the schema`, - ); - }); - - test('task_update schema rejects ids that are not stable tokens and accepts UUID-shaped / simple ids', () => { - const update = findTool( - buildTaskLedgerTools({ store: new FakeTaskLedgerStore() }), - TASK_UPDATE_TOOL_NAME, - ); - const params = update.parameters as z.ZodType; - const reject = [ - 'ab', - 'abc\ndef', - 'a b', - 'X'.repeat(5000), - '', - 'ghp_abcdefghijklmnopqrstuvwxyz', - 'sk-abcdefghi', - 'a'.repeat(40), - ]; - for (const id of reject) { - assert.equal( - params.safeParse({ id, status: 'completed', completionEvidence: 'done' }).success, - false, - `id ${JSON.stringify(id)} must be rejected`, - ); - } - const accept = ['123e4567-e89b-12d3-a456-426614174000', 'good-id_1:2']; - for (const id of accept) { - assert.equal( - params.safeParse({ id, status: 'completed', completionEvidence: 'done' }).success, - true, - `id ${id} must pass`, - ); - } - }); - - test('task_create result shows only the created tasks (with ids) and total, not the pre-existing ledger', async () => { - const store = new FakeTaskLedgerStore(); - const tools = buildTaskLedgerTools({ store }); - const create = findTool(tools, TASK_CREATE_TOOL_NAME); - // a pre-existing task that must NOT be replayed in the create result - await create.impl({ tasks: [{ subject: 'pre-existing' }] }, fakeContext(SESSION_ID)); - const result = String( - await create.impl({ tasks: [{ subject: 'new-task' }] }, fakeContext(SESSION_ID)), - ); - assert.match(result, /new-task/, 'result must include the created task'); - assert.match(result, /ledger total: 2/, 'result must include the ledger total'); - assert.equal( - result.includes('pre-existing'), - false, - 'result must not replay the pre-existing ledger', - ); - // the new task's id is present so the model can update it next - const all = await store.list(SESSION_ID); - const newId = all.find((t) => t.subject === 'new-task')?.id; - assert.ok(newId, 'new task must have been created'); - assert.equal(result.includes(newId), true, 'result must include the new task id'); - }); - - test('task_update result shows only the updated task and total, not the rest of the ledger', async () => { - const store = new FakeTaskLedgerStore(); - const tools = buildTaskLedgerTools({ store }); - const create = findTool(tools, TASK_CREATE_TOOL_NAME); - const update = findTool(tools, TASK_UPDATE_TOOL_NAME); - await create.impl( - { tasks: [{ subject: 'keep-1' }, { subject: 'keep-2' }, { subject: 'target' }] }, - fakeContext(SESSION_ID), - ); - const all = await store.list(SESSION_ID); - const target = all.find((t) => t.subject === 'target'); - assert.ok(target); - const result = String( - await update.impl( - { id: target.id, status: 'completed', completionEvidence: 'verified done' }, - fakeContext(SESSION_ID), - ), - ); - assert.match(result, /target/, 'result must include the updated task subject'); - assert.match(result, /ledger total: 3/, 'result must include the ledger total'); - assert.equal(result.includes('keep-1'), false, 'result must not replay unrelated tasks'); - assert.equal(result.includes('keep-2'), false, 'result must not replay unrelated tasks'); - }); - - test('tool results scrub secret-like subjects before they persist into history', async () => { - // Same samples the core redactSecrets tests use. Tool results replay to - // the provider, so redaction must happen before they persist into history. - const store = new FakeTaskLedgerStore(); - const tools = buildTaskLedgerTools({ store }); - const create = findTool(tools, TASK_CREATE_TOOL_NAME); - const update = findTool(tools, TASK_UPDATE_TOOL_NAME); - - const createResult = String( - await create.impl( - { tasks: [{ subject: '轮换 Bearer sk-live-secret-token-value' }] }, - fakeContext(SESSION_ID), - ), - ); - assert.equal(createResult.includes('sk-live-secret-token-value'), false); - assert.match(createResult, /\[redacted\]/); - - const updateResult = String( - await update.impl( - { id: 'id-0', subject: '换 ghp_abcdefghijklmnopqrstuvwxyz' }, - fakeContext(SESSION_ID), - ), - ); - assert.equal(updateResult.includes('ghp_abcdefghijklmnopqrstuvwxyz'), false); - }); - - test('tool results strip tag variants so a subject cannot smuggle envelope tags into history', async () => { - const store = new FakeTaskLedgerStore(); - const create = findTool(buildTaskLedgerTools({ store }), TASK_CREATE_TOOL_NAME); - const variants = [ - '', - '', - '', - '', - '', - '', - ]; - const drafts = variants.map((v) => ({ subject: '正常 ' + v + ' 假指令' })); - const result = String(await create.impl({ tasks: drafts }, fakeContext(SESSION_ID))); - assert.equal( - (result.match(/<\/?task-ledger[^>]*>/gi) || []).length, - 0, - 'tool result must not contain any task-ledger tag variant, got: ' + JSON.stringify(result), - ); - }); - - test('task_create schema enforces non-empty array, non-blank subjects, and the subject length cap', () => { - const create = findTool( - buildTaskLedgerTools({ store: new FakeTaskLedgerStore() }), - TASK_CREATE_TOOL_NAME, - ); - const schema = create.parameters as z.ZodTypeAny; - assert.equal(schema.safeParse({ tasks: [{ subject: 'ok' }] }).success, true); - assert.equal(schema.safeParse({ tasks: [] }).success, false); - assert.equal(schema.safeParse({ tasks: [{ subject: '' }] }).success, false); - assert.equal(schema.safeParse({ tasks: [{ subject: ' ' }] }).success, false); - assert.equal( - schema.safeParse({ tasks: [{ subject: 'x'.repeat(TASK_SUBJECT_MAX_CHARS) }] }).success, - true, - ); - assert.equal( - schema.safeParse({ tasks: [{ subject: 'x'.repeat(TASK_SUBJECT_MAX_CHARS + 1) }] }).success, - false, - ); - assert.equal(schema.safeParse({}).success, false); - assert.equal( - schema.safeParse({ tasks: [{ subject: 'child', parent_id: 'T1' }] }).success, - true, - ); - assert.equal( - schema.safeParse({ tasks: [{ subject: 'child', parent_id: '../T1' }] }).success, - false, - ); - }); - - test('task_create forwards parent_id as the storage parent reference', async () => { - const store = new FakeTaskLedgerStore(); - const create = findTool(buildTaskLedgerTools({ store }), TASK_CREATE_TOOL_NAME); - await create.impl({ tasks: [{ subject: 'child', parent_id: 'T1' }] }, fakeContext(SESSION_ID)); - assert.deepEqual(store.createCalls[0]?.drafts, [{ subject: 'child', parentId: 'T1' }]); - }); - - test('task_update schema requires id and at least one of status/subject, with the same subject cap', () => { - const update = findTool( - buildTaskLedgerTools({ store: new FakeTaskLedgerStore() }), - TASK_UPDATE_TOOL_NAME, - ); - const schema = update.parameters as z.ZodTypeAny; - assert.equal( - schema.safeParse({ id: 'x', status: 'completed', completionEvidence: 'done' }).success, - true, - ); - assert.equal(schema.safeParse({ id: 'x', subject: 'new' }).success, true); - assert.equal( - schema.safeParse({ id: 'x', status: 'blocked', blockedReason: 'waiting' }).success, - true, - ); - assert.equal( - schema.safeParse({ id: 'x', status: 'failed', failureReason: 'cannot proceed' }).success, - true, - ); - assert.equal( - schema.safeParse({ id: 'x', status: 'in_progress', explicitReopen: true }).success, - true, - ); - assert.equal(schema.safeParse({ id: 'x' }).success, false); - assert.equal( - schema.safeParse({ status: 'completed', completionEvidence: 'done' }).success, - false, - ); - assert.equal(schema.safeParse({ id: 'x', status: 'bogus' }).success, false); - assert.equal(schema.safeParse({ id: 'x', status: 'completed' }).success, false); - assert.equal(schema.safeParse({ id: 'x', status: 'blocked' }).success, false); - assert.equal(schema.safeParse({ id: 'x', status: 'failed' }).success, false); - assert.equal( - schema.safeParse({ id: 'x', subject: 'x'.repeat(TASK_SUBJECT_MAX_CHARS + 1) }).success, - false, - ); - assert.equal( - schema.safeParse({ id: 'x', completionEvidence: 'x'.repeat(TASK_EVIDENCE_MAX_CHARS + 1) }) - .success, - false, - ); - }); - - test('task_update forwards evidence fields to the store', async () => { - const store = new FakeTaskLedgerStore(); - const tools = buildTaskLedgerTools({ store }); - const create = findTool(tools, TASK_CREATE_TOOL_NAME); - const update = findTool(tools, TASK_UPDATE_TOOL_NAME); - await create.impl({ tasks: [{ subject: '原始' }] }, fakeContext(SESSION_ID)); - - const result = await update.impl( - { - id: 'id-0', - status: 'blocked', - blockedReason: 'waiting for approval', - }, - fakeContext(SESSION_ID, 'run-2'), - ); - assert.deepEqual(store.updateCalls.at(-1)?.patch, { - status: 'blocked', - blockedReason: 'waiting for approval', - }); - assert.deepEqual(store.updateCalls.at(-1)?.context, { - turnId: 'turn-1', - runId: 'run-2', - toolCallId: 'call-1', - source: 'tool', - actor: 'main_agent', - }); - assert.match(String(result), /blockedReason/); - }); - - test('task_update forwards explicitReopen without requiring evidence', async () => { - const store = new FakeTaskLedgerStore(); - const tools = buildTaskLedgerTools({ store }); - const create = findTool(tools, TASK_CREATE_TOOL_NAME); - const update = findTool(tools, TASK_UPDATE_TOOL_NAME); - await create.impl({ tasks: [{ subject: 'reopen' }] }, fakeContext(SESSION_ID)); - - await update.impl( - { - id: 'id-0', - status: 'in_progress', - explicitReopen: true, - }, - fakeContext(SESSION_ID, 'run-3'), - ); - - assert.deepEqual(store.updateCalls.at(-1)?.patch, { - status: 'in_progress', - explicitReopen: true, - }); - assert.deepEqual(store.updateCalls.at(-1)?.context, { - runId: 'run-3', - turnId: 'turn-1', - toolCallId: 'call-1', - source: 'tool', - actor: 'main_agent', - }); - }); - - test('task_list and task_get return compact task summaries', async () => { - const store = new FakeTaskLedgerStore(); - const tools = buildTaskLedgerTools({ store }); - const create = findTool(tools, TASK_CREATE_TOOL_NAME); - const update = findTool(tools, TASK_UPDATE_TOOL_NAME); - const list = findTool(tools, TASK_LIST_TOOL_NAME); - const get = findTool(tools, TASK_GET_TOOL_NAME); - await create.impl( - { tasks: [{ subject: 'first' }, { subject: 'second' }] }, - fakeContext(SESSION_ID), - ); - await update.impl({ id: 'id-1', status: 'in_progress' }, fakeContext(SESSION_ID)); - - const listResult = String(await list.impl({}, fakeContext(SESSION_ID))); - assert.match(listResult, /Task ledger total: 2/); - assert.match(listResult, /first/); - assert.match(listResult, /second/); - assert.equal(listResult.includes('resumeTrust='), false); - - const getResult = String(await get.impl({ id: 'id-1' }, fakeContext(SESSION_ID))); - assert.match(getResult, /second/); - assert.equal(getResult.includes('first'), false); - assert.equal(getResult.includes('resumeTrust='), false); - assert.equal( - await get.impl({ id: 'missing' }, fakeContext(SESSION_ID)), - 'No such task: missing', - ); - }); - - test('task_list forwards filters and rejects contradictory terminal options', async () => { - const store = new FakeTaskLedgerStore(); - store.seed([ - { id: 'a', key: 'T1', subject: 'active', status: 'pending', createdAt: 1, updatedAt: 1 }, - { - id: 'b', - key: 'T2', - subject: 'done', - status: 'completed', - completionEvidence: 'ok', - createdAt: 2, - updatedAt: 3, - }, - ]); - const list = findTool(buildTaskLedgerTools({ store }), TASK_LIST_TOOL_NAME); - const schema = list.parameters as z.ZodTypeAny; - assert.equal(schema.safeParse({ status: 'completed', include_terminal: false }).success, false); - const result = String( - await list.impl( - { - status: 'pending', - include_terminal: false, - include_archived: false, - }, - fakeContext(SESSION_ID), - ), - ); - assert.match(result, /active/); - assert.doesNotMatch(result, /done/); - assert.deepEqual(store.listCalls.at(-1)?.options, { - status: 'pending', - includeTerminal: false, - includeArchived: false, - }); - }); - - test('task_list and task_get hide untrusted fallback tasks from model-visible output', async () => { - const store = new FakeTaskLedgerStore(); - store.seed([ - { - id: 'safe-task', - key: 'T1', - subject: 'visible task', - status: 'pending', - createdAt: 1, - updatedAt: 1, - }, - { - id: 'fallback-task', - key: 'T2', - subject: 'corrupt cache fallback', - status: 'pending', - createdAt: 2, - updatedAt: 2, - resumeTrust: 'untrusted', - }, - ]); - const tools = buildTaskLedgerTools({ store }); - const list = findTool(tools, TASK_LIST_TOOL_NAME); - const get = findTool(tools, TASK_GET_TOOL_NAME); - - const listResult = String(await list.impl({}, fakeContext(SESSION_ID))); - assert.match(listResult, /visible task/); - assert.equal(listResult.includes('corrupt cache fallback'), false); - assert.equal(listResult.includes('fallback-task'), false); - assert.equal(listResult.includes('resumeTrust='), false); - - assert.equal( - await get.impl({ id: 'fallback-task' }, fakeContext(SESSION_ID)), - 'No such task: fallback-task', - ); - }); -}); diff --git a/packages/runtime/src/deep-research-tools.ts b/packages/runtime/src/deep-research-tools.ts index 7d06374c27..2a8c5a4883 100644 --- a/packages/runtime/src/deep-research-tools.ts +++ b/packages/runtime/src/deep-research-tools.ts @@ -648,7 +648,6 @@ function buildCheckpointTool(deps: BuildDeepResearchToolsDeps): MakaTool< summary: string; open_questions?: string[]; next_steps?: string[]; - task_ids?: string[]; artifact_ids?: string[]; }, string @@ -662,7 +661,7 @@ function buildCheckpointTool(deps: BuildDeepResearchToolsDeps): MakaTool< displayName: 'Checkpoint Research', description: 'Record a durable research checkpoint after a meaningful round or before context compaction. ' + - 'Include unresolved questions, next steps, task ids, and the artifacts needed to resume.', + 'Include unresolved questions, next steps, and the artifacts needed to resume.', parameters: z.object({ round: z.number().int().min(1).describe('Monotonic research round number.'), stage: z.enum(DEEP_RESEARCH_ACTIVE_STAGES).describe('Current two-stage workflow phase.'), @@ -679,7 +678,6 @@ function buildCheckpointTool(deps: BuildDeepResearchToolsDeps): MakaTool< .optional() .describe('Questions still requiring evidence or resolution.'), next_steps: itemArray.optional().describe('Concrete continuation steps.'), - task_ids: refArray.optional().describe('Related ids from the session Task Ledger.'), artifact_ids: refArray.optional().describe('Known research artifact ids required to resume.'), }), impl: async (input, ctx) => { @@ -693,7 +691,7 @@ function buildCheckpointTool(deps: BuildDeepResearchToolsDeps): MakaTool< summary: input.summary, openQuestions: dedupe(input.open_questions ?? []), nextSteps: dedupe(input.next_steps ?? []), - taskIds: dedupe(input.task_ids ?? []), + taskIds: [], artifactIds: dedupe(input.artifact_ids ?? []), }, mutationContext(ctx), diff --git a/packages/runtime/src/session-todo-tools.ts b/packages/runtime/src/session-todo-tools.ts new file mode 100644 index 0000000000..14cac0be95 --- /dev/null +++ b/packages/runtime/src/session-todo-tools.ts @@ -0,0 +1,102 @@ +/* + * 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 { + SESSION_TODO_CONTENT_MAX_CHARS, + SESSION_TODO_MAX_ITEMS, + SESSION_TODO_STATUSES, + sessionTodoContentForDisplay, + type SessionTodoSnapshot, +} from '@maka/core/session-todo'; +import { z } from 'zod'; +import type { MakaTool } from './tool-runtime.js'; + +export const TODO_READ_TOOL_NAME = 'todo_read'; +export const TODO_WRITE_TOOL_NAME = 'todo_write'; + +export interface SessionTodoToolStore { + read(sessionId: string): Promise; + replace(sessionId: string, items: unknown): Promise; +} + +export function buildSessionTodoTools(store: SessionTodoToolStore): MakaTool[] { + return [buildTodoReadTool(store), buildTodoWriteTool(store)]; +} + +function buildTodoReadTool(store: SessionTodoToolStore): MakaTool, string> { + return { + name: TODO_READ_TOOL_NAME, + displayName: 'Todo Read', + description: + 'Read the complete current session Todo list. Use this when you need the latest checklist.', + parameters: z.object({}).strict(), + impl: async (_input, ctx) => renderTodoSnapshot(await store.read(ctx.sessionId), 'read'), + }; +} + +function buildTodoWriteTool( + store: SessionTodoToolStore, +): MakaTool< + { todos: Array<{ content: string; status: (typeof SESSION_TODO_STATUSES)[number] }> }, + string +> { + return { + name: TODO_WRITE_TOOL_NAME, + displayName: 'Todo Write', + description: + 'Atomically replace the complete current session Todo list. Include every item that should remain. ' + + 'Completed means model-reported progress; it is not independently verified execution evidence.', + parameters: z + .object({ + todos: z + .array( + z + .object({ + content: z + .string() + .trim() + .min(1) + .refine( + (content) => + [...content.normalize('NFC')].length <= SESSION_TODO_CONTENT_MAX_CHARS, + `Todo content must be ${SESSION_TODO_CONTENT_MAX_CHARS} characters or fewer`, + ), + status: z.enum(SESSION_TODO_STATUSES), + }) + .strict(), + ) + .max(SESSION_TODO_MAX_ITEMS), + }) + .strict(), + impl: async (input, ctx) => + renderTodoSnapshot(await store.replace(ctx.sessionId, input.todos), 'write'), + }; +} + +function renderTodoSnapshot(snapshot: SessionTodoSnapshot, operation: 'read' | 'write'): string { + if (snapshot.items.length === 0) { + return operation === 'write' ? 'Todo list cleared.' : 'Todo list is empty.'; + } + const lines = snapshot.items.map( + (item, index) => + `${index + 1}. [${item.status}] ${JSON.stringify(sessionTodoContentForDisplay(item.content))}`, + ); + const prefix = operation === 'write' ? 'Todo list updated' : 'Todo list'; + return `${prefix} (${snapshot.items.length} items):\n${lines.join('\n')}`; +} diff --git a/packages/runtime/src/subagent-tools.ts b/packages/runtime/src/subagent-tools.ts index 0df610cacd..19a479c17d 100644 --- a/packages/runtime/src/subagent-tools.ts +++ b/packages/runtime/src/subagent-tools.ts @@ -18,7 +18,6 @@ */ import { z } from 'zod'; -import { TASK_ID_MAX_CHARS, isSafeTaskId, type TaskLedgerStore } from '@maka/core/task-ledger'; import { decodeCanonicalToolResultContent } from '@maka/core/tool-result-record-schema'; import { isSafeSubagentPresetId } from '@maka/core/subagent-settings'; import { type ToolResultContent } from '@maka/core/events'; @@ -89,7 +88,7 @@ export function buildChildAgentTools(tools: readonly MakaTool[]): MakaTool[] { } export function buildSubagentSpawnTool( - deps: { taskLedger?: TaskLedgerStore; definitions?: readonly AgentDefinition[] } = {}, + deps: { definitions?: readonly AgentDefinition[] } = {}, ): MakaTool< { profile?: string; @@ -97,7 +96,6 @@ export function buildSubagentSpawnTool( task: string; write_back?: string; isolation?: string; - task_id?: string; }, unknown > { @@ -109,7 +107,7 @@ export function buildSubagentSpawnTool( description: 'Run one bounded foreground child task. Prefer agent_list, then select the user-approved subagent_id whose description fits the task; profile is retained for legacy callers. If both selectors are present, subagent_id wins and profile is ignored.', parameters: z.preprocess( - (input) => cleanSubagentSpawnInput(input, deps.taskLedger !== undefined), + cleanSubagentSpawnInput, z .object({ profile: z.enum(profiles).optional().describe('Legacy child capability profile.'), @@ -137,17 +135,6 @@ export function buildSubagentSpawnTool( .describe( 'Requested child workspace isolation. Worktree profiles fail closed until a worktree child executor is available.', ), - ...(deps.taskLedger - ? { - task_id: z - .string() - .min(1) - .max(TASK_ID_MAX_CHARS) - .refine(isSafeTaskId) - .optional() - .describe('Existing task UUID or short key to bind to this child run.'), - } - : {}), }) .strip() .superRefine((input, ctx) => { @@ -208,20 +195,6 @@ export function buildSubagentSpawnTool( }, ); } - // task_id is meaningful only in compositions that advertise task - // binding. Older or over-eager callers may still send it elsewhere; - // ignore it instead of turning an optional integration into a refusal. - const taskId = deps.taskLedger ? input.task_id : undefined; - const boundTask = taskId ? await deps.taskLedger!.get(ctx.sessionId, taskId) : undefined; - if (taskId && !boundTask) throw new Error(`No such task in this session: ${taskId}`); - let claimedOwner: - | { - actor: 'child_agent'; - sessionId: string; - agentId: string; - turnId: string; - } - | undefined; let result: Omit; const progress = new ChildAgentProgressProjector(ctx); ctx.emitOutput('stdout', `Starting child agent: ${definition.name}\n`); @@ -231,27 +204,6 @@ export function buildSubagentSpawnTool( agentProfile: definition.profile, ...(input.subagent_id ? { subagentId: input.subagent_id } : {}), prompt: input.task, - ...(boundTask - ? { - onReady: async ({ childSessionId, turnId, agentId }) => { - const owner = { - actor: 'child_agent' as const, - sessionId: childSessionId, - agentId, - turnId, - }; - await deps.taskLedger!.claim(ctx.sessionId, boundTask.id, owner, { - runId: ctx.runId, - turnId: ctx.turnId, - toolCallId: ctx.toolCallId, - source: 'system', - actor: 'main_agent', - reason: `assigned to child agent ${agentId}`, - }); - claimedOwner = owner; - }, - } - : {}), onEvent: (event) => progress.observe(event), }), ); @@ -260,52 +212,9 @@ export function buildSubagentSpawnTool( 'stderr', `Child agent ${definition.name} failed: ${boundedChildError(error)}\n`, ); - if (boundTask && claimedOwner) { - await deps.taskLedger!.settleAgentOutcome( - ctx.sessionId, - boundTask.id, - { - status: 'failed', - owner: claimedOwner, - reason: - error instanceof Error - ? error.message - : 'Child agent failed before returning a result', - }, - { - turnId: claimedOwner.turnId, - toolCallId: ctx.toolCallId, - source: 'system', - actor: 'child_agent', - }, - ); - } throw error; } ctx.emitOutput('stdout', `Child agent ${definition.name}: ${result.status}\n`); - if (boundTask && claimedOwner) { - const owner = { - ...claimedOwner, - ...(result.runId ? { runId: result.runId } : {}), - turnId: result.turnId, - }; - await deps.taskLedger!.settleAgentOutcome( - ctx.sessionId, - boundTask.id, - { - status: result.status, - owner, - reason: result.failureClass ?? result.summary, - }, - { - runId: result.runId, - turnId: result.turnId, - toolCallId: ctx.toolCallId, - source: 'system', - actor: 'child_agent', - }, - ); - } return { kind: 'subagent', ...result, @@ -314,11 +223,10 @@ export function buildSubagentSpawnTool( }; } -function cleanSubagentSpawnInput(input: unknown, taskBindingAvailable: boolean): unknown { +function cleanSubagentSpawnInput(input: unknown): unknown { if (!input || typeof input !== 'object' || Array.isArray(input)) return input; const cleaned = { ...(input as Record) }; if (cleaned.subagent_id !== undefined) delete cleaned.profile; - if (!taskBindingAvailable) delete cleaned.task_id; return cleaned; } @@ -753,7 +661,7 @@ export function buildSubagentProjectionTools(): MakaTool[] { } export function buildParentAgentTools( - deps: { taskLedger?: TaskLedgerStore; definitions?: readonly AgentDefinition[] } = {}, + deps: { definitions?: readonly AgentDefinition[] } = {}, ): MakaTool[] { const definitions = deps.definitions ?? BUILTIN_AGENT_DEFINITIONS; return [ diff --git a/packages/runtime/src/task-ledger-tools.ts b/packages/runtime/src/task-ledger-tools.ts deleted file mode 100644 index 36c88b92a9..0000000000 --- a/packages/runtime/src/task-ledger-tools.ts +++ /dev/null @@ -1,323 +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 { z } from 'zod'; -import { - TASK_STATUSES, - TASK_EVIDENCE_MAX_CHARS, - TASK_SUBJECT_MAX_CHARS, - TASK_LEDGER_MAX_TASKS, - TASK_ID_MAX_CHARS, - filterModelVisibleTaskLedgerTasks, - isSafeTaskId, - isTerminalTaskStatus, - renderSafeTaskLedgerText, - type TaskLedgerStore, -} from '@maka/core/task-ledger'; -import type { MakaTool } from './tool-runtime.js'; - -export const TASK_CREATE_TOOL_NAME = 'task_create'; -export const TASK_UPDATE_TOOL_NAME = 'task_update'; -export const TASK_LIST_TOOL_NAME = 'task_list'; -export const TASK_GET_TOOL_NAME = 'task_get'; - -export function buildTaskLedgerTools(deps: { store: TaskLedgerStore }): MakaTool[] { - return [ - buildTaskCreateTool(deps.store, TASK_CREATE_TOOL_NAME, 'task_update'), - buildTaskUpdateTool(deps.store, TASK_UPDATE_TOOL_NAME), - buildTaskListTool(deps.store), - buildTaskGetTool(deps.store), - ]; -} - -function buildTaskCreateTool( - store: TaskLedgerStore, - name: string, - updateToolName: string, -): MakaTool<{ tasks: Array<{ subject: string; parent_id?: string }> }, string> { - return { - name, - displayName: 'Task Create', - description: - 'Add one or more tasks to the session task ledger. ' + - `Use task_list or task_get to read it later, and update status with ${updateToolName} as you progress.`, - parameters: z.object({ - tasks: z - .array( - z.object({ - subject: z - .string() - .trim() - .min(1) - .max(TASK_SUBJECT_MAX_CHARS) - .describe( - `Short imperative description of the task (max ${TASK_SUBJECT_MAX_CHARS} characters).`, - ), - parent_id: z - .string() - .min(1) - .max(TASK_ID_MAX_CHARS) - .refine(isSafeTaskId) - .optional() - .describe('Existing parent task UUID or short key (for example T1).'), - }), - ) - .min(1) - .max(TASK_LEDGER_MAX_TASKS) - .describe('One or more tasks to add. Each starts in the pending state.'), - }), - impl: async (input, ctx) => { - const { created, total } = await store.create( - ctx.sessionId, - input.tasks.map((task) => ({ - subject: task.subject, - ...(task.parent_id ? { parentId: task.parent_id } : {}), - })), - { - runId: ctx.runId, - turnId: ctx.turnId, - toolCallId: ctx.toolCallId, - source: 'tool', - actor: 'main_agent', - }, - ); - return `Created ${created.length} task(s); ledger total: ${total}.\n${renderSafeTaskLedgerText(created)}`; - }, - }; -} - -function buildTaskUpdateTool( - store: TaskLedgerStore, - name: string, -): MakaTool< - { - id: string; - status?: (typeof TASK_STATUSES)[number]; - subject?: string; - blockedReason?: string; - failureReason?: string; - completionEvidence?: string; - explicitReopen?: boolean; - }, - string -> { - return { - name, - displayName: 'Task Update', - description: - 'Update a task in the session task ledger by id. Mark tasks in_progress when you start them; ' + - 'blocked, failed, and completed updates require a reason or evidence field. ' + - 'Reopening completed/cancelled tasks requires explicitReopen=true.', - parameters: z - .object({ - id: z - .string() - .min(1) - .max(TASK_ID_MAX_CHARS) - .refine( - isSafeTaskId, - 'Task reference must be a UUID or short key from the current ledger.', - ) - .describe('Task UUID or short key.'), - status: z.enum(TASK_STATUSES).optional().describe('New task status.'), - subject: z - .string() - .trim() - .min(1) - .max(TASK_SUBJECT_MAX_CHARS) - .optional() - .describe(`Revised task description (max ${TASK_SUBJECT_MAX_CHARS} characters).`), - blockedReason: z - .string() - .trim() - .min(1) - .max(TASK_EVIDENCE_MAX_CHARS) - .optional() - .describe( - 'Required when setting status to blocked. Explain the external input, dependency, or permission needed.', - ), - failureReason: z - .string() - .trim() - .min(1) - .max(TASK_EVIDENCE_MAX_CHARS) - .optional() - .describe( - 'Required when setting status to failed. Explain why the task cannot be completed.', - ), - completionEvidence: z - .string() - .trim() - .min(1) - .max(TASK_EVIDENCE_MAX_CHARS) - .optional() - .describe( - 'Required when setting status to completed. Cite the check, tool result, artifact, or user confirmation.', - ), - explicitReopen: z - .boolean() - .optional() - .describe( - 'Required only when reopening completed -> in_progress or cancelled -> pending.', - ), - }) - .superRefine((input, ctx) => { - if ( - input.status === undefined && - input.subject === undefined && - input.blockedReason === undefined && - input.failureReason === undefined && - input.completionEvidence === undefined && - input.explicitReopen === undefined - ) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Provide at least one task field to update.', - }); - } - if (input.status === 'blocked' && input.blockedReason === undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'blockedReason is required when status is blocked.', - path: ['blockedReason'], - }); - } - if (input.status === 'failed' && input.failureReason === undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'failureReason is required when status is failed.', - path: ['failureReason'], - }); - } - if (input.status === 'completed' && input.completionEvidence === undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'completionEvidence is required when status is completed.', - path: ['completionEvidence'], - }); - } - }), - impl: async (input, ctx) => { - const { updated, total } = await store.update( - ctx.sessionId, - input.id, - { - ...(input.status !== undefined ? { status: input.status } : {}), - ...(input.subject !== undefined ? { subject: input.subject } : {}), - ...(input.blockedReason !== undefined ? { blockedReason: input.blockedReason } : {}), - ...(input.failureReason !== undefined ? { failureReason: input.failureReason } : {}), - ...(input.completionEvidence !== undefined - ? { completionEvidence: input.completionEvidence } - : {}), - ...(input.explicitReopen !== undefined ? { explicitReopen: input.explicitReopen } : {}), - }, - { - runId: ctx.runId, - turnId: ctx.turnId, - toolCallId: ctx.toolCallId, - source: 'tool', - actor: 'main_agent', - }, - ); - return `Updated 1 task; ledger total: ${total}.\n${renderSafeTaskLedgerText([updated])}`; - }, - }; -} - -function buildTaskListTool(store: TaskLedgerStore): MakaTool< - { - status?: (typeof TASK_STATUSES)[number]; - include_terminal?: boolean; - include_archived?: boolean; - }, - string -> { - return { - name: TASK_LIST_TOOL_NAME, - displayName: 'Task List', - description: 'List the current session task ledger in compact form.', - parameters: z - .object({ - status: z.enum(TASK_STATUSES).optional().describe('Optional exact status filter.'), - include_terminal: z - .boolean() - .optional() - .describe('Include terminal tasks. Defaults to true for compatibility.'), - include_archived: z - .boolean() - .optional() - .describe( - 'Include terminal tasks older than seven days. Defaults to true for compatibility.', - ), - }) - .superRefine((input, ctx) => { - if ( - input.status && - isTerminalTaskStatus(input.status) && - input.include_terminal === false - ) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['include_terminal'], - message: 'A terminal status filter conflicts with include_terminal=false.', - }); - } - }), - impl: async (input, ctx) => { - const tasks = filterModelVisibleTaskLedgerTasks( - await store.list(ctx.sessionId, { - ...(input.status ? { status: input.status } : {}), - ...(input.include_terminal !== undefined - ? { includeTerminal: input.include_terminal } - : {}), - ...(input.include_archived !== undefined - ? { includeArchived: input.include_archived } - : {}), - }), - ); - return tasks.length === 0 - ? 'Task ledger is empty.' - : `Task ledger total: ${tasks.length}.\n${renderSafeTaskLedgerText(tasks)}`; - }, - }; -} - -function buildTaskGetTool(store: TaskLedgerStore): MakaTool<{ id: string }, string> { - return { - name: TASK_GET_TOOL_NAME, - displayName: 'Task Get', - description: 'Get one task from the current session task ledger by id.', - parameters: z.object({ - id: z - .string() - .min(1) - .max(TASK_ID_MAX_CHARS) - .refine( - isSafeTaskId, - 'Task reference must be a UUID or short key from the current ledger.', - ), - }), - impl: async (input, ctx) => { - const task = await store.get(ctx.sessionId, input.id); - if (task?.resumeTrust === 'untrusted') return `No such task: ${input.id}`; - if (!task) return `No such task: ${input.id}`; - return renderSafeTaskLedgerText([task]); - }, - }; -} diff --git a/packages/storage/package.json b/packages/storage/package.json index fd6e6e0aa0..771f3d9d8d 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -43,6 +43,7 @@ "./scheduled-task-store": "./dist/scheduled-task-store.js", "./session-bundle-policy": "./dist/session-bundle-policy.js", "./session-copy-cleanup": "./dist/session-copy-cleanup.js", + "./session-todo-authority": "./dist/session-todo-authority.js", "./session-store": "./dist/session-store.js", "./settings-store": "./dist/settings-store.js", "./shell-run-authority": "./dist/shell-run-authority.js", @@ -52,7 +53,6 @@ "./stable-storage": "./dist/stable-storage.js", "./state-root-composition": "./dist/state-root-composition.js", "./storage-writer-composition": "./dist/storage-writer-composition.js", - "./task-ledger-authority": "./dist/task-ledger-authority.js", "./usage-stores": "./dist/usage-stores.js", "./work-board-store": "./dist/work-board-store.js", "./workspace-identity": "./dist/workspace-identity.js", diff --git a/packages/storage/src/__tests__/public-entrypoints.test.ts b/packages/storage/src/__tests__/public-entrypoints.test.ts index bdbb85bf7c..1e07c0c858 100644 --- a/packages/storage/src/__tests__/public-entrypoints.test.ts +++ b/packages/storage/src/__tests__/public-entrypoints.test.ts @@ -65,11 +65,11 @@ const SQLITE_BACKED_ENTRYPOINTS = [ './session-bundle-policy', './session-copy-cleanup', './session-store', + './session-todo-authority', './shell-run-authority', './shell-run-store', './sqlite-session-metadata-store', './storage-writer-composition', - './task-ledger-authority', './usage-stores', './work-board-store', ]; diff --git a/packages/storage/src/__tests__/session-todo-store.test.ts b/packages/storage/src/__tests__/session-todo-store.test.ts index a643482453..e1ecc82fac 100644 --- a/packages/storage/src/__tests__/session-todo-store.test.ts +++ b/packages/storage/src/__tests__/session-todo-store.test.ts @@ -146,7 +146,7 @@ describe('SQLite SessionTodo store', () => { { content: 'first', status: 'pending' }, ], }); - await reopened.purge(SESSION_ID); + await reopened.purgeSessionState(SESSION_ID); assert.deepEqual(await reopened.readOrBootstrap(SESSION_ID), { items: [] }); reopened.close(); }); @@ -246,6 +246,150 @@ describe('SQLite SessionTodo store', () => { todos.close(); }); }); + + test('initializes latest copies atomically and accepts only an identical retry', async () => { + await withRoot(async (root) => { + const todos = createSqliteSessionTodoStore(root); + await todos.replaceAll('source', [{ content: 'current work', status: 'in_progress' }]); + const input = { sourceSessionId: 'source', targetSessionId: 'target', copyCurrent: true }; + const expected = { items: [{ content: 'current work', status: 'in_progress' as const }] }; + assert.deepEqual(await todos.initializeCopy(input), expected); + assert.deepEqual(await todos.initializeCopy(input), expected); + await todos.replaceAll('target', [{ content: 'different', status: 'pending' }]); + await assert.rejects(() => todos.initializeCopy(input), /different state/); + todos.close(); + }); + }); + + test('fails closed when a copy source or target document is corrupt', async () => { + await withRoot(async (root) => { + const todos = createSqliteSessionTodoStore(root); + await todos.replaceAll('source', [{ content: 'current work', status: 'pending' }]); + await todos.replaceAll('corrupt-target', []); + + const database = new DatabaseSync(join(root, 'runtime.sqlite')); + try { + database + .prepare( + 'UPDATE workflow_session_todo_documents SET record_json = ? WHERE session_id = ?', + ) + .run('{not-json', 'corrupt-target'); + } finally { + database.close(); + } + + await assert.rejects( + () => + todos.initializeCopy({ + sourceSessionId: 'source', + targetSessionId: 'corrupt-target', + copyCurrent: true, + }), + /Invalid SessionTodo document JSON/, + ); + + const corruptSource = new DatabaseSync(join(root, 'runtime.sqlite')); + try { + corruptSource + .prepare( + 'UPDATE workflow_session_todo_documents SET record_json = ? WHERE session_id = ?', + ) + .run('{not-json', 'source'); + } finally { + corruptSource.close(); + } + await assert.rejects( + () => + todos.initializeCopy({ + sourceSessionId: 'source', + targetSessionId: 'new-target', + copyCurrent: true, + }), + /Invalid SessionTodo document JSON/, + ); + + 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('new-target')!.count, + 0, + ); + } finally { + verified.close(); + } + todos.close(); + }); + }); + + test('writes an explicit empty copy marker that later legacy events cannot revive', async () => { + await withRoot(async (root) => { + const todos = createSqliteSessionTodoStore(root); + assert.deepEqual( + await todos.initializeCopy({ + sourceSessionId: 'source', + targetSessionId: 'historical-target', + copyCurrent: false, + }), + { 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); + const database = new DatabaseSync(join(root, 'runtime.sqlite'), { readOnly: true }); + try { + assert.equal( + database + .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, + ); + } finally { + database.close(); + } + todos.close(); + }); + }); + + test('linearizes concurrent whole-document replacements', async () => { + await withRoot(async (root) => { + const todos = createSqliteSessionTodoStore(root); + const writes = Array.from({ length: 128 }, (_, index) => + todos.replaceAll(SESSION_ID, [{ content: `write ${index}`, status: 'pending' }]), + ); + await Promise.all(writes); + assert.deepEqual(await todos.readOrBootstrap(SESSION_ID), { + items: [{ content: 'write 127', status: 'pending' }], + }); + todos.close(); + }); + }); }); async function withRoot(run: (root: string) => Promise): Promise { diff --git a/packages/storage/src/session-todo-authority.ts b/packages/storage/src/session-todo-authority.ts index fb0fb6b855..03b059db9f 100644 --- a/packages/storage/src/session-todo-authority.ts +++ b/packages/storage/src/session-todo-authority.ts @@ -117,7 +117,8 @@ function createWriterFacade( [writerBrand]: true, readOrBootstrap: (sessionId) => run(() => store.readOrBootstrap(sessionId)), replaceAll: (sessionId, items) => run(() => store.replaceAll(sessionId, items)), - purge: (sessionId) => run(() => store.purge(sessionId)), + initializeCopy: (input) => run(() => store.initializeCopy(input)), + purgeSessionState: (sessionId) => run(() => store.purgeSessionState(sessionId)), close: () => { if (closed) return; closed = true; diff --git a/packages/storage/src/session-todo-store.ts b/packages/storage/src/session-todo-store.ts index 781fe373ab..9f99deb3ec 100644 --- a/packages/storage/src/session-todo-store.ts +++ b/packages/storage/src/session-todo-store.ts @@ -52,7 +52,14 @@ export interface SessionTodoStore { readOrBootstrap(sessionId: string): Promise; /** Replace the complete document without consulting legacy Task state. */ replaceAll(sessionId: string, items: unknown): Promise; - purge(sessionId: string): Promise; + /** Initialize one conversation-copy target without overwriting conflicting state. */ + initializeCopy(input: { + sourceSessionId: string; + targetSessionId: string; + copyCurrent: boolean; + }): Promise; + /** Purge current state and the legacy events that could bootstrap it. */ + purgeSessionState(sessionId: string): Promise; } export interface SqliteSessionTodoStore extends SessionTodoStore { @@ -83,7 +90,7 @@ class SqliteSessionTodoStoreImpl implements SqliteSessionTodoStore { async readOrBootstrap(sessionId: string): Promise { assertSafeSessionId(sessionId); let snapshot: SessionTodoSnapshot | undefined; - await chainWrite(this.writeQueues, sessionId, async () => { + await this.#write(async () => { snapshot = this.#lease.transaction('write', () => { const existing = readStoredDocument(this.#lease.database, sessionId); if (existing) return snapshotFromDocument(existing); @@ -104,7 +111,7 @@ class SqliteSessionTodoStoreImpl implements SqliteSessionTodoStore { schemaVersion: SESSION_TODO_DOCUMENT_SCHEMA_VERSION, items: normalized.value.items, }; - await chainWrite(this.writeQueues, sessionId, async () => { + await this.#write(async () => { this.#lease.transaction('write', () => upsertDocument(this.#lease.database, sessionId, document), ); @@ -112,16 +119,70 @@ class SqliteSessionTodoStoreImpl implements SqliteSessionTodoStore { return snapshotFromDocument(document); } - async purge(sessionId: string): Promise { + async initializeCopy(input: { + sourceSessionId: string; + targetSessionId: string; + copyCurrent: boolean; + }): Promise { + assertSafeSessionId(input.sourceSessionId); + assertSafeSessionId(input.targetSessionId); + if (input.sourceSessionId === input.targetSessionId) { + throw new Error('SessionTodo copy source and target must differ'); + } + 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 existing = readStoredDocument(this.#lease.database, input.targetSessionId); + if (existing) { + if (!sameDocument(existing, source)) { + throw new Error('SessionTodo copy target already has different state'); + } + return snapshotFromDocument(existing); + } + insertDocument(this.#lease.database, input.targetSessionId, source); + return snapshotFromDocument(source); + }); + }); + return snapshot!; + } + + async purgeSessionState(sessionId: string): Promise { assertSafeSessionId(sessionId); - await chainWrite(this.writeQueues, sessionId, async () => { + await this.#write(async () => { this.#lease.transaction('write', () => { 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); }); }); } + + #write(operation: () => Promise): Promise { + // Todo documents are small and infrequently mutated. One queue makes + // cross-Session initialization linearizable without lock ordering. + return chainWrite(this.writeQueues, 'session-todo', operation); + } +} + +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: [] }; +} + +function sameDocument(left: StoredSessionTodoDocument, right: StoredSessionTodoDocument): boolean { + return JSON.stringify(left) === JSON.stringify(right); } function readStoredDocument( diff --git a/packages/storage/src/storage-writer-composition.ts b/packages/storage/src/storage-writer-composition.ts index 9ed4ba4680..aa55029a42 100644 --- a/packages/storage/src/storage-writer-composition.ts +++ b/packages/storage/src/storage-writer-composition.ts @@ -31,8 +31,8 @@ import { openInteractiveProjectCatalogForWrite } from './project-catalog-authori import { assertStorageRootLease, type StorageRootLease } from './root-authority.js'; import { openInteractiveRuntimePolicyStoresForWrite } from './runtime-policy-stores.js'; import { openInteractiveScheduledTaskStoreForWrite } from './scheduled-task-store.js'; +import { openInteractiveSessionTodoStoreForWrite } from './session-todo-authority.js'; import { openInteractiveShellRunStoreForWrite } from './shell-run-authority.js'; -import { openInteractiveTaskLedgerStoreForWrite } from './task-ledger-authority.js'; import { openInteractiveUsageStoresForWrite } from './usage-stores.js'; export interface OpenStorageWriterCompositionOptions { @@ -55,7 +55,7 @@ export interface StorageWriterComposition { readonly goal: Awaited>; readonly memoryBundle: Awaited>; readonly longTermMemory: Awaited>; - readonly taskLedger: Awaited>; + readonly sessionTodo: Awaited>; readonly artifacts: Awaited>; readonly contextOffload?: Awaited>; /** Present when the optional context-offload capability could not be opened. */ @@ -152,8 +152,8 @@ async function createComposition( () => openInteractiveLongTermMemoryStoreForWrite(lease), closeWriter, ); - const taskLedger = await openWriter( - () => openInteractiveTaskLedgerStoreForWrite(lease), + const sessionTodo = await openWriter( + () => openInteractiveSessionTodoStoreForWrite(lease), closeWriter, ); const artifacts = await openWriter( @@ -192,7 +192,7 @@ async function createComposition( goal, memoryBundle, longTermMemory, - taskLedger, + sessionTodo, artifacts, ...(contextOffload ? { contextOffload } : {}), ...(contextOffloadUnavailable ? { contextOffloadUnavailable } : {}), diff --git a/packages/ui/src/__tests__/session-todo-panel.test.tsx b/packages/ui/src/__tests__/session-todo-panel.test.tsx new file mode 100644 index 0000000000..94ff4f71b2 --- /dev/null +++ b/packages/ui/src/__tests__/session-todo-panel.test.tsx @@ -0,0 +1,43 @@ +/* + * 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 { test } from 'node:test'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { LocaleProvider } from '../locale-context.js'; +import { SessionTodoPanel, sessionTodoActiveCount } from '../session-todo-panel.js'; + +test('renders the Host snapshot as one flat ordered list', () => { + const items = [ + { content: 'First pending item', status: 'pending' as const }, + { content: 'Second completed item', status: 'completed' as const }, + { content: 'Third active item', status: 'in_progress' as const }, + ]; + assert.equal(sessionTodoActiveCount(items), 2); + + const markup = renderToStaticMarkup( + + + , + ); + assert.ok(markup.indexOf('First pending item') < markup.indexOf('Second completed item')); + assert.ok(markup.indexOf('Second completed item') < markup.indexOf('Third active item')); + assert.equal(markup.includes('Task Create'), false); + assert.equal(markup.includes('T1'), false); +}); diff --git a/packages/ui/src/__tests__/task-ledger-panel.test.ts b/packages/ui/src/__tests__/task-ledger-panel.test.ts deleted file mode 100644 index ed2934d8e8..0000000000 --- a/packages/ui/src/__tests__/task-ledger-panel.test.ts +++ /dev/null @@ -1,59 +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 type { Task } from '@maka/core/task-ledger'; -import { deriveTaskLedgerPanelModel } from '../task-ledger-panel.js'; - -function task(input: Partial & Pick): Task { - return { - subject: input.id, - createdAt: 1, - updatedAt: 1, - ...input, - }; -} - -describe('task ledger panel model', () => { - test('keeps terminal ancestors around active descendants', () => { - const parent = task({ id: 'parent', key: 'T1', status: 'failed', failureReason: 'failed' }); - const child = task({ id: 'child', key: 'T1.1', parentId: parent.id, status: 'pending' }); - const model = deriveTaskLedgerPanelModel([parent, child]); - assert.equal(model.activeCount, 1); - assert.deepEqual(model.activeTree.map((item) => item.key), ['T1', 'T1.1']); - }); - - test('selects three recent terminal seeds and adds their ancestors without changing the count', () => { - const root = task({ id: 'root', key: 'T1', status: 'in_progress' }); - const completedChild = task({ - id: 'child', key: 'T1.1', parentId: root.id, status: 'completed', - completionEvidence: 'done', endedAt: 5, - }); - const terminals = [2, 3, 4].map((index) => task({ - id: `terminal-${index}`, - key: `T${index}`, - status: 'cancelled', - endedAt: index, - })); - const model = deriveTaskLedgerPanelModel([root, completedChild, ...terminals]); - assert.equal(model.recentTerminalCount, 3); - assert.deepEqual(model.recentTerminalTree.map((item) => item.key), ['T1', 'T1.1', 'T3', 'T4']); - }); -}); diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index cfa924716c..d9bf1e4f0d 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -57,7 +57,7 @@ export * from './user-question-prompt.js'; export * from './user-question-prompt-state.js'; export * from './redact.js'; export * from './thinking-stream.js'; -export * from './task-ledger-panel.js'; +export * from './session-todo-panel.js'; export * from './toast.js'; export * from './tool-output-stream.js'; export * from './ui.js'; diff --git a/packages/ui/src/session-todo-panel.tsx b/packages/ui/src/session-todo-panel.tsx new file mode 100644 index 0000000000..5ae0fcd94c --- /dev/null +++ b/packages/ui/src/session-todo-panel.tsx @@ -0,0 +1,90 @@ +/* + * 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 { Banner, EmptyState, IconButton, Spinner } from '@astryxdesign/core'; +import type { SessionTodoItem, SessionTodoStatus } from '@maka/core/session-todo'; +import { CheckCircle2, CircleGauge, Clock, ICON_SIZE, ListTodo, RefreshCcw } from './icons.js'; +import { useUiLocale } from './locale-context.js'; +import { getSharedUiCopy } from './shared-ui-copy.js'; + +const STATUS_ICONS = { + pending: Clock, + in_progress: CircleGauge, + completed: CheckCircle2, +} satisfies Record; + +export interface SessionTodoPanelProps { + items: readonly SessionTodoItem[]; + loading?: boolean; + error?: string; + onRetry?: () => void; +} + +export function sessionTodoActiveCount(items: readonly SessionTodoItem[]): number { + return items.filter((item) => item.status !== 'completed').length; +} + +/** Read-only flat projection of the Host-owned current Todo document. */ +export function SessionTodoPanel(props: SessionTodoPanelProps) { + const copy = getSharedUiCopy(useUiLocale()).sessionTodo; + return ( +
+ {props.error ? ( +
+ ); +} diff --git a/packages/ui/src/shared-ui-copy.ts b/packages/ui/src/shared-ui-copy.ts index ff7cce7cb3..5002ac9414 100644 --- a/packages/ui/src/shared-ui-copy.ts +++ b/packages/ui/src/shared-ui-copy.ts @@ -17,8 +17,6 @@ * under the License. */ -import type { TaskStatus } from '@maka/core/task-ledger'; - import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; export interface SharedUiCopy { @@ -97,17 +95,12 @@ export interface SharedUiCopy { close: string; resizeHandle: string; }; - taskLedger: { - status: Record; + sessionTodo: { ariaLabel: string; retry: string; loading: string; activeAriaLabel: string; empty: string; - recent: string; - recentAriaLabel: string; - childAgent: (agentId?: string) => string; - mainAgent: string; }; toast: { notifications: string; @@ -199,17 +192,12 @@ const SHARED_UI_COPY = { dailyReviewDisconnectedBody: '桌面端数据桥当前未连接。', }, primitives: { loading: '加载中', close: '关闭', resizeHandle: '调整宽度' }, - taskLedger: { - status: { pending: '待处理', in_progress: '进行中', blocked: '已阻塞', completed: '已完成', failed: '失败', cancelled: '已取消' }, + sessionTodo: { ariaLabel: '任务待办', retry: '重新载入待办', loading: '正在载入待办…', activeAriaLabel: '进行中的待办', empty: '这个任务还没有待办', - recent: '最近结束', - recentAriaLabel: '最近结束的待办', - childAgent: (agentId) => `子代理${agentId ? ` ${agentId}` : ''}`, - mainAgent: '主代理', }, toast: { notifications: '通知', closeNotification: '关闭通知', confirm: '确定', cancel: '取消' }, stream: { assistantChunkTruncated: '\n[…单条 delta 已截断]\n', assistantTailTruncated: '\n\n[…后续已截断]', thinkingHeadTruncated: '[…已截断早期 reasoning]\n', thinkingChunkTruncated: '\n[…单条 delta 已截断]\n', toolChunkTruncated: '\n[…已截断]\n' }, @@ -288,17 +276,12 @@ const SHARED_UI_COPY = { dailyReviewDisconnectedBody: 'The desktop data bridge is not connected.', }, primitives: { loading: 'Loading', close: 'Close', resizeHandle: 'Resize handle' }, - taskLedger: { - status: { pending: 'Pending', in_progress: 'In progress', blocked: 'Blocked', completed: 'Completed', failed: 'Failed', cancelled: 'Cancelled' }, + sessionTodo: { ariaLabel: 'To-do list', retry: 'Reload the to-do list', loading: 'Loading the to-do list…', activeAriaLabel: 'In-progress to-dos', empty: 'This task has no to-dos yet', - recent: 'Recently finished', - recentAriaLabel: 'Recently finished to-dos', - childAgent: (agentId) => `Child agent${agentId ? ` ${agentId}` : ''}`, - mainAgent: 'Main agent', }, toast: { notifications: 'Notifications', closeNotification: 'Close notification', confirm: 'Confirm', cancel: 'Cancel' }, stream: { assistantChunkTruncated: '\n[…single delta truncated]\n', assistantTailTruncated: '\n\n[…remaining output truncated]', thinkingHeadTruncated: '[…earlier reasoning truncated]\n', thinkingChunkTruncated: '\n[…single delta truncated]\n', toolChunkTruncated: '\n[…truncated]\n' }, diff --git a/packages/ui/src/task-ledger-panel.tsx b/packages/ui/src/task-ledger-panel.tsx deleted file mode 100644 index 068bcdb091..0000000000 --- a/packages/ui/src/task-ledger-panel.tsx +++ /dev/null @@ -1,236 +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 { useMemo, type CSSProperties, type ReactNode } from 'react'; -import { Banner, Collapsible, EmptyState, IconButton, Spinner } from '@astryxdesign/core'; -import type { Task, TaskStatus } from '@maka/core/task-ledger'; -import { - ICON_SIZE, - AlertCircle, - Ban, - CheckCircle2, - CircleGauge, - Clock, - ListTodo, - RefreshCcw, - X, -} from './icons.js'; -import { useUiLocale } from './locale-context.js'; -import { getSharedUiCopy, type SharedUiCopy } from './shared-ui-copy.js'; - -const STATUS_ICONS = { - pending: Clock, - in_progress: CircleGauge, - blocked: AlertCircle, - completed: CheckCircle2, - failed: X, - cancelled: Ban, -} satisfies Record; - -export interface TaskLedgerPanelProps { - tasks: readonly Task[]; - loading?: boolean; - error?: string; - onRetry?: () => void; -} - -export interface TaskLedgerPanelModel { - activeCount: number; - activeTree: Task[]; - recentTerminalCount: number; - recentTerminalTree: Task[]; -} - -export function deriveTaskLedgerPanelModel(tasks: readonly Task[]): TaskLedgerPanelModel { - const activeSeeds = tasks.filter((task) => !isTerminal(task.status)); - const recentTerminalSeeds = tasks - .filter((task) => isTerminal(task.status)) - .sort((a, b) => (b.endedAt ?? b.updatedAt) - (a.endedAt ?? a.updatedAt)) - .slice(0, 3); - return { - activeCount: activeSeeds.length, - activeTree: orderTaskTree(withAncestors(tasks, activeSeeds)), - recentTerminalCount: recentTerminalSeeds.length, - recentTerminalTree: orderTaskTree(withAncestors(tasks, recentTerminalSeeds)), - }; -} - -export function TaskLedgerPanel(props: TaskLedgerPanelProps) { - const copy = getSharedUiCopy(useUiLocale()).taskLedger; - const model = useMemo(() => deriveTaskLedgerPanelModel(props.tasks), [props.tasks]); - - return ( -
- {props.error ? ( -
- ); -} - -function TaskLedgerTree({ tasks, copy }: { tasks: readonly Task[]; copy: SharedUiCopy['taskLedger'] }) { - const included = new Set(tasks.map((task) => task.id)); - const childrenByParent = new Map(); - for (const task of tasks) { - const parent = task.parentId && included.has(task.parentId) ? task.parentId : undefined; - const siblings = childrenByParent.get(parent) ?? []; - siblings.push(task); - childrenByParent.set(parent, siblings); - } - const renderLevel = (parentId: string | undefined, level: number): ReactNode => { - const siblings = childrenByParent.get(parentId) ?? []; - return siblings.map((task, index) => ( - - {childrenByParent.has(task.id) ? ( -
- {renderLevel(task.id, level + 1)} -
- ) : null} -
- )); - }; - return renderLevel(undefined, 1); -} - -function TaskLedgerRow({ task, copy, level, position, setSize, children }: { - task: Task; - copy: SharedUiCopy['taskLedger']; - level: number; - position: number; - setSize: number; - children?: ReactNode; -}) { - const StatusIcon = STATUS_ICONS[task.status]; - const depth = level - 1; - const detail = task.blockedReason ?? task.failureReason ?? task.completionEvidence; - const owner = task.owner?.actor === 'child_agent' - ? copy.childAgent(task.owner.agentId) - : task.owner?.actor === 'main_agent' ? copy.mainAgent : undefined; - return ( -
-
- ); -} - -function isTerminal(status: TaskStatus): boolean { - return status === 'completed' || status === 'failed' || status === 'cancelled'; -} - -function orderTaskTree(tasks: readonly Task[]): Task[] { - return [...tasks].sort((a, b) => compareKeys(a.key, b.key)); -} - -function withAncestors(allTasks: readonly Task[], seeds: readonly Task[]): Task[] { - const byId = new Map(allTasks.map((task) => [task.id, task])); - const selected = new Map(); - for (const seed of seeds) { - let current: Task | undefined = seed; - const seen = new Set(); - while (current && !seen.has(current.id)) { - seen.add(current.id); - selected.set(current.id, current); - current = current.parentId ? byId.get(current.parentId) : undefined; - } - } - return [...selected.values()]; -} - -function compareKeys(left: string, right: string): number { - const a = left.slice(1).split('.').map(Number); - const b = right.slice(1).split('.').map(Number); - for (let index = 0; index < Math.max(a.length, b.length); index += 1) { - if (a[index] === undefined) return -1; - if (b[index] === undefined) return 1; - if (a[index] !== b[index]) return a[index]! - b[index]!; - } - return 0; -} From f2b00153195bf6b09cec51229a662f446e8620ac Mon Sep 17 00:00:00 2001 From: me2seeks Date: Mon, 31 Aug 2026 21:10:55 +0800 Subject: [PATCH 2/4] docs(runtime): document blocked task todo migration Clarify that the one-time legacy bootstrap keeps blocked Task subjects visible as pending Todo items while dropping workflow-only metadata. Refs #4338 Generated-by: OpenAI Codex --- docs/session-todo-lifecycle.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/session-todo-lifecycle.md b/docs/session-todo-lifecycle.md index 2b34bb9394..eb043d7a96 100644 --- a/docs/session-todo-lifecycle.md +++ b/docs/session-todo-lifecycle.md @@ -95,10 +95,12 @@ is what makes one-time migration and explicit clearing deterministic. ## One-time legacy bootstrap The first Host read of an uninitialized Session, through either `todo_read` or -`session.todo.query`, projects only canonical legacy Tasks whose status is -`pending` or `in_progress`, then persists the result even when it is empty. -Terminal, blocked, failed, cancelled, ownership, evidence, and hierarchy fields -are not imported. +`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 From 124d3da72b910b4c164b2459dc4fdde9d041ff4f Mon Sep 17 00:00:00 2001 From: me2seeks Date: Mon, 31 Aug 2026 23:24:34 +0800 Subject: [PATCH 3/4] fix(desktop): inject session todo copy Generated-by: OpenAI Codex --- apps/desktop/renderer-architecture.json | 1 - .../workbar/tools/tasks/use-session-todo.ts | 14 +++++++------- .../features/workbar/ui/workbar-surface.tsx | 8 ++++++-- .../src/renderer/locales/conversation-copy.ts | 3 +++ 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 4e65d58d7a..9fa160354b 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -313,7 +313,6 @@ "src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx -> src/renderer/use-composer-attachments", "src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts -> src/renderer/locales/conversation-copy", "src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts -> src/renderer/settled-message-merge", - "src/renderer/features/workbar/tools/tasks/use-session-todo.ts -> src/renderer/locales/shell-remaining-copy", "src/renderer/features/workbar/tools/terminal/session-terminal-panel.tsx -> src/renderer/locales/conversation-copy", "src/renderer/features/workbar/tools/terminal/session-terminal-panel.tsx -> src/renderer/theme", "src/renderer/features/workbar/ui/side-chat-close-confirmation.tsx -> src/renderer/locales/conversation-copy", diff --git a/apps/desktop/src/renderer/features/workbar/tools/tasks/use-session-todo.ts b/apps/desktop/src/renderer/features/workbar/tools/tasks/use-session-todo.ts index 0b55bb46a9..fd2dc7d7df 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/tasks/use-session-todo.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/tasks/use-session-todo.ts @@ -20,8 +20,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { generalizedErrorMessage, generalizedErrorMessageChinese } from '@maka/core/redaction'; import type { SessionTodoItem } from '@maka/core/session-todo'; -import { useUiLocale } from '@maka/ui'; -import { getShellRemainingCopy } from '../../../../locales/shell-remaining-copy.js'; +import type { UiLocale } from '@maka/ui'; import { useWorkbarServices } from '../../services-context.js'; interface SessionTodoView { @@ -33,10 +32,11 @@ interface SessionTodoView { const EMPTY: SessionTodoView = { items: [], loading: false }; -export function useSessionTodo(sessionId: string | undefined): SessionTodoView & { retry: () => void } { +export function useSessionTodo( + sessionId: string | undefined, + copy: { locale: UiLocale; loadFailed: string }, +): SessionTodoView & { retry: () => void } { const { todo } = useWorkbarServices(); - const locale = useUiLocale(); - const copy = getShellRemainingCopy(locale).tasks; const generation = useRef(0); const [snapshot, setSnapshot] = useState(EMPTY); @@ -59,13 +59,13 @@ export function useSessionTodo(sessionId: string | undefined): SessionTodoView & items: current.sessionId === targetSessionId ? current.items : [], loading: false, error: - locale === 'zh' + copy.locale === 'zh' ? generalizedErrorMessageChinese(error, copy.loadFailed) : generalizedErrorMessage(error, copy.loadFailed), })); }, ); - }, [copy.loadFailed, locale, todo]); + }, [copy.loadFailed, copy.locale, todo]); useEffect(() => { generation.current += 1; diff --git a/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx b/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx index 04ad6603d8..fc5ed6576a 100644 --- a/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx +++ b/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx @@ -718,8 +718,12 @@ export function WorkbarSurface(props: { sourceSession?: SessionSummary; modelChoices?: readonly ChatModelChoice[]; }) { - const copy = getDesktopConversationCopy(useUiLocale()).workbar; - const sessionTodo = useSessionTodo(props.sessionId); + const locale = useUiLocale(); + const copy = getDesktopConversationCopy(locale).workbar; + const sessionTodo = useSessionTodo(props.sessionId, { + locale, + loadFailed: copy.todoLoadFailed, + }); const taskCount = sessionTodoActiveCount(sessionTodo.items); const [artifactCount, setArtifactCount] = useState(0); const placements: SessionWorkbarPlacement[] = ['right', 'bottom']; diff --git a/apps/desktop/src/renderer/locales/conversation-copy.ts b/apps/desktop/src/renderer/locales/conversation-copy.ts index 45e77bdd02..a41bc754a7 100644 --- a/apps/desktop/src/renderer/locales/conversation-copy.ts +++ b/apps/desktop/src/renderer/locales/conversation-copy.ts @@ -96,6 +96,7 @@ export interface DesktopConversationCopy { terminal: string; terminalNumbered(index: number): string; tasks: string; + todoLoadFailed: string; workBoard: string; browser: string; files: string; @@ -459,6 +460,7 @@ const COPY = { terminal: '终端', terminalNumbered: (index) => `终端 ${index}`, tasks: '待办', + todoLoadFailed: '待办载入失败,请重试。', workBoard: '工作看板', browser: '浏览器', files: '生成文件', @@ -688,6 +690,7 @@ const COPY = { terminal: 'Terminal', terminalNumbered: (index) => `Terminal ${index}`, tasks: 'To-do', + todoLoadFailed: 'Failed to load the to-do list. Try again.', workBoard: 'Work board', browser: 'Browser', files: 'Generated files', From eb65f3653234667cda91a6e039356ffc7b2cf406 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 1 Sep 2026 00:03:06 +0800 Subject: [PATCH 4/4] test(desktop): assert flat session todo order Generated-by: OpenAI Codex --- apps/desktop/e2e/accessibility-coverage.spec.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/desktop/e2e/accessibility-coverage.spec.ts b/apps/desktop/e2e/accessibility-coverage.spec.ts index 82cc6e3a89..322ed8d8fc 100644 --- a/apps/desktop/e2e/accessibility-coverage.spec.ts +++ b/apps/desktop/e2e/accessibility-coverage.spec.ts @@ -142,13 +142,18 @@ test('module pages and global overlays expose named actionable controls', async await page.keyboard.press('Escape'); }); -test('data-backed conversation supports keyboard access to tools, models, tasks, and Graph', async ({ +test('data-backed conversation exposes ordered todos and keyboard access to tools, models, and Graph', async ({ accessibilityNarrativeWindow: page, }) => { const cdp = await page.context().newCDPSession(page); await expect(page.getByRole('region', { name: /对话:/ })).toBeVisible(); - await expect(page.getByRole('region', { name: '任务待办' })).toBeVisible(); - await expect(page.getByText('补齐桌面端无障碍覆盖', { exact: true })).toBeVisible(); + const todoRegion = page.getByRole('region', { name: '任务待办' }); + await expect(todoRegion).toBeVisible(); + await expect(todoRegion.getByRole('listitem')).toHaveText([ + '补齐桌面端无障碍覆盖', + '核对模型选择器的键盘路径', + '确认工具结果可以展开阅读', + ]); await assertAxHealth(cdp, 'conversation/data-backed'); await expect(page.getByRole('main')).toHaveCount(1); @@ -192,11 +197,6 @@ test('data-backed conversation supports keyboard access to tools, models, tasks, graphPanel.getByRole('button', { name: '展开 Agent Graph' }), ).toHaveAttribute('aria-expanded', 'false'); await assertAxHealth(cdp, 'conversation/agent-graph-empty'); - - const recentTasks = page.getByRole('button', { name: /最近结束/ }); - await tabTo(page, recentTasks, 'recent tasks', 80); - await page.keyboard.press('Enter'); - await expect(page.getByText('确认工具结果可以展开阅读', { exact: true })).toBeVisible(); }); test('toast and error states expose healthy live regions', async ({ window: page }) => {