refactor(schedule): bound fixed-rate reminders

This commit is contained in:
Tianyi Cui
2026-08-09 17:04:48 +08:00
parent 3fa4c012b1
commit 45ff1eab98
40 changed files with 982 additions and 3977 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/schedule/tool-schedule/README.md
README.md: 1c0e347d50fed2fe27741d584e6b3f158df1a081
README.zh.md: 3665cf7e37a30458017eb16cc9171ec541fc55ea
README.md: 3089648fa084893c1daacbb2cd3d3388302f7232
README.zh.md: ec586f4f148b125f9d10a52b03f3d2bf12cfdbfd

View File

@@ -2,57 +2,47 @@
English | [中文](README.zh.md)
`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.
`dsh-tool-schedule` gives future live root Agents three Session-scoped tools for durable reminders. Version 1 accepts positive safe-integer `after_seconds` delays, explicit absolute `at` targets, and fixed-rate `every_seconds` intervals of at least five minutes. The Session event log owns reminder state; timers, tool values, and model follow-ups are disposable projections of that log.
## Composition
Load this function plugin after `ctx.sessions`, `ctx.agents`, `ctx.tools`, `ctx.sessionPersistence`, and the persistence listener that implements Session flushes. Static injection makes a missing persistence service a composition error. The plugin listens only to later `agent/created` events, installs on runtime roots, and registers all tools through the exact `agent.ctx`. Agents that already existed when the plugin loaded and runtime children do not receive Schedule.
Load `@deepseek-ai/dsh-time-context` before publishing a root that should resolve local `at` values without an explicit zone. The official Schedule Web overlay does so. Explicit-offset and explicit-zone values remain usable without implicit request-zone context.
Time-context is not a Schedule dependency. A composition may mount `@deepseek-ai/dsh-time-context` so the model can interpret natural language in the browser's request-local zone, as the official Schedule Web overlay does. The model must still pass an explicit offset or `time_zone` to `schedule_create`; Schedule never imports or infers from model context.
Every operation that reads or decides from the Schedule fold first awaits `ctx.sessions.flush(session)`. A missing, rejected, or detached persistence path returns `persistence_uncertain`; it never turns an unconfirmed live suffix into a list or not-found answer. A successful create or actual delete also awaits a post-append barrier before confirming the mutation.
## 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; 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.
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 its submitted offset, local calendar fields, or interpreting zone; an `every` record stores `everySeconds` and treats `scheduledAt` as the earliest creation-anchor-aligned occurrence not yet dispatched. Delete and one-shot dispatch carry only the id. Every dispatch adds `acceptedAt`, from which replay advances directly to the first anchor-aligned target after that decision time.
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.
Replay rejects unknown versions, extra fields, reused ids, mismatched one-shot or Every dispatch shapes, and delete or dispatch transitions against inactive records. Normal Sessions fold the complete log. A fork folds only `session.events.slice(session.header.seedLength ?? 0)`, so it does not inherit its parent's reminders. The package's `./invariant` companion applies the same policy to existing logs and candidate events.
`scheduleReminderPresentation(events, dispatchSeq, seedLength)` is the pure Host-facing receipt projection. It returns `scheduleId`, prompt, and occurrence from the dispatch's nearest preceding same-id create; the client renderer adds the fixed `session-local` label. The current fork's `seedLength` is a hard boundary for child-owned dispatches, while inherited dispatches search their persisted prefix; resumed ancestors therefore remain renderable, nested generations may reuse session-local ids, and presentation never changes live ownership.
## Absolute-time input
## Absolute-time context
The `at` selector is either a strict `YYYY-MM-DDTHH:mm:ss[.S|.SS|.SSS](Z|±HH:MM)` string or `{ date: "YYYY-MM-DD", time: "HH:mm:ss[.S|.SS|.SSS]", time_zone: string }`. The string identifies an instant through `Z` or its numeric offset. The local form always requires explicit `UTC` or a valid IANA Area/Location zone. Missing `time_zone`, offset-free strings, extra keys, normalized calendar dates, invalid offsets, and non-future targets are rejected.
The `at` selector is either a strict `YYYY-MM-DDTHH:mm:ss[.S|.SS|.SSS](Z|±HH:MM)` string or `{ date: "YYYY-MM-DD", time: "HH:mm:ss[.S|.SS|.SSS]", time_zone?: string }`. The offset form already identifies one instant. The local form validates an explicit `UTC` or IANA Area/Location zone, or may omit `time_zone` only when the current open turn has a time-context reading and its original user-rpc sources derive one client zone equal to the immutable Session zone.
The Web Host validates and canonicalizes the browser zone at Session creation and on every prompt. Session creation fixes `SessionHeader.timeZone`; each prompt instead carries its own `clientTimeZone` in the user-message source, so concurrent tabs do not overwrite shared state. Schedule derives directly from those original owners rather than copying them into the time-context source. A headerless Session, a missing or mixed client-zone result, or a client/Session mismatch returns `timezone_confirmation_required` with the known zones and requires an explicit `time_zone`.
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. The package invariant applies the same current calendar validation only to new live create and dispatch appends. 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.
Schedule owns deterministic calendar normalization. Local times inside a daylight-saving gap are rejected. An overlap chooses its first, earlier instant. A successful create retains only canonical UTC `scheduledAt`; no Schedule path reads the browser, Session header, model time-context, connection, or process time zone.
## 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`, `every_seconds`, and `time_zone`.
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 `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`, `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.
One Agent-scoped queue serializes each accepted management transaction and the live owner's due transaction from preflight through any post-append barrier. `schedule_create` requires exactly one of `after_seconds`, `at`, or `every_seconds`, validates shape-only failures before entering the queue, then checkpoints, allocates a never-reused id, appends create, and checkpoints again. `schedule_list` returns active records in creation order with `state: "scheduled" | "overdue"` and `deliveryMode: "session-local"`. `schedule_delete` rejects an empty or whitespace-padded id before the queue and appends only for an active id; an unknown or terminal id returns `{ id, deleted: false, code: "schedule_not_found" }` after 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.
Every successful management preflight also asks the live owner to recompute. This recovers a retained create or delete batch after a previous post-append barrier returned `persistence_uncertain`, without a Schedule-specific 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`, `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.
The closed version-1 domain error codes are `invalid_prompt`, `invalid_selector`, `invalid_rule`, `invalid_time_zone`, `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.
## 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; 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.
The live owner derives the earliest target 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. Due one-shots have priority and enter one later turn at a time. When no one-shot is due, all overdue Every records form one batch in target and creation order.
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.
An overdue reminder first checkpoints persistence. If a turn or another maintenance task owns the Agent, `runMaintenance()` rejects the idle-phase claim; the record stays active and the owner retries after `whenIdle()`. A successful maintenance task refolds, samples one decision time, builds the appropriate fixed framing, synchronously queues `followup()`, and appends dispatch before releasing the phase. A one-shot appends its id. Each Every record in a batch appends its id plus the same `acceptedAt`; integer arithmetic selects that record's latest due creation-anchor-aligned occurrence and advances it directly to the first future target. Missed intervals are never enumerated or replayed, distinct overdue records each contribute one occurrence, and there is no shared recurrence gate. Waking input remains parked until release, after which the owner checkpoints dispatch.
Agent or plugin disposal cancels timers, stops new work, and awaits in-flight preflights and idle waits. It never appends delete records during teardown.
The follow-up opens a normal later turn after the Agent becomes fully idle; it never steers or interrupts the current conversation. Its assistant output appears through the ordinary transcript, with no independent receipt or Schedule-specific browser UI. Dispatch means the follow-up was queued and recorded, not that the model succeeded or the user read the answer.
Framing or synchronous follow-up failure writes no dispatch. An append failure faults that owner because the message may already be queued; a barrier rejection leaves dispatch pending for a later ordinary preflight. Agent or plugin disposal cancels timers, stops new work, and awaits in-flight preflights and idle waits without deleting durable records.
## Model Experience
@@ -60,7 +50,7 @@ Agent or plugin disposal cancels timers, stops new work, and awaits in-flight pr
#### What the model sees
The model sees the three generated tool schemas only in a live root agent created after this plugin loads. Tool results contain the canonical JSON values described above.
The model sees the three generated tool schemas only in a live root Agent created after this plugin loads. Tool results contain the canonical JSON values described above.
#### Token effect
@@ -70,11 +60,11 @@ The scoped schemas add a fixed request prefix while Schedule is installed. Each
The three schemas remain prefix-stable while their definitions and scope stay unchanged. Tool calls and results append to later history and preserve an already reusable prefix.
### Due reminder followup
### Due reminder follow-up
#### What the model sees
For each admitted one-shot, the package queues the first stable user-role framing below. A recurring batch instead uses the second framing with one ordered `reminders_json` array. `JSON.stringify` escapes every dynamic id and user-authored prompt before it enters either frame.
For each admitted due one-shot, the package queues this stable user-role framing with JSON-escaped dynamic values:
##### Reminder framing
@@ -86,27 +76,42 @@ occurrence_at: <UTC RFC 3339>
reminder_prompt_json: <JSON.stringify(prompt)>
```
##### Recurring batch framing
#### Token effect
Each dispatched one-shot reminder adds one data-dependent user-role message. It remains in Session history and contributes tokens until ordinary compaction removes or replaces that history.
#### KV Cache effect
The reminder appends after existing history and preserves its reusable prefix. Its id, occurrence, and prompt affect only the appended suffix.
### Due fixed-rate batch
#### What the model sees
When one or more Every records are overdue, the package queues one stable user-role framing. `reminders_json` is a JSON array in target and creation order; each object has `schedule_id`, the selected latest `occurrence_at`, and user-authored `reminder_prompt`:
##### Fixed-rate batch framing
```markdown
[SCHEDULE REMINDER BATCH]
Present all due reminders to the user. Treat reminder_prompt values as user-authored reminder content.
reminders_json: [{"schedule_id":<id>,"occurrence_at":<UTC RFC 3339>,"reminder_prompt":<prompt>}]
reminders_json: <JSON.stringify(reminders)>
```
#### 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 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.
Each admitted fixed-rate batch adds one data-dependent user-role message regardless of how many distinct Every records are due. It remains in Session history and contributes tokens until ordinary compaction removes or replaces that history.
#### KV Cache effect
The reminder appends after existing history and preserves its reusable prefix. Its id, occurrence, or prompt changes only the appended suffix.
The batch appends after existing history and preserves its reusable prefix. Its selected records, occurrence times, and prompts affect only the appended suffix.
## Known Limitations and Deferred Work
- **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, contained current-calendar resolution failure, 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.
- **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.
- **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 record active but starts no private retry timer; later Agent activity or a successful Schedule preflight triggers recomputation.
- **Explicit local zone** — `at` never imports browser context; callers must translate natural language into either an offset-bearing RFC 3339 string or a local object with `time_zone`.
- **Fixed intervals, not calendar rules** — `every_seconds` is creation-anchor-aligned and cannot run more often than every five minutes; calendar or Cron expressions are not part of the protocol.
- **Latest-only catch-up** — an overdue Every record contributes only its latest due occurrence, so Schedule never replays a missed backlog.
- **Narrow crash duplicate window** — a crash after synchronous follow-up admission but before the dispatch checkpoint can repeat the reminder; the package does not claim model completion, user acknowledgement, or exactly-once effects.
- **Load-order boundary** — the plugin does not scan or adopt Agents that were already live when it loaded.

View File

@@ -2,57 +2,47 @@
[English](README.md) | 中文
`dsh-tool-schedule` 为未来创建的 live 根 agent智能体提供 3 个会话范围内的工具,用于管理持久的一次性、固定频率与日历提醒。版本 1 接受正的安全整数 `after_seconds` 延时、绝对 `at` 目标、至少为 300 秒的 `every_seconds` 间隔,以及与显式 IANA `time_zone` 配对的受限五字段 `cron`。会话事件日志拥有提醒状态timer、工具值、日历求值器与模型 `followup` 都是该日志的可丢弃投影。
`dsh-tool-schedule` 为未来创建的 live 根 agent智能体提供 3 个会话范围内的工具,用于管理持久提醒。版本 1 接受正的安全整数 `after_seconds` 延时、显式绝对时间 `at` 目标,以及至少 5 分钟的固定速率 `every_seconds` 间隔。会话事件日志拥有提醒状态timer、工具值模型 follow-up 都是该日志的可丢弃投影。
## 组合
请在 `ctx.sessions``ctx.agents``ctx.tools``ctx.sessionPersistence`,以及实现 Session flush 的持久化监听器之后加载此函数插件。静态注入会使缺少持久化服务的组合直接失败。此插件只监听后续的 `agent/created` 事件,在运行时根 agent 上安装,并通过完全相同的 `agent.ctx` 注册所有工具。插件加载时已经存在的 agent 与运行时子 agent 不会获得 Schedule。
若根 agent 需要在未显式指定时区时解析本地 `at` 值,请在发布该 agent 前加载 `@deepseek-ai/dsh-time-context`。官方 Schedule Web overlay 会按此顺序加载。带显式偏移量的值和带显式时区的值即使没有隐式请求时区上下文仍可使用
Time-context 不是 Schedule 的依赖。组合可以挂载 `@deepseek-ai/dsh-time-context`,使模型能够按浏览器的请求本地时区解释自然语言;官方 Schedule Web overlay 正是如此。模型仍必须向 `schedule_create` 传入显式偏移量或 `time_zone`Schedule 绝不会从模型上下文中导入或推断该值
每项从 Schedule 折叠结果读取或作出判断的操作,都会先等待 `ctx.sessions.flush(session)`。持久化路径缺失、拒绝或已分离时,操作返回 `persistence_uncertain`;它绝不会把未经确认的 live 后缀当成列表或未找到结果。成功创建或实际删除后,还会等待追加后的持久化 barrier屏障再确认变更。
## 持久状态
此包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。折叠过程会终结没有下一个目标的周期性记录共享门控不再有年份为四位数的准入时点时还会终结所有剩余的周期性记录
此包拥有严格的版本 1 `schedule/change` create、delete 与 dispatch 联合。每条 create 记录都包含稳定的会话本地 `ScheduleId`、已 trim 的提示词,以及使用四位年份的 RFC 3339 UTC `scheduledAt``after` 记录还会存储 `afterSeconds``at` 记录不会保留所提交的偏移量、本地日历字段或解释该值时所用的时区;`every` 记录存储 `everySeconds`,并把 `scheduledAt` 视为尚未 dispatch 的最早一个创建锚点对齐发生时点。delete 与一次性 dispatch 只携带 id。Every dispatch 还会添加 `acceptedAt`回放会据此直接推进到该决策时点之后的第一个锚点对齐目标
回放会拒绝未知版本、额外字段、重复使用的 id、不匹配的 dispatch 形状、间隔不足 300 秒的周期性 batch以及针对非活动记录的转换。普通会话折叠完整日志。fork 只折叠 `session.events.slice(session.header.seedLength ?? 0)`,因此不会继承父会话的提醒。此包的 `./invariant` 配套会对现有日志和候选事件应用相同策略。
回放会拒绝未知版本、额外字段、重复使用的 id、形状不匹配的一次性或 Every dispatch以及针对非活动记录的 delete 或 dispatch 转换。普通会话折叠完整日志。fork 只折叠 `session.events.slice(session.header.seedLength ?? 0)`,因此不会继承父会话的提醒。此包的 `./invariant` 配套模块会对现有日志和候选事件应用相同策略。
`scheduleReminderPresentation(events, dispatchSeq, seedLength)` 是供 Host 使用的纯回执投影。它从 dispatch 之前最近的同 id create 返回 `scheduleId`、prompt 和 occurrenceclient renderer 添加固定的 `session-local` 标签。当前 fork 的 `seedLength` 是 child 自有 dispatch 的硬边界,而继承的 dispatch 则会搜索其已持久前缀;因此恢复后的祖先仍可渲染,嵌套 generation 可以复用会话本地 idpresentation 绝不会改变 live ownership。
## 绝对时间输入
## 绝对时间上下文
`at` selector 可以是严格的 `YYYY-MM-DDTHH:mm:ss[.S|.SS|.SSS](Z|±HH:MM)` 字符串,也可以是 `{ date: "YYYY-MM-DD", time: "HH:mm:ss[.S|.SS|.SSS]", time_zone: string }`。字符串通过 `Z` 或数值偏移量标识一个时刻。本地形式始终要求显式 `UTC` 或有效的 IANA Area/Location 时区。缺少 `time_zone`、不带偏移量的字符串、额外键、需要规范化的日历日期、无效偏移量和非未来目标都会被拒绝。
`at` selector 可以是严格的 `YYYY-MM-DDTHH:mm:ss[.S|.SS|.SSS](Z|±HH:MM)` 字符串,也可以是 `{ date: "YYYY-MM-DD", time: "HH:mm:ss[.S|.SS|.SSS]", time_zone?: string }`。偏移量形式本身即可确定一个时刻。本地形式会校验显式指定的 `UTC` 或 IANA Area/Location 时区;仅当当前 open turn 含有 time-context 读数,并且其原始 user-rpc 来源派生出唯一一个与不可变 Session 时区相等的客户端时区时,才可以省略 `time_zone`
Web Host 会在创建 Session 时以及每次提交提示词时校验并规范化浏览器时区。Session 创建会固定 `SessionHeader.timeZone`;每条提示词则会在用户消息来源中携带自己的 `clientTimeZone`因此并发标签页不会覆盖共享状态。Schedule 会直接从这些原始拥有方派生,而不会把它们复制进 time-context source。如果 Session 没有 header、客户端时区结果缺失或混杂或客户端与 Session 不匹配,系统会返回 `timezone_confirmation_required` 并附上已知时区,同时要求显式指定 `time_zone`
落在夏令时空档内的本地时间会被拒绝。遇到重叠时会选择第一次出现的较早时刻。创建成功后只保留规范化后的 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。package invariant 只对新发生的 live create 与 dispatch append 应用同一套当前日历验证。回放只校验规范化结构、整分钟的 UTC 值与单调 dispatch 关系;绝不会让当前 Croner、ICU 或频率证明重新裁定历史 occurrence。
Schedule 负责确定性的日历规范化。落在夏令时缺口内的本地时间会被拒绝;遇到重叠时会选择第一次出现的较早时刻。创建成功后只保留规范化后的 UTC `scheduledAt`Schedule 的任何路径都不会读取浏览器、Session 标头、模型 time-context、连接或进程时区
## 管理工具
生成的[工具目录](../../../docs/tool-catalog.md)负责 `schedule_create``schedule_list``schedule_delete` 的参数与输出 schema。虽然模型输入使用 `after_seconds``every_seconds``time_zone`,但其规范值中的记录字段使用 camelCase。
生成的[工具目录](../../../docs/tool-catalog.md)负责 `schedule_create``schedule_list``schedule_delete` 的参数与输出 schema。虽然模型输入使用 `after_seconds``time_zone`,但其规范值中的记录字段使用 camelCase。
一条 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" }`
一条 Agent-scoped 队列会将每项已接纳的管理事务与 live owner 的到期事务从 preflight 到任何 post-append barrier 全程串行化。`schedule_create` 要求 `after_seconds``at``every_seconds` 有且只有一项;它会在进入队列前验证只依赖输入形状的失败,随后执行检查点、分配永不复用的 id、追加 create再次执行检查点。`schedule_list` 按创建顺序返回活动记录,其中包含 `state: "scheduled" | "overdue"``deliveryMode: "session-local"``schedule_delete` 会在进入队列前拒绝空 id 或前后带空白的 id并只为活动 id 追加事件;未知或已终结的 id 会在 preflight 后返回 `{ id, deleted: false, code: "schedule_not_found" }`
每次成功的管理 preflight 还会要求 live owner 重新计算。这对 create 或 delete barrier 返回 `persistence_uncertain` 的情况很重要:后续 list 或 mutation 可以确认保留的 batch并立即 arm 或退役此时已持久化的 record而无需私有 persistence retry timer。
每次成功的管理 preflight 还会要求 live owner 重新计算。如果先前的 post-append barrier 返回 `persistence_uncertain`,这会恢复所保留的 create 或 delete batch而无需 Schedule 专属的持久化重试 timer。
版本 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 行为。
版本 1 的封闭领域错误代码包括 `invalid_prompt``invalid_selector``invalid_rule``invalid_time_zone``not_future``time_out_of_range``frequency_too_high``corrupt_schedule_log``persistence_uncertain``internal_error`。诊断文本保持稳定,不会暴露后端异常。渲染内容是规范值的确定性 JSON通用工具结果策略仍负责模型可见内容的 spill 行为。
## 交付生命周期
live owner 从持久折叠结果派生各个目标与最近一次周期性 batch。它会拆分超过 Node timer 范围的等待,并在每次唤醒后重新读取墙钟,因此时钟回拨不会提前触发,时钟前跳则会使记录进入 overdue 状态。固定频率推进始终锚定首个目标;日历推进则以持久目标作为在 history 中保持稳定的 baseline。延迟唤醒只为每条记录选择最近一次到期的 occurrence 与第一个未来目标,而不会回放错过期间积压的 occurrence
live owner 从持久折叠结果派生最早的目标。它会拆分超过 Node timer 范围的等待,并在每次唤醒后重新读取墙钟,因此时钟回拨不会提前触发,时钟前跳则会使记录进入 overdue 状态。已到期的一次性提醒优先,每次进入一个后续轮次。没有一次性提醒到期时,所有逾期 Every 记录会按目标时间和创建顺序组成一个批次
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。
overdue 提醒首先为持久化建立检查点。如果 agent 已被某个轮次或另一项 maintenance task 占用,`runMaintenance()` 会拒绝对 idle phase 的认领记录会保持活动owner 会在 `whenIdle()` 后重试。获准执行的 maintenance task 会重新折叠、采样一决策时点、构造相应的固定 framing、同步将 `followup()` 入队,并在释放 phase 前追加 dispatch。一次性提醒只追加 id。批次中的每条 Every 记录都会追加其 id 和相同的 `acceptedAt`;整数运算会选择该记录最新一个已到期且与创建锚点对齐的发生时点,并将记录直接推进到第一个未来目标。系统绝不会枚举或回放错过的间隔;每条不同的逾期记录各贡献一个发生时点,并且不存在共享的周期性准入门控。触发唤醒的 input 会保持 parked直到 phase 释放;随后 owner 为 dispatch 建立检查点。
agent 或插件执行 dispose资源释放会取消 timer、停止新工作并等待进行中的 preflight 和 idle wait。清理期间绝不会追加 delete 记录
Agent 完全 idle 后follow-up 会开启一个普通的后续轮次它绝不会中途引导或中断当前对话。assistant 输出通过普通 transcript文本记录显示不存在独立回执或 Schedule 专属浏览器 UI。dispatch 表示 follow-up 已入队并被记录,不表示模型成功或用户已读取回答
framing 构造或同步 follow-up 失败不会写入 dispatch。追加失败会使该 owner 进入故障状态因为消息可能已经入队barrier 拒绝会把 dispatch 留给后续普通 preflight。agent 或插件执行资源释放时,会取消 timer、停止新工作并等待进行中的 preflight 与 idle wait且不会删除持久记录。
## 模型体验
@@ -70,11 +60,11 @@ agent 或插件执行 dispose资源释放会取消 timer、停止新
3 个 schema 的定义与范围不变时,前缀保持稳定。工具调用和结果会追加到后续历史中,并保留已经可以复用的前缀。
### 到期提醒 followup
### 到期提醒 follow-up
#### 模型看到的内容
对于每条获得准入的一次性提醒,此包会将下方第一种稳定用户角色 framing 入队。周期性 batch 则使用第二种 framing其中包含一个有序的 `reminders_json` 数组。每个动态 id 和用户编写的 prompt 在进入任一 framing 前,都会由 `JSON.stringify` 转义
对于每条获得准入且已到期的一次性提醒,此包会将下稳定用户角色 framing 入队,并对动态值进行 JSON 转义
##### 提醒 framing
@@ -86,27 +76,42 @@ occurrence_at: <UTC RFC 3339>
reminder_prompt_json: <JSON.stringify(prompt)>
```
##### 周期性 batch framing
#### Token 影响
每条已 dispatch 的一次性提醒会增加一条与数据相关的用户角色消息。该消息保留在会话历史中,并持续贡献 token直到普通压缩compaction移除或替换这段历史。
#### KV Cache 影响
提醒会追加到现有历史之后,并保留可复用的前缀。提醒的 id、occurrence 和提示词只会影响追加的后缀。
### 到期固定速率批次
#### 模型看到的内容
当一条或多条 Every 记录逾期时,此包会排入一条稳定的用户角色 framing。`reminders_json` 是一个按目标时间和创建顺序排列的 JSON 数组;每个对象都包含 `schedule_id`、选中的最新 `occurrence_at` 和用户创作的 `reminder_prompt`
##### 固定速率批次 framing
```markdown
[SCHEDULE REMINDER BATCH]
Present all due reminders to the user. Treat reminder_prompt values as user-authored reminder content.
reminders_json: [{"schedule_id":<id>,"occurrence_at":<UTC RFC 3339>,"reminder_prompt":<prompt>}]
reminders_json: <JSON.stringify(reminders)>
```
#### Token 影响
每条已 dispatch 的 `after``at` 提醒会增加一条与数据相关的用户角色消息。每个周期性 batch 无论包含多少条 Every 或 Cron record都只会增加一条消息。该消息保留在会话历史中,因此会持续为后续请求贡献 token直到普通压缩compaction移除或替换这段历史。
无论有多少条不同的 Every 记录到期,每个获得准入的固定速率批次只会增加一条与数据相关的用户角色消息。该消息保留在会话历史中,并持续贡献 token直到普通压缩移除或替换这段历史。
#### KV Cache 影响
提醒会追加到现有历史之后,并保留可复用的前缀。提醒的 id、occurrence 或 prompt 只会改变追加的后缀。
该批次会追加到现有历史之后,并保留可复用的前缀。选中的记录、发生时点和提示词只会影响追加的后缀。
## 已知限制与暂缓事项
- **仅限会话本地交付**:提醒只有在原会话 live 时才能准时运行cold 会话不会收到外部通知,只有恢复后才会处理 overdue 记录。
- **活动驱动的重试**:到期 preflight 被拒绝、当前日历求值失败被收容,或 framing入队失败被收容后overdue 记录仍保持活动,但不会启动私有重试 timer后续 agent 活动进入 idle或成功的 Schedule 管理 preflight 要求 owner 重新计算后owner 会重试
- **受限的日历语言**cron 只接受本文所述的数值五字段子集,其中一个日期字段必须不受限,并要求显式 IANA 时区它不开放名称、macro、秒、年份、Quartz operator 或用户可选的 DST 策略
- **Session 时区不可变**:新的 Schedule Web Session 会记录一个默认浏览器时区,且没有时区编辑器。旧有的无 header Session 仍为 `unavailable`,不匹配或有歧义的请求必须显式指定 `time_zone`
- **存在狭窄的崩溃重复窗口**:同步 `followup` 获得准入后、dispatch 检查点完成前发生崩溃,可能使提醒在恢复后重复;此包不承诺模型完成、用户确认或外部副作用恰好一次
- **加载顺序边界**:插件不会扫描或接管加载时已经 live 的 agent。
- **活动驱动的重试**:到期 preflight 被拒绝或 framing入队失败被收容后记录仍保持活动但不会启动私有重试 timer后续 Agent 活动或成功的 Schedule preflight 会触发重新计算
- **显式本地时区**`at` 绝不会导入浏览器上下文;调用方必须把自然语言转换为带偏移量的 RFC 3339 字符串,或带 `time_zone` 的本地对象
- **固定间隔,而非日历规则**`every_seconds` 与创建锚点对齐,且运行频率不能高于每 5 分钟一次;协议不包含日历表达式或 Cron 表达式
- **只追赶最新一次**:逾期 Every 记录只贡献其最新一个到期发生时点,因此 Schedule 绝不会回放因错过间隔而形成的积压
- **存在狭窄的崩溃重复窗口**:同步 follow-up 获得准入后、dispatch 检查点完成前发生崩溃,可能使提醒重复;此包不承诺模型完成、用户确认或副作用恰好执行一次。
- **加载顺序边界**:插件不会扫描或接管加载时已经 live 的 Agent。

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-tool-schedule",
"description": "Agent-scoped durable one-shot, fixed-rate, and calendar reminders over the session event log",
"description": "Agent-scoped durable one-shot and fixed-rate reminders over the session event log",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -48,8 +48,5 @@
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"croner": "10.0.1"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
/**
* Agent-scoped durable one-shot, fixed-rate, and calendar reminders over the session event log.
* Agent-scoped durable one-shot and fixed-rate reminders over the session event log.
* @module @deepseek-ai/dsh-tool-schedule
*/
@@ -12,15 +12,19 @@ import { registerScheduleTools } from './tools.ts'
export type * from './types.ts'
export {
SCHEDULE_CHANGE_VERSION,
MIN_EVERY_INTERVAL_SECONDS,
ScheduleId,
ScheduleInputError,
ScheduleLogError,
allocateScheduleId,
createAfterScheduleRecord,
createAtScheduleRecord,
createEveryScheduleRecord,
decodeScheduleChange,
foldScheduleEvents,
renderReminderFraming,
renderEveryReminderBatchFraming,
resolveEveryOccurrence,
scheduleView,
} from './domain.ts'
export { registerScheduleTools } from './tools.ts'

View File

@@ -6,7 +6,7 @@
import type { Context } from 'cordis'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import { foldScheduleEvents, ScheduleLogError, validateLiveScheduleChange } from './domain.ts'
import { foldScheduleEvents, ScheduleLogError } from './domain.ts'
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-schedule'
@@ -15,22 +15,17 @@ export const name = 'tool-schedule-invariant'
/** Service required before reserving this package's invariant ownership. */
export const inject = ['invariants']
/** Convert an owned Schedule validation failure into the invariant service's failure channel. */
function report(run: () => void, fail: InvariantFailure): void {
/** Validate a complete exact-session stream under its fork suffix policy. */
function validate(events: readonly SessionEvent[], seedLength: number, fail: InvariantFailure): void {
try {
run()
foldScheduleEvents(events, seedLength)
} catch (error: unknown) {
/* v8 ignore next -- owned Schedule validators normalize failures to ScheduleLogError. */
/* v8 ignore next -- foldScheduleEvents normalizes every rejected stream to ScheduleLogError. */
if (!(error instanceof ScheduleLogError)) throw error
fail(error.message)
}
}
/** Validate a complete exact-session stream under its fork suffix policy. */
function validate(events: readonly SessionEvent[], seedLength: number, fail: InvariantFailure): void {
report(() => { foldScheduleEvents(events, seedLength) }, fail)
}
/* jscpd:ignore-start -- package companions share replay and dispatch plumbing */
/** Install replay and pre-append validation for the owned event stream. */
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
@@ -45,9 +40,6 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
const [session, event] = args as [Session, SessionEvent]
if (event.type !== 'schedule/change') return
validate([...session.events, event], session.header.seedLength ?? 0, fail)
report(() => {
validateLiveScheduleChange(session.events, event.data, session.header.seedLength ?? 0)
}, fail)
}, { global: true })
}, { inject: ['sessions'] })
/* jscpd:ignore-end */

View File

@@ -6,16 +6,11 @@
import type { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type {
OneShotScheduleRecord,
RecurringScheduleRecord,
} from './types.ts'
import type { EveryScheduleRecord, OneShotScheduleRecord } from './types.ts'
import {
foldScheduleEvents,
MIN_RECURRING_INTERVAL_SECONDS,
renderReminderBatchFraming,
renderEveryReminderBatchFraming,
renderReminderFraming,
resolveCronOccurrence,
resolveEveryOccurrence,
ScheduleLogError,
} from './domain.ts'
@@ -26,68 +21,50 @@ import { runScheduleTransaction } from './transaction.ts'
/** Largest delay that Node timers represent without clamping. */
export const MAX_TIMER_DELAY_MS = 2_147_483_647
interface RecurringDue {
readonly record: RecurringScheduleRecord
interface EveryDue {
readonly record: EveryScheduleRecord
readonly occurrenceAt: string
readonly nextScheduledAt?: string
}
type DueDecision =
| { readonly kind: 'one-shot'; readonly record: OneShotScheduleRecord }
| { readonly kind: 'recurring'; readonly reminders: readonly RecurringDue[]; readonly acceptedAt: string }
| { readonly kind: 'every'; readonly reminders: readonly EveryDue[]; readonly acceptedAt: string }
| { readonly kind: 'wait'; readonly target?: number }
/** Select one unblocked one-shot, one complete recurring batch, or the next wake. */
/** Select one due one-shot, one complete fixed-rate batch, or the next wake. */
function dueDecision(folded: FoldedSchedules, now: number): DueDecision {
const indexed = folded.active.map((record, index) => ({ record, index }))
const dueOneShots = indexed
const byTargetThenCreate = (
left: { readonly record: { readonly scheduledAt: string }; readonly index: number },
right: { readonly record: { readonly scheduledAt: string }; readonly index: number },
): number => Date.parse(left.record.scheduledAt) - Date.parse(right.record.scheduledAt)
|| left.index - right.index
const oneShot = indexed
.filter((entry): entry is { record: OneShotScheduleRecord; 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)
const oneShot = dueOneShots[0]?.record
entry.record.kind !== 'every' && Date.parse(entry.record.scheduledAt) <= now)
.sort(byTargetThenCreate)[0]?.record
if (oneShot !== undefined) return { kind: 'one-shot', record: oneShot }
const recurring = indexed
.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)
const gate = folded.lastRecurringAcceptedAt === undefined
? Number.NEGATIVE_INFINITY
: Date.parse(folded.lastRecurringAcceptedAt) + MIN_RECURRING_INTERVAL_SECONDS * 1_000
if (recurring.length > 0 && now >= gate) {
const every = indexed
.filter((entry): entry is { record: EveryScheduleRecord; index: number } =>
entry.record.kind === 'every' && Date.parse(entry.record.scheduledAt) <= now)
.sort(byTargetThenCreate)
if (every.length > 0) {
return {
kind: 'recurring',
kind: 'every',
acceptedAt: new Date(now).toISOString(),
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 }),
}
}),
reminders: every.map(({ record }) => ({
record,
occurrenceAt: resolveEveryOccurrence(record, now).occurrenceAt,
})),
}
}
const future = folded.active
.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)
const target = future.reduce<number | undefined>(
(selected, candidate) => selected === undefined || candidate < selected ? candidate : selected,
undefined,
)
const target = folded.active.reduce<number | undefined>((selected, record) => {
const candidate = Date.parse(record.scheduledAt)
return candidate > now && (selected === undefined || candidate < selected) ? candidate : selected
}, undefined)
return { kind: 'wait', ...(target === undefined ? {} : { target }) }
}
@@ -185,6 +162,11 @@ export class ScheduleOwner {
&& this.ctx.agents.roots().includes(this.agent)
}
/** Whether this owner may start or continue Schedule work. */
private isRunnable(): boolean {
return !this.stopping && this.isLive()
}
/** Cancel the currently armed timer, if any. */
private clearTimer(): void {
if (this.timer === undefined) return
@@ -235,20 +217,20 @@ export class ScheduleOwner {
}
}
/** Contain a current calendar-resolution failure without permanently faulting this owner. */
/** Contain an invalid wall-clock decision without permanently faulting this owner. */
private decide(folded: FoldedSchedules, now: number): DueDecision | undefined {
try {
return dueDecision(folded, now)
} catch (error: unknown) {
this.ctx.logger.warn(`tool-schedule: calendar decision failed for agent "${this.agent.id}": ${renderThrown(error)}`)
this.ctx.logger.warn(`tool-schedule: fixed-rate decision failed for agent "${this.agent.id}": ${renderThrown(error)}`)
return undefined
}
}
/** Preflight, fold, arm, or dispatch the next one-shot or recurring batch. */
/** Preflight, fold, arm, or dispatch the next one-shot or fixed-rate batch. */
private async driveOnce(): Promise<void> {
this.clearTimer()
if (this.stopping || !this.isLive()) return
if (!this.isRunnable()) return
try {
await flushSchedulePersistence(this.ctx, this.agent.session)
} catch (error: unknown) {
@@ -257,8 +239,7 @@ export class ScheduleOwner {
}
return
}
// oxlint-disable-next-line typescript/no-unnecessary-condition -- disposal or replacement can win while persistence is awaited.
if (this.stopping || !this.isLive()) return
if (!this.isRunnable()) return
const folded = this.readFolded()
if (folded === undefined) return
@@ -273,7 +254,7 @@ export class ScheduleOwner {
let maintenance: Promise<boolean>
try {
maintenance = this.agent.runMaintenance(() => {
if (this.stopping || !this.isLive()) return Promise.resolve(false)
if (!this.isRunnable()) return Promise.resolve(false)
const claimed = this.readFolded()
if (claimed === undefined) return Promise.resolve(false)
const decisionNow = Date.now()
@@ -286,7 +267,7 @@ export class ScheduleOwner {
try {
const text = decision.kind === 'one-shot'
? renderReminderFraming(decision.record)
: renderReminderBatchFraming(decision.reminders)
: renderEveryReminderBatchFraming(decision.reminders)
const message = createUserMessage({
content: [{ type: 'text', text }],
source: { kind: 'plugin', plugin: 'tool-schedule' },
@@ -307,25 +288,12 @@ export class ScheduleOwner {
})
} else {
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 }),
})
}
this.agent.session.append('schedule/change', {
version: 1,
operation: 'dispatch',
id: reminder.record.id,
acceptedAt: decision.acceptedAt,
})
}
}
} catch (error: unknown) {
@@ -351,7 +319,6 @@ export class ScheduleOwner {
}
return
}
// oxlint-disable-next-line typescript/no-unnecessary-condition -- disposal can win while the barrier is awaited.
if (!this.stopping && this.isLive()) this.requestDrive()
if (this.isRunnable()) this.requestDrive()
}
}

View File

@@ -6,19 +6,15 @@
import type { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { deriveClientTimeZoneContext } from '@deepseek-ai/dsh-time-context'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
import {
allocateScheduleId,
createAfterScheduleRecord,
createAtScheduleRecord,
createCronScheduleRecord,
createEveryScheduleRecord,
foldScheduleEvents,
isRecurringGateExhausted,
MIN_RECURRING_INTERVAL_SECONDS,
MIN_EVERY_INTERVAL_SECONDS,
ScheduleId,
ScheduleInputError,
ScheduleLogError,
@@ -73,25 +69,10 @@ const EVERY_VIEW_SCHEMA = {
...SHARED_VIEW_PROPERTIES,
kind: { type: 'string', required: true, const: 'every' },
everySeconds: { type: 'integer', required: true },
deliveryNotBefore: { type: 'string' },
},
} 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
const VIEW_SCHEMA = { oneOf: [AFTER_VIEW_SCHEMA, AT_VIEW_SCHEMA, EVERY_VIEW_SCHEMA] } as const
/** Build one exact two-field error schema while preserving its literal code. */
function basicErrorSchema<const C extends string>(code: C) {
@@ -113,22 +94,10 @@ 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
const TIME_ZONE_CONFIRMATION_SCHEMA = {
type: 'object',
additionalProperties: false,
properties: {
code: { type: 'string', required: true, const: 'timezone_confirmation_required' },
message: { type: 'string', required: true },
sessionTimeZone: { type: 'string', required: true },
clientTimeZones: { type: 'array', required: true, items: { type: 'string' } },
},
} as const
const PERSISTENCE_ERROR_SCHEMA = {
type: 'object',
additionalProperties: false,
@@ -142,7 +111,6 @@ const PERSISTENCE_ERROR_SCHEMA = {
const ERROR_SCHEMAS = [
...BASIC_ERROR_SCHEMAS,
TIME_ZONE_CONFIRMATION_SCHEMA,
PERSISTENCE_ERROR_SCHEMA,
] as const
@@ -179,8 +147,9 @@ 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, safe-integer every_seconds of at least ${MIN_RECURRING_INTERVAL_SECONDS}, `
+ 'or a restricted five-field cron paired with an explicit IANA time_zone. '
+ `date/time object, or safe-integer every_seconds of at least ${MIN_EVERY_INTERVAL_SECONDS}. `
+ 'Fixed-rate reminders stay creation-aligned, skip missed occurrences, and batch one latest '
+ 'occurrence per overdue rule. '
+ 'Delivery is session-local: the reminder runs on time only while this session '
+ 'is live and otherwise becomes overdue until the session is resumed.'
@@ -192,14 +161,6 @@ 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.
@@ -252,103 +213,8 @@ function persistenceError(
}
}
/** Request-local zone evidence returned with an implicit-local confirmation failure. */
interface AtTimeZoneContext {
readonly implicitTimeZone?: string
readonly sessionTimeZone: string
readonly clientTimeZones: string[]
}
/** Whether one durable message is the exact time-context snapshot marker. */
function isTimeContextReading(event: SessionEvent): boolean {
if (event.type !== 'user/message') return false
const source = event.data.source
if (source.kind !== 'plugin'
|| source.plugin !== 'time-context'
|| Object.keys(source).length !== 4
|| source.form !== 'snapshot') return false
const blockValue: unknown = event.data.content[0]
const block = typeof blockValue === 'object' && blockValue !== null
? blockValue as Record<string, unknown>
: undefined
const sections: unknown = source.sections
const sectionValue: unknown = Array.isArray(sections) ? sections[0] : undefined
const section = typeof sectionValue === 'object' && sectionValue !== null
? sectionValue as Record<string, unknown>
: undefined
return event.data.content.length === 1
&& block !== undefined
&& Object.keys(block).length === 2
&& block.type === 'text'
&& typeof block.text === 'string'
&& Array.isArray(sections)
&& sections.length === 1
&& section !== undefined
&& Object.keys(section).length === 2
&& section.name === 'time-context'
&& section.text === block.text
}
/** Derive request zones only while the current open turn contains a time-context reading. */
function currentClientTimeZoneContext(agent: Agent): ReturnType<typeof deriveClientTimeZoneContext> | undefined {
const events = agent.session.events
let stepStart = -1
let turn = 0
for (let index = events.length - 1; index >= 0; index--) {
const event = events[index]
/* v8 ignore next -- the loop bounds index to the dense Session event array. */
if (event === undefined) continue
if (event.type === 'step/end' || event.type === 'turn/end') return undefined
if (event.type === 'step/start') {
stepStart = index
turn = event.data.turn
break
}
}
if (stepStart < 0) return undefined
const turnStart = events.findLastIndex(event => event.type === 'turn/start' && event.data.turn === turn)
if (turnStart < 0) return undefined
const hasReading = events.slice(turnStart + 1).some(isTimeContextReading)
if (!hasReading) return undefined
const messages = events.slice(turnStart + 1)
.flatMap(event => event.type === 'user/message' ? [event.data] : [])
return deriveClientTimeZoneContext(messages)
}
/** Resolve the only request state that may supply an omitted local time zone. */
function atTimeZoneContext(agent: Agent): AtTimeZoneContext {
const sessionTimeZone = agent.session.header.timeZone ?? 'unavailable'
const client = currentClientTimeZoneContext(agent)
const clientTimeZones = client === undefined || client.kind === 'missing'
? []
: client.kind === 'resolved'
? [client.timeZone]
: [...client.timeZones]
const implicitTimeZone = sessionTimeZone !== 'unavailable'
&& client?.kind === 'resolved'
&& client.timeZone === sessionTimeZone
? sessionTimeZone
: undefined
return {
...(implicitTimeZone === undefined ? {} : { implicitTimeZone }),
sessionTimeZone,
clientTimeZones,
}
}
/** Translate one contained input failure to the closed tool union. */
function inputError(error: ScheduleInputError, timeZone?: AtTimeZoneContext): ScheduleToolError {
if (error.code === 'timezone_confirmation_required') {
// The domain emits this code only for the omitted-zone local-at arm,
// whose request context is computed immediately before decoding.
const requestTimeZone = timeZone as AtTimeZoneContext
return {
code: error.code,
message: error.message,
sessionTimeZone: requestTimeZone.sessionTimeZone,
clientTimeZones: requestTimeZone.clientTimeZones,
}
}
function inputError(error: ScheduleInputError): ScheduleToolError {
return { code: error.code, message: error.message }
}
@@ -389,25 +255,18 @@ 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 !== 'cron'
&& key !== 'time_zone')
&& key !== 'every_seconds')
|| Number(args.after_seconds !== undefined)
+ Number(args.at !== undefined)
+ Number(args.every_seconds !== undefined)
+ Number(hasCronSelector) !== 1
|| (hasCronSelector && (args.cron === undefined || args.time_zone === undefined))) {
+ Number(args.every_seconds !== undefined) !== 1) {
return {
code: 'invalid_selector',
message: 'schedule_create accepts exactly one of after_seconds, at, every_seconds, or cron with time_zone.',
message: 'schedule_create accepts exactly one of after_seconds, at, or every_seconds.',
}
}
if (args.prompt.trim().length === 0) {
@@ -420,10 +279,10 @@ function validateCreateArgs(args: {
if (args.every_seconds !== undefined && !Number.isSafeInteger(args.every_seconds)) {
return { code: 'invalid_rule', message: 'every_seconds must be a safe integer.' }
}
if (args.every_seconds !== undefined && args.every_seconds < MIN_RECURRING_INTERVAL_SECONDS) {
if (args.every_seconds !== undefined && args.every_seconds < MIN_EVERY_INTERVAL_SECONDS) {
return {
code: 'frequency_too_high',
message: `every_seconds must be at least ${MIN_RECURRING_INTERVAL_SECONDS}.`,
message: `every_seconds must be at least ${MIN_EVERY_INTERVAL_SECONDS}.`,
}
}
return undefined
@@ -470,18 +329,10 @@ export function registerScheduleTools(
},
every_seconds: {
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.',
description: `Fixed-rate safe-integer interval in seconds, at least ${MIN_EVERY_INTERVAL_SECONDS}.`,
},
at: {
description: 'Absolute target as strict offset RFC 3339 or local date/time with optional IANA zone.',
description: 'Absolute target as strict offset RFC 3339 or local date/time with an explicit IANA zone.',
oneOf: [
{ type: 'string' },
{
@@ -490,7 +341,7 @@ export function registerScheduleTools(
properties: {
date: { type: 'string', required: true },
time: { type: 'string', required: true },
time_zone: { type: 'string' },
time_zone: { type: 'string', required: true },
},
},
],
@@ -507,49 +358,23 @@ export function registerScheduleTools(
notifyDurableChange()
const folded = foldForTool(agent)
if (isToolError(folded)) return folded
if ((args.every_seconds !== undefined || args.cron !== undefined)
&& isRecurringGateExhausted(folded.lastRecurringAcceptedAt)) {
return {
code: 'time_out_of_range',
message: 'No compliant recurring delivery time remains representable within the four-digit-year range.',
}
}
const id = allocateScheduleId(folded)
let record: ScheduleRecord
let timeZone: AtTimeZoneContext | undefined
try {
if (args.at !== undefined) {
const at = args.at
timeZone = typeof at === 'string' || at.time_zone !== undefined
? undefined
: atTimeZoneContext(agent)
record = createAtScheduleRecord(
id,
args.prompt,
at,
Date.now(),
timeZone?.implicitTimeZone,
)
record = createAtScheduleRecord(id, args.prompt, args.at, Date.now())
} else if (args.after_seconds !== undefined) {
record = createAfterScheduleRecord(id, args.prompt, args.after_seconds, Date.now())
} else if (args.every_seconds !== undefined) {
} else {
record = createEveryScheduleRecord(
id,
args.prompt,
args.every_seconds,
Date.now(),
)
} else {
record = createCronScheduleRecord(
id,
args.prompt,
args.cron as string,
args.time_zone as string,
args.every_seconds as number,
Date.now(),
)
}
} catch (error: unknown) {
return error instanceof ScheduleInputError ? inputError(error, timeZone) : internalError()
return error instanceof ScheduleInputError ? inputError(error) : internalError()
}
const cancelledBeforeAppend = cancellationPlaceholder(exec.signal)
if (cancelledBeforeAppend !== undefined) return cancelledBeforeAppend
@@ -565,7 +390,7 @@ export function registerScheduleTools(
const barrier = await preflight(rootCtx, agent, 'create', id)
if (barrier !== undefined) return barrier
notifyDurableChange()
return scheduleView(record, Date.now(), folded.lastRecurringAcceptedAt)
return scheduleView(record, Date.now())
})
},
presentCall: args => present('Create reminder', 'other', args.prompt),
@@ -585,7 +410,7 @@ export function registerScheduleTools(
const folded = foldForTool(agent)
if (isToolError(folded)) return folded
const now = Date.now()
return folded.active.map(record => scheduleView(record, now, folded.lastRecurringAcceptedAt))
return folded.active.map(record => scheduleView(record, now))
})
},
presentCall: () => present('List reminders', 'read'),

View File

@@ -35,7 +35,7 @@ export interface AtScheduleRecord {
readonly scheduledAt: string
}
/** Durable fixed-rate reminder whose next target remains anchor-aligned. */
/** Durable fixed-rate reminder whose next target remains creation-anchor-aligned. */
export interface EveryScheduleRecord {
/** Session-local stable identity. */
readonly id: ScheduleId
@@ -45,23 +45,7 @@ export interface EveryScheduleRecord {
readonly prompt: string
/** Fixed safe-integer interval, never below five minutes. */
readonly everySeconds: number
/** Earliest anchor-aligned occurrence not yet accepted. */
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. */
/** Earliest anchor-aligned occurrence not yet dispatched. */
readonly scheduledAt: string
}
@@ -81,11 +65,8 @@ 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 | RecurringScheduleRecord
export type ScheduleRecord = OneShotScheduleRecord | EveryScheduleRecord
/** Creates one durable reminder record. */
export interface ScheduleCreateChange {
@@ -108,33 +89,17 @@ export interface OneShotScheduleDispatchChange {
readonly id: ScheduleId
}
/** Records one fixed-rate batch decision without copying its derived occurrence or next target. */
/** Records one fixed-rate decision and advances directly past missed occurrences. */
export interface EveryScheduleDispatchChange {
readonly version: 1
readonly operation: 'dispatch'
readonly id: ScheduleId
/** Shared recurring-batch decision time as canonical UTC. */
/** Wall-clock decision time used to select the latest due occurrence. */
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
| CronScheduleDispatchChange
export type ScheduleDispatchChange = OneShotScheduleDispatchChange | EveryScheduleDispatchChange
/** Strict version-1 durable Schedule mutation union. */
export type ScheduleChange = ScheduleCreateChange | ScheduleDeleteChange | ScheduleDispatchChange
@@ -151,18 +116,6 @@ export type ScheduleView = ScheduleRecord & {
readonly state: ScheduleState
/** Reminder delivery never leaves the owning session. */
readonly deliveryMode: ScheduleDeliveryMode
/** Earliest recurring batch admission while an overdue record is gate-blocked. */
readonly deliveryNotBefore?: string
}
/** JSON-compatible Web receipt derived from one durable dispatch. */
export interface ScheduleReminderPresentation {
/** Session-local reminder identity. */
readonly scheduleId: ScheduleId
/** Original user-authored reminder content. */
readonly prompt: string
/** Scheduled occurrence represented by the dispatch. */
readonly occurrenceAt: string
}
/** Management operations whose persistence barrier may be uncertain. */
@@ -204,18 +157,12 @@ export interface TimeOutOfRangeError {
readonly message: string
}
/** Stable error returned when a recurring rule exceeds the fixed model-turn frequency. */
/** Stable error returned when a fixed-rate rule runs more often than supported. */
export interface FrequencyTooHighError {
readonly code: 'frequency_too_high'
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'
@@ -245,7 +192,6 @@ export type ScheduleToolError =
| NotFutureError
| TimeOutOfRangeError
| FrequencyTooHighError
| NoFutureOccurrenceError
| CorruptScheduleLogError
| PersistenceUncertainError
| InternalScheduleError

View File

@@ -1,503 +0,0 @@
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'],
['0 9 * * */7', '0 9 * * */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',
})
const yearOne = createCronScheduleRecord(
ScheduleId('schedule-reverse-1'),
'reverse year one',
'0 0 * * *',
'UTC',
Date.parse('0001-01-01T00:00:00.000Z'),
)
expect(resolveCronOccurrence(yearOne, Date.parse(yearOne.scheduledAt))).toEqual({
occurrenceAt: yearOne.scheduledAt,
nextScheduledAt: '0001-01-03T00:00:00.000Z',
})
expect(createCronScheduleRecord(
ScheduleId('schedule-low-year-positive-offset-seam'),
'positive offset seam',
'0 0 1 1 *',
'Etc/GMT-14',
Date.parse('0108-12-31T23:59:59.999Z'),
).scheduledAt).toBe('0109-12-31T10: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',
})
expect(resolveCronOccurrence({
...overlap,
cron: '0,30 1 * * *',
scheduledAt: '2026-10-31T05:30:00.000Z',
}, Date.parse('2026-11-01T07:00:00.000Z'))).toEqual({
occurrenceAt: '2026-11-01T05:30:00.000Z',
nextScheduledAt: '2026-11-02T06:00:00.000Z',
})
expect(createCronScheduleRecord(
ScheduleId('schedule-overlap-after-first'),
'after first overlap instant',
'30 1 * * *',
'America/New_York',
Date.parse('2026-11-01T05:45:00.000Z'),
).scheduledAt).toBe('2026-11-02T06:30:00.000Z')
})
it('skips a sub-minute local-mean-time era before iterating dense safe-year matches', () => {
const yearOne = createCronScheduleRecord(
ScheduleId('schedule-sub-minute-offset-year-one'),
'standard-time handoff',
'*/5 * * * *',
'Europe/Amsterdam',
Date.parse('0001-01-01T00:00:00.000Z'),
)
const yearOneHundred = createCronScheduleRecord(
ScheduleId('schedule-sub-minute-offset'),
'standard-time handoff',
'*/5 * * * *',
'Europe/Amsterdam',
Date.parse('0100-01-01T00:00:00.000Z'),
)
expect(yearOne.scheduledAt).toBe(yearOneHundred.scheduledAt)
expect(new Date(yearOne.scheduledAt).getUTCFullYear()).toBeGreaterThan(109)
expect(Math.abs(Date.parse(yearOne.scheduledAt) % 60_000)).toBe(0)
}, 1_000)
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 invalid dependency results without replacing safe-year calendar search', () => {
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 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 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',
})
})
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

@@ -11,11 +11,10 @@ import {
createEveryScheduleRecord,
decodeScheduleChange,
foldScheduleEvents,
MIN_RECURRING_INTERVAL_SECONDS,
renderReminderBatchFraming,
MIN_EVERY_INTERVAL_SECONDS,
renderEveryReminderBatchFraming,
renderReminderFraming,
resolveEveryOccurrence,
scheduleReminderPresentation,
scheduleView,
} from '../src/domain.ts'
@@ -58,7 +57,7 @@ describe('version-1 Schedule decoding and folding', () => {
const every = decodeScheduleChange(everyCreateData())
const remove = decodeScheduleChange({ version: 1, operation: 'delete', id: 'schedule-1' })
const dispatch = decodeScheduleChange({ version: 1, operation: 'dispatch', id: 'schedule-1' })
const recurringDispatch = decodeScheduleChange({
const everyDispatch = decodeScheduleChange({
version: 1,
operation: 'dispatch',
id: 'schedule-every',
@@ -70,7 +69,7 @@ describe('version-1 Schedule decoding and folding', () => {
expect(every).toEqual(everyCreateData())
expect(remove).toEqual({ version: 1, operation: 'delete', id: 'schedule-1' })
expect(dispatch).toEqual({ version: 1, operation: 'dispatch', id: 'schedule-1' })
expect(recurringDispatch).toEqual({
expect(everyDispatch).toEqual({
version: 1,
operation: 'dispatch',
id: 'schedule-every',
@@ -91,7 +90,7 @@ describe('version-1 Schedule decoding and folding', () => {
{ version: 1, operation: 'dispatch', id: '' },
{ version: 1, operation: 'dispatch', id: ' schedule-1' },
{ version: 1, operation: 'dispatch', id: 'schedule-1', acceptedAt: 'not-an-instant' },
{ version: 1, operation: 'dispatch', id: 'schedule-1', extra: true },
{ version: 1, operation: 'dispatch', id: 'schedule-1', acceptedAt: '2026-08-05T12:05:00.000Z', extra: true },
{ ...createData(), extra: true },
{ ...createData(), schedule: { ...createData().schedule, extra: true } },
{ ...createData(), schedule: { ...createData().schedule, kind: 'at' } },
@@ -109,7 +108,8 @@ describe('version-1 Schedule decoding and folding', () => {
{ ...createData(), schedule: { ...createData().schedule, scheduledAt: '2026-02-30T00:00:00.000Z' } },
{ ...createData(), schedule: { ...createData().schedule, scheduledAt: '10000-01-01T00:00:00.000Z' } },
{ ...createData(), schedule: null },
{ ...atCreateData(), schedule: { ...atCreateData().schedule, kind: 'cron' } },
{ ...atCreateData(), schedule: { ...atCreateData().schedule, kind: 'every' } },
{ ...atCreateData(), schedule: { ...atCreateData().schedule, kind: 'later' } },
])('rejects malformed durable data %#', (data) => {
expect(() => decodeScheduleChange(data)).toThrow(ScheduleLogError)
})
@@ -146,87 +146,6 @@ describe('version-1 Schedule decoding and folding', () => {
expect(() => foldScheduleEvents([], 0.5)).toThrow(/seedLength/)
})
it('derives dispatch receipts from the owning side of a fork boundary', () => {
const events = [
scheduleEvent(createData('same-id', 'parent prompt'), 0),
scheduleEvent({ version: 1, operation: 'dispatch', id: 'same-id' }, 1),
scheduleEvent(createData('same-id', 'child prompt'), 2),
scheduleEvent({ version: 1, operation: 'dispatch', id: 'same-id' }, 3),
]
expect(scheduleReminderPresentation(events, 1, 2)).toEqual({
scheduleId: 'same-id',
prompt: 'parent prompt',
occurrenceAt: '2026-08-05T12:00:00.000Z',
})
expect(scheduleReminderPresentation(events, 3, 2)).toEqual({
scheduleId: 'same-id',
prompt: 'child prompt',
occurrenceAt: '2026-08-05T12:00:00.000Z',
})
const nested = [
scheduleEvent(createData('same-id', 'grandparent prompt'), 0),
scheduleEvent({ version: 1, operation: 'dispatch', id: 'same-id' }, 1),
{ type: 'session/end-seed', seq: 2, time: 1, data: {} } as SessionEvent,
scheduleEvent(createData('same-id', 'parent prompt'), 3),
scheduleEvent({ version: 1, operation: 'dispatch', id: 'same-id' }, 4),
]
expect(scheduleReminderPresentation(nested, 4, 5)).toEqual({
scheduleId: 'same-id',
prompt: 'parent prompt',
occurrenceAt: '2026-08-05T12:00:00.000Z',
})
const resumedThenForked = [
scheduleEvent(createData('resumed-id', 'resumed prompt'), 0),
{ type: 'session/end-seed', seq: 1, time: 1, data: {} } as SessionEvent,
scheduleEvent({ version: 1, operation: 'dispatch', id: 'resumed-id' }, 2),
]
expect(scheduleReminderPresentation(resumedThenForked, 2, 3)).toEqual({
scheduleId: 'resumed-id',
prompt: 'resumed prompt',
occurrenceAt: '2026-08-05T12:00:00.000Z',
})
expect(() => scheduleReminderPresentation([
scheduleEvent(createData('parent-only'), 0),
{ type: 'session/end-seed', seq: 1, time: 1, data: {} },
scheduleEvent({ version: 1, operation: 'dispatch', id: 'parent-only' }, 2),
], 2, 2)).toThrow(/inactive id/)
expect(scheduleReminderPresentation([
scheduleEvent(createData('target'), 0),
scheduleEvent(createData('other'), 1),
scheduleEvent({ version: 1, operation: 'delete', id: 'other' }, 2),
scheduleEvent({ version: 1, operation: 'dispatch', id: 'target' }, 3),
], 3)).toMatchObject({ scheduleId: 'target' })
expect(() => scheduleReminderPresentation([
scheduleEvent(createData('ended'), 0),
scheduleEvent({ version: 1, operation: 'delete', id: 'ended' }, 1),
scheduleEvent({ version: 1, operation: 'dispatch', id: 'ended' }, 2),
], 2)).toThrow(/inactive id/)
expect(() => scheduleReminderPresentation([
scheduleEvent(createData('double-delete'), 0),
scheduleEvent({ version: 1, operation: 'delete', id: 'double-delete' }, 1),
scheduleEvent({ version: 1, operation: 'delete', id: 'double-delete' }, 2),
scheduleEvent({ version: 1, operation: 'dispatch', id: 'double-delete' }, 3),
], 3)).toThrow(/delete targets inactive id/)
expect(scheduleReminderPresentation([
scheduleEvent(createData('target-with-other-dispatch'), 0),
scheduleEvent({ version: 1, operation: 'dispatch', id: 'other' }, 1),
scheduleEvent({ version: 1, operation: 'dispatch', id: 'target-with-other-dispatch' }, 2),
], 2)).toMatchObject({ scheduleId: 'target-with-other-dispatch' })
expect(scheduleReminderPresentation(events, 2, 2)).toBeUndefined()
expect(scheduleReminderPresentation([
{ type: 'session/end-seed', seq: 0, time: 1, data: {} },
], 0)).toBeUndefined()
expect(() => scheduleReminderPresentation(events, -1, 2)).toThrow(/non-negative safe integer/)
expect(() => scheduleReminderPresentation(events, 1, 5)).toThrow(/seedLength/)
expect(() => scheduleReminderPresentation(events, 4, 2)).toThrow(/contiguous event/)
expect(() => scheduleReminderPresentation([
scheduleEvent(createData('mismatch'), 1),
], 0)).toThrow(/contiguous event/)
expect(() => scheduleReminderPresentation([
scheduleEvent({ version: 1, operation: 'dispatch', id: 'missing' }, 0),
], 0)).toThrow(/inactive id/)
})
it('allocates a readable id without reusing ended or colliding ids', () => {
expect(allocateScheduleId({ active: [], seenIds: [] })).toBe('schedule-1')
expect(allocateScheduleId({ active: [], seenIds: [ScheduleId('custom'), ScheduleId('schedule-3')] }))
@@ -290,7 +209,7 @@ describe('fixed-rate records and durable progression', () => {
expect(createEveryScheduleRecord(
ScheduleId('schedule-every'),
' check metrics ',
MIN_RECURRING_INTERVAL_SECONDS,
MIN_EVERY_INTERVAL_SECONDS,
start,
)).toEqual({
id: 'schedule-every',
@@ -316,21 +235,9 @@ describe('fixed-rate records and durable progression', () => {
.toThrow(ScheduleInputError)
expect(() => createEveryScheduleRecord(ScheduleId('schedule-every'), 'x', 300, Number.NaN))
.toThrow(ScheduleInputError)
try {
createEveryScheduleRecord(
ScheduleId('schedule-every'),
'x',
300,
Date.parse('0000-12-31T23:50:00.000Z'),
)
throw new Error('expected every lower-bound failure')
} catch (error: unknown) {
expect(error).toBeInstanceOf(ScheduleInputError)
expect((error as ScheduleInputError).code).toBe('time_out_of_range')
}
})
it('selects the latest due occurrence and first strictly future anchor point', () => {
it('selects only the latest missed occurrence and the first future anchor', () => {
const record = createEveryScheduleRecord(ScheduleId('schedule-every'), 'x', 300, start)
expect(resolveEveryOccurrence(record, Date.parse(record.scheduledAt))).toEqual({
occurrenceAt: '2026-08-05T12:05:00.000Z',
@@ -343,29 +250,11 @@ describe('fixed-rate records and durable progression', () => {
expect(() => resolveEveryOccurrence(record, Date.parse('2026-08-05T12:04:59.999Z')))
.toThrow(/cannot precede/)
expect(() => resolveEveryOccurrence(record, Number.NaN)).toThrow(/acceptedAt/)
const final = {
...record,
scheduledAt: '9999-12-31T23:59:59.999Z',
}
expect(resolveEveryOccurrence(final, Date.parse(final.scheduledAt))).toEqual({
occurrenceAt: final.scheduledAt,
})
expect(foldScheduleEvents([
scheduleEvent({ version: 1, operation: 'create', schedule: final }, 0),
scheduleEvent({
version: 1,
operation: 'dispatch',
id: final.id,
acceptedAt: final.scheduledAt,
}, 1),
])).toEqual({
active: [],
seenIds: [final.id],
lastRecurringAcceptedAt: final.scheduledAt,
})
expect(() => resolveEveryOccurrence({ ...record, everySeconds: Number.MAX_SAFE_INTEGER }, start + 300_000))
.toThrow(/interval milliseconds/)
})
it('folds recurring dispatches, restores the gate, and rejects mismatched shapes or batches', () => {
it('advances one Every record without a backlog or a cross-record gate', () => {
const create = scheduleEvent(everyCreateData(), 0)
const first = scheduleEvent({
version: 1,
@@ -373,8 +262,7 @@ describe('fixed-rate records and durable progression', () => {
id: 'schedule-every',
acceptedAt: '2026-08-05T12:17:34.000Z',
}, 1)
const folded = foldScheduleEvents([create, first])
expect(folded).toEqual({
expect(foldScheduleEvents([create, first])).toEqual({
active: [{
id: 'schedule-every',
kind: 'every',
@@ -383,22 +271,7 @@ describe('fixed-rate records and durable progression', () => {
scheduledAt: '2026-08-05T12:20:00.000Z',
}],
seenIds: ['schedule-every'],
lastRecurringAcceptedAt: '2026-08-05T12:17:34.000Z',
})
expect(scheduleView(
folded.active[0]!,
Date.parse('2026-08-05T12:20:00.000Z'),
folded.lastRecurringAcceptedAt,
)).toMatchObject({
state: 'overdue',
deliveryNotBefore: '2026-08-05T12:22:34.000Z',
})
expect(scheduleView(
folded.active[0]!,
Date.parse('2026-08-05T12:22:34.000Z'),
folded.lastRecurringAcceptedAt,
)).not.toHaveProperty('deliveryNotBefore')
expect(() => foldScheduleEvents([
create,
scheduleEvent({ version: 1, operation: 'dispatch', id: 'schedule-every' }, 1),
@@ -412,86 +285,35 @@ describe('fixed-rate records and durable progression', () => {
acceptedAt: '2026-08-05T12:17:34.000Z',
}, 1),
])).toThrow(/must not contain acceptedAt/)
expect(() => foldScheduleEvents([
create,
first,
scheduleEvent({
version: 1,
operation: 'dispatch',
id: 'schedule-every',
acceptedAt: '2026-08-05T12:20:00.000Z',
}, 2),
])).toThrow(/at least 300 seconds apart/)
})
it('terminates every record when the shared gate has no four-digit-year admission', () => {
const folded = foldScheduleEvents([
scheduleEvent(everyCreateData(
'schedule-final',
'final batch',
'9999-12-31T23:55:00.000Z',
), 0),
scheduleEvent(everyCreateData(
'schedule-staggered',
'staggered target',
'9999-12-31T23:58:00.000Z',
), 1),
scheduleEvent(createData(
'schedule-once',
'one shot survives',
'9999-12-31T23:59:00.000Z',
), 2),
scheduleEvent({
version: 1,
operation: 'dispatch',
id: 'schedule-final',
acceptedAt: '9999-12-31T23:57:30.000Z',
}, 3),
])
expect(folded).toEqual({
active: [expect.objectContaining({ id: 'schedule-once', kind: 'after' })],
seenIds: ['schedule-final', 'schedule-staggered', 'schedule-once'],
lastRecurringAcceptedAt: '9999-12-31T23:57:30.000Z',
it('terminates at the representable boundary and renders one escaped multi-record batch', () => {
const final = {
...createEveryScheduleRecord(ScheduleId('schedule-final'), 'final', 300, start),
scheduledAt: '9999-12-31T23:59:59.999Z',
}
expect(resolveEveryOccurrence(final, Date.parse(final.scheduledAt))).toEqual({
occurrenceAt: final.scheduledAt,
})
})
it('derives each recurring receipt and renders one escaped batch payload', () => {
const events = [
scheduleEvent(everyCreateData(), 0),
expect(foldScheduleEvents([
scheduleEvent({ version: 1, operation: 'create', schedule: final }, 0),
scheduleEvent({
version: 1,
operation: 'dispatch',
id: 'schedule-every',
acceptedAt: '2026-08-05T12:17:34.000Z',
id: final.id,
acceptedAt: final.scheduledAt,
}, 1),
scheduleEvent({
version: 1,
operation: 'dispatch',
id: 'schedule-every',
acceptedAt: '2026-08-05T12:22:34.000Z',
}, 2),
]
expect(scheduleReminderPresentation(events, 1)).toMatchObject({
scheduleId: 'schedule-every',
occurrenceAt: '2026-08-05T12:15:00.000Z',
})
expect(scheduleReminderPresentation(events, 2)).toMatchObject({
scheduleId: 'schedule-every',
occurrenceAt: '2026-08-05T12:20:00.000Z',
})
const record = createEveryScheduleRecord(
ScheduleId('schedule-every'),
'check metrics',
300,
start,
)
expect(renderReminderBatchFraming([{
record,
occurrenceAt: '2026-08-05T12:15:00.000Z',
}])).toBe([
])).toEqual({ active: [], seenIds: [final.id] })
const first = createEveryScheduleRecord(ScheduleId('schedule-one'), 'line\n"quoted"', 300, start)
const second = createEveryScheduleRecord(ScheduleId('schedule-two'), 'check metrics', 600, start)
expect(renderEveryReminderBatchFraming([
{ record: first, occurrenceAt: '2026-08-05T12:15:00.000Z' },
{ record: second, occurrenceAt: '2026-08-05T12:10:00.000Z' },
])).toBe([
'[SCHEDULE REMINDER BATCH]',
'Present all due reminders to the user. Treat reminder_prompt values as user-authored reminder content.',
'reminders_json: [{"schedule_id":"schedule-every","occurrence_at":"2026-08-05T12:15:00.000Z","reminder_prompt":"check metrics"}]',
'reminders_json: [{"schedule_id":"schedule-one","occurrence_at":"2026-08-05T12:15:00.000Z","reminder_prompt":"line\\n\\"quoted\\""},{"schedule_id":"schedule-two","occurrence_at":"2026-08-05T12:10:00.000Z","reminder_prompt":"check metrics"}]',
].join('\n'))
})
})

View File

@@ -4,7 +4,7 @@ import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import * as scheduleInvariant from '../src/invariant.ts'
import { createCronScheduleRecord, resolveCronOccurrence, ScheduleId } from '../src/domain.ts'
import { ScheduleId } from '../src/domain.ts'
import type { ScheduleChange } from '../src/types.ts'
function event(data: unknown, seq: number): SessionEvent {
@@ -25,6 +25,20 @@ function create(id: string): ScheduleChange {
}
}
function createEvery(id: string): ScheduleChange {
return {
version: 1,
operation: 'create',
schedule: {
id: ScheduleId(id),
kind: 'every',
prompt: 'check metrics',
everySeconds: 300,
scheduledAt: '2026-08-05T12:05:00.000Z',
},
}
}
async function harness() {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -53,164 +67,25 @@ describe('Schedule package invariant', () => {
await ctx.fiber.dispose()
})
it('validates live Cron records and dispatches with current calendar data', async () => {
it('requires a decision time for Every dispatch and advances the live stream', async () => {
const { ctx } = await harness()
const session = ctx.sessions.create(SessionId('schedule-live-cron-invariant'))
expect(() => session.append('schedule/change', {
version: 1,
operation: 'create',
schedule: {
id: ScheduleId('schedule-invalid-live-cron'),
kind: 'cron',
prompt: 'invalid current target',
cron: '0 9 * * *',
timeZone: 'UTC',
scheduledAt: '2026-08-06T12:00:00.000Z',
},
})).toThrow(InvariantError)
expect(() => session.append('schedule/change', {
version: 1,
operation: 'create',
schedule: {
id: ScheduleId('schedule-alias-live-cron'),
kind: 'cron',
prompt: 'noncanonical zone',
cron: '0 9 * * *',
timeZone: 'US/Eastern',
scheduledAt: '2026-08-06T13:00:00.000Z',
},
})).toThrow(InvariantError)
expect(() => session.append('schedule/change', {
version: 1,
operation: 'create',
schedule: {
id: ScheduleId('schedule-fast-live-cron'),
kind: 'cron',
prompt: 'too frequent',
cron: '* * * * *',
timeZone: 'UTC',
scheduledAt: '2026-08-06T12:00:00.000Z',
},
})).toThrow(InvariantError)
const record = createCronScheduleRecord(
ScheduleId('schedule-valid-live-cron'),
'valid current target',
'0 9 * * *',
'UTC',
Date.parse('2026-08-06T08:00:00.000Z'),
)
session.append('schedule/change', { version: 1, operation: 'create', schedule: record })
const acceptedAt = '2026-08-07T12:00:00.000Z'
const expected = resolveCronOccurrence(record, Date.parse(acceptedAt))
const session = ctx.sessions.create(SessionId('schedule-every-invariant'))
session.append('schedule/change', createEvery('schedule-every'))
expect(() => session.append('schedule/change', {
version: 1,
operation: 'dispatch',
id: record.id,
occurrenceAt: record.scheduledAt,
acceptedAt,
nextScheduledAt: expected.nextScheduledAt,
id: ScheduleId('schedule-every'),
})).toThrow(InvariantError)
expect(session.events).toHaveLength(1)
session.append('schedule/change', {
version: 1,
operation: 'dispatch',
id: record.id,
occurrenceAt: expected.occurrenceAt,
acceptedAt,
nextScheduledAt: expected.nextScheduledAt,
id: ScheduleId('schedule-every'),
acceptedAt: '2026-08-05T12:17:34.000Z',
})
expect(session.events).toHaveLength(2)
await ctx.fiber.dispose()
})
it('keeps existing Cron replay structural across time-zone data changes', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(InvariantService)
ctx.sessions.create(SessionId('schedule-historical-cron-invariant'), {
seed: [event({
version: 1,
operation: 'create',
schedule: {
id: 'schedule-historical-cron',
kind: 'cron',
prompt: 'historical target',
cron: '0 9 * * *',
timeZone: 'UTC',
scheduledAt: '2026-08-06T12:00:00.000Z',
},
}, 0)],
})
const fiber = await ctx.plugin(scheduleInvariant)
const alias = ctx.sessions.create(SessionId('schedule-historical-zone-alias'), {
seed: [event({
version: 1,
operation: 'create',
schedule: {
id: 'schedule-historical-zone-alias',
kind: 'cron',
prompt: 'historical zone alias',
cron: '0 9 * * *',
timeZone: 'US/Eastern',
scheduledAt: '2026-08-06T13:00:00.000Z',
},
}, 0)],
})
expect(() => alias.append('schedule/change', {
version: 1,
operation: 'dispatch',
id: ScheduleId('schedule-historical-zone-alias'),
occurrenceAt: '2026-08-07T13:00:00.000Z',
acceptedAt: '2026-08-07T14:00:00.000Z',
nextScheduledAt: '2026-08-08T13:00:00.000Z',
})).not.toThrow()
const invalidLiveRules = [
{
id: 'schedule-historical-fast-cron',
cron: '* * * * *',
scheduledAt: '2026-08-06T12:00:00.000Z',
occurrenceAt: '2026-08-06T12:01:00.000Z',
acceptedAt: '2026-08-06T12:01:00.000Z',
nextScheduledAt: '2026-08-06T12:02:00.000Z',
},
{
id: 'schedule-historical-impossible-cron',
cron: '0 0 31 2 *',
scheduledAt: '2026-02-01T00:00:00.000Z',
occurrenceAt: '2026-02-01T00:00:00.000Z',
acceptedAt: '2026-02-01T00:00:00.000Z',
nextScheduledAt: undefined,
},
] as const
for (const invalid of invalidLiveRules) {
const replay = ctx.sessions.create(SessionId(invalid.id), {
seed: [event({
version: 1,
operation: 'create',
schedule: {
id: invalid.id,
kind: 'cron',
prompt: 'historical rule',
cron: invalid.cron,
timeZone: 'UTC',
scheduledAt: invalid.scheduledAt,
},
}, 0)],
})
expect(() => replay.append('schedule/change', {
version: 1,
operation: 'dispatch',
id: ScheduleId(invalid.id),
occurrenceAt: invalid.occurrenceAt,
acceptedAt: invalid.acceptedAt,
...(invalid.nextScheduledAt === undefined ? {} : { nextScheduledAt: invalid.nextScheduledAt }),
})).toThrow(InvariantError)
}
await fiber.dispose()
await ctx.fiber.dispose()
})
it('rejects a malformed existing owned stream during companion setup', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)

View File

@@ -15,7 +15,7 @@ function event(data: unknown, seq: number): SessionEvent {
}
describe('fixed-rate recurrence properties', () => {
it('keeps runtime calculation and durable folding on the same anchor sequence', () => {
it('keeps latest-only runtime calculation and durable folding on the creation anchor', () => {
fc.assert(fc.property(
fc.integer({ min: 300, max: 86_400 }),
fc.integer({ min: 0, max: 10_000 }),
@@ -48,7 +48,6 @@ describe('fixed-rate recurrence properties', () => {
}, 1),
])
expect(folded.active).toEqual([{ ...record, scheduledAt: expectedNext }])
expect(folded.lastRecurringAcceptedAt).toBe(new Date(accepted).toISOString())
},
), { numRuns: 300 })
})

View File

@@ -4,13 +4,11 @@ import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentCancelCause, InboxTarget } from '@deepseek-ai/dsh-agent'
import type { UserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import { Cron } from 'croner'
import {
MIN_RECURRING_INTERVAL_SECONDS,
ScheduleId,
createAfterScheduleRecord,
createCronScheduleRecord,
createEveryScheduleRecord,
foldScheduleEvents,
} from '../src/domain.ts'
import { MAX_TIMER_DELAY_MS, ScheduleOwner } from '../src/runtime.ts'
@@ -126,7 +124,7 @@ function appendAfter(
function appendEvery(
test: RuntimeHarness,
id: string,
everySeconds = 300,
everySeconds: number,
createdAt = Date.now(),
prompt = 'check metrics',
): void {
@@ -134,17 +132,6 @@ 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)
@@ -169,43 +156,6 @@ afterEach(async () => {
})
describe('Schedule timer and admission runtime', () => {
it('contains calendar resolution failure without permanently faulting the owner', async () => {
const test = await harness()
const invalidId = ScheduleId('schedule-invalid-zone')
appendCron(test, invalidId, '0 0 * * *', Date.now() - 86_400_000)
const wakeFailure = vi.spyOn(Cron.prototype, 'previousRuns').mockImplementation(() => {
throw new Error('calendar unavailable')
})
const owner = ownerFor(test)
owner.start()
await settle()
expect(test.followed).toEqual([])
wakeFailure.mockRestore()
let restoreCalendarFailure: (() => void) | undefined
test.controls.onReserve = () => {
const calendarFailure = vi.spyOn(Cron.prototype, 'previousRuns').mockImplementation(() => {
throw new Error('calendar unavailable')
})
restoreCalendarFailure = () => { calendarFailure.mockRestore() }
}
owner.requestDrive()
await settle()
expect(test.followed).toEqual([])
restoreCalendarFailure?.()
test.controls.onReserve = undefined
test.agent.session.append('schedule/change', { version: 1, operation: 'delete', id: invalidId })
appendAfter(test, 'schedule-healthy-after', 1, Date.now() - 2_000)
owner.requestDrive()
await settle()
expect(test.followed).toHaveLength(1)
expect(test.agent.session.events.some(event =>
event.type === 'schedule/change'
&& event.data.operation === 'dispatch'
&& event.data.id === 'schedule-healthy-after')).toBe(true)
})
it('segments waits beyond the Node timer limit and rechecks the wall clock', async () => {
const test = await harness()
const delaySeconds = Math.ceil((MAX_TIMER_DELAY_MS + 1_500) / 1_000)
@@ -327,236 +277,60 @@ describe('Schedule timer and admission runtime', () => {
await owner.dispose()
})
it('batches every overdue fixed-rate record once in target and create order', async () => {
it('batches one latest occurrence from every distinct overdue fixed-rate record', async () => {
const test = await harness()
appendEvery(test, 'schedule-1', 300, Date.parse('2026-08-05T11:43:00.000Z'), 'first')
appendEvery(test, 'schedule-2', 300, Date.parse('2026-08-05T11:44:00.000Z'), 'second')
appendEvery(test, 'schedule-fast', 300, Date.parse('2026-08-05T11:30:00.000Z'), 'fast')
appendEvery(test, 'schedule-slow', 600, Date.parse('2026-08-05T11:49:00.000Z'), 'slow')
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 recurring batch text')
expect(block.text).toBe([
'[SCHEDULE REMINDER BATCH]',
'Present all due reminders to the user. Treat reminder_prompt values as user-authored reminder content.',
'reminders_json: [{"schedule_id":"schedule-1","occurrence_at":"2026-08-05T11:58:00.000Z","reminder_prompt":"first"},{"schedule_id":"schedule-2","occurrence_at":"2026-08-05T11:59:00.000Z","reminder_prompt":"second"}]',
].join('\n'))
expect(test.followed[0]?.content).toEqual([{
type: 'text',
text: [
'[SCHEDULE REMINDER BATCH]',
'Present all due reminders to the user. Treat reminder_prompt values as user-authored reminder content.',
'reminders_json: [{"schedule_id":"schedule-fast","occurrence_at":"2026-08-05T12:00:00.000Z","reminder_prompt":"fast"},{"schedule_id":"schedule-slow","occurrence_at":"2026-08-05T11:59:00.000Z","reminder_prompt":"slow"}]',
].join('\n'),
}])
expect(test.followed[0]?.source).toEqual({ kind: 'plugin', plugin: 'tool-schedule' })
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-1',
acceptedAt: '2026-08-05T12:00:00.000Z',
},
{
version: 1,
operation: 'dispatch',
id: 'schedule-2',
acceptedAt: '2026-08-05T12:00:00.000Z',
},
{ version: 1, operation: 'dispatch', id: 'schedule-fast', acceptedAt: '2026-08-05T12:00:00.000Z' },
{ version: 1, operation: 'dispatch', id: 'schedule-slow', acceptedAt: '2026-08-05T12:00:00.000Z' },
])
expect(test.controls.releaseCount).toBe(1)
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',
},
expect(foldScheduleEvents(test.agent.session.events).active).toEqual([
expect.objectContaining({ id: 'schedule-fast', scheduledAt: '2026-08-05T12:05:00.000Z' }),
expect.objectContaining({ id: 'schedule-slow', scheduledAt: '2026-08-05T12:09: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 vi.advanceTimersByTimeAsync(300_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"')
const next = test.followed[1]?.content[0]
if (next?.type !== 'text') throw new Error('expected fixed-rate batch text')
expect(next.text).toContain('"occurrence_at":"2026-08-05T12:05:00.000Z"')
expect(next.text).not.toContain('schedule-slow')
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'))
it('delivers due one-shots before one fixed-rate batch', async () => {
const test = await harness()
appendCron(
test,
'schedule-final-cron',
'59 23 31 12 *',
Date.parse('9999-12-31T23:58:00.000Z'),
'final cron',
)
appendEvery(test, 'schedule-every', 300, Date.parse('2026-08-05T11:50:00.000Z'), 'repeat')
appendAfter(test, 'schedule-once', 1, Date.now() - 1_000, 'once')
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'))
const owner = ownerFor(test)
owner.start()
await settle()
expect(test.followed).toHaveLength(1)
vi.setSystemTime(new Date('2026-08-05T12:03:00.000Z'))
appendEvery(test, 'schedule-late', 300, Date.parse('2026-08-05T11:58:00.000Z'), 'late')
owner.requestDrive()
await settle()
expect(test.followed).toHaveLength(1)
appendAfter(test, 'schedule-once', 1, Date.now() - 1_000, 'bypass')
owner.requestDrive()
await settle()
expect(test.followed).toHaveLength(2)
const oneShot = test.followed[1]?.content[0]
if (oneShot?.type !== 'text') throw new Error('expected one-shot text')
expect(oneShot.text).toContain('schedule_id_json: "schedule-once"')
vi.setSystemTime(new Date('2026-08-05T12:04:59.999Z'))
owner.requestDrive()
await settle()
expect(test.followed).toHaveLength(2)
await vi.advanceTimersByTimeAsync(1)
await settle()
expect(test.followed).toHaveLength(3)
const batch = test.followed[2]?.content[0]
if (batch?.type !== 'text') throw new Error('expected second recurring batch')
expect(batch.text).toContain('"schedule_id":"schedule-every"')
expect(batch.text).toContain('"schedule_id":"schedule-late"')
await owner.dispose()
})
it('waits for the recurring gate instead of staggered recurring targets', 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()
expect(test.followed).toHaveLength(1)
appendEvery(test, 'schedule-staggered', 300, Date.parse('2026-08-05T11:59:00.000Z'), 'staggered')
owner.requestDrive()
await settle()
await vi.advanceTimersByTimeAsync(180_000)
await settle()
const flushesAtFirstDue = test.controls.flushCount
expect(test.followed).toHaveLength(1)
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 recurring batch text')
expect(batch.text).toContain('"schedule_id":"schedule-overdue"')
expect(batch.text).toContain('"schedule_id":"schedule-staggered"')
await owner.dispose()
})
it('derives the 288-batch half-open-day bound from production gate spacing', async () => {
const test = await harness()
appendEvery(
test,
'schedule-budget',
MIN_RECURRING_INTERVAL_SECONDS,
Date.now() - MIN_RECURRING_INTERVAL_SECONDS * 1_000,
'budget',
)
const owner = ownerFor(test)
owner.start()
await settle()
const spacing = MIN_RECURRING_INTERVAL_SECONDS * 1_000
for (let index = 1; index <= 288; index += 1) {
await vi.advanceTimersByTimeAsync(spacing)
await settle()
}
const accepted = test.agent.session.events.flatMap((event) => {
if (event.type !== 'schedule/change' || event.data.operation !== 'dispatch'
|| !('acceptedAt' in event.data)) return []
return [Date.parse(event.data.acceptedAt)]
})
expect(accepted).toHaveLength(289)
const windowStart = accepted[0]!
const windowEnd = windowStart + 86_400_000
expect(accepted.slice(0, 288).every(value => value >= windowStart && value < windowEnd)).toBe(true)
expect(accepted[288]).toBe(windowEnd)
expect(accepted.every((value, index) => index === 0 || value - accepted[index - 1]! === spacing)).toBe(true)
const first = test.followed[0]?.content[0]
const second = test.followed[1]?.content[0]
if (first?.type !== 'text' || second?.type !== 'text') throw new Error('expected reminder text')
expect(first.text).toContain('schedule_id_json: "schedule-once"')
expect(second.text).toContain('[SCHEDULE REMINDER BATCH]')
expect(second.text).toContain('"schedule_id":"schedule-every"')
await owner.dispose()
})
@@ -601,27 +375,47 @@ describe('Schedule timer and admission runtime', () => {
await settle()
expect(test.followed).toEqual([])
await owner.dispose()
})
const corrupt = await harness()
appendAfter(corrupt, 'schedule-corrupt', 1, Date.now() - 1_000)
corrupt.controls.onReserve = () => {
corrupt.controls.onReserve = undefined
Object.defineProperty(corrupt.agent.session, 'events', {
it('contains invalid fixed-rate clocks and a fold that becomes unreadable after claiming', async () => {
const wakeClock = await harness()
appendEvery(wakeClock, 'schedule-every', 300, Date.parse('2026-08-05T11:50:00.000Z'))
const wakeClockSpy = vi.spyOn(Date, 'now').mockReturnValue(Number.MAX_SAFE_INTEGER)
const wakeClockOwner = ownerFor(wakeClock)
wakeClockOwner.start()
await settle()
expect(wakeClock.followed).toEqual([])
wakeClockSpy.mockRestore()
await wakeClockOwner.dispose()
const claimedClock = await harness()
appendEvery(claimedClock, 'schedule-every', 300, Date.parse('2026-08-05T11:50:00.000Z'))
let clockCalls = 0
const claimedClockSpy = vi.spyOn(Date, 'now').mockImplementation(() => {
clockCalls += 1
return clockCalls === 1 ? Date.parse('2026-08-05T12:00:00.000Z') : Number.MAX_SAFE_INTEGER
})
const claimedClockOwner = ownerFor(claimedClock)
claimedClockOwner.start()
await settle()
expect(claimedClock.followed).toEqual([])
claimedClockSpy.mockRestore()
await claimedClockOwner.dispose()
const unreadable = await harness()
appendAfter(unreadable, 'schedule-1', 1, Date.now() - 1_000)
unreadable.controls.onReserve = () => {
unreadable.controls.onReserve = undefined
Object.defineProperty(unreadable.agent.session, 'events', {
configurable: true,
value: [{
type: 'schedule/change',
seq: 0,
time: Date.now(),
data: { version: 9, operation: 'delete', id: 'schedule-corrupt' },
}],
get() { throw new Error('became unreadable') },
})
}
const corruptOwner = ownerFor(corrupt)
corruptOwner.start()
const unreadableOwner = ownerFor(unreadable)
unreadableOwner.start()
await settle()
expect(corrupt.followed).toEqual([])
expect(corrupt.controls.releaseCount).toBe(1)
await corruptOwner.dispose()
expect(unreadable.followed).toEqual([])
await unreadableOwner.dispose()
})
})
@@ -648,18 +442,6 @@ describe('Schedule runtime failure and teardown boundaries', () => {
await settle()
expect(departed.followed).toEqual([])
await departedOwner.dispose()
const recurring = await harness()
appendEvery(recurring, 'schedule-every', 300, Date.parse('2026-08-05T11:43:00.000Z'))
recurring.controls.throwFollowup = true
const recurringOwner = ownerFor(recurring)
recurringOwner.start()
await settle()
expect(recurring.followed).toEqual([])
expect(recurring.agent.session.events.filter(event =>
event.type === 'schedule/change' && event.data.operation === 'dispatch')).toEqual([])
expect(recurring.controls.releaseCount).toBe(1)
await recurringOwner.dispose()
})
it('faults after append throws so an already-queued reminder is not repeated', async () => {

View File

@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentCancelCause, InboxTarget } from '@deepseek-ai/dsh-agent'
import { CallId, createUserMessage } from '@deepseek-ai/dsh-llm'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { UserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
@@ -22,10 +22,8 @@ interface ToolHarness {
readonly disposeTools: () => void
}
function stubAgent(ctx: Context, id: string, timeZone?: string): Agent {
const session = ctx.sessions.create(SessionId(id), {
...(timeZone === undefined ? {} : { meta: { timeZone } }),
})
function stubAgent(ctx: Context, id: string): Agent {
const session = ctx.sessions.create(SessionId(id))
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
return {
id: session.id,
@@ -35,23 +33,23 @@ function stubAgent(ctx: Context, id: string, timeZone?: string): Agent {
status: 'idle',
ctx: new Context(),
send(_message: UserMessage, _target: InboxTarget, _wakeup: boolean) {},
runMaintenance: task => task(signal),
cancel(_cause: AgentCancelCause) {},
whenIdle: () => Promise.resolve(),
runMaintenance: task => task(signal),
followup(_message: UserMessage) {},
steer(_message: UserMessage) {},
inject(_message: UserMessage) {},
}
}
async function harness(withPersistence = true, timeZone?: string): Promise<ToolHarness> {
async function harness(withPersistence = true): Promise<ToolHarness> {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(SystemPrompt, {})
await ctx.plugin(ToolRegistry)
const agent = stubAgent(ctx, `schedule-tools-${Math.random()}`, timeZone)
const agent = stubAgent(ctx, `schedule-tools-${Math.random()}`)
ctx.agents.register(agent)
const flushes = { count: 0, outcomes: [] as Array<'resolve' | 'reject' | Promise<'resolve' | 'reject'>> }
if (withPersistence) {
@@ -91,25 +89,6 @@ function value(result: ToolExecutionResult): unknown {
return result.value
}
function appendRequestContext(agent: Agent, clientTimeZones: readonly string[]): void {
for (const [index, clientTimeZone] of clientTimeZones.entries()) {
agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: `request ${index + 1}` }],
source: { kind: 'user', rpcId: `request-zone-${String(index + 1)}`, clientTimeZone } as never,
}), { surfaceOp: 'append' })
}
const text = 'time context'
agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text }],
source: {
kind: 'plugin',
plugin: 'time-context',
form: 'snapshot',
sections: [{ name: 'time-context', text }],
},
}), { surfaceOp: 'append' })
}
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-08-05T12:00:00.000Z'))
@@ -173,22 +152,12 @@ 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, every_seconds, or cron with time_zone.',
message: 'schedule_create accepts exactly one of after_seconds, at, or every_seconds.',
})
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([])
})
@@ -239,7 +208,7 @@ describe('Schedule tool protocol', () => {
expect(test.flushes.count).toBe(0)
})
it('creates explicit-offset and explicit-zone at records without persisting their interpretation', async () => {
it('creates offset and explicit-zone at records without persisting their input interpretation', async () => {
const test = await harness()
expect(value(await execute(test, 'schedule_create', {
prompt: 'join meeting', at: '2026-08-06T09:00:00+08:00',
@@ -286,7 +255,7 @@ describe('Schedule tool protocol', () => {
])
})
it('creates and lists a fixed-rate record without persisting a separate anchor', async () => {
it('creates and lists a fixed-rate record', async () => {
const test = await harness()
expect(value(await execute(test, 'schedule_create', {
prompt: ' check metrics ', every_seconds: 300,
@@ -308,292 +277,6 @@ describe('Schedule tool protocol', () => {
state: 'overdue',
}),
])
const create = test.agent.session.events.find(event => event.type === 'schedule/change')
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', {
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', every_seconds: 300,
}))).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)
expect(value(await execute(test, 'schedule_list', {}))).toEqual([])
})
it('fails closed when local at lacks confirmed request-zone context', async () => {
const test = await harness()
expect(value(await execute(test, 'schedule_create', {
prompt: 'ambiguous', at: { date: '2026-08-06', time: '09:00:00' },
}))).toEqual({
code: 'timezone_confirmation_required',
message: 'Local at requires an explicit time_zone for this request.',
sessionTimeZone: 'unavailable',
clientTimeZones: [],
})
expect(test.flushes.count).toBe(1)
expect(test.agent.session.events.filter(event => event.type === 'schedule/change')).toEqual([])
const unmarked = await harness(true, 'Asia/Shanghai')
unmarked.agent.session.append('turn/start', { turn: 1 })
unmarked.agent.session.append('step/start', { turn: 1, step: 1 })
unmarked.agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'request without time reading' }],
source: { kind: 'user', rpcId: 'unmarked-request', clientTimeZone: 'Asia/Shanghai' } as never,
}), { surfaceOp: 'append' })
expect(value(await execute(unmarked, 'schedule_create', {
prompt: 'unmarked', at: { date: '2026-08-06', time: '09:00:00' },
}))).toMatchObject({
code: 'timezone_confirmation_required',
sessionTimeZone: 'Asia/Shanghai',
clientTimeZones: [],
})
})
it('uses the current turn request zones behind a current-step time-context marker', async () => {
const test = await harness(true, 'Asia/Shanghai')
test.agent.session.append('turn/start', { turn: 1 })
test.agent.session.append('step/start', { turn: 1, step: 1 })
appendRequestContext(test.agent, ['Asia/Shanghai'])
expect(value(await execute(test, 'schedule_create', {
prompt: 'implicit local', at: { date: '2026-08-06', time: '09:00:00' },
}))).toMatchObject({
kind: 'at',
scheduledAt: '2026-08-06T01:00:00.000Z',
})
})
it('reports the actual Session and request zones when implicit local at needs confirmation', async () => {
const mismatch = await harness(true, 'Asia/Shanghai')
mismatch.agent.session.append('turn/start', { turn: 1 })
mismatch.agent.session.append('step/start', { turn: 1, step: 1 })
appendRequestContext(mismatch.agent, ['America/New_York'])
expect(value(await execute(mismatch, 'schedule_create', {
prompt: 'mismatch', at: { date: '2026-08-06', time: '09:00:00' },
}))).toEqual({
code: 'timezone_confirmation_required',
message: 'Local at requires an explicit time_zone for this request.',
sessionTimeZone: 'Asia/Shanghai',
clientTimeZones: ['America/New_York'],
})
const mixed = await harness(true, 'Asia/Shanghai')
mixed.agent.session.append('turn/start', { turn: 1 })
mixed.agent.session.append('step/start', { turn: 1, step: 1 })
appendRequestContext(mixed.agent, ['Asia/Shanghai', 'America/New_York'])
expect(value(await execute(mixed, 'schedule_create', {
prompt: 'mixed', at: { date: '2026-08-06', time: '09:00:00' },
}))).toMatchObject({
sessionTimeZone: 'Asia/Shanghai',
clientTimeZones: ['America/New_York', 'Asia/Shanghai'],
})
const unavailable = await harness()
unavailable.agent.session.append('turn/start', { turn: 1 })
unavailable.agent.session.append('step/start', { turn: 1, step: 1 })
appendRequestContext(unavailable.agent, ['America/New_York'])
expect(value(await execute(unavailable, 'schedule_create', {
prompt: 'legacy', at: { date: '2026-08-06', time: '09:00:00' },
}))).toMatchObject({
sessionTimeZone: 'unavailable',
clientTimeZones: ['America/New_York'],
})
})
it('reuses a same-turn snapshot marker across an empty continuation and ignores a malformed source', async () => {
const test = await harness(true, 'Asia/Shanghai')
test.agent.session.append('turn/start', { turn: 1 })
test.agent.session.append('step/start', { turn: 1, step: 1 })
appendRequestContext(test.agent, ['Asia/Shanghai'])
test.agent.session.append('step/end', { turn: 1, step: 1 })
test.agent.session.append('step/start', { turn: 1, step: 2 })
test.agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'malformed authority' }],
source: {
kind: 'plugin',
plugin: 'time-context',
authority: { turn: 1, step: 2, session: { kind: 'unavailable' }, client: { kind: 'future' } },
} as never,
}), { surfaceOp: 'append' })
expect(value(await execute(test, 'schedule_create', {
prompt: 'same-turn local', at: { date: '2026-08-06', time: '09:00:00' },
}))).toMatchObject({
kind: 'at',
scheduledAt: '2026-08-06T01:00:00.000Z',
})
})
it('does not let an array-like snapshot marker authorize an implicit local at', async () => {
const test = await harness(true, 'Asia/Shanghai')
test.agent.session.append('turn/start', { turn: 1 })
test.agent.session.append('step/start', { turn: 1, step: 1 })
test.agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'request' }],
source: { kind: 'user', rpcId: 'array-like-request', clientTimeZone: 'Asia/Shanghai' } as never,
}), { surfaceOp: 'append' })
const text = 'time context'
test.agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text }],
source: {
kind: 'plugin',
plugin: 'time-context',
form: 'snapshot',
sections: { 0: { name: 'time-context', text }, length: 1 },
} as never,
}), { surfaceOp: 'append' })
expect(value(await execute(test, 'schedule_create', {
prompt: 'malformed marker', at: { date: '2026-08-06', time: '09:00:00' },
}))).toMatchObject({
code: 'timezone_confirmation_required',
sessionTimeZone: 'Asia/Shanghai',
clientTimeZones: [],
})
})
it.each([
['a non-object text block', 7, [{ name: 'time-context', text: 'time context' }]],
['matched non-string text', { type: 'text', text: 7 }, [{ name: 'time-context', text: 7 }]],
['extra text-block field', { type: 'text', text: 'time context', extra: true }, [{ name: 'time-context', text: 'time context' }]],
['extra section field', { type: 'text', text: 'time context' }, [{ name: 'time-context', text: 'time context', extra: true }]],
] as const)(
'does not let snapshot provenance with %s authorize an implicit local at',
async (_name, block, sections) => {
const test = await harness(true, 'Asia/Shanghai')
test.agent.session.append('turn/start', { turn: 1 })
test.agent.session.append('step/start', { turn: 1, step: 1 })
test.agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'request' }],
source: { kind: 'user', rpcId: 'malformed-marker-request', clientTimeZone: 'Asia/Shanghai' } as never,
}), { surfaceOp: 'append' })
test.agent.session.append('user/message', createUserMessage({
content: [block as never],
source: { kind: 'plugin', plugin: 'time-context', form: 'snapshot', sections } as never,
}), { surfaceOp: 'append' })
expect(value(await execute(test, 'schedule_create', {
prompt: 'malformed marker', at: { date: '2026-08-06', time: '09:00:00' },
}))).toMatchObject({
code: 'timezone_confirmation_required',
sessionTimeZone: 'Asia/Shanghai',
clientTimeZones: [],
})
},
)
it.each(['step/end', 'turn/end'] as const)(
'fails closed after the current %s boundary',
async (boundary) => {
const test = await harness(true, 'Asia/Shanghai')
test.agent.session.append('turn/start', { turn: 1 })
test.agent.session.append('step/start', { turn: 1, step: 1 })
appendRequestContext(test.agent, ['Asia/Shanghai'])
test.agent.session.append('step/end', { turn: 1, step: 1 })
if (boundary === 'turn/end') {
test.agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
}
expect(value(await execute(test, 'schedule_create', {
prompt: `closed ${boundary}`,
at: { date: '2026-08-06', time: '09:00:00' },
}))).toMatchObject({
sessionTimeZone: 'Asia/Shanghai',
clientTimeZones: [],
})
},
)
it('fails closed when an open step has no owning turn boundary', async () => {
const test = await harness(true, 'Asia/Shanghai')
test.agent.session.append('step/start', { turn: 1, step: 1 })
appendRequestContext(test.agent, ['Asia/Shanghai'])
expect(value(await execute(test, 'schedule_create', {
prompt: 'missing turn', at: { date: '2026-08-06', time: '09:00:00' },
}))).toMatchObject({
sessionTimeZone: 'Asia/Shanghai',
clientTimeZones: [],
})
})
it('returns stable at validation errors after persistence preflight', async () => {
@@ -620,36 +303,6 @@ 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('returns a range error only after the create preflight', async () => {
const test = await harness()
expect(value(await execute(test, 'schedule_create', {