From 8b69252664e2e608af2f3b89b6b1b998ec42bd3d Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 6 Aug 2026 03:47:37 +0800 Subject: [PATCH] feat(schedule): add durable after reminders --- .../2026-08-05-durable-web-schedule.md | 10 +- .../2026-08-05-durable-web-schedule.zh.md | 10 +- apps/web/tests/scaffold.ts | 45 ++++-- apps/web/tests/schedule-after.e2e.ts | 146 +++++++++++++++++- packages/client/connection/README.md | 2 +- packages/client/connection/README.zh.md | 2 +- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- .../src/client/sessions/conversation.ts | 8 +- .../runtime/src/client/sessions/session.ts | 50 +++--- .../src/client/sessions/transcript-adapter.ts | 2 +- packages/client/runtime/tests/session.spec.ts | 24 ++- .../runtime/tests/transcript-adapter.spec.ts | 6 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/chat/ChatView.tsx | 6 +- .../src/client/chat/GenericEventCard.tsx | 4 +- .../ui-conversation/tests/chat-view.spec.tsx | 6 +- packages/client/ui-schedule/README.md | 2 +- packages/client/ui-schedule/README.zh.md | 2 +- .../ui-schedule/src/client/ReminderRow.tsx | 2 +- .../client/ui-schedule/src/client/index.ts | 2 +- .../ui-schedule/tests/browser-plugin.spec.ts | 2 +- .../ui-schedule/tests/reminder-row.spec.tsx | 4 +- .../core/tools/tests/gen-tool-catalog.spec.ts | 2 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 7 +- packages/host/apiproxy/src/api/events.ts | 8 +- .../host/apiproxy/src/api/sessions.schema.ts | 3 +- .../tests/api-proxy-schedule-view.spec.ts | 8 +- .../host/apiproxy/tests/rpc-schemas.spec.ts | 8 +- packages/schedule/tool-schedule/README.md | 2 +- packages/schedule/tool-schedule/README.zh.md | 2 +- packages/schedule/tool-schedule/src/domain.ts | 3 - packages/schedule/tool-schedule/src/index.ts | 1 - packages/schedule/tool-schedule/src/tools.ts | 3 + .../tool-schedule/tests/domain.spec.ts | 2 - .../tool-schedule/tests/tools.spec.ts | 11 ++ .../session-persistence/src/coordinator.ts | 86 +++++++---- .../tests/persistence.spec.ts | 7 +- 41 files changed, 354 insertions(+), 146 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md index 584d7be639..b24a816d64 100644 --- a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md +++ b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md @@ -51,11 +51,11 @@ Agent or plugin disposal cancels timers, stops new work, unwinds the three tool The Schedule package owns `scheduleReminderPresentation()`, which derives `{ scheduleId, prompt, occurrenceAt, deliveryMode }` from create plus dispatch. A dispatch inside an inherited fork prefix folds that parent segment for history display; a child-owned dispatch folds only the child suffix. Presentation therefore never changes live ownership. -The Host continues to send every raw event on append. It keeps one monotonic watermark per exact live `Session` in a `WeakMap`; only `session/flushed` advancement makes it redeliver newly covered dispatch events with the generic `{ for: 'event', presentationKey: 'schedule/reminder', view }` sidecar. Taking the maximum contains reversed concurrent flush completion, and exact object identity prevents a reused Session id from inheriting another lifecycle's cursor. +The Host continues to send every raw event on append. It keeps one monotonic watermark per exact live `Session` in a `WeakMap`; only `session/flushed` advancement makes it redeliver newly covered dispatch events with the generic `{ for: 'event', view }` sidecar. The durable `schedule/change` type selects the client renderer. Taking the maximum contains reversed concurrent flush completion, and exact object identity prevents a reused Session id from inheriting another lifecycle's cursor. Attached history independently inspects persistence and adds views only to a stored event prefix whose header identity and every event match the live Session. Persistence canonically writes absent top-level `delegationDepth` as zero, so those two forms are identity-equivalent; cwd, lineage, origin, timestamps, version, id, and every event still match exactly. Missing, failed, divergent, or longer inspection withholds the view while returning raw history. Detached history is already a persisted prefix. A parent dispatch copied into a fork seed therefore appears in child history only after child storage proves that prefix. -The browser Session accepts a repeated seq only when the durable event is deeply identical, then upgrades the sidecar without appending another event. Its existing `liveBuffer` is the sole rendezvous for tail loading, gap repair, and older-page pagination. Every current-generation settlement merges overlapping views and a contiguous suffix, including rejected, empty, and discontinuous responses; reconnect invalidates old requests and their loading ownership. `TranscriptAdapter` creates a generic `PresentedEventNode`. `ui-conversation` dispatches it through `conversation.chat.eventview` and retains an expandable JSON fallback, while `ui-schedule` owns the bilingual reminder row. +The browser Session accepts a repeated seq only when the durable event is deeply identical, then upgrades the sidecar immediately without appending another event. Tail loading and true gap repair retain uncovered events in the existing `liveBuffer`; ordinary older-page pagination keeps receiving live tail events in the current arrays and prepends its page after the await. Reconnect generations prevent stale page or repair results and `finally` blocks from touching the rebuilt window. `TranscriptAdapter` creates a generic `PresentedEventNode` keyed by the durable event type. `ui-conversation` dispatches it through `conversation.chat.eventview` and retains an expandable JSON fallback, while `ui-schedule` owns the bilingual `schedule/change` reminder row. ```text schedule_create → Session create event → persistence @@ -64,7 +64,7 @@ due → admission → followup → dispatch → flush(true) → session/flushed ↓ Host late event sidecar ↓ - client same-seq merge → keyed UI receipt + client same-seq upgrade → event-keyed UI receipt ``` ## Alternatives considered @@ -87,7 +87,7 @@ The design does not recognize or migrate any unmerged Schedule implementation or ## Verification -Package tests pin strict decoding, transitions, fork suffixes, id reuse, time bounds, bounded waits, wall-clock movement, overdue admission, fixed framing, enqueue and append failures, barrier recovery, registration rollback, and quiescent disposal at 100% per-file coverage. Persistence tests cover new, fork, and resumed initialization failures against the actual durable cursor, and a production JSONL restart proves both pending and dispatched states. Host/client tests cover commit gating, reversed watermarks, semantic header identity, per-event prefix matching, same-seq upgrades, every window merge exit, and reconnect generations. +Package tests pin strict decoding, transitions, fork suffixes, id reuse, time bounds, bounded waits, wall-clock movement, overdue admission, fixed framing, enqueue and append failures, barrier recovery, registration rollback, and quiescent disposal at 100% per-file coverage. Persistence tests cover new, fork, and resumed initialization failures against the actual durable cursor. The assembled Loader/Web restart lane proves pending recovery, fork isolation, one durable dispatch, cold-history rendering without Agent activation, and no redelivery after another restart. Host/client tests cover commit gating, reversed watermarks, semantic header identity, per-event prefix matching, immediate same-seq upgrades, concurrent live-tail pagination, true gaps, and reconnect generations. The opt-in Loader composition boots the source and built packages. A keyless real-browser scenario executes `schedule_create` through the complete tool pipeline, waits for a one-second dispatch, observes the identity-matched persisted prefix, and renders the durable reminder card from attached history. The deliberately absent model adapter closes the turn with an error after dispatch, proving that model failure does not remove the receipt. @@ -96,5 +96,5 @@ The opt-in Loader composition boots the source and built packages. A keyless rea - Reminder state survives process restart and replays through ordinary Session persistence without a new database or public service. - A cold Session does no work and sends no external notification; reopening it may deliver an overdue reminder, and every tool/card says `session-local`. - Each live root adds only fold-derived timers, an optional idle wait, and one in-flight operation. Long waits and plugin unload do not create a second durable state machine. -- The generic commit-aware event-view path is reusable by other durable events, but it adds identity checks and generation-aware merge behavior to the client Session window. +- The generic commit-aware event-view path is reusable by other durable events, but it adds event-identity checks and request-generation fencing to the client Session window. - The strict after-only protocol is intentionally small; other rule families require explicit record, time, and recurrence semantics rather than dormant fields. diff --git a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md index 2bfeb81cac..78939119b6 100644 --- a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md @@ -51,11 +51,11 @@ Agent 或插件 dispose 会取消 timer、停止新工作、撤销三个工具 Schedule package 拥有 `scheduleReminderPresentation()`,从 create 加 dispatch 派生 `{ scheduleId, prompt, occurrenceAt, deliveryMode }`。位于继承 fork 前缀中的 dispatch 会折叠该 parent segment 用于 history 显示;child 自有 dispatch 只折叠 child 后缀。因此 presentation 永远不会改变 live ownership。 -Host 在 append 时继续发送所有 raw event。它在 `WeakMap` 中按 exact live `Session` 保存一个单调 watermark;只有 `session/flushed` 前进时,才会用通用 `{ for: 'event', presentationKey: 'schedule/reminder', view }` sidecar 重投新覆盖的 dispatch event。取最大值可以收容反序完成的并发 flush,按对象身份键控则阻止复用的 Session id 继承另一个生命周期的 cursor。 +Host 在 append 时继续发送所有 raw event。它在 `WeakMap` 中按 exact live `Session` 保存一个单调 watermark;只有 `session/flushed` 前进时,才会用通用 `{ for: 'event', view }` sidecar 重投新覆盖的 dispatch event。持久 `schedule/change` 类型用于选择 client renderer。取最大值可以收容反序完成的并发 flush,按对象身份键控则阻止复用的 Session id 继承另一个生命周期的 cursor。 已附加 history 会独立 inspect persistence,只有 stored event prefix 的 header identity 与每个 event 都和 live Session 匹配时才添加 view。persistence 会把顶层缺失的 `delegationDepth` 规范写成零,因此两种形式在身份上等价;cwd、lineage、origin、时间戳、版本、id 与每个 event 仍必须精确匹配。inspect 缺失、失败、分歧或比 live 更长时,只会省略 view,raw history 仍然返回。已分离 history 本身就是持久前缀。因此复制进 fork seed 的 parent dispatch 只有在 child storage 证明该前缀后才会显示。 -浏览器 Session 只有在 durable event 深度一致时才接受重复 seq,随后只升级 sidecar,不再追加 event。既有 `liveBuffer` 是尾部加载、gap repair 与旧页分页期间唯一的汇合点。每个当前 generation 的结算出口都会合并重叠 view 与连续 suffix,包括拒绝、空页和不连续响应;重连会使旧请求及其 loading ownership 失效。`TranscriptAdapter` 创建通用 `PresentedEventNode`。`ui-conversation` 通过 `conversation.chat.eventview` 分发,并保留可展开 JSON fallback;`ui-schedule` 则拥有双语提醒行。 +浏览器 Session 只有在 durable event 深度一致时才接受重复 seq,随后立即升级 sidecar,不再追加 event。只有尾部加载与真正的 gap repair 才会将尚未覆盖的事件保留在既有 `liveBuffer` 中;普通旧页分页会让当前数组继续接收 live tail 事件,并在 await 后再前插该页。重连 generation 会阻止陈旧的 page 或 repair 结果以及 `finally` 块触碰重建后的 window。`TranscriptAdapter` 创建按持久事件类型键控的通用 `PresentedEventNode`。`ui-conversation` 通过 `conversation.chat.eventview` 分发,并保留可展开 JSON fallback;`ui-schedule` 则拥有双语 `schedule/change` 提醒行。 ```text schedule_create → Session create event → persistence @@ -64,7 +64,7 @@ due → admission → followup → dispatch → flush(true) → session/flushed ↓ Host late event sidecar ↓ - client same-seq merge → keyed UI receipt + client same-seq upgrade → event-keyed UI receipt ``` ## 已考虑的替代方案 @@ -87,7 +87,7 @@ due → admission → followup → dispatch → flush(true) → session/flushed ## 验证 -package 测试以逐文件 100% coverage 固定严格 decoding、transition、fork suffix、id 不复用、时间边界、有界等待、墙钟变化、overdue 准入、固定 framing、入队与 append 失败、barrier 恢复、注册 rollback 和完全停稳 dispose。persistence 测试依据实际 durable cursor 覆盖 new、fork 与 resumed 初始化失败;production JSONL restart 同时证明 pending 与 dispatched 状态。Host/client 测试覆盖 commit gating、反序 watermark、语义 header identity、逐 event 前缀匹配、same-seq 升级、每个 window merge 出口和 reconnect generation。 +package 测试以逐文件 100% coverage 固定严格 decoding、transition、fork suffix、id 不复用、时间边界、有界等待、墙钟变化、overdue 准入、固定 framing、入队与 append 失败、barrier 恢复、注册 rollback 和完全停稳 dispose。persistence 测试依据实际 durable cursor 覆盖 new、fork 与 resumed 初始化失败。组装后的 Loader/Web restart lane 证明 pending 恢复、fork 隔离、单次 durable dispatch、无需激活 agent 的 cold-history rendering,以及再次 restart 后不重投。Host/client 测试覆盖 commit gating、反序 watermark、语义 header identity、逐 event 前缀匹配、same-seq 立即升级、并发 live-tail 分页、真正的 gap 和 reconnect generation。 显式 Loader 组合可以启动 source 与 built package。无密钥真实浏览器场景会通过完整工具 pipeline 执行 `schedule_create`、等待一秒 dispatch、观察 identity-matched 持久前缀,并从已附加 history 渲染 durable reminder card。刻意缺少的模型 adapter 会在 dispatch 后以错误关闭 turn,从而证明模型失败不会移除回执。 @@ -96,5 +96,5 @@ package 测试以逐文件 100% coverage 固定严格 decoding、transition、fo - 提醒状态通过普通 Session persistence 跨进程重启并回放,无需新数据库或公开 service。 - cold Session 不工作、不发送外部通知;重新打开后可能交付 overdue 提醒,且每个工具/卡片都会显示 `session-local`。 - 每个 live 根只增加从 fold 派生的 timer、可选 idle wait 与一个 in-flight operation。长等待和插件卸载不会创建第二套持久状态机。 -- 通用 commit-aware event-view 路径可供其他持久 event 复用,但为 client Session window 增加了身份检查与 generation-aware merge 行为。 +- 通用 commit-aware event-view 路径可供其他持久 event 复用,但为 client Session window 增加了事件身份检查与请求 generation 栅栏。 - 严格的 after-only 协议有意保持小型;其他规则系列需要显式 record、时间与 recurrence 语义,而不是 dormant 字段。 diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 31a638c12e..64848f80d2 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -167,6 +167,11 @@ export interface WebScaffold { /** Options for {@link launchWebScaffold}. */ export interface LaunchOptions { + /** Caller-owned workspace and persistence roots reused across process-style restarts. */ + world?: { + workspaceCwd: string + persistenceRoot: string + } /** * Optional product overlay applied after the shipped Web surface and before * the scaffold's hermetic test patches, matching the launcher's `--patch` @@ -237,11 +242,18 @@ export interface LaunchOptions { } /** Dispose the booted tree and remove both owned temp roots, reporting every independent cleanup failure. */ -async function cleanupScaffoldWorld(ctx: Context, workspaceCwd: string, persistenceRoot: string): Promise { +async function cleanupScaffoldWorld( + ctx: Context, + workspaceCwd: string, + persistenceRoot: string, + removeWorld: boolean, +): Promise { const failures: unknown[] = [] await Promise.resolve(ctx.fiber.dispose()).catch((error: unknown) => failures.push(error)) - await rm(workspaceCwd, { recursive: true, force: true }).catch((error: unknown) => failures.push(error)) - await rm(persistenceRoot, { recursive: true, force: true }).catch((error: unknown) => failures.push(error)) + if (removeWorld) { + await rm(workspaceCwd, { recursive: true, force: true }).catch((error: unknown) => failures.push(error)) + await rm(persistenceRoot, { recursive: true, force: true }).catch((error: unknown) => failures.push(error)) + } return failures } @@ -276,19 +288,26 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise failures.push(cleanupError)) - if (failures.length > 1) throw new AggregateError(failures, 'web scaffold temp-root setup failed') - throw error + if (options.world !== undefined) { + persistenceRoot = await realpath(options.world.persistenceRoot) + } else { + try { + persistenceRoot = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-sessions-')) + } catch (error) { + const failures: unknown[] = [error] + await rm(workspaceCwd, { recursive: true, force: true }).catch((cleanupError: unknown) => failures.push(cleanupError)) + if (failures.length > 1) throw new AggregateError(failures, 'web scaffold temp-root setup failed') + throw error + } } if (maskDeepSeekCredential) Reflect.deleteProperty(process.env, 'DEEPSEEK_API_KEY') @@ -447,7 +466,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise 0) { throw new AggregateError([error, ...cleanupFailures], 'web scaffold setup failed and cleanup was incomplete') @@ -493,7 +512,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise boolean, timeoutMs: number): Promise { let scaffold: WebScaffold let agentHandle: AgentHandle @@ -87,7 +106,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable after reminder receipt', () expect(history.result.value.events?.find(entry => entry.event.type === 'schedule/change' && (entry.event.data as { operation?: unknown }).operation === 'dispatch')?.view).toMatchObject({ - for: 'event', presentationKey: 'schedule/reminder', + for: 'event', }) await waitForFact( () => agentHandle.agent.session.events.some(event => event.type === 'turn/start'), @@ -120,7 +139,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable after reminder receipt', () const group = page.locator('[role="treeitem"]').first() await group.waitFor({ timeout: 15_000 }) if (await group.getAttribute('aria-expanded') !== 'true') { - await group.evaluate((element) => { (element as HTMLElement).click() }) + await group.click() } await expect.poll(() => group.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('true') const session = page.locator('[role="treeitem"][aria-selected]').nth(1) @@ -143,3 +162,124 @@ describe.skipIf(MODE === 'record')('web e2e: durable after reminder receipt', () await assertFixtureInventory(SNAPSHOT_DIR, ['receipt.expected.md']) }) }) + +describe.skipIf(MODE === 'record')('web e2e: Schedule restart, fork, and cold history', () => { + it('preserves pending work, commits one overdue receipt, and replays it cold without activation', async () => { + const workspaceCwd = await realpath(await mkdtemp(join(tmpdir(), 'dsh-schedule-restart-ws-'))) + const persistenceRoot = await mkdtemp(join(tmpdir(), 'dsh-schedule-restart-sessions-')) + const world = { workspaceCwd, persistenceRoot } + const pendingId = SessionId('schedule-restart-pending') + const deliveredId = SessionId('schedule-restart-delivered') + let scaffold: WebScaffold | undefined + try { + scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY, world }) + const workspace = await scaffold.ctx.workspace.create(workspaceCwd, 'Schedule restart') + + const pending = scaffold.ctx.sessions.create(pendingId, { meta: { cwd: workspaceCwd } }) + appendCompletedTurn(pending, 'pending parent turn') + pending.append('session/title', { + title: 'Pending restart session', messageSeqs: [], source: { kind: 'user' }, + }) + const pendingRecord = createAfterScheduleRecord( + ScheduleId('schedule-pending'), 'Pending across restart', 3_600, Date.now(), + ) + pending.append('schedule/change', { version: 1, operation: 'create', schedule: pendingRecord }) + await expect(scaffold.ctx.sessions.flush(pending)).resolves.toBe(true) + await workspace.attachSession(pendingId) + + const delivered = scaffold.ctx.sessions.create(deliveredId, { meta: { cwd: workspaceCwd } }) + appendCompletedTurn(delivered, 'delivered parent turn') + delivered.append('session/title', { + title: 'Delivered restart session', messageSeqs: [], source: { kind: 'user' }, + }) + const overdueRecord = createAfterScheduleRecord( + ScheduleId('schedule-delivered'), 'Delivered after restart', 1, Date.now() - 60_000, + ) + delivered.append('schedule/change', { version: 1, operation: 'create', schedule: overdueRecord }) + await expect(scaffold.ctx.sessions.flush(delivered)).resolves.toBe(true) + await workspace.attachSession(deliveredId) + + await scaffold.close() + scaffold = undefined + + scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY, world }) + const pendingResume = await scaffold.ctx.apiProxy.sessions.create({ + rpcId: RpcId('schedule-pending-resume'), + payload: { sessionId: pendingId, cwd: workspaceCwd }, + }) + if (!pendingResume.result.ok) throw new Error(pendingResume.result.error.message) + const pendingAgent = scaffold.ctx.agents.get(pendingId) + if (pendingAgent === undefined) throw new Error('pending Session did not resume') + expect(foldScheduleEvents( + pendingAgent.session.events, + pendingAgent.session.header.seedLength ?? 0, + ).active).toEqual([expect.objectContaining({ id: 'schedule-pending' })]) + + const forked = await scaffold.ctx.apiProxy.sessions.fork({ + rpcId: RpcId('schedule-pending-fork'), + payload: { sessionId: pendingId }, + }) + if (!forked.result.ok) throw new Error(forked.result.error.message) + const child = scaffold.ctx.agents.get(forked.result.value.sessionId) + if (child === undefined) throw new Error('fork child was not published') + expect(foldScheduleEvents( + child.session.events, + child.session.header.seedLength ?? 0, + ).active).toEqual([]) + + const deliveredResume = await scaffold.ctx.apiProxy.sessions.create({ + rpcId: RpcId('schedule-delivered-resume'), + payload: { sessionId: deliveredId, cwd: workspaceCwd }, + }) + if (!deliveredResume.result.ok) throw new Error(deliveredResume.result.error.message) + const deliveredAgent = scaffold.ctx.agents.get(deliveredId) + if (deliveredAgent === undefined) throw new Error('overdue Session did not resume') + await waitForFact(() => deliveredAgent.session.events.some(event => + event.type === 'schedule/change' && event.data.operation === 'dispatch'), 15_000) + await deliveredAgent.whenIdle() + await expect(scaffold.ctx.sessions.flush(deliveredAgent.session)).resolves.toBe(true) + expect(deliveredAgent.session.events.filter(event => + event.type === 'schedule/change' && event.data.operation === 'dispatch')).toHaveLength(1) + + await scaffold.close() + scaffold = undefined + + scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY, world }) + expect(scaffold.ctx.agents.get(deliveredId)).toBeUndefined() + const coldHistory = await scaffold.ctx.apiProxy.sessions.history({ + rpcId: RpcId('schedule-cold-history'), + payload: { sessionId: deliveredId }, + }) + if (!coldHistory.result.ok) throw new Error(coldHistory.result.error.message) + const dispatchEntries = coldHistory.result.value.events.filter(entry => + entry.event.type === 'schedule/change' + && entry.event.data.operation === 'dispatch') + expect(dispatchEntries).toHaveLength(1) + expect(dispatchEntries[0]?.view?.for).toBe('event') + expect(scaffold.ctx.agents.get(deliveredId)).toBeUndefined() + + await scaffold.close() + scaffold = undefined + + scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY, world }) + const replayed = await scaffold.ctx.apiProxy.sessions.create({ + rpcId: RpcId('schedule-delivered-replay'), + payload: { sessionId: deliveredId, cwd: workspaceCwd }, + }) + if (!replayed.result.ok) throw new Error(replayed.result.error.message) + const replayedAgent = scaffold.ctx.agents.get(deliveredId) + if (replayedAgent === undefined) throw new Error('delivered Session did not resume again') + await replayedAgent.whenIdle() + await expect(scaffold.ctx.sessions.flush(replayedAgent.session)).resolves.toBe(true) + expect(replayedAgent.session.events.filter(event => + event.type === 'schedule/change' && event.data.operation === 'dispatch')).toHaveLength(1) + } finally { + const failures: unknown[] = [] + await scaffold?.close().catch((error: unknown) => failures.push(error)) + await rm(workspaceCwd, { recursive: true, force: true }).catch((error: unknown) => failures.push(error)) + await rm(persistenceRoot, { recursive: true, force: true }).catch((error: unknown) => failures.push(error)) + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'Schedule restart evidence teardown failed') + } + }, 180_000) +}) diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index fa1bc951f9..05733feecc 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -12,7 +12,7 @@ The node half guards every entry under `/api` before bridging or upgrading (`src `/api/events.mux` and `/api/events.host` each accept a WebSocket upgrade and send only the corresponding `ServerRequest` text messages to the browser; the client sends no application data over these sockets. If either socket ends, the current connection generation fails and rebuilds both streams; readiness still requires both sockets to be open and the `host.describe` HTTP call to succeed. Host teardown terminates both sockets, aborts their sources, and waits for source cleanup before returning. Ordinary network GETs to these paths return 426 with no SSE fallback; `toFetchHandler`'s SSE codec serves only the isomorphic in-process carrier. -`SessionEventView` is an optional non-persistent sidecar on both `session.history` entries and live `session/event` frames. Tool views keep their closed call/result shapes; a presented durable event instead carries `{ for: 'event', presentationKey, view }`, leaving the key space and JSON-compatible payload open to domain plugins. The same Session event may be delivered again with a new or changed sidecar, so consumers merge it by exact event identity and seq rather than treating the second frame as another log append. +`SessionEventView` is an optional non-persistent sidecar on both `session.history` entries and live `session/event` frames. Tool views keep their closed call/result shapes; a presented durable event instead carries `{ for: 'event', view }`, leaving the JSON-compatible payload open to domain plugins while its durable event type selects the renderer. The same Session event may be delivered again with a new or changed sidecar, so consumers merge it by exact event identity and seq rather than treating the second frame as another log append. ## Keyless fixture diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index e19c3f206f..b092757f07 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -12,7 +12,7 @@ node 半侧在桥接或 upgrade 前守卫 `/api` 下的每个入口(`src/api-r `/api/events.mux` 与 `/api/events.host` 各接受一条 WebSocket upgrade,并只向浏览器发送对应的 `ServerRequest` text message;客户端不会在这些 socket 上发送业务数据。任一 socket 结束都会使当前 connection generation 失败并重建两条流,连接就绪仍要求两条 socket open 且 `host.describe` HTTP 调用成功。Host teardown 会终止两条 socket、中止各自的 source,并等待 source 清理完成后再返回。普通网络 GET 这些路径会返回 426,不保留 SSE 回退;`toFetchHandler` 的 SSE 编解码只服务进程内同构载体。 -`SessionEventView` 是 `session.history` 条目与实时 `session/event` 帧上的可选、非持久 sidecar。工具 view 保持封闭的 call/result 形状;由 Host presentation 的持久事件则携带 `{ for: 'event', presentationKey, view }`,把 key 空间与兼容 JSON 的 payload 开放给领域插件。同一个 Session event 可以再次投递并带有新增或变化的 sidecar,因此消费方会按完全一致的事件身份与 seq 合并,而不会把第二个帧当作另一次日志 append。 +`SessionEventView` 是 `session.history` 条目与实时 `session/event` 帧上的可选、非持久 sidecar。工具 view 保持封闭的 call/result 形状;由 Host presentation 的持久事件则携带 `{ for: 'event', view }`,把兼容 JSON 的 payload 开放给领域插件,并由持久事件类型选择 renderer。同一个 Session event 可以再次投递并带有新增或变化的 sidecar,因此消费方会按完全一致的事件身份与 seq 合并,而不会把第二个帧当作另一次日志 append。 ## 无密钥 fixture diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 6c1e951804..d6130e6d9f 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -40,7 +40,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only `command/run` / `command/done` nodes splice in by seq, `Session` merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text, replaced-item count, and estimated shadowed-token count come from the checkpoint's cited `compact/summary` event; a window cut that left that event outside makes those fields unavailable, and a later page that supplies it resolves them. `CommandNode.outcome.sourceEventSeq` preserves a successful command's explicit reference to that summary event, allowing the presentation layer to pair `/compact` with its checkpoint without parsing settlement copy or assuming the two rows are adjacent. Performance contract: one append materializes at most one node and copies the projection only when it adds that node; an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity. -A Host may redeliver the same Session event seq with a new or changed non-persistent view after the event reaches its presentation commit point. `Session` first requires deep event identity, then upgrades only the sidecar; a generic event view becomes one `PresentedEventNode` keyed by its `presentationKey`. The existing `liveBuffer` is the sole rendezvous during tail loading, gap stitching, and `loadOlder`. One merge path upgrades overlaps, consumes covered entries, and attaches only a contiguous suffix on every current-generation settlement, including rejected, empty, and discontinuous page responses. Reconnect advances the generation and clears its loading ownership, so an older request's result or `finally` cannot mutate or block the rebuilt window. +A Host may redeliver the same Session event seq with a new or changed non-persistent view after the event reaches its presentation commit point. `Session` first requires deep event identity, then upgrades only the sidecar; a generic event view becomes one `PresentedEventNode` keyed by the durable event type. Tail loading and true gap repair continue to use the existing `liveBuffer`. Ordinary `loadOlder` leaves live-tail appends in the current window and prepends its page after the await, while an overlapping late sidecar upgrades immediately. Reconnect advances the generation and clears page or repair ownership, so an older request's result or `finally` cannot mutate or block the rebuilt window. ## Request inspection diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 56be211416..5c0775d480 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -40,7 +40,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本、被替换条目数量和估算的被遮蔽 token 数量都来自检查点引用的 `compact/summary` 事件;窗口切分把该事件留在窗口外时这些字段不可用,后续包含该事件的分页会解析出它们。`CommandNode.outcome.sourceEventSeq` 保留成功命令对该摘要事件的显式引用,使呈现层能够配对 `/compact` 与其检查点,而无须解析结算文案或假定两行相邻。性能约定:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。 -一个 Session event 到达其 presentation 提交点后,Host 可以用同一 seq 重新投递完全相同的事件,并携带新增或变化的非持久 view。`Session` 会先要求事件深度一致,再只升级 sidecar;通用 event view 会按 `presentationKey` 形成一个 `PresentedEventNode`。既有 `liveBuffer` 是尾部加载、gap stitching 与 `loadOlder` 期间唯一的汇合点。每个当前 generation 的结算出口都使用同一条 merge 路径升级窗口重叠项、消费已覆盖项,并只接入连续后缀;RPC 拒绝、空页和不连续页同样如此。重连会推进 generation 并清除其 loading 所有权,因此旧请求的结果或 `finally` 既不能改写,也不能阻塞重建后的窗口。 +一个 Session event 到达其 presentation 提交点后,Host 可以用同一 seq 重新投递完全相同的事件,并携带新增或变化的非持久 view。`Session` 会先要求事件深度一致,再只升级 sidecar;通用 event view 会按持久事件类型形成一个 `PresentedEventNode`。`liveBuffer` 仍只用于尾部加载与真正的 gap repair。普通 `loadOlder` 会将 live-tail 追加项留在当前窗口中,并在 await 后前插所取页面;重叠的迟到 sidecar 则会立即升级。重连会推进 generation 并清除 page/repair 的所有权,因此旧请求的结果或 `finally` 既不能改写,也不能阻塞重建后的窗口。 ## 请求检查 diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 41fdff7352..82745bac59 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -254,8 +254,8 @@ export interface CommandNode { /** * Host-computed presentation for one durable non-surface event. The generic - * runtime carries the keyed JSON-compatible payload without importing the - * producing domain; a client plugin owns the keyed renderer. + * runtime carries the durable event type and JSON-compatible payload without + * importing the producing domain; a client plugin owns the keyed renderer. */ export interface PresentedEventNode { kind: 'presented-event' @@ -263,8 +263,8 @@ export interface PresentedEventNode { seq: number /** Unix epoch ms from the source Session event. */ time: number - /** Open runtime key selecting an optional domain renderer. */ - presentationKey: string + /** Durable event type selecting an optional domain renderer. */ + eventType: string /** Domain-owned JSON-compatible presentation payload. */ view: unknown } diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index d59ac69859..edf7b58555 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -392,6 +392,7 @@ export class Session implements SessionFace { async loadOlder(): Promise { if (this.openState !== 'open' || !this.hasMore || this.loadingOlder) return const generation = this.openGeneration + const requestedBaseSeq = this.baseSeq this.loadingOlder = true this.notifier.markDirty() try { @@ -404,34 +405,27 @@ export class Session implements SessionFace { return } const tail = older[older.length - 1] - if (tail === undefined || tail.event.seq + 1 !== this.baseSeq) { + if (tail === undefined || tail.event.seq + 1 !== requestedBaseSeq) { // §D.2 continuity assertion: on violation drop the page fail-soft rather than render an out-of-order stream. - console.error(`[web-runtime] history page discontinuous: tail seq ${tail?.event.seq} vs baseSeq ${this.baseSeq}`) + console.error(`[web-runtime] history page discontinuous: tail seq ${tail?.event.seq} vs baseSeq ${requestedBaseSeq}`) this.hasMore = false return } - this.installWindow([ - ...older, - ...this.events.map((event, index): HistoryEntry => { - const view = this.views[index] - return view === undefined ? { event } : { event, view } - }), - ], result.value.hasMore) + this.events = [...older.map(entry => entry.event), ...this.events] + this.views = [...older.map(entry => entry.view), ...this.views] + /* v8 ignore next -- the empty-page branch returned above. */ + this.baseSeq = older[0]?.event.seq ?? this.baseSeq + this.hasMore = result.value.hasMore + this.transcript.reset(this.events, this.views) + this.rebuildDerivedFromWindow() } catch (error) { if (generation === this.openGeneration) { console.error('[web-runtime] loadOlder failed:', error) } } finally { if (generation === this.openGeneration) { - try { - const { hasGap } = this.mergeWindow() - // oxlint-disable-next-line typescript/no-unnecessary-condition -- resync can close the window while the page request is awaited. - if (hasGap && this.openState === 'open') void this.repairGap() - } catch (error) { - console.error('[web-runtime] loadOlder buffer merge failed:', error) - void this.resync() - } this.loadingOlder = false + if (this.liveBuffer.length > 0) void this.repairGap() this.notifier.markDirty() } } @@ -816,6 +810,21 @@ export class Session implements SessionFace { this.applyEventSideEffects(event, view) } + /** Verify one retained event and apply a defined late sidecar immediately. */ + private upgradeLiveView(event: SessionEvent, view?: SessionEventView): boolean { + const index = this.events.findIndex(candidate => candidate.seq === event.seq) + if (index === -1) return false + const retained = this.events[index] + /* v8 ignore next -- findIndex returned a dense-array position. */ + if (retained === undefined) return false + assertSameEvent(retained, event) + if (view === undefined || sameWireValue(this.views[index], view)) return false + this.views[index] = view + this.transcript.reset(this.events, this.views) + this.rebuildDerivedFromWindow() + return true + } + /** Retire the first matching live steering occurrence when its durable message takes over. */ private handoffPendingSteering(event: SessionEvent): void { if (event.type !== 'user/message') return @@ -840,9 +849,8 @@ export class Session implements SessionFace { if (this.openState !== 'open') return // cold/error: no window upkeep (history fully backfills on open) const tailSeq = this.windowTailSeq() if (tailSeq !== null && event.seq <= tailSeq) { - this.liveBuffer.push({ event, view }) try { - const { changed } = this.mergeWindow() + const changed = this.upgradeLiveView(event, view) if (changed) this.notifier.markDirty() } catch (error) { console.error('[web-runtime] duplicate session event failed identity validation:', error) @@ -852,12 +860,12 @@ export class Session implements SessionFace { } if (tailSeq !== null && event.seq > tailSeq + 1) { this.liveBuffer.push({ event, view }) - void this.repairGap() + if (!this.loadingOlder) void this.repairGap() return } if (tailSeq === null && event.seq !== 0) { this.liveBuffer.push({ event, view }) - void this.repairGap() + if (!this.loadingOlder) void this.repairGap() return } this.appendLive(event, view) diff --git a/packages/client/runtime/src/client/sessions/transcript-adapter.ts b/packages/client/runtime/src/client/sessions/transcript-adapter.ts index 3d851fedc0..f92f1f0836 100644 --- a/packages/client/runtime/src/client/sessions/transcript-adapter.ts +++ b/packages/client/runtime/src/client/sessions/transcript-adapter.ts @@ -125,7 +125,7 @@ function materializePresented(event: SessionEvent, sidecar: PresentedEventView): kind: 'presented-event', seq: event.seq, time: event.time, - presentationKey: sidecar.presentationKey, + eventType: event.type, view: sidecar.view, } } diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 62a793e705..9635f08515 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -45,7 +45,6 @@ function reminderEvent(seq: number, id: string): SessionEvent { function reminderView(id: string, prompt = '检查日志') { return { for: 'event' as const, - presentationKey: 'schedule/reminder', view: { id, prompt }, } } @@ -127,7 +126,7 @@ describe('late event views', () => { type: 'session/event', sessionId: SID, event, view: reminderView('schedule-1'), }) expect(session.getSnapshot().nodes).toMatchObject([{ - kind: 'presented-event', seq: 0, presentationKey: 'schedule/reminder', + kind: 'presented-event', seq: 0, eventType: 'schedule/change', view: { id: 'schedule-1', prompt: '检查日志' }, }]) @@ -707,6 +706,27 @@ describe('paging', () => { expect(snapshot.nodes.map(n => n.seq)).toEqual([1, 3, 7, 9]) }) + it('keeps a concurrent live tail in the current window before prepending the older page', async () => { + const older = plainTurn(0, 0, '旧问', '旧答') + const newer = plainTurn(6, 1, '新问', '新答') + const page = deferred>>() + const { api, session } = makeSession() + api.onHistory = payload => payload.beforeSeq === undefined + ? histResponse(newer, true) + : page.promise + await session.open() + + const loading = session.loadOlder() + session.handleMuxEnvelope('live-tail' as never, { + type: 'session/event', sessionId: SID, event: ev.user(12, '并发尾部'), + }) + expect(session.getSnapshot().nodes.map(node => node.seq)).toEqual([7, 9, 12]) + page.resolve(await histResponse(older, false)) + await loading + + expect(session.getSnapshot().nodes.map(node => node.seq)).toEqual([1, 3, 7, 9, 12]) + }) + it('renders a page whose checkpoint shadows seqs below the window head, logging nothing', async () => { // Pagination no longer spends maxMessages quota on replacement copies, so a // page can carry a compaction checkpoint whose surfaceOp.start lies outside diff --git a/packages/client/runtime/tests/transcript-adapter.spec.ts b/packages/client/runtime/tests/transcript-adapter.spec.ts index c6a4441c83..fcdcc4b15a 100644 --- a/packages/client/runtime/tests/transcript-adapter.spec.ts +++ b/packages/client/runtime/tests/transcript-adapter.spec.ts @@ -440,23 +440,21 @@ describe('TranscriptAdapter', () => { const adapter = new TranscriptAdapter() adapter.reset([replayed], [{ for: 'event', - presentationKey: 'schedule/reminder', view: { id: 'schedule-1', prompt: '检查日志' }, }]) adapter.append(live, { for: 'event', - presentationKey: 'schedule/reminder', view: { id: 'schedule-2', prompt: '检查发布' }, }) expect(adapter.nodes()).toEqual([ { kind: 'presented-event', seq: 0, time: 1_700_000_000_000, - presentationKey: 'schedule/reminder', + eventType: 'schedule/change', view: { id: 'schedule-1', prompt: '检查日志' }, }, { kind: 'presented-event', seq: 1, time: 1_700_000_000_001, - presentationKey: 'schedule/reminder', + eventType: 'schedule/change', view: { id: 'schedule-2', prompt: '检查发布' }, }, ]) diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 6476fbb368..d0df360b76 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -24,7 +24,7 @@ The chat view keeps Tool placement but delegates Tool presentation. It passes ea The chat flow projects consecutive model-retry nodes across retry turns into one stable, muted status row updated to the latest attempt; every retry event remains in the runtime snapshot and session log. Its frontend countdown anchors the scheduled delay to client receipt, avoiding host/browser clock skew, rounds remaining time up to seconds, and has a one-second floor. The latest unresolved retry uses a left-to-right text shimmer. Subsequent turn facts distinguish an attempt that started from one cancelled during backoff, while the Host running bit only controls the live animation; the row then shows a static completed or cancelled label. Normal policy rows show the finite retry maximum; always policy rows show `∞`. Activating the row reveals the latest exact retry delay and failure message. The client runtime removes each failed step's streaming tail before its retry node arrives, while the status remains visible after a later attempt succeeds. An unretried terminal failure renders as a persistent inline status at its turn boundary, showing the display-safe durable message and optional error code without offering an action the Host cannot fulfill; AUTH copy never echoes provider-supplied credential fragments. -Host-presented durable events use the keyed `'conversation.chat.eventview'` seat alongside whole-Tool presentation. The React-free runtime turns a generic `{ presentationKey, view }` sidecar into a `PresentedEventNode`; Chat dispatches on that open key, and a domain UI plugin may register its own row without adding domain vocabulary here. When no registrant is loaded, `GenericEventCard` keeps the presentation key and JSON payload visible in an expandable disclosure rather than dropping the durable event. +Host-presented durable events use the keyed `'conversation.chat.eventview'` seat alongside whole-Tool presentation. The React-free runtime turns a generic event sidecar into a `PresentedEventNode` carrying the durable event type and view; Chat dispatches on that open type, and a domain UI plugin may register its own row without adding domain vocabulary here. When no registrant is loaded, `GenericEventCard` keeps the event type and JSON payload visible in an expandable disclosure rather than dropping the durable event. `TodoDock` takes the `'conversation.input.dock'` list slot at `order: 0` — before Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus its own `·`-joined per-status counts (localized, `1 completed · 2 in progress · 1 pending`, zero-count segments omitted). The dock adapter owns selection so the panel stays a pure function of its props. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included. The `todo_write` Tool row belongs to [`ui-tool`](../ui-tool/README.md). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 858d0ae73a..273956bb85 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -24,7 +24,7 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时 审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。运行时 manager 会将所有审批或问题等待通过 `SessionSummary.pendingInteraction` 投影出来,未实例化的 Session 也不例外;`ui-workspace` 负责其侧边栏呈现。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission `,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。 -由 Host presentation 的持久事件使用键控的 `'conversation.chat.eventview'` 座位,与整体 Tool presentation 并行。无 React 的 runtime 会把通用 `{ presentationKey, view }` sidecar 转为 `PresentedEventNode`;Chat 按开放 key 分发,领域 UI 插件无需在本包增加领域词汇即可注册自己的行。没有 registrant 被加载时,`GenericEventCard` 会在可展开 disclosure 中保留可见的 presentation key 与 JSON payload,而不会丢弃该持久事件。 +由 Host presentation 的持久事件使用键控的 `'conversation.chat.eventview'` 座位,与整体 Tool presentation 并行。无 React 的 runtime 会把通用事件 sidecar 转为携带持久事件类型与 view 的 `PresentedEventNode`;Chat 按该开放类型分发,领域 UI 插件无需在本包增加领域词汇即可注册自己的行。没有 registrant 被加载时,`GenericEventCard` 会在可展开 disclosure 中保留可见的事件类型与 JSON payload,而不会丢弃该持久事件。 `TodoDock` 以 `order: 0` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 与 Queue 之前),作为计划条读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`。面板接收纯列表,列表为空时自我隐藏;列表非空时默认折叠,表头显示标题及以 `·` 连接的各状态计数(如 `1 已完成 · 2 进行中 · 1 待处理`,省略零计数)。dock adapter 拥有 selection,因此面板保持为 props 的纯函数。输入区 composer 链隐藏的一切也会隐藏整个 dock。`todo_write` Tool 行属于 [`ui-tool`](../ui-tool/README.md)。 diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index a67b4f9b25..5a23853227 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -212,8 +212,8 @@ const CommandRow = memo(function CommandRow({ renderSlot, node, compaction, t }: ) }) -/** One Host-presented durable event: keyed dispatch on its open presentation - * key, with a visible JSON disclosure when no domain renderer is loaded. */ +/** One Host-presented durable event: dispatch by durable event type, with a + * visible JSON disclosure when no domain renderer is loaded. */ const EventRow = memo(function EventRow({ renderSlot, node, t }: { renderSlot: RenderChatSlot node: PresentedEventNode @@ -223,7 +223,7 @@ const EventRow = memo(function EventRow({ renderSlot, node, t }: { return (
{renderSlot('conversation.chat.eventview', owner, { - entryKey: node.presentationKey, + entryKey: node.eventType, fallback: , })}
diff --git a/packages/client/ui-conversation/src/client/chat/GenericEventCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericEventCard.tsx index fb815e53b7..6feed66fd4 100644 --- a/packages/client/ui-conversation/src/client/chat/GenericEventCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/GenericEventCard.tsx @@ -1,6 +1,6 @@ // GenericEventCard: the visible fallback for a Host-presented durable event. // A domain plugin may replace it through the keyed eventview slot; without -// one, the presentation key and JSON sidecar remain inspectable in the flow. +// one, the durable event type and JSON sidecar remain inspectable in the flow. import { useMemo, useState } from 'react' import { IconSparkle16 } from '@deepseek-ai/dsh-client-ui-primitives' @@ -22,7 +22,7 @@ export function GenericEventCard({ node, t }: GenericEventCardProps) { className={css.root} icon={} chevronClassName={css.chevron} - title={t('message.presentedEvent', { key: node.presentationKey })} + title={t('message.presentedEvent', { key: node.eventType })} open={open} expandable expandOnRowClick diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 5031239b50..177aaef8d4 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -110,7 +110,7 @@ const presentedEvent = (seq: number): PresentedEventNode => ({ kind: 'presented-event', seq, time: seq * 1_000, - presentationKey: 'schedule/reminder', + eventType: 'schedule/change', view: { prompt: 'check logs', scheduleId: 'schedule-1' }, }) @@ -969,8 +969,8 @@ describe('ChatView', () => { return opts?.fallback ?? null }) const view = render() - expect(calls).toEqual([{ key: 'conversation.chat.eventview', entryKey: 'schedule/reminder' }]) - fireEvent.click(view.getByText('事件:schedule/reminder')) + expect(calls).toEqual([{ key: 'conversation.chat.eventview', entryKey: 'schedule/change' }]) + fireEvent.click(view.getByText('事件:schedule/change')) expect(view.getByText(/"prompt": "check logs"/)).toBeTruthy() expect(view.getByText(/"scheduleId": "schedule-1"/)).toBeTruthy() }) diff --git a/packages/client/ui-schedule/README.md b/packages/client/ui-schedule/README.md index 7a37baf92c..c7b1934cd5 100644 --- a/packages/client/ui-schedule/README.md +++ b/packages/client/ui-schedule/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Browser-only renderer for durable Schedule reminder receipts. The plugin registers the `schedule/reminder` key in the conversation-owned `conversation.chat.eventview` slot. The generic runtime continues to carry the durable event identity and its Host-computed JSON sidecar; this package owns only the Schedule card. +Browser-only renderer for durable Schedule reminder receipts. The plugin registers the durable `schedule/change` event type in the conversation-owned `conversation.chat.eventview` slot. The generic runtime continues to carry the durable event identity and its Host-computed JSON sidecar; this package owns only the Schedule card. The card displays the reminder prompt, Session-local Schedule ID, exact UTC occurrence, and the `session-local` delivery boundary. A malformed or incompatible sidecar remains visible as a contained unavailable receipt instead of crashing the conversation. Unloading the plugin removes only the keyed renderer; `ui-conversation` then shows its generic visible JSON fallback for the same durable event. diff --git a/packages/client/ui-schedule/README.zh.md b/packages/client/ui-schedule/README.zh.md index 8acc33cc39..165a29e11a 100644 --- a/packages/client/ui-schedule/README.zh.md +++ b/packages/client/ui-schedule/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -用于渲染持久 Schedule 提醒回执的纯浏览器插件。插件在会话拥有的 `conversation.chat.eventview` slot 中注册 `schedule/reminder` key。通用 runtime 继续携带持久事件身份与 Host 计算的 JSON sidecar;本包只拥有 Schedule 卡片。 +用于渲染持久 Schedule 提醒回执的纯浏览器插件。插件在会话拥有的 `conversation.chat.eventview` slot 中注册持久事件类型 `schedule/change`。通用 runtime 继续携带持久事件身份与 Host 计算的 JSON sidecar;本包只拥有 Schedule 卡片。 卡片显示提醒原文、Session 内的 Schedule ID、精确 UTC 发生时刻,以及 `session-local` 交付边界。若 sidecar 损坏或版本不兼容,组件会显示受控的不可用回执,而不会让会话崩溃。卸载插件只会移除该键控 renderer;`ui-conversation` 随后仍会为同一个持久事件显示通用且可见的 JSON fallback。 diff --git a/packages/client/ui-schedule/src/client/ReminderRow.tsx b/packages/client/ui-schedule/src/client/ReminderRow.tsx index 28dc491156..c79d1418f7 100644 --- a/packages/client/ui-schedule/src/client/ReminderRow.tsx +++ b/packages/client/ui-schedule/src/client/ReminderRow.tsx @@ -44,7 +44,7 @@ export function ReminderRow({ node, t }: ReminderRowProps) { {reminder !== null && {t('reminder.delivery')}} {reminder === null - ?

{t('reminder.invalid')} · {node.presentationKey}

+ ?

{t('reminder.invalid')} · {node.eventType}

: ( <>

{reminder.prompt}

diff --git a/packages/client/ui-schedule/src/client/index.ts b/packages/client/ui-schedule/src/client/index.ts index e48438f9f8..ebe8b9315b 100644 --- a/packages/client/ui-schedule/src/client/index.ts +++ b/packages/client/ui-schedule/src/client/index.ts @@ -30,7 +30,7 @@ export function apply(ctx: ClientContext): void { ctx.effect( () => ctx.slots.register({ name: 'conversation.chat.eventview', - key: 'schedule/reminder', + key: 'schedule/change', locale: NS, }, ReminderRow), 'ui-schedule: reminder row registration', diff --git a/packages/client/ui-schedule/tests/browser-plugin.spec.ts b/packages/client/ui-schedule/tests/browser-plugin.spec.ts index b93c885cdd..df29024c3d 100644 --- a/packages/client/ui-schedule/tests/browser-plugin.spec.ts +++ b/packages/client/ui-schedule/tests/browser-plugin.spec.ts @@ -38,7 +38,7 @@ describe('ui-schedule browser plugin', () => { await b.fiber.await() expect(b.entry()).toEqual({ name: 'conversation.chat.eventview', - key: 'schedule/reminder', + key: 'schedule/change', locale: 'schedule', component: ReminderRow, }) diff --git a/packages/client/ui-schedule/tests/reminder-row.spec.tsx b/packages/client/ui-schedule/tests/reminder-row.spec.tsx index 7c5337f80b..aec78fa423 100644 --- a/packages/client/ui-schedule/tests/reminder-row.spec.tsx +++ b/packages/client/ui-schedule/tests/reminder-row.spec.tsx @@ -16,7 +16,7 @@ function props(view: unknown): ReminderRowProps { kind: 'presented-event', seq: 4, time: Date.parse('2026-08-05T08:00:00.000Z'), - presentationKey: 'schedule/reminder', + eventType: 'schedule/change', view, } return { node, t } as ReminderRowProps @@ -48,7 +48,7 @@ describe('ReminderRow', () => { deliveryMode: 'external', })} />) - expect(screen.getByText('提醒回执不可用 · schedule/reminder')).toBeTruthy() + expect(screen.getByText('提醒回执不可用 · schedule/change')).toBeTruthy() expect(screen.queryByText('not trusted')).toBeNull() expect(screen.queryByText('仅在当前会话中交付')).toBeNull() }) diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index 2e59ffe32e..78fe4f915a 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => { it('boots every shipped tool package and harvests its model-facing schemas', async () => { const catalog = await collectToolCatalog() const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort() - expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'interrupt_agent', 'list_agents', 'lsp', 'pwsh', 'ralph', 'read', 'report', 'run_code', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) + expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'interrupt_agent', 'list_agents', 'lsp', 'pwsh', 'ralph', 'read', 'report', 'run_code', 'schedule_create', 'schedule_delete', 'schedule_list', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) // Every tool carries a JSON-Schema `parameters` object (what the model sees). for (const entry of catalog) { for (const schema of entry.schemas) { diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 7575974f61..241f59c7e0 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -26,7 +26,7 @@ Question responses are validated against their pending request before the first `session.history` reads an attached Session in memory or inspects a cold log through persistence without resuming or publishing an Agent, then pages on append-origin message boundaries. `maxMessages` counts `user/message` and `assistant/message` events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only `compact/summary` record on the same page as the replacement that cites it. -An optional `SessionEventView` is a non-persistent presentation sidecar. Tool calls/results keep their existing Host presenters. A Schedule dispatch remains raw on append; after an acknowledged `session/flushed(session, throughSeq)`, the gateway advances an exact-Session `WeakMap` cursor with `max`, derives newly covered receipts through the Schedule package, and redelivers the identical event with `{ for: 'event', presentationKey: 'schedule/reminder', view }`. Reversed flush completion cannot move the cursor backward or duplicate a receipt. Attached history adds these views only within a persistence-inspected prefix whose header and every event match the live identity; unavailable, failed, or mismatched inspection serves raw history without the sidecar. Detached history is already a persisted prefix. +An optional `SessionEventView` is a non-persistent presentation sidecar. Tool calls/results keep their existing Host presenters. A Schedule dispatch remains raw on append; after an acknowledged `session/flushed(session, throughSeq)`, the gateway advances an exact-Session `WeakMap` cursor with `max`, derives newly covered receipts through the Schedule package, and redelivers the identical event with `{ for: 'event', view }`. The durable event type selects the client renderer. Reversed flush completion cannot move the cursor backward or duplicate a receipt. Attached history adds these views only within a persistence-inspected prefix whose header and every event match the live identity; unavailable, failed, or mismatched inspection serves raw history without the sidecar. Detached history is already a persisted prefix. `session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 8c147e91cb..5ba28f8b9a 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -26,7 +26,7 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中 `session.history` 会读取已附加 Session 的内存状态,或通过持久化检查冷日志,而不会恢复或发布 agent,然后按追加来源的消息边界分页:`maxMessages` 统计以追加方式进入 surface 的 `user/message` 和 `assistant/message` 事件,因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间,从而让压缩(compaction)的仅日志 `compact/summary` 记录与引用它的替换留在同一页。 -可选的 `SessionEventView` 是非持久 presentation sidecar。工具 call/result 保留既有 Host presenter。Schedule dispatch 在 append 时保持 raw;收到获确认的 `session/flushed(session, throughSeq)` 后,网关才以 `max` 推进按 exact Session 键控的 `WeakMap` cursor,通过 Schedule package 派生新覆盖的回执,并用 `{ for: 'event', presentationKey: 'schedule/reminder', view }` 重投完全相同的事件。反序完成的 flush 不能让 cursor 后退或重复回执。已附加 history 只会在 persistence inspect 得到的前缀内添加这些 view,而且该前缀的 header 与每个 event 都必须和 live identity 一致;inspect 不可用、失败或不匹配时,仍会返回 raw history,只省略 sidecar。已分离 history 本身已经是持久前缀。 +可选的 `SessionEventView` 是非持久 presentation sidecar。工具 call/result 保留既有 Host presenter。Schedule dispatch 在 append 时保持 raw;收到获确认的 `session/flushed(session, throughSeq)` 后,网关才以 `max` 推进按 exact Session 键控的 `WeakMap` cursor,通过 Schedule package 派生新覆盖的回执,并用 `{ for: 'event', view }` 重投完全相同的事件。持久事件类型选择客户端 renderer。反序完成的 flush 不能让 cursor 后退或重复回执。已附加 history 只会在 persistence inspect 得到的前缀内添加这些 view,而且该前缀的 header 与每个 event 都必须和 live identity 一致;inspect 不可用、失败或不匹配时,仍会返回 raw history,只省略 sidecar。已分离 history 本身已经是持久前缀。 `session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元铸造一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 0968180ae7..a810e4453b 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -60,10 +60,7 @@ import { credentialRef } from '@deepseek-ai/dsh-credentials' // Value edge: the rename impl narrows the title service's validation failure; the import also resolves `ctx.get('sessionTitle')`. import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title' import type { CallId } from '@deepseek-ai/dsh-llm/brand' -import { - SCHEDULE_REMINDER_PRESENTATION_KEY, - scheduleReminderPresentation, -} from '@deepseek-ai/dsh-tool-schedule' +import { scheduleReminderPresentation } from '@deepseek-ai/dsh-tool-schedule' import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval' // Side-effect type import: resolves the `approval/request` waterfall and // `ctx.get('approval')` without a value dependency on the seam (optional composition). @@ -486,7 +483,7 @@ function scheduleViewFor( const view = scheduleReminderPresentation(events, event.seq, header.seedLength ?? 0) return view === undefined ? undefined - : { for: 'event', presentationKey: SCHEDULE_REMINDER_PRESENTATION_KEY, view } + : { for: 'event', view } } catch (error: unknown) { ctx.logger.warn(`api-proxy: Schedule presentation failed at seq ${event.seq}; serving raw event: ${String(error)}`) return undefined diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index 4424c1ae60..4db508f43a 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -33,14 +33,12 @@ export type ToolEventView = | { for: 'result'; view: ToolResultView } /** - * Host-computed presentation for one non-surface Session event. The domain - * owns the presentation key and JSON-compatible view shape; the carrier keeps - * both generic so an opt-in client plugin can render the event without adding - * domain vocabulary to the connection package. + * Host-computed presentation for one non-surface Session event. The durable + * event type selects an optional client renderer; the sidecar carries only the + * JSON-compatible view so the connection package adds no domain vocabulary. */ export interface PresentedEventView { for: 'event' - presentationKey: string view: unknown } diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index fa9a2a0335..599de24d5e 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -193,10 +193,9 @@ export const toolEventViewSchema = z.discriminatedUnion('for', [ z.object({ for: z.literal('result'), view: z.looseObject({ card: z.string() }) }), ]) as unknown as z.ZodType -/** Domain-owned presented-event sidecar with a carrier-validated key and present payload. */ +/** Domain-owned presented-event sidecar whose durable event supplies the renderer key. */ const presentedEventViewSchema = z.object({ for: z.literal('event'), - presentationKey: z.string().min(1), view: z.unknown(), }).refine(value => Object.hasOwn(value, 'view'), { message: 'presented event view payload is required', diff --git a/packages/host/apiproxy/tests/api-proxy-schedule-view.spec.ts b/packages/host/apiproxy/tests/api-proxy-schedule-view.spec.ts index 4fbfe9f170..a424f55b4d 100644 --- a/packages/host/apiproxy/tests/api-proxy-schedule-view.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-schedule-view.spec.ts @@ -104,7 +104,6 @@ describe('commit-aware Schedule live views', () => { expect(presented.map(frame => frame.view)).toEqual([ { for: 'event', - presentationKey: 'schedule/reminder', view: { scheduleId: 'schedule-1', prompt: 'first', occurrenceAt: '2026-08-05T12:00:01.000Z', deliveryMode: 'session-local', @@ -112,7 +111,6 @@ describe('commit-aware Schedule live views', () => { }, { for: 'event', - presentationKey: 'schedule/reminder', view: { scheduleId: 'schedule-2', prompt: 'second', occurrenceAt: '2026-08-05T12:00:01.000Z', deliveryMode: 'session-local', @@ -143,7 +141,7 @@ describe('commit-aware Schedule live views', () => { const frames = await collected expect(frames.filter(frame => frame.view?.for === 'event')).toHaveLength(1) expect(frames.at(-1)?.view).toMatchObject({ - for: 'event', presentationKey: 'schedule/reminder', + for: 'event', }) await ctx.fiber.dispose() }) @@ -180,7 +178,7 @@ describe('Schedule history views', () => { events: [...session.events.slice(0, 2)], }) expect((await history()).find(entry => entry.event.seq === 1)?.view).toMatchObject({ - for: 'event', presentationKey: 'schedule/reminder', + for: 'event', }) inspect = () => Promise.resolve({ meta: { ...session.header, cwd: '/different', delegationDepth: 0 }, @@ -213,7 +211,7 @@ describe('Schedule history views', () => { }) if (!response.result.ok) throw new Error(response.result.error.message) expect(response.result.value.events.find(entry => entry.event.seq === 1)?.view).toMatchObject({ - for: 'event', presentationKey: 'schedule/reminder', + for: 'event', }) await ctx.fiber.dispose() }) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 8fb84e0440..50a2321b2d 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -198,16 +198,12 @@ describe('sessions domain schemas', () => { event: { type: 'schedule/change', seq: 2, time: 3, data: { operation: 'dispatch' } }, view: { for: 'event', - presentationKey: 'schedule/reminder', view: { scheduleId: 'schedule-1' }, }, } const parsedHistory = sessionHistoryValueSchema.parse({ events: [presented], hasMore: false }) expect(parsedHistory.events?.at(0)?.view).toEqual(presented.view) - for (const view of [ - { for: 'event', presentationKey: '', view: {} }, - { for: 'event', presentationKey: 'schedule/reminder' }, - ]) { + for (const view of [{ for: 'event' }]) { expect(() => sessionHistoryValueSchema.parse({ events: [{ event: presented.event, view }], hasMore: false, @@ -442,7 +438,7 @@ describe('events frame schemas', () => { { type: 'session/event', sessionId: 's', event: { type: 'schedule/change', seq: 1, time: 2, data: { operation: 'dispatch' } }, - view: { for: 'event', presentationKey: 'schedule/reminder', view: null }, + view: { for: 'event', view: null }, }, { type: 'session/subscribed', sessionId: 's', lastSeq: -1 }, { type: 'approval/requested', sessionId: 's', approvalId: 'a', toolName: 'bash', callId: 'c', reason: 'r' }, diff --git a/packages/schedule/tool-schedule/README.md b/packages/schedule/tool-schedule/README.md index 55842c3cb4..eb169d4d42 100644 --- a/packages/schedule/tool-schedule/README.md +++ b/packages/schedule/tool-schedule/README.md @@ -22,7 +22,7 @@ Replay rejects unknown versions, extra fields, reused ids, and delete or dispatc The generated [tool catalog](../../../docs/tool-catalog.md) owns the argument and output schemas for `schedule_create`, `schedule_list`, and `schedule_delete`. Their canonical values use camelCase record fields even though model input uses `after_seconds`. -`schedule_create` validates shape-only failures before persistence, then checkpoints, allocates a never-reused id, appends the create, and checkpoints again. `schedule_list` returns every active record in create order with `state: "scheduled" | "overdue"` and `deliveryMode: "session-local"`. `schedule_delete` appends only for an active id; an unknown or terminal id returns `{ id, deleted: false, code: "schedule_not_found" }` after its preflight. +`schedule_create` validates shape-only failures before persistence, then checkpoints, allocates a never-reused id, appends the create, and checkpoints again. `schedule_list` returns every active record in create order with `state: "scheduled" | "overdue"` and `deliveryMode: "session-local"`. `schedule_delete` rejects an empty or whitespace-padded id before persistence and appends only for an active id; an unknown or terminal id returns `{ id, deleted: false, code: "schedule_not_found" }` after its preflight. Every successful management preflight also asks the live owner to recompute. This matters after a create or delete barrier returned `persistence_uncertain`: a later list or mutation can confirm the retained batch and immediately arm or retire the now-durable record without a private persistence-retry timer. diff --git a/packages/schedule/tool-schedule/README.zh.md b/packages/schedule/tool-schedule/README.zh.md index 8738ac6b45..b090c01d15 100644 --- a/packages/schedule/tool-schedule/README.zh.md +++ b/packages/schedule/tool-schedule/README.zh.md @@ -22,7 +22,7 @@ 生成的[工具目录](../../../docs/tool-catalog.md)负责 `schedule_create`、`schedule_list` 和 `schedule_delete` 的参数与输出 schema。虽然模型输入使用 `after_seconds`,但其规范值中的记录字段使用 camelCase。 -`schedule_create` 会在持久化前验证只依赖输入形状的失败,随后执行检查点、分配永不复用的 id、追加 create,再次执行检查点。`schedule_list` 按创建顺序返回所有活动记录,其中包含 `state: "scheduled" | "overdue"` 与 `deliveryMode: "session-local"`。`schedule_delete` 只为活动 id 追加事件;未知或已终结的 id 会在 preflight(预检)后返回 `{ id, deleted: false, code: "schedule_not_found" }`。 +`schedule_create` 会在持久化前验证只依赖输入形状的失败,随后执行检查点、分配永不复用的 id、追加 create,再次执行检查点。`schedule_list` 按创建顺序返回所有活动记录,其中包含 `state: "scheduled" | "overdue"` 与 `deliveryMode: "session-local"`。`schedule_delete` 会在持久化前拒绝空 id 或前后带空白的 id,并只为活动 id 追加事件;未知或已终结的 id 会在 preflight(预检)后返回 `{ id, deleted: false, code: "schedule_not_found" }`。 每次成功的管理 preflight 还会要求 live owner 重新计算。这对 create 或 delete barrier 返回 `persistence_uncertain` 的情况很重要:后续 list 或 mutation 可以确认保留的 batch,并立即 arm 或退役此时已持久化的 record,而无需私有 persistence retry timer。 diff --git a/packages/schedule/tool-schedule/src/domain.ts b/packages/schedule/tool-schedule/src/domain.ts index 18b6173863..290af3fdea 100644 --- a/packages/schedule/tool-schedule/src/domain.ts +++ b/packages/schedule/tool-schedule/src/domain.ts @@ -15,9 +15,6 @@ import type { /** Durable Schedule protocol version implemented by this package. */ export const SCHEDULE_CHANGE_VERSION = 1 as const -/** Key used by the generic Host/client event-presentation slot. */ -export const SCHEDULE_REMINDER_PRESENTATION_KEY = 'schedule/reminder' - const MAX_FOUR_DIGIT_YEAR_MS = Date.parse('9999-12-31T23:59:59.999Z') const UTC_INSTANT = /^(?!0000)\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d\.\d{3}Z$/ diff --git a/packages/schedule/tool-schedule/src/index.ts b/packages/schedule/tool-schedule/src/index.ts index 0b69416729..b5c10140d9 100644 --- a/packages/schedule/tool-schedule/src/index.ts +++ b/packages/schedule/tool-schedule/src/index.ts @@ -12,7 +12,6 @@ import { registerScheduleTools } from './tools.ts' export type * from './types.ts' export { SCHEDULE_CHANGE_VERSION, - SCHEDULE_REMINDER_PRESENTATION_KEY, ScheduleId, ScheduleInputError, ScheduleLogError, diff --git a/packages/schedule/tool-schedule/src/tools.ts b/packages/schedule/tool-schedule/src/tools.ts index 070c6dfc2a..abcc7db1e7 100644 --- a/packages/schedule/tool-schedule/src/tools.ts +++ b/packages/schedule/tool-schedule/src/tools.ts @@ -310,6 +310,9 @@ export function registerScheduleTools( }, output: { schema: DELETE_OUTPUT_SCHEMA, render: renderValue }, async execute(args, exec): Promise { + if (args.id.length === 0 || args.id.trim() !== args.id) { + return { code: 'invalid_rule', message: 'schedule_delete id must be non-empty without surrounding whitespace.' } + } const id = ScheduleId(args.id) if (exec.agent !== agent) return internalError() const uncertain = await preflight(rootCtx, agent, 'delete', id) diff --git a/packages/schedule/tool-schedule/tests/domain.spec.ts b/packages/schedule/tool-schedule/tests/domain.spec.ts index cf6ccbc2b9..5b8ecae638 100644 --- a/packages/schedule/tool-schedule/tests/domain.spec.ts +++ b/packages/schedule/tool-schedule/tests/domain.spec.ts @@ -4,7 +4,6 @@ import { ScheduleId, ScheduleInputError, ScheduleLogError, - SCHEDULE_REMINDER_PRESENTATION_KEY, allocateScheduleId, createAfterScheduleRecord, decodeScheduleChange, @@ -98,7 +97,6 @@ describe('version-1 Schedule decoding and folding', () => { scheduleEvent(createData('same-id', 'child prompt'), 2), scheduleEvent({ version: 1, operation: 'dispatch', id: 'same-id' }, 3), ] - expect(SCHEDULE_REMINDER_PRESENTATION_KEY).toBe('schedule/reminder') expect(scheduleReminderPresentation(events, 1, 2)).toEqual({ scheduleId: 'same-id', prompt: 'parent prompt', diff --git a/packages/schedule/tool-schedule/tests/tools.spec.ts b/packages/schedule/tool-schedule/tests/tools.spec.ts index 43a3b60bbe..6071fa1f18 100644 --- a/packages/schedule/tool-schedule/tests/tools.spec.ts +++ b/packages/schedule/tool-schedule/tests/tools.spec.ts @@ -185,6 +185,17 @@ describe('Schedule tool protocol', () => { .toMatchObject({ id: 'schedule-2' }) }) + it('rejects an empty or padded delete id before persistence', async () => { + const test = await harness() + for (const id of ['', ' schedule-1']) { + expect(value(await execute(test, 'schedule_delete', { id }))).toEqual({ + code: 'invalid_rule', + message: 'schedule_delete id must be non-empty without surrounding whitespace.', + }) + } + expect(test.flushes.count).toBe(0) + }) + it('returns a range error only after the create preflight', async () => { const test = await harness() expect(value(await execute(test, 'schedule_create', { diff --git a/packages/session/session-persistence/src/coordinator.ts b/packages/session/session-persistence/src/coordinator.ts index 50222f9ff6..e27ff9476a 100644 --- a/packages/session/session-persistence/src/coordinator.ts +++ b/packages/session/session-persistence/src/coordinator.ts @@ -186,7 +186,10 @@ interface SessionState { /** One live session's initialization and bounded write-behind controller. */ interface LiveSessionState { - init: Promise + /** Exclusive end of the immutable Session prefix present when this lifecycle was first seen. */ + seedEnd: number + /** Initialization settlement; retained after success and cleared only after rejection. */ + init: Promise | undefined writes: SessionWriteBehind } @@ -1086,11 +1089,7 @@ export class PersistenceCoordinator { this.live.set(session, restored) return restored } - const seed = session.events.map(e => structuredClone(e)) - const live: LiveSessionState = { - init: Promise.resolve(), - writes: this.createWriteBehind(session, () => live.init), - } + const live = this.createLiveState(session) this.live.set(session, live) void this.ensureInitialized(session, live).catch(() => { /* observed by flush/dispose through the controller or retried by a later barrier */ @@ -1112,17 +1111,45 @@ export class PersistenceCoordinator { const suffix = session.events.slice(state.cursor).map(event => structuredClone(event)) this.preparations.attach(reservation) state.owner = session - const live: LiveSessionState = { - init: Promise.resolve(), - writes: this.createWriteBehind(session, () => live.init), - } + const live = this.createLiveState(session) if (suffix.length > 0) { - live.init = this.serialize(session.id, () => this.appendCore(session.id, suffix)) - live.init.catch(() => { /* observed by flush/dispose through the controller */ }) + const init = this.serialize(session.id, () => this.appendCore(session.id, suffix)).catch((error: unknown) => { + if (live.init === init) live.init = undefined + throw error + }) + live.init = init + init.catch(() => { /* observed by flush/dispose through the controller */ }) + } else { + live.init = Promise.resolve() } return live } + /** Build one live controller whose write readiness retries the immutable initial prefix. */ + private createLiveState(session: Session): LiveSessionState { + let live: LiveSessionState + live = { + seedEnd: session.events.length, + init: undefined, + writes: this.createWriteBehind(session, () => this.ensureInitialized(session, live)), + } + return live + } + + /** Start or join one initialization attempt, rebuilding the immutable seed prefix on retry. */ + private ensureInitialized(session: Session, live: LiveSessionState): Promise { + if (live.init !== undefined) return live.init + const init = this.serialize(session.header.id, async () => { + const seed = session.events.slice(0, live.seedEnd) + await this.onCreated(session, seed) + }).catch((error: unknown) => { + if (live.init === init) live.init = undefined + throw error + }) + live.init = init + return init + } + /** * Whether a live session's `seed` reproduces the first `cursor` persisted * events. A `cursor` of 0 (nothing persisted yet) trivially matches. Used when @@ -1230,22 +1257,7 @@ export class PersistenceCoordinator { if (seed.length > 0) await this.appendCore(session.header.id, seed) return } - const { meta, events, tornMarker } = stored - this.assertStoredId(session.header.id, meta) - if (meta.cwd !== session.header.cwd) { - throw new Error(`session "${session.header.id}" is already persisted at a different cwd (persisted: ${String(meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`) - } - this.assertVersion(meta) - const storedEvents = snapshotStoredEvents(events, session.header.id) - if (!seedCoversPrefix(seed, storedEvents)) { - throw new Error(`session "${session.header.id}" already has a persisted log on disk that does not match this live session (id collision)`) - } - if (tornMarker !== undefined) await this.backend.commitRepair(meta, tornMarker, []) - tracked.meta = { ...meta } - tracked.cursor = storedEvents.length - tracked.materialized = true - const suffix = seed.slice(storedEvents.length) - if (suffix.length > 0) await this.appendCore(session.header.id, suffix) + await this.adoptLivePrefix(session, seed, stored, tracked) } /** @@ -1254,7 +1266,12 @@ export class PersistenceCoordinator { * the live Session is still the authority), bind ownership, and persist the * live suffix that was ahead of the stored prefix. */ - private async adoptLivePrefix(session: Session, seed: readonly SessionEvent[], stored: StoredPrefix): Promise { + private async adoptLivePrefix( + session: Session, + seed: readonly SessionEvent[], + stored: StoredPrefix, + tracked?: SessionState, + ): Promise { const { meta, events, tornMarker } = stored this.assertStoredId(session.header.id, meta) if (meta.cwd !== session.header.cwd) { @@ -1267,12 +1284,17 @@ export class PersistenceCoordinator { } // Truncate-only repair (no closers): the open turn is NOT closed here. if (tornMarker !== undefined) await this.backend.commitRepair(meta, tornMarker, []) - this.states.set(session.header.id, { + const state = tracked ?? { meta: { ...meta }, cursor: storedEvents.length, materialized: true, owner: session, - }) + } + state.meta = { ...meta } + state.cursor = storedEvents.length + state.materialized = true + state.owner = session + if (tracked === undefined) this.states.set(session.header.id, state) const suffix = seed.slice(storedEvents.length) if (suffix.length > 0) await this.appendCore(session.header.id, suffix) } @@ -1281,7 +1303,7 @@ export class PersistenceCoordinator { const live = this.initFor(session) live.writes.cancelAutomaticWait() try { - await live.init + await this.ensureInitialized(session, live) } catch (error: unknown) { // Admission is closed during retirement/teardown, but an ordinary flush // may have raced one last enqueue while initialization was pending. diff --git a/packages/session/session-persistence/tests/persistence.spec.ts b/packages/session/session-persistence/tests/persistence.spec.ts index f4699269db..4d46bbe1fc 100644 --- a/packages/session/session-persistence/tests/persistence.spec.ts +++ b/packages/session/session-persistence/tests/persistence.spec.ts @@ -55,6 +55,8 @@ interface MemoryConfig { store?: MemoryStore } interface CoordinatorInternals { states: Map live: Map | undefined writes: { pending: unknown[]; active: Promise | undefined; hasWork: boolean } }> chains: Map @@ -415,7 +417,10 @@ describe('PersistenceCoordinator retryable live initialization', () => { expect(backend.store.get(session.id)?.events.map(event => event.seq)).toEqual([0, 1]) const live = [...(coordinator as unknown as CoordinatorInternals).live.values()][0] - expect(live).toMatchObject({ seedEnd: 0, initialized: true }) + if (live === undefined) throw new Error('live controller was not retained') + expect(live.seedEnd).toBe(0) + expect(live.init).toBeInstanceOf(Promise) + expect(live).not.toHaveProperty('initialized') expect(live).not.toHaveProperty('seed') } finally { loadGate.resolve(undefined)