feat(schedule): add durable after reminders

This commit is contained in:
pku-xht
2026-08-06 03:47:37 +08:00
committed by Tianyi Cui
parent f7e7851e3f
commit 8b69252664
41 changed files with 354 additions and 146 deletions

View File

@@ -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.

View File

@@ -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。

View File

@@ -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$/

View File

@@ -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,

View File

@@ -310,6 +310,9 @@ export function registerScheduleTools(
},
output: { schema: DELETE_OUTPUT_SCHEMA, render: renderValue },
async execute(args, exec): Promise<ScheduleDeleteValue> {
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)

View File

@@ -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',

View File

@@ -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', {