* feat(schedule): manage Host tasks with typed timing and delivery targets * fix(schedule): align task creation button halves * refactor(schedule): remove obsolete Session reminder runtime * refactor(schedule): simplify task UI and preserve keyboard navigation * fix(schedule): address task delivery and interface review findings * test(schedule): normalize bundle module paths on Windows * fix(schedule): cover reserved targets and trim unrelated changes * refactor(schedule): minimize task management changes after rebase * test(schedule): align rebased fixtures and documentation records * test(schedule): refresh SDK scenarios added on master --------- Co-authored-by: Magolor <[email protected]>
23 KiB
宿主级 Schedule
English | 中文
Schedule 独立于 Session 的激活状态存储宿主任务。同一管理 API 按 id 操作任务,同一求值器处理七种权威时间类型:after、at、every、daily、weekly、cron 和 rrule。目标可以复用已有 Session、创建一个 Session 后复用,或每次投递创建 Session。包 README 负责组合与提醒格式;完整值类型和请求类型以 types.ts 为准。
持久任务
插件 README 拥有当前 v2 任务 schema、v1 默认值与持久 JSON 示例。领域保留名称、路由和整单元布局。七种时间类型均保持权威性;规范化保留已提交发生时点,不将 Cron 转为 RRULE。
损坏的存储、无效行、与任务身份不一致的键及不支持的版本在任务激活前拒绝。打开失败不改变介质。文件不存在时从空域开始。目标 Session 缺失的任务仍在管理界面中可见;读取任务从不激活其 Session。
存储版本
README 定义任务何时升级、SQLite 不变的物理 schema、修改成本以及未来兼容 v3。compatibleVersions 允许旧单元版本戳进入当前解码;正常编辑与投递持久化当前行。读取保持存储不变。共享的领域版本策略同时适用于 JSON 和 SQLite。
重复规则
dsh-util-time 负责可复用的 Cron、RRule 解析及有界发生时点计算。两种解析值均暴露其权威表达式、时区、next(after) 和 resolve(scheduledAt, acceptedAt)。解析器返回接受值或拒绝原因;Schedule 将原因映射为自身输入错误,并负责记录验证、准入限制、持久化与投递。
每个已存 scheduledAt 都是带毫秒精度、年份为 0001 至 9999 的规范 RFC 3339 UTC 时刻。解码不会根据当前时区数据重新计算它。只追赶最新一次的策略保留该已提交下界,投递最新到期时点一次,再提交第一个未来时点。规则耗尽后任务以 ended 状态保留。
| 类型 | 权威时间字段 |
|---|---|
after |
正整数 afterSeconds 和已提交的一次性 UTC 目标。 |
at |
已提交的一次性 UTC 目标。输入接受显式偏移时刻,或本地日期、时间与显式时区。 |
every |
不小于 60 的整数 everySeconds;发生时点始终对齐已提交的间隔锚点。 |
daily |
HH:mm:ss.SSS 形式的本地 time 与显式 timeZone。 |
weekly |
Daily 字段加非空、唯一、升序的 ISO weekdays 集合,周一为 1、周日为 7。 |
cron |
规范五字段 expression 与显式 timeZone。 |
rrule |
rrule 中一条规范 DTSTART 和一条 RRULE 行;DTSTART 提供锚点与时区。 |
本地日历规则跳过不存在的墙钟时间,并在重叠时选择较早的时刻。固定间隔保留经过时间的含义。一次性延迟可短于一分钟;首次投递之后仍适用单任务最小投递间隔。
绝对时间输入
at 选择器接受带显式 Z 或数字偏移的 RFC 3339 时刻,或 { date, time, time_zone }。本地日期使用 YYYY-MM-DD;本地时间使用 HH:mm:ss,可带一至三位小数秒。歧义或不存在的日历值通过共享时间工具验证。新的绝对目标必须严格位于未来且可表示。
每日与每周输入
Daily 输入提供 { time, time_zone };Weekly 另加显式 weekdays。创建时选择第一个严格位于未来的实际发生时点。追赶选择最新实际到期时点,并推进到之后匹配的本地日期。等价编辑保留已存 IANA 别名和固定 UTC 目标,包括毫秒。
固定频率输入与追赶
Every 输入提供 every_seconds。新间隔从接受时间开始;修改间隔会在编辑被接受时建立新锚点。不变的间隔或名称/指令编辑保留原锚点。停机后合并错过的间隔,而非逐次重放。
Cron 输入
Cron 输入提供 { expression, time_zone }。五个数字字段依次为分钟、小时、月内日期、月份和星期。支持的原子形式为 *、单值、闭区间、星号或区间步长,以及逗号分隔列表。周日接受 0 或 7,规范为 0。拒绝名称、宏、秒字段和单值后跟步长的形式。
Cron 保留 Vixie 的日期匹配方式:两个日期字段都受限时,月内日期或星期任意一个匹配即可;任一字段以 * 开头(包括带步长的星号)时,两组匹配值均须匹配。规范化保留此差异。搜索受一个 400 年公历周期和四位 UTC 年份范围约束。启用时拒绝没有可表示未来时点的规则。
Cron 记录从不转换为 RRULE。读取、描述性编辑、重新指定目标和重启均保留其已存表达式与时区作为权威来源。自定义编辑器接受五字段 Cron 表达式,并显示显式时区选择器。
RRULE 输入
RRULE 接受有界的 RFC 5545 子集,以共享的 rrule.ts 解析器为准。输入恰好包含两条非空内容行:先 DTSTART,再 RRULE。开始时间为真实的 YYYYMMDDTHHMMSS 日期时间,使用 UTC Z 或显式 IANA TZID;拒绝浮动本地时间、数字偏移、仅日期值及额外内容行。引擎使用 rrule 2.8.1,支持 MINUTELY、HOURLY、DAILY、WEEKLY、MONTHLY 和 YEARLY。一次性投递使用 COUNT=1。
只接受 FREQ、INTERVAL、COUNT、UNTIL、WKST、BYSECOND、BYMINUTE、BYHOUR、BYDAY、BYMONTHDAY、BYYEARDAY、BYWEEKNO、BYMONTH 和 BYSETPOS;字段名和列表值必须唯一。INTERVAL 为正安全整数。WKST 为 MO 至 SU 中的星期,默认 MO。数字列表须在解析器规定的范围内。分钟/小时规则不接受 BY 部分。BYMONTHDAY 和带序数的 BYDAY 仅用于每月或每年;BYYEARDAY 和 BYWEEKNO 仅用于每年。带序数的 BYDAY 不能与 BYWEEKNO 组合,BYSETPOS 必须搭配另一项 BY。
验证发生在枚举之前。拒绝 SECONDLY、多个 BYSECOND 值及一个频率周期中超过 10,080 个可能候选时点的规则。COUNT 为 1 至 1,000,不能与 UNTIL 共存;UNTIL 为以 Z 结尾的绝对 UTC 日期时间。开始与截止年份为 0100 至 9999。集合位置至多 31 个,每个位置为 −366 至 −1 或 1 至 366,且必须在有界周期内。较严格的新规则编写检查还会在周期至多有一个候选时点时拒绝 BYSETPOS;解码和恢复未改变的受支持规则仍使用正常求值器。
不存在的本地 DTSTART 会被拒绝。后续不存在的本地发生时点会跳过,且不消耗 COUNT;重叠时间选择较早时刻。即使正负集合位置选中同一时点,COUNT 也仅计数一次。
自定义 RRULE 文本保留其 DTSTART 时区。面向模型的工具继续指导并接受 RRULE;共享管理 API 则显式接受每种时间类型。
投递目标
| 目标 | 投递行为 |
|---|---|
session |
投递到一个已有 Session。启用时在管理 FIFO 内检查其存在、未归档且不属于委托子 Agent。投递时再次检查归档状态。 |
new_once |
首次投递前预留 Session id,需要时创建该 Session,然后保存为 session 并复用。重试和重启保留该预留标识。 |
new |
每次投递创建 Session。创建前持久化 pendingDelivery.targetSessionId,使失败尝试重用同一 Session。 |
创建时省略目标则默认 new_once。新增或替换 new、new_once 目标需要嵌入环境提供 Session 创建能力。SDK 组合缺少该能力时,在保存前以 invalid_rule 拒绝这些请求;已有 session 目标仍可使用。创建值 cwd 和 agentPreset 可省略,使用 Host 默认值。创建的 Session 会加入其目录已注册的 Workspace(如有),且尚无标题时使用任务标题;new 另加该规则时区中的创建分钟。加入或重命名失败会记录日志,但不阻断投递。
任务管理
所有调用方使用相同的 ScheduleCreateRequest、ScheduleCatalogEntry、ScheduleTiming 和 ScheduleEditRequest。浏览器安全类型来自 @deepseek-ai/dsh-schedule/client。不存在 Session 绑定的管理入口或第二套类型别名。
| 操作 | 行为 |
|---|---|
create(request, actorSessionId?, signal?) |
分配 id 并保存标题、指令、带类型的 timing 及可选目标。 |
list(request?) |
分页读取;默认 100、最多 500,可选 active 过滤及非负 offset。 |
catalog() / view({ id }) |
无分页上限读取所有任务,或读取一个任务。 |
history({ id, limit, before? }) |
不激活 Session 地读取已保存投递;limit 为 1 至 100。 |
edit(request, actorSessionId?, signal?) |
按 id 原子应用 update patch 或 delete。 |
deleteInactive() |
一次批量写入仅删除 ended 任务,保留活动和暂停任务,返回 { deleted }。空集合不写入。 |
run({ id }, signal?) |
经正常准入立即投递,不改变已计划发生时点或生命周期。 |
deliveryLimits() |
读取 new 目标的配置最小间隔。 |
Remote 路由提供 create、list、catalog、view、history、edit、deleteInactive、run 和 deliveryLimits;传输包装器委托给相同管理方法。Remote 验证失败使用带 Schedule 输入码的 schedule/input-invalid。原生调用方收到 ScheduleInputError。
非空的 deleteInactive 批量写入成功后发出 schedule/changed,供读取方刷新;不向 Agent 排入通知。批量写入被拒绝时也发出该事件,让读取方丢弃旧状态。正常失败保留全部行;unknown-state 失败可能已移除行,并隔离领域直到重新打开。失败调用以拒绝结束,不返回删除数量。
更新可以替换标题、指令、带类型时间、目标或活动状态。省略字段保留原值。规则改变或显式恢复时,在 FIFO 接受时刻计算未来时点;等价时间及描述性编辑保留已提交目标。已投递的一次性任务需要新的未来时间才能恢复。重新指定目标不要求转换时间类型。
expectedUpdatedAt 是可选的乐观比较。字符串与已存修订比较;显式 null 与缺失的修订比较。不一致时返回 schedule_conflict。编辑没有修订的行会创建修订;即使时钟回拨,后续修订仍单调递增。开始写入前检查取消,已经开始的持久写入不回滚。
时间编辑
timing 判别字段只选择其匹配的载荷,例如 { kind: "cron", cron: { expression: "0 9 * * 1-5", time_zone: "Asia/Shanghai" } } 或 { kind: "rrule", rrule: "DTSTART:20990101T090000Z\nRRULE:FREQ=DAILY" }。其他选择器使用 after_seconds、at、every_seconds、daily 或 weekly。持久化前拒绝无效组合。UI 经相同 API 保存带类型的 Cron/RRULE 输入,并保留未修改的时间规则。
已保存投递记录
回执记录 scheduledAt、deliveredAt、messageId 和 targetSessionId;它确认持久 Session 收件箱写入,而非模型完成。已保存记录还可含不可变的已发送 prompt。旧回执缺少目标 id 时,在任何重新指定目标的编辑之前使用已解析的可复用目标,因此后续目标不会重写其历史。 回执既没有显式目的地也没有已知复用目标时,拒绝解码;新默认预留不能证明历史目的地。
新任务从已知的空历史开始。旧行没有 deliveryHistory 时,暴露其最新回执(如有),并设置 earlierRecordsUnavailable: true;不会编造丢失的历史或指令快照。省略的 earlierRecordsPruned 读为 false。消息 id 重复,或最新保存记录与 lastDelivery 不一致,均拒绝解码。
历史按追加顺序排列,不受时钟回拨影响。确认在同一任务提交中追加并裁剪,默认保留最多 200 条、30 天,同时保留最新回执。查询页从新到旧,使用不含游标自身的消息 id 游标。硬删除移除任务及历史,不移除已入队的 Session 消息。
宿主投递
一个 Host 定时器驱动活动任务。运行时与管理写入共用一个 FIFO。已有和预留 Session 通过 schedule/resolve-agent 解析,由嵌入环境拥有激活,已注入的 Web 控制器作为后备。复用 Session 的任务占用执行槽,直到目标 Agent 空闲;期间发生的时点会合并。new 目标允许多个执行并行。到期且投向相同已有 Session 的重复记录共享一条消息;after 和 at 单独投递。
定时与手动投递均遵守单任务 60 秒最小间隔。newSessionIntervalSeconds 可为 new 目标提高间隔,最多一周。已存后继时点保持间隔时,下界从上次发生时点计算;否则从上次确认计算,因此投递延迟不会移动规则锚点。读取仅在适用时暴露 executionActive 和 deliveryFloorEndsAt。
Session 收件箱持久化先于任务回执写入,因此两份存储之间崩溃可能重复投递。Host 必须运行。指向某 Session 的活动任务会参与其归档准入;归档停止操作会删除这些活动任务。
管理变更提交后,Schedule 向除操作者之外的每个已加载相关 Agent 排队发送尽力而为的通知。通知加入下一模型步骤,不开启新轮次;其 Session I/O 不占用管理/投递队列。每次投递新建的 Session 不会累积到相关元数据中。
历史 Session 变更
历史版本 1 的 schedule/change 事件保留冻结的 LegacyScheduleRecord 类型:after、at、every,包括原来的可选标题。它们仍是已保存的 Session 数据,Schedule 不为其保留解释器、警告钩子、导入器或定时器。schedule.json 中已有的 Host 任务仍使用正常的解码器与求值器。
Cordis API
Generated from source by scripts/gen-cordis-catalog.ts (verified fresh by pnpm run verify-cordis-catalog in doc-sync; regenerate with pnpm run gen-cordis-catalog) — the language sides differ only in locale-specific paired document paths. Signature blocks use a ts cordis-catalog fence and keep the original source JSDoc; dispatch modes are defined in the primer, and the framework-inherited ctx API lives in cordis-api/inherited.md.
ctx.schedule — ScheduleService
Shared management service; reads and management writes never activate a Session, and only delivery creates or resumes one.
sessionPersistence supplies header-only target validation and the session/flush listener that acknowledges durable inbox delivery through ctx.sessions.flush().
/** Read effective delivery limits without loading or activating a task.
* @returns The configured minimum spacing between deliveries that each create a Session, in seconds.
*/
@Remote('deliveryLimits') deliveryLimits(): { readonly newSessionIntervalSeconds: number }
/**
* Create one task with a Host-generated identity for a human Remote caller.
* @param request - Title, prompt, recurrence, and delivery destination.
* @param signal - Cancellation checked before the durable write begins, including after FIFO waits.
* @returns The stored task. Cancellation does not roll back an in-flight write.
*/
@Remote('create') async remoteCreate(request: ScheduleCreateRequest, signal?: AbortSignal): Promise<ScheduleCatalogEntry>
/**
* Create one task and record the acting Agent's Session as involved; the Remote form omits that Session.
*
* The record is built from the clock reading taken before the request joins the serialized queue. A `session`
* target must exist and must not belong to a delegated child; a `new_once` target reserves its Session identity.
* @param request - Title, prompt, recurrence, and delivery destination.
* @param actorSessionId - Session of the acting Agent, or undefined for a human caller.
* @param signal - Cancellation checked before the durable write begins, including after FIFO waits.
* @returns The stored task. Cancellation does not roll back an in-flight write.
*/
async create( request: ScheduleCreateRequest, actorSessionId?: SessionId, signal?: AbortSignal, ): Promise<ScheduleCatalogEntry>
/**
* Read one page of Host tasks without activating target Sessions.
* @param request - Optional activity filter, page size, and offset.
* @returns The requested page and total matching task count.
*/
async list(request: ScheduleListRequest = {}): Promise<ScheduleListResult>
/**
* Remote form of the shared Host-wide list.
* @param request - Optional activity filter, page size, and offset.
* @returns The requested page and total matching task count.
*/
@Remote('list') async remoteList(request: ScheduleListRequest): Promise<ScheduleListResult>
/**
* Read every Host task without a page bound or Session activation.
* @returns All tasks, active first, with target and execution metadata.
*/
@Remote('catalog') async catalog(): Promise<ScheduleCatalogEntry[]>
/**
* Read one task without activating its target Sessions.
* @param request - Task identity.
* @returns The task, or a non-mutating miss.
*/
@Remote('view') async view(request: ScheduleViewRequest): Promise<ScheduleViewResult>
/**
* Read one page of saved deliveries without activating or reading a target Session.
* @param request - Task identity, explicit page size, and optional exclusive cursor.
* @returns Newest-first saved deliveries, or a task/cursor lookup failure.
*/
async history(request: ScheduleDeliveryHistoryRequest): Promise<ScheduleDeliveryHistoryResult>
/**
* Remote form of the target-based {@link ScheduleService.history}; an invalid limit rejects with
* `schedule/input-invalid`.
* @param request - Task identity, explicit limit, and optional exclusive message cursor.
* @returns Newest-first deliveries in append order, or a task/cursor lookup failure.
*/
@Remote('history') async remoteHistory(request: ScheduleDeliveryHistoryRequest): Promise<ScheduleDeliveryHistoryResult>
/**
* Deliver one task now without consuming or moving its scheduled occurrence; an archived destination Session rejects
* with `schedule/target-archived`.
* @param request - Task identity.
* @param signal - Cancellation checked after entering the delivery transaction and before delivery.
* @returns Task state after Session persistence acknowledged the delivery, `schedule_execution_pending` while
* another execution of the task is queued, running, or inside its delivery floor, or a non-mutating miss.
*/
@Remote('run') async run(request: ScheduleRunRequest, signal?: AbortSignal): Promise<ScheduleRunResult>
/**
* Delete every ended Host task, leaving active tasks armed.
*
* Ended tasks never schedule another delivery, so the removal changes no pending
* target and no timer is recomputed. Rows are enumerated and removed in one FIFO
* slot, so a delivery ordered behind this call ends its task after the
* enumeration rather than during it. The batch is one domain write: a normal
* failure rejects with every listed row still stored, while a backend
* `unknown-state` failure may have removed rows and quarantines the Host
* domain, so later schedule operations reject until the domain is reopened.
* A successful nonempty batch emits `schedule/changed` without Agent notices.
* A rejected batch announces `schedule/changed` because it does not establish
* which rows landed, and clients then re-read the catalog.
* @returns Count of removed rows; zero when the Host held no ended task.
*/
@Remote('deleteInactive') async deleteInactive(): Promise<ScheduleDeleteInactiveResult>
/**
* Update or delete one task for a human Remote caller; an invalid patch rejects with `schedule/input-invalid`.
* @param request - Task identity and one update or delete operation.
* @param signal - Cancellation checked before the durable write begins, including after FIFO waits.
* @returns The updated task, the deletion outcome, or a non-mutating miss or conflict.
*/
@Remote('edit') async remoteEdit(request: ScheduleEditRequest, signal?: AbortSignal): Promise<ScheduleEditResult>
/**
* Update or delete one task and record the acting Agent's Session as involved; the Remote form omits that Session.
*
* An update applies every patch field in one write and recomputes the next occurrence when the rule changes or
* the task resumes. A supplied `expectedUpdatedAt` that differs from the stored value returns `schedule_conflict`.
* @param request - Task identity and one update or delete operation.
* @param actorSessionId - Session of the acting Agent, or undefined for a human caller.
* @param signal - Cancellation checked before the durable write begins, including after FIFO waits.
* @returns The updated task, the deletion outcome, or a non-mutating miss or conflict.
*/
async edit( request: ScheduleEditRequest, actorSessionId?: SessionId, signal?: AbortSignal, ): Promise<ScheduleEditResult>
Types: SessionId
Source: packages/schedule/schedule/src/index.ts
schedule/* events
schedule/changed — emit
Durable task set changed; clients refetch global task and Session-active catalogs.
/** Durable task set changed; clients refetch global task and Session-active catalogs.
* @mode emit
*/
'schedule/changed'(): void
Source: packages/schedule/schedule/src/types.ts
schedule/resolve-agent — waterfall
Restore a reminder's Session through its embedding's owning controller. Call next() to use the Web Session controller.
/**
* Restore a reminder's Session through its embedding's owning controller.
* Call next() to use the Web Session controller.
* @param sessionId - Original Session receiving the reminder.
* @mode waterfall
*/
'schedule/resolve-agent'(sessionId: SessionId, next: () => Promise<Agent>): Promise<Agent>