feat(schedule): add explicit-time-zone cron reminders

This commit is contained in:
pku-xht
2026-08-06 23:48:57 +08:00
committed by Tianyi Cui
parent 9abecc103d
commit 31c0c6a9f6
23 changed files with 1833 additions and 92 deletions

View File

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

View File

@@ -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 构造器会重映射 099 年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 路径。只要有周期性记录因门控关闭而处于 overdueowner 就会在该门控时点或更早的一次性提醒到期时唤醒,而不会在其间的周期性目标处唤醒。周期性 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 路径。只要有周期性记录因门控关闭而处于 overdueowner 就会在该门控时点或更早的一次性提醒到期时唤醒,而不会在其间的周期性目标处唤醒。周期性 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`日历规则需要明确的语法、IANADST 求值,以及在 history 中保持稳定的转换语义
- **受限的日历语言**cron 只接受本文所述的数值五字段子集,其中一个日期字段必须不受限,并要求显式 IANA 时区它不开放名称、macro、秒、年份、Quartz operator 或用户可选的 DST 策略
- **Session 时区不可变**:新的 Schedule Web Session 会记录一个默认浏览器时区,且没有时区编辑器。旧有的无 header Session 仍为 `unavailable`,不匹配或有歧义的请求必须显式指定 `time_zone`
- **存在狭窄的崩溃重复窗口**:同步 `followup` 获得准入后、dispatch 检查点完成前发生崩溃,可能使提醒在恢复后重复;此包不承诺模型完成、用户确认或外部副作用恰好一次。
- **加载顺序边界**:插件不会扫描或接管加载时已经 live 的 agent。

View File

@@ -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"
}
}

View File

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

View File

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

View File

@@ -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) {

View File

@@ -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(),
)
}

View File

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

View File

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

View File

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

View File

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