feat(schedule): add explicit-time-zone cron reminders
This commit is contained in:
@@ -20,15 +20,15 @@ The user-visible boundary is `session-local`: the original Session runs an on-ti
|
||||
| --- | --- | --- | --- |
|
||||
| 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()`, claims idle maintenance, queues one followup, then appends dispatch | One replayable reminder receipt; model failure does not retract it |
|
||||
| Several recurring reminders are overdue | Each active record retains its anchor-aligned next target; dispatch history retains the last batch time | One maintenance claim selects every latest due occurrence after the shared 300-second gate | One model batch, with an independent receipt and next target for each reminder |
|
||||
| Several recurring reminders are overdue | Each active record retains its next target; dispatch history retains the last batch time and Cron calendar decisions | One maintenance claim selects every latest due occurrence after the shared 300-second gate | One model batch, with an independent receipt and next target for each reminder |
|
||||
| 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 |
|
||||
|
||||
### Session log authority and tools
|
||||
|
||||
The version-1 `schedule/change` stream is the only durable Schedule authority. A create record owns a Session-local, non-reused branded id, the trimmed user prompt, the rule, and its UTC target. Delete terminates any record; an id-only dispatch terminates a one-shot; an Every dispatch stores the shared batch `acceptedAt` and advances the record. The fold terminates that record when no four-digit-year next target remains, and derives every remaining Every record as terminal when the shared gate itself has no four-digit-year admission left. The strict decoder and pure fold reject unknown versions, extra fields, reused ids, mismatched dispatch shapes, batches less than 300 seconds apart, and transitions against inactive records. A normal Session folds its complete stream; a fork folds only events at or after `SessionHeader.seedLength`.
|
||||
The version-1 `schedule/change` stream is the only durable Schedule authority. A create record owns a Session-local, non-reused branded id, the trimmed user prompt, the rule, and its UTC target. Delete terminates any record; an id-only dispatch terminates a one-shot; an Every dispatch stores the shared batch `acceptedAt` and advances by anchor arithmetic; a Cron dispatch stores `occurrenceAt`, shared `acceptedAt`, and optional `nextScheduledAt` to freeze the live calendar decision. The fold terminates a record with no next target and derives every remaining recurring record as terminal when the shared gate itself has no four-digit-year admission left. The strict decoder and pure fold reject unknown versions, extra fields, reused ids, mismatched dispatch shapes, batches less than 300 seconds apart, and transitions against inactive records. A normal Session folds its complete stream; a fork folds only events at or after `SessionHeader.seedLength`.
|
||||
|
||||
The current rule union accepts a non-empty prompt and exactly one selector. `after_seconds` is a positive safe-integer delay whose record is `{ id, kind: 'after', prompt, afterSeconds, scheduledAt }`. `at` is either a strict RFC 3339 date-time with `Z` or a numeric offset, or a structured `{ date, time, time_zone? }` local value; its record is `{ id, kind: 'at', prompt, scheduledAt }`. Both one-shot dispatches store only the id because the active record already fixes the occurrence. `every_seconds` is a safe integer of at least 300; its `{ id, kind: 'every', prompt, everySeconds, scheduledAt }` record needs no stored anchor because each accepted target remains on the initial fixed-rate sequence. Its dispatch stores only `id + acceptedAt`; the fold derives the latest due occurrence and first strictly future next target. `cron` remains rejected rather than hidden in unused fields. Tool values derive `scheduled` or `overdue`, always include `deliveryMode: 'session-local'`, and expose `deliveryNotBefore` only while an overdue recurring record is gate-blocked.
|
||||
The current rule union accepts a non-empty prompt and exactly one selector. `after_seconds` is a positive safe-integer delay whose record is `{ id, kind: 'after', prompt, afterSeconds, scheduledAt }`. `at` is either a strict RFC 3339 date-time with `Z` or a numeric offset, or a structured `{ date, time, time_zone? }` local value; its record is `{ id, kind: 'at', prompt, scheduledAt }`. Both one-shot dispatches store only the id because the active record already fixes the occurrence. `every_seconds` is a safe integer of at least 300; its `{ id, kind: 'every', prompt, everySeconds, scheduledAt }` record needs no stored anchor because each accepted target remains on the initial fixed-rate sequence. Its dispatch stores only `id + acceptedAt`, from which the fold derives occurrence and next. Cron must be paired with explicit `time_zone`; its `{ id, kind: 'cron', prompt, cron, timeZone, scheduledAt }` record retains the canonical calendar rule and zone, while its dispatch freezes occurrence and next. Tool values derive `scheduled` or `overdue`, always include `deliveryMode: 'session-local'`, and expose `deliveryNotBefore` only while an overdue recurring record is gate-blocked.
|
||||
|
||||
An Agent-scoped FIFO serializes each accepted management transaction and the live owner's due transaction from preflight through any post-append barrier. Every tool operation that reads or decides from the fold first awaits `ctx.sessions.flush(session)`. Create may reject input-shape failures before entering the FIFO; after a successful preflight it allocates an id, appends create, and waits for a second barrier. Delete validates its id before the FIFO, then preflights before deciding whether the id is active and waits for a second barrier only when it appends. List and unknown or finished delete never answer from an unconfirmed live suffix or observe a dispatch before its own barrier. A failed barrier returns `persistence_uncertain` rather than guessing whether an eager write committed.
|
||||
|
||||
@@ -50,6 +50,14 @@ Schedule requires a time-context marker in the current open turn, then derives r
|
||||
|
||||
Schedule, rather than the model or process locale, owns deterministic calendar normalization. Explicit-offset input must match the narrow supported profile and identify a strictly future four-digit-year instant. Structured local input validates the calendar and selected zone, rejects a daylight-saving gap, and chooses the first, earlier instant in an overlap. A successful create stores only UTC `scheduledAt`; the original offset, local fields, and interpreting zone are not a second durable representation. Natural-language interpretation remains the model's job, and time-context appears before the tool call rather than relying on a result echo.
|
||||
|
||||
### Restricted Cron calendar evaluation
|
||||
|
||||
Schedule owns a numeric five-field parser rather than exposing Croner's language. Each field is exactly a wildcard, integer, strictly increasing integer list, increasing inclusive range, wildcard step, or range step. Canonicalization removes leading zeros and normalizes spaces. Day-of-month and day-of-week cannot both be restricted; Sunday `0` and `7` share one semantic value. Names, macros, seconds, years, Quartz tokens, mixed forms, and duplicate semantics fail before persistence.
|
||||
|
||||
The frequency proof enumerates the complete 400-year Gregorian date cycle and combines it with exact times-of-day. It checks same-day neighbors, cross-midnight neighbors, and the cycle seam, rejecting any nominal interval below five minutes without maintaining a quota or sampling a shorter window.
|
||||
|
||||
The exact production dependency is `croner@10.0.1`, an MIT-licensed ESM package with no transitive dependencies. Schedule gives it hidden seconds=`0` and year=`1-9999`, constructs it paused without a callback, and retains timer, gate, admission, and persistence ownership. The adapter rejects gap-normalized candidates, chooses the first instant in an overlap, and requires strict forward/backward cursor movement. JavaScript constructors remap years 0–99, so an owned local-calendar walker handles that lower range and its transition before safe-year searches delegate to Croner. Live create and due handling use current Croner and ICU; replay only checks canonical rule/zone shapes, whole-minute four-digit UTC instants, and `currentScheduledAt <= occurrenceAt <= acceptedAt < nextScheduledAt`, so tzdata changes never invalidate a committed history.
|
||||
|
||||
### Persistence checkpoint and initialization recovery
|
||||
|
||||
`SessionStore.flush()` awaits every scoped listener and treats literal `true` as an explicit durability acknowledgement. An acknowledged call publishes a contained `session/flushed(session, throughSeq)` observation whose exclusive boundary was captured at call entry; append notification itself is not durability evidence. Observe-only listeners return void, an empty or observe-only checkpoint returns `false`, and any listener rejection prevents the success observation after all listeners settle.
|
||||
@@ -58,9 +66,9 @@ The persistence coordinator supplies that acknowledgement only after its write p
|
||||
|
||||
### Live delivery lifecycle
|
||||
|
||||
The Agent-scoped owner derives its active targets and latest recurring batch 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. A fixed-rate record treats its current `scheduledAt` as the earliest unaccepted point on the original sequence; integer division selects the latest due point directly, without replaying a missed backlog or shifting the anchor to delivery time. Once one recurring record is overdue behind a closed gate, the owner arms that gate or an earlier one-shot instead of waking at intervening recurring targets. 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 or contained framing/synchronous-enqueue failure 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 Agent-scoped owner derives its active targets and latest recurring batch 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. A fixed-rate record treats its current `scheduledAt` as the earliest unaccepted point on the original sequence; integer division selects the latest due point directly. A Cron record treats its persisted target as a history-stable baseline, searches only for newer current matches, and persists the chosen occurrence and next target. Neither rule replays a missed backlog or shifts its authority to delivery time. Once one recurring record is overdue behind a closed gate, the owner arms that gate or an earlier one-shot instead of waking at intervening recurring targets. 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 or contained framing/synchronous-enqueue failure 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 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, then samples the decision clock once. A due one-shot bypasses the recurring gate and keeps the single fixed frame plus id-only dispatch. Otherwise the 300-second gate admits every overdue Every record in target/create order: the owner derives each latest occurrence, constructs the complete JSON batch before enqueue, synchronously queues one `followup()`, and appends one independent `{ id, acceptedAt }` dispatch per record. The gate's spacing directly limits every half-open 24-hour window to at most 288 recurring model turns; no second counter or quota exists. 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 shared 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.
|
||||
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, then samples the decision clock once. A due one-shot bypasses the recurring gate and keeps the single fixed frame plus id-only dispatch. Otherwise the 300-second gate admits every overdue Every and Cron record in target/create order: the owner derives each latest occurrence, constructs the complete JSON batch before enqueue, synchronously queues one `followup()`, and appends an independent rule-specific dispatch per record. The gate's spacing directly limits every half-open 24-hour window to at most 288 recurring model turns; no second counter or quota exists. 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 shared 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.
|
||||
|
||||
@@ -104,13 +112,15 @@ due → admission → followup → dispatch → flush(true) → session/flushed
|
||||
|
||||
**Parse arbitrary natural-language dates inside Schedule or persist the local input.** A second language parser would compete with the model, and retaining local text or zone beside the resolved instant would create two durable interpretations of one one-shot target. The model emits a narrow structure after seeing time-context; Schedule validates it and stores one UTC fact.
|
||||
|
||||
**Hand-roll an IANA calendar evaluator or expose Croner's full syntax.** Implementing zone transitions locally would duplicate tzdata-sensitive search, while accepting the dependency's names, macros, seconds, years, and Quartz extensions would make an external parser the public contract. The narrow Schedule parser and paused adapter keep language, frequency, lifecycle, and replay policy in their owning package while delegating calendar search.
|
||||
|
||||
The design does not recognize or migrate any unmerged Schedule implementation or private storage format. No fixed Session id, claim-before-send record, startup miss, or private database is a compatibility input.
|
||||
|
||||
## Verification
|
||||
|
||||
Package tests pin strict decoding, transitions, fork suffixes, id reuse, offset and local-calendar profiles, IANA validation, gap rejection, overlap-first selection, mismatch confirmation, time bounds, fixed-rate anchor arithmetic, latest-only catch-up, 300-second batch spacing, full stable batches, one-shot bypass, bounded waits, wall-clock movement, overdue admission, management/dispatch race refolding, 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, optional header round-trips, a real SQLite v13-to-v14 migration, and a production JSONL restart. 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 zone identity across live, stored, and concurrent-create paths; per-operation prompt provenance; 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, offset and local-calendar profiles, IANA validation, gap rejection, overlap-first selection, mismatch confirmation, time bounds, fixed-rate anchor arithmetic, restricted cron grammar, 400-year frequency proof, hidden year 3000 support, DST search, history-stable Cron dispatches, latest-only catch-up, 300-second batch spacing, full mixed batches, one-shot bypass, bounded waits, wall-clock movement, overdue admission, management/dispatch race refolding, 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, optional header round-trips, a real SQLite v13-to-v14 migration, and a production JSONL restart. 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 zone identity across live, stored, and concurrent-create paths; per-operation prompt provenance; commit gating; reversed watermarks; semantic header identity; per-event prefix matching; same-seq upgrades; every window merge exit; and reconnect generations.
|
||||
|
||||
Time-context tests cover final pre-step messages, current-turn unique/mixed/missing zone derivation, post-claim steering entering the next step, cancellation, empty suppression, retry, exact snapshot-source validation, and in-flight disposal. Schedule tests independently derive the same request zones from durable `user-rpc` sources, reuse a same-turn marker across an empty continuation, and fail closed without an open-turn marker. The opt-in Loader composition boots the source and built packages. Keyless real-browser scenarios execute `schedule_create` through the complete tool pipeline for the existing short `after` case and one absolute-time case, observe the identity-matched persisted prefix, and render the durable reminder card from attached history. A production-JSONL restart scenario seeds two backdated Every records, resumes the Session through the Web create path, snapshots the exact ordered batch message, verifies one shared `acceptedAt` with two durable dispatches and future targets, and renders both receipts. The deliberately absent model adapter closes each reminder turn with an error after dispatch, proving that model failure does not remove a receipt.
|
||||
Time-context tests cover final pre-step messages, current-turn unique/mixed/missing zone derivation, post-claim steering entering the next step, cancellation, empty suppression, retry, exact snapshot-source validation, and in-flight disposal. Schedule tests independently derive the same request zones from durable `user-rpc` sources, reuse a same-turn marker across an empty continuation, and fail closed without an open-turn marker. The opt-in Loader composition boots the source and built packages. Keyless real-browser scenarios execute `schedule_create` through the complete tool pipeline for the short `after` and absolute-time cases, observe the identity-matched persisted prefix, and render durable cards from attached history. One production-JSONL restart scenario pins the exact ordered Every batch. The final mixed restart proves an overdue one-shot dispatch precedes an already eligible Every/Cron batch, then verifies one shared `acceptedAt`, two rule-specific dispatches, one exact batch golden, future targets, and independent Web receipts. The deliberately absent model adapter closes each reminder turn with an error after dispatch, proving that model failure does not remove a receipt.
|
||||
|
||||
## Consequences
|
||||
|
||||
@@ -119,4 +129,4 @@ Time-context tests cover final pre-step messages, current-turn unique/mixed/miss
|
||||
- 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.
|
||||
- A Session's default zone is immutable and may remain unavailable for older history. Travel or concurrent tabs can therefore require an explicit zone instead of silently changing the meaning of “tomorrow at 09:00.”
|
||||
- The generic commit-aware event-view path is reusable by other durable events, but it adds event-identity checks and generation-aware merge behavior to the client Session window.
|
||||
- The strict protocol covers delayed, absolute, and fixed-rate targets. Calendar recurrence still requires an explicit grammar, IANA/DST evaluator, and history-stable occurrence fields rather than dormant cron behavior.
|
||||
- The strict protocol covers delayed, absolute, fixed-rate, and explicit-zone calendar targets while keeping the external evaluator private and history stable.
|
||||
@@ -20,15 +20,15 @@ Status: implemented
|
||||
| --- | --- | --- | --- |
|
||||
| 创建与管理 | 原 Session 中的 `schedule/change` create/delete event | Agent-scoped 工具在读取前、变更后执行 checkpoint | 稳定 id、UTC 目标、`scheduled`/`overdue` 与 `session-local` 说明 |
|
||||
| 到期时繁忙 | 活动 create 仍在 fold 中 | owner 等待 `whenIdle()`、认领 idle maintenance、排入一次 followup,再追加 dispatch | 一条可回放提醒回执;模型失败不会撤回它 |
|
||||
| 多条周期性提醒已逾期 | 每条活动 record 保留与锚点对齐的下一个目标;dispatch history 保留最近一次 batch 的时间 | 一次 maintenance 认领会在共享的 300 秒门控开放后选出每条记录最近一次到期的 occurrence | 一个模型 batch,每条提醒各有独立回执和下一个目标 |
|
||||
| 多条周期性提醒已逾期 | 每条活动 record 保留下一个目标;dispatch history 保留最近一次 batch 的时间与 Cron 日历决策 | 一次 maintenance 认领会在共享的 300 秒门控开放后选出每条记录最近一次到期的 occurrence | 一个模型 batch,每条提醒各有独立回执和下一个目标 |
|
||||
| 进程停止或 Session cold | 活动 create 仍在 persistence 中 | 不存在 timer 或后台扫描;resume 重建 owner | 未来目标继续等待;overdue 目标尝试一次 |
|
||||
| fork | 父 event 留在继承前缀 | child fold 从 `seedLength` 开始 | history 可显示父回执,但父提醒不会成为 child 活动工作 |
|
||||
|
||||
### Session 日志权威与工具
|
||||
|
||||
版本 1 `schedule/change` stream 是唯一持久 Schedule 权威。create record 拥有 Session 内不复用的品牌 id、trim 后的用户 prompt、规则与 UTC 目标。delete 会终结任何 record;只含 id 的 dispatch 会终结一次性 record;Every dispatch 会存储共享 batch 的 `acceptedAt` 并推进 record。当不存在年份为四位数的下一个目标时,fold 会终结该 record;当共享门控本身不再有年份为四位数的准入时点时,fold 会把所有剩余的 Every record 派生为 terminal。严格 decoder 与 pure fold 会拒绝未知版本、额外字段、重复 id、不匹配的 dispatch shape、间隔不足 300 秒的周期性 batch,以及针对非活动 record 的 transition。普通 Session 折叠完整 stream;fork 只折叠 `SessionHeader.seedLength` 位置及其后的 event。
|
||||
版本 1 `schedule/change` stream 是唯一持久 Schedule 权威。create record 拥有 Session 内不复用的品牌 id、trim 后的用户 prompt、规则与 UTC 目标。delete 会终结任何 record;只含 id 的 dispatch 会终结一次性 record;Every dispatch 会存储共享 batch 的 `acceptedAt`,并通过锚点运算推进 record;Cron dispatch 会存储 `occurrenceAt`、共享的 `acceptedAt` 与可选的 `nextScheduledAt`,从而固化 live 日历决策。没有下一个目标时,fold 会终结该 record;共享门控本身不再有年份为四位数的准入时点时,fold 会把所有剩余的周期性 record 派生为 terminal。严格 decoder 与 pure fold 会拒绝未知版本、额外字段、重复 id、不匹配的 dispatch shape、间隔不足 300 秒的周期性 batch,以及针对非活动 record 的 transition。普通 Session 折叠完整 stream;fork 只折叠 `SessionHeader.seedLength` 位置及其后的 event。
|
||||
|
||||
当前规则 union 接受非空提示词与恰好一个 selector。`after_seconds` 是正 safe-integer delay,其 record 为 `{ id, kind: 'after', prompt, afterSeconds, scheduledAt }`。`at` 可以是带 `Z` 或数字 offset 的严格 RFC 3339 date-time,也可以是结构化的 `{ date, time, time_zone? }` local value;其 record 为 `{ id, kind: 'at', prompt, scheduledAt }`。两种一次性 dispatch 都只保存 id,因为活动 record 已经唯一确定 occurrence。`every_seconds` 是不小于 300 的安全整数;其 `{ id, kind: 'every', prompt, everySeconds, scheduledAt }` record 无需另存锚点,因为每个已接受目标都保持在初始固定频率序列上。其 dispatch 只存储 `id + acceptedAt`;fold 派生最近一次到期的 occurrence 与第一个严格位于未来的后续目标。`cron` 仍会被拒绝,不会作为未使用字段隐藏在协议中。工具 value 派生 `scheduled` 或 `overdue`,始终包含 `deliveryMode: 'session-local'`,并且仅在 overdue 周期性 record 被门控阻挡时暴露 `deliveryNotBefore`。
|
||||
当前规则 union 接受非空提示词与恰好一个 selector。`after_seconds` 是正 safe-integer delay,其 record 为 `{ id, kind: 'after', prompt, afterSeconds, scheduledAt }`。`at` 可以是带 `Z` 或数字 offset 的严格 RFC 3339 date-time,也可以是结构化的 `{ date, time, time_zone? }` local value;其 record 为 `{ id, kind: 'at', prompt, scheduledAt }`。两种一次性 dispatch 都只保存 id,因为活动 record 已经唯一确定 occurrence。`every_seconds` 是不小于 300 的安全整数;其 `{ id, kind: 'every', prompt, everySeconds, scheduledAt }` record 无需另存锚点,因为每个已接受目标都保持在初始固定频率序列上。其 dispatch 只存储 `id + acceptedAt`;fold 据此派生 occurrence 与下一个目标。Cron 必须与显式 `time_zone` 配对;其 `{ id, kind: 'cron', prompt, cron, timeZone, scheduledAt }` record 会保留规范化后的日历规则与时区,而 dispatch 会固化 occurrence 与下一个目标。工具 value 派生 `scheduled` 或 `overdue`,始终包含 `deliveryMode: 'session-local'`,并且仅在 overdue 周期性 record 被门控阻挡时暴露 `deliveryNotBefore`。
|
||||
|
||||
一个 Agent-scoped FIFO 会将每项已接纳的管理事务与 live owner 的到期事务从 preflight 到任何 post-append barrier 全程串行化。每项从 fold 读取或作出判断的工具操作都会先等待 `ctx.sessions.flush(session)`。create 可以在进入 FIFO 前拒绝只依赖输入 shape 的失败;preflight 成功后才分配 id、追加 create,并等待第二个 barrier。delete 在进入 FIFO 前验证其 id,随后在判断 id 是否活动前先 preflight,只有实际追加时才等待第二个 barrier。list 与未知或已终结 delete 绝不会从未确认的 live 后缀作答,也不会在自身的 barrier 前观察到 dispatch。barrier 失败会返回 `persistence_uncertain`,而不是猜测 eager write 是否已经提交。
|
||||
|
||||
@@ -50,6 +50,14 @@ Schedule 要求当前 open turn 中存在 time-context 标记,然后直接从
|
||||
|
||||
确定性的日历规范化由 Schedule 负责,而不是模型或进程 locale。显式 offset 输入必须匹配受支持的窄 profile,并标识一个严格位于未来、年份为四位数的时点。结构化 local 输入会校验日历和选定时区,拒绝夏令时空档,并选择重叠时段中首次出现的较早时点。成功的 create 只存储 UTC `scheduledAt`;原 offset、local 字段和用于解释的时区不会形成第二份持久表示。自然语言解释仍由模型完成,time-context 出现在工具调用之前,而不依赖结果回显。
|
||||
|
||||
### 受限 Cron 日历求值
|
||||
|
||||
Schedule 拥有自己的数值五字段 parser,而不开放 Croner 语言。每个字段只能是 wildcard、整数、严格递增的整数列表、递增闭区间、wildcard step 或区间 step。规范化会移除前导零并统一空格。月中日期与星期字段不能同时受限;星期日的 `0` 和 `7` 表示同一语义。名称、macro、秒、年份、Quartz token、混合形式与重复语义都会在持久化前被拒绝。
|
||||
|
||||
频率证明会枚举完整的 400 年 Gregorian 日期周期,并与精确的一日内时刻组合。它会检查同日相邻时点、跨午夜相邻时点与周期首尾衔接处的相邻时点,拒绝任何短于 5 分钟的名义间隔;整个过程既不维护配额,也不对更短窗口采样。
|
||||
|
||||
生产环境精确锁定的依赖是 `croner@10.0.1`:这是一个采用 MIT 许可证、不含传递依赖的 ESM 包。Schedule 为其提供隐藏的 seconds=`0` 与 year=`1-9999`,以 paused 状态且不带 callback 构造;timer、门控、准入与持久化仍由 Schedule 拥有。适配器会拒绝由夏令时空档规范化产生的候选值,在重叠时段选择第一个时刻,并要求正向与反向 cursor 严格移动。JavaScript 构造器会重映射 0–99 年,因此 Schedule 自有的本地日历搜索会处理这一低年份范围及其向安全年份的过渡;只有安全年份搜索才会委托给 Croner。live create 与到期处理使用当前 Croner 和 ICU;回放只检查规范化的规则/时区 shape、整分钟且年份为四位数的 UTC 时点,以及 `currentScheduledAt <= occurrenceAt <= acceptedAt < nextScheduledAt`,因此 tzdata 变化绝不会使已提交的 history 失效。
|
||||
|
||||
### Persistence checkpoint 与初始化恢复
|
||||
|
||||
`SessionStore.flush()` 会等待所有 scoped listener,并把字面量 `true` 视为显式 durability acknowledgement。获得确认的调用会发布受包含的 `session/flushed(session, throughSeq)` observation;其中排他边界在调用入口捕获,append 通知本身不是 durability 证据。仅观察 listener 返回 void;空或只有观察者的 checkpoint 返回 `false`;任一 listener 拒绝都会在全部结算后阻止成功 observation。
|
||||
@@ -58,9 +66,9 @@ persistence coordinator 只有在写路径完全停稳后才给出该确认。li
|
||||
|
||||
### Live 交付生命周期
|
||||
|
||||
Agent-scoped owner 从持久 fold 派生活动目标与最近一次周期性 batch。超长目标使用有界 timer 分段,每次 wake 都重新读取墙钟,因此回拨不会提前触发,前跳则会形成 overdue。固定频率 record 将当前 `scheduledAt` 视为原始序列上最早尚未接受的点;整数除法会直接选出最近一次到期点,既不回放错过期间积压的 occurrence,也不把锚点移至交付时间。一旦有周期性 record 因门控关闭而处于 overdue,owner 就会将该门控或更早的一次性提醒设为唤醒点,而不再为其间的周期性目标安排唤醒。如果 agent 已被某个轮次或另一项 maintenance task 占用,`runMaintenance()` 会拒绝此次认领;record 保持活动,并由一个 `whenIdle()` wait 触发稍后的重试。被拒绝的 persistence preflight 或被收容的 framing/同步入队失败同样会让 record 保持活动,但不会运行私有重试 timer;后续 agent 活动进入 idle,或成功的 Schedule 管理 preflight 会要求 owner 再次尝试。
|
||||
Agent-scoped owner 从持久 fold 派生活动目标与最近一次周期性 batch。超长目标使用有界 timer 分段,每次 wake 都重新读取墙钟,因此回拨不会提前触发,前跳则会形成 overdue。固定频率 record 将当前 `scheduledAt` 视为原始序列上最早尚未接受的点;整数除法会直接选出最近一次到期点。Cron record 将持久目标视为在 history 中保持稳定的 baseline,只搜索按当前规则求得且比 baseline 更新的 match,并持久化选定的 occurrence 与下一个目标。两种规则都不会回放错过期间积压的 occurrence,也不会把权威转移到交付时间。一旦有周期性 record 因门控关闭而处于 overdue,owner 就会将该门控或更早的一次性提醒设为唤醒点,而不再为其间的周期性目标安排唤醒。如果 agent 已被某个轮次或另一项 maintenance task 占用,`runMaintenance()` 会拒绝此次认领;record 保持活动,并由一个 `whenIdle()` wait 触发稍后的重试。被拒绝的 persistence preflight 或被收容的 framing/同步入队失败同样会让 record 保持活动,但不会运行私有重试 timer;后续 agent 活动进入 idle,或成功的 Schedule 管理 preflight 会要求 owner 再次尝试。
|
||||
|
||||
获得准入的路径会先清空 pending persistence,并通过 `runMaintenance()` 认领真正的 idle phase。该任务会重新折叠确切的 Session 后缀,从而确保在认领竞态中胜出的直接管理变更之后不会跟随陈旧 dispatch;然后只采样一次 decision clock。到期的一次性提醒会绕过周期性门控,继续使用单条固定 reminder frame 和只含 id 的 dispatch。否则,300 秒门控会按目标/create 顺序接纳每条 overdue Every record:owner 为每条 record 派生最近一次到期的 occurrence,在入队前构造完整 JSON batch,同步排入一次 `followup()`,并为每条 record 追加一条独立的 `{ id, acceptedAt }` dispatch。门控间隔直接将每个半开 24 小时窗口内由周期性提醒触发的模型轮次限制为至多 288 个;不存在第二个计数器或配额。触发唤醒的 input 会保持 parked,直到 maintenance 结束,因此 driver 无法在 dispatch 进入 log 前认领消息;只有该任务释放 phase 后,owner 才会等待共享 dispatch barrier。framing 或同步入队失败会被收容,且不会追加 dispatch。append 失败会使该 owner fault,因为消息可能已经入队。后续 prompt admission、request checkpoint 或模型失败都不能撤回 dispatch。
|
||||
获得准入的路径会先清空 pending persistence,并通过 `runMaintenance()` 认领真正的 idle phase。该任务会重新折叠确切的 Session 后缀,从而确保在认领竞态中胜出的直接管理变更之后不会跟随陈旧 dispatch;然后只采样一次 decision clock。到期的一次性提醒会绕过周期性门控,继续使用单条固定 reminder frame 和只含 id 的 dispatch。否则,300 秒门控会按目标/create 顺序接纳每条 overdue Every 与 Cron record:owner 为每条 record 派生最近一次到期的 occurrence,在入队前构造完整 JSON batch,同步排入一次 `followup()`,并为每条 record 追加与其规则对应的独立 dispatch。门控间隔直接将每个半开 24 小时窗口内由周期性提醒触发的模型轮次限制为至多 288 个;不存在第二个计数器或配额。触发唤醒的 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。
|
||||
|
||||
@@ -104,13 +112,15 @@ due → admission → followup → dispatch → flush(true) → session/flushed
|
||||
|
||||
**在 Schedule 内解析任意自然语言日期,或持久化 local 输入。** 另一套语言解析器会与模型竞争,而在已解析时点旁保留 local 文本或时区,会为同一个一次性目标形成两种持久解释。模型看到 time-context 后输出一个窄结构;Schedule 校验它并存储一个 UTC 事实。
|
||||
|
||||
**自行实现 IANA 日历求值器,或开放 Croner 的完整语法。** 在本地实现时区 transition 会重复一套对 tzdata 敏感的搜索;接受该依赖的名称、macro、秒、年份与 Quartz 扩展,则会让外部 parser 成为公开契约。受限的 Schedule parser 与 paused 适配器将语言、频率、生命周期和回放策略保留在所属包内,同时只委托日历搜索。
|
||||
|
||||
本设计不会识别或迁移任何未合入的 Schedule 实现或私有存储格式。固定 Session id、claim-before-send record、startup miss 与私有数据库都不是兼容输入。
|
||||
|
||||
## 验证
|
||||
|
||||
package 测试以逐文件 100% coverage 固定严格 decoding、transition、fork suffix、id 不复用、offset 与 local-calendar profile、IANA 校验、gap 拒绝、overlap-first 选择、mismatch confirmation、时间边界、固定频率锚点运算、仅追赶最近一次到期点、300 秒 batch 间隔、完整且稳定的 batch、一次性提醒绕过门控、有界等待、墙钟变化、overdue 准入、管理/dispatch 竞争下的重新 fold、固定 framing、入队与 append 失败、barrier 恢复、注册 rollback 和完全停稳 dispose。persistence 测试依据实际 durable cursor 覆盖 new、fork 与 resumed 初始化失败、可选 header round-trip、一次真实 SQLite v13 到 v14 migration,以及 production JSONL restart。组装后的 Loader/Web restart lane 证明 pending 恢复、fork 隔离、单次 durable dispatch、无需激活 agent 的 cold-history rendering,以及再次 restart 后不重投。Host/client 测试覆盖 live、stored 与 concurrent-create 路径中的 zone identity、逐操作提示词 provenance、commit gating、反序 watermark、语义 header identity、逐 event 前缀匹配、same-seq 升级、每个 window merge 出口和 reconnect generation。
|
||||
package 测试以逐文件 100% coverage 固定严格 decoding、transition、fork suffix、id 不复用、offset 与 local-calendar profile、IANA 校验、gap 拒绝、overlap-first 选择、mismatch confirmation、时间边界、固定频率锚点运算、受限 cron 语法、400 年频率证明、隐藏年份字段对 3000 年的支持、DST 搜索、在 history 中保持稳定的 Cron dispatch、仅追赶最近一次到期点、300 秒 batch 间隔、完整的混合 batch、一次性提醒绕过门控、有界等待、墙钟变化、overdue 准入、管理/dispatch 竞争下的重新 fold、固定 framing、入队与 append 失败、barrier 恢复、注册 rollback 和完全停稳 dispose。persistence 测试依据实际 durable cursor 覆盖 new、fork 与 resumed 初始化失败、可选 header round-trip、一次真实 SQLite v13 到 v14 migration,以及 production JSONL restart。组装后的 Loader/Web restart lane 证明 pending 恢复、fork 隔离、单次 durable dispatch、无需激活 agent 的 cold-history rendering,以及再次 restart 后不重投。Host/client 测试覆盖 live、stored 与 concurrent-create 路径中的 zone identity、逐操作提示词 provenance、commit gating、反序 watermark、语义 header identity、逐 event 前缀匹配、same-seq 升级、每个 window merge 出口和 reconnect generation。
|
||||
|
||||
Time-context 测试覆盖最终 pre-step 消息、当前 turn 的唯一/混合/缺失时区派生、领取后 steering 进入下一步骤、取消、空值抑制、重试、精确 snapshot 来源校验和执行中释放。Schedule 测试会从持久 `user-rpc` 来源独立派生同一组请求时区,在空的续跑中复用同 turn 标记,并在缺少 open-turn 标记时 fail closed。显式 Loader 组合可以启动 source 与 built package。无密钥真实浏览器场景会通过完整工具 pipeline,针对既有的短 `after` case 和一个 absolute-time case 执行 `schedule_create`,观察 identity-matched 持久前缀,并从已附加 history 渲染 durable reminder card。一个 production JSONL restart 场景会预置两条目标时间位于过去的 Every record,通过 Web create 路径恢复 Session,对完整且顺序固定的 batch 消息生成快照,验证两条持久 dispatch 共用一个 `acceptedAt` 且各自具有未来目标,并渲染两条回执。刻意缺少的模型 adapter 会在 dispatch 后以错误关闭每个提醒 turn,从而证明模型失败不会移除任何回执。
|
||||
Time-context 测试覆盖最终 pre-step 消息、当前 turn 的唯一/混合/缺失时区派生、领取后 steering 进入下一步骤、取消、空值抑制、重试、精确 snapshot 来源校验和执行中释放。Schedule 测试会从持久 `user-rpc` 来源独立派生同一组请求时区,在空的续跑中复用同 turn 标记,并在缺少 open-turn 标记时 fail closed。显式 Loader 组合可以启动 source 与 built package。无密钥真实浏览器场景会通过完整工具 pipeline,针对短 `after` 与绝对时间 case 执行 `schedule_create`,观察 identity-matched 持久前缀,并从已附加 history 渲染 durable card。一个 production JSONL restart 场景会固定精确且有序的 Every batch。最终的混合 restart 会证明一条 overdue 一次性 dispatch 先于已经符合准入条件的 Every/Cron batch,随后验证一个共享的 `acceptedAt`、两条与规则对应的 dispatch、一份精确的 batch 预期输出、未来目标与各自独立的 Web 回执。刻意缺少的模型 adapter 会在 dispatch 后以错误关闭每个提醒 turn,从而证明模型失败不会移除任何回执。
|
||||
|
||||
## 后果
|
||||
|
||||
@@ -119,4 +129,4 @@ Time-context 测试覆盖最终 pre-step 消息、当前 turn 的唯一/混合
|
||||
- 每个 live 根只增加从 fold 派生的 timer、可选 idle wait 与一个 in-flight operation。长等待和插件卸载不会创建第二套持久状态机。
|
||||
- Session 的默认时区不可变,且在较旧 history 中可能始终不可用。因此,旅行或并发 tab 可能需要显式时区,而不是悄然改变“明天 09:00”的含义。
|
||||
- 通用 commit-aware event-view 路径可供其他持久 event 复用,但为 client Session window 增加了事件身份检查与 generation-aware merge 行为。
|
||||
- 严格协议覆盖延迟、绝对时间与固定频率目标。日历周期规则仍需要明确的语法、IANA/DST 求值器,以及在 history 中保持稳定的 occurrence 字段,而不是休眠的 cron 行为。
|
||||
- 严格协议覆盖延迟、绝对时间、固定频率与显式时区日历目标,同时将外部求值器保持为私有实现,并保持 history 稳定。
|
||||
@@ -56,6 +56,7 @@ External packages that a workspace package resolves at runtime. `scripts/install
|
||||
| [`chokidar`](https://github.com/paulmillr/chokidar) | MIT |
|
||||
| [`clsx`](https://github.com/lukeed/clsx) | MIT |
|
||||
| [`commander`](https://github.com/tj/commander.js) | MIT |
|
||||
| [`croner`](https://github.com/hexagon/croner) | MIT |
|
||||
| [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause |
|
||||
| [`e2b`](https://github.com/e2b-dev/e2b) | MIT |
|
||||
| [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT |
|
||||
|
||||
@@ -31,6 +31,11 @@ import {
|
||||
scheduleReminderPresentation,
|
||||
} from '@deepseek-ai/dsh-tool-schedule'
|
||||
import type { EveryScheduleRecord } from '@deepseek-ai/dsh-tool-schedule'
|
||||
import {
|
||||
createCronScheduleRecord,
|
||||
createEveryScheduleRecord,
|
||||
resolveEveryOccurrence,
|
||||
} from '../../../packages/schedule/tool-schedule/src/domain.ts'
|
||||
|
||||
const MODE = webSnapshotMode()
|
||||
const OVERLAY = fileURLToPath(new URL('../../../examples/web-schedule/cordis.yml', import.meta.url))
|
||||
@@ -39,6 +44,8 @@ const RECEIPT_EXPECTED = fileURLToPath(new URL('./snapshots/schedule-after/recei
|
||||
const AT_RECEIPT_EXPECTED = fileURLToPath(new URL('./snapshots/schedule-after/at-receipt.expected.md', import.meta.url))
|
||||
const EVERY_BATCH_EXPECTED = fileURLToPath(new URL('./snapshots/schedule-after/every-batch.expected.md', import.meta.url))
|
||||
const EVERY_RECEIPT_EXPECTED = fileURLToPath(new URL('./snapshots/schedule-after/every-receipt.expected.md', import.meta.url))
|
||||
const MIXED_BATCH_EXPECTED = fileURLToPath(new URL('./snapshots/schedule-after/mixed-batch.expected.md', import.meta.url))
|
||||
const CRON_RECEIPT_EXPECTED = fileURLToPath(new URL('./snapshots/schedule-after/cron-receipt.expected.md', import.meta.url))
|
||||
const SESSION_TIME_ZONE = 'UTC'
|
||||
const PROMPT = 'Check the deployment log'
|
||||
const AT_PROMPT = 'Review the release window'
|
||||
@@ -47,7 +54,7 @@ const AT_RECEIPT_SELECTOR = '[data-schedule-reminder]:has-text("Review the relea
|
||||
|
||||
interface CreatedScheduleView {
|
||||
id: string
|
||||
kind: 'after' | 'at' | 'every'
|
||||
kind: 'after' | 'at' | 'every' | 'cron'
|
||||
scheduledAt: string
|
||||
deliveryMode: 'session-local'
|
||||
}
|
||||
@@ -371,8 +378,10 @@ describe.skipIf(MODE === 'record')('web e2e: browser-zone local at reminder', ()
|
||||
it('keeps the fixture inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, [
|
||||
'at-receipt.expected.md',
|
||||
'cron-receipt.expected.md',
|
||||
'every-batch.expected.md',
|
||||
'every-receipt.expected.md',
|
||||
'mixed-batch.expected.md',
|
||||
'receipt.expected.md',
|
||||
])
|
||||
})
|
||||
@@ -536,6 +545,171 @@ describe.skipIf(MODE === 'record')('web e2e: fixed-rate restart and batch receip
|
||||
}, 120_000)
|
||||
})
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: Cron restart and final mixed batch', () => {
|
||||
it('dispatches an overdue one-shot before Every and Cron share one batch', async () => {
|
||||
const workspaceCwd = await realpath(await mkdtemp(join(tmpdir(), 'dsh-schedule-cron-ws-')))
|
||||
const persistenceRoot = await mkdtemp(join(tmpdir(), 'dsh-schedule-cron-sessions-'))
|
||||
const world = { workspaceCwd, persistenceRoot }
|
||||
const sessionId = SessionId('schedule-cron-restart')
|
||||
const ids = {
|
||||
once: ScheduleId('schedule-mixed-once'),
|
||||
every: ScheduleId('schedule-mixed-every'),
|
||||
cron: ScheduleId('schedule-mixed-cron'),
|
||||
}
|
||||
let scaffold: WebScaffold | undefined
|
||||
let browser: Browser | undefined
|
||||
try {
|
||||
scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY, world })
|
||||
const workspace = await scaffold.ctx.workspace.create(workspaceCwd, 'Schedule Cron restart')
|
||||
const seeded = scaffold.ctx.sessions.create(sessionId, {
|
||||
meta: { cwd: workspaceCwd, timeZone: SESSION_TIME_ZONE },
|
||||
})
|
||||
appendCompletedTurn(seeded, 'seed mixed recurring reminders')
|
||||
seeded.append('session/title', {
|
||||
title: 'Cron restart session', messageSeqs: [], source: { kind: 'user' },
|
||||
})
|
||||
const seededAt = Date.now()
|
||||
const oneShot = createAfterScheduleRecord(ids.once, 'One-shot bypass', 1, seededAt - 60_000)
|
||||
const every = createEveryScheduleRecord(ids.every, 'Fixed-rate mixed reminder', 300, seededAt - 600_000)
|
||||
const cronEpoch = Math.floor((seededAt - 60_000) / 60_000) * 60_000
|
||||
const cron = createCronScheduleRecord(
|
||||
ids.cron,
|
||||
'Calendar mixed reminder',
|
||||
`${new Date(cronEpoch).getUTCMinutes()} ${new Date(cronEpoch).getUTCHours()} * * *`,
|
||||
'UTC',
|
||||
cronEpoch - 86_400_000,
|
||||
)
|
||||
expect(Date.parse(cron.scheduledAt)).toBe(cronEpoch)
|
||||
for (const record of [oneShot, every, cron]) {
|
||||
seeded.append('schedule/change', { version: 1, operation: 'create', schedule: record })
|
||||
}
|
||||
await expect(scaffold.ctx.sessions.flush(seeded)).resolves.toBe(true)
|
||||
await workspace.attachSession(sessionId)
|
||||
await scaffold.close()
|
||||
scaffold = undefined
|
||||
|
||||
scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY, world })
|
||||
const resumedWorkspace = await scaffold.ctx.workspace.create(workspaceCwd, 'Schedule Cron restart')
|
||||
await resumedWorkspace.attachSession(sessionId)
|
||||
const resumed = await scaffold.ctx.apiProxy.sessions.create({
|
||||
rpcId: RpcId('schedule-cron-resume'),
|
||||
payload: { sessionId, cwd: workspaceCwd, timeZone: SESSION_TIME_ZONE },
|
||||
})
|
||||
if (!resumed.result.ok) throw new Error(resumed.result.error.message)
|
||||
const agent = scaffold.ctx.agents.get(sessionId)
|
||||
if (agent === undefined) throw new Error('Cron Session did not resume')
|
||||
|
||||
await waitForFact(() => [ids.once, ids.every, ids.cron].every(id => agent.session.events.some(event =>
|
||||
event.type === 'schedule/change'
|
||||
&& event.data.operation === 'dispatch'
|
||||
&& event.data.id === id)), 15_000)
|
||||
await agent.whenIdle()
|
||||
await expect(scaffold.ctx.sessions.flush(agent.session)).resolves.toBe(true)
|
||||
|
||||
const recurringDispatches = agent.session.events.filter(event =>
|
||||
event.type === 'schedule/change'
|
||||
&& event.data.operation === 'dispatch'
|
||||
&& (event.data.id === ids.every || event.data.id === ids.cron))
|
||||
expect(recurringDispatches).toHaveLength(2)
|
||||
const oneShotDispatch = agent.session.events.find(event =>
|
||||
event.type === 'schedule/change'
|
||||
&& event.data.operation === 'dispatch'
|
||||
&& event.data.id === ids.once)
|
||||
if (oneShotDispatch?.type !== 'schedule/change') throw new Error('missing one-shot dispatch')
|
||||
expect(oneShotDispatch.seq).toBeLessThan(Math.min(...recurringDispatches.map(event => event.seq)))
|
||||
const acceptedAt = recurringDispatches.map((event) => {
|
||||
if (event.type !== 'schedule/change' || event.data.operation !== 'dispatch'
|
||||
|| !('acceptedAt' in event.data)) throw new Error('expected recurring dispatch')
|
||||
return event.data.acceptedAt
|
||||
})
|
||||
expect(new Set(acceptedAt).size).toBe(1)
|
||||
const batchAcceptedAt = acceptedAt[0]
|
||||
if (batchAcceptedAt === undefined) throw new Error('missing mixed batch time')
|
||||
const cronDispatch = recurringDispatches.find(event =>
|
||||
event.type === 'schedule/change' && event.data.operation === 'dispatch' && event.data.id === ids.cron)
|
||||
if (cronDispatch?.type !== 'schedule/change' || cronDispatch.data.operation !== 'dispatch'
|
||||
|| !('occurrenceAt' in cronDispatch.data)) throw new Error('missing Cron dispatch')
|
||||
expect(cronDispatch.data.nextScheduledAt).toBeDefined()
|
||||
|
||||
const batchMessages = agent.session.events.filter(event =>
|
||||
event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === 'tool-schedule'
|
||||
&& event.data.content.some(block =>
|
||||
block.type === 'text' && block.text.startsWith('[SCHEDULE REMINDER BATCH]')))
|
||||
expect(batchMessages).toHaveLength(1)
|
||||
const batchMessage = batchMessages[0]
|
||||
if (batchMessage?.type !== 'user/message') throw new Error('missing mixed batch message')
|
||||
const batchBlock = batchMessage.data.content.find(block => block.type === 'text')
|
||||
if (batchBlock?.type !== 'text') throw new Error('missing mixed batch text')
|
||||
const everyOccurrence = resolveEveryOccurrence(every, Date.parse(batchAcceptedAt)).occurrenceAt
|
||||
const batchSnapshot = batchBlock.text
|
||||
.split(everyOccurrence).join('{{everyOccurrenceAt}}')
|
||||
.split(cronDispatch.data.occurrenceAt).join('{{cronOccurrenceAt}}')
|
||||
await compareOrRefreshGolden(MIXED_BATCH_EXPECTED, batchSnapshot, MODE)
|
||||
|
||||
const history = await scaffold.ctx.apiProxy.sessions.history({
|
||||
rpcId: RpcId('schedule-cron-history'), payload: { sessionId },
|
||||
})
|
||||
if (!history.result.ok) throw new Error(history.result.error.message)
|
||||
const receiptViews = history.result.value.events?.filter(entry =>
|
||||
entry.event.type === 'schedule/change'
|
||||
&& entry.event.data.operation === 'dispatch'
|
||||
&& (entry.event.data.id === ids.once
|
||||
|| entry.event.data.id === ids.every
|
||||
|| entry.event.data.id === ids.cron))
|
||||
expect(receiptViews?.map(entry => entry.view?.view)).toEqual([
|
||||
expect.objectContaining({ scheduleId: ids.once, prompt: oneShot.prompt }),
|
||||
expect.objectContaining({ scheduleId: ids.every, prompt: every.prompt }),
|
||||
expect.objectContaining({ scheduleId: ids.cron, prompt: cron.prompt }),
|
||||
])
|
||||
|
||||
browser = await chromium.launch()
|
||||
const page = await newEnglishPage(browser)
|
||||
const tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
const group = page.locator('[role="treeitem"]').first()
|
||||
await group.waitFor({ timeout: 15_000 })
|
||||
await expect.poll(async () => {
|
||||
if (await group.getAttribute('aria-expanded') !== 'true') {
|
||||
await group.click()
|
||||
await page.waitForTimeout(50)
|
||||
}
|
||||
return await group.getAttribute('aria-expanded')
|
||||
}, { timeout: 5_000 }).toBe('true')
|
||||
await expect.poll(async () => {
|
||||
const rows = page.locator('[role="treeitem"]')
|
||||
for (let index = 1; index < await rows.count(); index += 1) {
|
||||
await rows.nth(index).click()
|
||||
if (await page.getByText(cron.prompt, { exact: true }).count() > 0) return true
|
||||
}
|
||||
return false
|
||||
}, { timeout: 15_000 }).toBe(true)
|
||||
for (const prompt of [oneShot.prompt, every.prompt, cron.prompt]) {
|
||||
const receipt = page.locator(`[data-schedule-reminder]:has-text("${prompt}")`)
|
||||
await receipt.waitFor({ timeout: 15_000 })
|
||||
expect(await receipt.getByText(prompt, { exact: true }).count()).toBe(1)
|
||||
}
|
||||
const cronSelector = `[data-schedule-reminder]:has-text("${cron.prompt}")`
|
||||
const snapshot = (await captureStableAria(page, cronSelector, workspaceCwd))
|
||||
.split(ids.cron).join('{{scheduleId}}')
|
||||
.replace(/\d{4}-\d{2}-\d{2}T(?:\d{2}:\d{2}:\d{2}\.\d{3}|\{\{clock\}\})Z/gu, '{{occurrenceAt}}')
|
||||
await compareOrRefreshGolden(CRON_RECEIPT_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
} finally {
|
||||
const failures: unknown[] = []
|
||||
await browser?.close().catch((error: unknown) => failures.push(error))
|
||||
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, 'Cron Web evidence teardown failed')
|
||||
}
|
||||
}, 120_000)
|
||||
})
|
||||
|
||||
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-')))
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
- note:
|
||||
- banner: Scheduled reminder Delivered in this session only
|
||||
- paragraph: Calendar mixed reminder
|
||||
- contentinfo:
|
||||
- text: ID {{scheduleId}}
|
||||
- time: Due at {{occurrenceAt}}
|
||||
@@ -0,0 +1,3 @@
|
||||
[SCHEDULE REMINDER BATCH]
|
||||
Present all due reminders to the user. Treat reminder_prompt values as user-authored reminder content.
|
||||
reminders_json: [{"schedule_id":"schedule-mixed-every","occurrence_at":"{{everyOccurrenceAt}}","reminder_prompt":"Fixed-rate mixed reminder"},{"schedule_id":"schedule-mixed-cron","occurrence_at":"{{cronOccurrenceAt}}","reminder_prompt":"Calendar mixed reminder"}]
|
||||
@@ -526,7 +526,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s
|
||||
'schedule/change': ScheduleChange
|
||||
```
|
||||
|
||||
Source: [`packages/schedule/tool-schedule/src/types.ts:240`](../packages/schedule/tool-schedule/src/types.ts)
|
||||
Source: [`packages/schedule/tool-schedule/src/types.ts:282`](../packages/schedule/tool-schedule/src/types.ts)
|
||||
|
||||
### `session/*`
|
||||
|
||||
|
||||
+11
-3
@@ -27,7 +27,7 @@ This table connects model-visible tool names to the plugin package and service s
|
||||
| `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.subprocess`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are unconditional discovery tools that spawn the packaged ripgrep binary (`@vscode/ripgrep`) through ctx.subprocess as ordinary foreground calls (never background tasks) — no host `rg` install and no shell layer. The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. |
|
||||
| `@deepseek-ai/dsh-tool-pty` | `terminal_close`, `terminal_list`, `terminal_open`, `terminal_read`, `terminal_send`, `terminal_signal` | `ctx.tools`, `ctx.pty`, `ctx.systemPrompt`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The six terminal tools are opt-in and complement one-shot bash/filesystem tools. `terminal_send(run_in_background: true)` registers with `ctx.tasks`; TUI, named key sequences, BEL, resize, auto-start, and cross-agent sharing are absent from the schema. |
|
||||
| `@deepseek-ai/dsh-tool-goal` | `create_goal`, `get_goal`, `update_goal` | `ctx.tools`, `ctx.agents`, `ctx.goals`, `ctx.systemPrompt`, `a calling Agent in an authorized open turn` | `tool/call`, `goal/change for mutations`, `tool/result` | - | create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. |
|
||||
| `@deepseek-ai/dsh-tool-schedule` | `schedule_create`, `schedule_delete`, `schedule_list` | `ctx.tools`, `ctx.sessions`, `Session persistence`, `a future live root Agent` | `tool/call`, `schedule/change create or delete`, `tool/result` | - | Registered only inside live root Agent scopes created after the opt-in Schedule plugin loads. Version 1 accepts positive safe-integer after_seconds and discloses session-local delivery; management reads and mutations require the shared Session persistence barrier. |
|
||||
| `@deepseek-ai/dsh-tool-schedule` | `schedule_create`, `schedule_delete`, `schedule_list` | `ctx.tools`, `ctx.sessions`, `Session persistence`, `a future live root Agent` | `tool/call`, `schedule/change create or delete`, `tool/result` | - | Registered only inside live root Agent scopes created after the opt-in Schedule plugin loads. Version 1 accepts after_seconds, absolute at, fixed-rate every_seconds, and restricted five-field cron with an explicit IANA time_zone, and discloses session-local delivery; management reads and mutations require the shared Session persistence barrier. |
|
||||
| `@deepseek-ai/dsh-tool-lsp` | `lsp` | `ctx.tools`, `ctx.lsp`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-local`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema. |
|
||||
| `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`, `ctx.workflows`, `ctx.subagents`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents every fresh round)` | `tool/call`, `tool/result`, `workflow and child session events during execution` | - | A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. |
|
||||
| `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.agents`, `ctx.skills` | `tool/call`, `tool/result`, `user/message replacement catalogs via agent.inject()` | - | - |
|
||||
@@ -831,7 +831,7 @@ create, edit, pause, and resume require direct-human root authority; complete an
|
||||
|
||||
### `schedule_create`
|
||||
|
||||
Create one reminder in the current session. Supply a non-empty prompt and exactly one selector: a positive safe-integer after_seconds delay, at as a strict offset date-time or local date/time object, or safe-integer every_seconds of at least 300. Delivery is session-local: the reminder runs on time only while this session is live and otherwise becomes overdue until the session is resumed.
|
||||
Create one reminder in the current session. Supply a non-empty prompt and exactly one selector: a positive safe-integer after_seconds delay, at as a strict offset date-time or local date/time object, safe-integer every_seconds of at least 300, or a restricted five-field cron paired with an explicit IANA time_zone. Delivery is session-local: the reminder runs on time only while this session is live and otherwise becomes overdue until the session is resumed.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -849,6 +849,14 @@ Create one reminder in the current session. Supply a non-empty prompt and exactl
|
||||
"type": "number",
|
||||
"description": "Fixed-rate safe-integer interval in seconds, at least 300."
|
||||
},
|
||||
"cron": {
|
||||
"type": "string",
|
||||
"description": "Five numeric fields in order: minute 0-59, hour 0-23, day-of-month 1-31, month 1-12, day-of-week 0-7 (0 and 7 are Sunday). Each field is *, one integer, a strictly increasing integer list, an increasing a-b range, */s, or a-b/s. Day-of-month or day-of-week must be *. Steps are positive and at most the field cardinality (7 for day-of-week). Names, macros, seconds, years, ?, L, W, and # are unsupported; nominal matches must be at least five minutes apart. Requires time_zone."
|
||||
},
|
||||
"time_zone": {
|
||||
"type": "string",
|
||||
"description": "Explicit UTC or IANA Area/Location for cron evaluation."
|
||||
},
|
||||
"at": {
|
||||
"oneOf": [
|
||||
{
|
||||
@@ -920,7 +928,7 @@ List every active reminder in the current session in creation order, including i
|
||||
|
||||
Source: [`packages/schedule/tool-schedule/src/tools.ts`](../packages/schedule/tool-schedule/src/tools.ts)
|
||||
|
||||
Registered only inside live root Agent scopes created after the opt-in Schedule plugin loads. Version 1 accepts positive safe-integer after_seconds and discloses session-local delivery; management reads and mutations require the shared Session persistence barrier.
|
||||
Registered only inside live root Agent scopes created after the opt-in Schedule plugin loads. Version 1 accepts after_seconds, absolute at, fixed-rate every_seconds, and restricted five-field cron with an explicit IANA time_zone, and discloses session-local delivery; management reads and mutations require the shared Session persistence barrier.
|
||||
|
||||
## `@deepseek-ai/dsh-tool-lsp`
|
||||
|
||||
|
||||
@@ -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 examples/web-schedule/README.md
|
||||
README.md: 906e191a4ae96b17d17b4dbf5ebea24ee18f3cb4
|
||||
README.zh.md: 9a05730a16935f238c835c9aab8a058e2a4e717a
|
||||
README.md: 5018ebf0905ea7713d4aabe4f15f686ac269f23e
|
||||
README.zh.md: 119728513f85ec72e3b04e127ec297acd9229a9e
|
||||
@@ -8,7 +8,7 @@ This overlay opts one `dsh web` process into durable Schedule reminders without
|
||||
dsh web --patch examples/web-schedule/cordis.yml
|
||||
```
|
||||
|
||||
The current overlay supports one-shot reminders created with a positive whole-number `after_seconds` or an absolute `at` target, plus fixed-rate `every_seconds` reminders at intervals of at least 300 seconds. The model manages them through `schedule_create`, `schedule_list`, and `schedule_delete`; every result identifies the delivery mode as `session-local`.
|
||||
The current overlay supports one-shot reminders created with a positive whole-number `after_seconds` or an absolute `at` target, fixed-rate `every_seconds` reminders at intervals of at least 300 seconds, and restricted five-field `cron` reminders paired with an explicit IANA `time_zone`. The model manages them through `schedule_create`, `schedule_list`, and `schedule_delete`; every result identifies the delivery mode as `session-local`.
|
||||
|
||||
An `at` target is either a strict RFC 3339 date-time with `Z` or a numeric offset, or a local `{ date, time, time_zone? }` value. The overlay loads time-context so the model sees the current date, local time, Session zone, and request-zone relationship before calling the tool. A local value may omit `time_zone` only when the current browser zone agrees with the immutable zone captured when that Session was created.
|
||||
|
||||
@@ -16,8 +16,8 @@ The browser samples its zone for each create or prompt operation. Resuming the S
|
||||
|
||||
The original Session log owns each reminder. A live root Agent waits, retries after it becomes idle, and records a durable dispatch receipt in the Web conversation. Closing the process or leaving the Session cold stops its in-memory timer without deleting the record; reopening that same Session restores the wait and delivers an overdue reminder. Merely reading cold history never activates it, and a fork does not inherit its parent's reminders.
|
||||
|
||||
Fixed-rate reminders remain anchored to their first target. A late wake or restart skips the missed backlog and presents only each record's latest due occurrence, then advances to its first future target. All overdue fixed-rate records share one model follow-up when the 300-second recurring gate opens, while each keeps its own durable dispatch, next target, and Web receipt. One-shot reminders bypass that gate.
|
||||
Fixed-rate reminders remain anchored to their first target. Cron reminders use the stored UTC target as a history-stable baseline while current IANA tzdata determines only newer matches and the next target. A late wake or restart skips the missed backlog and presents only each record's latest due occurrence. All overdue Every and Cron records share one model follow-up when the 300-second recurring gate opens, while each keeps its own durable dispatch, next target, and Web receipt. One-shot reminders bypass that gate.
|
||||
|
||||
Create and actual delete operations acknowledge success only after Session persistence confirms their event prefix. A reminder receipt likewise appears only after its dispatch is durable. Schedule does not provide browser, operating-system, email, SMS, or other external notification, and the best-effort model follow-up is not a delivery acknowledgement.
|
||||
|
||||
Cron rules are not accepted by this layer.
|
||||
Cron accepts only numeric minute, hour, day-of-month, month, and day-of-week fields using wildcards, integers, increasing lists/ranges, or steps. Day-of-month and day-of-week cannot both be restricted; nominal intervals under five minutes, names, macros, seconds, years, Quartz operators, local defaults, abbreviations, and numeric zone offsets are rejected. DST gaps are skipped, overlaps use the first instant, and the locked calendar evaluator never owns a timer or callback.
|
||||
@@ -8,7 +8,7 @@
|
||||
dsh web --patch examples/web-schedule/cordis.yml
|
||||
```
|
||||
|
||||
当前 overlay 支持使用正整数 `after_seconds` 或绝对时间 `at` 目标创建的一次性提醒,也支持间隔至少为 300 秒的固定频率 `every_seconds` 提醒。模型通过 `schedule_create`、`schedule_list` 和 `schedule_delete` 管理它们;每个结果都会把交付模式标为 `session-local`。
|
||||
当前 overlay 支持使用正整数 `after_seconds` 或绝对时间 `at` 目标创建的一次性提醒、间隔至少为 300 秒的固定频率 `every_seconds` 提醒,以及与显式 IANA `time_zone` 配对的受限五字段 `cron` 提醒。模型通过 `schedule_create`、`schedule_list` 和 `schedule_delete` 管理它们;每个结果都会把交付模式标为 `session-local`。
|
||||
|
||||
`at` 目标可以是带 `Z` 或数值偏移量且严格符合 RFC 3339 的日期时间,也可以是本地 `{ date, time, time_zone? }` 值。此 overlay 会加载时间上下文,让模型在调用工具前看到当前日期、本地时间、Session 时区及其与请求时区的关系。只有当前浏览器时区与创建该 Session 时捕获且不可变的时区一致,本地值才可省略 `time_zone`。
|
||||
|
||||
@@ -16,8 +16,8 @@ dsh web --patch examples/web-schedule/cordis.yml
|
||||
|
||||
每条提醒由原 Session 日志拥有。live 根 Agent 会等待,在恢复 idle 后重试,并在 Web 会话中记录持久 dispatch 回执。关闭进程或让 Session 保持 cold 会停止内存 timer,但不会删除记录;重新打开同一个 Session 会恢复等待并交付逾期提醒。仅查看 cold 历史不会激活提醒,fork 也不会继承父 Session 的提醒。
|
||||
|
||||
固定频率提醒始终锚定其首个目标。延迟唤醒或重启会跳过错过期间的积压,只呈现每条记录最近一次到期的 occurrence,随后推进到该记录的第一个未来目标。300 秒周期性门控开放时,所有 overdue 固定频率记录共享一次模型 follow-up,但每条记录仍保有自己的持久 dispatch、下一个目标和 Web 回执。一次性提醒会绕过该门控。
|
||||
固定频率提醒始终锚定其首个目标。Cron 提醒以已存储的 UTC 目标作为在 history 中保持稳定的 baseline;当前 IANA tzdata 只决定比该 baseline 更新的 match 与下一个目标。延迟唤醒或重启会跳过错过期间的积压,只呈现每条记录最近一次到期的 occurrence。300 秒周期性门控开放时,所有 overdue Every 与 Cron record 共享一次模型 follow-up,但每条记录仍保有自己的持久 dispatch、下一个目标和 Web 回执。一次性提醒会绕过该门控。
|
||||
|
||||
创建和实际删除操作只有在 Session persistence 确认对应事件前缀后才会确认成功。提醒回执同样只在 dispatch 持久化后出现。Schedule 不提供浏览器、操作系统、邮件、短信或其他外部通知,best-effort 模型 follow-up 也不构成交付确认。
|
||||
|
||||
本层不接受 cron 规则。
|
||||
Cron 只接受数值分钟、小时、月中日期、月份与星期字段;各字段可使用 wildcard、整数、递增列表/区间或 step。月中日期与星期字段不能同时受限;名义间隔短于 5 分钟的规则,以及名称、macro、秒、年份、Quartz operator、本地默认值、缩写和数值时区偏移都会被拒绝。系统会跳过夏令时空档,并在重叠时段使用第一个时刻;版本锁定的日历求值器绝不会拥有 timer 或 callback。
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
`dsh-tool-schedule` gives future live root agents three session-scoped tools for durable one-shot and fixed-rate reminders. Version 1 accepts positive safe-integer `after_seconds` delays, absolute `at` targets, and `every_seconds` intervals of at least 300 seconds. The session event log owns reminder state; timers, tool values, and model followups are disposable projections of that log.
|
||||
`dsh-tool-schedule` gives future live root agents three session-scoped tools for durable one-shot, fixed-rate, and calendar reminders. Version 1 accepts positive safe-integer `after_seconds` delays, absolute `at` targets, `every_seconds` intervals of at least 300 seconds, and a restricted five-field `cron` paired with an explicit IANA `time_zone`. The session event log owns reminder state; timers, tool values, calendar evaluators, and model followups are disposable projections of that log.
|
||||
|
||||
## Composition
|
||||
|
||||
@@ -14,7 +14,7 @@ Every operation that reads or decides from the Schedule fold first awaits `ctx.s
|
||||
|
||||
## Durable state
|
||||
|
||||
The package owns the strict version-1 `schedule/change` create, delete, and dispatch union. Every create record contains a stable session-local `ScheduleId`, the trimmed prompt, and a four-digit-year RFC 3339 UTC `scheduledAt`. An `after` record also stores `afterSeconds`; an `at` record stores no copy of the submitted offset, local calendar fields, or interpreting zone; an `every` record stores `everySeconds` and its earliest unaccepted target without a separate anchor. Delete and one-shot dispatch carry only the id. Every dispatch adds the shared batch `acceptedAt`; the fold derives its latest due occurrence and first anchor-aligned future target, or terminates all remaining Every records when the shared gate has no four-digit-year admission left.
|
||||
The package owns the strict version-1 `schedule/change` create, delete, and dispatch union. Every create record contains a stable session-local `ScheduleId`, the trimmed prompt, and a four-digit-year RFC 3339 UTC `scheduledAt`. An `after` record also stores `afterSeconds`; an `at` record stores no copy of the submitted offset, local calendar fields, or interpreting zone; an `every` record stores `everySeconds` and its earliest unaccepted target without a separate anchor; a `cron` record stores the canonical restricted expression, canonical IANA `timeZone`, and earliest unaccepted UTC target. Delete and one-shot dispatch carry only the id. Every dispatch adds the shared batch `acceptedAt`, from which the fold derives occurrence and next. Cron dispatch instead freezes `occurrenceAt`, shared `acceptedAt`, and an optional `nextScheduledAt`, so later tzdata cannot reinterpret history. The fold terminates a recurring record with no next target and all remaining recurring records when the shared gate has no four-digit-year admission left.
|
||||
|
||||
Replay rejects unknown versions, extra fields, reused ids, mismatched dispatch shapes, recurring batches less than 300 seconds apart, and 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.
|
||||
|
||||
@@ -28,21 +28,29 @@ The Web Host validates and canonicalizes the browser zone at Session creation an
|
||||
|
||||
Local times inside a daylight-saving gap are rejected. An overlap chooses its first, earlier instant. A successful create retains only the canonical UTC target, and no Schedule path reads the process time zone.
|
||||
|
||||
## Calendar recurrence
|
||||
|
||||
The public cron language has exactly five numeric fields: minute, hour, day of month, month, and day of week. A field is one wildcard, integer, strictly increasing integer list, increasing inclusive range, wildcard step, or range step. Canonicalization removes leading zeros and normalizes spaces; names, macros, seconds, years, Quartz tokens, mixed list/range forms, and simultaneously restricted day-of-month/day-of-week fields are rejected. Sunday is `0` or `7`, but duplicate Sunday semantics are invalid.
|
||||
|
||||
Schedule proves the nominal local interval against the complete 400-year Gregorian cycle, including cross-midnight and cycle-seam neighbors, and rejects any rule that can recur in under five minutes. It canonicalizes the explicit zone through `Intl`; `UTC` and IANA Area/Location names or links are accepted, while local defaults, abbreviations, and numeric offsets are not.
|
||||
|
||||
The private `croner@10.0.1` adapter runs paused without a callback or timer. It supplies hidden seconds=`0` and year=`1-9999`, filters daylight-saving gap normalization, chooses the first instant in an overlap, and strictly advances forward and backward cursors. Because JavaScript constructors remap years 0–99, an owned local-calendar search covers that lower range and its transition before the adapter delegates safe years to Croner. Create chooses the first match strictly after admission. A late wake retains the persisted target as its baseline, selects the latest newer current match at or before the shared `acceptedAt`, and finds the first future match. Replay validates only canonical structure, whole-minute UTC values, and monotonic dispatch relations; it never asks current Croner, ICU, or the frequency proof to re-decide a historical occurrence.
|
||||
|
||||
## 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` and `every_seconds`.
|
||||
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`, `every_seconds`, and `time_zone`.
|
||||
|
||||
One Agent-scoped queue serializes each accepted management transaction and the live owner's due transaction from preflight through any post-append barrier. Direct callers therefore cannot interleave a fold with another Schedule mutation or observe a dispatch before its own barrier. `schedule_create` requires exactly one of `after_seconds`, `at`, or `every_seconds`, validates shape-only failures before entering that queue, then checkpoints, allocates a never-reused id, appends the create, and checkpoints again. An absolute target must be strictly future; a fixed-rate interval must be a safe integer of at least 300 seconds. `schedule_list` returns every active record in create order with `state: "scheduled" | "overdue"` and `deliveryMode: "session-local"`; an overdue recurring record delayed by the shared gate also reports `deliveryNotBefore`. `schedule_delete` rejects an empty or whitespace-padded id before entering the queue and appends only for an active id; an unknown or terminal id returns `{ id, deleted: false, code: "schedule_not_found" }` after its preflight.
|
||||
One Agent-scoped queue serializes each accepted management transaction and the live owner's due transaction from preflight through any post-append barrier. Direct callers therefore cannot interleave a fold with another Schedule mutation or observe a dispatch before its own barrier. `schedule_create` requires exactly one of `after_seconds`, `at`, `every_seconds`, or the `cron` plus `time_zone` pair, validates shape-only failures before entering that queue, then checkpoints, allocates a never-reused id, appends the create, and checkpoints again. An absolute target must be strictly future; a fixed-rate interval and every nominal cron interval must be at least 300 seconds. `schedule_list` returns every active record in create order with `state: "scheduled" | "overdue"` and `deliveryMode: "session-local"`; an overdue recurring record delayed by the shared gate also reports `deliveryNotBefore`. `schedule_delete` rejects an empty or whitespace-padded id before entering the queue 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.
|
||||
|
||||
The closed v1 domain error codes are `invalid_prompt`, `invalid_selector`, `invalid_rule`, `invalid_time_zone`, `timezone_confirmation_required`, `not_future`, `time_out_of_range`, `frequency_too_high`, `corrupt_schedule_log`, `persistence_uncertain`, and `internal_error`. Diagnostics are stable and do not expose backend exceptions. Rendered content is deterministic JSON of the canonical value; generic tool-result policy remains responsible for any model-facing spill behavior.
|
||||
The closed v1 domain error codes are `invalid_prompt`, `invalid_selector`, `invalid_rule`, `invalid_time_zone`, `timezone_confirmation_required`, `not_future`, `time_out_of_range`, `frequency_too_high`, `no_future_occurrence`, `corrupt_schedule_log`, `persistence_uncertain`, and `internal_error`. Diagnostics are stable and do not expose backend exceptions. Rendered content is deterministic JSON of the canonical value; generic tool-result policy remains responsible for any model-facing spill behavior.
|
||||
|
||||
## Delivery lifecycle
|
||||
|
||||
The live owner derives targets and the latest recurring batch from the durable fold. It splits waits longer than the Node timer range and rereads the wall clock after every wake, so a rollback cannot fire early and a forward jump makes the record overdue. Fixed-rate progression remains anchored to the first target: a late wake selects only the latest due occurrence and advances to the first strictly future target instead of replaying the missed backlog.
|
||||
The live owner derives targets and the latest recurring batch from the durable fold. It splits waits longer than the Node timer range and rereads the wall clock after every wake, so a rollback cannot fire early and a forward jump makes the record overdue. Fixed-rate progression remains anchored to the first target; calendar progression uses the persisted target as its history-stable baseline. A late wake selects only each record's latest due occurrence and first future target instead of replaying the missed backlog.
|
||||
|
||||
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()`. One-shots bypass the recurring gate and keep their single-message, id-only dispatch path. While any recurring record is overdue behind a closed gate, the owner wakes at that gate or an earlier one-shot rather than at intervening recurring targets. Recurring batches are at least 300 seconds apart: when the gate opens, one decision sample selects every overdue fixed-rate record in target/create order, constructs the complete JSON batch, queues one `followup()`, and appends an independent `{ id, acceptedAt }` dispatch for each record before releasing the phase. Waking input remains parked until that release, after which the owner checkpoints the batch. 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 dispatches pending for a later ordinary preflight and does not start a private retry timer.
|
||||
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()`. One-shots bypass the recurring gate and keep their single-message, id-only dispatch path. While any recurring record is overdue behind a closed gate, the owner wakes at that gate or an earlier one-shot rather than at intervening recurring targets. Recurring batches are at least 300 seconds apart: when the gate opens, one decision sample selects every overdue Every and Cron record in target/create order, constructs the complete JSON batch, queues one `followup()`, and appends an independent rule-specific dispatch for each record before releasing the phase. Waking input remains parked until that release, after which the owner checkpoints the batch. 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 dispatches pending for a later ordinary preflight and does not start a private retry timer.
|
||||
|
||||
Agent or plugin disposal cancels timers, stops new work, and awaits in-flight preflights and idle waits. It never appends delete records during teardown.
|
||||
|
||||
@@ -88,7 +96,7 @@ reminders_json: [{"schedule_id":<id>,"occurrence_at":<UTC RFC 3339>,"reminder_pr
|
||||
|
||||
#### Token effect
|
||||
|
||||
Each dispatched `after` or `at` reminder adds one data-dependent user-role message. A recurring batch adds one message regardless of how many fixed-rate records it contains. The message remains in session history and therefore contributes tokens to later requests until ordinary compaction removes or replaces that history.
|
||||
Each dispatched `after` or `at` reminder adds one data-dependent user-role message. A recurring batch adds one message regardless of how many Every or Cron records it contains. The message remains in session history and therefore contributes tokens to later requests until ordinary compaction removes or replaces that history.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -98,7 +106,7 @@ The reminder appends after existing history and preserves its reusable prefix. I
|
||||
|
||||
- **Session-local delivery only** — a reminder runs on time only while its original session is live; a cold session receives no external notification and processes an overdue record only after resume.
|
||||
- **Activity-driven retry** — a rejected due preflight or contained framing/enqueue failure leaves the overdue record active but starts no private retry timer; the owner retries after later Agent activity reaches idle or a successful Schedule management preflight asks it to recompute.
|
||||
- **No calendar recurrence yet** — version 1 supports `after`, `at`, and fixed-rate `every_seconds` but rejects `cron`; calendar rules require explicit grammar, IANA/DST evaluation, and history-stable transition semantics.
|
||||
- **Restricted calendar language** — cron accepts only the documented numeric five-field subset with one unrestricted day field and an explicit IANA zone; it does not expose names, macros, seconds, years, Quartz operators, or user-selectable DST policy.
|
||||
- **Immutable Session zone** — a new Schedule Web Session captures one default browser zone and has no zone editor. Older headerless Sessions remain `unavailable`, and a mismatched or ambiguous request must name `time_zone` explicitly.
|
||||
- **Narrow crash duplicate window** — a crash after synchronous followup admission but before the dispatch checkpoint can repeat the reminder after recovery; the package does not claim model completion, user acknowledgement, or exactly-once external effects.
|
||||
- **Load-order boundary** — the plugin does not scan or adopt agents that were already live when it loaded.
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
`dsh-tool-schedule` 为未来创建的 live 根 agent(智能体)提供 3 个会话范围内的工具,用于管理持久的一次性提醒与固定频率提醒。版本 1 接受正的安全整数 `after_seconds` 延时、绝对 `at` 目标,以及至少为 300 秒的 `every_seconds` 间隔。会话事件日志拥有提醒状态;timer、工具值与模型 `followup` 都是该日志的可丢弃投影。
|
||||
`dsh-tool-schedule` 为未来创建的 live 根 agent(智能体)提供 3 个会话范围内的工具,用于管理持久的一次性、固定频率与日历提醒。版本 1 接受正的安全整数 `after_seconds` 延时、绝对 `at` 目标、至少为 300 秒的 `every_seconds` 间隔,以及与显式 IANA `time_zone` 配对的受限五字段 `cron`。会话事件日志拥有提醒状态;timer、工具值、日历求值器与模型 `followup` 都是该日志的可丢弃投影。
|
||||
|
||||
## 组合
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
## 持久状态
|
||||
|
||||
此包(package)拥有严格的版本 1 `schedule/change` create、delete 与 dispatch 联合。每条 create 记录都包含稳定的会话本地 `ScheduleId`、已 trim 的 prompt,以及使用四位年份的 RFC 3339 UTC `scheduledAt`。`after` 记录还会存储 `afterSeconds`;`at` 记录不会保留所提交的偏移量、本地日历字段或解释该值时所用的时区;`every` 记录会存储 `everySeconds` 和最早尚未接受的目标,而不另存锚点。delete 与一次性 dispatch 只携带 id。Every dispatch 会带上共享 batch 的 `acceptedAt`;折叠过程会派生该记录最近一次到期的 occurrence 和第一个与锚点对齐的未来目标,或在共享门控不再有年份为四位数的准入时点时终结所有剩余的 Every record。
|
||||
此包(package)拥有严格的版本 1 `schedule/change` create、delete 与 dispatch 联合。每条 create 记录都包含稳定的会话本地 `ScheduleId`、已 trim 的 prompt,以及使用四位年份的 RFC 3339 UTC `scheduledAt`。`after` 记录还会存储 `afterSeconds`;`at` 记录不会保留所提交的偏移量、本地日历字段或解释该值时所用的时区;`every` 记录会存储 `everySeconds` 和最早尚未接受的目标,而不另存锚点;`cron` 记录会存储规范化后的受限表达式、规范化后的 IANA `timeZone` 与最早尚未接受的 UTC 目标。delete 与一次性 dispatch 只携带 id。Every dispatch 会带上共享 batch 的 `acceptedAt`;折叠过程据此派生 occurrence 与下一个目标。Cron dispatch 则会固化 `occurrenceAt`、共享的 `acceptedAt` 与可选的 `nextScheduledAt`,从而使后续 tzdata 无法重新解释 history。折叠过程会终结没有下一个目标的周期性记录;共享门控不再有年份为四位数的准入时点时,还会终结所有剩余的周期性记录。
|
||||
|
||||
回放会拒绝未知版本、额外字段、重复使用的 id、不匹配的 dispatch 形状、间隔不足 300 秒的周期性 batch,以及针对非活动记录的转换。普通会话折叠完整日志。fork 只折叠 `session.events.slice(session.header.seedLength ?? 0)`,因此不会继承父会话的提醒。此包的 `./invariant` 配套项会对现有日志和候选事件应用相同策略。
|
||||
|
||||
@@ -28,21 +28,29 @@ Web Host 会在创建 Session 时以及每次提交提示词时校验并规范
|
||||
|
||||
落在夏令时空档内的本地时间会被拒绝。遇到重叠时会选择第一次出现的较早时刻。创建成功后只保留规范化后的 UTC 目标,Schedule 的任何路径都不会读取进程时区。
|
||||
|
||||
## 日历周期
|
||||
|
||||
公开 cron 语言恰好包含 5 个数值字段:分钟、小时、月中日期、月份和星期。每个字段只能是一个 wildcard、整数、严格递增的整数列表、递增闭区间、wildcard step 或区间 step。规范化会移除前导零并统一空格;名称、macro、秒、年份、Quartz token、混合使用列表与区间的形式,以及同时受限的月中日期和星期字段都会被拒绝。星期日可写作 `0` 或 `7`,但重复的星期日语义无效。
|
||||
|
||||
Schedule 会针对完整的 400 年 Gregorian 历法周期证明名义本地间隔,其中包括跨午夜相邻时点与周期首尾衔接处的相邻时点;任何可能以不足 5 分钟的间隔重复发生的规则都会被拒绝。它通过 `Intl` 规范化显式时区;接受 `UTC`、IANA Area/Location 名称或链接,不接受本地默认值、缩写或数值偏移。
|
||||
|
||||
私有 `croner@10.0.1` 适配器以 paused 状态运行,不创建 callback 或 timer。它补入隐藏的 seconds=`0` 与 year=`1-9999`,过滤由夏令时空档规范化产生的候选值,在重叠时段选择第一个时刻,并严格推进正向与反向 cursor。由于 JavaScript 构造器会重映射 0–99 年,Schedule 自有的本地日历搜索会覆盖这一低年份范围及其向安全年份的过渡;只有进入安全年份后,适配器才会将搜索委托给 Croner。create 选择严格晚于 admission 的第一个 match。延迟唤醒以持久目标为 baseline,选择比 baseline 更新且不晚于共享 `acceptedAt` 的最新 current match,并找到第一个未来 match。回放只校验规范化结构、整分钟的 UTC 值与单调 dispatch 关系;绝不会让当前 Croner、ICU 或频率证明重新裁定历史 occurrence。
|
||||
|
||||
## 管理工具
|
||||
|
||||
生成的[工具目录](../../../docs/tool-catalog.md)负责 `schedule_create`、`schedule_list` 和 `schedule_delete` 的参数与输出 schema。虽然模型输入使用 `after_seconds` 和 `every_seconds`,但其规范值中的记录字段使用 camelCase。
|
||||
生成的[工具目录](../../../docs/tool-catalog.md)负责 `schedule_create`、`schedule_list` 和 `schedule_delete` 的参数与输出 schema。虽然模型输入使用 `after_seconds`、`every_seconds` 和 `time_zone`,但其规范值中的记录字段使用 camelCase。
|
||||
|
||||
一条 Agent-scoped 队列会将每项已接纳的管理事务与 live owner 的到期事务从 preflight 到任何 post-append barrier 全程串行化。因此,直接调用方无法让一次 fold 与另一项 Schedule 变更交错,也无法在自身的 barrier 前观察到 dispatch。`schedule_create` 要求 `after_seconds`、`at` 与 `every_seconds` 中有且只有一项;它会在进入该队列前验证只依赖输入形状的失败,随后执行检查点、分配永不复用的 id、追加 create,再次执行检查点。绝对目标必须严格位于未来;固定频率间隔的秒数必须是至少为 300 的安全整数。`schedule_list` 按创建顺序返回所有活动记录,其中包含 `state: "scheduled" | "overdue"` 与 `deliveryMode: "session-local"`;因共享门控而延迟的 overdue 周期性记录还会报告 `deliveryNotBefore`。`schedule_delete` 会在进入该队列前拒绝空 id 或前后带空白的 id,并只为活动 id 追加事件;未知或已终结的 id 会在 preflight(预检)后返回 `{ id, deleted: false, code: "schedule_not_found" }`。
|
||||
一条 Agent-scoped 队列会将每项已接纳的管理事务与 live owner 的到期事务从 preflight 到任何 post-append barrier 全程串行化。因此,直接调用方无法让一次 fold 与另一项 Schedule 变更交错,也无法在自身的 barrier 前观察到 dispatch。`schedule_create` 要求恰好选择以下一种 selector:`after_seconds`、`at`、`every_seconds`,或成对提供的 `cron` 与 `time_zone`;它会在进入该队列前验证只依赖输入形状的失败,随后执行检查点、分配永不复用的 id、追加 create,再次执行检查点。绝对目标必须严格位于未来;固定频率间隔与每个 cron 名义间隔都必须至少为 300 秒。`schedule_list` 按创建顺序返回所有活动记录,其中包含 `state: "scheduled" | "overdue"` 与 `deliveryMode: "session-local"`;因共享门控而延迟的 overdue 周期性记录还会报告 `deliveryNotBefore`。`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。
|
||||
|
||||
版本 1 的封闭领域错误代码包括 `invalid_prompt`、`invalid_selector`、`invalid_rule`、`invalid_time_zone`、`timezone_confirmation_required`、`not_future`、`time_out_of_range`、`frequency_too_high`、`corrupt_schedule_log`、`persistence_uncertain` 和 `internal_error`。诊断文本保持稳定,不会暴露后端异常。渲染内容是规范值的确定性 JSON;通用工具结果策略仍负责模型可见内容的 spill 行为。
|
||||
版本 1 的封闭领域错误代码包括 `invalid_prompt`、`invalid_selector`、`invalid_rule`、`invalid_time_zone`、`timezone_confirmation_required`、`not_future`、`time_out_of_range`、`frequency_too_high`、`no_future_occurrence`、`corrupt_schedule_log`、`persistence_uncertain` 和 `internal_error`。诊断文本保持稳定,不会暴露后端异常。渲染内容是规范值的确定性 JSON;通用工具结果策略仍负责模型可见内容的 spill 行为。
|
||||
|
||||
## 交付生命周期
|
||||
|
||||
live owner 从持久折叠结果派生各个目标与最近一次周期性 batch。它会拆分超过 Node timer 范围的等待,并在每次唤醒后重新读取墙钟,因此时钟回拨不会提前触发,时钟前跳则会使记录进入 overdue 状态。固定频率推进始终锚定首个目标:延迟唤醒只选择最近一次到期的 occurrence,并推进至第一个严格位于未来的目标,而不会回放错过期间积压的 occurrence。
|
||||
live owner 从持久折叠结果派生各个目标与最近一次周期性 batch。它会拆分超过 Node timer 范围的等待,并在每次唤醒后重新读取墙钟,因此时钟回拨不会提前触发,时钟前跳则会使记录进入 overdue 状态。固定频率推进始终锚定首个目标;日历推进则以持久目标作为在 history 中保持稳定的 baseline。延迟唤醒只为每条记录选择最近一次到期的 occurrence 与第一个未来目标,而不会回放错过期间积压的 occurrence。
|
||||
|
||||
overdue 提醒首先为持久化建立检查点。如果 agent 已被某个轮次或另一项 maintenance task 占用,`runMaintenance()` 会拒绝对 idle phase 的认领;记录会保持活动,owner 会在 `whenIdle()` 后重试。一次性提醒会绕过周期性门控,仍走单条消息、只含 id 的 dispatch 路径。只要有周期性记录因门控关闭而处于 overdue,owner 就会在该门控时点或更早的一次性提醒到期时唤醒,而不会在其间的周期性目标处唤醒。周期性 batch 之间至少间隔 300 秒:门控开放时,owner 会采样一次决策时间,按目标/create 顺序选择所有 overdue 固定频率记录,构造完整 JSON batch,同步将一个 `followup()` 入队,并在释放 phase 前为每条记录追加独立的 `{ id, acceptedAt }` dispatch。触发唤醒的 input 会保持 parked,直到该 phase 释放;随后 owner 为整个 batch 建立检查点。framing 构造或同步 followup 失败不会写入 dispatch。追加失败会使该 owner 进入故障状态,因为消息可能已经入队;barrier 拒绝会把这些 dispatch 留给后续普通 preflight 处理,而不会启动私有重试 timer。
|
||||
overdue 提醒首先为持久化建立检查点。如果 agent 已被某个轮次或另一项 maintenance task 占用,`runMaintenance()` 会拒绝对 idle phase 的认领;记录会保持活动,owner 会在 `whenIdle()` 后重试。一次性提醒会绕过周期性门控,仍走单条消息、只含 id 的 dispatch 路径。只要有周期性记录因门控关闭而处于 overdue,owner 就会在该门控时点或更早的一次性提醒到期时唤醒,而不会在其间的周期性目标处唤醒。周期性 batch 之间至少间隔 300 秒:门控开放时,owner 会采样一次决策时间,按目标/create 顺序选择所有 overdue Every 与 Cron record,构造完整 JSON batch,同步将一个 `followup()` 入队,并在释放 phase 前为每条记录追加与其规则对应的独立 dispatch。触发唤醒的 input 会保持 parked,直到该 phase 释放;随后 owner 为整个 batch 建立检查点。framing 构造或同步 followup 失败不会写入 dispatch。追加失败会使该 owner 进入故障状态,因为消息可能已经入队;barrier 拒绝会把这些 dispatch 留给后续普通 preflight 处理,而不会启动私有重试 timer。
|
||||
|
||||
agent 或插件执行 dispose(资源释放)时,会取消 timer、停止新工作,并等待进行中的 preflight 和 idle wait。清理期间绝不会追加 delete 记录。
|
||||
|
||||
@@ -88,7 +96,7 @@ reminders_json: [{"schedule_id":<id>,"occurrence_at":<UTC RFC 3339>,"reminder_pr
|
||||
|
||||
#### Token 影响
|
||||
|
||||
每条已 dispatch 的 `after` 或 `at` 提醒会增加一条与数据相关的用户角色消息。每个周期性 batch 无论包含多少条固定频率记录,都只会增加一条消息。该消息保留在会话历史中,因此会持续为后续请求贡献 token,直到普通压缩(compaction)移除或替换这段历史。
|
||||
每条已 dispatch 的 `after` 或 `at` 提醒会增加一条与数据相关的用户角色消息。每个周期性 batch 无论包含多少条 Every 或 Cron record,都只会增加一条消息。该消息保留在会话历史中,因此会持续为后续请求贡献 token,直到普通压缩(compaction)移除或替换这段历史。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
@@ -98,7 +106,7 @@ reminders_json: [{"schedule_id":<id>,"occurrence_at":<UTC RFC 3339>,"reminder_pr
|
||||
|
||||
- **仅限会话本地交付**:提醒只有在原会话 live 时才能准时运行;cold 会话不会收到外部通知,只有恢复后才会处理 overdue 记录。
|
||||
- **活动驱动的重试**:到期 preflight 被拒绝或 framing/入队失败被收容后,overdue 记录仍保持活动,但不会启动私有重试 timer;后续 agent 活动进入 idle,或成功的 Schedule 管理 preflight 要求 owner 重新计算后,owner 会重试。
|
||||
- **尚不支持日历周期**:版本 1 支持 `after`、`at` 与固定频率的 `every_seconds`,但拒绝 `cron`;日历规则需要明确的语法、IANA/DST 求值,以及在 history 中保持稳定的转换语义。
|
||||
- **受限的日历语言**:cron 只接受本文所述的数值五字段子集,其中一个日期字段必须不受限,并要求显式 IANA 时区;它不开放名称、macro、秒、年份、Quartz operator 或用户可选的 DST 策略。
|
||||
- **Session 时区不可变**:新的 Schedule Web Session 会记录一个默认浏览器时区,且没有时区编辑器。旧有的无 header Session 仍为 `unavailable`,不匹配或有歧义的请求必须显式指定 `time_zone`。
|
||||
- **存在狭窄的崩溃重复窗口**:同步 `followup` 获得准入后、dispatch 检查点完成前发生崩溃,可能使提醒在恢复后重复;此包不承诺模型完成、用户确认或外部副作用恰好一次。
|
||||
- **加载顺序边界**:插件不会扫描或接管加载时已经 live 的 agent。
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tool-schedule",
|
||||
"description": "Agent-scoped durable one-shot and fixed-rate reminders over the session event log",
|
||||
"description": "Agent-scoped durable one-shot, fixed-rate, and calendar reminders over the session event log",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -48,5 +48,8 @@
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"croner": "10.0.1"
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,16 @@
|
||||
*/
|
||||
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { Cron } from 'croner'
|
||||
import type {
|
||||
AfterScheduleRecord,
|
||||
AtInput,
|
||||
AtScheduleRecord,
|
||||
CronScheduleRecord,
|
||||
EveryScheduleRecord,
|
||||
LocalAtInput,
|
||||
OneShotScheduleRecord,
|
||||
RecurringScheduleRecord,
|
||||
ScheduleChange,
|
||||
ScheduleId as ScheduleIdType,
|
||||
ScheduleRecord,
|
||||
@@ -63,7 +66,7 @@ export class ScheduleLogError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** Error from a model-supplied after rule that cannot become a record. */
|
||||
/** Error from a model-supplied Schedule rule that cannot become a record. */
|
||||
export class ScheduleInputError extends Error {
|
||||
/** Stable public Schedule input code. */
|
||||
readonly code:
|
||||
@@ -74,6 +77,7 @@ export class ScheduleInputError extends Error {
|
||||
| 'not_future'
|
||||
| 'time_out_of_range'
|
||||
| 'frequency_too_high'
|
||||
| 'no_future_occurrence'
|
||||
|
||||
/**
|
||||
* Construct a stable input failure.
|
||||
@@ -89,7 +93,8 @@ export class ScheduleInputError extends Error {
|
||||
| 'timezone_confirmation_required'
|
||||
| 'not_future'
|
||||
| 'time_out_of_range'
|
||||
| 'frequency_too_high',
|
||||
| 'frequency_too_high'
|
||||
| 'no_future_occurrence',
|
||||
message: string,
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
@@ -117,6 +122,14 @@ export interface EveryOccurrence {
|
||||
readonly nextScheduledAt?: string
|
||||
}
|
||||
|
||||
/** One calendar decision frozen by a durable Cron dispatch. */
|
||||
export interface CronOccurrence {
|
||||
/** Latest accepted occurrence, retaining a persisted baseline across tzdata changes. */
|
||||
readonly occurrenceAt: string
|
||||
/** First current-environment target strictly after the batch, or exhaustion. */
|
||||
readonly nextScheduledAt?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Brand a raw session-local id without changing its runtime value.
|
||||
* @param value - Raw session-local id.
|
||||
@@ -396,6 +409,454 @@ function resolveLocalInstant(parts: CalendarParts, timeZone: string): number {
|
||||
return first
|
||||
}
|
||||
|
||||
interface CronFieldSpec {
|
||||
readonly name: string
|
||||
readonly min: number
|
||||
readonly max: number
|
||||
readonly cardinality?: number
|
||||
readonly sundayAlias?: boolean
|
||||
}
|
||||
|
||||
interface ParsedCronField {
|
||||
readonly canonical: string
|
||||
readonly values: readonly number[]
|
||||
}
|
||||
|
||||
interface ParsedCronRule {
|
||||
readonly canonical: string
|
||||
readonly hasMatchingDate: boolean
|
||||
readonly minute: ParsedCronField
|
||||
readonly hour: ParsedCronField
|
||||
readonly dayOfMonth: ParsedCronField
|
||||
readonly month: ParsedCronField
|
||||
readonly dayOfWeek: ParsedCronField
|
||||
}
|
||||
|
||||
type CronRuleFields = Omit<ParsedCronRule, 'hasMatchingDate'>
|
||||
|
||||
const CRON_FIELD_SPECS = [
|
||||
{ name: 'minute', min: 0, max: 59 },
|
||||
{ name: 'hour', min: 0, max: 23 },
|
||||
{ name: 'day-of-month', min: 1, max: 31 },
|
||||
{ name: 'month', min: 1, max: 12 },
|
||||
{ name: 'day-of-week', min: 0, max: 7, cardinality: 7, sundayAlias: true },
|
||||
] as const satisfies readonly CronFieldSpec[]
|
||||
|
||||
const CRON_INTEGER = /^\d+$/
|
||||
const CRON_LIST = /^\d+(?:,\d+)+$/
|
||||
const CRON_RANGE = /^(?<lower>\d+)-(?<upper>\d+)$/
|
||||
const CRON_WILDCARD_STEP = /^\*\/(?<step>\d+)$/
|
||||
const CRON_RANGE_STEP = /^(?<lower>\d+)-(?<upper>\d+)\/(?<step>\d+)$/
|
||||
|
||||
/** Throw the stable public grammar failure for one cron field. */
|
||||
function invalidCronField(spec: CronFieldSpec): never {
|
||||
throw new ScheduleInputError('invalid_rule', `cron ${spec.name} has an unsupported value.`)
|
||||
}
|
||||
|
||||
/** Parse one bounded decimal cron integer and return its canonical spelling. */
|
||||
function cronInteger(raw: string, spec: CronFieldSpec): { value: number; canonical: string } {
|
||||
if (!CRON_INTEGER.test(raw)) invalidCronField(spec)
|
||||
const value = Number(raw)
|
||||
if (!Number.isSafeInteger(value) || value < spec.min || value > spec.max) invalidCronField(spec)
|
||||
return { value, canonical: String(value) }
|
||||
}
|
||||
|
||||
/** Read one named group from a fixed successful cron-field expression. */
|
||||
function cronGroup(
|
||||
groups: Record<string, string | undefined>,
|
||||
name: string,
|
||||
spec: CronFieldSpec,
|
||||
): string {
|
||||
const value = groups[name]
|
||||
/* v8 ignore next -- each caller requests a mandatory group from its matched expression. */
|
||||
if (value === undefined) invalidCronField(spec)
|
||||
return value
|
||||
}
|
||||
|
||||
/** Expand one inclusive integer sequence. */
|
||||
function cronRange(lower: number, upper: number, step = 1): number[] {
|
||||
const values: number[] = []
|
||||
for (let value = lower; value <= upper; value += step) values.push(value)
|
||||
return values
|
||||
}
|
||||
|
||||
/** Apply Sunday aliasing and reject duplicate semantics outside a wildcard. */
|
||||
function cronValues(values: readonly number[], spec: CronFieldSpec, wildcard: boolean): readonly number[] {
|
||||
const semantic = values.map(value => spec.sundayAlias === true && value === 7 ? 0 : value)
|
||||
const unique = new Set<number>()
|
||||
for (const value of semantic) {
|
||||
if (!wildcard && unique.has(value)) invalidCronField(spec)
|
||||
unique.add(value)
|
||||
}
|
||||
return Object.freeze([...unique].sort((left, right) => left - right))
|
||||
}
|
||||
|
||||
/** Parse and canonicalize one complete cron field. */
|
||||
function parseCronField(raw: string, spec: CronFieldSpec): ParsedCronField {
|
||||
if (raw === '*') {
|
||||
return Object.freeze({
|
||||
canonical: '*',
|
||||
values: cronValues(cronRange(spec.min, spec.max), spec, true),
|
||||
})
|
||||
}
|
||||
|
||||
const wildcardStep = CRON_WILDCARD_STEP.exec(raw)?.groups
|
||||
if (wildcardStep !== undefined) {
|
||||
const step = cronInteger(cronGroup(wildcardStep, 'step', spec), {
|
||||
...spec,
|
||||
min: 1,
|
||||
max: spec.cardinality ?? spec.max - spec.min + 1,
|
||||
})
|
||||
const canonical = step.value === 1 ? '*' : `*/${step.canonical}`
|
||||
return Object.freeze({
|
||||
canonical,
|
||||
values: cronValues(cronRange(spec.min, spec.max, step.value), spec, canonical === '*'),
|
||||
})
|
||||
}
|
||||
|
||||
const rangeStep = CRON_RANGE_STEP.exec(raw)?.groups
|
||||
if (rangeStep !== undefined) {
|
||||
const lower = cronInteger(cronGroup(rangeStep, 'lower', spec), spec)
|
||||
const upper = cronInteger(cronGroup(rangeStep, 'upper', spec), spec)
|
||||
const step = cronInteger(cronGroup(rangeStep, 'step', spec), {
|
||||
...spec,
|
||||
min: 1,
|
||||
max: spec.cardinality ?? spec.max - spec.min + 1,
|
||||
})
|
||||
if (lower.value >= upper.value) invalidCronField(spec)
|
||||
return Object.freeze({
|
||||
canonical: `${lower.canonical}-${upper.canonical}/${step.canonical}`,
|
||||
values: cronValues(cronRange(lower.value, upper.value, step.value), spec, false),
|
||||
})
|
||||
}
|
||||
|
||||
const range = CRON_RANGE.exec(raw)?.groups
|
||||
if (range !== undefined) {
|
||||
const lower = cronInteger(cronGroup(range, 'lower', spec), spec)
|
||||
const upper = cronInteger(cronGroup(range, 'upper', spec), spec)
|
||||
if (lower.value >= upper.value) invalidCronField(spec)
|
||||
return Object.freeze({
|
||||
canonical: `${lower.canonical}-${upper.canonical}`,
|
||||
values: cronValues(cronRange(lower.value, upper.value), spec, false),
|
||||
})
|
||||
}
|
||||
|
||||
if (CRON_LIST.test(raw)) {
|
||||
const entries = raw.split(',').map(entry => cronInteger(entry, spec))
|
||||
let previous = Number.NEGATIVE_INFINITY
|
||||
for (const entry of entries) {
|
||||
if (previous >= entry.value) invalidCronField(spec)
|
||||
previous = entry.value
|
||||
}
|
||||
return Object.freeze({
|
||||
canonical: entries.map(entry => entry.canonical).join(','),
|
||||
values: cronValues(entries.map(entry => entry.value), spec, false),
|
||||
})
|
||||
}
|
||||
|
||||
const entry = cronInteger(raw, spec)
|
||||
return Object.freeze({ canonical: entry.canonical, values: cronValues([entry.value], spec, false) })
|
||||
}
|
||||
|
||||
/** Whether one year follows Gregorian leap-year rules. */
|
||||
function isGregorianLeapYear(year: number): boolean {
|
||||
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0)
|
||||
}
|
||||
|
||||
/** Whether a parsed rule matches one local calendar date. */
|
||||
function cronMatchesDate(rule: CronRuleFields, month: number, day: number, dayOfWeek: number): boolean {
|
||||
if (!rule.month.values.includes(month)) return false
|
||||
return rule.dayOfMonth.canonical === '*'
|
||||
? rule.dayOfWeek.values.includes(dayOfWeek)
|
||||
: rule.dayOfMonth.values.includes(day)
|
||||
}
|
||||
|
||||
/** Prove whether the 400-year Gregorian cycle has any or adjacent matching dates. */
|
||||
function cronDatePattern(rule: CronRuleFields): { readonly any: boolean; readonly adjacent: boolean } {
|
||||
let dayOfWeek = 6 // 2000-01-01 was Saturday; the Gregorian cycle repeats every 400 years.
|
||||
let previous = false
|
||||
let first = false
|
||||
let last = false
|
||||
let any = false
|
||||
let adjacent = false
|
||||
for (let year = 2000; year < 2400; year += 1) {
|
||||
const monthLengths = [31, isGregorianLeapYear(year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
||||
for (const [monthIndex, days] of monthLengths.entries()) {
|
||||
const month = monthIndex + 1
|
||||
for (let day = 1; day <= days; day += 1) {
|
||||
const matches = cronMatchesDate(rule, month, day, dayOfWeek)
|
||||
if (year === 2000 && month === 1 && day === 1) first = matches
|
||||
adjacent ||= previous && matches
|
||||
any ||= matches
|
||||
previous = matches
|
||||
last = matches
|
||||
dayOfWeek = (dayOfWeek + 1) % 7
|
||||
}
|
||||
}
|
||||
}
|
||||
return { any, adjacent: (last && first) || adjacent }
|
||||
}
|
||||
|
||||
/** Enforce the fixed five-minute nominal local-occurrence interval. */
|
||||
function validateCronFrequency(
|
||||
rule: CronRuleFields,
|
||||
dates: { readonly any: boolean; readonly adjacent: boolean },
|
||||
): void {
|
||||
if (!dates.any) return
|
||||
const times = rule.hour.values.flatMap(hour => rule.minute.values.map(minute => hour * 60 + minute))
|
||||
.sort((left, right) => left - right)
|
||||
let previous: number | undefined
|
||||
for (const time of times) {
|
||||
if (previous !== undefined && time - previous < 5) {
|
||||
throw new ScheduleInputError('frequency_too_high', 'cron occurrences must be at least five minutes apart.')
|
||||
}
|
||||
previous = time
|
||||
}
|
||||
const first = Math.min(...times)
|
||||
const last = Math.max(...times)
|
||||
if (1_440 - last + first < 5 && dates.adjacent) {
|
||||
throw new ScheduleInputError('frequency_too_high', 'cron occurrences must be at least five minutes apart.')
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse the restricted five-field language and prove its nominal frequency. */
|
||||
function parseCronRule(value: string, proveFrequency = true): ParsedCronRule {
|
||||
if (value.length === 0 || value.trim() !== value) {
|
||||
throw new ScheduleInputError('invalid_rule', 'cron must be a non-empty five-field expression without surrounding whitespace.')
|
||||
}
|
||||
const parts = value.split(/[\t\n\v\f\r ]+/u)
|
||||
if (parts.length !== CRON_FIELD_SPECS.length) {
|
||||
throw new ScheduleInputError('invalid_rule', 'cron must contain exactly five fields.')
|
||||
}
|
||||
const [minuteRaw, hourRaw, dayOfMonthRaw, monthRaw, dayOfWeekRaw] = parts as [
|
||||
string, string, string, string, string,
|
||||
]
|
||||
const minute = parseCronField(minuteRaw, CRON_FIELD_SPECS[0])
|
||||
const hour = parseCronField(hourRaw, CRON_FIELD_SPECS[1])
|
||||
const dayOfMonth = parseCronField(dayOfMonthRaw, CRON_FIELD_SPECS[2])
|
||||
const month = parseCronField(monthRaw, CRON_FIELD_SPECS[3])
|
||||
const dayOfWeek = parseCronField(dayOfWeekRaw, CRON_FIELD_SPECS[4])
|
||||
if (dayOfMonth.canonical !== '*' && dayOfWeek.canonical !== '*') {
|
||||
throw new ScheduleInputError('invalid_rule', 'cron requires day-of-month or day-of-week to be *.')
|
||||
}
|
||||
const partial = Object.freeze({
|
||||
canonical: [minute, hour, dayOfMonth, month, dayOfWeek].map(field => field.canonical).join(' '),
|
||||
minute,
|
||||
hour,
|
||||
dayOfMonth,
|
||||
month,
|
||||
dayOfWeek,
|
||||
})
|
||||
if (!proveFrequency) return Object.freeze({ ...partial, hasMatchingDate: true })
|
||||
const dates = cronDatePattern(partial)
|
||||
validateCronFrequency(partial, dates)
|
||||
return Object.freeze({ ...partial, hasMatchingDate: dates.any })
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and canonicalize the public five-field cron language.
|
||||
* @param value - Raw model-supplied cron expression.
|
||||
* @returns Canonical five-field text after the complete frequency proof.
|
||||
*/
|
||||
export function canonicalizeCronExpression(value: string): string {
|
||||
return parseCronRule(value).canonical
|
||||
}
|
||||
|
||||
/** Construct one paused Croner evaluator with the private seconds/year fields. */
|
||||
function cronEvaluator(rule: ParsedCronRule, timeZone: string): Cron {
|
||||
return new Cron(`0 ${rule.canonical} 1-9999`, {
|
||||
paused: true,
|
||||
timezone: timeZone,
|
||||
mode: '7-part',
|
||||
domAndDow: true,
|
||||
legacyMode: false,
|
||||
})
|
||||
}
|
||||
|
||||
/** Formatter used to distinguish gaps and the first instant in an overlap. */
|
||||
function cronLocalFormatter(timeZone: string): Intl.DateTimeFormat {
|
||||
return new Intl.DateTimeFormat('en-US-u-ca-iso8601-nu-latn', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
fractionalSecondDigits: 3,
|
||||
hourCycle: 'h23',
|
||||
timeZoneName: 'longOffset',
|
||||
})
|
||||
}
|
||||
|
||||
/** Whether a Croner candidate is a real whole-minute match and the first overlap instant. */
|
||||
function isCanonicalCronCandidate(
|
||||
evaluator: Cron,
|
||||
formatter: Intl.DateTimeFormat,
|
||||
timeZone: string,
|
||||
epoch: number,
|
||||
): boolean {
|
||||
if (!Number.isSafeInteger(epoch)
|
||||
|| epoch < MIN_FOUR_DIGIT_YEAR_MS
|
||||
|| epoch > MAX_FOUR_DIGIT_YEAR_MS
|
||||
|| epoch % 60_000 !== 0
|
||||
|| !evaluator.match(new Date(epoch))) return false
|
||||
return resolveLocalInstant(localProjection(formatter, epoch), timeZone) === epoch
|
||||
}
|
||||
|
||||
const CRONER_LOW_YEAR_CUTOFF = 108
|
||||
const CRONER_LOW_YEAR_SEARCH_END = 109
|
||||
const MAX_CRON_CURSOR_CORRECTIONS = 1_440
|
||||
|
||||
/** Search owned local-calendar candidates without JavaScript's legacy 0..99 year remapping. */
|
||||
function ownedCronInstant(
|
||||
rule: ParsedCronRule,
|
||||
timeZone: string,
|
||||
boundary: number,
|
||||
direction: 1 | -1,
|
||||
minYear: number,
|
||||
maxYear: number,
|
||||
lowerExclusive = MIN_FOUR_DIGIT_YEAR_MS - 1,
|
||||
): number | undefined {
|
||||
const utcYear = new Date(boundary).getUTCFullYear()
|
||||
const startYear = direction === 1
|
||||
? Math.max(minYear, utcYear - 1)
|
||||
: Math.min(maxYear, utcYear + 1)
|
||||
const months = direction === 1 ? rule.month.values : [...rule.month.values].reverse()
|
||||
const times = rule.hour.values.flatMap(hour => rule.minute.values.map(minute => ({ hour, minute })))
|
||||
if (direction === -1) times.reverse()
|
||||
for (
|
||||
let year = startYear;
|
||||
direction === 1 ? year <= maxYear : year >= minYear;
|
||||
year += direction
|
||||
) {
|
||||
const monthLengths = [31, isGregorianLeapYear(year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
||||
for (const month of months) {
|
||||
const daysInMonth = monthLengths[month - 1]
|
||||
/* v8 ignore next -- parsed month values are restricted to 1..12. */
|
||||
if (daysInMonth === undefined) continue
|
||||
for (
|
||||
let day = direction === 1 ? 1 : daysInMonth;
|
||||
direction === 1 ? day <= daysInMonth : day >= 1;
|
||||
day += direction
|
||||
) {
|
||||
const midnight = calendarEpoch({ year, month, day, hour: 0, minute: 0, second: 0, millisecond: 0 })
|
||||
if (!cronMatchesDate(rule, month, day, new Date(midnight).getUTCDay())) continue
|
||||
for (const time of times) {
|
||||
let candidate: number
|
||||
try {
|
||||
candidate = resolveLocalInstant({
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
hour: time.hour,
|
||||
minute: time.minute,
|
||||
second: 0,
|
||||
millisecond: 0,
|
||||
}, timeZone)
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next -- canonical zones make non-Schedule failures unreachable here. */
|
||||
if (!(error instanceof ScheduleInputError)) throw error
|
||||
continue
|
||||
}
|
||||
if (candidate % 60_000 !== 0) continue
|
||||
if (direction === 1) {
|
||||
if (candidate > boundary) return candidate
|
||||
} else {
|
||||
if (candidate <= lowerExclusive) return undefined
|
||||
if (candidate <= boundary) return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Find the first valid calendar occurrence strictly after one instant. */
|
||||
function nextCronInstant(rule: ParsedCronRule, timeZone: string, after: number): number | undefined {
|
||||
if (!rule.hasMatchingDate) return undefined
|
||||
let cursor = after
|
||||
if (new Date(after).getUTCFullYear() <= CRONER_LOW_YEAR_CUTOFF) {
|
||||
const lower = ownedCronInstant(rule, timeZone, after, 1, 1, CRONER_LOW_YEAR_SEARCH_END)
|
||||
if (lower !== undefined) return lower
|
||||
cursor = Math.max(cursor, Date.parse('0109-12-31T23:59:59.999Z'))
|
||||
}
|
||||
const evaluator = cronEvaluator(rule, timeZone)
|
||||
const formatter = cronLocalFormatter(timeZone)
|
||||
let corrections = 0
|
||||
while (cursor < MAX_FOUR_DIGIT_YEAR_MS) {
|
||||
const candidate = evaluator.nextRun(new Date(cursor))
|
||||
if (candidate === null) return undefined
|
||||
const epoch = candidate.getTime()
|
||||
if (!Number.isSafeInteger(epoch)) {
|
||||
throw new ScheduleInputError('invalid_rule', 'The cron evaluator did not advance its cursor.')
|
||||
}
|
||||
if (epoch <= cursor) {
|
||||
corrections += 1
|
||||
if (corrections > MAX_CRON_CURSOR_CORRECTIONS) {
|
||||
return ownedCronInstant(rule, timeZone, after, 1, 1, 9_999)
|
||||
}
|
||||
cursor += 60_000
|
||||
continue
|
||||
}
|
||||
if (epoch > MAX_FOUR_DIGIT_YEAR_MS) return undefined
|
||||
if (isCanonicalCronCandidate(evaluator, formatter, timeZone, epoch)) return epoch
|
||||
return ownedCronInstant(rule, timeZone, after, 1, 1, 9_999)
|
||||
}
|
||||
/* v8 ignore next -- only repeated stale dependency candidates can exhaust the bounded cursor. */
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Find the latest valid calendar occurrence at or before one instant. */
|
||||
function previousCronInstant(
|
||||
rule: ParsedCronRule,
|
||||
timeZone: string,
|
||||
acceptedAt: number,
|
||||
baseline: number,
|
||||
): number | undefined {
|
||||
if (new Date(acceptedAt).getUTCFullYear() <= CRONER_LOW_YEAR_CUTOFF) {
|
||||
return ownedCronInstant(
|
||||
rule, timeZone, acceptedAt, -1, 1, CRONER_LOW_YEAR_SEARCH_END, baseline,
|
||||
)
|
||||
}
|
||||
const evaluator = cronEvaluator(rule, timeZone)
|
||||
const formatter = cronLocalFormatter(timeZone)
|
||||
const nextMinute = Math.floor(acceptedAt / 60_000) * 60_000 + 60_000
|
||||
let reference = Math.min(MAX_FOUR_DIGIT_YEAR_MS, nextMinute)
|
||||
let corrections = 0
|
||||
while (reference > baseline) {
|
||||
const candidate = evaluator.previousRuns(1, new Date(reference))[0]
|
||||
if (candidate === undefined) {
|
||||
return ownedCronInstant(rule, timeZone, acceptedAt, -1, 1, 9_999, baseline)
|
||||
}
|
||||
const epoch = candidate.getTime()
|
||||
if (!Number.isSafeInteger(epoch)) {
|
||||
throw new ScheduleInputError('invalid_rule', 'The cron evaluator did not retreat its cursor.')
|
||||
}
|
||||
if (epoch >= reference) {
|
||||
corrections += 1
|
||||
if (corrections > MAX_CRON_CURSOR_CORRECTIONS) {
|
||||
return ownedCronInstant(rule, timeZone, acceptedAt, -1, 1, 9_999, baseline)
|
||||
}
|
||||
reference -= 60_000
|
||||
continue
|
||||
}
|
||||
if (epoch <= baseline) {
|
||||
return ownedCronInstant(rule, timeZone, acceptedAt, -1, 1, 9_999, baseline)
|
||||
}
|
||||
if (isCanonicalCronCandidate(evaluator, formatter, timeZone, epoch)) return epoch
|
||||
if (epoch >= MIN_FOUR_DIGIT_YEAR_MS && epoch <= MAX_FOUR_DIGIT_YEAR_MS
|
||||
&& epoch % 60_000 === 0 && evaluator.match(candidate)) {
|
||||
return resolveLocalInstant(localProjection(formatter, epoch), timeZone)
|
||||
}
|
||||
const owned = ownedCronInstant(rule, timeZone, acceptedAt, -1, 1, 9_999, baseline)
|
||||
if (owned !== undefined) return owned
|
||||
reference = Math.min(reference - 60_000, epoch - 1)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Decode the exact v1 after record shape. */
|
||||
function decodeAfterRecord(value: unknown): AfterScheduleRecord {
|
||||
if (!isRecord(value) || !hasExactKeys(value, ['id', 'kind', 'prompt', 'afterSeconds', 'scheduledAt'])) {
|
||||
@@ -461,6 +922,49 @@ function decodeEveryRecord(value: unknown): EveryScheduleRecord {
|
||||
})
|
||||
}
|
||||
|
||||
/** Decode the exact v1 calendar-recurring record shape without reevaluating occurrence membership. */
|
||||
function decodeCronRecord(value: unknown): CronScheduleRecord {
|
||||
if (!isRecord(value)
|
||||
|| !hasExactKeys(value, ['id', 'kind', 'prompt', 'cron', 'timeZone', 'scheduledAt'])) {
|
||||
throw new ScheduleLogError('cron schedule must contain exactly id, kind, prompt, cron, timeZone, and scheduledAt')
|
||||
}
|
||||
const prompt = value['prompt']
|
||||
const cron = value['cron']
|
||||
const timeZone = value['timeZone']
|
||||
if (typeof prompt !== 'string' || prompt.length === 0 || prompt.trim() !== prompt) {
|
||||
throw new ScheduleLogError('cron prompt must be non-empty and already trimmed')
|
||||
}
|
||||
if (typeof cron !== 'string' || typeof timeZone !== 'string') {
|
||||
throw new ScheduleLogError('cron rule and timeZone must be strings')
|
||||
}
|
||||
try {
|
||||
const rule = parseCronRule(cron, false)
|
||||
if (rule.canonical !== cron) {
|
||||
throw new ScheduleLogError('cron rule must use its canonical five-field representation')
|
||||
}
|
||||
if (timeZone !== 'UTC' && !IANA_ZONE.test(timeZone)) {
|
||||
throw new ScheduleLogError('cron timeZone must use the persisted IANA Area/Location shape')
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof ScheduleLogError) throw error
|
||||
/* v8 ignore next -- owned cron validators throw Error subclasses. */
|
||||
const detail = error instanceof Error ? error.message : String(error)
|
||||
throw new ScheduleLogError(`cron record is invalid: ${detail}`)
|
||||
}
|
||||
const scheduledAt = decodeInstant(value['scheduledAt'])
|
||||
if (Date.parse(scheduledAt) % 60_000 !== 0) {
|
||||
throw new ScheduleLogError('cron scheduledAt must be a whole-minute UTC instant')
|
||||
}
|
||||
return Object.freeze({
|
||||
id: decodeId(value['id']),
|
||||
kind: 'cron',
|
||||
prompt,
|
||||
cron,
|
||||
timeZone,
|
||||
scheduledAt,
|
||||
})
|
||||
}
|
||||
|
||||
/** Decode one current durable record variant by its exact discriminator. */
|
||||
function decodeScheduleRecord(value: unknown): ScheduleRecord {
|
||||
if (!isRecord(value)) throw new ScheduleLogError('schedule record must be an object')
|
||||
@@ -468,7 +972,8 @@ function decodeScheduleRecord(value: unknown): ScheduleRecord {
|
||||
case 'after': return decodeAfterRecord(value)
|
||||
case 'at': return decodeAtRecord(value)
|
||||
case 'every': return decodeEveryRecord(value)
|
||||
default: throw new ScheduleLogError('v1 schedule kind must be "after", "at", or "every"')
|
||||
case 'cron': return decodeCronRecord(value)
|
||||
default: throw new ScheduleLogError('v1 schedule kind must be "after", "at", "every", or "cron"')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -518,7 +1023,28 @@ export function decodeScheduleChange(value: unknown): ScheduleChange {
|
||||
acceptedAt: decodeInstant(value['acceptedAt']),
|
||||
})
|
||||
}
|
||||
throw new ScheduleLogError('schedule dispatch must contain id and optional acceptedAt only')
|
||||
if (hasExactKeys(value, ['version', 'operation', 'id', 'occurrenceAt', 'acceptedAt'])) {
|
||||
return Object.freeze({
|
||||
version: SCHEDULE_CHANGE_VERSION,
|
||||
operation: 'dispatch',
|
||||
id: decodeId(value['id']),
|
||||
occurrenceAt: decodeInstant(value['occurrenceAt']),
|
||||
acceptedAt: decodeInstant(value['acceptedAt']),
|
||||
})
|
||||
}
|
||||
if (hasExactKeys(value, [
|
||||
'version', 'operation', 'id', 'occurrenceAt', 'acceptedAt', 'nextScheduledAt',
|
||||
])) {
|
||||
return Object.freeze({
|
||||
version: SCHEDULE_CHANGE_VERSION,
|
||||
operation: 'dispatch',
|
||||
id: decodeId(value['id']),
|
||||
occurrenceAt: decodeInstant(value['occurrenceAt']),
|
||||
acceptedAt: decodeInstant(value['acceptedAt']),
|
||||
nextScheduledAt: decodeInstant(value['nextScheduledAt']),
|
||||
})
|
||||
}
|
||||
throw new ScheduleLogError('schedule dispatch has an unsupported field combination')
|
||||
}
|
||||
default:
|
||||
throw new ScheduleLogError('schedule/change operation must be create, delete, or dispatch')
|
||||
@@ -562,6 +1088,41 @@ export function resolveEveryOccurrence(
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one live calendar decision while retaining the persisted baseline across tzdata changes.
|
||||
* @param record - Active canonical Cron record whose target is the prior environment's promise.
|
||||
* @param acceptedAt - Shared recurring-batch wall-clock sample.
|
||||
* @returns Latest current match after the baseline and first future match, if representable.
|
||||
*/
|
||||
export function resolveCronOccurrence(
|
||||
record: CronScheduleRecord,
|
||||
acceptedAt: number,
|
||||
): CronOccurrence {
|
||||
const target = Date.parse(record.scheduledAt)
|
||||
if (!Number.isSafeInteger(acceptedAt)
|
||||
|| acceptedAt < MIN_FOUR_DIGIT_YEAR_MS
|
||||
|| acceptedAt > MAX_FOUR_DIGIT_YEAR_MS) {
|
||||
throw new ScheduleLogError('cron acceptedAt must be a representable four-digit-year instant')
|
||||
}
|
||||
if (acceptedAt < target) {
|
||||
throw new ScheduleLogError('cron dispatch cannot precede the active scheduledAt')
|
||||
}
|
||||
try {
|
||||
const rule = parseCronRule(record.cron, false)
|
||||
const latest = previousCronInstant(rule, record.timeZone, acceptedAt, target)
|
||||
const occurrence = latest !== undefined && latest > target ? latest : target
|
||||
const next = nextCronInstant(rule, record.timeZone, acceptedAt)
|
||||
return Object.freeze({
|
||||
occurrenceAt: new Date(occurrence).toISOString(),
|
||||
...(next === undefined ? {} : { nextScheduledAt: new Date(next).toISOString() }),
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next -- the exact adapter and owned validators throw Errors. */
|
||||
const detail = error instanceof Error ? error.message : String(error)
|
||||
throw new ScheduleLogError(`cron evaluation failed: ${detail}`)
|
||||
}
|
||||
}
|
||||
|
||||
type DecodedDispatch = Extract<ScheduleChange, { operation: 'dispatch' }>
|
||||
|
||||
interface AppliedDispatch {
|
||||
@@ -573,21 +1134,51 @@ interface AppliedDispatch {
|
||||
/** Apply one decoded dispatch to its exact active record. */
|
||||
function applyDispatch(record: ScheduleRecord, change: DecodedDispatch): AppliedDispatch {
|
||||
const hasAcceptedAt = 'acceptedAt' in change
|
||||
if (record.kind !== 'every') {
|
||||
const hasOccurrenceAt = 'occurrenceAt' in change
|
||||
if (record.kind !== 'every' && record.kind !== 'cron') {
|
||||
if (hasAcceptedAt) throw new ScheduleLogError('one-shot dispatch must not contain acceptedAt')
|
||||
return Object.freeze({ occurrenceAt: record.scheduledAt })
|
||||
}
|
||||
if (!hasAcceptedAt) throw new ScheduleLogError('every dispatch must contain acceptedAt')
|
||||
const occurrence = resolveEveryOccurrence(record, Date.parse(change.acceptedAt))
|
||||
if (record.kind === 'every') {
|
||||
if (!hasAcceptedAt || hasOccurrenceAt) {
|
||||
throw new ScheduleLogError('every dispatch must contain acceptedAt without calendar fields')
|
||||
}
|
||||
const occurrence = resolveEveryOccurrence(record, Date.parse(change.acceptedAt))
|
||||
return Object.freeze({
|
||||
occurrenceAt: occurrence.occurrenceAt,
|
||||
acceptedAt: change.acceptedAt,
|
||||
...(occurrence.nextScheduledAt === undefined
|
||||
? {}
|
||||
: {
|
||||
nextRecord: Object.freeze({
|
||||
...record,
|
||||
scheduledAt: occurrence.nextScheduledAt,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
}
|
||||
if (!hasAcceptedAt || !hasOccurrenceAt) {
|
||||
throw new ScheduleLogError('cron dispatch must contain occurrenceAt and acceptedAt')
|
||||
}
|
||||
const target = Date.parse(record.scheduledAt)
|
||||
const occurrence = Date.parse(change.occurrenceAt)
|
||||
const accepted = Date.parse(change.acceptedAt)
|
||||
const nextScheduledAt = 'nextScheduledAt' in change ? change.nextScheduledAt : undefined
|
||||
const next = nextScheduledAt === undefined ? undefined : Date.parse(nextScheduledAt)
|
||||
if (target % 60_000 !== 0 || occurrence % 60_000 !== 0
|
||||
|| occurrence < target || occurrence > accepted
|
||||
|| (next !== undefined && (next % 60_000 !== 0 || next <= accepted))) {
|
||||
throw new ScheduleLogError('cron dispatch times must preserve whole-minute monotonic progression')
|
||||
}
|
||||
return Object.freeze({
|
||||
occurrenceAt: occurrence.occurrenceAt,
|
||||
occurrenceAt: change.occurrenceAt,
|
||||
acceptedAt: change.acceptedAt,
|
||||
...(occurrence.nextScheduledAt === undefined
|
||||
...(nextScheduledAt === undefined
|
||||
? {}
|
||||
: {
|
||||
nextRecord: Object.freeze({
|
||||
...record,
|
||||
scheduledAt: occurrence.nextScheduledAt,
|
||||
scheduledAt: nextScheduledAt,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
@@ -651,10 +1242,10 @@ export function foldScheduleEvents(
|
||||
}
|
||||
}
|
||||
}
|
||||
// A gate beyond the supported time profile can never admit another Every batch.
|
||||
// A gate beyond the supported time profile can never admit another recurring batch.
|
||||
if (isRecurringGateExhausted(lastRecurringAcceptedAt)) {
|
||||
for (const [id, record] of active) {
|
||||
if (record.kind === 'every') active.delete(id)
|
||||
if (record.kind === 'every' || record.kind === 'cron') active.delete(id)
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
@@ -833,6 +1424,48 @@ export function createEveryScheduleRecord(
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate one restricted calendar rule and compute its first current-environment target.
|
||||
* @param id - Already allocated session-local id.
|
||||
* @param prompt - User-authored reminder content.
|
||||
* @param cron - Restricted five-field calendar expression.
|
||||
* @param timeZone - Explicit `UTC` or IANA Area/Location selector.
|
||||
* @param now - Single creation-time wall-clock sample in epoch milliseconds.
|
||||
* @returns Frozen durable calendar record.
|
||||
*/
|
||||
export function createCronScheduleRecord(
|
||||
id: ScheduleIdType,
|
||||
prompt: string,
|
||||
cron: string,
|
||||
timeZone: string,
|
||||
now: number,
|
||||
): CronScheduleRecord {
|
||||
const normalizedPrompt = prompt.trim()
|
||||
if (normalizedPrompt.length === 0) {
|
||||
throw new ScheduleInputError('invalid_prompt', 'prompt must be non-empty after trimming.')
|
||||
}
|
||||
if (!Number.isSafeInteger(now) || now < MIN_FOUR_DIGIT_YEAR_MS || now > MAX_FOUR_DIGIT_YEAR_MS) {
|
||||
throw new ScheduleInputError(
|
||||
'time_out_of_range',
|
||||
'The scheduled time must be representable as a four-digit-year RFC 3339 UTC instant.',
|
||||
)
|
||||
}
|
||||
const rule = parseCronRule(cron)
|
||||
const canonicalTimeZone = canonicalizeTimeZone(timeZone)
|
||||
const target = nextCronInstant(rule, canonicalTimeZone, now)
|
||||
if (target === undefined) {
|
||||
throw new ScheduleInputError('no_future_occurrence', 'The cron rule has no future four-digit-year occurrence.')
|
||||
}
|
||||
return Object.freeze({
|
||||
id,
|
||||
kind: 'cron',
|
||||
prompt: normalizedPrompt,
|
||||
cron: rule.canonical,
|
||||
timeZone: canonicalTimeZone,
|
||||
scheduledAt: new Date(target).toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive one execution-local management view.
|
||||
* @param record - Active durable record.
|
||||
@@ -847,7 +1480,8 @@ export function scheduleView(
|
||||
): ScheduleView {
|
||||
const target = Date.parse(record.scheduledAt)
|
||||
let deliveryNotBefore: string | undefined
|
||||
if (record.kind === 'every' && now >= target && lastRecurringAcceptedAt !== undefined) {
|
||||
if ((record.kind === 'every' || record.kind === 'cron')
|
||||
&& now >= target && lastRecurringAcceptedAt !== undefined) {
|
||||
const notBefore = Date.parse(lastRecurringAcceptedAt) + MIN_RECURRING_INTERVAL_SECONDS * 1_000
|
||||
if (now < notBefore && notBefore <= MAX_FOUR_DIGIT_YEAR_MS) {
|
||||
deliveryNotBefore = new Date(notBefore).toISOString()
|
||||
@@ -974,7 +1608,7 @@ export function renderReminderFraming(record: OneShotScheduleRecord): string {
|
||||
* @returns Stable model-visible text whose dynamic payload is canonical JSON.
|
||||
*/
|
||||
export function renderReminderBatchFraming(
|
||||
reminders: readonly { readonly record: EveryScheduleRecord; readonly occurrenceAt: string }[],
|
||||
reminders: readonly { readonly record: RecurringScheduleRecord; readonly occurrenceAt: string }[],
|
||||
): string {
|
||||
const payload = reminders.map(({ record, occurrenceAt }) => ({
|
||||
schedule_id: record.id,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Agent-scoped durable one-shot and fixed-rate reminders over the session event log.
|
||||
* Agent-scoped durable one-shot, fixed-rate, and calendar reminders over the session event log.
|
||||
* @module @deepseek-ai/dsh-tool-schedule
|
||||
*/
|
||||
|
||||
|
||||
@@ -7,14 +7,15 @@ import type { Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
EveryScheduleRecord,
|
||||
OneShotScheduleRecord,
|
||||
RecurringScheduleRecord,
|
||||
} from './types.ts'
|
||||
import {
|
||||
foldScheduleEvents,
|
||||
MIN_RECURRING_INTERVAL_SECONDS,
|
||||
renderReminderBatchFraming,
|
||||
renderReminderFraming,
|
||||
resolveCronOccurrence,
|
||||
resolveEveryOccurrence,
|
||||
ScheduleLogError,
|
||||
} from './domain.ts'
|
||||
@@ -26,8 +27,9 @@ import { runScheduleTransaction } from './transaction.ts'
|
||||
export const MAX_TIMER_DELAY_MS = 2_147_483_647
|
||||
|
||||
interface RecurringDue {
|
||||
readonly record: EveryScheduleRecord
|
||||
readonly record: RecurringScheduleRecord
|
||||
readonly occurrenceAt: string
|
||||
readonly nextScheduledAt?: string
|
||||
}
|
||||
|
||||
type DueDecision =
|
||||
@@ -40,7 +42,8 @@ function dueDecision(folded: FoldedSchedules, now: number): DueDecision {
|
||||
const indexed = folded.active.map((record, index) => ({ record, index }))
|
||||
const dueOneShots = indexed
|
||||
.filter((entry): entry is { record: OneShotScheduleRecord; index: number } =>
|
||||
entry.record.kind !== 'every' && Date.parse(entry.record.scheduledAt) <= now)
|
||||
entry.record.kind !== 'every' && entry.record.kind !== 'cron'
|
||||
&& Date.parse(entry.record.scheduledAt) <= now)
|
||||
.sort((left, right) =>
|
||||
Date.parse(left.record.scheduledAt) - Date.parse(right.record.scheduledAt)
|
||||
|| left.index - right.index)
|
||||
@@ -48,8 +51,9 @@ function dueDecision(folded: FoldedSchedules, now: number): DueDecision {
|
||||
if (oneShot !== undefined) return { kind: 'one-shot', record: oneShot }
|
||||
|
||||
const recurring = indexed
|
||||
.filter((entry): entry is { record: EveryScheduleRecord; index: number } =>
|
||||
entry.record.kind === 'every' && Date.parse(entry.record.scheduledAt) <= now)
|
||||
.filter((entry): entry is { record: RecurringScheduleRecord; index: number } =>
|
||||
(entry.record.kind === 'every' || entry.record.kind === 'cron')
|
||||
&& Date.parse(entry.record.scheduledAt) <= now)
|
||||
.sort((left, right) =>
|
||||
Date.parse(left.record.scheduledAt) - Date.parse(right.record.scheduledAt)
|
||||
|| left.index - right.index)
|
||||
@@ -60,15 +64,23 @@ function dueDecision(folded: FoldedSchedules, now: number): DueDecision {
|
||||
return {
|
||||
kind: 'recurring',
|
||||
acceptedAt: new Date(now).toISOString(),
|
||||
reminders: recurring.map(({ record }) => ({
|
||||
record,
|
||||
occurrenceAt: resolveEveryOccurrence(record, now).occurrenceAt,
|
||||
})),
|
||||
reminders: recurring.map(({ record }) => {
|
||||
const occurrence = record.kind === 'every'
|
||||
? resolveEveryOccurrence(record, now)
|
||||
: resolveCronOccurrence(record, now)
|
||||
return {
|
||||
record,
|
||||
occurrenceAt: occurrence.occurrenceAt,
|
||||
...(occurrence.nextScheduledAt === undefined
|
||||
? {}
|
||||
: { nextScheduledAt: occurrence.nextScheduledAt }),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const future = folded.active
|
||||
.filter(record => recurring.length === 0 || record.kind !== 'every')
|
||||
.filter(record => recurring.length === 0 || (record.kind !== 'every' && record.kind !== 'cron'))
|
||||
.map(record => Date.parse(record.scheduledAt))
|
||||
.filter(target => target > now)
|
||||
if (recurring.length > 0) future.push(gate)
|
||||
@@ -282,13 +294,26 @@ export class ScheduleOwner {
|
||||
id: decision.record.id,
|
||||
})
|
||||
} else {
|
||||
for (const { record } of decision.reminders) {
|
||||
this.agent.session.append('schedule/change', {
|
||||
version: 1,
|
||||
operation: 'dispatch',
|
||||
id: record.id,
|
||||
acceptedAt: decision.acceptedAt,
|
||||
})
|
||||
for (const reminder of decision.reminders) {
|
||||
if (reminder.record.kind === 'every') {
|
||||
this.agent.session.append('schedule/change', {
|
||||
version: 1,
|
||||
operation: 'dispatch',
|
||||
id: reminder.record.id,
|
||||
acceptedAt: decision.acceptedAt,
|
||||
})
|
||||
} else {
|
||||
this.agent.session.append('schedule/change', {
|
||||
version: 1,
|
||||
operation: 'dispatch',
|
||||
id: reminder.record.id,
|
||||
occurrenceAt: reminder.occurrenceAt,
|
||||
acceptedAt: decision.acceptedAt,
|
||||
...(reminder.nextScheduledAt === undefined
|
||||
? {}
|
||||
: { nextScheduledAt: reminder.nextScheduledAt }),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
allocateScheduleId,
|
||||
createAfterScheduleRecord,
|
||||
createAtScheduleRecord,
|
||||
createCronScheduleRecord,
|
||||
createEveryScheduleRecord,
|
||||
foldScheduleEvents,
|
||||
isRecurringGateExhausted,
|
||||
@@ -76,7 +77,21 @@ const EVERY_VIEW_SCHEMA = {
|
||||
},
|
||||
} as const
|
||||
|
||||
const VIEW_SCHEMA = { oneOf: [AFTER_VIEW_SCHEMA, AT_VIEW_SCHEMA, EVERY_VIEW_SCHEMA] } as const
|
||||
const CRON_VIEW_SCHEMA = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
...SHARED_VIEW_PROPERTIES,
|
||||
kind: { type: 'string', required: true, const: 'cron' },
|
||||
cron: { type: 'string', required: true },
|
||||
timeZone: { type: 'string', required: true },
|
||||
deliveryNotBefore: { type: 'string' },
|
||||
},
|
||||
} as const
|
||||
|
||||
const VIEW_SCHEMA = {
|
||||
oneOf: [AFTER_VIEW_SCHEMA, AT_VIEW_SCHEMA, EVERY_VIEW_SCHEMA, CRON_VIEW_SCHEMA],
|
||||
} as const
|
||||
|
||||
/** Build one exact two-field error schema while preserving its literal code. */
|
||||
function basicErrorSchema<const C extends string>(code: C) {
|
||||
@@ -98,6 +113,7 @@ const BASIC_ERROR_SCHEMAS = [
|
||||
basicErrorSchema('not_future'),
|
||||
basicErrorSchema('time_out_of_range'),
|
||||
basicErrorSchema('frequency_too_high'),
|
||||
basicErrorSchema('no_future_occurrence'),
|
||||
basicErrorSchema('corrupt_schedule_log'),
|
||||
basicErrorSchema('internal_error'),
|
||||
] as const
|
||||
@@ -163,7 +179,8 @@ const DELETE_OUTPUT_SCHEMA = {
|
||||
const CREATE_DESCRIPTION =
|
||||
'Create one reminder in the current session. Supply a non-empty prompt and exactly one selector: '
|
||||
+ 'a positive safe-integer after_seconds delay, at as a strict offset date-time or local '
|
||||
+ `date/time object, or safe-integer every_seconds of at least ${MIN_RECURRING_INTERVAL_SECONDS}. `
|
||||
+ `date/time object, safe-integer every_seconds of at least ${MIN_RECURRING_INTERVAL_SECONDS}, `
|
||||
+ 'or a restricted five-field cron paired with an explicit IANA time_zone. '
|
||||
+ 'Delivery is session-local: the reminder runs on time only while this session '
|
||||
+ 'is live and otherwise becomes overdue until the session is resumed.'
|
||||
|
||||
@@ -175,6 +192,14 @@ const DELETE_DESCRIPTION =
|
||||
'Delete one active reminder in the current session by the exact id returned by schedule_create '
|
||||
+ 'or schedule_list. Unknown or already-finished ids return deleted false.'
|
||||
|
||||
const CRON_DESCRIPTION =
|
||||
'Five numeric fields in order: minute 0-59, hour 0-23, day-of-month 1-31, month 1-12, '
|
||||
+ 'day-of-week 0-7 (0 and 7 are Sunday). Each field is *, one integer, a strictly increasing '
|
||||
+ 'integer list, an increasing a-b range, */s, or a-b/s. Day-of-month or day-of-week must be *. '
|
||||
+ 'Steps are positive and at most the field cardinality (7 for day-of-week). Names, macros, '
|
||||
+ 'seconds, years, ?, L, W, and # are unsupported; nominal matches must be at '
|
||||
+ 'least five minutes apart. Requires time_zone.'
|
||||
|
||||
/** Deterministic model content for every canonical Schedule value. */
|
||||
function renderValue(_args: unknown, value: unknown): ContentBlock[] {
|
||||
// The ToolRegistry has already validated the value against the lossless-JSON output schema.
|
||||
@@ -364,18 +389,25 @@ function validateCreateArgs(args: {
|
||||
after_seconds?: number
|
||||
at?: AtInput
|
||||
every_seconds?: number
|
||||
cron?: string
|
||||
time_zone?: string
|
||||
}): ScheduleToolError | undefined {
|
||||
const keys = Object.keys(args as unknown as Record<string, unknown>)
|
||||
const hasCronSelector = args.cron !== undefined || args.time_zone !== undefined
|
||||
if (keys.some(key => key !== 'prompt'
|
||||
&& key !== 'after_seconds'
|
||||
&& key !== 'at'
|
||||
&& key !== 'every_seconds')
|
||||
&& key !== 'every_seconds'
|
||||
&& key !== 'cron'
|
||||
&& key !== 'time_zone')
|
||||
|| Number(args.after_seconds !== undefined)
|
||||
+ Number(args.at !== undefined)
|
||||
+ Number(args.every_seconds !== undefined) !== 1) {
|
||||
+ Number(args.every_seconds !== undefined)
|
||||
+ Number(hasCronSelector) !== 1
|
||||
|| (hasCronSelector && (args.cron === undefined || args.time_zone === undefined))) {
|
||||
return {
|
||||
code: 'invalid_selector',
|
||||
message: 'schedule_create accepts exactly one of after_seconds, at, or every_seconds.',
|
||||
message: 'schedule_create accepts exactly one of after_seconds, at, every_seconds, or cron with time_zone.',
|
||||
}
|
||||
}
|
||||
if (args.prompt.trim().length === 0) {
|
||||
@@ -440,6 +472,14 @@ export function registerScheduleTools(
|
||||
type: 'number',
|
||||
description: `Fixed-rate safe-integer interval in seconds, at least ${MIN_RECURRING_INTERVAL_SECONDS}.`,
|
||||
},
|
||||
cron: {
|
||||
type: 'string',
|
||||
description: CRON_DESCRIPTION,
|
||||
},
|
||||
time_zone: {
|
||||
type: 'string',
|
||||
description: 'Explicit UTC or IANA Area/Location for cron evaluation.',
|
||||
},
|
||||
at: {
|
||||
description: 'Absolute target as strict offset RFC 3339 or local date/time with optional IANA zone.',
|
||||
oneOf: [
|
||||
@@ -467,7 +507,7 @@ export function registerScheduleTools(
|
||||
notifyDurableChange()
|
||||
const folded = foldForTool(agent)
|
||||
if (isToolError(folded)) return folded
|
||||
if (args.every_seconds !== undefined
|
||||
if ((args.every_seconds !== undefined || args.cron !== undefined)
|
||||
&& isRecurringGateExhausted(folded.lastRecurringAcceptedAt)) {
|
||||
return {
|
||||
code: 'time_out_of_range',
|
||||
@@ -492,11 +532,19 @@ export function registerScheduleTools(
|
||||
)
|
||||
} else if (args.after_seconds !== undefined) {
|
||||
record = createAfterScheduleRecord(id, args.prompt, args.after_seconds, Date.now())
|
||||
} else {
|
||||
} else if (args.every_seconds !== undefined) {
|
||||
record = createEveryScheduleRecord(
|
||||
id,
|
||||
args.prompt,
|
||||
args.every_seconds as number,
|
||||
args.every_seconds,
|
||||
Date.now(),
|
||||
)
|
||||
} else {
|
||||
record = createCronScheduleRecord(
|
||||
id,
|
||||
args.prompt,
|
||||
args.cron as string,
|
||||
args.time_zone as string,
|
||||
Date.now(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -49,6 +49,22 @@ export interface EveryScheduleRecord {
|
||||
readonly scheduledAt: string
|
||||
}
|
||||
|
||||
/** Durable calendar reminder evaluated in one explicit IANA time zone. */
|
||||
export interface CronScheduleRecord {
|
||||
/** Session-local stable identity. */
|
||||
readonly id: ScheduleId
|
||||
/** Rule discriminator for a calendar recurring reminder. */
|
||||
readonly kind: 'cron'
|
||||
/** Trimmed user-authored reminder content. */
|
||||
readonly prompt: string
|
||||
/** Canonical restricted five-field cron expression. */
|
||||
readonly cron: string
|
||||
/** Canonical IANA time-zone name used for future evaluation. */
|
||||
readonly timeZone: string
|
||||
/** Earliest calendar occurrence not yet accepted. */
|
||||
readonly scheduledAt: string
|
||||
}
|
||||
|
||||
/** Structured local-calendar input accepted by `schedule_create`. */
|
||||
export interface LocalAtInput {
|
||||
/** Four-digit ISO calendar date. */
|
||||
@@ -65,8 +81,11 @@ export type AtInput = string | LocalAtInput
|
||||
/** One-shot record variants that terminate on an id-only dispatch. */
|
||||
export type OneShotScheduleRecord = AfterScheduleRecord | AtScheduleRecord
|
||||
|
||||
/** Recurring record variants that share one model-turn gate. */
|
||||
export type RecurringScheduleRecord = EveryScheduleRecord | CronScheduleRecord
|
||||
|
||||
/** The v1 durable reminder record union. */
|
||||
export type ScheduleRecord = OneShotScheduleRecord | EveryScheduleRecord
|
||||
export type ScheduleRecord = OneShotScheduleRecord | RecurringScheduleRecord
|
||||
|
||||
/** Creates one durable reminder record. */
|
||||
export interface ScheduleCreateChange {
|
||||
@@ -98,8 +117,24 @@ export interface EveryScheduleDispatchChange {
|
||||
readonly acceptedAt: string
|
||||
}
|
||||
|
||||
/** Freezes one calendar decision against the live evaluator and tzdata. */
|
||||
export interface CronScheduleDispatchChange {
|
||||
readonly version: 1
|
||||
readonly operation: 'dispatch'
|
||||
readonly id: ScheduleId
|
||||
/** Latest accepted calendar occurrence as canonical UTC. */
|
||||
readonly occurrenceAt: string
|
||||
/** Shared recurring-batch decision time as canonical UTC. */
|
||||
readonly acceptedAt: string
|
||||
/** First future calendar occurrence, omitted only at four-digit-year exhaustion. */
|
||||
readonly nextScheduledAt?: string
|
||||
}
|
||||
|
||||
/** Durable dispatch shapes supported by the current rule set. */
|
||||
export type ScheduleDispatchChange = OneShotScheduleDispatchChange | EveryScheduleDispatchChange
|
||||
export type ScheduleDispatchChange =
|
||||
| OneShotScheduleDispatchChange
|
||||
| EveryScheduleDispatchChange
|
||||
| CronScheduleDispatchChange
|
||||
|
||||
/** Strict version-1 durable Schedule mutation union. */
|
||||
export type ScheduleChange = ScheduleCreateChange | ScheduleDeleteChange | ScheduleDispatchChange
|
||||
@@ -175,6 +210,12 @@ export interface FrequencyTooHighError {
|
||||
readonly message: string
|
||||
}
|
||||
|
||||
/** Stable error returned when a recurring rule has no representable future occurrence. */
|
||||
export interface NoFutureOccurrenceError {
|
||||
readonly code: 'no_future_occurrence'
|
||||
readonly message: string
|
||||
}
|
||||
|
||||
/** Stable error returned when the durable Schedule stream is malformed. */
|
||||
export interface CorruptScheduleLogError {
|
||||
readonly code: 'corrupt_schedule_log'
|
||||
@@ -204,6 +245,7 @@ export type ScheduleToolError =
|
||||
| NotFutureError
|
||||
| TimeOutOfRangeError
|
||||
| FrequencyTooHighError
|
||||
| NoFutureOccurrenceError
|
||||
| CorruptScheduleLogError
|
||||
| PersistenceUncertainError
|
||||
| InternalScheduleError
|
||||
|
||||
@@ -0,0 +1,543 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { Cron } from 'croner'
|
||||
import {
|
||||
ScheduleId,
|
||||
ScheduleInputError,
|
||||
ScheduleLogError,
|
||||
canonicalizeCronExpression,
|
||||
createCronScheduleRecord,
|
||||
decodeScheduleChange,
|
||||
foldScheduleEvents,
|
||||
resolveCronOccurrence,
|
||||
scheduleReminderPresentation,
|
||||
scheduleView,
|
||||
} from '../src/domain.ts'
|
||||
|
||||
function event(data: unknown, seq: number): SessionEvent {
|
||||
return { type: 'schedule/change', seq, time: 0, data } as SessionEvent
|
||||
}
|
||||
|
||||
function cronCreate(
|
||||
id = 'schedule-cron',
|
||||
scheduledAt = '2026-08-07T01:00:00.000Z',
|
||||
cron = '0 9 * * 1,2,3,4,5',
|
||||
timeZone = 'Asia/Shanghai',
|
||||
) {
|
||||
return {
|
||||
version: 1,
|
||||
operation: 'create',
|
||||
schedule: { id, kind: 'cron', prompt: 'daily review', cron, timeZone, scheduledAt },
|
||||
}
|
||||
}
|
||||
|
||||
function expectInputCode(run: () => unknown, code: ScheduleInputError['code']): void {
|
||||
try {
|
||||
run()
|
||||
throw new Error(`expected ${code}`)
|
||||
} catch (error: unknown) {
|
||||
expect(error).toBeInstanceOf(ScheduleInputError)
|
||||
expect((error as ScheduleInputError).code).toBe(code)
|
||||
}
|
||||
}
|
||||
|
||||
describe('restricted cron grammar and frequency proof', () => {
|
||||
it.each([
|
||||
['00 09 * * 1,2,3,4,5', '0 9 * * 1,2,3,4,5'],
|
||||
['0 0 * */01 *', '0 0 * * *'],
|
||||
['5-20/05 1-3 * * *', '5-20/5 1-3 * * *'],
|
||||
['05 01 01,15 01,12 *', '5 1 1,15 1,12 *'],
|
||||
['0 0 * * 7', '0 0 * * 7'],
|
||||
])('canonicalizes %s', (input, canonical) => {
|
||||
expect(canonicalizeCronExpression(input)).toBe(canonical)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'',
|
||||
' 0 0 * * *',
|
||||
'0 0 * * * ',
|
||||
'0 0 * *',
|
||||
'0 0 0 * * *',
|
||||
'@daily',
|
||||
'0 0 * JAN *',
|
||||
'0 0 * * MON',
|
||||
'0 0 ? * *',
|
||||
'0 0 L * *',
|
||||
'0 0 W * *',
|
||||
'0 0 * * 1#2',
|
||||
'-1 0 * * *',
|
||||
'1.5 0 * * *',
|
||||
'60 0 * * *',
|
||||
'0 24 * * *',
|
||||
'0 0 0 * *',
|
||||
'0 0 32 * *',
|
||||
'0 0 * 13 *',
|
||||
'0 0 * * 8',
|
||||
'0,0 0 * * *',
|
||||
'2,1 0 * * *',
|
||||
'1,2-3 0 * * *',
|
||||
'2-2 0 * * *',
|
||||
'3-2 0 * * *',
|
||||
'*/0 0 * * *',
|
||||
'*/61 0 * * *',
|
||||
'1-5/61 0 * * *',
|
||||
'1-1/2 0 * * *',
|
||||
'0 0 * * 0,7',
|
||||
'0 0 * * 0-7',
|
||||
'0 0 * * */8',
|
||||
'0 0 * * 0-6/8',
|
||||
'0 0 * * 1-7/8',
|
||||
'0 0 1 * 1',
|
||||
])('rejects unsupported grammar %s', (input) => {
|
||||
expectInputCode(() => canonicalizeCronExpression(input), 'invalid_rule')
|
||||
})
|
||||
|
||||
it('proves same-day and cycle-seam frequency while allowing the five-minute boundary', () => {
|
||||
expectInputCode(() => canonicalizeCronExpression('0,4 * * * *'), 'frequency_too_high')
|
||||
expectInputCode(() => canonicalizeCronExpression('3,59 0,23 * * *'), 'frequency_too_high')
|
||||
expect(canonicalizeCronExpression('0,5 * * * *')).toBe('0,5 * * * *')
|
||||
expect(canonicalizeCronExpression('4,59 0,23 * * *')).toBe('4,59 0,23 * * *')
|
||||
expect(canonicalizeCronExpression('3,59 0,23 * * 1')).toBe('3,59 0,23 * * 1')
|
||||
expect(canonicalizeCronExpression('3,59 0,23 29 2 *')).toBe('3,59 0,23 29 2 *')
|
||||
expectInputCode(() => canonicalizeCronExpression('3,59 0,23 * * 5,6'), 'frequency_too_high')
|
||||
expect(canonicalizeCronExpression('* * 31 2 *')).toBe('* * 31 2 *')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Croner calendar adapter', () => {
|
||||
it('creates a canonical explicit-zone record and crosses from 2999 into 3000', () => {
|
||||
expect(createCronScheduleRecord(
|
||||
ScheduleId('schedule-workday'),
|
||||
' review metrics ',
|
||||
'00 09 * * 1,2,3,4,5',
|
||||
'US/Eastern',
|
||||
Date.parse('2026-08-06T12:00:00.000Z'),
|
||||
)).toEqual({
|
||||
id: 'schedule-workday',
|
||||
kind: 'cron',
|
||||
prompt: 'review metrics',
|
||||
cron: '0 9 * * 1,2,3,4,5',
|
||||
timeZone: 'America/New_York',
|
||||
scheduledAt: '2026-08-06T13:00:00.000Z',
|
||||
})
|
||||
expect(createCronScheduleRecord(
|
||||
ScheduleId('schedule-3000'),
|
||||
'new millennium',
|
||||
'0 0 1 1 *',
|
||||
'UTC',
|
||||
Date.parse('2999-12-31T23:59:59.999Z'),
|
||||
).scheduledAt).toBe('3000-01-01T00:00:00.000Z')
|
||||
})
|
||||
|
||||
it('owns forward and reverse calendar search across years 0001 through 0100', () => {
|
||||
expect(createCronScheduleRecord(
|
||||
ScheduleId('schedule-year-1'),
|
||||
'year one',
|
||||
'0 0 * * *',
|
||||
'UTC',
|
||||
Date.parse('0001-01-01T00:00:00.000Z'),
|
||||
).scheduledAt).toBe('0001-01-02T00:00:00.000Z')
|
||||
expect(createCronScheduleRecord(
|
||||
ScheduleId('schedule-year-100'),
|
||||
'year one hundred',
|
||||
'0 0 * * *',
|
||||
'UTC',
|
||||
Date.parse('0099-12-31T00:00:00.000Z'),
|
||||
).scheduledAt).toBe('0100-01-01T00:00:00.000Z')
|
||||
expect(createCronScheduleRecord(
|
||||
ScheduleId('schedule-low-leap'),
|
||||
'low leap',
|
||||
'0 0 29 2 *',
|
||||
'UTC',
|
||||
Date.parse('0001-01-01T00:00:00.000Z'),
|
||||
).scheduledAt).toBe('0004-02-29T00:00:00.000Z')
|
||||
const historicalOffset = createCronScheduleRecord(
|
||||
ScheduleId('schedule-low-offset'),
|
||||
'low offset',
|
||||
'0 0 29 2 *',
|
||||
'Pacific/Kiritimati',
|
||||
Date.parse('0001-01-01T00:00:00.000Z'),
|
||||
)
|
||||
expect(new Date(historicalOffset.scheduledAt).getUTCFullYear()).toBeGreaterThan(109)
|
||||
const baseline = createCronScheduleRecord(
|
||||
ScheduleId('schedule-reverse-100'),
|
||||
'reverse one hundred',
|
||||
'0 0 * * *',
|
||||
'UTC',
|
||||
Date.parse('0099-12-30T00:00:00.000Z'),
|
||||
)
|
||||
expect(resolveCronOccurrence(baseline, Date.parse('0100-01-01T00:00:00.000Z'))).toEqual({
|
||||
occurrenceAt: '0100-01-01T00:00:00.000Z',
|
||||
nextScheduledAt: '0100-01-02T00:00:00.000Z',
|
||||
})
|
||||
})
|
||||
|
||||
it('skips a DST gap and chooses the first instant in an overlap', () => {
|
||||
const gap = createCronScheduleRecord(
|
||||
ScheduleId('schedule-gap'),
|
||||
'gap',
|
||||
'30 2 * * *',
|
||||
'America/New_York',
|
||||
Date.parse('2026-03-08T05:00:00.000Z'),
|
||||
)
|
||||
expect(gap.scheduledAt).toBe('2026-03-09T06:30:00.000Z')
|
||||
const gapBaseline = {
|
||||
...gap,
|
||||
scheduledAt: '2026-03-07T07:30:00.000Z',
|
||||
}
|
||||
expect(resolveCronOccurrence(gapBaseline, Date.parse('2026-03-08T08:00:00.000Z'))).toEqual({
|
||||
occurrenceAt: gapBaseline.scheduledAt,
|
||||
nextScheduledAt: '2026-03-09T06:30:00.000Z',
|
||||
})
|
||||
|
||||
const overlap = createCronScheduleRecord(
|
||||
ScheduleId('schedule-overlap'),
|
||||
'overlap',
|
||||
'30 1 * * *',
|
||||
'America/New_York',
|
||||
Date.parse('2026-10-31T06:00:00.000Z'),
|
||||
)
|
||||
expect(overlap.scheduledAt).toBe('2026-11-01T05:30:00.000Z')
|
||||
expect(resolveCronOccurrence({
|
||||
...overlap,
|
||||
scheduledAt: '2026-10-31T05:30:00.000Z',
|
||||
}, Date.parse('2026-11-01T06:00:00.000Z'))).toEqual({
|
||||
occurrenceAt: '2026-11-01T05:30:00.000Z',
|
||||
nextScheduledAt: '2026-11-02T06:30:00.000Z',
|
||||
})
|
||||
expect(resolveCronOccurrence(overlap, Date.parse('2026-11-01T07:00:00.000Z'))).toEqual({
|
||||
occurrenceAt: '2026-11-01T05:30:00.000Z',
|
||||
nextScheduledAt: '2026-11-02T06:30:00.000Z',
|
||||
})
|
||||
})
|
||||
|
||||
it('selects the latest current match after a persisted baseline', () => {
|
||||
const record = createCronScheduleRecord(
|
||||
ScheduleId('schedule-latest'),
|
||||
'latest',
|
||||
'0 9 * * *',
|
||||
'Asia/Shanghai',
|
||||
Date.parse('2026-08-01T00:00:00.000Z'),
|
||||
)
|
||||
expect(resolveCronOccurrence(record, Date.parse('2026-08-06T12:34:56.789Z'))).toEqual({
|
||||
occurrenceAt: '2026-08-06T01:00:00.000Z',
|
||||
nextScheduledAt: '2026-08-07T01:00:00.000Z',
|
||||
})
|
||||
})
|
||||
|
||||
it('reports invalid zones, impossible calendars, and four-digit-year exhaustion', () => {
|
||||
expectInputCode(() => createCronScheduleRecord(
|
||||
ScheduleId('bad-prompt'), ' ', '0 0 * * *', 'UTC', Date.parse('2026-01-01T00:00:00Z'),
|
||||
), 'invalid_prompt')
|
||||
expectInputCode(() => createCronScheduleRecord(
|
||||
ScheduleId('bad-zone'), 'x', '0 0 * * *', 'CST', Date.parse('2026-01-01T00:00:00Z'),
|
||||
), 'invalid_time_zone')
|
||||
expectInputCode(() => createCronScheduleRecord(
|
||||
ScheduleId('no-date'), 'x', '* * 31 2 *', 'UTC', Date.parse('2026-01-01T00:00:00Z'),
|
||||
), 'no_future_occurrence')
|
||||
expectInputCode(() => createCronScheduleRecord(
|
||||
ScheduleId('no-year'), 'x', '59 23 31 12 *', 'UTC', Date.parse('9999-12-31T23:59:00Z'),
|
||||
), 'no_future_occurrence')
|
||||
expectInputCode(() => createCronScheduleRecord(
|
||||
ScheduleId('bad-now'), 'x', '0 0 * * *', 'UTC', Number.NaN,
|
||||
), 'time_out_of_range')
|
||||
expectInputCode(() => createCronScheduleRecord(
|
||||
ScheduleId('last-now'), 'x', '0 0 * * *', 'UTC', Date.parse('9999-12-31T23:59:59.999Z'),
|
||||
), 'no_future_occurrence')
|
||||
})
|
||||
|
||||
it('contains dependency cursor failures and preserves the baseline when current search has no match', () => {
|
||||
const record = createCronScheduleRecord(
|
||||
ScheduleId('schedule-dependency'),
|
||||
'dependency',
|
||||
'30 1 * * *',
|
||||
'America/New_York',
|
||||
Date.parse('2026-10-31T06:00:00.000Z'),
|
||||
)
|
||||
|
||||
const noPrevious = vi.spyOn(Cron.prototype, 'previousRuns').mockReturnValue([])
|
||||
expect(resolveCronOccurrence(record, Date.parse(record.scheduledAt))).toMatchObject({
|
||||
occurrenceAt: record.scheduledAt,
|
||||
})
|
||||
noPrevious.mockRestore()
|
||||
|
||||
const repeatedPrevious = vi.spyOn(Cron.prototype, 'previousRuns')
|
||||
.mockImplementationOnce((_count, reference) => [new Date(reference ?? record.scheduledAt)])
|
||||
.mockReturnValue([new Date('2026-11-01T05:30:00.000Z')])
|
||||
expect(resolveCronOccurrence(record, Date.parse('2026-11-01T07:00:00.000Z'))).toMatchObject({
|
||||
occurrenceAt: '2026-11-01T05:30:00.000Z',
|
||||
})
|
||||
repeatedPrevious.mockRestore()
|
||||
|
||||
const laterOverlap = vi.spyOn(Cron.prototype, 'previousRuns')
|
||||
.mockReturnValue([new Date('2026-11-01T06:30:00.000Z')])
|
||||
expect(resolveCronOccurrence(record, Date.parse('2026-11-01T07:00:00.000Z'))).toMatchObject({
|
||||
occurrenceAt: '2026-11-01T05:30:00.000Z',
|
||||
})
|
||||
laterOverlap.mockRestore()
|
||||
|
||||
const gapThenNone = vi.spyOn(Cron.prototype, 'previousRuns')
|
||||
.mockReturnValueOnce([new Date('2026-03-08T07:30:00.000Z')])
|
||||
.mockReturnValueOnce([])
|
||||
const gapBaseline = {
|
||||
...record,
|
||||
cron: '30 2 * * *',
|
||||
scheduledAt: '2026-03-07T07:30:00.000Z',
|
||||
}
|
||||
expect(resolveCronOccurrence(gapBaseline, Date.parse('2026-03-08T08:00:00.000Z'))).toMatchObject({
|
||||
occurrenceAt: gapBaseline.scheduledAt,
|
||||
})
|
||||
gapThenNone.mockRestore()
|
||||
|
||||
const boundaryThenEnd = vi.spyOn(Cron.prototype, 'previousRuns')
|
||||
.mockReturnValue([new Date('0001-01-01T00:00:00.000Z')])
|
||||
const boundaryBaseline = {
|
||||
...record,
|
||||
cron: '1 0 * * *',
|
||||
timeZone: 'UTC',
|
||||
scheduledAt: '0001-01-01T00:01:00.000Z',
|
||||
}
|
||||
expect(resolveCronOccurrence(boundaryBaseline, Date.parse('2026-01-01T00:00:00.000Z'))).toMatchObject({
|
||||
occurrenceAt: '2025-12-31T00:01:00.000Z',
|
||||
})
|
||||
boundaryThenEnd.mockRestore()
|
||||
|
||||
const repeatedNext = vi.spyOn(Cron.prototype, 'nextRun')
|
||||
.mockImplementationOnce(reference =>
|
||||
reference instanceof Date ? new Date(reference) : new Date('2026-01-01T00:00:00.000Z'))
|
||||
.mockReturnValue(new Date('2026-01-02T00:00:00.000Z'))
|
||||
expect(createCronScheduleRecord(
|
||||
ScheduleId('stuck-next'), 'x', '0 0 * * *', 'UTC', Date.parse('2026-01-01T00:00:00Z'),
|
||||
).scheduledAt).toBe('2026-01-02T00:00:00.000Z')
|
||||
repeatedNext.mockRestore()
|
||||
|
||||
const neverAdvancingNext = vi.spyOn(Cron.prototype, 'nextRun').mockImplementation(reference =>
|
||||
reference instanceof Date ? new Date(reference) : new Date('2026-01-01T00:00:00.000Z'))
|
||||
expect(createCronScheduleRecord(
|
||||
ScheduleId('fallback-next'), 'x', '0 0 * * *', 'UTC', Date.parse('2026-01-01T00:00:00Z'),
|
||||
).scheduledAt).toBe('2026-01-02T00:00:00.000Z')
|
||||
neverAdvancingNext.mockRestore()
|
||||
|
||||
const invalidNext = vi.spyOn(Cron.prototype, 'nextRun').mockReturnValue(new Date(Number.NaN))
|
||||
expectInputCode(() => createCronScheduleRecord(
|
||||
ScheduleId('invalid-next'), 'x', '0 0 * * *', 'UTC', Date.parse('2026-01-01T00:00:00Z'),
|
||||
), 'invalid_rule')
|
||||
invalidNext.mockRestore()
|
||||
|
||||
const outOfRangeNext = vi.spyOn(Cron.prototype, 'nextRun')
|
||||
.mockReturnValue(new Date('+010000-01-01T00:00:00.000Z'))
|
||||
expectInputCode(() => createCronScheduleRecord(
|
||||
ScheduleId('large-next'), 'x', '0 0 * * *', 'UTC', Date.parse('2026-01-01T00:00:00Z'),
|
||||
), 'no_future_occurrence')
|
||||
outOfRangeNext.mockRestore()
|
||||
|
||||
const invalidPrevious = vi.spyOn(Cron.prototype, 'previousRuns')
|
||||
.mockReturnValue([new Date(Number.NaN)])
|
||||
expect(() => resolveCronOccurrence(record, Date.parse('2026-11-01T07:00:00.000Z')))
|
||||
.toThrow(/cron evaluation failed: The cron evaluator did not retreat/)
|
||||
invalidPrevious.mockRestore()
|
||||
|
||||
const repeatedAtBaseline = vi.spyOn(Cron.prototype, 'previousRuns')
|
||||
.mockImplementation((_count, reference) => [new Date(reference ?? record.scheduledAt)])
|
||||
expect(resolveCronOccurrence(record, Date.parse(record.scheduledAt))).toMatchObject({
|
||||
occurrenceAt: record.scheduledAt,
|
||||
})
|
||||
repeatedAtBaseline.mockRestore()
|
||||
|
||||
const neverRetreating = vi.spyOn(Cron.prototype, 'previousRuns')
|
||||
.mockImplementation((_count, reference) => [new Date(reference ?? record.scheduledAt)])
|
||||
expect(resolveCronOccurrence({
|
||||
...record,
|
||||
scheduledAt: '2026-10-31T05:30:00.000Z',
|
||||
}, Date.parse('2026-11-01T07:00:00.000Z'))).toMatchObject({
|
||||
occurrenceAt: '2026-11-01T05:30:00.000Z',
|
||||
})
|
||||
neverRetreating.mockRestore()
|
||||
|
||||
const nonMinutePrevious = vi.spyOn(Cron.prototype, 'previousRuns')
|
||||
.mockReturnValue([new Date('2026-11-01T05:30:30.000Z')])
|
||||
expect(resolveCronOccurrence(record, Date.parse('2026-11-01T07:00:00.000Z'))).toMatchObject({
|
||||
occurrenceAt: '2026-11-01T05:30:00.000Z',
|
||||
})
|
||||
nonMinutePrevious.mockRestore()
|
||||
|
||||
const gapWithOwnedMatch = vi.spyOn(Cron.prototype, 'previousRuns')
|
||||
.mockReturnValue([new Date('2026-03-08T07:30:00.000Z')])
|
||||
const gapWithNextDay = {
|
||||
...record,
|
||||
cron: '30 2 * * *',
|
||||
scheduledAt: '2026-03-07T07:30:00.000Z',
|
||||
}
|
||||
expect(resolveCronOccurrence(gapWithNextDay, Date.parse('2026-03-09T08:00:00.000Z'))).toMatchObject({
|
||||
occurrenceAt: '2026-03-09T06:30:00.000Z',
|
||||
})
|
||||
gapWithOwnedMatch.mockRestore()
|
||||
|
||||
const thrownNext = vi.spyOn(Cron.prototype, 'nextRun').mockImplementation(() => {
|
||||
throw new Error('dependency failed')
|
||||
})
|
||||
expect(() => resolveCronOccurrence(record, Date.parse('2026-11-01T07:00:00.000Z')))
|
||||
.toThrow(/cron evaluation failed: dependency failed/)
|
||||
thrownNext.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('durable Cron replay', () => {
|
||||
it('decodes canonical records and advances only from persisted dispatch facts', () => {
|
||||
const create = event(cronCreate(), 0)
|
||||
expect(decodeScheduleChange(create.data)).toEqual(cronCreate())
|
||||
const dispatch = event({
|
||||
version: 1,
|
||||
operation: 'dispatch',
|
||||
id: 'schedule-cron',
|
||||
occurrenceAt: '2026-08-08T01:00:00.000Z',
|
||||
acceptedAt: '2026-08-08T03:00:00.000Z',
|
||||
nextScheduledAt: '2026-08-11T01:00:00.000Z',
|
||||
}, 1)
|
||||
expect(foldScheduleEvents([create, dispatch])).toEqual({
|
||||
active: [{
|
||||
...cronCreate().schedule,
|
||||
scheduledAt: '2026-08-11T01:00:00.000Z',
|
||||
}],
|
||||
seenIds: ['schedule-cron'],
|
||||
lastRecurringAcceptedAt: '2026-08-08T03:00:00.000Z',
|
||||
})
|
||||
expect(scheduleReminderPresentation([create, dispatch], 1)).toEqual({
|
||||
scheduleId: 'schedule-cron',
|
||||
prompt: 'daily review',
|
||||
occurrenceAt: '2026-08-08T01:00:00.000Z',
|
||||
deliveryMode: 'session-local',
|
||||
})
|
||||
})
|
||||
|
||||
it('terminates at exhaustion and rejects mismatched or non-monotonic dispatches', () => {
|
||||
const create = event(cronCreate(), 0)
|
||||
const terminal = event({
|
||||
version: 1,
|
||||
operation: 'dispatch',
|
||||
id: 'schedule-cron',
|
||||
occurrenceAt: '2026-08-08T01:00:00.000Z',
|
||||
acceptedAt: '2026-08-08T03:00:00.000Z',
|
||||
}, 1)
|
||||
expect(foldScheduleEvents([create, terminal]).active).toEqual([])
|
||||
expect(() => foldScheduleEvents([
|
||||
create,
|
||||
event({ version: 1, operation: 'dispatch', id: 'schedule-cron', acceptedAt: '2026-08-08T03:00:00.000Z' }, 1),
|
||||
])).toThrow(/cron dispatch must contain occurrenceAt/)
|
||||
expect(() => foldScheduleEvents([
|
||||
create,
|
||||
event({
|
||||
version: 1,
|
||||
operation: 'dispatch',
|
||||
id: 'schedule-cron',
|
||||
occurrenceAt: '2026-08-07T00:59:00.000Z',
|
||||
acceptedAt: '2026-08-08T03:00:00.000Z',
|
||||
}, 1),
|
||||
])).toThrow(/monotonic progression/)
|
||||
expect(() => foldScheduleEvents([
|
||||
create,
|
||||
event({
|
||||
version: 1,
|
||||
operation: 'dispatch',
|
||||
id: 'schedule-cron',
|
||||
occurrenceAt: '2026-08-08T01:00:00.000Z',
|
||||
acceptedAt: '2026-08-08T03:00:00.000Z',
|
||||
nextScheduledAt: '2026-08-08T03:00:00.000Z',
|
||||
}, 1),
|
||||
])).toThrow(/monotonic progression/)
|
||||
const decoded = decodeScheduleChange(cronCreate())
|
||||
if (decoded.operation !== 'create') throw new Error('expected decoded create')
|
||||
const decodedRecord = decoded.schedule
|
||||
if (decodedRecord.kind !== 'cron') throw new Error('expected decoded Cron record')
|
||||
expect(() => resolveCronOccurrence(decodedRecord, Number.NaN)).toThrow(/acceptedAt/)
|
||||
expect(() => resolveCronOccurrence(
|
||||
decodedRecord,
|
||||
Date.parse('2026-08-07T00:59:00.000Z'),
|
||||
)).toThrow(/cannot precede/)
|
||||
})
|
||||
|
||||
it('shares gate projection and exhaustion with Every records', () => {
|
||||
const gateSource = {
|
||||
version: 1,
|
||||
operation: 'create',
|
||||
schedule: {
|
||||
id: 'schedule-gate',
|
||||
kind: 'every',
|
||||
prompt: 'gate',
|
||||
everySeconds: 300,
|
||||
scheduledAt: '2026-08-05T11:55:00.000Z',
|
||||
},
|
||||
}
|
||||
const activeCron = cronCreate('schedule-cron', '2026-08-05T12:03:00.000Z', '3 12 * * *', 'UTC')
|
||||
const folded = foldScheduleEvents([
|
||||
event(gateSource, 0),
|
||||
event({
|
||||
version: 1,
|
||||
operation: 'dispatch',
|
||||
id: 'schedule-gate',
|
||||
acceptedAt: '2026-08-05T12:00:00.000Z',
|
||||
}, 1),
|
||||
event({ version: 1, operation: 'delete', id: 'schedule-gate' }, 2),
|
||||
event(activeCron, 3),
|
||||
])
|
||||
expect(scheduleView(
|
||||
folded.active[0]!,
|
||||
Date.parse('2026-08-05T12:03:00.000Z'),
|
||||
folded.lastRecurringAcceptedAt,
|
||||
)).toMatchObject({
|
||||
kind: 'cron',
|
||||
state: 'overdue',
|
||||
deliveryNotBefore: '2026-08-05T12:05:00.000Z',
|
||||
})
|
||||
|
||||
const exhausted = foldScheduleEvents([
|
||||
event({
|
||||
...gateSource,
|
||||
schedule: { ...gateSource.schedule, scheduledAt: '9999-12-31T23:55:00.000Z' },
|
||||
}, 0),
|
||||
event(cronCreate(
|
||||
'schedule-staggered-cron',
|
||||
'9999-12-31T23:58:00.000Z',
|
||||
'58 23 * * *',
|
||||
'UTC',
|
||||
), 1),
|
||||
event({
|
||||
version: 1,
|
||||
operation: 'dispatch',
|
||||
id: 'schedule-gate',
|
||||
acceptedAt: '9999-12-31T23:57:30.000Z',
|
||||
}, 2),
|
||||
])
|
||||
expect(exhausted.active).toEqual([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ ...cronCreate(), schedule: { ...cronCreate().schedule, cron: '00 9 * * 1,2,3,4,5' } },
|
||||
{ ...cronCreate(), schedule: { ...cronCreate().schedule, scheduledAt: '2026-08-07T01:00:01.000Z' } },
|
||||
{ ...cronCreate(), schedule: { ...cronCreate().schedule, extra: true } },
|
||||
{ ...cronCreate(), schedule: { ...cronCreate().schedule, prompt: '' } },
|
||||
{ ...cronCreate(), schedule: { ...cronCreate().schedule, cron: 1 } },
|
||||
{ ...cronCreate(), schedule: { ...cronCreate().schedule, timeZone: 1 } },
|
||||
{ ...cronCreate(), schedule: { ...cronCreate().schedule, timeZone: 'CST' } },
|
||||
{ ...cronCreate(), schedule: { ...cronCreate().schedule, cron: 'not cron' } },
|
||||
{ ...cronCreate(), schedule: { ...cronCreate().schedule, kind: 'calendar' } },
|
||||
])('rejects noncanonical durable Cron data %#', (data) => {
|
||||
expect(() => decodeScheduleChange(data)).toThrow(ScheduleLogError)
|
||||
})
|
||||
|
||||
it('replays structural Cron facts without current frequency or ICU canonicalization', () => {
|
||||
expect(decodeScheduleChange(cronCreate(
|
||||
'schedule-legacy-zone',
|
||||
'2026-08-07T01:00:00.000Z',
|
||||
'* * 31 2 *',
|
||||
'Europe/Kyiv',
|
||||
))).toMatchObject({
|
||||
operation: 'create',
|
||||
schedule: {
|
||||
id: 'schedule-legacy-zone',
|
||||
cron: '* * 31 2 *',
|
||||
timeZone: 'Europe/Kyiv',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
MIN_RECURRING_INTERVAL_SECONDS,
|
||||
ScheduleId,
|
||||
createAfterScheduleRecord,
|
||||
createCronScheduleRecord,
|
||||
createEveryScheduleRecord,
|
||||
} from '../src/domain.ts'
|
||||
import { MAX_TIMER_DELAY_MS, ScheduleOwner } from '../src/runtime.ts'
|
||||
@@ -132,6 +133,17 @@ function appendEvery(
|
||||
test.agent.session.append('schedule/change', { version: 1, operation: 'create', schedule: record })
|
||||
}
|
||||
|
||||
function appendCron(
|
||||
test: RuntimeHarness,
|
||||
id: string,
|
||||
cron: string,
|
||||
createdAt: number,
|
||||
prompt = 'calendar review',
|
||||
): void {
|
||||
const record = createCronScheduleRecord(ScheduleId(id), prompt, cron, 'UTC', createdAt)
|
||||
test.agent.session.append('schedule/change', { version: 1, operation: 'create', schedule: record })
|
||||
}
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
for (let index = 0; index < 8; index += 1) await Promise.resolve()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
@@ -313,6 +325,104 @@ describe('Schedule timer and admission runtime', () => {
|
||||
await owner.dispose()
|
||||
})
|
||||
|
||||
it('batches overdue Every and Cron records with independent durable dispatch shapes', async () => {
|
||||
const test = await harness()
|
||||
appendEvery(test, 'schedule-every', 300, Date.parse('2026-08-05T11:53:00.000Z'), 'fixed rate')
|
||||
appendCron(
|
||||
test,
|
||||
'schedule-cron',
|
||||
'0 12 * * *',
|
||||
Date.parse('2026-08-04T12:01:00.000Z'),
|
||||
'calendar rate',
|
||||
)
|
||||
const owner = ownerFor(test)
|
||||
owner.start()
|
||||
await settle()
|
||||
|
||||
expect(test.followed).toHaveLength(1)
|
||||
const block = test.followed[0]?.content[0]
|
||||
if (block?.type !== 'text') throw new Error('expected mixed recurring batch text')
|
||||
expect(block.text).toContain('"schedule_id":"schedule-every"')
|
||||
expect(block.text).toContain('"schedule_id":"schedule-cron"')
|
||||
const dispatches = test.agent.session.events.filter(event =>
|
||||
event.type === 'schedule/change' && event.data.operation === 'dispatch')
|
||||
expect(dispatches.map(event => event.data)).toEqual([
|
||||
{
|
||||
version: 1,
|
||||
operation: 'dispatch',
|
||||
id: 'schedule-every',
|
||||
acceptedAt: '2026-08-05T12:00:00.000Z',
|
||||
},
|
||||
{
|
||||
version: 1,
|
||||
operation: 'dispatch',
|
||||
id: 'schedule-cron',
|
||||
occurrenceAt: '2026-08-05T12:00:00.000Z',
|
||||
acceptedAt: '2026-08-05T12:00:00.000Z',
|
||||
nextScheduledAt: '2026-08-06T12:00:00.000Z',
|
||||
},
|
||||
])
|
||||
await owner.dispose()
|
||||
})
|
||||
|
||||
it('waits for the shared gate instead of a staggered future Cron target', async () => {
|
||||
const test = await harness()
|
||||
appendEvery(test, 'schedule-overdue', 300, Date.parse('2026-08-05T11:53:00.000Z'), 'overdue')
|
||||
const owner = ownerFor(test)
|
||||
owner.start()
|
||||
await settle()
|
||||
appendCron(
|
||||
test,
|
||||
'schedule-staggered-cron',
|
||||
'4 12 * * *',
|
||||
Date.parse('2026-08-05T11:59:00.000Z'),
|
||||
'staggered cron',
|
||||
)
|
||||
owner.requestDrive()
|
||||
await settle()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(180_000)
|
||||
await settle()
|
||||
const flushesAtFirstDue = test.controls.flushCount
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
await settle()
|
||||
expect(test.controls.flushCount).toBe(flushesAtFirstDue)
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
await settle()
|
||||
expect(test.followed).toHaveLength(2)
|
||||
const batch = test.followed[1]?.content[0]
|
||||
if (batch?.type !== 'text') throw new Error('expected mixed gate batch')
|
||||
expect(batch.text).toContain('"schedule_id":"schedule-overdue"')
|
||||
expect(batch.text).toContain('"schedule_id":"schedule-staggered-cron"')
|
||||
await owner.dispose()
|
||||
})
|
||||
|
||||
it('omits Cron nextScheduledAt when the four-digit calendar is exhausted', async () => {
|
||||
vi.setSystemTime(new Date('9999-12-31T23:59:00.000Z'))
|
||||
const test = await harness()
|
||||
appendCron(
|
||||
test,
|
||||
'schedule-final-cron',
|
||||
'59 23 31 12 *',
|
||||
Date.parse('9999-12-31T23:58:00.000Z'),
|
||||
'final cron',
|
||||
)
|
||||
const owner = ownerFor(test)
|
||||
owner.start()
|
||||
await settle()
|
||||
|
||||
const dispatch = test.agent.session.events.find(event =>
|
||||
event.type === 'schedule/change' && event.data.operation === 'dispatch')
|
||||
expect(dispatch?.data).toEqual({
|
||||
version: 1,
|
||||
operation: 'dispatch',
|
||||
id: 'schedule-final-cron',
|
||||
occurrenceAt: '9999-12-31T23:59:00.000Z',
|
||||
acceptedAt: '9999-12-31T23:59:00.000Z',
|
||||
})
|
||||
await owner.dispose()
|
||||
})
|
||||
|
||||
it('restores the recurring gate while allowing an overdue one-shot to bypass it', async () => {
|
||||
const test = await harness()
|
||||
appendEvery(test, 'schedule-every', 300, Date.parse('2026-08-05T11:43:00.000Z'))
|
||||
|
||||
@@ -173,12 +173,22 @@ describe('Schedule tool protocol', () => {
|
||||
expect(value(await execute(test, 'schedule_create', { prompt: 'x', after_seconds: 1, at: 'later' })))
|
||||
.toEqual({
|
||||
code: 'invalid_selector',
|
||||
message: 'schedule_create accepts exactly one of after_seconds, at, or every_seconds.',
|
||||
message: 'schedule_create accepts exactly one of after_seconds, at, every_seconds, or cron with time_zone.',
|
||||
})
|
||||
expect(value(await execute(test, 'schedule_create', { prompt: 'x', every_seconds: 1.5 })))
|
||||
.toEqual({ code: 'invalid_rule', message: 'every_seconds must be a safe integer.' })
|
||||
expect(value(await execute(test, 'schedule_create', { prompt: 'x', every_seconds: 299 })))
|
||||
.toEqual({ code: 'frequency_too_high', message: 'every_seconds must be at least 300.' })
|
||||
for (const args of [
|
||||
{ prompt: 'x', cron: '0 9 * * *' },
|
||||
{ prompt: 'x', time_zone: 'UTC' },
|
||||
{ prompt: 'x', every_seconds: 300, cron: '0 9 * * *', time_zone: 'UTC' },
|
||||
]) {
|
||||
expect(value(await execute(test, 'schedule_create', args))).toEqual({
|
||||
code: 'invalid_selector',
|
||||
message: 'schedule_create accepts exactly one of after_seconds, at, every_seconds, or cron with time_zone.',
|
||||
})
|
||||
}
|
||||
expect(test.flushes.count).toBe(0)
|
||||
expect(test.agent.session.events.filter(event => event.type === 'schedule/change')).toEqual([])
|
||||
})
|
||||
@@ -302,6 +312,62 @@ describe('Schedule tool protocol', () => {
|
||||
expect(create?.data).not.toHaveProperty('anchorAt')
|
||||
})
|
||||
|
||||
it('creates and lists a canonical explicit-zone Cron record', async () => {
|
||||
const test = await harness()
|
||||
expect(value(await execute(test, 'schedule_create', {
|
||||
prompt: ' workday review ',
|
||||
cron: '00 09 * * 1,2,3,4,5',
|
||||
time_zone: 'US/Eastern',
|
||||
}))).toEqual({
|
||||
id: 'schedule-1',
|
||||
kind: 'cron',
|
||||
prompt: 'workday review',
|
||||
cron: '0 9 * * 1,2,3,4,5',
|
||||
timeZone: 'America/New_York',
|
||||
scheduledAt: '2026-08-05T13:00:00.000Z',
|
||||
state: 'scheduled',
|
||||
deliveryMode: 'session-local',
|
||||
})
|
||||
expect(value(await execute(test, 'schedule_list', {}))).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'schedule-1',
|
||||
kind: 'cron',
|
||||
cron: '0 9 * * 1,2,3,4,5',
|
||||
timeZone: 'America/New_York',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects Cron creation after the shared gate exhausts despite a wall-clock rollback', async () => {
|
||||
const test = await harness()
|
||||
test.agent.session.append('schedule/change', {
|
||||
version: 1,
|
||||
operation: 'create',
|
||||
schedule: {
|
||||
id: 'schedule-final',
|
||||
kind: 'every',
|
||||
prompt: 'final batch',
|
||||
everySeconds: 300,
|
||||
scheduledAt: '9999-12-31T23:55:00.000Z',
|
||||
},
|
||||
} as never)
|
||||
test.agent.session.append('schedule/change', {
|
||||
version: 1,
|
||||
operation: 'dispatch',
|
||||
id: 'schedule-final',
|
||||
acceptedAt: '9999-12-31T23:57:30.000Z',
|
||||
} as never)
|
||||
vi.setSystemTime(new Date('9999-12-31T23:50:00.000Z'))
|
||||
|
||||
expect(value(await execute(test, 'schedule_create', {
|
||||
prompt: 'rolled back', cron: '55 23 * * *', time_zone: 'UTC',
|
||||
}))).toEqual({
|
||||
code: 'time_out_of_range',
|
||||
message: 'No compliant recurring delivery time remains representable within the four-digit-year range.',
|
||||
})
|
||||
expect(test.agent.session.events.filter(event => event.type === 'schedule/change')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('rejects Every creation after the shared gate exhausts despite a wall-clock rollback', async () => {
|
||||
const test = await harness()
|
||||
test.agent.session.append('schedule/change', {
|
||||
@@ -554,6 +620,47 @@ describe('Schedule tool protocol', () => {
|
||||
expect(test.agent.session.events.filter(event => event.type === 'schedule/change')).toEqual([])
|
||||
})
|
||||
|
||||
it('returns stable Cron validation errors after persistence preflight', async () => {
|
||||
const test = await harness()
|
||||
expect(value(await execute(test, 'schedule_create', {
|
||||
prompt: 'too frequent', cron: '*/4 * * * *', time_zone: 'UTC',
|
||||
}))).toEqual({
|
||||
code: 'frequency_too_high',
|
||||
message: 'cron occurrences must be at least five minutes apart.',
|
||||
})
|
||||
expect(value(await execute(test, 'schedule_create', {
|
||||
prompt: 'bad zone', cron: '0 9 * * *', time_zone: 'CST',
|
||||
}))).toEqual({
|
||||
code: 'invalid_time_zone',
|
||||
message: 'time_zone must be UTC or a valid IANA Area/Location name.',
|
||||
})
|
||||
expect(value(await execute(test, 'schedule_create', {
|
||||
prompt: 'bad weekday step', cron: '0 0 * * */8', time_zone: 'UTC',
|
||||
}))).toEqual({
|
||||
code: 'invalid_rule',
|
||||
message: 'cron day-of-week has an unsupported value.',
|
||||
})
|
||||
expect(value(await execute(test, 'schedule_create', {
|
||||
prompt: 'impossible', cron: '* * 31 2 *', time_zone: 'UTC',
|
||||
}))).toEqual({
|
||||
code: 'no_future_occurrence',
|
||||
message: 'The cron rule has no future four-digit-year occurrence.',
|
||||
})
|
||||
expect(test.flushes.count).toBe(4)
|
||||
expect(test.agent.session.events.filter(event => event.type === 'schedule/change')).toEqual([])
|
||||
})
|
||||
|
||||
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', {
|
||||
|
||||
@@ -342,7 +342,8 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
scope: ctx => catalogChildScopes.get(ctx) as Agent,
|
||||
note:
|
||||
'Registered only inside live root Agent scopes created after the opt-in Schedule plugin loads. '
|
||||
+ 'Version 1 accepts positive safe-integer after_seconds and discloses session-local delivery; '
|
||||
+ 'Version 1 accepts after_seconds, absolute at, fixed-rate every_seconds, and restricted '
|
||||
+ 'five-field cron with an explicit IANA time_zone, and discloses session-local delivery; '
|
||||
+ 'management reads and mutations require the shared Session persistence barrier.',
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user