You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by zh...@apache.org on 2026/08/31 16:44:21 UTC
(maka) branch main updated: feat(runtime): cut over SessionTodo to Runtime Host authority (#4351)
This is an automated email from the ASF dual-hosted git repository.
me2seeks pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git
The following commit(s) were added to refs/heads/main by this push:
new 6b9d16fed1 feat(runtime): cut over SessionTodo to Runtime Host authority (#4351)
6b9d16fed1 is described below
commit 6b9d16fed1c41fce9e16d119d43c99996329d383
Author: Zhang <96...@users.noreply.github.com>
AuthorDate: Tue Sep 1 00:44:16 2026 +0800
feat(runtime): cut over SessionTodo to Runtime Host authority (#4351)
* 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
* 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
* fix(desktop): inject session todo copy
Generated-by: OpenAI Codex
* test(desktop): assert flat session todo order
Generated-by: OpenAI Codex
---
apps/desktop/e2e/accessibility-coverage.spec.ts | 16 +-
apps/desktop/renderer-architecture.json | 1 -
.../runtime-host-client-operations.test.ts | 79 +--
.../main/__tests__/runtime-host-client-uds.test.ts | 20 +-
.../runtime-host-session-domains-ipc-main.test.ts | 16 +-
.../__tests__/workbar-services-adapter.test.ts | 12 +-
apps/desktop/src/main/e2e-fixture.ts | 40 +-
apps/desktop/src/main/runtime-host-client.ts | 38 +-
.../main/runtime-host-session-domains-ipc-main.ts | 13 +-
apps/desktop/src/preload/bridge-contract.d.ts | 8 +-
apps/desktop/src/preload/preload.ts | 12 +-
.../desktop/src/renderer/features/workbar/ports.ts | 10 +-
.../src/renderer/features/workbar/testing.ts | 4 +-
.../workbar/tools/tasks/use-session-tasks.ts | 96 ----
.../workbar/tools/tasks/use-session-todo.ts | 92 ++++
.../features/workbar/ui/workbar-surface.tsx | 26 +-
.../src/renderer/locales/conversation-copy.ts | 3 +
.../platform/desktop/create-workbar-services.ts | 8 +-
apps/desktop/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 | 176 +++++++
packages/cli/src/__tests__/pi-transcript.test.ts | 96 ++++
packages/core/src/__tests__/session-todo.test.ts | 13 +
.../core/src/__tests__/tool-activity-args.test.ts | 9 +
.../core/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 +-
.../src/__tests__/execution-host-message.test.ts | 6 -
.../src/__tests__/execution-host-queue.test.ts | 6 -
.../src/__tests__/execution-host-recovery.test.ts | 6 -
.../src/__tests__/execution-host.test.ts | 252 ++-------
.../__tests__/execution-model-composition.test.ts | 28 +-
.../src/__tests__/fixtures/execution-host-suite.ts | 6 -
.../src/__tests__/fixtures/uncooperative-host.ts | 2 +-
.../src/__tests__/goal-coordinator.test.ts | 11 -
.../src/__tests__/goal-root-authority.test.ts | 1 -
.../src/__tests__/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 +--
.../src/__tests__/session-todo-coordinator.test.ts | 96 ++++
.../src/__tests__/session-todo-protocol.test.ts | 50 ++
.../src/__tests__/task-ledger-protocol.test.ts | 225 --------
packages/runtime-host/src/protocol/index.ts | 7 +-
packages/runtime-host/src/protocol/operations.ts | 7 +-
.../src/protocol/session-continuity.ts | 2 +-
packages/runtime-host/src/protocol/session-todo.ts | 70 +++
packages/runtime-host/src/protocol/task-ledger.ts | 425 ---------------
.../src/server/child-agent-composition.ts | 6 +-
.../src/server/execution-composition.ts | 43 +-
.../runtime-host/src/server/goal-coordinator.ts | 28 -
.../src/server/interactive-run-composer.ts | 20 +-
.../src/server/operation-dispatcher.ts | 5 +-
.../src/server/session-retirement-coordinator.ts | 10 +-
.../src/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 +++++
.../runtime/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 +++++-
packages/storage/src/session-todo-authority.ts | 3 +-
packages/storage/src/session-todo-store.ts | 71 ++-
packages/storage/src/storage-writer-composition.ts | 10 +-
.../ui/src/__tests__/session-todo-panel.test.tsx | 43 ++
.../ui/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 ---------
85 files changed, 1662 insertions(+), 3490 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 }) => {
diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json
index 3cad453abf..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-tasks.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 </session-todo>',
+ 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>|\[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>): 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<Task[]> {
- 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<SessionTodoItem[]> {
+ 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<Task[]>;
- subscribeChanges(handler: (event: TaskLedgerChangedEvent) => void): () => void;
+ todo: {
+ read(sessionId: string): Promise<SessionTodoItem[]>;
+ subscribeChanges(handler: (event: { sessionId: string; at: number }) => void): () => void;
};
deepResearch: {
get(sessionId: string): Promise<DeepResearchClientProgress | undefined>;
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<Task[]> {
- return invokeProjectedSessionRuntimeHost('tasks:list', sessionId);
+ todo: {
+ read(sessionId: string): Promise<SessionTodoItem[]> {
+ 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<Task[]>;
+export interface WorkbarTodoService {
+ read(sessionId: string): Promise<SessionTodoItem[]>;
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-tasks.ts
deleted file mode 100644
index 853a04a424..0000000000
--- a/apps/desktop/src/renderer/features/workbar/tools/tasks/use-session-tasks.ts
+++ /dev/null
@@ -1,96 +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 { useCallback, useEffect, useRef, useState } from 'react';
-import { generalizedErrorMessage, generalizedErrorMessageChinese } from '@maka/core/redaction';
-import { type Task } from '@maka/core/task-ledger';
-import { useUiLocale } from '@maka/ui';
-import { getShellRemainingCopy } from '../../../../locales/shell-remaining-copy.js';
-import { useWorkbarServices } from '../../services-context.js';
-
-interface SessionTaskSnapshot {
- sessionId?: string;
- tasks: Task[];
- loading: boolean;
- error?: string;
-}
-
-const EMPTY_SNAPSHOT: SessionTaskSnapshot = {
- tasks: [],
- loading: false,
-};
-
-export function useSessionTasks(sessionId: string | undefined): SessionTaskSnapshot & { retry: () => void } {
- const { tasks: tasksService } = useWorkbarServices();
- const locale = useUiLocale();
- const copy = getShellRemainingCopy(locale).tasks;
- const revisionRef = useRef(0);
- const [snapshot, setSnapshot] = useState<SessionTaskSnapshot>(EMPTY_SNAPSHOT);
-
- const load = useCallback((targetSessionId: string, preserveTasks: boolean) => {
- const revision = ++revisionRef.current;
- setSnapshot((current) => ({
- sessionId: targetSessionId,
- tasks: preserveTasks && current.sessionId === targetSessionId ? current.tasks : [],
- loading: true,
- }));
- void tasksService.list(targetSessionId).then(
- (tasks) => {
- if (revision !== revisionRef.current) return;
- setSnapshot({ sessionId: targetSessionId, tasks, loading: false });
- },
- (error: unknown) => {
- if (revision !== revisionRef.current) return;
- setSnapshot((current) => ({
- sessionId: targetSessionId,
- tasks: current.sessionId === targetSessionId ? current.tasks : [],
- loading: false,
- error: locale === 'zh'
- ? generalizedErrorMessageChinese(error, copy.loadFailed)
- : generalizedErrorMessage(error, copy.loadFailed),
- }));
- },
- );
- }, [copy.loadFailed, locale, tasksService]);
-
- useEffect(() => {
- revisionRef.current += 1;
- if (!sessionId) {
- setSnapshot(EMPTY_SNAPSHOT);
- return;
- }
- const unsubscribe = tasksService.subscribeChanges((event) => {
- if (event.sessionId === sessionId) load(sessionId, true);
- });
- load(sessionId, false);
- return () => {
- revisionRef.current += 1;
- unsubscribe();
- };
- }, [load, sessionId, tasksService]);
-
- const retry = useCallback(() => {
- if (sessionId) load(sessionId, true);
- }, [load, sessionId]);
-
- if (snapshot.sessionId !== sessionId) {
- return { ...EMPTY_SNAPSHOT, loading: Boolean(sessionId), retry };
- }
- return { ...snapshot, retry };
-}
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
new file mode 100644
index 0000000000..fd2dc7d7df
--- /dev/null
+++ b/apps/desktop/src/renderer/features/workbar/tools/tasks/use-session-todo.ts
@@ -0,0 +1,92 @@
+/*
+ * 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 { useCallback, useEffect, useRef, useState } from 'react';
+import { generalizedErrorMessage, generalizedErrorMessageChinese } from '@maka/core/redaction';
+import type { SessionTodoItem } from '@maka/core/session-todo';
+import type { UiLocale } from '@maka/ui';
+import { useWorkbarServices } from '../../services-context.js';
+
+interface SessionTodoView {
+ sessionId?: string;
+ items: SessionTodoItem[];
+ loading: boolean;
+ error?: string;
+}
+
+const EMPTY: SessionTodoView = { items: [], loading: false };
+
+export function useSessionTodo(
+ sessionId: string | undefined,
+ copy: { locale: UiLocale; loadFailed: string },
+): SessionTodoView & { retry: () => void } {
+ const { todo } = useWorkbarServices();
+ const generation = useRef(0);
+ const [snapshot, setSnapshot] = useState<SessionTodoView>(EMPTY);
+
+ const load = useCallback((targetSessionId: string, preserve: boolean) => {
+ const requestGeneration = ++generation.current;
+ setSnapshot((current) => ({
+ sessionId: targetSessionId,
+ items: preserve && current.sessionId === targetSessionId ? current.items : [],
+ loading: true,
+ }));
+ void todo.read(targetSessionId).then(
+ (items) => {
+ if (requestGeneration !== generation.current) return;
+ setSnapshot({ sessionId: targetSessionId, items, loading: false });
+ },
+ (error: unknown) => {
+ if (requestGeneration !== generation.current) return;
+ setSnapshot((current) => ({
+ sessionId: targetSessionId,
+ items: current.sessionId === targetSessionId ? current.items : [],
+ loading: false,
+ error:
+ copy.locale === 'zh'
+ ? generalizedErrorMessageChinese(error, copy.loadFailed)
+ : generalizedErrorMessage(error, copy.loadFailed),
+ }));
+ },
+ );
+ }, [copy.loadFailed, copy.locale, todo]);
+
+ useEffect(() => {
+ generation.current += 1;
+ if (!sessionId) {
+ setSnapshot(EMPTY);
+ return;
+ }
+ const unsubscribe = todo.subscribeChanges((event) => {
+ if (event.sessionId === sessionId) load(sessionId, true);
+ });
+ load(sessionId, false);
+ return () => {
+ generation.current += 1;
+ unsubscribe();
+ };
+ }, [load, sessionId, todo]);
+
+ const retry = useCallback(() => {
+ if (sessionId) load(sessionId, true);
+ }, [load, sessionId]);
+
+ 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..fc5ed6576a 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';
@@ -718,9 +718,13 @@ export function WorkbarSurface(props: {
sourceSession?: SessionSummary;
modelChoices?: readonly ChatModelChoice[];
}) {
- const copy = getDesktopConversationCopy(useUiLocale()).workbar;
- const sessionTasks = useSessionTasks(props.sessionId);
- const taskCount = deriveTaskLedgerPanelModel(sessionTasks.tasks).activeCount;
+ 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'];
const positionedTabs = placements.flatMap((placement) =>
@@ -821,11 +825,11 @@ export function WorkbarSurface(props: {
);
} else if (tab.kind === 'tasks') {
content = (
- <TaskLedgerPanel
- tasks={sessionTasks.tasks}
- loading={sessionTasks.loading}
- error={sessionTasks.error}
- onRetry={sessionTasks.retry}
+ <SessionTodoPanel
+ items={sessionTodo.items}
+ loading={sessionTodo.loading}
+ error={sessionTodo.error}
+ onRetry={sessionTodo.retry}
/>
);
} else if (tab.kind === 'work-board') {
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',
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 @@
-<!--
- Licensed to the Apache Software Foundation (ASF) under one
- or more contributor license agreements. See the NOTICE file
- distributed with this work for additional information
- regarding copyright ownership. The ASF licenses this file
- to you under the Apache License, Version 2.0 (the
- "License"); you may not use this file except in compliance
- with the License. You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
- Unless required by applicable law or agreed to in writing,
- software distributed under the License is distributed on an
- "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- KIND, either express or implied. See the License for the
- specific language governing permissions and limitations
- under the License.
--->
-
-# Session 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..eb043d7a96
--- /dev/null
+++ b/docs/session-todo-lifecycle.md
@@ -0,0 +1,176 @@
+<!--
+ 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.
+-->
+
+# 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 `<session-todo>` 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`, keeps canonical `pending` and `in_progress` Tasks at their
+current status. A canonical `blocked` Task is imported as `pending` with the
+same subject so unfinished work remains visible for replanning. The Host then
+persists the result even when it is empty. Workflow-only blocked reasons,
+ownership, evidence, hierarchy, and terminal `completed`, `failed`, or
+`cancelled` Tasks are not imported.
+
+The first explicit `todo_write` never reads or merges legacy Tasks. It writes
+the requested complete list directly. Once a SessionTodo row exists, no later
+read consults the legacy Task Ledger again.
+
+Malformed legacy events fail closed without creating the initialized marker.
+An explicit whole-document write can recover from malformed legacy input
+because it does not decode it.
+
+## 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<typeof createMakaPiTranscriptState>) =>
+ replaceTranscriptWithStoredMessages(state, messages),
+ (state: ReturnType<typeof createMakaPiTranscriptState>) => {
+ 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 </session-todo>',
+ );
+ assert.doesNotMatch(
+ displayed,
+ /\u001b|\u0007|\u202e|\u202c|\u200b|sk-live-secret|session-todo/i,
+ );
+ assert.match(displayed, /deploy/);
+ assert.match(displayed, /<redacted>|\[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<string, unknown> | 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<ReturnType<OperationHandlerMap['task.ledger.query']>> = {
+ const sessionTodoUnavailable: Awaited<ReturnType<OperationHandlerMap['session.todo.query']>> = {
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<TaskCreateInput>(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<TaskUpdateInput>(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<TaskUpdateInput>(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<TaskLedgerQueryResult, { kind: 'page' }>;
-type TaskLedgerTool<Input> = MakaTool<Input, string> & {
- parameters: { parse(value: unknown): Input };
+type SessionTodoWriteTool = MakaTool<{ todos: SessionTodoItem[] }, string> & {
+ parameters: { parse(value: unknown): { todos: SessionTodoItem[] } };
};
-async function withOwnedTaskLedgerToolPort<T>(
+async function withOwnedSessionTodoToolPort<T>(
fixture: ExecutionFixture,
- run: (coordinator: HostTaskLedgerCoordinator, tools: MakaTool[]) => Promise<T>,
+ run: (coordinator: HostSessionTodoCoordinator, tools: MakaTool[]) => Promise<T>,
): Promise<T> {
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<ReturnType<typeof openInteractiveTaskLedgerStoreForWrite>> | undefined;
+ if (!owner) throw new Error('Unable to acquire the interactive SessionTodo tool port');
+ let writer: Awaited<ReturnType<typeof openInteractiveSessionTodoStoreForWrite>> | 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<Input>(
- tools: readonly MakaTool[],
- name: 'task_create' | 'task_update',
-): TaskLedgerTool<Input> {
- const tool = tools.find((candidate) => candidate.name === name);
- assert.ok(tool, `Expected ${name} Runtime tool`);
- return tool as TaskLedgerTool<Input>;
+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, 'runId' | 'turnId' | 'toolCallId'>,
-): 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<void>(() => {}) };
},
- 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<void>(() => {}) };
},
- 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 <task-ledger> B' }),
- validTask(1, {
- subject: '<task-ledger>',
- status: 'completed',
- completionEvidence: 'Verified <task-ledger> ghp_abcdefghijklmnopqrstuvwxyz123456',
- resumeTrust: 'trusted',
- }),
- validTask(2, {
- status: 'failed',
- failureReason: '<task-ledger>',
- resumeTrust: 'trusted',
- }),
- validTask(3, {
- status: 'blocked',
- blockedReason: '<task-ledger>',
- 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> = {}): 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<Task>;
-
-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<string, unknown>,
- field: 'parentId',
-): Pick<Task, 'parentId'> | Record<string, never> {
- return Object.hasOwn(record, field)
- ? { [field]: stableTaskId(record[field], `task ${field}`) }
- : {};
-}
-
-function optionalOwner(
- record: Record<string, unknown>,
-): Pick<Task, 'owner'> | Record<string, never> {
- 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<Field extends Exclude<keyof TaskOwner, 'actor'>>(
- record: Record<string, unknown>,
- field: Field,
-): Pick<TaskOwner, Field> | Record<string, never> {
- return Object.hasOwn(record, field)
- ? ({ [field]: stableTaskId(record[field], `task owner ${field}`) } as Pick<TaskOwner, Field>)
- : {};
-}
-
-function optionalTimestamp(
- record: Record<string, unknown>,
- field: 'endedAt',
-): Pick<Task, 'endedAt'> | Record<string, never> {
- return Object.hasOwn(record, field) ? { [field]: timestamp(record[field], `task ${field}`) } : {};
-}
-
-function optionalTaskText<Field extends 'blockedReason' | 'failureReason' | 'completionEvidence'>(
- record: Record<string, unknown>,
- field: Field,
-): Pick<Task, Field> | Record<string, never> {
- return Object.hasOwn(record, field)
- ? ({ [field]: boundedTaskText(record[field], field) } as Pick<Task, Field>)
- : {};
-}
-
-function optionalResumeTrust(
- record: Record<string, unknown>,
-): Pick<Task, 'resumeTrust'> | Record<string, never> {
- 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<Task, Field> | Record<string, never> {
- if (value === undefined) return {};
- const normalized = normalizeTaskEvidenceText(value, field);
- if (normalized.ok) return { [field]: normalized.value } as Pick<Task, Field>;
- 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<string, unknown>,
- 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<string[]>;
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<void> {
- 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<OperationKey, 'session.recap.generate'>;
+export type SessionTodoOperationKey = Extract<OperationKey, 'session.todo.query'>;
export type SessionCatalogOperationKey = Exclude<
Extract<OperationKey, `session.${string}`>,
| SessionContinuityOperationKey
| SessionRevisionOperationKey
| SessionRetirementOperationKey
| SessionEffectOperationKey
+ | SessionTodoOperationKey
>;
-export type TaskLedgerOperationKey = Extract<OperationKey, 'task.ledger.query'>;
export type ArtifactOperationKey = Extract<OperationKey, `artifact.${string}`>;
export type SkillCatalogOperationKey = Extract<OperationKey, `skill.catalog.${string}`>;
export type UsagePricingOperationKey = Extract<OperationKey, 'usage.query' | `pricing.${string}`>;
@@ -193,7 +194,7 @@ export type SessionRetirementOperationHandlerMap = Pick<
SessionRetirementOperationKey
>;
export type SessionEffectOperationHandlerMap = Pick<OperationHandlerMap, SessionEffectOperationKey>;
-export type TaskLedgerOperationHandlerMap = Pick<OperationHandlerMap, TaskLedgerOperationKey>;
+export type SessionTodoOperationHandlerMap = Pick<OperationHandlerMap, SessionTodoOperationKey>;
export type ArtifactOperationHandlerMap = Pick<OperationHandlerMap, ArtifactOperationKey>;
export type SkillCatalogOperationHandlerMap = Pick<OperationHandlerMap, SkillCatalogOperationKey>;
export type UsagePricingOperationHandlerMap = Pick<OperationHandlerMap, UsagePricingOperationKey>;
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<InteractiveArtifactStoreWriter, 'purgeSessionArtifacts'>;
- readonly taskLedger: Pick<InteractiveTaskLedgerWriter, 'purgeConversationTaskLedger'>;
+ readonly sessionTodo: Pick<InteractiveSessionTodoWriter, 'purgeSessionState'>;
readonly assertNoContextOffloadReferences?: (sessionIds: readonly string[]) => Promise<void>;
readonly purgeOperationalState: (sessionId: string) => Promise<void>;
readonly purgeAgentGraphState: (sessionId: string) => Promise<void>;
@@ -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<void> {
@@ -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<InteractiveArtifactStoreWriter, 'purgeSessionArtifacts'>;
- readonly taskLedger: Pick<InteractiveTaskLedgerWriter, 'purgeConversationTaskLedger'>;
+ readonly sessionTodo: Pick<InteractiveSessionTodoWriter, 'purgeSessionState'>;
readonly purgeOperationalState: (sessionId: string) => Promise<void>;
}
@@ -32,7 +32,7 @@ export async function purgeSessionSidecars(
): Promise<void> {
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<SessionTodoSnapshot>;
+ replace(sessionId: string, items: unknown): Promise<SessionTodoSnapshot>;
+}
+
+/** 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<SessionTodoSnapshot> {
+ return this.sessionAdmission.run(sessionId, async () => {
+ await this.#requirePresent(sessionId);
+ return this.#writer.readOrBootstrap(sessionId);
+ });
+ }
+
+ replace(sessionId: string, items: unknown): Promise<SessionTodoSnapshot> {
+ 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<OperationOutcome<'session.todo.query'>> {
+ 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<void> {
+ 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<Task[]> {
- return this.sessionAdmission.run(sessionId, () => this.#writer.list(sessionId, options));
- }
-
- get(sessionId: string, id: string, options?: TaskLedgerListOptions): Promise<Task | undefined> {
- 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<OperationOutcome<'task.ledger.query'>> {
- 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 </session-todo>',
+ 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>|\[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<string, unknown>;
@@ -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<Task[]> {
- 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<Task | undefined> {
- 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 = [
- 'a<task-ledger/>b',
- '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 <task-ledger> 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 = [
- '</task-ledger>',
- '</task-ledger >',
- '<task-ledger x="1">',
- '</task-ledger\t>',
- '<task-ledger/>',
- '<task-ledger>',
- ];
- 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<SessionTodoSnapshot>;
+ replace(sessionId: string, items: unknown): Promise<SessionTodoSnapshot>;
+}
+
+export function buildSessionTodoTools(store: SessionTodoToolStore): MakaTool[] {
+ return [buildTodoReadTool(store), buildTodoWriteTool(store)];
+}
+
+function buildTodoReadTool(store: SessionTodoToolStore): MakaTool<Record<string, never>, 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<SubagentToolResult, 'kind'>;
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<string, unknown>) };
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<void>): Promise<void> {
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<SessionTodoSnapshot>;
/** Replace the complete document without consulting legacy Task state. */
replaceAll(sessionId: string, items: unknown): Promise<SessionTodoSnapshot>;
- purge(sessionId: string): Promise<void>;
+ /** Initialize one conversation-copy target without overwriting conflicting state. */
+ initializeCopy(input: {
+ sourceSessionId: string;
+ targetSessionId: string;
+ copyCurrent: boolean;
+ }): Promise<SessionTodoSnapshot>;
+ /** Purge current state and the legacy events that could bootstrap it. */
+ purgeSessionState(sessionId: string): Promise<void>;
}
export interface SqliteSessionTodoStore extends SessionTodoStore {
@@ -83,7 +90,7 @@ class SqliteSessionTodoStoreImpl implements SqliteSessionTodoStore {
async readOrBootstrap(sessionId: string): Promise<SessionTodoSnapshot> {
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<void> {
+ async initializeCopy(input: {
+ sourceSessionId: string;
+ targetSessionId: string;
+ copyCurrent: boolean;
+ }): Promise<SessionTodoSnapshot> {
+ 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<void> {
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<void>): Promise<void> {
+ // 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<ReturnType<typeof openInteractiveGoalAuthorityForWrite>>;
readonly memoryBundle: Awaited<ReturnType<typeof openInteractiveMemoryBundleStoreForWrite>>;
readonly longTermMemory: Awaited<ReturnType<typeof openInteractiveLongTermMemoryStoreForWrite>>;
- readonly taskLedger: Awaited<ReturnType<typeof openInteractiveTaskLedgerStoreForWrite>>;
+ readonly sessionTodo: Awaited<ReturnType<typeof openInteractiveSessionTodoStoreForWrite>>;
readonly artifacts: Awaited<ReturnType<typeof openInteractiveArtifactStoreForWrite>>;
readonly contextOffload?: Awaited<ReturnType<typeof openInteractiveContextOffloadStoreForWrite>>;
/** 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(
+ <LocaleProvider locale="en">
+ <SessionTodoPanel items={items} />
+ </LocaleProvider>,
+ );
+ 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<Task> & Pick<Task, 'id' | 'key' | 'status'>): 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<SessionTodoStatus, typeof Clock>;
+
+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 (
+ <section className="maka-task-ledger-panel" aria-label={copy.ariaLabel}>
+ {props.error ? (
+ <Banner
+ status="error"
+ role="alert"
+ className="maka-task-ledger-message"
+ title={props.error}
+ endContent={props.onRetry ? (
+ <IconButton
+ variant="ghost"
+ size="sm"
+ className="maka-task-ledger-retry"
+ onClick={props.onRetry}
+ label={copy.retry}
+ tooltip={copy.retry}
+ icon={<RefreshCcw size={ICON_SIZE.control} aria-hidden="true" />}
+ />
+ ) : undefined}
+ />
+ ) : props.loading && props.items.length === 0 ? (
+ <Spinner size="sm" shade="subtle" label={copy.loading} className="maka-task-ledger-message" />
+ ) : props.items.length === 0 ? (
+ <EmptyState
+ isCompact
+ className="maka-task-ledger-empty"
+ icon={<ListTodo size={ICON_SIZE.empty} aria-hidden="true" />}
+ title={copy.empty}
+ />
+ ) : (
+ <ol className="maka-task-ledger-tree" aria-label={copy.activeAriaLabel}>
+ {props.items.map((item, index) => {
+ const StatusIcon = STATUS_ICONS[item.status];
+ return (
+ <li className="maka-task-ledger-row" key={`${index}:${item.status}:${item.content}`}>
+ <StatusIcon size={ICON_SIZE.control} aria-hidden="true" />
+ <span>{item.content}</span>
+ </li>
+ );
+ })}
+ </ol>
+ )}
+ </section>
+ );
+}
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<TaskStatus, string>;
+ 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<TaskStatus, typeof Clock>;
-
-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 (
- <section className="maka-task-ledger-panel" aria-label={copy.ariaLabel}>
- {props.error ? (
- <Banner
- status="error"
- role="alert"
- className="maka-task-ledger-message"
- title={props.error}
- endContent={props.onRetry ? (
- <IconButton
- variant="ghost"
- size="sm"
- className="maka-task-ledger-retry"
- onClick={props.onRetry}
- label={copy.retry}
- tooltip={copy.retry}
- icon={<RefreshCcw size={ICON_SIZE.control} aria-hidden="true" />}
- />
- ) : undefined}
- />
- ) : props.loading && props.tasks.length === 0 ? (
- <Spinner size="sm" shade="subtle" label={copy.loading} className="maka-task-ledger-message" />
- ) : (
- <>
- {model.activeCount > 0 ? (
- <div className="maka-task-ledger-tree" role="tree" aria-label={copy.activeAriaLabel}>
- <TaskLedgerTree tasks={model.activeTree} copy={copy} />
- </div>
- ) : (
- <EmptyState
- isCompact
- className="maka-task-ledger-empty"
- icon={<ListTodo size={ICON_SIZE.empty} aria-hidden="true" />}
- title={copy.empty}
- />
- )}
- {model.recentTerminalCount > 0 && (
- <Collapsible
- className="maka-task-ledger-terminal"
- defaultIsOpen={false}
- trigger={(
- <span className="maka-task-ledger-terminal-trigger">
- <span>{copy.recent}</span>
- <span>{model.recentTerminalCount}</span>
- </span>
- )}
- >
- <div className="maka-task-ledger-tree" role="tree" aria-label={copy.recentAriaLabel}>
- <TaskLedgerTree tasks={model.recentTerminalTree} copy={copy} />
- </div>
- </Collapsible>
- )}
- </>
- )}
- </section>
- );
-}
-
-function TaskLedgerTree({ tasks, copy }: { tasks: readonly Task[]; copy: SharedUiCopy['taskLedger'] }) {
- const included = new Set(tasks.map((task) => task.id));
- const childrenByParent = new Map<string | undefined, Task[]>();
- 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) => (
- <TaskLedgerRow
- key={task.id}
- task={task}
- copy={copy}
- level={level}
- position={index + 1}
- setSize={siblings.length}
- >
- {childrenByParent.has(task.id) ? (
- <div role="group" className="maka-task-ledger-group">
- {renderLevel(task.id, level + 1)}
- </div>
- ) : null}
- </TaskLedgerRow>
- ));
- };
- 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 (
- <div
- className="maka-task-ledger-row"
- role="treeitem"
- aria-level={level}
- aria-posinset={position}
- aria-setsize={setSize}
- data-status={task.status}
- style={{ '--task-depth': Math.min(depth, 6) } as CSSProperties}
- >
- <StatusIcon size={ICON_SIZE.control} aria-hidden="true" />
- <code className="maka-task-ledger-key">{task.key}</code>
- <span className="maka-task-ledger-subject" title={task.subject}>{task.subject}</span>
- <span className="maka-task-ledger-meta">
- <span>{copy.status[task.status]}</span>
- {owner && <span title={owner}>{owner}</span>}
- </span>
- {detail && <span className="maka-task-ledger-detail" title={detail}>{detail}</span>}
- {children}
- </div>
- );
-}
-
-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<string, Task>();
- for (const seed of seeds) {
- let current: Task | undefined = seed;
- const seen = new Set<string>();
- 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;
-}
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org