fix(schedule): close review gaps

This commit is contained in:
pku-xht
2026-08-06 06:35:50 +08:00
committed by Tianyi Cui
parent c3058e8d46
commit 2e187ccf14
17 changed files with 235 additions and 78 deletions

View File

@@ -19,7 +19,7 @@ The user-visible boundary is `session-local`: the original Session runs an on-ti
| Scenario | Durable fact | Live behavior | User-visible result |
| --- | --- | --- | --- |
| Create and manage | `schedule/change` create/delete events in the original Session | Agent-scoped tools checkpoint before reading and after mutations | Stable id, UTC target, `scheduled`/`overdue`, and `session-local` disclosure |
| Due while busy | Active create remains in the fold | Owner waits for `whenIdle()`, reserves admission, queues one followup, then appends dispatch | One replayable reminder receipt; model failure does not retract it |
| Due while busy | Active create remains in the fold | Owner waits for `whenIdle()`, claims idle maintenance, queues one followup, then appends dispatch | One replayable reminder receipt; model failure does not retract it |
| Process stopped or Session cold | Active create remains in persistence | No timer or background scan exists; resume rebuilds the owner | Future target waits again; overdue target is attempted once |
| Fork | Parent events remain in the inherited prefix | Child fold starts at `seedLength` | Parent receipt may appear in history, but no parent reminder becomes active child work |
@@ -41,21 +41,21 @@ The persistence coordinator supplies that acknowledgement only after its write p
### Live delivery lifecycle
The Agent-scoped owner derives its earliest target from the durable fold. Long targets use bounded timer segments, and every wake reads the wall clock again, so a rollback cannot fire early and a forward jump becomes overdue. An unavailable `reserveTurnAdmission()` leaves the record active and installs one `whenIdle()` wait before retrying.
The Agent-scoped owner derives its earliest target from the durable fold. Long targets use bounded timer segments, and every wake reads the wall clock again, so a rollback cannot fire early and a forward jump becomes overdue. If a turn or another maintenance task already owns the Agent, `runMaintenance()` rejects the claim; the record stays active and one `whenIdle()` wait triggers a later retry. A rejected persistence preflight also leaves the record active, but no private retry timer runs; later Agent activity reaching idle or a successful Schedule management preflight asks the owner to try again.
The accepted path first clears pending persistence, reserves turn admission, samples the decision clock once, and constructs the complete fixed reminder frame with JSON-escaped id and prompt. It synchronously queues one `followup()`, appends the id-only dispatch, and releases the reservation in `finally`; only then does it wait for the dispatch barrier. A framing or synchronous enqueue failure appends no dispatch. An append failure faults that owner because the message may already be queued. A later prompt-admission, request-checkpoint, or model failure cannot retract a dispatch.
The accepted path first clears pending persistence and claims the true idle phase through `runMaintenance()`. Inside that task it refolds the exact Session suffix so a direct management mutation that won the claim race cannot be followed by a stale dispatch, samples the decision clock once, constructs the complete fixed reminder frame with JSON-escaped id and prompt, synchronously queues one `followup()`, and appends the id-only dispatch. Waking input remains parked until maintenance settles, so the driver cannot claim the message before dispatch enters the log; only after the task releases the phase does the owner wait for the dispatch barrier. A framing or synchronous enqueue failure is contained and appends no dispatch. An append failure faults that owner because the message may already be queued. A later prompt-admission, request-checkpoint, or model failure cannot retract a dispatch.
Agent or plugin disposal cancels timers, stops new work, unwinds the three tool registrations, and waits for in-flight preflights or idle waits. It never deletes durable records during teardown. The narrow crash interval after synchronous followup admission and before durable dispatch may repeat the reminder after recovery; the design prefers a visible duplicate over silent loss and makes no model-success, user-read, external-effect, or exactly-once promise.
### 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 that parent segment for history display; 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. 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 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 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, while a sidecar below the current window stays with the in-flight page and attaches only when that page returns the identical event. 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.
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`; an accepted repair snapshot starts another pull when it advanced the tail but left a later buffered gap, while an identity conflict triggers a full resync. Ordinary older-page pagination keeps receiving live tail events in the current arrays, while a sidecar below the current window stays with the in-flight page and attaches only when that page returns the identical event. 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

View File

@@ -19,7 +19,7 @@ Status: implemented
| 场景 | 持久事实 | live 行为 | 用户可见结果 |
| --- | --- | --- | --- |
| 创建与管理 | 原 Session 中的 `schedule/change` createdelete event | Agent-scoped 工具在读取前、变更后执行 checkpoint | 稳定 id、UTC 目标、`scheduled``overdue``session-local` 说明 |
| 到期时繁忙 | 活动 create 仍在 fold 中 | owner 等待 `whenIdle()`预留准入、排入一次 followup再追加 dispatch | 一条可回放提醒回执;模型失败不会撤回它 |
| 到期时繁忙 | 活动 create 仍在 fold 中 | owner 等待 `whenIdle()`认领 idle maintenance、排入一次 followup再追加 dispatch | 一条可回放提醒回执;模型失败不会撤回它 |
| 进程停止或 Session cold | 活动 create 仍在 persistence 中 | 不存在 timer 或后台扫描resume 重建 owner | 未来目标继续等待overdue 目标尝试一次 |
| fork | 父 event 留在继承前缀 | child fold 从 `seedLength` 开始 | history 可显示父回执,但父提醒不会成为 child 活动工作 |
@@ -41,21 +41,21 @@ persistence coordinator 只有在写路径完全停稳后才给出该确认。li
### Live 交付生命周期
Agent-scoped owner 从持久 fold 派生最早目标。超长目标使用有界 timer 分段,每次 wake 都重新读取墙钟,因此回拨不会提前触发,前跳则会形成 overdue。`reserveTurnAdmission()` 不可用时,record 保持活动,并安装一个 `whenIdle()` wait 后再重试。
Agent-scoped owner 从持久 fold 派生最早目标。超长目标使用有界 timer 分段,每次 wake 都重新读取墙钟,因此回拨不会提前触发,前跳则会形成 overdue。如果 agent 已被某个轮次或另一项 maintenance task 占用,`runMaintenance()` 会拒绝此次认领;record 保持活动,并一个 `whenIdle()` wait 触发稍后的重试。被拒绝的 persistence preflight 同样会让 record 保持活动,但不会运行私有重试 timer后续 agent 活动进入 idle或成功的 Schedule 管理 preflight 会要求 owner 再次尝试。
获得准入的路径会先清空 pending persistence、预留 turn admission、只采样一次 decision clock使用 JSON-escaped id 与 prompt 构造完整固定 reminder frame。它同步排入一次 `followup()`,追加只含 id 的 dispatch,并在 `finally` 中释放 reservation之后才等待 dispatch barrier。framing 或同步入队失败不会追加 dispatch。append 失败会使该 owner fault因为消息可能已经入队。后续 prompt admission、request checkpoint 或模型失败都不能撤回 dispatch。
获得准入的路径会先清空 pending persistence,并通过 `runMaintenance()` 认领真正的 idle phase。该任务会重新折叠确切的 Session 后缀,从而确保在认领竞态中胜出的直接管理变更之后不会跟随陈旧 dispatch随后只采样一次 decision clock使用 JSON-escaped id 与 prompt 构造完整固定 reminder frame同步排入一次 `followup()`追加只含 id 的 dispatch。触发唤醒的 input 会保持 parked直到 maintenance 结束,因此 driver 无法在 dispatch 进入 log 前认领消息;只有该任务释放 phase 后owner 才会等待 dispatch barrier。framing 或同步入队失败会被收容,且不会追加 dispatch。append 失败会使该 owner fault因为消息可能已经入队。后续 prompt admission、request checkpoint 或模型失败都不能撤回 dispatch。
Agent 或插件 dispose 会取消 timer、停止新工作、撤销三个工具注册并等待进行中的 preflight 或 idle wait。teardown 绝不会删除持久 record。同步 followup 获得准入后、durable dispatch 前的狭窄崩溃窗口可能在恢复后重复提醒;本设计选择可见重复而非静默丢失,不承诺模型成功、用户阅读、外部副作用或 exactly-once。
### Commit-aware Web 回执
Schedule package 拥有 `scheduleReminderPresentation()`,从 create 加 dispatch 派生 `{ scheduleId, prompt, occurrenceAt, deliveryMode }`。位于继承 fork 前缀中的 dispatch 会折叠该 parent segment 用于 history 显示child 自有 dispatch 只折叠 child 后缀。因此 presentation 永远不会改变 live ownership。
Schedule package 拥有 `scheduleReminderPresentation()`,从 create 加 dispatch 派生 `{ scheduleId, prompt, occurrenceAt, deliveryMode }`。位于继承 fork 前缀中的 dispatch 会从其最近的前置 `session/end-seed` 边界开始折叠,保留嵌套 generation 的 id 复用child 自有 dispatch 只折叠 child 后缀。因此 presentation 永远不会改变 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。
已附加 history 会独立 inspect persistence只有 stored event prefix 的 header identity 与每个 event 都和 live Session 匹配时才添加 view。persistence 会把顶层缺失的 `delegationDepth` 规范写成零因此两种形式在身份上等价cwd、lineage、origin、时间戳、版本、id 与每个 event 仍必须精确匹配。inspect 缺失、失败、分歧或比 live 更长时,只会省略 viewraw history 仍然返回。已分离 history 本身就是持久前缀。因此复制进 fork seed 的 parent dispatch 只有在 child storage 证明该前缀后才会显示。
浏览器 Session 只有在 durable event 深度一致时才接受重复 seq随后立即升级 sidecar不再追加 event。只有尾部加载与真正的 gap repair 才会将尚未覆盖的事件保留在既有 `liveBuffer` 中;普通旧页分页会让当前数组继续接收 live tail 事件,当前 window 以下的 sidecar 则由 in-flight page 自身暂存,只有该页返回身份完全相同的事件时才附着。重连 generation 会阻止陈旧的 page 或 repair 结果以及 `finally` 块触碰重建后的 window。`TranscriptAdapter` 创建按持久事件类型键控的通用 `PresentedEventNode``ui-conversation` 通过 `conversation.chat.eventview` 分发,并保留可展开 JSON fallback`ui-schedule` 则拥有双语 `schedule/change` 提醒行。
浏览器 Session 只有在 durable event 深度一致时才接受重复 seq随后立即升级 sidecar不再追加 event。只有尾部加载与真正的 gap repair 才会将尚未覆盖的事件保留在既有 `liveBuffer` 中;已接受的 repair 快照在推进 tail 但仍留下后续已缓冲的 gap 时会启动另一次 pull身份冲突则会触发全量重新同步。普通旧页分页会让当前数组继续接收 live tail 事件,当前 window 以下的 sidecar 则由 in-flight page 自身暂存,只有该页返回身份完全相同的事件时才附着。重连 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