refactor(schedule): bound fixed-rate reminders
This commit is contained in:
@@ -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 .agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md
|
||||
2026-08-05-durable-web-schedule.md: 1963d0437e585df3b1260c031dbb2a4a49dc9046
|
||||
2026-08-05-durable-web-schedule.zh.md: 65e482d4bb65edab02e7721511f82ce1af9de9b8
|
||||
2026-08-05-durable-web-schedule.md: 689a9c985eb8c732740aa127a1fcf4c5107e5ae5
|
||||
2026-08-05-durable-web-schedule.zh.md: 070bf866ca38693db03609c93dc349ac2c110160
|
||||
@@ -1,4 +1,4 @@
|
||||
# Agent Note: Durable Session-local Web reminders
|
||||
# Agent Note: Durable Session-local reminders
|
||||
|
||||
Status: implemented
|
||||
|
||||
@@ -6,127 +6,81 @@ English | [中文](2026-08-05-durable-web-schedule.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A reminder created inside a conversation needs to survive a process restart and remain attributable to that exact Session. A process-local timer or model inbox item cannot provide that durability, while a global scheduler or private database would introduce a second identity, persistence, and lifecycle system. The user also needs a visible receipt even when the best-effort model turn later fails, without seeing a reminder whose dispatch never reached storage.
|
||||
A reminder created inside a conversation must remain attributable to that exact Session and survive a process restart. A process-local timer or inbox item cannot provide that durability, while a global scheduler or private database introduces a second identity, persistence, and lifecycle system.
|
||||
|
||||
Busy Agents, long waits, wall-clock changes, cold Sessions, forks, persistence failures, and browser history races make a simple timeout insufficient. The design must distinguish a durable record from its disposable live wait, keep a fork from inheriting its parent's active reminders, and merge a presentation sidecar that can arrive after the underlying event.
|
||||
Busy Agents, long waits, wall-clock changes, cold Sessions, forks, persistence failures, absolute calendar input, and teardown make a simple timeout insufficient. The design must distinguish a durable record from its disposable live wait, keep a fork from inheriting its parent's active reminders, and avoid spreading Schedule-specific presentation or time-zone state across unrelated components.
|
||||
|
||||
## Decision
|
||||
|
||||
The [`examples/web-schedule`](../../../../examples/web-schedule/README.md) overlay explicitly loads `@deepseek-ai/dsh-time-context`, `@deepseek-ai/dsh-tool-schedule`, and the separate `@deepseek-ai/dsh-client-ui-schedule` renderer. The default Web tree remains unchanged. Schedule observes only root Agents published after the plugin loads and installs its three tools plus one disposable owner in that Agent scope. Cold history reads, already-published roots, child Agents, and other hosts do not activate it.
|
||||
The [`examples/web-schedule`](../../../../examples/web-schedule/README.md) overlay explicitly loads `@deepseek-ai/dsh-time-context` and `@deepseek-ai/dsh-tool-schedule`; the default Web tree remains unchanged. Schedule observes only root Agents published after the plugin loads and installs its three tools plus one disposable owner in that Agent scope. Cold history reads, already-published roots, child Agents, and other hosts do not activate it.
|
||||
|
||||
The user-visible boundary is `session-local`: the original Session runs an on-time reminder only while it is live, does no external notification while cold, and processes an overdue reminder after that Session becomes live again.
|
||||
The user-visible boundary is `session-local`: the original Session runs an on-time reminder only while live, does no external notification while cold, and processes an overdue reminder after it becomes live again. Due work waits until the Agent is fully idle, then enters the ordinary next-turn queue through `followup()`; it never steers the current turn and has no independent Web receipt ([conversational delivery](../simplification/2026-08-09-conversational-schedule-delivery.md)).
|
||||
|
||||
| Scenario | Durable fact | Live behavior | User-visible result |
|
||||
| --- | --- | --- | --- |
|
||||
| Create and manage | `schedule/change` create/delete events in the original Session | Agent-scoped tools checkpoint before reading and after mutations | Stable id, UTC target, `scheduled`/`overdue`, and `session-local` disclosure |
|
||||
| Due while busy | Active create remains in the fold | Owner waits for `whenIdle()`, claims idle maintenance, queues one followup, then appends dispatch | One replayable reminder receipt; model failure does not retract it |
|
||||
| Several recurring reminders are overdue | Each active record retains its next target; dispatch history retains the last batch time and Cron calendar decisions | One maintenance claim selects every latest due occurrence after the shared 300-second gate | One model batch, with an independent receipt and next target for each reminder |
|
||||
| Process stopped or Session cold | Active create remains in persistence | No timer or background scan exists; resume rebuilds the owner | Future target waits again; overdue target is attempted once |
|
||||
| Fork | Parent events remain in the inherited prefix | Child fold starts at `seedLength` | Parent receipt may appear in history, but no parent reminder becomes active child work |
|
||||
| Create and manage | `schedule/change` create/delete in the original Session | Agent-scoped tools checkpoint before reads and after mutations | Stable id, UTC target, state, and `session-local` disclosure |
|
||||
| Due while busy | Active create remains in the fold | Owner waits for idle maintenance, queues one follow-up, then appends dispatch | A later ordinary conversation turn |
|
||||
| Several Every records are overdue | Each active record retains its earliest unaccepted anchor-aligned target | One decision selects each record's latest occurrence and advances it past now | One ordinary follow-up containing one occurrence per record |
|
||||
| Process stopped or Session cold | Active create remains persisted | No timer or background scan; resume rebuilds the owner | Future target waits; overdue target is attempted |
|
||||
| Fork | Parent events remain in the inherited prefix | Child fold starts at `seedLength` | Parent work does not become active in the child |
|
||||
|
||||
### Session log authority and tools
|
||||
### Session-log authority and tools
|
||||
|
||||
The version-1 `schedule/change` stream is the only durable Schedule authority. A create record owns a Session-local, non-reused branded id, the trimmed user prompt, the rule, and its UTC target. Delete terminates any record; an id-only dispatch terminates a one-shot; an Every dispatch stores the shared batch `acceptedAt` and advances by anchor arithmetic; a Cron dispatch stores `occurrenceAt`, shared `acceptedAt`, and optional `nextScheduledAt` to freeze the live calendar decision. The fold terminates a record with no next target and derives every remaining recurring record as terminal when the shared gate itself has no four-digit-year admission left. The strict decoder and pure fold reject unknown versions, extra fields, reused ids, mismatched dispatch shapes, batches less than 300 seconds apart, and transitions against inactive records. A normal Session folds its complete stream; a fork folds only events at or after `SessionHeader.seedLength`.
|
||||
The version-1 `schedule/change` stream is the only durable Schedule authority. A create record owns a Session-local, non-reused branded id, the trimmed prompt, its rule discriminator, and UTC target. Delete and one-shot dispatch are terminal transitions. Every dispatch stores its id and decision time so the fold advances that record directly past missed occurrences. The strict decoder and pure fold reject unknown versions, extra fields, reused ids, mismatched dispatch shapes, and transitions against inactive records. A normal Session folds its complete stream; a fork folds only events at or after `SessionHeader.seedLength`.
|
||||
|
||||
The current rule union accepts a non-empty prompt and exactly one selector. `after_seconds` is a positive safe-integer delay whose record is `{ id, kind: 'after', prompt, afterSeconds, scheduledAt }`. `at` is either a strict RFC 3339 date-time with `Z` or a numeric offset, or a structured `{ date, time, time_zone? }` local value; its record is `{ id, kind: 'at', prompt, scheduledAt }`. Both one-shot dispatches store only the id because the active record already fixes the occurrence. `every_seconds` is a safe integer of at least 300; its `{ id, kind: 'every', prompt, everySeconds, scheduledAt }` record needs no stored anchor because each accepted target remains on the initial fixed-rate sequence. Its dispatch stores only `id + acceptedAt`, from which the fold derives occurrence and next. Cron must be paired with explicit `time_zone`; its `{ id, kind: 'cron', prompt, cron, timeZone, scheduledAt }` record retains the canonical calendar rule and zone, while its dispatch freezes occurrence and next. Tool values derive `scheduled` or `overdue`, always include `deliveryMode: 'session-local'`, and expose `deliveryNotBefore` only while an overdue recurring record is gate-blocked.
|
||||
The current rule union accepts a non-empty prompt and exactly one selector. `after_seconds` is a positive safe-integer delay whose record is `{ id, kind: 'after', prompt, afterSeconds, scheduledAt }`. `at` is either strict RFC 3339 with `Z` or a numeric offset, or structured `{ date, time, time_zone }` with an explicit zone; its record is `{ id, kind: 'at', prompt, scheduledAt }`. `every_seconds` is a safe integer of at least 300 whose `{ id, kind: 'every', prompt, everySeconds, scheduledAt }` record stays aligned to its creation-plus-interval sequence. One-shot dispatch stores only the id; Every dispatch stores `id + acceptedAt`. Tool values derive `scheduled` or `overdue` and include `deliveryMode: 'session-local'`.
|
||||
|
||||
An Agent-scoped FIFO serializes each accepted management transaction and the live owner's due transaction from preflight through any post-append barrier. Every tool operation that reads or decides from the fold first awaits `ctx.sessions.flush(session)`. Create may reject input-shape failures before entering the FIFO; after a successful preflight it allocates an id, appends create, and waits for a second barrier. Delete validates its id before the FIFO, then preflights before deciding whether the id is active and waits for a second barrier only when it appends. List and unknown or finished delete never answer from an unconfirmed live suffix or observe a dispatch before its own barrier. A failed barrier returns `persistence_uncertain` rather than guessing whether an eager write committed.
|
||||
An Agent-scoped FIFO serializes management transactions and the live owner's due transaction from preflight through post-append barriers. Every tool read first awaits `ctx.sessions.flush(session)`. Create rejects input-shape failures before the FIFO when possible, preflights, allocates an id, appends, and checkpoints again. Delete validates its id before the FIFO, preflights before deciding whether it is active, and checkpoints again only after append. List and not-found delete never answer from an unconfirmed live suffix. Failed barriers return `persistence_uncertain` rather than guessing whether an eager write committed.
|
||||
|
||||
Every successful management preflight also asks the live owner to recompute. This closes the recovery path where create appended successfully but its post-append barrier rejected: a later list can confirm the coordinator's retained batch, return the active record, and arm its timer without a Schedule-specific retry loop.
|
||||
Every successful management preflight asks the live owner to recompute. A later list can therefore confirm a retained create after a previous post-append rejection and arm it without a private persistence-retry timer.
|
||||
|
||||
### Session and request time-zone ownership
|
||||
### Explicit absolute-time boundary
|
||||
|
||||
The official Web create path requires the browser's IANA zone, validates and canonicalizes it at the Host boundary, and stores it once as immutable `SessionHeader.timeZone`. Resume preserves that value, fork copies it, and another create for the same id and cwd conflicts when its canonical zone differs. Session core keeps the field optional so pre-zone Sessions remain readable but explicitly `unavailable`; a legacy header is never backfilled from a later browser request. JSONL preserves the optional header, while SQLite schema v14 adds nullable `time_zone` and upgrades an owned v13 database atomically without guessing values for existing rows.
|
||||
Natural-language interpretation and Schedule parsing are deliberately separate ([time-zone simplification](../simplification/2026-08-09-explicit-schedule-time-zone.md)). Each browser prompt carries its Host-validated IANA zone only on that durable user message. Time-context tells the model to assume that zone for otherwise-unqualified dates and times. Schedule neither imports that plugin nor stores a Session zone: the model must turn its interpretation into an offset-bearing RFC 3339 value or a local object with explicit `time_zone`.
|
||||
|
||||
That exact v13-to-v14 transaction is a narrow planned exception to the pre-release default of rejecting old storage formats: valid headerless Session databases can exist before time-zone metadata is introduced. It accepts only the owned v13 layout, rejects older, newer, or spoofed schemas without mutation, and does not establish a general migration framework.
|
||||
Schedule validates exact calendar shapes, offsets, zone names, and a strictly future four-digit-year instant. A local time inside a daylight-saving gap is rejected; an overlap chooses its first, earlier instant. A successful create stores only canonical UTC `scheduledAt`, not the original offset, local fields, or zone.
|
||||
|
||||
Every Web prompt samples its own `clientTimeZone`, which the Host validates before Agent entry and binds to that immutable `user-rpc` message source. This is request provenance, not a mutable property of the connection or Session, so concurrent tabs cannot overwrite one another and queue, steering, edit, retry, and persisted history retain the originating zone.
|
||||
### Bounded fixed-rate semantics
|
||||
|
||||
Time-context delegates through `agent/pre-step`, derives the final non-empty entered batch's zones from the immutable Session header and message-bound browser sources, and appends one model-visible reading to that batch. Its source remains the simple plugin marker; it does not copy those facts into another durable authority. Steering inserted after AgentLoop claims the current batch keeps ordinary next-step ownership and receives fresh context when that step enters. Rejection, an empty decision, cancellation, or failure before `step/start` records no reading, and this feature adds no inbox or AgentLoop lifecycle state.
|
||||
Every is a fixed-duration interval, not a calendar rule. The first target is creation time plus the interval. At a due decision, integer division selects the latest sequence point at or before the sampled wall clock and the first sequence point after it. The selected occurrence is presented once and the record advances directly to the future target, so a cold Session never accumulates a replay backlog and delayed model work never shifts the sequence.
|
||||
|
||||
Schedule requires a time-context marker in the current open turn, then derives request zones directly from that turn's original `user-rpc` sources. An implicit local `at` is accepted only when that derivation has one client zone equal to the Session zone. A headerless Session, missing or mixed client provenance, or a client/Session mismatch returns `timezone_confirmation_required` with the known zones. An explicit `time_zone` bypasses that ambiguity check but still passes the same IANA validation.
|
||||
All distinct overdue Every records participate in one batch, each with one latest occurrence and one shared `acceptedAt`. There is no cross-record cooldown, gate, quota, or retained batch timestamp. A five-minute minimum bounds wake and model-request frequency. If the next sequence point would exceed the four-digit-year storage range, dispatch terminates that record.
|
||||
|
||||
### Absolute-time normalization
|
||||
|
||||
Schedule, rather than the model or process locale, owns deterministic calendar normalization. Explicit-offset input must match the narrow supported profile and identify a strictly future four-digit-year instant. Structured local input validates the calendar and selected zone, rejects a daylight-saving gap, and chooses the first, earlier instant in an overlap. A successful create stores only UTC `scheduledAt`; the original offset, local fields, and interpreting zone are not a second durable representation. Natural-language interpretation remains the model's job, and time-context appears before the tool call rather than relying on a result echo.
|
||||
|
||||
### Restricted Cron calendar evaluation
|
||||
|
||||
Schedule owns a numeric five-field parser rather than exposing Croner's language. Each field is exactly a wildcard, integer, strictly increasing integer list, increasing inclusive range, wildcard step, or range step. Canonicalization removes leading zeros and normalizes spaces. Day-of-month and day-of-week cannot both be restricted; Sunday `0` and `7` share one semantic value. Names, macros, seconds, years, Quartz tokens, mixed forms, and duplicate semantics fail before persistence.
|
||||
|
||||
The frequency proof enumerates the complete 400-year Gregorian date cycle and combines it with exact times-of-day. It checks same-day neighbors, cross-midnight neighbors, and the cycle seam, rejecting any nominal interval below five minutes without maintaining a quota or sampling a shorter window.
|
||||
|
||||
The exact production dependency is `croner@10.0.1`, an MIT-licensed ESM package with no transitive dependencies. Schedule gives it hidden seconds=`0` and year=`1-9999`, constructs it paused without a callback, and retains timer, gate, admission, and persistence ownership. The adapter rejects gap-normalized candidates, chooses the first instant in an overlap, and requires strict forward/backward cursor movement. JavaScript constructors remap years 0–99, so an owned local-calendar walker handles that lower range and its transition before safe-year searches delegate to Croner. Live create and due handling, including the pre-append package invariant, use current Croner and ICU; replay only checks canonical rule/zone shapes, whole-minute four-digit UTC instants, and `currentScheduledAt <= occurrenceAt <= acceptedAt < nextScheduledAt`, so tzdata changes never invalidate a committed history.
|
||||
|
||||
### Persistence checkpoint and initialization recovery
|
||||
|
||||
`SessionStore.flush()` awaits every scoped listener and treats literal `true` as an explicit durability acknowledgement. An acknowledged call publishes a contained `session/flushed(session, throughSeq)` observation whose exclusive boundary was captured at call entry; append notification itself is not durability evidence. Observe-only listeners return void, an empty or observe-only checkpoint returns `false`, and any listener rejection prevents the success observation after all listeners settle.
|
||||
|
||||
The persistence coordinator supplies that acknowledgement only after its write path is quiescent. Its live controller retains the initial `seedEnd` scalar rather than a seed copy. If the first initialization rejects, a later flush rebuilds that immutable prefix from the append-only Session, reads the backend's actual cursor, and appends only a missing suffix. This covers failures before storage changed and failures reported after a commit, so one transient error neither permanently poisons the Session nor duplicates its prefix.
|
||||
Calendar and Cron expressions are deliberately absent ([bounded recurrence simplification](../simplification/2026-08-09-bounded-fixed-rate-schedule.md)); supporting them would add a time-zone-sensitive calendar language, evaluator dependency, validation surface, and tzdata replay policy unrelated to fixed-rate reminders.
|
||||
|
||||
### Live delivery lifecycle
|
||||
|
||||
The Agent-scoped owner derives its active targets and latest recurring batch from the durable fold. Long targets use bounded timer segments, and every wake reads the wall clock again, so a rollback cannot fire early and a forward jump becomes overdue. A fixed-rate record treats its current `scheduledAt` as the earliest unaccepted point on the original sequence; integer division selects the latest due point directly. A Cron record treats its persisted target as a history-stable baseline, searches only for newer current matches, and persists the chosen occurrence and next target. Neither rule replays a missed backlog or shifts its authority to delivery time. Once one recurring record is overdue behind a closed gate, the owner arms that gate or an earlier one-shot instead of waking at intervening recurring targets. If a turn or another maintenance task already owns the Agent, `runMaintenance()` rejects the claim; the record stays active and one `whenIdle()` wait triggers a later retry. A rejected persistence preflight, contained current-calendar resolution failure, or contained framing/synchronous-enqueue failure also leaves the record active, but no private retry timer runs; later Agent activity reaching idle or a successful Schedule management preflight asks the owner to try again.
|
||||
The Agent-scoped owner derives its earliest target from the durable fold. Long targets use bounded timer segments, and every wake reads the wall clock again, so a rollback cannot fire early and a forward jump becomes overdue. Due one-shots have priority and are admitted one at a time; otherwise every overdue Every record enters one batch in target and creation order. If a turn or maintenance task owns the Agent, `runMaintenance()` rejects the claim; the records stay active and one `whenIdle()` wait triggers another attempt. A rejected preflight or contained framing/enqueue failure also leaves them active without starting a private retry timer.
|
||||
|
||||
The accepted path first clears pending persistence and claims the true idle phase through `runMaintenance()`. Inside that task it refolds the exact Session suffix so a direct management mutation that won the claim race cannot be followed by a stale dispatch, then samples the decision clock once. A due one-shot bypasses the recurring gate and keeps the single fixed frame plus id-only dispatch. Otherwise the 300-second gate admits every overdue Every and Cron record in target/create order: the owner derives each latest occurrence, constructs the complete JSON batch before enqueue, synchronously queues one `followup()`, and appends an independent rule-specific dispatch per record. The gate's spacing directly limits every half-open 24-hour window to at most 288 recurring model turns; no second counter or quota exists. Waking input remains parked until maintenance settles, so the driver cannot claim the message before dispatch enters the log; only after the task releases the phase does the owner wait for the shared dispatch barrier. A framing or synchronous enqueue failure is contained and appends no dispatch. An append failure faults that owner because the message may already be queued. A later prompt-admission, request-checkpoint, or model failure cannot retract a dispatch.
|
||||
The accepted path clears pending persistence and claims the true idle phase. It refolds the exact Session suffix, samples the decision clock, constructs fixed reminder framing with JSON-escaped values, synchronously queues one `followup()`, and appends dispatch before releasing maintenance. A one-shot appends an id-only terminal dispatch. A fixed-rate batch appends one `id + acceptedAt` transition per participating record. Waking input remains parked until release, so the message cannot be claimed before dispatch enters the log; afterward the owner checkpoints dispatch.
|
||||
|
||||
Agent or plugin disposal cancels timers, stops new work, unwinds the three tool registrations, and waits for in-flight preflights or idle waits. It never deletes durable records during teardown. The narrow crash interval after synchronous followup admission and before durable dispatch may repeat the reminder after recovery; the design prefers a visible duplicate over silent loss and makes no model-success, user-read, external-effect, or exactly-once promise.
|
||||
|
||||
### Commit-aware Web receipt
|
||||
|
||||
The Schedule package owns `scheduleReminderPresentation()`, which derives `{ scheduleId, prompt, occurrenceAt }` from create plus dispatch; the client renderer adds the fixed `session-local` label. The current fork's `seedLength` is a hard boundary for child-owned dispatches. An inherited dispatch instead pairs with its nearest preceding same-id create because `session/end-seed` also marks replay or resume construction, not only fork ownership. This keeps resumed ancestor receipts renderable, preserves nested-generation id reuse, and never changes live ownership.
|
||||
|
||||
The Host continues to send every raw event on append. It keeps one monotonic watermark per exact live `Session` in a `WeakMap`; only `session/flushed` advancement makes it redeliver newly covered dispatch events with the generic `{ for: 'event', view }` sidecar. The durable `schedule/change` type selects the client renderer. Taking the maximum contains reversed concurrent flush completion, and exact object identity prevents a reused Session id from inheriting another lifecycle's cursor.
|
||||
|
||||
Attached history independently inspects persistence and adds views only to a stored event prefix whose header identity and every event match the live Session. Persistence canonically writes absent top-level `delegationDepth` as zero, so those two forms are identity-equivalent; cwd, lineage, origin, timestamps, version, id, and every event still match exactly. Missing, failed, divergent, or longer inspection withholds the view while returning raw history. Detached history is already a persisted prefix. A parent dispatch copied into a fork seed therefore appears in child history only after child storage proves that prefix.
|
||||
|
||||
The browser Session accepts a repeated seq only when the durable event is deeply identical, then upgrades the sidecar immediately without appending another event. Tail loading and true gap repair retain uncovered events in the existing `liveBuffer`; an accepted repair snapshot starts another pull when it advanced the tail but left a later buffered gap, while an identity conflict triggers a full resync. Ordinary older-page pagination keeps receiving live tail events in the current arrays, while a sidecar below the current window stays with the in-flight page and attaches only when that page returns the identical event. Reconnect generations prevent stale page or repair results and `finally` blocks from touching the rebuilt window. `TranscriptAdapter` creates a generic `PresentedEventNode` keyed by the durable event type. `ui-conversation` dispatches it through `conversation.chat.eventview` and retains an expandable JSON fallback, while `ui-schedule` owns the bilingual `schedule/change` reminder row.
|
||||
|
||||
```text
|
||||
schedule_create → Session create event → persistence
|
||||
↓ live owner
|
||||
due → admission → followup → dispatch → flush(true) → session/flushed
|
||||
↓
|
||||
Host late event sidecar
|
||||
↓
|
||||
client same-seq upgrade → event-keyed UI receipt
|
||||
```
|
||||
Dispatch records queue admission, not model completion or user receipt. Framing or synchronous enqueue failure appends no dispatch. An append failure faults that owner because the message may already be queued. Agent or plugin disposal cancels timers, stops new work, unwinds tool registrations, and awaits in-flight work without deleting durable records. A crash after follow-up admission but before durable dispatch can repeat the reminder after recovery; the design makes no exactly-once promise.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Use `ctx.tasks`.** Tasks own process-local work, terminal outcomes, collection, and notifications rather than Session-log state and replayable conversation receipts. Reusing them would make the wrong lifecycle authoritative.
|
||||
**Use `ctx.tasks`.** Tasks own process-local work, outcomes, and notifications rather than Session-log state and conversation follow-ups.
|
||||
|
||||
**Store reminders in a private SQLite table or global scheduler.** This could run cold Sessions, but requires a second Session identity map, startup scan, ownership lease, crash protocol, and notification policy. The accepted scope deliberately runs only while the original Session is live.
|
||||
**Store reminders in a private database or global scheduler.** This could run cold Sessions but requires a second identity map, startup scan, ownership lease, crash protocol, and notification policy.
|
||||
|
||||
**Claim dispatch before `followup()` or add exactly-once fencing.** A claim-first record can silently lose the user-visible reminder when enqueue fails. Cross-process exactly-once requires a lease, outbox, acknowledgement, and downstream idempotency boundary that Session-local best-effort model work does not provide.
|
||||
**Persist a Session time zone and infer local `at`.** This spreads one interpretive default through Session core, Host create/fork, persistence formats, clients, and mismatch recovery. Request-local model guidance plus an explicit tool boundary deletes that coupling.
|
||||
|
||||
**Treat the model message as the receipt.** The queued inbox item is process-local and may fail before a durable user message exists. A dispatch-derived Web receipt remains visible and replayable independently of model success.
|
||||
**Keep an independent durable Web receipt.** Dispatch is an internal queue fact, not the user's reminder. Rendering the ordinary assistant answer avoids a second delivery meaning and removes Schedule code from Host and client layers.
|
||||
|
||||
**Attach the reminder view on append.** `session/event` precedes the durability result, so this would display a ghost receipt after a rejected flush. The success watermark makes presentation follow the commit point.
|
||||
**Add a general recurring-rule engine.** Fixed-duration intervals need only anchor arithmetic. A shared recurrence abstraction, global admission gate, and calendar evaluator would enlarge replay and runtime state without serving the retained product behavior.
|
||||
|
||||
**Add a Schedule-specific wire frame, client cache, or management page.** The generic event sidecar, existing Session window buffer, keyed slot, and model-facing tools already carry the required result. A parallel transport or state store would duplicate identity and replay logic.
|
||||
**Claim dispatch before `followup()` or add exactly-once fencing.** Claim-first can silently lose a reminder when enqueue fails. Cross-process exactly-once needs a lease, outbox, acknowledgement, and downstream idempotency boundary outside this Session-local scope.
|
||||
|
||||
**Adopt existing roots or register global tools.** Late adoption makes plugin load order change which unseen timers begin running and exposes tools outside the supported root-Agent composition. Future-root, Agent-scoped installation gives one clear lifecycle.
|
||||
|
||||
**Use the process zone or the most recently connected browser as the default.** The process zone is deployment state, while a connection-level value lets one tab or a later trip silently reinterpret another request. An immutable Session default plus message-bound client provenance makes disagreement visible without creating shared mutable zone state.
|
||||
|
||||
**Parse arbitrary natural-language dates inside Schedule or persist the local input.** A second language parser would compete with the model, and retaining local text or zone beside the resolved instant would create two durable interpretations of one one-shot target. The model emits a narrow structure after seeing time-context; Schedule validates it and stores one UTC fact.
|
||||
|
||||
**Hand-roll an IANA calendar evaluator or expose Croner's full syntax.** Implementing zone transitions locally would duplicate tzdata-sensitive search, while accepting the dependency's names, macros, seconds, years, and Quartz extensions would make an external parser the public contract. The narrow Schedule parser and paused adapter keep language, frequency, lifecycle, and replay policy in their owning package while delegating calendar search.
|
||||
|
||||
The design does not recognize or migrate any unmerged Schedule implementation or private storage format. No fixed Session id, claim-before-send record, startup miss, or private database is a compatibility input.
|
||||
**Adopt existing roots or register global tools.** Late adoption makes plugin load order activate unseen timers and exposes tools outside the supported root composition.
|
||||
|
||||
## Verification
|
||||
|
||||
Package tests pin strict decoding, transitions, fork suffixes, id reuse, offset and local-calendar profiles, IANA validation, gap rejection, overlap-first selection, mismatch confirmation, time bounds, fixed-rate anchor arithmetic, restricted cron grammar, 400-year frequency proof, hidden year 3000 support, DST search, history-stable Cron dispatches, latest-only catch-up, 300-second batch spacing, full mixed batches, one-shot bypass, bounded waits, wall-clock movement, overdue admission, management/dispatch race refolding, fixed framing, enqueue and append failures, barrier recovery, registration rollback, and quiescent disposal at 100% per-file coverage. Persistence tests cover new, fork, and resumed initialization failures against the actual durable cursor, optional header round-trips, a real SQLite v13-to-v14 migration, and a production JSONL restart. The assembled Loader/Web restart lane proves pending recovery, fork isolation, one durable dispatch, cold-history rendering without Agent activation, and no redelivery after another restart. Host/client tests cover zone identity across live, stored, and concurrent-create paths; per-operation prompt provenance; commit gating; reversed watermarks; semantic header identity; per-event prefix matching; same-seq upgrades; every window merge exit; and reconnect generations.
|
||||
|
||||
Time-context tests cover final pre-step messages, current-turn unique/mixed/missing zone derivation, post-claim steering entering the next step, cancellation, empty suppression, retry, exact snapshot-source validation, and in-flight disposal. Schedule tests independently derive the same request zones from durable `user-rpc` sources, reuse a same-turn marker across an empty continuation, and fail closed without an open-turn marker. The opt-in Loader composition boots the source and built packages. Keyless real-browser scenarios execute `schedule_create` through the complete tool pipeline for the short `after` and absolute-time cases, observe the identity-matched persisted prefix, and render durable cards from attached history. One production-JSONL restart scenario pins the exact ordered Every batch. The final mixed restart proves an overdue one-shot dispatch precedes an already eligible Every/Cron batch, then verifies one shared `acceptedAt`, two rule-specific dispatches, one exact batch golden, future targets, and independent Web receipts. The deliberately absent model adapter closes each reminder turn with an error after dispatch, proving that model failure does not remove a receipt.
|
||||
Package tests pin strict replay, one-shot and Every transitions, creation-anchor arithmetic, latest-only catch-up, multi-record batching, fork suffixes, id reuse, offset and local-calendar profiles, IANA validation, daylight-saving gaps and overlaps, time bounds, timer segmentation, wall-clock movement, overdue admission, fixed framing, enqueue and append failures, barrier recovery, registration rollback, and quiescent disposal at per-file 100% coverage. A property test compares Every calculation and replay across varied intervals and skipped spans. A production JSONL restart test proves one overdue reminder dispatches through the real Agent lifecycle and does not redispatch after another restart. Host/client tests pin browser-zone sampling and prompt-bound validation. Keyless assembled Web scenarios cover browser-local At and an overdue two-record Every batch through ordinary assistant follow-ups with no receipt UI.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Reminder state survives process restart and replays through ordinary Session persistence without a new database or public service.
|
||||
- A cold Session does no work and sends no external notification; reopening it may deliver an overdue reminder, and every tool/card says `session-local`.
|
||||
- Each live root adds only fold-derived timers, an optional idle wait, and one in-flight operation. Long waits and plugin unload do not create a second durable state machine.
|
||||
- A Session's default zone is immutable and may remain unavailable for older history. Travel or concurrent tabs can therefore require an explicit zone instead of silently changing the meaning of “tomorrow at 09:00.”
|
||||
- The generic commit-aware event-view path is reusable by other durable events, but it adds event-identity checks and generation-aware merge behavior to the client Session window.
|
||||
- The strict protocol covers delayed, absolute, fixed-rate, and explicit-zone calendar targets while keeping the external evaluator private and history stable.
|
||||
- Reminder state survives restart through ordinary Session persistence without a new database or public service.
|
||||
- Cold Sessions do no work and send no external notification; reopening one may deliver overdue work.
|
||||
- Absolute input is deterministic without persistent Session-zone state or a dependency from Schedule to time-context.
|
||||
- Users see normal conversation output; dispatch never overstates model success or acknowledgement.
|
||||
- Each live root adds only fold-derived timers, an optional idle wait, and one in-flight operation.
|
||||
- Fixed-rate recurrence is bounded by a five-minute minimum, latest-only catch-up, and one batched occurrence per overdue record; calendar recurrence remains outside this product boundary.
|
||||
@@ -1,4 +1,4 @@
|
||||
# Agent Note: 持久、仅限 Session 内的 Web 提醒
|
||||
# Agent Note: 持久、仅限 Session 内的提醒
|
||||
|
||||
Status: implemented
|
||||
|
||||
@@ -6,127 +6,81 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
在对话中创建的提醒需要跨进程重启存活,并始终归属于确切的原 Session。进程内 timer 或模型 inbox 项无法提供这种持久性,而全局 scheduler 或私有数据库又会引入第二套身份、持久化和生命周期系统。即使后续 best-effort 模型轮次失败,用户仍需要看到回执;但 dispatch 尚未到达存储的提醒绝不能提前显示。
|
||||
在对话中创建的提醒必须始终归属于确切的那个 Session,并且跨进程重启存活。进程本地 timer 或 inbox 项无法提供这种持久性,而全局 scheduler 或私有数据库又会引入第二套身份、持久化和生命周期系统。
|
||||
|
||||
繁忙的 Agent、长等待、墙钟变化、cold Session、fork、持久化失败和浏览器 history 竞态,使简单 timeout 无法满足要求。设计必须区分持久 record 与可丢弃的 live wait,阻止 fork 继承父 Session 的活动提醒,并合并可能晚于原始 event 到达的 presentation sidecar。
|
||||
繁忙的 Agent(智能体)、长等待、墙钟变化、cold Session、fork、持久化失败、绝对日历输入和资源释放,使简单 timeout 无法满足要求。设计必须区分持久记录与可丢弃的 live wait,阻止 fork 继承父 Session 的活动提醒,并避免把 Schedule 专属的呈现或时区状态扩散到无关组件。
|
||||
|
||||
## 决策
|
||||
|
||||
[`examples/web-schedule`](../../../../examples/web-schedule/README.md) overlay 显式加载 `@deepseek-ai/dsh-time-context`、`@deepseek-ai/dsh-tool-schedule` 与独立 renderer `@deepseek-ai/dsh-client-ui-schedule`。默认 Web 配置树保持不变。Schedule 只观察插件加载后发布的根 Agent,并在该 Agent scope 中安装三个工具和一个可丢弃 owner。cold history 读取、已发布的根、child Agent 与其他宿主都不会激活它。
|
||||
[`examples/web-schedule`](../../../../examples/web-schedule/README.md) overlay 显式加载 `@deepseek-ai/dsh-time-context` 与 `@deepseek-ai/dsh-tool-schedule`;默认 Web 配置树保持不变。Schedule 只观察插件加载后发布的根 Agent,并在该 Agent scope 中安装三个工具和一个可丢弃 owner。cold history 读取、已发布的根、child Agent 与其他 host 都不会激活它。
|
||||
|
||||
用户可见边界固定为 `session-local`:原 Session 只有在 live 时才会准点运行提醒,cold 期间不发送任何外部通知;该 Session 再次 live 后才会处理 overdue 提醒。
|
||||
用户可见边界是 `session-local`:原 Session 只有在 live 时才会准时运行提醒,cold 期间不发送任何外部通知;该 Session 再次 live 后才会处理 overdue 提醒。到期工作会等待 Agent 完全 idle,再通过 `followup()` 进入普通的下一轮队列;它绝不会中途引导当前轮次,也没有独立 Web 回执([对话式交付](../simplification/2026-08-09-conversational-schedule-delivery.md))。
|
||||
|
||||
| 场景 | 持久事实 | live 行为 | 用户可见结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| 创建与管理 | 原 Session 中的 `schedule/change` create/delete event | Agent-scoped 工具在读取前、变更后执行 checkpoint | 稳定 id、UTC 目标、`scheduled`/`overdue` 与 `session-local` 说明 |
|
||||
| 到期时繁忙 | 活动 create 仍在 fold 中 | owner 等待 `whenIdle()`、认领 idle maintenance、排入一次 followup,再追加 dispatch | 一条可回放提醒回执;模型失败不会撤回它 |
|
||||
| 多条周期性提醒已逾期 | 每条活动 record 保留下一个目标;dispatch history 保留最近一次 batch 的时间与 Cron 日历决策 | 一次 maintenance 认领会在共享的 300 秒门控开放后选出每条记录最近一次到期的 occurrence | 一个模型 batch,每条提醒各有独立回执和下一个目标 |
|
||||
| 进程停止或 Session cold | 活动 create 仍在 persistence 中 | 不存在 timer 或后台扫描;resume 重建 owner | 未来目标继续等待;overdue 目标尝试一次 |
|
||||
| fork | 父 event 留在继承前缀 | child fold 从 `seedLength` 开始 | history 可显示父回执,但父提醒不会成为 child 活动工作 |
|
||||
| 创建与管理 | 原 Session 中的 `schedule/change` create/delete | Agent-scoped 工具在读取前、变更后执行 checkpoint | 稳定 id、UTC 目标、状态与 `session-local` 说明 |
|
||||
| 到期时繁忙 | 活动 create 仍在 fold 中 | owner 等待 idle maintenance,排入一个 follow-up,再追加 dispatch | 后续一个普通对话轮次 |
|
||||
| 多条 Every 记录逾期 | 每条活动记录都保留最早一个尚未接受且与锚点对齐的目标 | 一次决策选择每条记录的最新发生时点,并将其推进到当前时刻之后 | 一个普通 follow-up,其中每条记录各有一个发生时点 |
|
||||
| 进程停止或 Session cold | 活动 create 仍在 persistence 中 | 不存在 timer 或后台扫描;resume 重建 owner | 未来目标继续等待;overdue 目标会被尝试 |
|
||||
| fork | 父 event 留在继承前缀 | child fold 从 `seedLength` 开始 | 父工作不会在 child 中变为活动状态 |
|
||||
|
||||
### Session 日志权威与工具
|
||||
|
||||
版本 1 `schedule/change` stream 是唯一持久 Schedule 权威。create record 拥有 Session 内不复用的品牌 id、trim 后的用户 prompt、规则与 UTC 目标。delete 会终结任何 record;只含 id 的 dispatch 会终结一次性 record;Every dispatch 会存储共享 batch 的 `acceptedAt`,并通过锚点运算推进 record;Cron dispatch 会存储 `occurrenceAt`、共享的 `acceptedAt` 与可选的 `nextScheduledAt`,从而固化 live 日历决策。没有下一个目标时,fold 会终结该 record;共享门控本身不再有年份为四位数的准入时点时,fold 会把所有剩余的周期性 record 派生为 terminal。严格 decoder 与 pure fold 会拒绝未知版本、额外字段、重复 id、不匹配的 dispatch shape、间隔不足 300 秒的周期性 batch,以及针对非活动 record 的 transition。普通 Session 折叠完整 stream;fork 只折叠 `SessionHeader.seedLength` 位置及其后的 event。
|
||||
版本 1 `schedule/change` stream 是唯一持久的 Schedule 权威。create 记录拥有一个 Session 内不复用的品牌 id、trim 后的提示词、规则判别字段和 UTC 目标。delete 与一次性 dispatch 是终结转换。Every dispatch 会存储 id 与决策时点,使 fold 将该记录直接推进到错过的发生时点之后。严格 decoder 与纯 fold 会拒绝未知版本、额外字段、重复使用的 id、形状不匹配的 dispatch,以及针对非活动记录的转换。普通 Session 折叠完整 stream;fork 只折叠 `SessionHeader.seedLength` 位置及其后的 event。
|
||||
|
||||
当前规则 union 接受非空提示词与恰好一个 selector。`after_seconds` 是正 safe-integer delay,其 record 为 `{ id, kind: 'after', prompt, afterSeconds, scheduledAt }`。`at` 可以是带 `Z` 或数字 offset 的严格 RFC 3339 date-time,也可以是结构化的 `{ date, time, time_zone? }` local value;其 record 为 `{ id, kind: 'at', prompt, scheduledAt }`。两种一次性 dispatch 都只保存 id,因为活动 record 已经唯一确定 occurrence。`every_seconds` 是不小于 300 的安全整数;其 `{ id, kind: 'every', prompt, everySeconds, scheduledAt }` record 无需另存锚点,因为每个已接受目标都保持在初始固定频率序列上。其 dispatch 只存储 `id + acceptedAt`;fold 据此派生 occurrence 与下一个目标。Cron 必须与显式 `time_zone` 配对;其 `{ id, kind: 'cron', prompt, cron, timeZone, scheduledAt }` record 会保留规范化后的日历规则与时区,而 dispatch 会固化 occurrence 与下一个目标。工具 value 派生 `scheduled` 或 `overdue`,始终包含 `deliveryMode: 'session-local'`,并且仅在 overdue 周期性 record 被门控阻挡时暴露 `deliveryNotBefore`。
|
||||
当前规则 union 接受非空提示词和恰好一个 selector。`after_seconds` 是正的安全整数 delay,其记录为 `{ id, kind: 'after', prompt, afterSeconds, scheduledAt }`。`at` 可以是带 `Z` 或数值偏移量且严格符合 RFC 3339 的值,也可以是带显式时区的结构化 `{ date, time, time_zone }`;其记录为 `{ id, kind: 'at', prompt, scheduledAt }`。`every_seconds` 是不小于 300 的安全整数,其 `{ id, kind: 'every', prompt, everySeconds, scheduledAt }` 记录始终与从创建时刻加一个间隔开始的序列对齐。一次性 dispatch 只存储 id;Every dispatch 存储 `id + acceptedAt`。工具值派生 `scheduled` 或 `overdue`,并包含 `deliveryMode: 'session-local'`。
|
||||
|
||||
一个 Agent-scoped FIFO 会将每项已接纳的管理事务与 live owner 的到期事务从 preflight 到任何 post-append barrier 全程串行化。每项从 fold 读取或作出判断的工具操作都会先等待 `ctx.sessions.flush(session)`。create 可以在进入 FIFO 前拒绝只依赖输入 shape 的失败;preflight 成功后才分配 id、追加 create,并等待第二个 barrier。delete 在进入 FIFO 前验证其 id,随后在判断 id 是否活动前先 preflight,只有实际追加时才等待第二个 barrier。list 与未知或已终结 delete 绝不会从未确认的 live 后缀作答,也不会在自身的 barrier 前观察到 dispatch。barrier 失败会返回 `persistence_uncertain`,而不是猜测 eager write 是否已经提交。
|
||||
一个 Agent-scoped FIFO 会将管理事务与 live owner 的到期事务从 preflight 到 post-append barrier 全程串行化。每项工具读取都会先等待 `ctx.sessions.flush(session)`。create 会尽可能在进入 FIFO 前拒绝输入形状错误,随后执行 preflight、分配 id、追加记录并再次 checkpoint。delete 会在进入 FIFO 前验证 id,在判断其是否活动前执行 preflight,并且只在追加后再次 checkpoint。list 与 not-found delete 绝不会根据未经确认的 live 后缀作答。barrier 失败会返回 `persistence_uncertain`,而不是猜测 eager write 是否已经提交。
|
||||
|
||||
每次成功的管理 preflight 也会要求 live owner 重新计算。这闭合了 create 已成功追加、但 post-append barrier 拒绝时的恢复路径:后续 list 可以确认 coordinator 保留的 batch、返回活动 record,并在没有 Schedule 私有重试循环的情况下 arm timer。
|
||||
每次成功的管理 preflight 也会要求 live owner 重新计算。因此,如果先前的 post-append 被拒绝,后续 list 可以确认保留的 create 并将其 arm,而无需私有的 persistence 重试 timer。
|
||||
|
||||
### Session 与请求时区归属
|
||||
### 显式绝对时间边界
|
||||
|
||||
官方 Web create 路径要求浏览器提供 IANA 时区,在 Host 边界校验并规范化后,将其一次性存为不可变的 `SessionHeader.timeZone`。resume 保留该值,fork 复制该值;若针对相同 id 与 cwd 的另一次 create 得到的规范化时区不同,则发生冲突。Session core 保持该字段可选,使时区支持前的 Session 仍可读取,但其时区明确为 `unavailable`;绝不会用后续浏览器请求回填 legacy header。JSONL 保留该可选 header;SQLite schema v14 增加 nullable `time_zone`,并以原子方式升级自有 v13 数据库,不为既有行猜测值。
|
||||
自然语言解释与 Schedule 解析被有意分开([时区简化](../simplification/2026-08-09-explicit-schedule-time-zone.md))。每条浏览器提示词只在其对应的持久 user message 上携带由 Host 校验过的 IANA 时区。Time-context 会告诉模型,把未明确限定时区的日期和时间解释为该时区。Schedule 既不导入该插件,也不存储 Session 时区:模型必须把其解释结果转换为带偏移量的 RFC 3339 值,或带显式 `time_zone` 的本地对象。
|
||||
|
||||
这笔精确的 v13 到 v14 事务,是对“预发布阶段默认拒绝旧存储格式”立场的一项窄幅、已规划例外:在引入时区 metadata 前,可能已经存在有效的无时区 Session 数据库。它只接受自有 v13 布局;更旧、更新或伪造的 schema 都会在不修改数据的前提下被拒绝,而且不会建立通用迁移框架。
|
||||
Schedule 会校验精确的日历形状、偏移量、时区名称,以及一个严格位于未来、年份为四位数的时点。落在夏令时缺口内的本地时间会被拒绝;遇到重叠时会选择第一次出现的较早时点。创建成功后只存储规范化后的 UTC `scheduledAt`,不会存储原始偏移量、本地字段或时区。
|
||||
|
||||
每条 Web 提示词都会单独采样自己的 `clientTimeZone`;Host 在进入 Agent 前校验该值,并把它绑定到不可变的 `user-rpc` 消息来源。它是请求 provenance,而不是连接或 Session 的可变属性,因此并发 tab 无法相互覆盖,排队、steering(中途引导)、编辑、重试和持久化 history 都会保留来源时区。
|
||||
### 有界固定速率语义
|
||||
|
||||
Time-context 会委托 `agent/pre-step`,从不可变 Session header 和与消息绑定的浏览器来源为最终进入的非空批次派生时区,再向该批次追加一条模型可见读数。其来源仍是简单插件标记,不会把这些事实复制成另一份持久权威。AgentLoop 领取当前批次后才插入的 steering(中途引导)保留常规 next-step 归属,并在该步骤进入时获得新上下文。`step/start` 之前出现 reject、空决策、取消或失败时,不会记录读数;本功能也不增加 inbox 或 AgentLoop 生命周期状态。
|
||||
Every 是固定时长间隔,而不是日历规则。第一个目标是创建时刻加上一个间隔。作出到期决策时,整数除法会选出不晚于所采样墙钟的最新序列点,以及其后的第一个序列点。选中的发生时点只呈现一次,记录会直接推进到未来目标,因此 cold Session 绝不会积累回放任务,延迟执行的模型工作也绝不会使该序列漂移。
|
||||
|
||||
Schedule 要求当前 open turn 中存在 time-context 标记,然后直接从该 turn 的原始 `user-rpc` 来源派生请求时区。只有派生结果包含一个与 Session 时区相等的 client 时区,才会接受隐式 local `at`。无 header 的 Session、client provenance 缺失或 mixed,或 client/Session 不匹配,都会返回 `timezone_confirmation_required` 及已知时区。显式 `time_zone` 可绕过这项歧义检查,但仍要通过相同的 IANA 校验。
|
||||
所有不同的逾期 Every 记录都会参与同一个批次,每条记录各自提供一个最新发生时点,并共享同一个 `acceptedAt`。系统不存在跨记录的冷却、门控、配额或保留的批次时间戳。至少 5 分钟的限制约束了唤醒与模型请求频率。如果下一个序列点会超出四位年份存储范围,dispatch 会终结该记录。
|
||||
|
||||
### 绝对时间规范化
|
||||
|
||||
确定性的日历规范化由 Schedule 负责,而不是模型或进程 locale。显式 offset 输入必须匹配受支持的窄 profile,并标识一个严格位于未来、年份为四位数的时点。结构化 local 输入会校验日历和选定时区,拒绝夏令时空档,并选择重叠时段中首次出现的较早时点。成功的 create 只存储 UTC `scheduledAt`;原 offset、local 字段和用于解释的时区不会形成第二份持久表示。自然语言解释仍由模型完成,time-context 出现在工具调用之前,而不依赖结果回显。
|
||||
|
||||
### 受限 Cron 日历求值
|
||||
|
||||
Schedule 拥有自己的数值五字段 parser,而不开放 Croner 语言。每个字段只能是 wildcard、整数、严格递增的整数列表、递增闭区间、wildcard step 或区间 step。规范化会移除前导零并统一空格。月中日期与星期字段不能同时受限;星期日的 `0` 和 `7` 表示同一语义。名称、macro、秒、年份、Quartz token、混合形式与重复语义都会在持久化前被拒绝。
|
||||
|
||||
频率证明会枚举完整的 400 年 Gregorian 日期周期,并与精确的一日内时刻组合。它会检查同日相邻时点、跨午夜相邻时点与周期首尾衔接处的相邻时点,拒绝任何短于 5 分钟的名义间隔;整个过程既不维护配额,也不对更短窗口采样。
|
||||
|
||||
生产环境精确锁定的依赖是 `croner@10.0.1`:这是一个采用 MIT 许可证、不含传递依赖的 ESM 包。Schedule 为其提供隐藏的 seconds=`0` 与 year=`1-9999`,以 paused 状态且不带 callback 构造;timer、门控、准入与持久化仍由 Schedule 拥有。适配器会拒绝由夏令时空档规范化产生的候选值,在重叠时段选择第一个时刻,并要求正向与反向 cursor 严格移动。JavaScript 构造器会重映射 0–99 年,因此 Schedule 自有的本地日历搜索会处理这一低年份范围及其向安全年份的过渡;只有安全年份搜索才会委托给 Croner。live create 与到期处理(包括 append 前的 package invariant)使用当前 Croner 和 ICU;回放只检查规范化的规则/时区 shape、整分钟且年份为四位数的 UTC 时点,以及 `currentScheduledAt <= occurrenceAt <= acceptedAt < nextScheduledAt`,因此 tzdata 变化绝不会使已提交的 history 失效。
|
||||
|
||||
### Persistence checkpoint 与初始化恢复
|
||||
|
||||
`SessionStore.flush()` 会等待所有 scoped listener,并把字面量 `true` 视为显式 durability acknowledgement。获得确认的调用会发布受包含的 `session/flushed(session, throughSeq)` observation;其中排他边界在调用入口捕获,append 通知本身不是 durability 证据。仅观察 listener 返回 void;空或只有观察者的 checkpoint 返回 `false`;任一 listener 拒绝都会在全部结算后阻止成功 observation。
|
||||
|
||||
persistence coordinator 只有在写路径完全停稳后才给出该确认。live controller 只保留初始 `seedEnd` 标量,不复制 seed。首次初始化拒绝后,后续 flush 会从仅追加 Session 重建该不可变前缀、读取后端实际 cursor,并只追加缺失 suffix。无论失败发生在存储变更前,还是提交后才返回拒绝,一次暂时性错误都不会永久毒化 Session 或重复写入其前缀。
|
||||
日历表达式与 Cron 表达式被有意排除([有界周期性简化](../simplification/2026-08-09-bounded-fixed-rate-schedule.md));支持这些表达式需要增加时区敏感的日历语言、求值器依赖、校验范围和 tzdata 回放策略,而这些都与固定速率提醒无关。
|
||||
|
||||
### Live 交付生命周期
|
||||
|
||||
Agent-scoped owner 从持久 fold 派生活动目标与最近一次周期性 batch。超长目标使用有界 timer 分段,每次 wake 都重新读取墙钟,因此回拨不会提前触发,前跳则会形成 overdue。固定频率 record 将当前 `scheduledAt` 视为原始序列上最早尚未接受的点;整数除法会直接选出最近一次到期点。Cron record 将持久目标视为在 history 中保持稳定的 baseline,只搜索按当前规则求得且比 baseline 更新的 match,并持久化选定的 occurrence 与下一个目标。两种规则都不会回放错过期间积压的 occurrence,也不会把权威转移到交付时间。一旦有周期性 record 因门控关闭而处于 overdue,owner 就会将该门控或更早的一次性提醒设为唤醒点,而不再为其间的周期性目标安排唤醒。如果 agent 已被某个轮次或另一项 maintenance task 占用,`runMaintenance()` 会拒绝此次认领;record 保持活动,并由一个 `whenIdle()` wait 触发稍后的重试。被拒绝的 persistence preflight、被收容的当前日历求值失败,或被收容的 framing/同步入队失败同样会让 record 保持活动,但不会运行私有重试 timer;后续 agent 活动进入 idle,或成功的 Schedule 管理 preflight 会要求 owner 再次尝试。
|
||||
Agent-scoped owner 从持久 fold 派生最早目标。超长目标使用有界 timer 分段,每次 wake 都会重新读取墙钟,因此回拨不会提前触发,前跳则会形成 overdue。已到期的一次性提醒优先,每次准入一条;否则,所有逾期 Every 记录会按目标时间和创建顺序进入同一个批次。如果 Agent 已被某个轮次或另一项 maintenance task 占用,`runMaintenance()` 会拒绝此次认领;这些记录保持活动,并由一次 `whenIdle()` wait 触发另一次尝试。被拒绝的 preflight 或被收容的 framing/入队失败同样会使其保持活动,但不会启动私有重试 timer。
|
||||
|
||||
获得准入的路径会先清空 pending persistence,并通过 `runMaintenance()` 认领真正的 idle phase。该任务会重新折叠确切的 Session 后缀,从而确保在认领竞态中胜出的直接管理变更之后不会跟随陈旧 dispatch;然后只采样一次 decision clock。到期的一次性提醒会绕过周期性门控,继续使用单条固定 reminder frame 和只含 id 的 dispatch。否则,300 秒门控会按目标/create 顺序接纳每条 overdue Every 与 Cron record:owner 为每条 record 派生最近一次到期的 occurrence,在入队前构造完整 JSON batch,同步排入一次 `followup()`,并为每条 record 追加与其规则对应的独立 dispatch。门控间隔直接将每个半开 24 小时窗口内由周期性提醒触发的模型轮次限制为至多 288 个;不存在第二个计数器或配额。触发唤醒的 input 会保持 parked,直到 maintenance 结束,因此 driver 无法在 dispatch 进入 log 前认领消息;只有该任务释放 phase 后,owner 才会等待共享 dispatch barrier。framing 或同步入队失败会被收容,且不会追加 dispatch。append 失败会使该 owner fault,因为消息可能已经入队。后续 prompt admission、request checkpoint 或模型失败都不能撤回 dispatch。
|
||||
获得准入的路径会刷新所有 pending persistence 并认领真正的 idle phase。它会重新折叠确切的 Session 后缀、采样 decision clock、用经过 JSON 转义的值构造固定提醒 framing、同步排入一个 `followup()`,并在释放 maintenance 前追加 dispatch。一次性提醒会追加只含 id 的终结 dispatch。固定速率批次会为每条参与记录追加一个 `id + acceptedAt` 转换。触发唤醒的 input 会保持 parked,直到 maintenance 释放,因此在 dispatch 进入日志前,消息不会被认领;随后 owner 会为 dispatch 执行 checkpoint。
|
||||
|
||||
Agent 或插件 dispose 会取消 timer、停止新工作、撤销三个工具注册,并等待进行中的 preflight 或 idle wait。teardown 绝不会删除持久 record。同步 followup 获得准入后、durable dispatch 前的狭窄崩溃窗口可能在恢复后重复提醒;本设计选择可见重复而非静默丢失,不承诺模型成功、用户阅读、外部副作用或 exactly-once。
|
||||
|
||||
### Commit-aware Web 回执
|
||||
|
||||
Schedule package 拥有 `scheduleReminderPresentation()`,从 create 加 dispatch 派生 `{ scheduleId, prompt, occurrenceAt }`。client renderer 会添加固定的 `session-local` 标签。当前 fork 的 `seedLength` 是 child 自有 dispatch 的硬边界。继承的 dispatch 则会与它之前最近的同 id create 配对,因为 `session/end-seed` 也会标记回放或恢复构造,而不仅标记 fork 所有权。这使恢复后的祖先回执仍可渲染,保留嵌套 generation 的 id 复用,并且绝不会改变 live ownership。
|
||||
|
||||
Host 在 append 时继续发送所有 raw event。它在 `WeakMap` 中按 exact live `Session` 保存一个单调 watermark;只有 `session/flushed` 前进时,才会用通用 `{ for: 'event', view }` sidecar 重投新覆盖的 dispatch event。持久 `schedule/change` 类型用于选择 client renderer。取最大值可以收容反序完成的并发 flush,按对象身份键控则阻止复用的 Session id 继承另一个生命周期的 cursor。
|
||||
|
||||
已附加 history 会独立 inspect persistence,只有 stored event prefix 的 header identity 与每个 event 都和 live Session 匹配时才添加 view。persistence 会把顶层缺失的 `delegationDepth` 规范写成零,因此两种形式在身份上等价;cwd、lineage、origin、时间戳、版本、id 与每个 event 仍必须精确匹配。inspect 缺失、失败、分歧或比 live 更长时,只会省略 view,raw history 仍然返回。已分离 history 本身就是持久前缀。因此复制进 fork seed 的 parent dispatch 只有在 child storage 证明该前缀后才会显示。
|
||||
|
||||
浏览器 Session 只有在 durable event 深度一致时才接受重复 seq,随后立即升级 sidecar,不再追加 event。尾部加载与真正的 gap repair 会将尚未覆盖的事件保留在既有 `liveBuffer` 中;已接受的 repair 快照在推进 tail 但仍留下后续已缓冲的 gap 时会启动另一次 pull,身份冲突则会触发全量重新同步。普通旧页分页会让当前数组继续接收 live tail 事件,当前 window 以下的 sidecar 则由 in-flight page 自身暂存,只有该页返回身份完全相同的事件时才附着。重连 generation 会阻止陈旧的 page 或 repair 结果以及 `finally` 块触碰重建后的 window。`TranscriptAdapter` 创建按持久事件类型键控的通用 `PresentedEventNode`。`ui-conversation` 通过 `conversation.chat.eventview` 分发,并保留可展开 JSON fallback;`ui-schedule` 则拥有双语 `schedule/change` 提醒行。
|
||||
|
||||
```text
|
||||
schedule_create → Session create event → persistence
|
||||
↓ live owner
|
||||
due → admission → followup → dispatch → flush(true) → session/flushed
|
||||
↓
|
||||
Host late event sidecar
|
||||
↓
|
||||
client same-seq upgrade → event-keyed UI receipt
|
||||
```
|
||||
dispatch 记录的是队列准入,而不是模型完成或用户收到提醒。framing 构造或同步入队失败不会追加 dispatch。append 失败会使该 owner fault,因为消息可能已经入队。Agent 或插件 dispose 会取消 timer、停止新工作、撤销工具注册,并等待进行中的工作,且不会删除持久记录。follow-up 获得准入后、持久 dispatch 前发生崩溃,可能使提醒在恢复后重复;本设计不作 exactly-once 承诺。
|
||||
|
||||
## 已考虑的替代方案
|
||||
|
||||
**使用 `ctx.tasks`。** Task 拥有进程内工作、终态结果、收集与通知语义,而不是 Session 日志状态和可回放会话回执。复用它会让错误的生命周期成为权威。
|
||||
**使用 `ctx.tasks`。** Task 拥有进程本地工作、结果和通知,而不是 Session 日志状态和对话 follow-up。
|
||||
|
||||
**把提醒存入私有 SQLite 表或全局 scheduler。** 这样可以运行 cold Session,却必须增加第二套 Session 身份映射、startup 扫描、ownership lease、崩溃协议与通知政策。当前范围有意只在原 Session live 时运行。
|
||||
**把提醒存入私有数据库或全局 scheduler。** 这样可以运行 cold Session,却需要第二套身份映射、启动扫描、ownership lease、崩溃协议和通知策略。
|
||||
|
||||
**在 `followup()` 前 claim dispatch,或增加 exactly-once fencing。** claim-first record 会在入队失败时静默丢失用户可见提醒。跨进程 exactly-once 需要 lease、outbox、acknowledgement 与下游幂等边界,而 Session-local best-effort 模型工作不具备这些边界。
|
||||
**持久化 Session 时区并推断本地 `at`。** 这会让一个解释默认值扩散到 Session core、Host create/fork、持久化格式、client 和不匹配恢复中。请求本地的模型指导与显式工具边界消除了这种耦合。
|
||||
|
||||
**把模型消息当作回执。** 已排队 inbox 项是进程内状态,可能在产生持久 user message 前失败。从 dispatch 派生的 Web 回执不依赖模型成功,仍然可见、可回放。
|
||||
**保留独立的持久 Web 回执。** dispatch 是内部队列事实,而不是用户的提醒。渲染普通 assistant 回答既避免了第二种交付含义,也从 Host 与 client 层移除了 Schedule 代码。
|
||||
|
||||
**在 append 时附加提醒 view。** `session/event` 早于 durability 结果;这样会在 flush 拒绝后显示幽灵回执。成功 watermark 让 presentation 服从提交点。
|
||||
**增加通用周期规则引擎。** 固定时长间隔只需要锚点运算。共享的周期抽象、全局准入门控和日历求值器会扩大回放与运行时状态,却不能服务于保留的产品行为。
|
||||
|
||||
**增加 Schedule 专属 wire frame、client cache 或管理页面。** 通用 event sidecar、既有 Session window buffer、键控 slot 与面向模型工具已经能承载所需结果。平行 transport 或状态 store 会重复身份与回放逻辑。
|
||||
**在 `followup()` 前认领 dispatch,或增加 exactly-once fencing。** claim-first 会在入队失败时静默丢失提醒。跨进程 exactly-once 需要 lease、outbox、acknowledgement 与下游幂等边界,超出了此 Session-local 范围。
|
||||
|
||||
**接管既有根或注册全局工具。** 晚接管会让插件加载顺序改变哪些不可见 timer 开始运行,并把工具暴露到支持范围之外。只面向未来根、按 Agent scope 安装,提供了单一明确生命周期。
|
||||
|
||||
**将进程时区或最近连接的浏览器用作默认值。** 进程时区属于部署状态,而连接级值会让某个 tab 或后续出行悄然重新解释另一个请求。不可变的 Session 默认值加上绑定到消息的 client provenance,能让分歧显现,而不创建共享的可变时区状态。
|
||||
|
||||
**在 Schedule 内解析任意自然语言日期,或持久化 local 输入。** 另一套语言解析器会与模型竞争,而在已解析时点旁保留 local 文本或时区,会为同一个一次性目标形成两种持久解释。模型看到 time-context 后输出一个窄结构;Schedule 校验它并存储一个 UTC 事实。
|
||||
|
||||
**自行实现 IANA 日历求值器,或开放 Croner 的完整语法。** 在本地实现时区 transition 会重复一套对 tzdata 敏感的搜索;接受该依赖的名称、macro、秒、年份与 Quartz 扩展,则会让外部 parser 成为公开契约。受限的 Schedule parser 与 paused 适配器将语言、频率、生命周期和回放策略保留在所属包内,同时只委托日历搜索。
|
||||
|
||||
本设计不会识别或迁移任何未合入的 Schedule 实现或私有存储格式。固定 Session id、claim-before-send record、startup miss 与私有数据库都不是兼容输入。
|
||||
**接管既有根或注册全局工具。** 晚接管会让插件加载顺序激活不可见的 timer,并把工具暴露到受支持的根组合之外。
|
||||
|
||||
## 验证
|
||||
|
||||
package 测试以逐文件 100% coverage 固定严格 decoding、transition、fork suffix、id 不复用、offset 与 local-calendar profile、IANA 校验、gap 拒绝、overlap-first 选择、mismatch confirmation、时间边界、固定频率锚点运算、受限 cron 语法、400 年频率证明、隐藏年份字段对 3000 年的支持、DST 搜索、在 history 中保持稳定的 Cron dispatch、仅追赶最近一次到期点、300 秒 batch 间隔、完整的混合 batch、一次性提醒绕过门控、有界等待、墙钟变化、overdue 准入、管理/dispatch 竞争下的重新 fold、固定 framing、入队与 append 失败、barrier 恢复、注册 rollback 和完全停稳 dispose。persistence 测试依据实际 durable cursor 覆盖 new、fork 与 resumed 初始化失败、可选 header round-trip、一次真实 SQLite v13 到 v14 migration,以及 production JSONL restart。组装后的 Loader/Web restart lane 证明 pending 恢复、fork 隔离、单次 durable dispatch、无需激活 agent 的 cold-history rendering,以及再次 restart 后不重投。Host/client 测试覆盖 live、stored 与 concurrent-create 路径中的 zone identity、逐操作提示词 provenance、commit gating、反序 watermark、语义 header identity、逐 event 前缀匹配、same-seq 升级、每个 window merge 出口和 reconnect generation。
|
||||
|
||||
Time-context 测试覆盖最终 pre-step 消息、当前 turn 的唯一/混合/缺失时区派生、领取后 steering 进入下一步骤、取消、空值抑制、重试、精确 snapshot 来源校验和执行中释放。Schedule 测试会从持久 `user-rpc` 来源独立派生同一组请求时区,在空的续跑中复用同 turn 标记,并在缺少 open-turn 标记时 fail closed。显式 Loader 组合可以启动 source 与 built package。无密钥真实浏览器场景会通过完整工具 pipeline,针对短 `after` 与绝对时间 case 执行 `schedule_create`,观察 identity-matched 持久前缀,并从已附加 history 渲染 durable card。一个 production JSONL restart 场景会固定精确且有序的 Every batch。最终的混合 restart 会证明一条 overdue 一次性 dispatch 先于已经符合准入条件的 Every/Cron batch,随后验证一个共享的 `acceptedAt`、两条与规则对应的 dispatch、一份精确的 batch 预期输出、未来目标与各自独立的 Web 回执。刻意缺少的模型 adapter 会在 dispatch 后以错误关闭每个提醒 turn,从而证明模型失败不会移除任何回执。
|
||||
包测试以逐文件 100% coverage 固定严格回放、一次性与 Every 状态转换、创建锚点运算、只追赶最新一次、多记录批处理、fork 后缀、id 复用、偏移量与本地日历 profile、IANA 校验、夏令时缺口与重叠、时间边界、timer 分段、墙钟变化、overdue 准入、固定 framing、入队与 append 失败、barrier 恢复、注册 rollback 和完全停稳的 dispose。属性测试会在不同间隔与跳过跨度下比较 Every 计算与回放。production JSONL restart 测试证明一条 overdue 提醒会经过真实 Agent 生命周期 dispatch,并且再次 restart 后不会重复 dispatch。Host/client 测试固定浏览器时区采样与绑定到提示词的校验。无密钥组装 Web 场景覆盖浏览器本地 At,以及通过普通 assistant follow-up 交付的逾期双记录 Every 批次,两者都没有回执 UI。
|
||||
|
||||
## 后果
|
||||
|
||||
- 提醒状态通过普通 Session persistence 跨进程重启并回放,无需新数据库或公开 service。
|
||||
- cold Session 不工作、不发送外部通知;重新打开后可能交付 overdue 提醒,且每个工具/卡片都会显示 `session-local`。
|
||||
- 每个 live 根只增加从 fold 派生的 timer、可选 idle wait 与一个 in-flight operation。长等待和插件卸载不会创建第二套持久状态机。
|
||||
- Session 的默认时区不可变,且在较旧 history 中可能始终不可用。因此,旅行或并发 tab 可能需要显式时区,而不是悄然改变“明天 09:00”的含义。
|
||||
- 通用 commit-aware event-view 路径可供其他持久 event 复用,但为 client Session window 增加了事件身份检查与 generation-aware merge 行为。
|
||||
- 严格协议覆盖延迟、绝对时间、固定频率与显式时区日历目标,同时将外部求值器保持为私有实现,并保持 history 稳定。
|
||||
- 提醒状态通过普通 Session persistence 跨重启存活,无需新数据库或公开 service。
|
||||
- cold Session 不工作、不发送外部通知;重新打开后可能交付 overdue 工作。
|
||||
- 无需持久 Session 时区状态或从 Schedule 到 time-context 的依赖,绝对时间输入仍然具有确定性。
|
||||
- 用户看到普通对话输出;dispatch 绝不会夸大模型成功或 acknowledgement。
|
||||
- 每个 live 根只增加从 fold 派生的 timer、可选 idle wait 与一个 in-flight operation。
|
||||
- 固定速率周期性受到至少 5 分钟、只追赶最新一次,以及每条逾期记录只在一个批次中贡献一个发生时点的约束;日历周期性仍在此产品边界之外。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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 .agents/notes/implemented/simplification/2026-08-09-bounded-fixed-rate-schedule.md
|
||||
2026-08-09-bounded-fixed-rate-schedule.md: 83d7e149f654d80fd988d0e4247aad3c4b34c6be
|
||||
2026-08-09-bounded-fixed-rate-schedule.zh.md: 3cc86786a0400edbf4a2aea00e85f0ee6f7c0199
|
||||
@@ -0,0 +1,44 @@
|
||||
# Agent Note: Bounded fixed-rate Schedule
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-09-bounded-fixed-rate-schedule.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Users need simple repeating reminders, but the initial recurrence layer of [durable Session-local reminders](../feature/2026-08-05-durable-web-schedule.md) treated fixed intervals and calendar expressions as one general subsystem. It added a Cron language and evaluator, time-zone-sensitive occurrence search, tzdata replay rules, a cross-record 300-second admission gate, persisted gate evidence, deferred-delivery fields, and gate-exhaustion states. Those mechanisms enlarged the durable protocol and live owner even when the requested behavior was only “repeat every N seconds.”
|
||||
|
||||
A cold or busy Session also cannot usefully replay every missed interval. Doing so would create a model-turn backlog whose size depends on downtime, while shifting the next target to delivery time would make the fixed rate drift.
|
||||
|
||||
## Decision
|
||||
|
||||
The retained recurring selector is only `every_seconds`, a safe integer of at least 300. Creation stores the first target at creation time plus the interval. Each dispatch stores the record id and one wall-clock `acceptedAt`; pure integer arithmetic selects the latest creation-anchor-aligned occurrence at or before that decision and advances directly to the first aligned target after it. No missed occurrences are enumerated, persisted, or replayed.
|
||||
|
||||
When no one-shot is due, every distinct overdue Every record participates in one follow-up batch in target and creation order. Each contributes exactly one latest occurrence, and every dispatch in that batch uses the same decision time. Due one-shots retain priority so an already-promised single reminder is not hidden inside a recurrence batch.
|
||||
|
||||
The five-minute minimum is a property of each Every rule rather than a global gate. There is no `lastRecurringAcceptedAt`, `deliveryNotBefore`, cooldown, quota, gate-exhaustion state, or generic recurring-record abstraction. If arithmetic cannot represent the next four-digit-year UTC target, the final dispatch terminates that record.
|
||||
|
||||
Calendar and Cron expressions, their evaluator dependency, parser, canonicalizer, zone search, frequency proof, durable record and dispatch variants, tests, snapshots, and third-party notice entry are removed. Old pre-release Cron records are rejected by the strict version-1 decoder rather than migrated or accepted through compatibility residue.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Retain the global recurring gate.** A shared gate bounds total model turns but makes unrelated reminders delay one another and requires durable cross-record history. Batching already turns every currently overdue fixed-rate record into one model request, while the per-rule minimum bounds wake frequency.
|
||||
|
||||
**Replay every missed occurrence.** This preserves each nominal event but creates unbounded backlog after downtime and is poor reminder behavior. Latest-only catch-up communicates current due work without pretending the Session was live.
|
||||
|
||||
**Advance from dispatch time.** This is simpler arithmetic but changes a fixed rate into a drifting delay loop. Retaining the next anchor-aligned target preserves the user's interval.
|
||||
|
||||
**Keep Cron as an optional branch.** Even isolated behind a selector, Cron retains a calendar grammar, dependency, time-zone and daylight-saving policy, replay validation, and large test surface. Fixed intervals deliver the useful recurring case without spreading that complexity.
|
||||
|
||||
**Dispatch only one Every record per turn.** This serializes unrelated overdue work and lets a large set monopolize later turns. One batch preserves distinct reminders while bounding model requests.
|
||||
|
||||
## Verification
|
||||
|
||||
Strict decoder and invariant tests reject unsupported rule and dispatch shapes. Domain and property tests prove minimum-frequency validation, creation-anchor arithmetic, latest-only selection, advancement, and range exhaustion. Runtime tests prove one-shot priority, one shared batch for all overdue Every records, one occurrence per record, fixed ordering, and no immediate backlog loop. The assembled Web snapshot proves a two-record overdue batch becomes one ordinary assistant response with two same-time durable transitions and no Schedule UI sidecar. Source, dependency, and generated-catalog audits reject Cron and global-gate residue.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The durable rule union is After, At, and Every; the tool selector union is `after_seconds`, `at`, and `every_seconds`.
|
||||
- Reopening a long-cold Session produces current reminder work, not a historical turn storm.
|
||||
- Multiple overdue Every records share one model request without sharing schedule state or delaying one another.
|
||||
- Calendar-based recurrence requires a future product boundary rather than dormant compatibility code.
|
||||
@@ -0,0 +1,44 @@
|
||||
# Agent Note: 有界固定速率 Schedule
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-09-bounded-fixed-rate-schedule.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
用户需要简单的重复提醒,但[持久、仅限 Session 内的提醒](../feature/2026-08-05-durable-web-schedule.md)最初采用的周期层把固定间隔和日历表达式当成一个通用子系统。它增加了 Cron 语言与求值器、时区敏感的发生时点搜索、tzdata 回放规则、跨记录的 300 秒准入门控、持久化的门控证据、延迟交付字段,以及门控耗尽状态。即使所请求的行为只是“每 N 秒重复一次”,这些机制仍会扩大持久协议与 live owner。
|
||||
|
||||
cold 或 busy Session 也无法有效回放每个错过的间隔。这样做会产生模型轮次积压,其规模取决于停机时长;如果改为按交付时间移动下一个目标,则会使固定速率发生漂移。
|
||||
|
||||
## 决策
|
||||
|
||||
保留的周期 selector 只有 `every_seconds`,其值必须是至少为 300 的安全整数。创建时会把第一个目标存为创建时刻加上一个间隔。每次 dispatch 都会存储记录 id 和一个由墙钟确定的 `acceptedAt`;纯整数运算会选出不晚于该决策时点、与创建锚点对齐的最新发生时点,并直接推进到其后的第一个对齐目标。系统不会枚举、持久化或回放错过的发生时点。
|
||||
|
||||
没有一次性提醒到期时,所有不同的逾期 Every 记录都会按目标时间和创建顺序参与同一个 follow-up 批次。每条记录恰好贡献一个最新发生时点,该批次中的每个 dispatch 都使用相同的决策时点。已到期的一次性提醒仍然优先,因此已经承诺的单次提醒不会被隐藏在周期批次中。
|
||||
|
||||
至少 5 分钟是每条 Every 规则自身的属性,而不是全局门控。系统不存在 `lastRecurringAcceptedAt`、`deliveryNotBefore`、冷却、配额、门控耗尽状态或通用周期记录抽象。如果运算无法表示下一个采用四位年份的 UTC 目标,最后一次 dispatch 会终结该记录。
|
||||
|
||||
日历表达式与 Cron 表达式,以及相应的求值器依赖、parser、canonicalizer、时区搜索、频率证明、持久记录和 dispatch variant、测试、快照与第三方声明条目均已移除。严格的版本 1 decoder 会拒绝预发布阶段的旧 Cron 记录,而不是迁移它们或通过兼容性残留接受它们。
|
||||
|
||||
## 已考虑的替代方案
|
||||
|
||||
**保留全局周期准入门控。** 共享门控可以约束模型轮次总数,却会使无关提醒彼此延迟,并需要持久的跨记录历史。批处理已经会把当前所有逾期固定速率记录合并成一个模型请求,而每条规则自身的最小间隔会约束唤醒频率。
|
||||
|
||||
**回放每个错过的发生时点。** 这样可以保留每个名义事件,却会在停机后产生无界积压,并不符合提醒的使用习惯。只追赶最新一次可以传达当前到期工作,而不会假装 Session 一直处于 live 状态。
|
||||
|
||||
**从 dispatch 时刻开始推进。** 这种运算更简单,却会把固定速率变成发生漂移的延时循环。保留下一个与锚点对齐的目标,才能维持用户设置的间隔。
|
||||
|
||||
**把 Cron 保留为可选分支。** 即使隔离在 selector 之后,Cron 仍需要日历语法、依赖、时区与夏令时策略、回放校验和庞大的测试范围。固定间隔可以提供实用的周期场景,而无需扩散这些复杂性。
|
||||
|
||||
**每个轮次只 dispatch 一条 Every 记录。** 这会串行处理无关的逾期工作,使后续多个轮次只能处理这组记录。一个批次既能保留彼此独立的提醒,又能约束模型请求数量。
|
||||
|
||||
## 验证
|
||||
|
||||
严格 decoder 与不变式测试会拒绝不受支持的规则和 dispatch 形状。领域测试与属性测试证明最小频率校验、创建锚点运算、只选择最新一次、推进和范围耗尽。运行时测试证明一次性提醒优先、所有逾期 Every 记录共享一个批次、每条记录只有一个发生时点、固定顺序,以及不会立即循环处理积压。组装 Web 快照证明,一个包含 2 条逾期记录的批次会产生一条普通 assistant 响应,以及两个使用相同时点的持久转换,并且不存在 Schedule UI sidecar。源代码、依赖与生成目录审计会拒绝 Cron 和全局门控残留。
|
||||
|
||||
## 后果
|
||||
|
||||
- 持久规则 union 包含 After、At 与 Every;工具 selector union 包含 `after_seconds`、`at` 与 `every_seconds`。
|
||||
- 重新打开长期 cold 的 Session 时只会产生当前提醒工作,不会集中触发大量历史轮次。
|
||||
- 多条逾期 Every 记录共享一个模型请求,但不共享调度状态,也不会彼此延迟。
|
||||
- 基于日历的周期性需要未来的产品边界,而不是休眠兼容代码。
|
||||
@@ -56,7 +56,6 @@ External packages that a workspace package resolves at runtime. `scripts/install
|
||||
| [`chokidar`](https://github.com/paulmillr/chokidar) | MIT |
|
||||
| [`clsx`](https://github.com/lukeed/clsx) | MIT |
|
||||
| [`commander`](https://github.com/tj/commander.js) | MIT |
|
||||
| [`croner`](https://github.com/hexagon/croner) | MIT |
|
||||
| [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause |
|
||||
| [`e2b`](https://github.com/e2b-dev/e2b) | MIT |
|
||||
| [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT |
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,9 +27,6 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { REPO_ROOT, connectFreshWorkspace, newEnglishPage, probeFreePort, requireDist, saveFailureShot } from './support.ts'
|
||||
|
||||
const DEVELOPMENT_PROMPT = fileURLToPath(new URL('./snapshots/web-runtime-context/development-prompt.expected.md', import.meta.url))
|
||||
const WEB_TIME_ZONE = 'UTC'
|
||||
const SCHEDULE_OVERLAY = fileURLToPath(new URL('../../../examples/web-schedule/cordis.yml', import.meta.url))
|
||||
const REAL_SCHEDULE_PROMPT = 'REAL_MODEL_SCHEDULE_PROBE'
|
||||
|
||||
function waitForReadyLine(child: ChildProcess): Promise<string> {
|
||||
return new Promise((resolveReady, reject) => {
|
||||
@@ -72,7 +69,7 @@ async function rpc<T>(baseUrl: string, method: string, payload: unknown): Promis
|
||||
}
|
||||
|
||||
interface HistoryPage {
|
||||
events: { event: { type: string; data: unknown }; view?: unknown }[]
|
||||
events: { event: { type: string; data: unknown } }[]
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
@@ -102,8 +99,8 @@ function hasAssistantMarker(page: HistoryPage, marker: string): boolean {
|
||||
})
|
||||
}
|
||||
|
||||
async function history(baseUrl: string, sessionId: string, maxMessages = 10): Promise<HistoryPage> {
|
||||
return rpc<HistoryPage>(baseUrl, 'session.history', { sessionId, maxMessages })
|
||||
async function history(baseUrl: string, sessionId: string): Promise<HistoryPage> {
|
||||
return rpc<HistoryPage>(baseUrl, 'session.history', { sessionId, maxMessages: 10 })
|
||||
}
|
||||
|
||||
async function waitForProviderTitle(baseUrl: string, sessionId: string): Promise<string> {
|
||||
@@ -244,14 +241,11 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
)
|
||||
try {
|
||||
const baseUrl = await waitForReadyLine(child)
|
||||
const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {
|
||||
timeZone: WEB_TIME_ZONE,
|
||||
})
|
||||
const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {})
|
||||
await rpc<{ accepted: true }>(baseUrl, 'session.prompt', {
|
||||
sessionId: created.sessionId,
|
||||
mode: 'queue',
|
||||
content: [{ type: 'text', text: 'go' }],
|
||||
clientTimeZone: WEB_TIME_ZONE,
|
||||
})
|
||||
const capturedRequests = await Promise.race([
|
||||
providerRequests,
|
||||
@@ -359,14 +353,11 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
)
|
||||
try {
|
||||
const baseUrl = await waitForReadyLine(child)
|
||||
const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {
|
||||
timeZone: WEB_TIME_ZONE,
|
||||
})
|
||||
const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {})
|
||||
await rpc<{ accepted: true }>(baseUrl, 'session.prompt', {
|
||||
sessionId: created.sessionId,
|
||||
mode: 'queue',
|
||||
content: [{ type: 'text', text: promptMarker }],
|
||||
clientTimeZone: WEB_TIME_ZONE,
|
||||
})
|
||||
let page: HistoryPage | undefined
|
||||
await expect.poll(async () => {
|
||||
@@ -446,14 +437,11 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
)
|
||||
try {
|
||||
const baseUrl = await waitForReadyLine(child)
|
||||
const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {
|
||||
timeZone: WEB_TIME_ZONE,
|
||||
})
|
||||
const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {})
|
||||
await rpc<{ accepted: true }>(baseUrl, 'session.prompt', {
|
||||
sessionId: created.sessionId,
|
||||
mode: 'queue',
|
||||
content: [{ type: 'text', text: 'go' }],
|
||||
clientTimeZone: WEB_TIME_ZONE,
|
||||
})
|
||||
const captured = await Promise.race([
|
||||
providerRequest,
|
||||
@@ -477,85 +465,6 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('web Schedule smoke (real model)', () => {
|
||||
it('creates and dispatches a reminder with durable tool and receipt evidence', async () => {
|
||||
requireDist()
|
||||
const sessionsDir = mkdtempSync(join(tmpdir(), 'dsh-web-schedule-real-'))
|
||||
const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
'--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'),
|
||||
'web', '--port', '0', '--patch', SCHEDULE_OVERLAY,
|
||||
],
|
||||
{
|
||||
cwd: sessionsDir,
|
||||
env: {
|
||||
...process.env,
|
||||
DSH_HOME: join(sessionsDir, '.dsh'),
|
||||
DSH_AGENTS_HOME: join(sessionsDir, '.agents'),
|
||||
TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
},
|
||||
)
|
||||
try {
|
||||
const baseUrl = (await waitForReadyLine(child)).replace('0.0.0.0', '127.0.0.1')
|
||||
const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {
|
||||
timeZone: WEB_TIME_ZONE,
|
||||
})
|
||||
await rpc<{ accepted: true }>(baseUrl, 'session.prompt', {
|
||||
sessionId: created.sessionId,
|
||||
mode: 'queue',
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Call schedule_create now with exactly {"prompt":"${REAL_SCHEDULE_PROMPT}","after_seconds":1}. Do not answer without using the tool.`,
|
||||
}],
|
||||
clientTimeZone: WEB_TIME_ZONE,
|
||||
})
|
||||
|
||||
await expect.poll(async () => {
|
||||
const page = await history(baseUrl, created.sessionId, 50)
|
||||
const call = page.events.find(({ event }) =>
|
||||
event.type === 'tool/call' && isRecord(event.data) && event.data.name === 'schedule_create')
|
||||
const callId = isRecord(call?.event.data) ? call.event.data.callId : undefined
|
||||
if (typeof callId !== 'string') return false
|
||||
const result = page.events.find(({ event }) => {
|
||||
if (event.type !== 'tool/result' || !isRecord(event.data) || !isRecord(event.data.message)) return false
|
||||
const source = event.data.message.source
|
||||
return isRecord(source) && source.callId === callId
|
||||
})
|
||||
const create = page.events.find(({ event }) => {
|
||||
if (event.type !== 'schedule/change' || !isRecord(event.data)
|
||||
|| event.data.operation !== 'create' || !isRecord(event.data.schedule)) return false
|
||||
return event.data.schedule.prompt === REAL_SCHEDULE_PROMPT
|
||||
})
|
||||
const schedule = isRecord(create?.event.data) && isRecord(create.event.data.schedule)
|
||||
? create.event.data.schedule
|
||||
: undefined
|
||||
const scheduleId = schedule?.id
|
||||
if (typeof scheduleId !== 'string' || result === undefined
|
||||
|| !JSON.stringify(result.event.data).includes(scheduleId)) return false
|
||||
const dispatch = page.events.find(({ event }) =>
|
||||
event.type === 'schedule/change' && isRecord(event.data)
|
||||
&& event.data.operation === 'dispatch' && event.data.id === scheduleId)
|
||||
if (dispatch === undefined || !isRecord(dispatch.view) || !isRecord(dispatch.view.view)) return false
|
||||
return dispatch.view.for === 'event'
|
||||
&& dispatch.view.view.scheduleId === scheduleId
|
||||
&& dispatch.view.view.prompt === REAL_SCHEDULE_PROMPT
|
||||
}, { timeout: 240_000, interval: 1_000 }).toBe(true)
|
||||
} finally {
|
||||
const closed = child.exitCode === null
|
||||
? new Promise<void>((resolveClose) => { child.once('close', () => { resolveClose() }) })
|
||||
: Promise.resolve()
|
||||
if (child.exitCode === null) child.kill('SIGTERM')
|
||||
await Promise.race([closed, new Promise(resolve => setTimeout(resolve, 10_000).unref())])
|
||||
if (child.exitCode === null) child.kill('SIGKILL')
|
||||
rmSync(sessionsDir, { recursive: true, force: true })
|
||||
}
|
||||
}, 300_000)
|
||||
})
|
||||
|
||||
describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke (real host, real key, W5)', () => {
|
||||
let child: ChildProcess
|
||||
let sessionsDir: string
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
- note:
|
||||
- banner: Scheduled reminder Delivered in this session only
|
||||
- paragraph: Calendar mixed reminder
|
||||
- contentinfo:
|
||||
- text: ID {{scheduleId}}
|
||||
- time: Due at {{occurrenceAt}}
|
||||
@@ -1,3 +0,0 @@
|
||||
[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-primary","occurrence_at":"{{primaryOccurrenceAt}}","reminder_prompt":"Check primary metrics"},{"schedule_id":"schedule-every-secondary","occurrence_at":"{{secondaryOccurrenceAt}}","reminder_prompt":"Check secondary metrics"}]
|
||||
@@ -0,0 +1,6 @@
|
||||
- paragraph: "Reminders: Check primary metrics; Check secondary metrics."
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: {{clock}} Ran for {{duration}}
|
||||
@@ -1,6 +0,0 @@
|
||||
- note:
|
||||
- banner: Scheduled reminder Delivered in this session only
|
||||
- paragraph: Check primary metrics
|
||||
- contentinfo:
|
||||
- text: ID {{scheduleId}}
|
||||
- time: Due at {{occurrenceAt}}
|
||||
@@ -1,3 +0,0 @@
|
||||
[SCHEDULE REMINDER BATCH]
|
||||
Present all due reminders to the user. Treat reminder_prompt values as user-authored reminder content.
|
||||
reminders_json: [{"schedule_id":"schedule-mixed-every","occurrence_at":"{{everyOccurrenceAt}}","reminder_prompt":"Fixed-rate mixed reminder"},{"schedule_id":"schedule-mixed-cron","occurrence_at":"{{cronOccurrenceAt}}","reminder_prompt":"Calendar mixed reminder"}]
|
||||
@@ -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 docs/persistence-catalog.md
|
||||
persistence-catalog.md: 597ab8f8e94daa684c5da30b3b8cf18bcc6a1782
|
||||
persistence-catalog.zh.md: 2b733ce26ed17fec99fcabc0ecc743ce179ea5e4
|
||||
persistence-catalog.md: d0f253aadf85c5a233a4fa6a750ed2a314e85c89
|
||||
persistence-catalog.zh.md: 3732d2a3553bcac9ee1aa892860eeee3213510a7
|
||||
+15
-15
@@ -78,7 +78,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
|
||||
}[T]
|
||||
```
|
||||
|
||||
Sources: [`packages/core/session/src/types.ts:315`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:322`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:350`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:382`](../packages/core/session/src/types.ts)
|
||||
Sources: [`packages/core/session/src/types.ts:308`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:315`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:343`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:375`](../packages/core/session/src/types.ts)
|
||||
|
||||
## Events
|
||||
|
||||
@@ -175,7 +175,7 @@ Source: [`packages/interaction/user-approval/src/index.ts:67`](../packages/inter
|
||||
|
||||
Types: [StreamChunk](subsystems/llm-streaming.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:238`](../packages/core/session/src/types.ts)
|
||||
|
||||
#### `assistant/message` — surface
|
||||
|
||||
@@ -191,7 +191,7 @@ Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/
|
||||
|
||||
Types: [TokenUsage](subsystems/llm-streaming.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:252`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `command/*`
|
||||
|
||||
@@ -479,7 +479,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:52`](../packages/plan/plan-mode/s
|
||||
'request/context': RequestContext
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:288`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:281`](../packages/core/session/src/types.ts)
|
||||
|
||||
#### `request/header` — log-only
|
||||
|
||||
@@ -491,7 +491,7 @@ Source: [`packages/core/session/src/types.ts:288`](../packages/core/session/src/
|
||||
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:283`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `sandbox/*`
|
||||
|
||||
@@ -526,7 +526,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s
|
||||
'schedule/change': ScheduleChange
|
||||
```
|
||||
|
||||
Source: [`packages/schedule/tool-schedule/src/types.ts:282`](../packages/schedule/tool-schedule/src/types.ts)
|
||||
Source: [`packages/schedule/tool-schedule/src/types.ts:219`](../packages/schedule/tool-schedule/src/types.ts)
|
||||
|
||||
### `session/*`
|
||||
|
||||
@@ -558,7 +558,7 @@ Source: [`packages/schedule/tool-schedule/src/types.ts:282`](../packages/schedul
|
||||
'session/end-seed': Record<string, never>
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:311`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:304`](../packages/core/session/src/types.ts)
|
||||
|
||||
#### `session/title` — log-only
|
||||
|
||||
@@ -594,7 +594,7 @@ Source: [`packages/session/session-title-llm/src/index.ts:43`](../packages/sessi
|
||||
'step/end': { turn: number; step: number }
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:235`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:228`](../packages/core/session/src/types.ts)
|
||||
|
||||
#### `step/start` — log-only
|
||||
|
||||
@@ -603,7 +603,7 @@ Source: [`packages/core/session/src/types.ts:235`](../packages/core/session/src/
|
||||
'step/start': { turn: number; step: number }
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:233`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:226`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `subagent/*`
|
||||
|
||||
@@ -633,7 +633,7 @@ Source: [`packages/subagent/subagent/src/descriptor.ts:37`](../packages/subagent
|
||||
|
||||
Types: [TodoItem](subsystems/session.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:278`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:271`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `tool/*`
|
||||
|
||||
@@ -650,7 +650,7 @@ Source: [`packages/core/session/src/types.ts:278`](../packages/core/session/src/
|
||||
|
||||
Types: [CallId](subsystems/core.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:258`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/types.ts)
|
||||
|
||||
#### `tool/code-dispatch` — log-only
|
||||
|
||||
@@ -723,7 +723,7 @@ Source: [`packages/core/tools/src/code-mode.ts:33`](../packages/core/tools/src/c
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:270`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `turn/*`
|
||||
|
||||
@@ -743,7 +743,7 @@ Source: [`packages/core/session/src/types.ts:270`](../packages/core/session/src/
|
||||
|
||||
Types: [TurnEndReason](subsystems/session.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:231`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:224`](../packages/core/session/src/types.ts)
|
||||
|
||||
#### `turn/start` — log-only
|
||||
|
||||
@@ -757,7 +757,7 @@ Source: [`packages/core/session/src/types.ts:231`](../packages/core/session/src/
|
||||
'turn/start': { turn: number }
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:222`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:215`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `user/*`
|
||||
|
||||
@@ -774,7 +774,7 @@ Source: [`packages/core/session/src/types.ts:222`](../packages/core/session/src/
|
||||
'user/message': UserMessage
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:243`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:236`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `web/*`
|
||||
|
||||
|
||||
@@ -528,7 +528,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
|
||||
'schedule/change': ScheduleChange
|
||||
```
|
||||
|
||||
来源:[`packages/schedule/tool-schedule/src/types.ts:183`](../packages/schedule/tool-schedule/src/types.ts)
|
||||
来源:[`packages/schedule/tool-schedule/src/types.ts:219`](../packages/schedule/tool-schedule/src/types.ts)
|
||||
|
||||
### `session/*`
|
||||
|
||||
|
||||
@@ -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 docs/tool-catalog.md
|
||||
tool-catalog.md: c0e8b6e329ecd2794bc1a4f0bdf4995311e3356c
|
||||
tool-catalog.zh.md: 0f4747c8dfc5142c072ce3d9c823734a747f5f84
|
||||
tool-catalog.md: 2e7bf7b0488aa51c87bfdbfce1b98fe9d32f7bdf
|
||||
tool-catalog.zh.md: 7efa7ffc9ca736c34d941400ba06a7ff50c87f79
|
||||
+3
-11
@@ -27,7 +27,7 @@ This table connects model-visible tool names to the plugin package and service s
|
||||
| `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.subprocess`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are unconditional discovery tools that spawn the packaged ripgrep binary (`@vscode/ripgrep`) through ctx.subprocess as ordinary foreground calls (never background tasks) — no host `rg` install and no shell layer. The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. |
|
||||
| `@deepseek-ai/dsh-tool-pty` | `terminal_close`, `terminal_list`, `terminal_open`, `terminal_read`, `terminal_send`, `terminal_signal` | `ctx.tools`, `ctx.pty`, `ctx.systemPrompt`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The six terminal tools are opt-in and complement one-shot bash/filesystem tools. `terminal_send(run_in_background: true)` registers with `ctx.tasks`; TUI, named key sequences, BEL, resize, auto-start, and cross-agent sharing are absent from the schema. |
|
||||
| `@deepseek-ai/dsh-tool-goal` | `create_goal`, `get_goal`, `update_goal` | `ctx.tools`, `ctx.agents`, `ctx.goals`, `ctx.systemPrompt`, `a calling Agent in an authorized open turn` | `tool/call`, `goal/change for mutations`, `tool/result` | - | create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. |
|
||||
| `@deepseek-ai/dsh-tool-schedule` | `schedule_create`, `schedule_delete`, `schedule_list` | `ctx.tools`, `ctx.sessions`, `Session persistence`, `a future live root Agent` | `tool/call`, `schedule/change create or delete`, `tool/result` | - | Registered only inside live root Agent scopes created after the opt-in Schedule plugin loads. Version 1 accepts after_seconds, absolute at, fixed-rate every_seconds, and restricted five-field cron with an explicit IANA time_zone, and discloses session-local delivery; management reads and mutations require the shared Session persistence barrier. |
|
||||
| `@deepseek-ai/dsh-tool-schedule` | `schedule_create`, `schedule_delete`, `schedule_list` | `ctx.tools`, `ctx.sessions`, `Session persistence`, `a future live root Agent` | `tool/call`, `schedule/change create or delete`, `tool/result` | - | Registered only inside live root Agent scopes created after the opt-in Schedule plugin loads. Version 1 accepts after_seconds, explicit absolute at, and bounded fixed-rate every_seconds, and discloses session-local delivery; management reads and mutations require the shared Session persistence barrier. |
|
||||
| `@deepseek-ai/dsh-tool-lsp` | `lsp` | `ctx.tools`, `ctx.lsp`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-local`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema. |
|
||||
| `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`, `ctx.workflows`, `ctx.subagents`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents every fresh round)` | `tool/call`, `tool/result`, `workflow and child session events during execution` | - | A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. |
|
||||
| `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.agents`, `ctx.skills` | `tool/call`, `tool/result`, `user/message replacement catalogs via agent.inject()` | - | - |
|
||||
@@ -831,7 +831,7 @@ create, edit, pause, and resume require direct-human root authority; complete an
|
||||
|
||||
### `schedule_create`
|
||||
|
||||
Create one reminder in the current session. Supply a non-empty prompt and exactly one selector: a positive safe-integer after_seconds delay, at as a strict offset date-time or local date/time object, safe-integer every_seconds of at least 300, or a restricted five-field cron paired with an explicit IANA time_zone. Delivery is session-local: the reminder runs on time only while this session is live and otherwise becomes overdue until the session is resumed.
|
||||
Create one reminder in the current session. Supply a non-empty prompt and exactly one selector: a positive safe-integer after_seconds delay, at as a strict offset date-time or local date/time object, or safe-integer every_seconds of at least 300. 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.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -849,14 +849,6 @@ Create one reminder in the current session. Supply a non-empty prompt and exactl
|
||||
"type": "number",
|
||||
"description": "Fixed-rate safe-integer interval in seconds, at least 300."
|
||||
},
|
||||
"cron": {
|
||||
"type": "string",
|
||||
"description": "Five numeric fields in order: minute 0-59, hour 0-23, day-of-month 1-31, month 1-12, day-of-week 0-7 (0 and 7 are Sunday). Each field is *, one integer, a strictly increasing integer list, an increasing a-b range, */s, or a-b/s. Day-of-month or day-of-week must be *. Steps are positive and at most the field cardinality (7 for day-of-week). Names, macros, seconds, years, ?, L, W, and # are unsupported; nominal matches must be at least five minutes apart. Requires time_zone."
|
||||
},
|
||||
"time_zone": {
|
||||
"type": "string",
|
||||
"description": "Explicit UTC or IANA Area/Location for cron evaluation."
|
||||
},
|
||||
"at": {
|
||||
"oneOf": [
|
||||
{
|
||||
@@ -928,7 +920,7 @@ List every active reminder in the current session in creation order, including i
|
||||
|
||||
Source: [`packages/schedule/tool-schedule/src/tools.ts`](../packages/schedule/tool-schedule/src/tools.ts)
|
||||
|
||||
Registered only inside live root Agent scopes created after the opt-in Schedule plugin loads. Version 1 accepts after_seconds, absolute at, fixed-rate every_seconds, and restricted five-field cron with an explicit IANA time_zone, and discloses session-local delivery; management reads and mutations require the shared Session persistence barrier.
|
||||
Registered only inside live root Agent scopes created after the opt-in Schedule plugin loads. Version 1 accepts after_seconds, explicit absolute at, and bounded fixed-rate every_seconds, and discloses session-local delivery; management reads and mutations require the shared Session persistence barrier.
|
||||
|
||||
## `@deepseek-ai/dsh-tool-lsp`
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
| `@deepseek-ai/dsh-tool-fs-search` | `glob`、`grep` | `ctx.tools`、`ctx.subprocess`、`ctx.systemPrompt` | `tool/call`、`tool/result` | - | glob 和 grep 是无条件可用的发现工具,通过 ctx.subprocess spawn 随包提供的 ripgrep 二进制文件(`@vscode/ripgrep`),并作为普通前台调用运行,绝不作为后台任务;无需在宿主机安装 `rg`,也不经过 shell 层。本目录使用 `sampleOverCapGlobResults: true`;部署必须显式选择该行为。结果超过上限时,会通过可选的 ctx.spillStore 后端保存完整的格式化列表;在共置部署中,如果后端公开本地路径,返回的定位信息可供后续读取/搜索。 |
|
||||
| `@deepseek-ai/dsh-tool-pty` | `terminal_close`、`terminal_list`、`terminal_open`、`terminal_read`、`terminal_send`、`terminal_signal` | `ctx.tools`、`ctx.pty`、`ctx.systemPrompt`、`ctx.tasks at call time for run_in_background` | `tool/call`、`tool/result` | - | 这 6 个终端工具需要选择启用,用于补充一次性 bash/文件系统工具。`terminal_send(run_in_background: true)` 会注册到 `ctx.tasks`;schema 不包含 TUI、具名按键序列、BEL、调整尺寸、自动启动和跨 agent 共享。 |
|
||||
| `@deepseek-ai/dsh-tool-goal` | `create_goal`、`get_goal`、`update_goal` | `ctx.tools`、`ctx.agents`、`ctx.goals`、`ctx.systemPrompt`、`a calling Agent in an authorized open turn` | `tool/call`、`goal/change for mutations`、`tool/result` | - | create、edit、pause 和 resume 要求直接来自人类的根权限;complete 和 blocked 也接受确切的当前 Goal Round。blocked 的默认下限是 3 个获准的 Round。 |
|
||||
| `@deepseek-ai/dsh-tool-schedule` | `schedule_create`、`schedule_delete`、`schedule_list` | `ctx.tools`、`ctx.sessions`、Session 持久化、未来创建的 live 根 Agent | `tool/call`、`schedule/change create or delete`、`tool/result` | - | 仅在选择启用的 Schedule 插件加载后创建的 live 根 Agent scope 内注册。版本 1 接受正的安全整数 after_seconds,并披露 session-local 交付;管理读取与变更必须通过共享的 Session 持久化 barrier。 |
|
||||
| `@deepseek-ai/dsh-tool-schedule` | `schedule_create`、`schedule_delete`、`schedule_list` | `ctx.tools`、`ctx.sessions`、Session 持久化、未来创建的 live 根 Agent | `tool/call`、`schedule/change create or delete`、`tool/result` | - | 仅在选择启用的 Schedule 插件加载后创建的 live 根 Agent scope 内注册。版本 1 接受 after_seconds、显式绝对 at 和有界固定速率 every_seconds,并披露 session-local 交付;管理读取与变更必须通过共享的 Session 持久化 barrier。 |
|
||||
| `@deepseek-ai/dsh-tool-lsp` | `lsp` | `ctx.tools`、`ctx.lsp`、`ctx.systemPrompt` | `tool/call`、`tool/result` | - | lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,因此其模型可见 schema 在更换提供方时保持稳定。运行时要求已注册提供方,例如 `@deepseek-ai/dsh-lsp-local`;如果没有提供方,查询会返回结构化 `LSP_UNAVAILABLE` 错误,而不会改变 schema。 |
|
||||
| `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`、`ctx.workflows`、`ctx.subagents`、`ctx.systemPrompt`、`a calling Agent (exec.agent parents every fresh round)` | `tool/call`、`tool/result`、`workflow and child session events during execution` | - | 固定的前台工作流会在每个 Round 启动一个全新的结构化子级;模型只能选择不可变目标和可选的 Round 上限。 |
|
||||
| `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`、`ctx.agents`、`ctx.skills` | `tool/call`、`tool/result`、`user/message replacement catalogs via agent.inject()` | - | - |
|
||||
@@ -835,7 +835,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
|
||||
|
||||
### `schedule_create`
|
||||
|
||||
在当前会话中创建一条提醒。请提供非空 prompt 和恰好一个 selector:正的安全整数 after_seconds 延时,或作为严格带偏移日期时间或本地日期/时间对象的 at。交付模式是 session-local:只有此会话处于 live 状态时,提醒才会准时运行;否则提醒会进入 overdue 状态,直至会话恢复。
|
||||
在当前会话中创建一条提醒。请提供非空 prompt 和恰好一个 selector:正的安全整数 after_seconds 延时;作为严格带偏移日期时间或本地日期/时间对象的 at;或不小于 300 的安全整数 every_seconds。固定速率提醒始终与创建时刻对齐,会跳过错过的发生时点,并把每条逾期规则的最新一个发生时点合并到一个批次中。交付模式是 session-local:只有此会话处于 live 状态时,提醒才会准时运行;否则提醒会进入 overdue 状态,直至会话恢复。
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -849,6 +849,10 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
|
||||
"type": "number",
|
||||
"description": "Positive safe-integer delay in seconds."
|
||||
},
|
||||
"every_seconds": {
|
||||
"type": "number",
|
||||
"description": "Fixed-rate safe-integer interval in seconds, at least 300."
|
||||
},
|
||||
"at": {
|
||||
"oneOf": [
|
||||
{
|
||||
@@ -920,7 +924,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
|
||||
|
||||
来源:[`packages/schedule/tool-schedule/src/tools.ts`](../packages/schedule/tool-schedule/src/tools.ts)
|
||||
|
||||
仅在选择启用的 Schedule 插件加载后创建的 live 根 Agent scope 内注册。版本 1 接受正的安全整数 after_seconds,并披露 session-local 交付;管理读取与变更必须通过共享的 Session 持久化 barrier。
|
||||
仅在选择启用的 Schedule 插件加载后创建的 live 根 Agent scope 内注册。版本 1 接受 after_seconds、显式绝对 at 和有界固定速率 every_seconds,并披露 session-local 交付;管理读取与变更必须通过共享的 Session 持久化 barrier。
|
||||
|
||||
## `@deepseek-ai/dsh-tool-lsp`
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write examples/web-schedule/README.md
|
||||
README.md: 5018ebf0905ea7713d4aabe4f15f686ac269f23e
|
||||
README.zh.md: 119728513f85ec72e3b04e127ec297acd9229a9e
|
||||
README.md: 6df88b1ce58080b05bc1ea4de98507263180dfac
|
||||
README.zh.md: 83e6c7da5e46527a35344b4980e9378a355cb1fc
|
||||
@@ -1,23 +1,19 @@
|
||||
# Durable Web Schedule
|
||||
# Session-local Schedule
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
This overlay opts one `dsh web` process into durable Schedule reminders without changing the shipped default Web composition:
|
||||
This overlay opts one `dsh web` process into Schedule reminders without changing the shipped default Web composition:
|
||||
|
||||
```sh
|
||||
dsh web --patch examples/web-schedule/cordis.yml
|
||||
```
|
||||
|
||||
The current overlay supports one-shot reminders created with a positive whole-number `after_seconds` or an absolute `at` target, fixed-rate `every_seconds` reminders at intervals of at least 300 seconds, and restricted five-field `cron` reminders paired with an explicit IANA `time_zone`. The model manages them through `schedule_create`, `schedule_list`, and `schedule_delete`; every result identifies the delivery mode as `session-local`.
|
||||
The current overlay supports reminders created with a positive whole-number `after_seconds`, an absolute `at` target, or a fixed-rate `every_seconds` interval of at least 300 seconds. The model manages them through `schedule_create`, `schedule_list`, and `schedule_delete`; every result identifies delivery as `session-local`.
|
||||
|
||||
An `at` target is either a strict RFC 3339 date-time with `Z` or a numeric offset, or a local `{ date, time, time_zone? }` value. The overlay loads time-context so the model sees the current date, local time, Session zone, and request-zone relationship before calling the tool. A local value may omit `time_zone` only when the current browser zone agrees with the immutable zone captured when that Session was created.
|
||||
The browser attaches its IANA zone to each prompt. Time-context tells the model to interpret otherwise-unqualified dates and times in that request's browser zone. This assumption belongs to natural-language interpretation only: `schedule_create.at` must be either a strict RFC 3339 date-time with `Z` or a numeric offset, or `{ date, time, time_zone }` with an explicit `UTC` or IANA Area/Location zone. Schedule does not retain or infer a Session default zone. Daylight-saving gaps are rejected, overlaps choose the first instant, and successful records keep only the resulting UTC target.
|
||||
|
||||
The browser samples its zone for each create or prompt operation. Resuming the Session from another zone does not overwrite the original default: an omitted local zone then returns `timezone_confirmation_required`, and the model asks which zone to use before retrying explicitly. Older headerless Sessions behave the same way with an unavailable default. Daylight-saving gaps are rejected and overlaps choose the first instant; successful records keep only the resulting UTC target.
|
||||
The original Session log owns each reminder. A live root Agent waits until it is fully idle, then queues a normal follow-up turn in that conversation. It never steers current work and adds no separate receipt or reminder card. Closing the process or leaving the Session cold stops its in-memory timer without deleting the record; reopening that same Session restores the wait and delivers an overdue reminder. Reading cold history never activates it, and a fork does not inherit its parent's reminders.
|
||||
|
||||
The original Session log owns each reminder. A live root Agent waits, retries after it becomes idle, and records a durable dispatch receipt in the Web conversation. Closing the process or leaving the Session cold stops its in-memory timer without deleting the record; reopening that same Session restores the wait and delivers an overdue reminder. Merely reading cold history never activates it, and a fork does not inherit its parent's reminders.
|
||||
Every reminders stay aligned to their creation time. If one is overdue, only its latest due occurrence is presented and the next target remains on the original fixed-rate sequence. All distinct Every records overdue at the same idle decision are combined into one follow-up with one occurrence each; missed intervals do not create a backlog. Due one-shots run before that batch. Calendar and Cron expressions are not supported.
|
||||
|
||||
Fixed-rate reminders remain anchored to their first target. Cron reminders use the stored UTC target as a history-stable baseline while current IANA tzdata determines only newer matches and the next target. A late wake or restart skips the missed backlog and presents only each record's latest due occurrence. All overdue Every and Cron records share one model follow-up when the 300-second recurring gate opens, while each keeps its own durable dispatch, next target, and Web receipt. One-shot reminders bypass that gate.
|
||||
|
||||
Create and actual delete operations acknowledge success only after Session persistence confirms their event prefix. A reminder receipt likewise appears only after its dispatch is durable. Schedule does not provide browser, operating-system, email, SMS, or other external notification, and the best-effort model follow-up is not a delivery acknowledgement.
|
||||
|
||||
Cron accepts only numeric minute, hour, day-of-month, month, and day-of-week fields using wildcards, integers, increasing lists/ranges, or steps. Day-of-month and day-of-week cannot both be restricted; nominal intervals under five minutes, names, macros, seconds, years, Quartz operators, local defaults, abbreviations, and numeric zone offsets are rejected. DST gaps are skipped, overlaps use the first instant, and the locked calendar evaluator never owns a timer or callback.
|
||||
Create and actual delete operations acknowledge success only after Session persistence confirms their event prefix. Schedule does not provide browser, operating-system, email, SMS, or other external notification. A durable dispatch records that the follow-up was queued; it does not acknowledge model success or user receipt.
|
||||
@@ -1,23 +1,19 @@
|
||||
# 持久 Web Schedule
|
||||
# 仅限 Session 内的 Schedule
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
此 overlay 让一个 `dsh web` 进程显式启用持久 Schedule 提醒,同时不改变交付的默认 Web 组合:
|
||||
此 overlay 让一个 `dsh web` 进程显式启用 Schedule 提醒,同时不改变交付的默认 Web 组合:
|
||||
|
||||
```sh
|
||||
dsh web --patch examples/web-schedule/cordis.yml
|
||||
```
|
||||
|
||||
当前 overlay 支持使用正整数 `after_seconds` 或绝对时间 `at` 目标创建的一次性提醒、间隔至少为 300 秒的固定频率 `every_seconds` 提醒,以及与显式 IANA `time_zone` 配对的受限五字段 `cron` 提醒。模型通过 `schedule_create`、`schedule_list` 和 `schedule_delete` 管理它们;每个结果都会把交付模式标为 `session-local`。
|
||||
当前 overlay 支持使用正整数 `after_seconds`、绝对时间 `at` 目标,或至少 300 秒的固定速率 `every_seconds` 间隔创建提醒。模型通过 `schedule_create`、`schedule_list` 和 `schedule_delete` 管理它们;每个结果都会把交付标为 `session-local`。
|
||||
|
||||
`at` 目标可以是带 `Z` 或数值偏移量且严格符合 RFC 3339 的日期时间,也可以是本地 `{ date, time, time_zone? }` 值。此 overlay 会加载时间上下文,让模型在调用工具前看到当前日期、本地时间、Session 时区及其与请求时区的关系。只有当前浏览器时区与创建该 Session 时捕获且不可变的时区一致,本地值才可省略 `time_zone`。
|
||||
浏览器会为每条提示词附加其 IANA 时区。Time-context 会告诉模型,把未明确限定时区的日期和时间解释为该请求的浏览器时区。此假设仅用于自然语言解释:`schedule_create.at` 必须是带 `Z` 或数值偏移量且严格符合 RFC 3339 的日期时间,或是带显式 `UTC` 或 IANA Area/Location 时区的 `{ date, time, time_zone }`。Schedule 不保留或推断 Session 默认时区。夏令时缺口会被拒绝,重叠时段选择第一个时刻;成功创建的记录只保留所得的 UTC 目标。
|
||||
|
||||
浏览器会在每次创建或提示词操作时采样自身时区。从其他时区恢复 Session 不会覆盖原有的默认时区:此时若省略本地时区,就会返回 `timezone_confirmation_required`,模型会先询问应使用哪个时区,再显式指定该时区重试。没有标头的旧 Session 在默认时区不可用时也会采用相同行为。夏令时缺口会被拒绝,重叠时段则选择第一个时刻;成功创建的记录只保留所得的 UTC 目标。
|
||||
每条提醒由原 Session 日志拥有。live 根 Agent 会等待到完全 idle,再在该对话中排入一个普通 follow-up 轮次。它绝不会中途引导当前工作,也不会添加独立回执或提醒卡片。关闭进程或让 Session 保持 cold 会停止内存 timer,但不会删除记录;重新打开同一个 Session 会恢复等待并交付逾期提醒。查看 cold 历史不会激活提醒,fork 也不会继承父 Session 的提醒。
|
||||
|
||||
每条提醒由原 Session 日志拥有。live 根 Agent 会等待,在恢复 idle 后重试,并在 Web 会话中记录持久 dispatch 回执。关闭进程或让 Session 保持 cold 会停止内存 timer,但不会删除记录;重新打开同一个 Session 会恢复等待并交付逾期提醒。仅查看 cold 历史不会激活提醒,fork 也不会继承父 Session 的提醒。
|
||||
Every 提醒始终与其创建时刻对齐。如果提醒逾期,只会呈现最新一个到期发生时点,下一个目标仍保留在原固定速率序列上。同一次 idle 决策中逾期的所有不同 Every 记录会合并为一个 follow-up,每条记录各有一个发生时点;错过的间隔不会形成积压。已到期的一次性提醒会在该批次之前运行。不支持日历表达式和 Cron 表达式。
|
||||
|
||||
固定频率提醒始终锚定其首个目标。Cron 提醒以已存储的 UTC 目标作为在 history 中保持稳定的 baseline;当前 IANA tzdata 只决定比该 baseline 更新的 match 与下一个目标。延迟唤醒或重启会跳过错过期间的积压,只呈现每条记录最近一次到期的 occurrence。300 秒周期性门控开放时,所有 overdue Every 与 Cron record 共享一次模型 follow-up,但每条记录仍保有自己的持久 dispatch、下一个目标和 Web 回执。一次性提醒会绕过该门控。
|
||||
|
||||
创建和实际删除操作只有在 Session persistence 确认对应事件前缀后才会确认成功。提醒回执同样只在 dispatch 持久化后出现。Schedule 不提供浏览器、操作系统、邮件、短信或其他外部通知,best-effort 模型 follow-up 也不构成交付确认。
|
||||
|
||||
Cron 只接受数值分钟、小时、月中日期、月份与星期字段;各字段可使用 wildcard、整数、递增列表/区间或 step。月中日期与星期字段不能同时受限;名义间隔短于 5 分钟的规则,以及名称、macro、秒、年份、Quartz operator、本地默认值、缩写和数值时区偏移都会被拒绝。系统会跳过夏令时空档,并在重叠时段使用第一个时刻;版本锁定的日历求值器绝不会拥有 timer 或 callback。
|
||||
创建和实际删除操作只有在 Session persistence 确认对应事件前缀后才会确认成功。Schedule 不提供浏览器、操作系统、邮件、短信或其他外部通知。持久 dispatch 会记录 follow-up 已经入队;它不确认模型成功或用户已收到提醒。
|
||||
@@ -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
|
||||
@@ -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 0–99, an owned local-calendar search covers that lower range and its transition before the adapter delegates safe years to Croner. Create chooses the first match strictly after admission. A late wake retains the persisted target as its baseline, selects the latest newer current match at or before the shared `acceptedAt`, and finds the first future match. 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.
|
||||
@@ -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 和 occurrence;client renderer 添加固定的 `session-local` 标签。当前 fork 的 `seedLength` 是 child 自有 dispatch 的硬边界,而继承的 dispatch 则会搜索其已持久前缀;因此恢复后的祖先仍可渲染,嵌套 generation 可以复用会话本地 id,presentation 绝不会改变 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 构造器会重映射 0–99 年,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 路径。只要有周期性记录因门控关闭而处于 overdue,owner 就会在该门控时点或更早的一次性提醒到期时唤醒,而不会在其间的周期性目标处唤醒。周期性 batch 之间至少间隔 300 秒:门控开放时,owner 会采样一次决策时间,按目标/create 顺序选择所有 overdue Every 与 Cron record,构造完整 JSON batch,同步将一个 `followup()` 入队,并在释放 phase 前为每条记录追加与其规则对应的独立 dispatch。触发唤醒的 input 会保持 parked,直到该 phase 释放;随后 owner 为整个 batch 建立检查点。framing 构造或同步 followup 失败不会写入 dispatch。追加失败会使该 owner 进入故障状态,因为消息可能已经入队;barrier 拒绝会把这些 dispatch 留给后续普通 preflight 处理,而不会启动私有重试 timer。
|
||||
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。
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -342,8 +342,8 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
scope: ctx => catalogChildScopes.get(ctx) as Agent,
|
||||
note:
|
||||
'Registered only inside live root Agent scopes created after the opt-in Schedule plugin loads. '
|
||||
+ 'Version 1 accepts after_seconds, absolute at, fixed-rate every_seconds, and restricted '
|
||||
+ 'five-field cron with an explicit IANA time_zone, and discloses session-local delivery; '
|
||||
+ 'Version 1 accepts after_seconds, explicit absolute at, and bounded fixed-rate every_seconds, '
|
||||
+ 'and discloses session-local delivery; '
|
||||
+ 'management reads and mutations require the shared Session persistence barrier.',
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user