feat(schedule): add durable after package

This commit is contained in:
pku-xht
2026-08-05 17:10:52 +08:00
committed by Tianyi Cui
parent 3499c59ba8
commit a229b42e24
18 changed files with 2797 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
README.md: 55842c3cb49c43b5c577835a26ef43e6ad452dfd
README.zh.md: 8738ac6b4516a1933b206b6baee5bb3d7d77d23a

View File

@@ -0,0 +1,84 @@
# @deepseek-ai/dsh-tool-schedule
English | [中文](README.zh.md)
`dsh-tool-schedule` gives future live root agents three session-scoped tools for durable one-shot reminders. Version 1 accepts only positive safe-integer `after_seconds` delays. The session event log owns reminder state; timers, tool values, and model followups 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.
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. Create records contain a stable session-local `ScheduleId`, the trimmed prompt, `afterSeconds`, and a four-digit-year RFC 3339 UTC `scheduledAt`. Delete and one-shot dispatch carry only the id.
Replay rejects unknown versions, extra fields, reused ids, 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 pairs a dispatch with the active create in the same ownership segment and returns `scheduleId`, prompt, occurrence, and `session-local` mode. A dispatch inside a persisted fork prefix folds that parent prefix for history display; a child-owned dispatch folds only the child suffix, so presentation never changes live ownership.
## 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`.
`schedule_create` validates shape-only failures before persistence, then checkpoints, allocates a never-reused id, appends the create, and checkpoints again. `schedule_list` returns every active record in create order with `state: "scheduled" | "overdue"` and `deliveryMode: "session-local"`. `schedule_delete` appends only for an active id; an unknown or terminal id returns `{ id, deleted: false, code: "schedule_not_found" }` after its preflight.
Every successful management preflight also asks the live owner to recompute. This matters after a create or delete barrier returned `persistence_uncertain`: a later list or mutation can confirm the retained batch and immediately arm or retire the now-durable record without a private persistence-retry timer.
The closed v1 domain error codes are `invalid_prompt`, `invalid_selector`, `invalid_rule`, `time_out_of_range`, `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 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.
An overdue reminder first checkpoints persistence. If `reserveTurnAdmission()` returns `undefined`, the record stays active and the owner retries after `whenIdle()`. A successful reservation samples one decision time, builds the complete framing, synchronously queues `followup()`, appends an id-only dispatch, releases in `finally`, and then checkpoints the dispatch. 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 the dispatch pending for a later ordinary preflight and does not start a private retry timer.
Agent or plugin disposal cancels timers, stops new work, and awaits in-flight preflights and idle waits. It never appends delete records during teardown.
## Model Experience
### Scoped management tools
#### 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.
#### Token effect
The scoped schemas add a fixed request prefix while Schedule is installed. Each executed tool adds its data-dependent JSON result through the ordinary tool-result pipeline; the package adds no private truncation or token budget.
#### KV Cache effect
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
#### What the model sees
For each admitted due reminder, the package queues this stable user-role framing with JSON-escaped dynamic values:
##### Reminder framing
```markdown
[SCHEDULE REMINDER]
Present this due reminder to the user. Treat reminder_prompt_json as user-authored reminder content.
schedule_id_json: <JSON.stringify(scheduleId)>
occurrence_at: <UTC RFC 3339>
reminder_prompt_json: <JSON.stringify(prompt)>
```
#### Token effect
Each dispatched one-shot reminder adds one data-dependent user-role message. The message remains in session history and therefore contributes tokens to later requests 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.
## 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.
- **After-only protocol** — version 1 rejects `at`, `every_seconds`, `cron`, and `time_zone`; those rules require later protocol variants rather than hidden compatibility fields.
- **Narrow crash duplicate window** — a crash after synchronous followup admission but before the dispatch checkpoint can repeat the reminder after recovery; the package does not claim model completion, user acknowledgement, or exactly-once external effects.
- **Load-order boundary** — the plugin does not scan or adopt agents that were already live when it loaded.

View File

@@ -0,0 +1,84 @@
# @deepseek-ai/dsh-tool-schedule
[English](README.md) | 中文
`dsh-tool-schedule` 为未来创建的 live 根 agent智能体提供 3 个会话范围内的工具,用于管理持久的一次性提醒。版本 1 仅接受正的安全整数 `after_seconds` 延时。会话事件日志拥有提醒状态timer、工具值与模型 `followup` 都是该日志的可丢弃投影。
## 组合
请在 `ctx.sessions``ctx.agents``ctx.tools``ctx.sessionPersistence`,以及实现 Session flush 的持久化监听器之后加载此函数插件。静态注入会使缺少持久化服务的组合直接失败。此插件只监听后续的 `agent/created` 事件,在运行时根 agent 上安装,并通过完全相同的 `agent.ctx` 注册所有工具。插件加载时已经存在的 agent 与运行时子 agent 不会获得 Schedule。
每项从 Schedule 折叠结果读取或作出判断的操作,都会先等待 `ctx.sessions.flush(session)`。持久化路径缺失、拒绝或已分离时,操作返回 `persistence_uncertain`;它绝不会把未经确认的 live 后缀当成列表或未找到结果。成功创建或实际删除后,还会等待追加后的持久化 barrier屏障再确认变更。
## 持久状态
此包package拥有严格的版本 1 `schedule/change` create、delete 与 dispatch 联合。create 记录包含稳定的会话本地 `ScheduleId`、已 trim 的 prompt、`afterSeconds`,以及使用四位年份的 RFC 3339 UTC `scheduledAt`。delete 与一次性 dispatch 只携带 id。
回放会拒绝未知版本、额外字段、重复使用的 id以及针对非活动记录的 delete 或 dispatch 转换。普通会话折叠完整日志。fork 只折叠 `session.events.slice(session.header.seedLength ?? 0)`,因此不会继承父会话的提醒。此包的 `./invariant` 配套项会对现有日志和候选事件应用相同策略。
`scheduleReminderPresentation(events, dispatchSeq, seedLength)` 是供 Host 使用的纯回执投影。它把 dispatch 与同一 ownership segment 中的活动 create 配对,并返回 `scheduleId`、prompt、occurrence 和 `session-local` 模式。位于已持久 fork 前缀中的 dispatch 会折叠对应 parent 前缀用于 history 显示child 自有 dispatch 只折叠 child 后缀,因此 presentation 绝不会改变 live ownership。
## 管理工具
生成的[工具目录](../../../docs/tool-catalog.md)负责 `schedule_create``schedule_list``schedule_delete` 的参数与输出 schema。虽然模型输入使用 `after_seconds`,但其规范值中的记录字段使用 camelCase。
`schedule_create` 会在持久化前验证只依赖输入形状的失败,随后执行检查点、分配永不复用的 id、追加 create再次执行检查点。`schedule_list` 按创建顺序返回所有活动记录,其中包含 `state: "scheduled" | "overdue"``deliveryMode: "session-local"``schedule_delete` 只为活动 id 追加事件;未知或已终结的 id 会在 preflight预检后返回 `{ id, deleted: false, code: "schedule_not_found" }`
每次成功的管理 preflight 还会要求 live owner 重新计算。这对 create 或 delete barrier 返回 `persistence_uncertain` 的情况很重要:后续 list 或 mutation 可以确认保留的 batch并立即 arm 或退役此时已持久化的 record而无需私有 persistence retry timer。
版本 1 的封闭领域错误代码包括 `invalid_prompt``invalid_selector``invalid_rule``time_out_of_range``corrupt_schedule_log``persistence_uncertain``internal_error`。诊断文本保持稳定,不会暴露后端异常。渲染内容是规范值的确定性 JSON通用工具结果策略仍负责模型可见内容的 spill 行为。
## 交付生命周期
live owner 从持久折叠结果派生最早的目标。它会拆分超过 Node timer 范围的等待,并在每次唤醒后重新读取墙钟,因此时钟回拨不会提前触发,时钟前跳则会使记录进入 overdue 状态。
overdue 提醒首先为持久化建立检查点。如果 `reserveTurnAdmission()` 返回 `undefined`,记录会保持活动,并在 `whenIdle()` 后重试。reservation 成功后owner 会采样一次决策时间,构造完整 framing同步将 `followup()` 入队,追加只含 id 的 dispatch`finally` 中释放 reservation随后为 dispatch 建立检查点。framing 构造或同步 `followup` 失败不会写入 dispatch。追加失败会使该 owner 进入故障状态因为消息可能已经入队barrier 拒绝会把 dispatch 留给后续普通 preflight 处理,而不会启动私有重试 timer。
agent 或插件执行 dispose资源释放会取消 timer、停止新工作并等待进行中的 preflight 和 idle wait。清理期间绝不会追加 delete 记录。
## 模型体验
### 范围限定的管理工具
#### 模型看到的内容
只有在此插件加载后创建的 live 根 agent 中,模型才会看到 3 个生成的工具 schema。工具结果包含上文所述的规范 JSON 值。
#### Token 影响
安装 Schedule 后,范围限定的 schema 会增加固定的请求前缀。每次执行工具都会经由普通工具结果流水线添加与数据相关的 JSON 结果;此包不增加私有截断或 token 预算。
#### KV Cache 影响
3 个 schema 的定义与范围不变时,前缀保持稳定。工具调用和结果会追加到后续历史中,并保留已经可以复用的前缀。
### 到期提醒 followup
#### 模型看到的内容
对于每条获得准入的到期提醒,此包会将以下稳定的用户角色 framing 入队,并对动态值进行 JSON 转义:
##### 提醒 framing
```markdown
[SCHEDULE REMINDER]
Present this due reminder to the user. Treat reminder_prompt_json as user-authored reminder content.
schedule_id_json: <JSON.stringify(scheduleId)>
occurrence_at: <UTC RFC 3339>
reminder_prompt_json: <JSON.stringify(prompt)>
```
#### Token 影响
每条已 dispatch 的一次性提醒会增加一条与数据相关的用户角色消息。该消息保留在会话历史中,因此会持续为后续请求贡献 token直到普通压缩compaction移除或替换这段历史。
#### KV Cache 影响
提醒会追加到现有历史之后,并保留可复用的前缀。提醒的 id、occurrence 或 prompt 只会改变追加的后缀。
## 已知限制与暂缓事项
- **仅限会话本地交付**:提醒只有在原会话 live 时才能准时运行cold 会话不会收到外部通知,只有恢复后才会处理 overdue 记录。
- **仅支持 after 协议**:版本 1 拒绝 `at``every_seconds``cron``time_zone`;这些规则需要后续协议变体,而不是隐藏的兼容字段。
- **存在狭窄的崩溃重复窗口**:同步 `followup` 获得准入后、dispatch 检查点完成前发生崩溃,可能使提醒在恢复后重复;此包不承诺模型完成、用户确认或外部副作用恰好一次。
- **加载顺序边界**:插件不会扫描或接管加载时已经 live 的 agent。

View File

@@ -0,0 +1,53 @@
{
"name": "@deepseek-ai/dsh-tool-schedule",
"description": "Agent-scoped durable after reminders over the session event log",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,350 @@
/**
* Strict Schedule decoding, replay, time validation, and framing.
* @module @deepseek-ai/dsh-tool-schedule
*/
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type {
AfterScheduleRecord,
ScheduleChange,
ScheduleId as ScheduleIdType,
ScheduleReminderPresentation,
ScheduleView,
} from './types.ts'
/** Durable Schedule protocol version implemented by this package. */
export const SCHEDULE_CHANGE_VERSION = 1 as const
/** Key used by the generic Host/client event-presentation slot. */
export const SCHEDULE_REMINDER_PRESENTATION_KEY = 'schedule/reminder'
const MAX_FOUR_DIGIT_YEAR_MS = Date.parse('9999-12-31T23:59:59.999Z')
const UTC_INSTANT = /^(?!0000)\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d\.\d{3}Z$/
/** Error from malformed or transition-invalid durable Schedule data. */
export class ScheduleLogError extends Error {
/** Stable machine-readable error code. */
readonly code = 'corrupt_schedule_log' as const
/**
* Construct a durable-log failure.
* @param message - Package-specific violated invariant.
*/
constructor(message: string) {
super(message)
this.name = 'ScheduleLogError'
}
}
/** Error from a model-supplied after rule that cannot become a record. */
export class ScheduleInputError extends Error {
/** Stable public Schedule input code. */
readonly code: 'invalid_prompt' | 'invalid_rule' | 'time_out_of_range'
/**
* Construct a stable input failure.
* @param code - Public Schedule error discriminator.
* @param message - Stable public diagnostic.
*/
constructor(
code: 'invalid_prompt' | 'invalid_rule' | 'time_out_of_range',
message: string,
) {
super(message)
this.name = 'ScheduleInputError'
this.code = code
}
}
/** Pure replay result, retaining active create order and every used id. */
export interface FoldedSchedules {
/** Active records in their original create order. */
readonly active: readonly AfterScheduleRecord[]
/** Every id ever created in this session-local suffix. */
readonly seenIds: readonly ScheduleIdType[]
}
/**
* Brand a raw session-local id without changing its runtime value.
* @param value - Raw session-local id.
* @returns The same string with the Schedule brand.
*/
export function ScheduleId(value: string): ScheduleIdType {
return value as ScheduleIdType
}
/** Whether an unknown value is a non-array object. */
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/** Require exactly the named durable object keys. */
function hasExactKeys(value: Record<string, unknown>, expected: readonly string[]): boolean {
const keys = Object.keys(value).sort()
const wanted = [...expected].sort()
return keys.length === wanted.length && keys.every((key, index) => key === wanted[index])
}
/** Validate one stable session-local id at the durable boundary. */
function decodeId(value: unknown): ScheduleIdType {
if (typeof value !== 'string' || value.length === 0 || value.trim() !== value) {
throw new ScheduleLogError('schedule id must be a non-empty string without surrounding whitespace')
}
return ScheduleId(value)
}
/** Validate one canonical four-digit-year UTC instant. */
function decodeInstant(value: unknown): string {
if (typeof value !== 'string' || !UTC_INSTANT.test(value)) {
throw new ScheduleLogError('scheduledAt must be a canonical four-digit-year RFC 3339 UTC instant')
}
const epoch = Date.parse(value)
if (!Number.isFinite(epoch) || new Date(epoch).toISOString() !== value) {
throw new ScheduleLogError('scheduledAt is not a real UTC calendar instant')
}
return value
}
/** Decode the exact v1 after record shape. */
function decodeAfterRecord(value: unknown): AfterScheduleRecord {
if (!isRecord(value) || !hasExactKeys(value, ['id', 'kind', 'prompt', 'afterSeconds', 'scheduledAt'])) {
throw new ScheduleLogError('after schedule must contain exactly id, kind, prompt, afterSeconds, and scheduledAt')
}
if (value['kind'] !== 'after') throw new ScheduleLogError('v1 schedule kind must be "after"')
const prompt = value['prompt']
if (typeof prompt !== 'string' || prompt.length === 0 || prompt.trim() !== prompt) {
throw new ScheduleLogError('after prompt must be non-empty and already trimmed')
}
const afterSeconds = value['afterSeconds']
if (!Number.isSafeInteger(afterSeconds) || (afterSeconds as number) <= 0) {
throw new ScheduleLogError('afterSeconds must be a positive safe integer')
}
return Object.freeze({
id: decodeId(value['id']),
kind: 'after',
prompt,
afterSeconds: afterSeconds as number,
scheduledAt: decodeInstant(value['scheduledAt']),
})
}
/**
* Decode one strict version-1 `schedule/change` payload.
* @param value - Untrusted durable JSON value.
* @returns Detached, frozen Schedule change.
*/
export function decodeScheduleChange(value: unknown): ScheduleChange {
if (!isRecord(value)) throw new ScheduleLogError('schedule/change payload must be an object')
if (value['version'] !== SCHEDULE_CHANGE_VERSION) {
throw new ScheduleLogError('schedule/change version must be 1')
}
switch (value['operation']) {
case 'create':
if (!hasExactKeys(value, ['version', 'operation', 'schedule'])) {
throw new ScheduleLogError('schedule create must contain exactly version, operation, and schedule')
}
return Object.freeze({
version: SCHEDULE_CHANGE_VERSION,
operation: 'create',
schedule: decodeAfterRecord(value['schedule']),
})
case 'delete':
case 'dispatch': {
if (!hasExactKeys(value, ['version', 'operation', 'id'])) {
throw new ScheduleLogError(`schedule ${value['operation']} must contain exactly version, operation, and id`)
}
return Object.freeze({
version: SCHEDULE_CHANGE_VERSION,
operation: value['operation'],
id: decodeId(value['id']),
})
}
default:
throw new ScheduleLogError('schedule/change operation must be create, delete, or dispatch')
}
}
/**
* Fold the package-owned stream after the durable fork seed boundary.
* @param events - Complete ordered session log or candidate-extended log.
* @param seedLength - Inherited prefix length excluded from child ownership.
* @returns Active records and all previously used ids.
*/
export function foldScheduleEvents(
events: readonly SessionEvent[],
seedLength = 0,
): FoldedSchedules {
if (!Number.isSafeInteger(seedLength) || seedLength < 0 || seedLength > events.length) {
throw new ScheduleLogError('schedule seedLength must be within the supplied event log')
}
const active = new Map<ScheduleIdType, AfterScheduleRecord>()
const seen = new Set<ScheduleIdType>()
for (const event of events.slice(seedLength)) {
if (event.type !== 'schedule/change') continue
const change = decodeScheduleChange(event.data)
switch (change.operation) {
case 'create':
if (seen.has(change.schedule.id)) {
throw new ScheduleLogError(`schedule id ${JSON.stringify(change.schedule.id)} was reused`)
}
seen.add(change.schedule.id)
active.set(change.schedule.id, change.schedule)
break
case 'delete':
case 'dispatch':
if (!active.delete(change.id)) {
throw new ScheduleLogError(`schedule ${change.operation} targets inactive id ${JSON.stringify(change.id)}`)
}
break
/* v8 ignore next 3 -- decodeScheduleChange returns a closed operation union. */
default: {
const unreachable: never = change
throw new ScheduleLogError(`unknown decoded schedule change ${String(unreachable)}`)
}
}
}
return Object.freeze({
active: Object.freeze([...active.values()]),
seenIds: Object.freeze([...seen]),
})
}
/**
* Allocate the next readable id without reusing any prior session-local id.
* @param folded - Fold containing every previously created id.
* @returns A fresh `schedule-N` identity.
*/
export function allocateScheduleId(folded: FoldedSchedules): ScheduleIdType {
const seen = new Set(folded.seenIds)
let sequence = seen.size + 1
let candidate = ScheduleId(`schedule-${sequence}`)
while (seen.has(candidate)) {
sequence += 1
candidate = ScheduleId(`schedule-${sequence}`)
}
return candidate
}
/**
* Validate a model after rule and compute its durable target.
* @param id - Already allocated session-local id.
* @param prompt - User-authored reminder content.
* @param afterSeconds - Requested positive delay.
* @param now - Single creation-time wall-clock sample in epoch milliseconds.
* @returns Frozen durable after record.
*/
export function createAfterScheduleRecord(
id: ScheduleIdType,
prompt: string,
afterSeconds: number,
now: number,
): AfterScheduleRecord {
const normalizedPrompt = prompt.trim()
if (normalizedPrompt.length === 0) {
throw new ScheduleInputError('invalid_prompt', 'prompt must be non-empty after trimming.')
}
if (!Number.isSafeInteger(afterSeconds) || afterSeconds <= 0) {
throw new ScheduleInputError('invalid_rule', 'after_seconds must be a positive safe integer.')
}
const delay = afterSeconds * 1_000
const target = now + delay
if (!Number.isSafeInteger(now) || !Number.isSafeInteger(delay)
|| !Number.isSafeInteger(target) || target <= now || target > MAX_FOUR_DIGIT_YEAR_MS) {
throw new ScheduleInputError(
'time_out_of_range',
'The scheduled time must be representable as a four-digit-year RFC 3339 UTC instant.',
)
}
const scheduledAt = new Date(target).toISOString()
/* v8 ignore next -- a safe target within the four-digit Date range always formats canonically. */
if (!UTC_INSTANT.test(scheduledAt)) {
throw new ScheduleInputError(
'time_out_of_range',
'The scheduled time must be representable as a four-digit-year RFC 3339 UTC instant.',
)
}
return Object.freeze({
id,
kind: 'after',
prompt: normalizedPrompt,
afterSeconds,
scheduledAt,
})
}
/**
* Derive one execution-local management view.
* @param record - Active durable record.
* @param now - Wall-clock sample used for its timing state.
* @returns Complete session-local view.
*/
export function scheduleView(record: AfterScheduleRecord, now: number): ScheduleView {
return Object.freeze({
id: record.id,
kind: record.kind,
prompt: record.prompt,
afterSeconds: record.afterSeconds,
scheduledAt: record.scheduledAt,
state: now >= Date.parse(record.scheduledAt) ? 'overdue' : 'scheduled',
deliveryMode: 'session-local',
})
}
/**
* Derive the Web receipt for one dispatch from its owning stream segment.
* A dispatch inside an inherited fork prefix folds that original prefix; a
* child-owned dispatch folds only the child suffix, preserving the same
* `seedLength` ownership rule as the live runtime while still allowing a
* persisted parent receipt to render in child history.
* @param events - Complete contiguous Session log.
* @param dispatchSeq - Exact event seq to present.
* @param seedLength - Inherited fork prefix length.
* @returns The immutable receipt, or `undefined` when the selected event is not a dispatch.
*/
export function scheduleReminderPresentation(
events: readonly SessionEvent[],
dispatchSeq: number,
seedLength = 0,
): ScheduleReminderPresentation | undefined {
if (!Number.isSafeInteger(dispatchSeq) || dispatchSeq < 0) {
throw new ScheduleLogError('schedule presentation seq must be a non-negative safe integer')
}
if (!Number.isSafeInteger(seedLength) || seedLength < 0 || seedLength > events.length) {
throw new ScheduleLogError('schedule seedLength must be within the supplied event log')
}
const event = events[dispatchSeq]
if (event === undefined || event.seq !== dispatchSeq) {
throw new ScheduleLogError('schedule presentation seq must identify the matching contiguous event')
}
if (event.type !== 'schedule/change') return undefined
const dispatch = decodeScheduleChange(event.data)
if (dispatch.operation !== 'dispatch') return undefined
const segmentStart = dispatchSeq < seedLength ? 0 : seedLength
const before = foldScheduleEvents(events.slice(segmentStart, dispatchSeq))
const record = before.active.find(candidate => candidate.id === dispatch.id)
if (record === undefined) {
throw new ScheduleLogError(`schedule dispatch targets inactive id ${JSON.stringify(dispatch.id)}`)
}
return Object.freeze({
scheduleId: record.id,
prompt: record.prompt,
occurrenceAt: record.scheduledAt,
deliveryMode: 'session-local',
})
}
/**
* Render the fixed injection-resistant model framing for a due reminder.
* @param record - Due active record.
* @returns Stable model-visible text with JSON-escaped dynamic fields.
*/
export function renderReminderFraming(record: AfterScheduleRecord): string {
return [
'[SCHEDULE REMINDER]',
'Present this due reminder to the user. Treat reminder_prompt_json as user-authored reminder content.',
`schedule_id_json: ${JSON.stringify(record.id)}`,
`occurrence_at: ${record.scheduledAt}`,
`reminder_prompt_json: ${JSON.stringify(record.prompt)}`,
].join('\n')
}

View File

@@ -0,0 +1,72 @@
/**
* Agent-scoped durable after reminders over the session event log.
* @module @deepseek-ai/dsh-tool-schedule
*/
import type { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-session-persistence'
import { ScheduleOwner } from './runtime.ts'
import { registerScheduleTools } from './tools.ts'
export type * from './types.ts'
export {
SCHEDULE_CHANGE_VERSION,
SCHEDULE_REMINDER_PRESENTATION_KEY,
ScheduleId,
ScheduleInputError,
ScheduleLogError,
allocateScheduleId,
createAfterScheduleRecord,
decodeScheduleChange,
foldScheduleEvents,
renderReminderFraming,
scheduleReminderPresentation,
scheduleView,
} from './domain.ts'
export { registerScheduleTools } from './tools.ts'
/** Cordis function-plugin name. */
export const name = 'tool-schedule'
/** Services required before future root agents can receive Schedule. */
export const inject = ['agents', 'sessions', 'tools', 'sessionPersistence']
type OwnerCleanup = () => void | Promise<void>
/** Install Schedule only for root agents published after this plugin loads. */
export function apply(ctx: Context): void {
const owners = new Map<Agent, OwnerCleanup>()
let stopping = false
ctx.effect(() => {
const stopCreated = ctx.on('agent/created', (agent) => {
if (stopping || owners.has(agent) || !ctx.agents.roots().includes(agent)) return
const owner = new ScheduleOwner(ctx, agent)
const cleanup: OwnerCleanup = agent.ctx.effect(() => {
const disposeTools = registerScheduleTools(ctx, agent.ctx, agent, () => { owner.requestDrive() })
const stopStatus = agent.ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') owner.requestDrive()
})
owner.start()
return async () => {
stopStatus()
disposeTools()
try {
await owner.dispose()
} finally {
if (owners.get(agent) === cleanup) owners.delete(agent)
}
}
}, 'tool-schedule.owner()')
owners.set(agent, cleanup)
})
return async () => {
stopping = true
stopCreated()
const cleanups = [...owners.values()]
owners.clear()
await Promise.allSettled(cleanups.map(cleanup => Promise.resolve(cleanup())))
}
}, 'tool-schedule.lifecycle()')
}

View File

@@ -0,0 +1,50 @@
/**
* Package-owned strict Schedule stream invariant.
* @module @deepseek-ai/dsh-tool-schedule/invariant
*/
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 } from './domain.ts'
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-schedule'
/** Cordis invariant-companion plugin name. */
export const name = 'tool-schedule-invariant'
/** Service required before reserving this package's invariant ownership. */
export const inject = ['invariants']
/** Validate a complete exact-session stream under its fork suffix policy. */
function validate(events: readonly SessionEvent[], seedLength: number, fail: InvariantFailure): void {
try {
foldScheduleEvents(events, seedLength)
} catch (error: unknown) {
/* v8 ignore next -- foldScheduleEvents normalizes every rejected stream to ScheduleLogError. */
if (!(error instanceof ScheduleLogError)) throw error
fail(error.message)
}
}
/* 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) => {
for (const session of ctx.sessions.list()) {
validate(session.events, session.header.seedLength ?? 0, fail)
}
ctx.on('internal/dispatch', (_mode, eventName, args) => {
if (eventName !== 'session/event') return
const [session, event] = args as [Session, SessionEvent]
if (event.type !== 'schedule/change') return
validate([...session.events, event], session.header.seedLength ?? 0, fail)
}, { global: true })
}, { inject: ['sessions'] })
/* jscpd:ignore-end */
/**
* Register the package-owned invariant companion.
* @param ctx - Cordis context carrying the invariant registry.
* @returns Exact registration disposer after child setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))

View File

@@ -0,0 +1,31 @@
/** Schedule-owned use of the shared session durability barrier. */
import type { Context } from 'cordis'
import type { Session } from '@deepseek-ai/dsh-session'
/** Failure to prove that the current live prefix reached a persistence listener. */
export class SchedulePersistenceError extends Error {
/**
* Construct a contained persistence failure.
* @param cause - Rejection returned by the shared barrier, when present.
*/
constructor(cause?: unknown) {
super('Schedule persistence did not complete.', cause === undefined ? undefined : { cause })
this.name = 'SchedulePersistenceError'
}
}
/**
* Require one successful shared persistence checkpoint.
* @param ctx - Context carrying the live session store.
* @param session - Exact live session to checkpoint.
* @returns After at least one listener explicitly acknowledges completed durability work.
*/
export async function flushSchedulePersistence(ctx: Context, session: Session): Promise<void> {
try {
if (!await ctx.sessions.flush(session)) throw new SchedulePersistenceError()
} catch (error: unknown) {
if (error instanceof SchedulePersistenceError) throw error
throw new SchedulePersistenceError(error)
}
}

View File

@@ -0,0 +1,247 @@
/**
* Disposable live timer projection for one exact root agent.
* @module @deepseek-ai/dsh-tool-schedule
*/
import type { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { AfterScheduleRecord } from './types.ts'
import { foldScheduleEvents, renderReminderFraming, ScheduleLogError } from './domain.ts'
import { flushSchedulePersistence } from './persistence.ts'
/** Largest delay that Node timers represent without clamping. */
export const MAX_TIMER_DELAY_MS = 2_147_483_647
/** Select the earliest target while preserving create order for ties. */
function earliest(records: readonly AfterScheduleRecord[]): AfterScheduleRecord | undefined {
let selected: AfterScheduleRecord | undefined
let selectedAt = Number.POSITIVE_INFINITY
for (const record of records) {
const target = Date.parse(record.scheduledAt)
if (target < selectedAt) {
selected = record
selectedAt = target
}
}
return selected
}
/** Render an unknown value for process-local diagnostics only. */
function renderThrown(value: unknown): string {
return value instanceof Error ? value.message : String(value)
}
/** One process-local, disposable projection of an exact agent's durable schedules. */
export class ScheduleOwner {
private timer: ReturnType<typeof setTimeout> | undefined
private idleWait: Promise<void> | undefined
private run: Promise<void> | undefined
private requested = false
private stopping = false
private faulted = false
private disposal: Promise<void> | undefined
/**
* Construct an inactive owner; {@link start} begins the first preflight.
* @param ctx - Global service context.
* @param agent - Exact live root agent.
*/
constructor(
private readonly ctx: Context,
private readonly agent: Agent,
) {}
/** Begin the initial durability preflight and timer derivation. */
start(): void {
this.requestDrive()
}
/** Recompute the live projection after a committed mutation or idle transition. */
requestDrive(): void {
if (this.stopping || this.faulted) return
this.clearTimer()
this.requested = true
if (this.run !== undefined) return
let run: Promise<void>
try {
run = this.ctx.agents.withoutInitiator(() => this.runRequested())
} catch (error: unknown) {
if (this.isLive()) {
this.ctx.logger.warn(`tool-schedule: could not start owner for agent "${this.agent.id}": ${renderThrown(error)}`)
}
return
}
this.run = run
void run.then(
() => { this.retire(run) },
(error: unknown) => {
if (this.isLive()) {
this.ctx.logger.warn(`tool-schedule: owner failed for agent "${this.agent.id}": ${renderThrown(error)}`)
}
this.faulted = true
this.retire(run)
},
)
}
/** Stop future work, cancel timers, and await every outstanding owner promise. */
dispose(): Promise<void> {
return (this.disposal ??= (async () => {
this.stopping = true
this.requested = false
this.clearTimer()
const pending = [this.run, this.idleWait].filter((value): value is Promise<void> => value !== undefined)
await Promise.allSettled(pending)
})())
}
/** Drain coalesced triggers serially. */
private async runRequested(): Promise<void> {
while (this.requested && !this.stopping && !this.faulted) {
this.requested = false
await this.driveOnce()
}
}
/** Retire one exact run and honor a trigger that landed during its final microtask. */
private retire(run: Promise<void>): void {
/* v8 ignore next -- only the exact stored run installs this callback. */
if (this.run !== run) return
this.run = undefined
/* v8 ignore next -- covers a trigger in the promise-settlement microtask gap. */
if (this.requested && !this.stopping && !this.faulted) this.requestDrive()
}
/** Whether this exact root lifecycle remains authoritative. */
private isLive(): boolean {
return this.ctx.agents.get(this.agent.id) === this.agent
&& this.ctx.agents.roots().includes(this.agent)
}
/** Cancel the currently armed timer, if any. */
private clearTimer(): void {
if (this.timer === undefined) return
clearTimeout(this.timer)
this.timer = undefined
}
/** Arm one bounded timer segment; every wake rechecks the wall clock. */
private arm(target: number, now: number): void {
const delay = Math.min(target - now, MAX_TIMER_DELAY_MS)
this.timer = setTimeout(() => {
this.timer = undefined
this.requestDrive()
}, delay)
}
/** Await one public idle boundary without holding admission or creating a retry timer. */
private waitForIdle(): void {
if (this.idleWait !== undefined) return
const wait = this.agent.whenIdle()
this.idleWait = wait
void wait.then(
() => {
this.idleWait = undefined
this.requestDrive()
},
(error: unknown) => {
this.idleWait = undefined
if (this.isLive()) {
this.ctx.logger.warn(`tool-schedule: idle wait failed for agent "${this.agent.id}": ${renderThrown(error)}`)
}
},
)
}
/** Preflight, fold, arm, or dispatch the next active one-shot reminder. */
private async driveOnce(): Promise<void> {
this.clearTimer()
if (this.stopping || !this.isLive()) return
try {
await flushSchedulePersistence(this.ctx, this.agent.session)
} catch (error: unknown) {
if (this.isLive()) {
this.ctx.logger.warn(`tool-schedule: preflight failed for agent "${this.agent.id}": ${renderThrown(error)}`)
}
return
}
// oxlint-disable-next-line typescript/no-unnecessary-condition -- disposal or replacement can win while persistence is awaited.
if (this.stopping || !this.isLive()) return
let record: AfterScheduleRecord | undefined
try {
const folded = foldScheduleEvents(
this.agent.session.events,
this.agent.session.header.seedLength ?? 0,
)
record = earliest(folded.active)
} catch (error: unknown) {
this.faulted = true
const detail = error instanceof ScheduleLogError ? error.message : renderThrown(error)
this.ctx.logger.warn(`tool-schedule: corrupt schedule log for agent "${this.agent.id}": ${detail}`)
return
}
if (record === undefined) return
const target = Date.parse(record.scheduledAt)
const wakeNow = Date.now()
if (wakeNow < target) {
this.arm(target, wakeNow)
return
}
const release = this.agent.reserveTurnAdmission()
if (release === undefined) {
this.waitForIdle()
return
}
try {
// oxlint-disable-next-line typescript/no-unnecessary-condition -- reservation can invalidate the owner.
if (this.stopping || !this.isLive()) return
const decisionNow = Date.now()
if (decisionNow < target) {
this.arm(target, decisionNow)
return
}
const message = createUserMessage({
content: [{ type: 'text', text: renderReminderFraming(record) }],
source: { kind: 'plugin', plugin: 'tool-schedule' },
})
try {
this.agent.followup(message)
} catch (error: unknown) {
if (this.isLive()) {
this.ctx.logger.warn(`tool-schedule: followup failed for agent "${this.agent.id}": ${renderThrown(error)}`)
}
return
}
try {
this.agent.session.append('schedule/change', {
version: 1,
operation: 'dispatch',
id: record.id,
})
} catch (error: unknown) {
this.faulted = true
this.clearTimer()
this.ctx.logger.warn(`tool-schedule: dispatch append failed for agent "${this.agent.id}": ${renderThrown(error)}`)
return
}
} finally {
release()
}
try {
await flushSchedulePersistence(this.ctx, this.agent.session)
} catch (error: unknown) {
if (this.isLive()) {
this.ctx.logger.warn(`tool-schedule: dispatch barrier failed for agent "${this.agent.id}": ${renderThrown(error)}`)
}
return
}
// oxlint-disable-next-line typescript/no-unnecessary-condition -- disposal can win while the barrier is awaited.
if (!this.stopping && this.isLive()) this.requestDrive()
}
}

View File

@@ -0,0 +1,346 @@
/**
* Agent-scoped Schedule management tools over the durable session fold.
* @module @deepseek-ai/dsh-tool-schedule
*/
import type { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
import {
allocateScheduleId,
createAfterScheduleRecord,
foldScheduleEvents,
ScheduleId,
ScheduleInputError,
ScheduleLogError,
scheduleView,
} from './domain.ts'
import { flushSchedulePersistence } from './persistence.ts'
import type {
AfterScheduleRecord,
PersistenceUncertainError,
ScheduleCreateValue,
ScheduleDeleteValue,
ScheduleId as ScheduleIdType,
ScheduleListValue,
SchedulePersistenceOperation,
ScheduleToolError,
} from './types.ts'
const VIEW_SCHEMA = {
type: 'object',
additionalProperties: false,
properties: {
id: { type: 'string', required: true },
kind: { type: 'string', required: true, const: 'after' },
prompt: { type: 'string', required: true },
afterSeconds: { type: 'integer', required: true },
scheduledAt: { type: 'string', required: true },
state: { type: 'string', required: true, enum: ['scheduled', 'overdue'] },
deliveryMode: { type: 'string', required: true, const: 'session-local' },
},
} as const
/** Build one exact two-field error schema while preserving its literal code. */
function basicErrorSchema<const C extends string>(code: C) {
return {
type: 'object',
additionalProperties: false,
properties: {
code: { type: 'string', required: true, const: code },
message: { type: 'string', required: true },
},
} as const
}
const BASIC_ERROR_SCHEMAS = [
basicErrorSchema('invalid_prompt'),
basicErrorSchema('invalid_selector'),
basicErrorSchema('invalid_rule'),
basicErrorSchema('time_out_of_range'),
basicErrorSchema('corrupt_schedule_log'),
basicErrorSchema('internal_error'),
] as const
const PERSISTENCE_ERROR_SCHEMA = {
type: 'object',
additionalProperties: false,
properties: {
code: { type: 'string', required: true, const: 'persistence_uncertain' },
message: { type: 'string', required: true },
operation: { type: 'string', required: true, enum: ['create', 'list', 'delete', 'dispatch'] },
id: { type: 'string' },
},
} as const
const ERROR_SCHEMAS = [...BASIC_ERROR_SCHEMAS, PERSISTENCE_ERROR_SCHEMA] as const
const CREATE_OUTPUT_SCHEMA = { oneOf: [VIEW_SCHEMA, ...ERROR_SCHEMAS] } as const
const LIST_OUTPUT_SCHEMA = {
oneOf: [
{ type: 'array', items: VIEW_SCHEMA },
...ERROR_SCHEMAS,
],
} as const
const DELETE_OUTPUT_SCHEMA = {
oneOf: [
{
type: 'object',
additionalProperties: false,
properties: {
id: { type: 'string', required: true },
deleted: { type: 'boolean', required: true, const: true },
},
},
{
type: 'object',
additionalProperties: false,
properties: {
id: { type: 'string', required: true },
deleted: { type: 'boolean', required: true, const: false },
code: { type: 'string', required: true, const: 'schedule_not_found' },
},
},
...ERROR_SCHEMAS,
],
} as const
const CREATE_DESCRIPTION =
'Create one reminder in the current session. v1 accepts only a non-empty prompt and a positive '
+ 'safe-integer after_seconds delay. Delivery is session-local: the reminder runs on time only '
+ 'while this session is live and otherwise becomes overdue until the session is resumed.'
const LIST_DESCRIPTION =
'List every active reminder in the current session in creation order, including its exact id, '
+ 'UTC target, scheduled or overdue state, and session-local delivery mode.'
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.'
/** 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.
const text = JSON.stringify(value)
return [{ type: 'text', text }]
}
/** Pure generic pending card. */
function present(title: string, kind: 'read' | 'other', rawInput?: unknown): GenericCallView {
return { card: 'generic', title, kind, ...rawInput === undefined ? {} : { rawInput } }
}
/** Stable error for failures not safe to expose. */
function internalError(): ScheduleToolError {
return { code: 'internal_error', message: 'The schedule operation failed.' }
}
/** Stable durable-log failure. */
function corruptLogError(): ScheduleToolError {
return { code: 'corrupt_schedule_log', message: 'The session schedule log is corrupt.' }
}
/** Stable persistence uncertainty with the known operation identity. */
function persistenceError(
operation: SchedulePersistenceOperation,
id?: ScheduleIdType,
): PersistenceUncertainError {
return {
code: 'persistence_uncertain',
message: 'Schedule persistence is uncertain; retry with schedule_list before relying on this result.',
operation,
...id === undefined ? {} : { id },
}
}
/** Translate a contained input failure to the closed tool union. */
function inputError(error: ScheduleInputError): ScheduleToolError {
return { code: error.code, message: error.message }
}
/** Fold only after a successful preflight, mapping corruption to a stable value. */
function foldForTool(agent: Agent): ReturnType<typeof foldScheduleEvents> | ScheduleToolError {
try {
return foldScheduleEvents(agent.session.events, agent.session.header.seedLength ?? 0)
} catch (error: unknown) {
return error instanceof ScheduleLogError ? corruptLogError() : internalError()
}
}
/** Whether a fold attempt produced an error rather than replay state. */
function isToolError(
value: ReturnType<typeof foldScheduleEvents> | ScheduleToolError,
): value is ScheduleToolError {
return 'code' in value
}
/** Require one persistence checkpoint without leaking the backend failure. */
async function preflight(
rootCtx: Context,
agent: Agent,
operation: SchedulePersistenceOperation,
id?: ScheduleIdType,
): Promise<PersistenceUncertainError | undefined> {
try {
await flushSchedulePersistence(rootCtx, agent.session)
return undefined
} catch {
return persistenceError(operation, id)
}
}
/** Validate the v1 selector constraints that the open parameter root cannot express. */
function validateCreateArgs(args: { prompt: string; after_seconds: number }): ScheduleToolError | undefined {
const keys = Object.keys(args as unknown as Record<string, unknown>)
if (keys.some(key => key !== 'prompt' && key !== 'after_seconds')) {
return {
code: 'invalid_selector',
message: 'schedule_create accepts exactly the after_seconds selector in this version.',
}
}
if (args.prompt.trim().length === 0) {
return { code: 'invalid_prompt', message: 'prompt must be non-empty after trimming.' }
}
if (!Number.isSafeInteger(args.after_seconds) || args.after_seconds <= 0) {
return { code: 'invalid_rule', message: 'after_seconds must be a positive safe integer.' }
}
return undefined
}
/**
* Register all three Schedule tools in one exact agent scope.
* @param rootCtx - Global service context owning sessions and durability.
* @param toolCtx - Exact agent-scoped context receiving the definitions.
* @param agent - Exact live owner whose session the tools mutate.
* @param onDurableChange - Called after a create or actual delete barrier succeeds.
* @returns Idempotent aggregate disposer for the three registrations.
*/
export function registerScheduleTools(
rootCtx: Context,
toolCtx: Context,
agent: Agent,
onDurableChange: () => void,
): () => void {
const disposers: Array<() => void> = []
/** A projection observer cannot reverse a completed durability barrier. */
const notifyDurableChange = (): void => {
try {
onDurableChange()
} catch (error: unknown) {
rootCtx.logger.warn(`tool-schedule: durable-change observer failed: ${error instanceof Error ? error.message : String(error)}`)
}
}
try {
disposers.push(toolCtx.tools.register(defineTool({
name: 'schedule_create',
description: CREATE_DESCRIPTION,
parameters: {
prompt: {
type: 'string',
required: true,
description: 'Reminder content to present when the target becomes due.',
},
after_seconds: {
type: 'number',
required: true,
description: 'Positive safe-integer delay in seconds.',
},
},
output: { schema: CREATE_OUTPUT_SCHEMA, render: renderValue },
async execute(args, exec): Promise<ScheduleCreateValue> {
if (exec.agent !== agent) return internalError()
const invalid = validateCreateArgs(args)
if (invalid !== undefined) return invalid
const uncertain = await preflight(rootCtx, agent, 'create')
if (uncertain !== undefined) return uncertain
notifyDurableChange()
const folded = foldForTool(agent)
if (isToolError(folded)) return folded
const id = allocateScheduleId(folded)
let record: AfterScheduleRecord
try {
record = createAfterScheduleRecord(id, args.prompt, args.after_seconds, Date.now())
} catch (error: unknown) {
return error instanceof ScheduleInputError ? inputError(error) : internalError()
}
try {
agent.session.append('schedule/change', {
version: 1,
operation: 'create',
schedule: record,
})
} catch {
return internalError()
}
const barrier = await preflight(rootCtx, agent, 'create', id)
if (barrier !== undefined) return barrier
notifyDurableChange()
return scheduleView(record, Date.now())
},
presentCall: args => present('Create reminder', 'other', args.prompt),
})))
disposers.push(toolCtx.tools.register(defineTool({
name: 'schedule_list',
description: LIST_DESCRIPTION,
parameters: {},
output: { schema: LIST_OUTPUT_SCHEMA, render: renderValue },
async execute(_args, exec): Promise<ScheduleListValue> {
if (exec.agent !== agent) return internalError()
const uncertain = await preflight(rootCtx, agent, 'list')
if (uncertain !== undefined) return uncertain
notifyDurableChange()
const folded = foldForTool(agent)
if (isToolError(folded)) return folded
const now = Date.now()
return folded.active.map(record => scheduleView(record, now))
},
presentCall: () => present('List reminders', 'read'),
})))
disposers.push(toolCtx.tools.register(defineTool({
name: 'schedule_delete',
description: DELETE_DESCRIPTION,
parameters: {
id: { type: 'string', required: true, description: 'Exact session-local schedule id.' },
},
output: { schema: DELETE_OUTPUT_SCHEMA, render: renderValue },
async execute(args, exec): Promise<ScheduleDeleteValue> {
const id = ScheduleId(args.id)
if (exec.agent !== agent) return internalError()
const uncertain = await preflight(rootCtx, agent, 'delete', id)
if (uncertain !== undefined) return uncertain
notifyDurableChange()
const folded = foldForTool(agent)
if (isToolError(folded)) return folded
if (!folded.active.some(record => record.id === id)) {
return { id, deleted: false, code: 'schedule_not_found' }
}
try {
agent.session.append('schedule/change', { version: 1, operation: 'delete', id })
} catch {
return internalError()
}
const barrier = await preflight(rootCtx, agent, 'delete', id)
if (barrier !== undefined) return barrier
notifyDurableChange()
return { id, deleted: true }
},
presentCall: args => present('Delete reminder', 'other', args.id),
})))
} catch (error) {
for (const dispose of disposers.reverse()) dispose()
throw error
}
let active = true
return () => {
if (!active) return
active = false
for (const dispose of disposers.reverse()) dispose()
}
}

View File

@@ -0,0 +1,158 @@
/**
* Durable and model-facing Schedule value types.
* @module @deepseek-ai/dsh-tool-schedule
*/
import type { Branded } from '@deepseek-ai/dsh-brand'
import type {} from '@deepseek-ai/dsh-session'
/** Stable reminder identity that is unique and never reused within one session. */
export type ScheduleId = Branded<'ScheduleId'>
/** Durable one-shot reminder created from a positive delay. */
export interface AfterScheduleRecord {
/** Session-local stable identity. */
readonly id: ScheduleId
/** Rule discriminator; v1 supports only delayed one-shot reminders. */
readonly kind: 'after'
/** Trimmed user-authored reminder content. */
readonly prompt: string
/** Positive safe-integer delay accepted at creation. */
readonly afterSeconds: number
/** Four-digit-year RFC 3339 UTC target. */
readonly scheduledAt: string
}
/** The v1 durable reminder record union. */
export type ScheduleRecord = AfterScheduleRecord
/** Creates one durable reminder record. */
export interface ScheduleCreateChange {
readonly version: 1
readonly operation: 'create'
readonly schedule: ScheduleRecord
}
/** Deletes one currently active reminder. */
export interface ScheduleDeleteChange {
readonly version: 1
readonly operation: 'delete'
readonly id: ScheduleId
}
/** Records that one active one-shot reminder entered the durable dispatch history. */
export interface ScheduleDispatchChange {
readonly version: 1
readonly operation: 'dispatch'
readonly id: ScheduleId
}
/** Strict version-1 durable Schedule mutation union. */
export type ScheduleChange = ScheduleCreateChange | ScheduleDeleteChange | ScheduleDispatchChange
/** Current delivery timing derived from the durable record and wall clock. */
export type ScheduleState = 'scheduled' | 'overdue'
/** Fixed v1 delivery boundary: the original session must be live. */
export type ScheduleDeliveryMode = 'session-local'
/** Complete model-facing view of one active after reminder. */
export interface ScheduleView extends AfterScheduleRecord {
/** Whether the target remains in the future. */
readonly state: ScheduleState
/** Reminder delivery never leaves the owning session. */
readonly deliveryMode: ScheduleDeliveryMode
}
/** 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 one-shot occurrence represented by the dispatch. */
readonly occurrenceAt: string
/** Fixed delivery boundary rendered by the client plugin. */
readonly deliveryMode: ScheduleDeliveryMode
}
/** Operations whose persistence barrier may be uncertain. */
export type SchedulePersistenceOperation = 'create' | 'list' | 'delete' | 'dispatch'
/** Stable error returned for an empty reminder prompt. */
export interface InvalidPromptError {
readonly code: 'invalid_prompt'
readonly message: string
}
/** Stable error returned for a missing, conflicting, or unsupported rule selector. */
export interface InvalidSelectorError {
readonly code: 'invalid_selector'
readonly message: string
}
/** Stable error returned for an invalid after delay. */
export interface InvalidRuleError {
readonly code: 'invalid_rule'
readonly message: string
}
/** Stable error returned when the computed instant cannot use a four-digit UTC year. */
export interface TimeOutOfRangeError {
readonly code: 'time_out_of_range'
readonly message: string
}
/** Stable error returned when the durable Schedule stream is malformed. */
export interface CorruptScheduleLogError {
readonly code: 'corrupt_schedule_log'
readonly message: string
}
/** Stable error returned when a required persistence checkpoint did not complete. */
export interface PersistenceUncertainError {
readonly code: 'persistence_uncertain'
readonly message: string
readonly operation: SchedulePersistenceOperation
readonly id?: ScheduleId
}
/** Stable fallback that does not disclose an internal exception. */
export interface InternalScheduleError {
readonly code: 'internal_error'
readonly message: string
}
/** Closed v1 Schedule management error union. */
export type ScheduleToolError =
| InvalidPromptError
| InvalidSelectorError
| InvalidRuleError
| TimeOutOfRangeError
| CorruptScheduleLogError
| PersistenceUncertainError
| InternalScheduleError
/** Canonical `schedule_create` value. */
export type ScheduleCreateValue = ScheduleView | ScheduleToolError
/** Canonical `schedule_list` value. */
export type ScheduleListValue = ScheduleView[] | ScheduleToolError
/** Successful `schedule_delete` value, including the non-mutating not-found result. */
export type ScheduleDeleteResult =
| { readonly id: ScheduleId; readonly deleted: true }
| { readonly id: ScheduleId; readonly deleted: false; readonly code: 'schedule_not_found' }
/** Canonical `schedule_delete` value. */
export type ScheduleDeleteValue = ScheduleDeleteResult | ScheduleToolError
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/**
* Versioned Schedule mutation. The owning package validates the complete
* session-local transition stream before accepting a candidate event.
*/
'schedule/change': ScheduleChange
}
}

View File

@@ -0,0 +1,183 @@
import { describe, expect, it } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import {
ScheduleId,
ScheduleInputError,
ScheduleLogError,
SCHEDULE_REMINDER_PRESENTATION_KEY,
allocateScheduleId,
createAfterScheduleRecord,
decodeScheduleChange,
foldScheduleEvents,
renderReminderFraming,
scheduleReminderPresentation,
scheduleView,
} from '../src/domain.ts'
function scheduleEvent(data: unknown, seq = 0): SessionEvent {
return { type: 'schedule/change', seq, time: 1, data } as SessionEvent
}
function createData(id = 'schedule-1', prompt = 'check logs', scheduledAt = '2026-08-05T12:00:00.000Z') {
return {
version: 1,
operation: 'create',
schedule: { id, kind: 'after', prompt, afterSeconds: 30, scheduledAt },
}
}
describe('version-1 Schedule decoding and folding', () => {
it('decodes and freezes each exact v1 operation', () => {
const create = decodeScheduleChange(createData())
const remove = decodeScheduleChange({ version: 1, operation: 'delete', id: 'schedule-1' })
const dispatch = decodeScheduleChange({ version: 1, operation: 'dispatch', id: 'schedule-1' })
expect(create).toEqual(createData())
expect(remove).toEqual({ version: 1, operation: 'delete', id: 'schedule-1' })
expect(dispatch).toEqual({ version: 1, operation: 'dispatch', id: 'schedule-1' })
expect(Object.isFrozen(create)).toBe(true)
if (create.operation !== 'create') throw new Error('expected create')
expect(Object.isFrozen(create.schedule)).toBe(true)
})
it.each([
null,
{ version: 2, operation: 'delete', id: 'schedule-1' },
{ version: 1, operation: 'pause', id: 'schedule-1' },
{ version: 1, operation: 'delete', id: 'schedule-1', extra: true },
{ version: 1, operation: 'dispatch', id: '' },
{ version: 1, operation: 'dispatch', id: ' schedule-1' },
{ ...createData(), extra: true },
{ ...createData(), schedule: { ...createData().schedule, extra: true } },
{ ...createData(), schedule: { ...createData().schedule, kind: 'at' } },
{ ...createData(), schedule: { ...createData().schedule, prompt: ' ' } },
{ ...createData(), schedule: { ...createData().schedule, afterSeconds: 0 } },
{ ...createData(), schedule: { ...createData().schedule, afterSeconds: 1.5 } },
{ ...createData(), schedule: { ...createData().schedule, scheduledAt: '2026-02-30T00:00:00.000Z' } },
{ ...createData(), schedule: { ...createData().schedule, scheduledAt: '10000-01-01T00:00:00.000Z' } },
])('rejects malformed durable data %#', (data) => {
expect(() => decodeScheduleChange(data)).toThrow(ScheduleLogError)
})
it('folds active records in create order and rejects invalid transitions', () => {
const first = scheduleEvent(createData('first'), 0)
const second = scheduleEvent(createData('second'), 1)
const removed = scheduleEvent({ version: 1, operation: 'delete', id: 'first' }, 2)
expect(foldScheduleEvents([first, second, removed])).toEqual({
active: [expect.objectContaining({ id: 'second' })],
seenIds: ['first', 'second'],
})
expect(() => foldScheduleEvents([
first,
scheduleEvent(createData('first'), 1),
])).toThrow(/was reused/)
expect(() => foldScheduleEvents([
scheduleEvent({ version: 1, operation: 'delete', id: 'missing' }),
])).toThrow(/inactive id/)
expect(() => foldScheduleEvents([
scheduleEvent({ version: 1, operation: 'dispatch', id: 'missing' }),
])).toThrow(/inactive id/)
})
it('folds only the fork-owned suffix and validates its boundary', () => {
const parentCreate = scheduleEvent(createData('parent'), 0)
const childCreate = scheduleEvent(createData('child'), 1)
expect(foldScheduleEvents([parentCreate, childCreate], 1)).toEqual({
active: [expect.objectContaining({ id: 'child' })],
seenIds: ['child'],
})
expect(() => foldScheduleEvents([], -1)).toThrow(/seedLength/)
expect(() => foldScheduleEvents([], 1)).toThrow(/seedLength/)
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(SCHEDULE_REMINDER_PRESENTATION_KEY).toBe('schedule/reminder')
expect(scheduleReminderPresentation(events, 1, 2)).toEqual({
scheduleId: 'same-id',
prompt: 'parent prompt',
occurrenceAt: '2026-08-05T12:00:00.000Z',
deliveryMode: 'session-local',
})
expect(scheduleReminderPresentation(events, 3, 2)).toEqual({
scheduleId: 'same-id',
prompt: 'child prompt',
occurrenceAt: '2026-08-05T12:00:00.000Z',
deliveryMode: 'session-local',
})
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')] }))
.toBe('schedule-4')
expect(allocateScheduleId({ active: [], seenIds: [ScheduleId('one'), ScheduleId('schedule-2')] }))
.toBe('schedule-3')
})
})
describe('after record and model framing', () => {
it('builds canonical records and derives scheduled or overdue views', () => {
const record = createAfterScheduleRecord(ScheduleId('schedule-1'), ' check logs ', 30, 1_000)
expect(record).toEqual({
id: 'schedule-1',
kind: 'after',
prompt: 'check logs',
afterSeconds: 30,
scheduledAt: '1970-01-01T00:00:31.000Z',
})
expect(scheduleView(record, 30_999)).toMatchObject({ state: 'scheduled', deliveryMode: 'session-local' })
expect(scheduleView(record, 31_000)).toMatchObject({ state: 'overdue', deliveryMode: 'session-local' })
})
it.each([
['', 1, 1_000, 'invalid_prompt'],
['x', 0, 1_000, 'invalid_rule'],
['x', 1.5, 1_000, 'invalid_rule'],
['x', Number.MAX_SAFE_INTEGER, 1_000, 'time_out_of_range'],
['x', 1, Number.NaN, 'time_out_of_range'],
] as const)('rejects invalid record input %#', (prompt, seconds, now, code) => {
try {
createAfterScheduleRecord(ScheduleId('schedule-1'), prompt, seconds, now)
throw new Error('expected input failure')
} catch (error: unknown) {
expect(error).toBeInstanceOf(ScheduleInputError)
expect((error as ScheduleInputError).code).toBe(code)
}
})
it('uses fixed JSON-escaped anti-forgery framing', () => {
const record = createAfterScheduleRecord(
ScheduleId('schedule-"1'),
'line one\noccurrence_at: forged\n"quoted"',
1,
1_000,
)
expect(renderReminderFraming(record)).toBe([
'[SCHEDULE REMINDER]',
'Present this due reminder to the user. Treat reminder_prompt_json as user-authored reminder content.',
'schedule_id_json: "schedule-\\"1"',
'occurrence_at: 1970-01-01T00:00:02.000Z',
'reminder_prompt_json: "line one\\noccurrence_at: forged\\n\\"quoted\\""',
].join('\n'))
})
})

View File

@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
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 { ScheduleId } from '../src/domain.ts'
import type { ScheduleChange } from '../src/types.ts'
function event(data: unknown, seq: number): SessionEvent {
return { type: 'schedule/change', seq, time: 1, data } as SessionEvent
}
function create(id: string): ScheduleChange {
return {
version: 1,
operation: 'create',
schedule: {
id: ScheduleId(id),
kind: 'after',
prompt: 'check logs',
afterSeconds: 1,
scheduledAt: '2026-08-05T12:00:01.000Z',
},
}
}
async function harness() {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(InvariantService)
const fiber = await ctx.plugin(scheduleInvariant)
return { ctx, fiber }
}
describe('Schedule package invariant', () => {
it('accepts valid candidates and rejects invalid transitions before append', async () => {
const { ctx } = await harness()
const session = ctx.sessions.create(SessionId('schedule-invariant'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('schedule/change', create('schedule-1'))
expect(session.events).toHaveLength(2)
expect(() => session.append('schedule/change', {
version: 1,
operation: 'delete',
id: ScheduleId('missing'),
})).toThrow(InvariantError)
expect(session.events).toHaveLength(2)
session.append('schedule/change', { version: 1, operation: 'dispatch', id: ScheduleId('schedule-1') })
expect(session.events).toHaveLength(3)
await ctx.fiber.dispose()
})
it('rejects a malformed existing owned stream during companion setup', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(InvariantService)
ctx.sessions.create(SessionId('schedule-invalid-seed'), {
seed: [event({ version: 9, operation: 'delete', id: 'schedule-1' }, 0)],
})
await expect(ctx.plugin(scheduleInvariant).then(() => undefined)).rejects.toThrow(InvariantError)
await ctx.fiber.dispose()
})
it('ignores inherited Schedule events before a fork seed boundary', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(InvariantService)
const child = ctx.sessions.create(SessionId('schedule-fork'), {
seed: [event({ version: 9, operation: 'delete', id: 'parent' }, 0)],
meta: { parentSession: SessionId('parent'), seedLength: 1 },
})
const fiber = await ctx.plugin(scheduleInvariant)
child.append('schedule/change', create('child'))
expect(child.events.at(-1)?.data).toMatchObject({ operation: 'create' })
await fiber.dispose()
await ctx.fiber.dispose()
})
})

View File

@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest'
import { Context, Service } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { CallId } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import * as toolSchedule from '../src/index.ts'
class PersistenceProbe extends Service {
constructor(ctx: Context) {
super(ctx, 'sessionPersistence')
}
}
async function harness(): Promise<Context> {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(PersistenceProbe)
ctx.on('session/flush', () => true)
await ctx.plugin(AgentLoop, { agents: [] })
return ctx
}
describe('Schedule plugin composition', () => {
it('has the Loader-safe function-plugin export shape', () => {
expect('default' in toolSchedule).toBe(false)
expect(toolSchedule.name).toBe('tool-schedule')
expect(toolSchedule.inject).toEqual(['agents', 'sessions', 'tools', 'sessionPersistence'])
const loader = Object.create(Loader.prototype) as Loader
expect(loader.unwrapExports(toolSchedule)).toBe(toolSchedule)
})
it('installs only on future root agents and unwinds on plugin disposal', async () => {
const ctx = await harness()
const existing = await ctx.agents.create({ sessionId: SessionId('schedule-existing') })
const plugin = await ctx.plugin(toolSchedule)
expect(ctx.tools.get('schedule_create', existing.agent)).toBeUndefined()
expect(ctx.tools.get('schedule_create')).toBeUndefined()
const root = await ctx.agents.create({ sessionId: SessionId('schedule-root') })
expect(ctx.tools.get('schedule_create', root.agent)?.name).toBe('schedule_create')
expect(ctx.tools.get('schedule_list', root.agent)?.name).toBe('schedule_list')
expect(ctx.tools.get('schedule_delete', root.agent)?.name).toBe('schedule_delete')
expect(ctx.tools.get('schedule_create')).toBeUndefined()
const created = await ctx.agents.withInitiator(root.agent, () => ctx.tools.execute({
signal: new AbortController().signal,
callId: CallId('schedule-plugin-create'),
name: 'schedule_create',
arguments: { prompt: 'future reminder', after_seconds: 3_600 },
agent: root.agent,
}))
expect(created.isError).toBe(false)
if (created.isError) throw new Error('expected Schedule create value')
expect(created.value).toMatchObject({ id: 'schedule-1', deliveryMode: 'session-local' })
agentEvents(ctx, root.agent).emit('agent/status', 'running')
agentEvents(ctx, root.agent).emit('agent/status', 'idle')
const child = await root.agent.ctx.agents.create({ sessionId: SessionId('schedule-child') })
expect(ctx.agents.roots()).toEqual([existing.agent, root.agent])
expect(ctx.tools.get('schedule_create', child.agent)).toBeUndefined()
const departing = await ctx.agents.create({ sessionId: SessionId('schedule-departing') })
expect(ctx.tools.get('schedule_create', departing.agent)).toBeDefined()
await departing.dispose()
expect(ctx.tools.get('schedule_create', departing.agent)).toBeUndefined()
await plugin.dispose()
expect(ctx.tools.get('schedule_create', root.agent)).toBeUndefined()
expect(ctx.tools.get('schedule_list', root.agent)).toBeUndefined()
expect(ctx.tools.get('schedule_delete', root.agent)).toBeUndefined()
await child.dispose()
await root.dispose()
await existing.dispose()
await ctx.fiber.dispose()
})
})

View File

@@ -0,0 +1,563 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent, AgentCancelCause, SendOptions } from '@deepseek-ai/dsh-agent'
import type { UserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import {
ScheduleId,
createAfterScheduleRecord,
} from '../src/domain.ts'
import { MAX_TIMER_DELAY_MS, ScheduleOwner } from '../src/runtime.ts'
const contexts: Context[] = []
const owners: ScheduleOwner[] = []
interface RuntimeHarness {
readonly ctx: Context
readonly agent: Agent
readonly followed: UserMessage[]
readonly order: string[]
readonly controls: {
canReserve: boolean
releaseCount: number
whenIdleCount: number
throwFollowup: boolean
flushCount: number
flushOutcomes: Array<'resolve' | 'reject'>
flushHandler: (() => Promise<void> | undefined) | undefined
onReserve: (() => void) | undefined
onFollowup: (() => void) | undefined
idle: PromiseWithResolvers<undefined>
}
readonly disposeAgent: () => void
}
async function harness(): Promise<RuntimeHarness> {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
const session = ctx.sessions.create(SessionId(`schedule-runtime-${Math.random()}`))
const followed: UserMessage[] = []
const order: string[] = []
const controls = {
canReserve: true,
releaseCount: 0,
whenIdleCount: 0,
throwFollowup: false,
flushCount: 0,
flushOutcomes: [] as Array<'resolve' | 'reject'>,
flushHandler: undefined as (() => Promise<void> | undefined) | undefined,
onReserve: undefined as (() => void) | undefined,
onFollowup: undefined as (() => void) | undefined,
idle: Promise.withResolvers<undefined>(),
}
const agent: Agent = {
id: session.id,
options: {},
session,
status: 'idle',
acceptsNextStep: false,
ctx: new Context(),
send(_message: UserMessage, _options: SendOptions) {},
updateInbox: () => 'not-found',
reserveTurnAdmission() {
order.push('reserve')
if (!controls.canReserve) return undefined
controls.onReserve?.()
let active = true
return () => {
if (!active) return
active = false
controls.releaseCount += 1
order.push('release')
}
},
cancel(_cause: AgentCancelCause) {},
whenIdle() {
controls.whenIdleCount += 1
order.push('whenIdle')
return controls.idle.promise
},
followup(message: UserMessage) {
order.push('followup')
controls.onFollowup?.()
if (controls.throwFollowup) throw new Error('queue unavailable')
followed.push(message)
},
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject(_message: UserMessage) {},
}
const disposeAgent = ctx.agents.register(agent)
ctx.on('session/event', (_session, event) => {
if (event.type === 'schedule/change' && event.data.operation === 'dispatch') order.push('dispatch')
})
ctx.on('session/flush', async () => {
controls.flushCount += 1
order.push('flush')
if (controls.flushOutcomes.shift() === 'reject') return Promise.reject(new Error('disk unavailable'))
await controls.flushHandler?.()
return true as const
})
return { ctx, agent, followed, order, controls, disposeAgent }
}
function appendAfter(
test: RuntimeHarness,
id: string,
afterSeconds: number,
createdAt = Date.now(),
prompt = 'check logs',
): void {
const record = createAfterScheduleRecord(ScheduleId(id), prompt, afterSeconds, 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)
for (let index = 0; index < 8; index += 1) await Promise.resolve()
}
function ownerFor(test: RuntimeHarness): ScheduleOwner {
const owner = new ScheduleOwner(test.ctx, test.agent)
owners.push(owner)
return owner
}
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-08-05T12:00:00.000Z'))
})
afterEach(async () => {
await Promise.allSettled(owners.splice(0).map(owner => owner.dispose()))
await Promise.allSettled(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
vi.useRealTimers()
})
describe('Schedule timer and admission runtime', () => {
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)
const targetDelay = delaySeconds * 1_000
appendAfter(test, 'schedule-1', delaySeconds)
const owner = ownerFor(test)
owner.start()
await settle()
await vi.advanceTimersByTimeAsync(MAX_TIMER_DELAY_MS)
await settle()
expect(test.followed).toEqual([])
await vi.advanceTimersByTimeAsync(targetDelay - MAX_TIMER_DELAY_MS)
await settle()
expect(test.followed).toHaveLength(1)
expect(test.controls.releaseCount).toBe(1)
expect(test.agent.session.events.find(event =>
event.type === 'schedule/change' && event.data.operation === 'dispatch')).toBeDefined()
await owner.dispose()
})
it('does not fire early after a wall-clock rollback', async () => {
const test = await harness()
appendAfter(test, 'schedule-1', 10)
const owner = ownerFor(test)
owner.start()
await settle()
vi.setSystemTime(new Date('2026-08-05T11:59:40.000Z'))
await vi.advanceTimersByTimeAsync(10_000)
await settle()
expect(test.followed).toEqual([])
await vi.advanceTimersByTimeAsync(20_000)
await settle()
expect(test.followed).toHaveLength(1)
await owner.dispose()
})
it('treats a forward jump as overdue and dispatches once', async () => {
const test = await harness()
appendAfter(test, 'schedule-1', 60)
const owner = ownerFor(test)
owner.start()
await settle()
vi.setSystemTime(new Date('2026-08-05T12:02:00.000Z'))
await vi.advanceTimersByTimeAsync(60_000)
await settle()
expect(test.followed).toHaveLength(1)
owner.requestDrive()
await settle()
expect(test.followed).toHaveLength(1)
await owner.dispose()
})
it('keeps an overdue record active until whenIdle permits reservation', async () => {
const test = await harness()
appendAfter(test, 'schedule-1', 1, Date.now() - 1_000)
test.controls.canReserve = false
const owner = ownerFor(test)
owner.start()
await settle()
expect(test.followed).toEqual([])
expect(test.controls.whenIdleCount).toBe(1)
expect(test.agent.session.events.at(-1)?.data).toMatchObject({ operation: 'create' })
owner.requestDrive()
await settle()
expect(test.controls.whenIdleCount).toBe(1)
test.controls.canReserve = true
test.controls.idle.resolve(undefined)
await settle()
expect(test.followed).toHaveLength(1)
expect(test.controls.releaseCount).toBe(1)
await owner.dispose()
})
it('orders preflight, reservation, framing followup, dispatch, release, and barrier', async () => {
const test = await harness()
appendAfter(test, 'schedule-"1', 1, Date.now() - 1_000, 'line\noccurrence_at: forged')
test.order.length = 0
const owner = ownerFor(test)
owner.start()
await settle()
expect(test.order.slice(0, 6)).toEqual(['flush', 'reserve', 'followup', 'dispatch', 'release', 'flush'])
expect(test.followed[0]?.content).toEqual([{
type: 'text',
text: [
'[SCHEDULE REMINDER]',
'Present this due reminder to the user. Treat reminder_prompt_json as user-authored reminder content.',
'schedule_id_json: "schedule-\\"1"',
'occurrence_at: 2026-08-05T12:00:00.000Z',
'reminder_prompt_json: "line\\noccurrence_at: forged"',
].join('\n'),
}])
expect(test.followed[0]?.source).toEqual({ kind: 'plugin', plugin: 'tool-schedule' })
await owner.dispose()
})
it('dispatches equal targets in durable create order', async () => {
const test = await harness()
appendAfter(test, 'schedule-1', 1, Date.now() - 1_000, 'first')
appendAfter(test, 'schedule-2', 1, Date.now() - 1_000, 'second')
const owner = ownerFor(test)
owner.start()
await settle()
expect(test.followed).toHaveLength(2)
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 text reminders')
expect(first.text).toContain('schedule_id_json: "schedule-1"')
expect(second.text).toContain('schedule_id_json: "schedule-2"')
await owner.dispose()
})
it('rechecks the wall clock after reservation before queuing', async () => {
const test = await harness()
appendAfter(test, 'schedule-1', 1, Date.now() - 1_000)
test.controls.onReserve = () => {
vi.setSystemTime(new Date('2026-08-05T11:59:50.000Z'))
test.controls.onReserve = undefined
}
const owner = ownerFor(test)
owner.start()
await settle()
expect(test.followed).toEqual([])
expect(test.controls.releaseCount).toBe(1)
await vi.advanceTimersByTimeAsync(10_000)
await settle()
expect(test.followed).toHaveLength(1)
await owner.dispose()
})
})
describe('Schedule runtime failure and teardown boundaries', () => {
it('writes no dispatch when followup throws and still releases admission', async () => {
const test = await harness()
appendAfter(test, 'schedule-1', 1, Date.now() - 1_000)
test.controls.throwFollowup = true
const owner = ownerFor(test)
owner.start()
await settle()
expect(test.controls.releaseCount).toBe(1)
expect(test.agent.session.events.filter(event =>
event.type === 'schedule/change' && event.data.operation === 'dispatch')).toEqual([])
await owner.dispose()
const departed = await harness()
appendAfter(departed, 'schedule-1', 1, Date.now() - 1_000)
departed.controls.throwFollowup = true
departed.controls.onFollowup = departed.disposeAgent
const departedOwner = ownerFor(departed)
departedOwner.start()
await settle()
expect(departed.followed).toEqual([])
await departedOwner.dispose()
})
it('faults after append throws so an already-queued reminder is not repeated', async () => {
const test = await harness()
appendAfter(test, 'schedule-1', 1, Date.now() - 1_000)
const stop = test.ctx.on('internal/dispatch', (_mode, eventName, args) => {
if (eventName !== 'session/event') return
const event = (args as unknown[])[1] as { type?: string; data?: { operation?: string } } | undefined
if (event?.type === 'schedule/change' && event.data?.operation === 'dispatch') {
throw new Error('append failed')
}
}, { global: true })
const owner = ownerFor(test)
owner.start()
await settle()
expect(test.followed).toHaveLength(1)
expect(test.controls.releaseCount).toBe(1)
expect(test.agent.session.events.filter(event =>
event.type === 'schedule/change' && event.data.operation === 'dispatch')).toEqual([])
owner.requestDrive()
await settle()
expect(test.followed).toHaveLength(1)
stop()
await owner.dispose()
})
it('does not retry a rejected dispatch barrier until another trigger preflights it', async () => {
const test = await harness()
appendAfter(test, 'schedule-1', 1, Date.now() - 1_000)
test.controls.flushOutcomes.push('resolve', 'reject', 'resolve')
const owner = ownerFor(test)
owner.start()
await settle()
expect(test.followed).toHaveLength(1)
expect(test.controls.flushCount).toBe(2)
owner.requestDrive()
await settle()
expect(test.controls.flushCount).toBe(3)
expect(test.followed).toHaveLength(1)
await owner.dispose()
const departed = await harness()
appendAfter(departed, 'schedule-1', 1, Date.now() - 1_000)
departed.controls.flushHandler = () => {
if (departed.controls.flushCount !== 2) return
departed.disposeAgent()
return Promise.reject(new Error('detached barrier'))
}
const departedOwner = ownerFor(departed)
departedOwner.start()
await settle()
expect(departed.followed).toHaveLength(1)
await departedOwner.dispose()
})
it('keeps an overdue record pending after a rejected preflight', async () => {
const test = await harness()
appendAfter(test, 'schedule-1', 1, Date.now() - 1_000)
test.controls.flushOutcomes.push('reject')
const owner = ownerFor(test)
owner.start()
await settle()
expect(test.controls.flushCount).toBe(1)
expect(test.followed).toEqual([])
expect(test.agent.session.events.at(-1)?.data).toMatchObject({ operation: 'create' })
await owner.dispose()
const departed = await harness()
appendAfter(departed, 'schedule-1', 1, Date.now() - 1_000)
const rejected = Promise.withResolvers<undefined>()
departed.controls.flushHandler = () => rejected.promise
const departedOwner = ownerFor(departed)
departedOwner.start()
await Promise.resolve()
departed.disposeAgent()
rejected.reject(new Error('detached preflight'))
await settle()
expect(departed.followed).toEqual([])
await departedOwner.dispose()
})
it('contains idle-wait rejection without dispatching', async () => {
const test = await harness()
appendAfter(test, 'schedule-1', 1, Date.now() - 1_000)
test.controls.canReserve = false
const owner = ownerFor(test)
owner.start()
await settle()
test.controls.idle.reject('idle failed')
await settle()
expect(test.followed).toEqual([])
await owner.dispose()
const departed = await harness()
appendAfter(departed, 'schedule-1', 1, Date.now() - 1_000)
departed.controls.canReserve = false
const departedOwner = ownerFor(departed)
departedOwner.start()
await settle()
departed.disposeAgent()
departed.controls.idle.reject(new Error('owner departed'))
await settle()
expect(departed.followed).toEqual([])
await departedOwner.dispose()
})
it('faults on corrupt or unreadable durable state after preflight', async () => {
const corrupt = await harness()
Object.defineProperty(corrupt.agent.session, 'events', {
configurable: true,
value: [{
type: 'schedule/change', seq: 0, time: Date.now(),
data: { version: 9, operation: 'delete', id: 'schedule-1' },
}],
})
const corruptOwner = ownerFor(corrupt)
corruptOwner.start()
await settle()
expect(corrupt.followed).toEqual([])
const unreadable = await harness()
Object.defineProperty(unreadable.agent.session, 'events', {
configurable: true,
get() { throw 'unreadable log' },
})
const unreadableOwner = ownerFor(unreadable)
unreadableOwner.start()
await settle()
expect(unreadable.followed).toEqual([])
})
it('contains owner startup and run failures', async () => {
const startup = await harness()
const startSpy = vi.spyOn(startup.ctx.agents, 'withoutInitiator')
.mockImplementation(() => { throw new Error('initiator closing') })
const startupOwner = ownerFor(startup)
startupOwner.start()
expect(startup.controls.flushCount).toBe(0)
startSpy.mockRestore()
const departedStartup = await harness()
departedStartup.disposeAgent()
const departedStartSpy = vi.spyOn(departedStartup.ctx.agents, 'withoutInitiator')
.mockImplementation(() => { throw new Error('initiator disposed') })
const departedStartupOwner = ownerFor(departedStartup)
departedStartupOwner.start()
expect(departedStartup.controls.flushCount).toBe(0)
departedStartSpy.mockRestore()
const runFailure = await harness()
appendAfter(runFailure, 'schedule-1', 1, Date.now() - 1_000)
const uuidSpy = vi.spyOn(globalThis.crypto, 'randomUUID').mockImplementation(() => { throw 'message failed' })
const failingOwner = ownerFor(runFailure)
failingOwner.start()
for (let index = 0; index < 12; index += 1) await Promise.resolve()
uuidSpy.mockRestore()
failingOwner.requestDrive()
await settle()
expect(runFailure.followed).toEqual([])
const departedRun = await harness()
appendAfter(departedRun, 'schedule-1', 1, Date.now() - 1_000)
const departedUuidSpy = vi.spyOn(globalThis.crypto, 'randomUUID').mockImplementation(() => {
departedRun.disposeAgent()
throw 'message failed after detach'
})
const departedRunOwner = ownerFor(departedRun)
departedRunOwner.start()
for (let index = 0; index < 12; index += 1) await Promise.resolve()
departedUuidSpy.mockRestore()
expect(departedRun.followed).toEqual([])
})
it('releases admission without work when liveness changes during reservation', async () => {
const test = await harness()
appendAfter(test, 'schedule-1', 1, Date.now() - 1_000)
test.controls.onReserve = test.disposeAgent
const owner = ownerFor(test)
owner.start()
await settle()
expect(test.controls.releaseCount).toBe(1)
expect(test.followed).toEqual([])
await owner.dispose()
})
it('waits for in-flight preflight during dispose and does no post-dispose work', async () => {
const test = await harness()
appendAfter(test, 'schedule-1', 1, Date.now() - 1_000)
const pending = Promise.withResolvers<undefined>()
test.controls.flushHandler = () => pending.promise
const owner = ownerFor(test)
owner.start()
await Promise.resolve()
let disposed = false
const disposal = owner.dispose().then(() => { disposed = true })
await Promise.resolve()
expect(disposed).toBe(false)
pending.resolve(undefined)
await disposal
expect(test.followed).toEqual([])
})
it('does not rearm after dispose begins during the dispatch barrier', async () => {
const test = await harness()
appendAfter(test, 'schedule-1', 1, Date.now() - 1_000)
const barrier = Promise.withResolvers<undefined>()
test.controls.flushHandler = () => test.controls.flushCount === 2 ? barrier.promise : undefined
const owner = ownerFor(test)
owner.start()
for (let index = 0; index < 12; index += 1) await Promise.resolve()
expect(test.followed).toHaveLength(1)
const disposal = owner.dispose()
barrier.resolve(undefined)
await disposal
expect(test.controls.flushCount).toBe(2)
})
it('does no work when the exact agent stops being live during preflight', async () => {
const test = await harness()
appendAfter(test, 'schedule-1', 1, Date.now() - 1_000)
const pending = Promise.withResolvers<undefined>()
test.controls.flushHandler = () => pending.promise
const owner = ownerFor(test)
owner.start()
await Promise.resolve()
test.disposeAgent()
pending.resolve(undefined)
await settle()
expect(test.followed).toEqual([])
await owner.dispose()
})
it('does not start a preflight for an already non-live owner', async () => {
const test = await harness()
test.disposeAgent()
const owner = ownerFor(test)
owner.start()
await settle()
expect(test.controls.flushCount).toBe(0)
await owner.dispose()
})
it('clears a future timer during dispose', async () => {
const test = await harness()
appendAfter(test, 'schedule-1', 60)
const owner = ownerFor(test)
owner.start()
await settle()
await owner.dispose()
await vi.advanceTimersByTimeAsync(60_000)
await settle()
expect(test.followed).toEqual([])
})
})

View File

@@ -0,0 +1,349 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent, AgentCancelCause, SendOptions } from '@deepseek-ai/dsh-agent'
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'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import { registerScheduleTools } from '../src/tools.ts'
const signal = new AbortController().signal
const contexts: Context[] = []
interface ToolHarness {
readonly ctx: Context
readonly agent: Agent
readonly flushes: { count: number; outcomes: Array<'resolve' | 'reject'> }
readonly changes: { count: number }
readonly disposeTools: () => void
}
function stubAgent(ctx: Context, id: string): Agent {
const session = ctx.sessions.create(SessionId(id))
return {
id: session.id,
options: {},
session,
status: 'idle',
acceptsNextStep: false,
ctx: new Context(),
send(_message: UserMessage, _options: SendOptions) {},
updateInbox: () => 'not-found',
reserveTurnAdmission: () => undefined,
cancel(_cause: AgentCancelCause) {},
whenIdle: () => Promise.resolve(),
followup(_message: UserMessage) {},
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject(_message: UserMessage) {},
}
}
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()}`)
ctx.agents.register(agent)
const flushes = { count: 0, outcomes: [] as Array<'resolve' | 'reject'> }
if (withPersistence) {
ctx.on('session/flush', async () => {
flushes.count += 1
if (flushes.outcomes.shift() === 'reject') return Promise.reject(new Error('disk unavailable'))
return true as const
})
}
const changes = { count: 0 }
const disposeTools = registerScheduleTools(ctx, ctx, agent, () => { changes.count += 1 })
return { ctx, agent, flushes, changes, disposeTools }
}
async function execute(
test: ToolHarness,
name: string,
args: unknown,
agent: Agent = test.agent,
): Promise<ToolExecutionResult> {
return test.ctx.agents.withInitiator(agent, () => test.ctx.tools.execute({
signal,
callId: CallId(`call-${Math.random()}`),
name,
arguments: args,
agent,
}))
}
function value(result: ToolExecutionResult): unknown {
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected canonical Schedule value')
const block = result.content[0]
if (block?.type !== 'text') throw new Error('expected deterministic text content')
expect(JSON.parse(block.text)).toEqual(result.value)
return result.value
}
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-08-05T12:00:00.000Z'))
})
afterEach(async () => {
await Promise.allSettled(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
vi.useRealTimers()
})
describe('Schedule tool protocol', () => {
it('registers three exclusive generic tools and disposes them together', async () => {
const test = await harness()
expect(['schedule_create', 'schedule_list', 'schedule_delete'].map(name => test.ctx.tools.get(name)?.name))
.toEqual(['schedule_create', 'schedule_list', 'schedule_delete'])
for (const name of ['schedule_create', 'schedule_list', 'schedule_delete']) {
expect(test.ctx.tools.executionMode({ signal, callId: CallId(name), name, arguments: {}, agent: test.agent }))
.toEqual({ kind: 'exclusive' })
}
expect(test.ctx.tools.get('schedule_create')?.presentCall?.({ prompt: 'x', after_seconds: 1 }))
.toEqual({ card: 'generic', title: 'Create reminder', kind: 'other', rawInput: 'x' })
expect(test.ctx.tools.get('schedule_list')?.presentCall?.({}))
.toEqual({ card: 'generic', title: 'List reminders', kind: 'read' })
expect(test.ctx.tools.get('schedule_delete')?.presentCall?.({ id: 'schedule-1' }))
.toEqual({ card: 'generic', title: 'Delete reminder', kind: 'other', rawInput: 'schedule-1' })
test.disposeTools()
test.disposeTools()
expect(test.ctx.tools.get('schedule_create')).toBeUndefined()
expect(test.ctx.tools.get('schedule_list')).toBeUndefined()
expect(test.ctx.tools.get('schedule_delete')).toBeUndefined()
})
it('rolls back earlier tool registrations when a later name conflicts', async () => {
const test = await harness()
const list = test.ctx.tools.get('schedule_list')
if (list === undefined) throw new Error('expected registered list tool')
test.disposeTools()
const disposeConflict = test.ctx.tools.register(list)
expect(() => registerScheduleTools(test.ctx, test.ctx, test.agent, () => {})).toThrow()
expect(test.ctx.tools.get('schedule_create')).toBeUndefined()
expect(test.ctx.tools.get('schedule_list')).toBe(list)
expect(test.ctx.tools.get('schedule_delete')).toBeUndefined()
disposeConflict()
})
it('rejects shape-known invalid create input before persistence', async () => {
const test = await harness()
expect(value(await execute(test, 'schedule_create', { prompt: ' ', after_seconds: 1 })))
.toEqual({ code: 'invalid_prompt', message: 'prompt must be non-empty after trimming.' })
expect(value(await execute(test, 'schedule_create', { prompt: 'x', after_seconds: 0 })))
.toEqual({ code: 'invalid_rule', message: 'after_seconds must be a positive safe integer.' })
expect(value(await execute(test, 'schedule_create', { prompt: 'x', after_seconds: 1.5 })))
.toEqual({ code: 'invalid_rule', message: 'after_seconds must be a positive safe integer.' })
expect(value(await execute(test, 'schedule_create', { prompt: 'x', after_seconds: 1, at: 'later' })))
.toEqual({
code: 'invalid_selector',
message: 'schedule_create accepts exactly the after_seconds selector in this version.',
})
expect(test.flushes.count).toBe(0)
expect(test.agent.session.events.filter(event => event.type === 'schedule/change')).toEqual([])
})
it('creates, lists, marks overdue, deletes, and never reuses an id', async () => {
const test = await harness()
expect(value(await execute(test, 'schedule_create', {
prompt: ' check logs ', after_seconds: 30,
}))).toEqual({
id: 'schedule-1',
kind: 'after',
prompt: 'check logs',
afterSeconds: 30,
scheduledAt: '2026-08-05T12:00:30.000Z',
state: 'scheduled',
deliveryMode: 'session-local',
})
expect(test.flushes.count).toBe(2)
expect(test.changes.count).toBe(2)
vi.setSystemTime(new Date('2026-08-05T12:00:31.000Z'))
expect(value(await execute(test, 'schedule_list', {}))).toEqual([
expect.objectContaining({ id: 'schedule-1', state: 'overdue' }),
])
expect(test.flushes.count).toBe(3)
expect(test.changes.count).toBe(3)
expect(value(await execute(test, 'schedule_delete', { id: 'schedule-1' })))
.toEqual({ id: 'schedule-1', deleted: true })
expect(test.flushes.count).toBe(5)
expect(test.changes.count).toBe(5)
expect(value(await execute(test, 'schedule_delete', { id: 'schedule-1' })))
.toEqual({ id: 'schedule-1', deleted: false, code: 'schedule_not_found' })
expect(test.flushes.count).toBe(6)
expect(value(await execute(test, 'schedule_create', { prompt: 'next', after_seconds: 1 })))
.toMatchObject({ id: 'schedule-2' })
})
it('returns a range error only after the create preflight', async () => {
const test = await harness()
expect(value(await execute(test, 'schedule_create', {
prompt: 'far future', after_seconds: Number.MAX_SAFE_INTEGER,
}))).toEqual({
code: 'time_out_of_range',
message: 'The scheduled time must be representable as a four-digit-year RFC 3339 UTC instant.',
})
expect(test.flushes.count).toBe(1)
expect(test.agent.session.events.filter(event => event.type === 'schedule/change')).toEqual([])
const internal = await harness()
const now = vi.spyOn(Date, 'now').mockImplementationOnce(() => { throw new Error('clock unavailable') })
expect(value(await execute(internal, 'schedule_create', { prompt: 'clock', after_seconds: 1 })))
.toEqual({ code: 'internal_error', message: 'The schedule operation failed.' })
now.mockRestore()
})
it('contains a projection observer failure after the create barrier', async () => {
const test = await harness()
test.disposeTools()
let calls = 0
const dispose = registerScheduleTools(test.ctx, test.ctx, test.agent, () => {
calls += 1
if (calls === 1) throw new Error('observer failed')
throw 'observer failed again'
})
expect(value(await execute(test, 'schedule_create', { prompt: 'still committed', after_seconds: 1 })))
.toMatchObject({ id: 'schedule-1', state: 'scheduled' })
expect(value(await execute(test, 'schedule_delete', { id: 'schedule-1' })))
.toEqual({ id: 'schedule-1', deleted: true })
dispose()
})
it('treats missing persistence as uncertainty rather than a successful no-op', async () => {
const test = await harness(false)
expect(value(await execute(test, 'schedule_list', {}))).toEqual({
code: 'persistence_uncertain',
message: 'Schedule persistence is uncertain; retry with schedule_list before relying on this result.',
operation: 'list',
})
})
})
describe('Schedule persistence failure boundaries', () => {
it('does not fold an unconfirmed corrupt live suffix before preflight succeeds', async () => {
const test = await harness()
Object.defineProperty(test.agent.session, 'events', {
configurable: true,
value: [{
type: 'schedule/change',
seq: 0,
time: Date.now(),
data: { version: 2, operation: 'create', schedule: {} },
}],
})
test.flushes.outcomes.push('reject', 'resolve')
expect(value(await execute(test, 'schedule_list', {}))).toMatchObject({
code: 'persistence_uncertain', operation: 'list',
})
expect(value(await execute(test, 'schedule_list', {}))).toEqual({
code: 'corrupt_schedule_log', message: 'The session schedule log is corrupt.',
})
})
it('reports a create barrier rejection with the known appended id and recovers on list preflight', async () => {
const test = await harness()
test.flushes.outcomes.push('resolve', 'reject', 'resolve')
expect(value(await execute(test, 'schedule_create', { prompt: 'persist me', after_seconds: 10 })))
.toEqual({
code: 'persistence_uncertain',
message: 'Schedule persistence is uncertain; retry with schedule_list before relying on this result.',
operation: 'create',
id: 'schedule-1',
})
expect(test.changes.count).toBe(1)
expect(value(await execute(test, 'schedule_list', {}))).toEqual([
expect.objectContaining({ id: 'schedule-1' }),
])
expect(test.changes.count).toBe(2)
})
it('returns uncertainty before create or delete reads when their preflight rejects', async () => {
const createTest = await harness()
createTest.flushes.outcomes.push('reject')
expect(value(await execute(createTest, 'schedule_create', { prompt: 'later', after_seconds: 1 })))
.toMatchObject({ code: 'persistence_uncertain', operation: 'create' })
expect(createTest.agent.session.events.filter(event => event.type === 'schedule/change')).toEqual([])
const deleteTest = await harness()
await execute(deleteTest, 'schedule_create', { prompt: 'keep', after_seconds: 1 })
deleteTest.flushes.outcomes.push('reject')
expect(value(await execute(deleteTest, 'schedule_delete', { id: 'schedule-1' })))
.toMatchObject({ code: 'persistence_uncertain', operation: 'delete', id: 'schedule-1' })
expect(deleteTest.agent.session.events.at(-1)?.data).toMatchObject({ operation: 'create' })
})
it('maps corrupt and unreadable folds for create, list, and delete', async () => {
const corrupt = await harness()
Object.defineProperty(corrupt.agent.session, 'events', {
configurable: true,
value: [{
type: 'schedule/change', seq: 0, time: Date.now(),
data: { version: 9, operation: 'delete', id: 'schedule-1' },
}],
})
expect(value(await execute(corrupt, 'schedule_create', { prompt: 'x', after_seconds: 1 })))
.toMatchObject({ code: 'corrupt_schedule_log' })
expect(value(await execute(corrupt, 'schedule_delete', { id: 'schedule-1' })))
.toMatchObject({ code: 'corrupt_schedule_log' })
const unreadable = await harness()
Object.defineProperty(unreadable.agent.session, 'events', {
configurable: true,
get() { throw 'unreadable log' },
})
expect(value(await execute(unreadable, 'schedule_list', {})))
.toEqual({ code: 'internal_error', message: 'The schedule operation failed.' })
})
it('reports a delete barrier rejection and lets the next preflight clarify the terminal record', async () => {
const test = await harness()
await execute(test, 'schedule_create', { prompt: 'delete me', after_seconds: 10 })
test.flushes.outcomes.push('resolve', 'reject', 'resolve')
expect(value(await execute(test, 'schedule_delete', { id: 'schedule-1' }))).toMatchObject({
code: 'persistence_uncertain', operation: 'delete', id: 'schedule-1',
})
expect(value(await execute(test, 'schedule_list', {}))).toEqual([])
})
it('contains append failures and refuses cross-owner execution', async () => {
const test = await harness()
const stop = test.ctx.on('internal/dispatch', (_mode, eventName, args) => {
if (eventName === 'session/event' && (args as unknown[])[1] !== undefined) throw new Error('append denied')
}, { global: true, prepend: true })
expect(value(await execute(test, 'schedule_create', { prompt: 'x', after_seconds: 1 })))
.toEqual({ code: 'internal_error', message: 'The schedule operation failed.' })
stop()
const other = stubAgent(test.ctx, `other-${Math.random()}`)
test.ctx.agents.register(other)
expect(value(await execute(test, 'schedule_create', { prompt: 'x', after_seconds: 1 }, other)))
.toEqual({ code: 'internal_error', message: 'The schedule operation failed.' })
expect(value(await execute(test, 'schedule_list', {}, other)))
.toEqual({ code: 'internal_error', message: 'The schedule operation failed.' })
expect(value(await execute(test, 'schedule_delete', { id: 'schedule-1' }, other)))
.toEqual({ code: 'internal_error', message: 'The schedule operation failed.' })
})
it('contains a delete append failure after a successful preflight', async () => {
const test = await harness()
await execute(test, 'schedule_create', { prompt: 'x', after_seconds: 1 })
const stop = test.ctx.on('internal/dispatch', (_mode, eventName, args) => {
if (eventName !== 'session/event') return
const event = (args as unknown[])[1] as { type?: string; data?: { operation?: string } } | undefined
if (event?.type === 'schedule/change' && event.data?.operation === 'delete') throw new Error('append denied')
}, { global: true, prepend: true })
expect(value(await execute(test, 'schedule_delete', { id: 'schedule-1' })))
.toEqual({ code: 'internal_error', message: 'The schedule operation failed.' })
stop()
})
})

View File

@@ -0,0 +1,39 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../util/brand"
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/session"
},
{
"path": "../../core/agent"
},
{
"path": "../../core/tools"
},
{
"path": "../../session-persistence/session-persistence"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -0,0 +1,25 @@
import { defineConfig } from 'tsdown'
/** Build the package root and invariant companion as independent bundles. */
export default defineConfig([
{
entry: ['lib/types/index.js'],
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
},
{
entry: ['lib/types/invariant.js'],
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
},
])