Kaynağa Gözat

feat(schedule): add durable after package

pku-xht 1 ay önce
ebeveyn
işleme
a229b42e24

+ 2 - 0
packages/schedule/tool-schedule/README.i18n.yaml

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

+ 84 - 0
packages/schedule/tool-schedule/README.md

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

+ 84 - 0
packages/schedule/tool-schedule/README.zh.md

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

+ 53 - 0
packages/schedule/tool-schedule/package.json

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

+ 350 - 0
packages/schedule/tool-schedule/src/domain.ts

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

+ 72 - 0
packages/schedule/tool-schedule/src/index.ts

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

+ 50 - 0
packages/schedule/tool-schedule/src/invariant.ts

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

+ 31 - 0
packages/schedule/tool-schedule/src/persistence.ts

@@ -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)
+  }
+}

+ 247 - 0
packages/schedule/tool-schedule/src/runtime.ts

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

+ 346 - 0
packages/schedule/tool-schedule/src/tools.ts

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

+ 158 - 0
packages/schedule/tool-schedule/src/types.ts

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

+ 183 - 0
packages/schedule/tool-schedule/tests/domain.spec.ts

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

+ 81 - 0
packages/schedule/tool-schedule/tests/invariant.spec.ts

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

+ 80 - 0
packages/schedule/tool-schedule/tests/plugin.spec.ts

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

+ 563 - 0
packages/schedule/tool-schedule/tests/runtime.spec.ts

@@ -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([])
+  })
+})

+ 349 - 0
packages/schedule/tool-schedule/tests/tools.spec.ts

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

+ 39 - 0
packages/schedule/tool-schedule/tsconfig.json

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

+ 25 - 0
packages/schedule/tool-schedule/tsdown.config.ts

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