refactor(schedule): keep reminder delivery conversational

This commit is contained in:
Tianyi Cui
2026-08-09 15:22:53 +08:00
parent 2f3e8974ec
commit 36ef892559
134 changed files with 598 additions and 3139 deletions

View File

@@ -2,10 +2,9 @@
These rules supplement the repository and package instructions for `packages/schedule/*`.
- The owning Session's versioned `schedule/change` stream is the only durable Schedule state. Folds validate every durable JSON boundary and derive active records; timers, waiters, admission reservations, presentation cursors, and tool values remain disposable projections.
- The owning Session's versioned `schedule/change` stream is the only durable Schedule state. Folds validate every durable JSON boundary and derive active records; timers, idle waiters, and tool values remain disposable projections.
- A normal Session folds its complete log. A fork derives active Schedule state only from events at or after `SessionHeader.seedLength`; it never inherits an active parent reminder.
- Every Schedule management operation that reads or decides from the fold first awaits `ctx.sessions.flush(session)`. Create and an actual delete await a second barrier after append; a failed barrier returns the stable uncertainty result instead of inferring durability from the live log.
- Runtime owners attach only to future live root Agents while the plugin is loaded. They do not scan persisted Sessions, adopt already-published roots, wake cold Sessions, register global tools, or delete durable records during teardown.
- Due handling rechecks the wall clock and exact live owner, reserves turn admission through the public Agent seam, constructs the complete escaped framing before `followup()`, appends dispatch only after synchronous enqueue returns, releases the reservation in `finally`, and then awaits durability. A synchronous framing/enqueue failure appends no dispatch; a later model failure does not roll one back.
- Due handling rechecks the wall clock and exact live owner, claims the idle maintenance phase through the public Agent seam, constructs the complete escaped framing before `followup()`, appends dispatch only after synchronous enqueue returns, releases maintenance, and then awaits durability. A synchronous framing/enqueue failure appends no dispatch; a later model failure does not roll one back.
- Rule math and durable transition logic stay pure and deterministic. Production uses the platform wall clock and segmented timers; tests supply explicit samples or fake timers without adding a production clock service.
- Host and browser presentation is derived from a durability-proven event prefix. Domain view construction belongs to Schedule, generic transport and keyed fallback belong to the Host/client runtime, and the Schedule card belongs to its separate client plugin.

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/schedule/README.md
README.md: 1f21dd03d71d00e08a167efabd676dc5319f9671
README.zh.md: ab56383cd8b00001db83120d41e4bcd292a10f04
README.md: edcd84b11444c596028cbd9ccae3926e4fbfdca8
README.zh.md: 09e6cb5bdd1a726cfa6c964438df011035ba0a1e

View File

@@ -1,11 +1,11 @@
# schedule/ — durable Session-local reminders
# schedule/ — Session-local reminders
English | [中文](README.zh.md)
The Schedule family owns reminders whose durable state and delivery receipt live in the original Session log. A process-local owner waits only while that Session has a live root Agent; cold Sessions resume overdue work when they become live again and never imply an external notification channel.
The Schedule family owns reminders whose durable state lives in the original Session log. A process-local owner waits only while that Session has a live root Agent; cold Sessions resume overdue work when they become live again and never imply an external notification channel.
| Package | Role | ctx key |
|---|---|---|
| `tool-schedule/` | Versioned Schedule events and fold, model-facing create/list/delete tools, live root-Agent timer owner, and pure reminder presentation | — |
| `tool-schedule/` | Versioned Schedule events and fold, model-facing create/list/delete tools, and a live root-Agent timer owner | — |
The package deliberately exposes no public Schedule service or mutable database. Tools and runtime append to the Session stream, while Web presentation and the browser renderer consume derived, durability-proven views.
The package deliberately exposes no public Schedule service or mutable database. Tools and runtime append to the Session stream; due work enters the same conversation through the Agent's ordinary follow-up queue.

View File

@@ -1,11 +1,11 @@
# schedule/:持久、仅限 Session 内的提醒
# schedule/:仅限 Session 内的提醒
[English](README.md) | 中文
Schedule 家族负责把持久状态与交付回执保存在原 Session 日志中的提醒。进程内 owner 只会在该 Session 拥有 live 根 Agent 时等待;cold Session 再次 live 后会恢复逾期工作,但不会表示存在外部通知渠道。
Schedule 家族负责管理提醒,其持久状态保存在原 Session 日志中。进程内 owner 只会在该 Session 拥有 live 根 Agent 时等待;cold Session 再次 live 后会恢复逾期工作,但这不意味着存在外部通知渠道。
| 包 | 职责 | ctx 键 |
|---|---|---|
| `tool-schedule/` | 版本化 Schedule 事件与 fold、面向模型的创建/列出/删除工具、live 根 Agent timer owner,以及纯提醒 presentation | 无 |
| `tool-schedule/` | 版本化 Schedule 事件与 fold、面向模型的创建/列出/删除工具,以及 live 根 Agent timer owner | 无 |
本包有意不公开 Schedule service 或可变数据库。工具与 runtime 向 Session stream 追加事件;Web presentation 与浏览器 renderer 则消费由已证明持久的前缀派生出的 view。
本包有意不公开 Schedule service 或可变数据库。工具与 runtime 向 Session stream 追加事件;到期工作通过 Agent 的普通 follow-up 队列进入同一对话。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/schedule/tool-schedule/README.md
README.md: 8068e649d2116da628af1436e1e3cc71b09dcaa0
README.zh.md: 72367b421a8b8dbf5ac866933684740be82157bf
README.md: 216e8fc5c0a4dd6a500c47b0497f80376b651fa9
README.zh.md: e71fe486241979350ece5b4177c4fed2849e96a6

View File

@@ -16,8 +16,6 @@ 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 returns `scheduleId`, prompt, and occurrence from the dispatch's nearest preceding same-id create; the client renderer adds the fixed `session-local` label. 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
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`.
@@ -34,6 +32,8 @@ The live owner derives the earliest target from the durable fold. It splits wait
An overdue reminder first checkpoints persistence. If a turn or another maintenance task already owns the Agent, `runMaintenance()` rejects the idle-phase claim; the record stays active and the owner retries after `whenIdle()`. A successful maintenance task samples one decision time, builds the complete framing, synchronously queues `followup()`, and appends an id-only dispatch before releasing the phase. Waking input remains parked until that release, after which the owner checkpoints dispatch. Framing or synchronous followup failure writes no dispatch. An append failure faults that owner because the message may already be queued; a barrier rejection leaves the dispatch pending for a later ordinary preflight and does not start a private retry timer.
The follow-up opens a normal later turn after the Agent becomes fully idle; it never steers or interrupts the current turn. Its assistant output appears through the ordinary conversation transcript. Dispatch means that the follow-up was queued and recorded, not that the model succeeded or the user read the answer, and Schedule adds no independent Web receipt.
Agent or plugin disposal cancels timers, stops new work, and awaits in-flight preflights and idle waits. It never appends delete records during teardown.
## Model Experience

View File

@@ -16,8 +16,6 @@
回放会拒绝未知版本、额外字段、重复使用的 id,以及针对非活动记录的 delete 或 dispatch 转换。普通会话折叠完整日志。fork 只折叠 `session.events.slice(session.header.seedLength ?? 0)`,因此不会继承父会话的提醒。此包的 `./invariant` 配套项会对现有日志和候选事件应用相同策略。
`scheduleReminderPresentation(events, dispatchSeq, seedLength)` 是供 Host 使用的纯回执投影。它从 dispatch 之前最近的同 id create 返回 `scheduleId`、prompt 和 occurrence;client renderer 添加固定的 `session-local` 标签。当前 fork 的 `seedLength` 是 child 自有 dispatch 的硬边界,而继承的 dispatch 则会搜索其已持久前缀;因此恢复后的祖先仍可渲染,嵌套 generation 可以复用会话本地 id,presentation 绝不会改变 live ownership。
## 管理工具
生成的[工具目录](../../../docs/tool-catalog.md)负责 `schedule_create`、`schedule_list` 和 `schedule_delete` 的参数与输出 schema。虽然模型输入使用 `after_seconds`,但其规范值中的记录字段使用 camelCase。
@@ -34,6 +32,8 @@ live owner 从持久折叠结果派生最早的目标。它会拆分超过 Node
overdue 提醒首先为持久化建立检查点。如果 agent 已被某个轮次或另一项 maintenance task 占用,`runMaintenance()` 会拒绝对 idle phase 的认领;记录会保持活动,owner 会在 `whenIdle()` 后重试。获准执行的 maintenance task 会采样一次决策时间,构造完整 framing,同步将 `followup()` 入队,并在释放 phase 前追加只含 id 的 dispatch。触发唤醒的 input 会保持 parked,直到该 phase 释放;随后 owner 为 dispatch 建立检查点。framing 构造或同步 `followup` 失败不会写入 dispatch。追加失败会使该 owner 进入故障状态,因为消息可能已经入队;barrier 拒绝会把 dispatch 留给后续普通 preflight 处理,而不会启动私有重试 timer。
Agent 完全 idle 后,follow-up 会开启一个普通的后续轮次;它绝不会中途引导或中断当前轮次。assistant 输出通过普通会话 transcript(文本记录)显示。dispatch 表示 follow-up 已入队并被记录,不表示模型成功或用户已读取回答;Schedule 也不会添加独立的 Web 回执。
agent 或插件执行 dispose(资源释放)时,会取消 timer、停止新工作,并等待进行中的 preflight 和 idle wait。清理期间绝不会追加 delete 记录。
## 模型体验

View File

@@ -8,7 +8,6 @@ import type {
AfterScheduleRecord,
ScheduleChange,
ScheduleId as ScheduleIdType,
ScheduleReminderPresentation,
ScheduleView,
} from './types.ts'
@@ -287,64 +286,6 @@ export function scheduleView(record: AfterScheduleRecord, now: number): Schedule
})
}
/**
* Derive the Web receipt for one dispatch from its owning stream segment.
* 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.
* @returns The immutable receipt, or `undefined` when the selected event is not a dispatch.
*/
export function scheduleReminderPresentation(
events: readonly SessionEvent[],
dispatchSeq: number,
seedLength = 0,
): ScheduleReminderPresentation | undefined {
if (!Number.isSafeInteger(dispatchSeq) || dispatchSeq < 0) {
throw new ScheduleLogError('schedule presentation seq must be a non-negative safe integer')
}
if (!Number.isSafeInteger(seedLength) || seedLength < 0 || seedLength > events.length) {
throw new ScheduleLogError('schedule seedLength must be within the supplied event log')
}
const event = events[dispatchSeq]
if (event === undefined || event.seq !== dispatchSeq) {
throw new ScheduleLogError('schedule presentation seq must identify the matching contiguous event')
}
if (event.type !== 'schedule/change') return undefined
const dispatch = decodeScheduleChange(event.data)
if (dispatch.operation !== 'dispatch') return undefined
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,
})
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)}`)
}
}
}
throw new ScheduleLogError(`schedule dispatch targets inactive id ${JSON.stringify(dispatch.id)}`)
}
/**
* Render the fixed injection-resistant model framing for a due reminder.
* @param record - Due active record.

View File

@@ -20,7 +20,6 @@ export {
decodeScheduleChange,
foldScheduleEvents,
renderReminderFraming,
scheduleReminderPresentation,
scheduleView,
} from './domain.ts'
export { registerScheduleTools } from './tools.ts'

View File

@@ -64,16 +64,6 @@ export interface ScheduleView extends AfterScheduleRecord {
readonly deliveryMode: ScheduleDeliveryMode
}
/** JSON-compatible Web receipt derived from one durable dispatch. */
export interface ScheduleReminderPresentation {
/** Session-local reminder identity. */
readonly scheduleId: ScheduleId
/** Original user-authored reminder content. */
readonly prompt: string
/** Scheduled one-shot occurrence represented by the dispatch. */
readonly occurrenceAt: string
}
/** Management operations whose persistence barrier may be uncertain. */
export type SchedulePersistenceOperation = 'create' | 'list' | 'delete'

View File

@@ -9,7 +9,6 @@ import {
decodeScheduleChange,
foldScheduleEvents,
renderReminderFraming,
scheduleReminderPresentation,
scheduleView,
} from '../src/domain.ts'
@@ -90,76 +89,6 @@ describe('version-1 Schedule decoding and folding', () => {
expect(() => foldScheduleEvents([], 0.5)).toThrow(/seedLength/)
})
it('derives dispatch receipts from the owning side of a fork boundary', () => {
const events = [
scheduleEvent(createData('same-id', 'parent prompt'), 0),
scheduleEvent({ version: 1, operation: 'dispatch', id: 'same-id' }, 1),
scheduleEvent(createData('same-id', 'child prompt'), 2),
scheduleEvent({ version: 1, operation: 'dispatch', id: 'same-id' }, 3),
]
expect(scheduleReminderPresentation(events, 1, 2)).toEqual({
scheduleId: 'same-id',
prompt: 'parent prompt',
occurrenceAt: '2026-08-05T12:00:00.000Z',
})
expect(scheduleReminderPresentation(events, 3, 2)).toEqual({
scheduleId: 'same-id',
prompt: 'child prompt',
occurrenceAt: '2026-08-05T12:00:00.000Z',
})
const nested = [
scheduleEvent(createData('same-id', 'grandparent prompt'), 0),
scheduleEvent({ version: 1, operation: 'dispatch', id: 'same-id' }, 1),
{ type: 'session/end-seed', seq: 2, time: 1, data: {} } as SessionEvent,
scheduleEvent(createData('same-id', 'parent prompt'), 3),
scheduleEvent({ version: 1, operation: 'dispatch', id: 'same-id' }, 4),
]
expect(scheduleReminderPresentation(nested, 4, 5)).toEqual({
scheduleId: 'same-id',
prompt: 'parent prompt',
occurrenceAt: '2026-08-05T12:00:00.000Z',
})
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',
})
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: {} },
], 0)).toBeUndefined()
expect(() => scheduleReminderPresentation(events, -1, 2)).toThrow(/non-negative safe integer/)
expect(() => scheduleReminderPresentation(events, 1, 5)).toThrow(/seedLength/)
expect(() => scheduleReminderPresentation(events, 4, 2)).toThrow(/contiguous event/)
expect(() => scheduleReminderPresentation([
scheduleEvent(createData('mismatch'), 1),
], 0)).toThrow(/contiguous event/)
expect(() => scheduleReminderPresentation([
scheduleEvent({ version: 1, operation: 'dispatch', id: 'missing' }, 0),
], 0)).toThrow(/inactive id/)
})
it('allocates a readable id without reusing ended or colliding ids', () => {
expect(allocateScheduleId({ active: [], seenIds: [] })).toBe('schedule-1')
expect(allocateScheduleId({ active: [], seenIds: [ScheduleId('custom'), ScheduleId('schedule-3')] }))

View File

@@ -15,7 +15,6 @@ import {
ScheduleId,
createAfterScheduleRecord,
foldScheduleEvents,
scheduleReminderPresentation,
} from '../src/domain.ts'
const roots: string[] = []
@@ -112,19 +111,6 @@ describe('Schedule production JSONL restart', () => {
const dispatches = dispatchedStored.events.filter(event =>
event.type === 'schedule/change' && event.data.operation === 'dispatch')
expect(dispatches).toHaveLength(1)
const dispatch = dispatches[0]
if (dispatch?.type !== 'schedule/change' || dispatch.data.operation !== 'dispatch') {
throw new Error('missing durable Schedule dispatch')
}
expect(scheduleReminderPresentation(
dispatchedStored.events,
dispatch.seq,
dispatchedStored.meta.seedLength ?? 0,
)).toEqual({
scheduleId: 'schedule-1',
prompt: 'restart reminder',
occurrenceAt: pendingRecord.scheduledAt,
})
expect(dispatchingAdapter.requests).toHaveLength(1)
await handle.dispose()
await disposeContext(restarted)

View File

@@ -18,7 +18,7 @@ async function harness(): Promise<Context> {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(PersistenceProbe)
ctx.on('session/flush', () => true)
ctx.on('session/flush', () => {})
await ctx.plugin(AgentLoop, { agents: [] })
return ctx
}

View File

@@ -104,7 +104,6 @@ async function harness(): Promise<RuntimeHarness> {
order.push('flush')
if (controls.flushOutcomes.shift() === 'reject') return Promise.reject(new Error('disk unavailable'))
await controls.flushHandler?.()
return true as const
})
return { ctx, agent, followed, order, controls, disposeAgent }
}

View File

@@ -57,7 +57,6 @@ async function harness(withPersistence = true): Promise<ToolHarness> {
flushes.count += 1
const outcome = await (flushes.outcomes.shift() ?? 'resolve')
if (outcome === 'reject') return Promise.reject(new Error('disk unavailable'))
return true as const
})
}
const changes = { count: 0 }

View File

@@ -30,10 +30,10 @@
"path": "../../core/tools"
},
{
"path": "../../session-persistence/session-persistence"
"path": "../../session/session-persistence"
},
{
"path": "../../session-persistence/session-persistence-jsonl"
"path": "../../session/session-persistence-jsonl"
},
{
"path": "../../support/invariants"