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 e5afff7d8e..fe9b64244e 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 @@ -49,7 +49,7 @@ Agent or plugin disposal cancels timers, stops new work, unwinds the three tool ### Commit-aware Web receipt -The Schedule package owns `scheduleReminderPresentation()`, which derives `{ scheduleId, prompt, occurrenceAt, deliveryMode }` from create plus dispatch. A dispatch inside an inherited fork prefix folds from its nearest preceding `session/end-seed` boundary, preserving nested-generation id reuse; a child-owned dispatch folds only the child suffix. Presentation therefore never changes live ownership. +The Schedule package owns `scheduleReminderPresentation()`, which derives `{ scheduleId, prompt, occurrenceAt, deliveryMode }` from create plus dispatch. The current fork's `seedLength` is a hard boundary for child-owned dispatches. An inherited dispatch instead pairs with its nearest preceding same-id create because `session/end-seed` also marks replay or resume construction, not only fork ownership. This keeps resumed ancestor receipts renderable, preserves nested-generation id reuse, and 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', 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. 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 39fe02f96b..0c00a457d6 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 @@ -49,7 +49,7 @@ Agent 或插件 dispose 会取消 timer、停止新工作、撤销三个工具 ### Commit-aware Web 回执 -Schedule package 拥有 `scheduleReminderPresentation()`,从 create 加 dispatch 派生 `{ scheduleId, prompt, occurrenceAt, deliveryMode }`。位于继承 fork 前缀中的 dispatch 会从其最近的前置 `session/end-seed` 边界开始折叠,保留嵌套 generation 的 id 复用;child 自有 dispatch 只折叠 child 后缀。因此 presentation 永远不会改变 live ownership。 +Schedule package 拥有 `scheduleReminderPresentation()`,从 create 加 dispatch 派生 `{ scheduleId, prompt, occurrenceAt, deliveryMode }`。当前 fork 的 `seedLength` 是 child 自有 dispatch 的硬边界。继承的 dispatch 则会与它之前最近的同 id create 配对,因为 `session/end-seed` 也会标记回放或恢复构造,而不仅标记 fork 所有权。这使恢复后的祖先回执仍可渲染,保留嵌套 generation 的 id 复用,并且绝不会改变 live ownership。 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。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index a810e4453b..05da411d2c 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -470,8 +470,8 @@ function viewFor(ctx: Context, event: SessionEvent, argsFor: (callId: string) => /** * Derive one Schedule-owned event sidecar without allowing corrupt domain data - * to break raw event delivery. `seedLength` selects the parent-prefix or - * child-suffix ownership segment inside the package helper. + * to break raw event delivery. `seedLength` keeps a child-owned dispatch inside + * its own suffix while the package helper pairs inherited receipts by id. */ function scheduleViewFor( ctx: Context, 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 a424f55b4d..e70abbd14c 100644 --- a/packages/host/apiproxy/tests/api-proxy-schedule-view.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-schedule-view.spec.ts @@ -22,6 +22,20 @@ interface FlushControl { handler: () => true | Promise } +function reminderCreateData(id: string, prompt: string) { + return { + version: 1 as const, + operation: 'create' as const, + schedule: { + id: ScheduleId(id), + kind: 'after' as const, + prompt, + afterSeconds: 1, + scheduledAt: '2026-08-05T12:00:01.000Z', + }, + } +} + async function harness(control?: FlushControl): Promise { const ctx = new Context() await ctx.plugin(SessionStore) @@ -39,17 +53,7 @@ function appendReminder( prompt: string, ): { create: SessionEvent; dispatch: SessionEvent } { const scheduleId = ScheduleId(id) - const create = session.append('schedule/change', { - version: 1, - operation: 'create', - schedule: { - id: scheduleId, - kind: 'after', - prompt, - afterSeconds: 1, - scheduledAt: '2026-08-05T12:00:01.000Z', - }, - }) + const create = session.append('schedule/change', reminderCreateData(id, prompt)) const dispatch = session.append('schedule/change', { version: 1, operation: 'dispatch', @@ -148,6 +152,45 @@ describe('commit-aware Schedule live views', () => { }) describe('Schedule history views', () => { + it('presents a resumed ancestor dispatch copied into a fork seed', async () => { + const ctx = await harness() + const scheduleId = ScheduleId('resumed-reminder') + const resumed = ctx.sessions.create(SessionId('schedule-resumed'), { + seed: [{ + type: 'schedule/change', + seq: 0, + time: 1, + data: reminderCreateData('resumed-reminder', 'after restart'), + }], + meta: { cwd: '/tmp' }, + }) + const dispatch = resumed.append('schedule/change', { + version: 1, + operation: 'dispatch', + id: scheduleId, + }) + const child = ctx.sessions.fork(resumed, undefined, SessionId('schedule-fork')) + ctx.provide('sessionPersistence', { + inspect: () => Promise.resolve({ meta: child.header, events: [...child.events] }), + } as never) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + + const response = await api.sessions.history({ + rpcId: RpcId('schedule-resumed-fork'), payload: { sessionId: child.id }, + }) + if (!response.result.ok) throw new Error(response.result.error.message) + expect(response.result.value.events.find(entry => entry.event.seq === dispatch.seq)?.view).toEqual({ + for: 'event', + view: { + scheduleId, + prompt: 'after restart', + occurrenceAt: '2026-08-05T12:00:01.000Z', + deliveryMode: 'session-local', + }, + }) + await ctx.fiber.dispose() + }) + it('uses only the attached identity-matching stored prefix and fails soft to raw history', async () => { const ctx = await harness() const parent = ctx.sessions.create(SessionId('schedule-parent'), { meta: { cwd: '/tmp' } }) diff --git a/packages/schedule/tool-schedule/README.md b/packages/schedule/tool-schedule/README.md index 9bd3e5a151..a9e72270cf 100644 --- a/packages/schedule/tool-schedule/README.md +++ b/packages/schedule/tool-schedule/README.md @@ -16,7 +16,7 @@ The package owns the strict version-1 `schedule/change` create, delete, and disp Replay rejects unknown versions, extra fields, reused ids, and delete or dispatch transitions against inactive records. Normal sessions fold the complete log. A fork folds only `session.events.slice(session.header.seedLength ?? 0)`, so it does not inherit its parent's reminders. The package's `./invariant` companion applies the same policy to existing logs and candidate events. -`scheduleReminderPresentation(events, dispatchSeq, seedLength)` is the pure Host-facing receipt projection. It pairs a dispatch with the active create in the same ownership segment and returns `scheduleId`, prompt, occurrence, and `session-local` mode. A dispatch inside a persisted fork prefix folds from its nearest preceding `session/end-seed` boundary, so nested generations may reuse session-local ids without hiding ancestor receipts; a child-owned dispatch folds only the child suffix, so presentation never changes live ownership. +`scheduleReminderPresentation(events, dispatchSeq, seedLength)` is the pure Host-facing receipt projection. It returns `scheduleId`, prompt, occurrence, and `session-local` mode from the dispatch's nearest preceding same-id create. The current fork's `seedLength` is a hard boundary for child-owned dispatches, while inherited dispatches search their persisted prefix; resumed ancestors therefore remain renderable, nested generations may reuse session-local ids, and presentation never changes live ownership. ## Management tools diff --git a/packages/schedule/tool-schedule/README.zh.md b/packages/schedule/tool-schedule/README.zh.md index 2c63e97f35..2ebe7c928d 100644 --- a/packages/schedule/tool-schedule/README.zh.md +++ b/packages/schedule/tool-schedule/README.zh.md @@ -16,7 +16,7 @@ 回放会拒绝未知版本、额外字段、重复使用的 id,以及针对非活动记录的 delete 或 dispatch 转换。普通会话折叠完整日志。fork 只折叠 `session.events.slice(session.header.seedLength ?? 0)`,因此不会继承父会话的提醒。此包的 `./invariant` 配套项会对现有日志和候选事件应用相同策略。 -`scheduleReminderPresentation(events, dispatchSeq, seedLength)` 是供 Host 使用的纯回执投影。它把 dispatch 与同一 ownership segment 中的活动 create 配对,并返回 `scheduleId`、prompt、occurrence 和 `session-local` 模式。位于已持久 fork 前缀中的 dispatch 会从最近的前置 `session/end-seed` 边界开始折叠,因此嵌套 generation 可以复用会话本地 id,而不会隐藏祖先回执;child 自有 dispatch 只折叠 child 后缀,因此 presentation 绝不会改变 live ownership。 +`scheduleReminderPresentation(events, dispatchSeq, seedLength)` 是供 Host 使用的纯回执投影。它从 dispatch 之前最近的同 id create 返回 `scheduleId`、prompt、occurrence 和 `session-local` 模式。当前 fork 的 `seedLength` 是 child 自有 dispatch 的硬边界,而继承的 dispatch 则会搜索其已持久前缀;因此恢复后的祖先仍可渲染,嵌套 generation 可以复用会话本地 id,presentation 绝不会改变 live ownership。 ## 管理工具 diff --git a/packages/schedule/tool-schedule/src/domain.ts b/packages/schedule/tool-schedule/src/domain.ts index f03aec4c54..2ea4518cbf 100644 --- a/packages/schedule/tool-schedule/src/domain.ts +++ b/packages/schedule/tool-schedule/src/domain.ts @@ -289,10 +289,9 @@ export function scheduleView(record: AfterScheduleRecord, now: number): Schedule /** * Derive the Web receipt for one dispatch from its owning stream segment. - * A dispatch inside an inherited fork prefix folds from its nearest preceding - * `session/end-seed` boundary; a child-owned dispatch folds only the child - * suffix. Nested forks can therefore reuse session-local ids without hiding a - * persisted ancestor receipt in descendant history. + * A child-owned dispatch cannot cross the current fork's `seedLength`. + * An inherited dispatch pairs with its nearest preceding same-id create, so + * resumed ancestors remain renderable and nested forks may reuse local ids. * @param events - Complete contiguous Session log. * @param dispatchSeq - Exact event seq to present. * @param seedLength - Inherited fork prefix length. @@ -317,20 +316,34 @@ export function scheduleReminderPresentation( const dispatch = decodeScheduleChange(event.data) if (dispatch.operation !== 'dispatch') return undefined - const segmentStart = dispatchSeq < seedLength - ? events.slice(0, dispatchSeq).findLastIndex(candidate => candidate.type === 'session/end-seed') + 1 - : seedLength - const before = foldScheduleEvents(events.slice(segmentStart, dispatchSeq)) - const record = before.active.find(candidate => candidate.id === dispatch.id) - if (record === undefined) { - throw new ScheduleLogError(`schedule dispatch targets inactive id ${JSON.stringify(dispatch.id)}`) + const segmentStart = dispatchSeq < seedLength ? 0 : seedLength + for (let index = dispatchSeq - 1; index >= segmentStart; index -= 1) { + const candidate = events[index] + if (candidate?.type !== 'schedule/change') continue + const change = decodeScheduleChange(candidate.data) + switch (change.operation) { + case 'create': + if (change.schedule.id !== dispatch.id) break + return Object.freeze({ + scheduleId: change.schedule.id, + prompt: change.schedule.prompt, + occurrenceAt: change.schedule.scheduledAt, + deliveryMode: 'session-local', + }) + case 'delete': + case 'dispatch': + if (change.id === dispatch.id) { + throw new ScheduleLogError(`schedule dispatch targets inactive id ${JSON.stringify(dispatch.id)}`) + } + break + /* v8 ignore next 3 -- decodeScheduleChange returns a closed operation union. */ + default: { + const unreachable: never = change + throw new ScheduleLogError(`unknown decoded schedule change ${String(unreachable)}`) + } + } } - return Object.freeze({ - scheduleId: record.id, - prompt: record.prompt, - occurrenceAt: record.scheduledAt, - deliveryMode: 'session-local', - }) + throw new ScheduleLogError(`schedule dispatch targets inactive id ${JSON.stringify(dispatch.id)}`) } /** diff --git a/packages/schedule/tool-schedule/tests/domain.spec.ts b/packages/schedule/tool-schedule/tests/domain.spec.ts index cf09ad1c48..a289f26ac1 100644 --- a/packages/schedule/tool-schedule/tests/domain.spec.ts +++ b/packages/schedule/tool-schedule/tests/domain.spec.ts @@ -122,6 +122,33 @@ describe('version-1 Schedule decoding and folding', () => { occurrenceAt: '2026-08-05T12:00:00.000Z', deliveryMode: 'session-local', }) + const resumedThenForked = [ + scheduleEvent(createData('resumed-id', 'resumed prompt'), 0), + { type: 'session/end-seed', seq: 1, time: 1, data: {} } as SessionEvent, + scheduleEvent({ version: 1, operation: 'dispatch', id: 'resumed-id' }, 2), + ] + expect(scheduleReminderPresentation(resumedThenForked, 2, 3)).toEqual({ + scheduleId: 'resumed-id', + prompt: 'resumed prompt', + occurrenceAt: '2026-08-05T12:00:00.000Z', + deliveryMode: 'session-local', + }) + expect(() => scheduleReminderPresentation([ + scheduleEvent(createData('parent-only'), 0), + { type: 'session/end-seed', seq: 1, time: 1, data: {} }, + scheduleEvent({ version: 1, operation: 'dispatch', id: 'parent-only' }, 2), + ], 2, 2)).toThrow(/inactive id/) + expect(scheduleReminderPresentation([ + scheduleEvent(createData('target'), 0), + scheduleEvent(createData('other'), 1), + scheduleEvent({ version: 1, operation: 'delete', id: 'other' }, 2), + scheduleEvent({ version: 1, operation: 'dispatch', id: 'target' }, 3), + ], 3)).toMatchObject({ scheduleId: 'target' }) + expect(() => scheduleReminderPresentation([ + scheduleEvent(createData('ended'), 0), + scheduleEvent({ version: 1, operation: 'delete', id: 'ended' }, 1), + scheduleEvent({ version: 1, operation: 'dispatch', id: 'ended' }, 2), + ], 2)).toThrow(/inactive id/) expect(scheduleReminderPresentation(events, 2, 2)).toBeUndefined() expect(scheduleReminderPresentation([ { type: 'session/end-seed', seq: 0, time: 1, data: {} },