dsh-ops-cron/docs/cron_reschedule原语详设.md
oliver 89ab5f59af Add cron_reschedule so pending one-shots can be moved without delete/recreate.
Keeps retrigger for consumed jobs only; agents can advance or delay nextRunAt safely.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 19:57:22 +08:00

105 lines
4.7 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# cron_reschedule 原语详设
**状态**:已在本包落地(`rescheduleJob` + `cron_reschedule` + `POST /jobs/:id/reschedule`)
**包**:`dsh-ops-cron`(本仓库可读源码,非混淆服务)
**背景**:一次性 `at` 任务在仍有 `nextRunAt`(pending)时,`cron_retrigger` 会拒绝;Agent 只能删重建,体验差。
---
## 1. 结论
**改 pending 可以做,且应做。**
语义是「把预定的那一次触发改期/提前」,不是「再开一次并发 run」。比放开 `cron_retrigger` 更精确、更安全。
| 方案 | 做法 | 评价 |
|------|------|------|
| A | 改 `cron_retrigger`:有 pending 时也接受,行为=抢占改期 | 可行,但混淆「已消费再触发」与「未到期改期」 |
| **B(推荐)** | **新增 `cron_reschedule`** | 不碰 retrigger 语义;最小侵入、向后兼容 |
---
## 2. 现状(代码事实)
- `isRetriggerable` / `retriggerJob`:one-shot 仅当 `nextRunAt == null` 且 `lastStatus` 终态才可 retrigger(`lib/monitor.js`、`lib/host.js`)。
- pending 拒绝文案已写明:`one-shot still has a pending next run; wait or edit the schedule instead of retrigger`。
- **侧栏 / HTTP 已能改期**:`PATCH /jobs/:id` → `updateJob({ schedule })`;若 patch 了 `schedule`,会经 `createJobRecord` **重算 `nextRunAt`**(`host.js` 约 698–700 行:仅当未改 schedule/enabled 才保留旧 next)。
- **缺口**:Agent 工具面没有 `cron_update` / `cron_reschedule`,只有 create / list / pause / resume / retrigger / delete。
因此:不必「热改运行中混淆服务」;在本包加工具(可选再加 host 薄封装)即可落地。
---
## 3. 推荐 API:`cron_reschedule`
### 3.1 签名
```text
cron_reschedule({
id | task_id: string, // 必填
after_minutes?: number, // ≥0;与 at 二选一(优先 after_minutes)
at?: string, // ISO 或与 cron_create 一致的本地时间语义
timezone?: string // 可选;默认沿用 job.schedule.timezone
})
```
### 3.2 行为
1. 仅 `schedule.kind === 'at'`。
2. 仅当 **仍有 pending next**(`nextRunAt != null`)且 **无 active run**(queued/running)。
3. 计算新触发时刻 `T`(`after_minutes=0` → 立即 due;`>0` → now+N;`at` → 解析后的绝对时间)。
4. `T` 必须 ≥ now(允许 0 表示立刻进入 due,由现有 tick/`run-now` 路径消费;不要另开第二条 pending)。
5. 写回:
- `schedule = { kind: 'at', at: ISO(T), timezone }`
- `nextRunAt = T`
- `enabled` 保持不变(paused 允许改 next,**不隐式 resume**)
6. **不**调用 `dispatchRun`(除非产品明确要 `fire_now=true`;默认不火,交给调度器)。
7. 返回 `{ ok, job, nextRunAt, previousNextRunAt }`。
### 3.3 拒绝条件(稳定 error.code)
| 条件 | code |
|------|------|
| 非 one-shot | `INVALID_RESCHEDULE` |
| `nextRunAt == null`(已消费) | `INVALID_RESCHEDULE` → 提示改用 `cron_retrigger` |
| 已有 queued/running | `ALREADY_RUNNING` |
| 时间非法 / 过去 | `INVALID_RESCHEDULE` |
| 周期 cron | `INVALID_RESCHEDULE`(周期改期另议,勿混进本原语) |
### 3.4 与 retrigger 分工(写进 tool description + skill)
| 状态 | 用哪个 |
|------|--------|
| one-shot 已跑完(`next=n/a`,`retriggerable=true`) | `cron_retrigger` |
| one-shot 仍在等(有 `nextRunAt`) | **`cron_reschedule`** |
| recurring 立刻多跑一次 | `cron_retrigger`(不改 cron 表达式) |
---
## 4. 实现落点(本仓库)
1. **`lib/host.js`**:新增 `rescheduleJob(jobId, opts, identity)`(或在 `updateJob` 外包一层校验);可复用 `scheduleFromArgs` / `nextFire`。
2. **`lib/tools.js`**:注册 `cron_reschedule`;更新 `scheduled-tasks` skill 文案(禁止删重建来改期)。
3. **HTTP(可选)**:`POST /jobs/:id/reschedule`,与 PATCH 并存;Agent 走工具即可。
4. **测试**:`host.test.js` / `tools.test.js`
- pending → after_minutes=1 → `nextRunAt` 前移
- pending + active run → 拒绝
- consumed → 拒绝并指向 retrigger
- cron kind → 拒绝
- paused pending → 只改 next,仍 `enabled=false`
工作量小:调度内核已支持「改 schedule ⇒ 新 next」;主要是 **Agent 可发现原语 + 边界校验**。
---
## 5. 明确不做
- 不对 pending one-shot 放开无条件 `cron_retrigger`(避免与「已消费再触发」心智冲突)。
- 不引入第二条并发 pending。
- 不把 reschedule 做成隐式 start(paused 保持 paused)。
---
## 6. 临时绕过(原语未上线前)
- **人**:侧栏编辑任务时间 → Save(已走 PATCH)。
- **Agent**:无工具时只能 delete + create(应在 skill 里标明为 workaround,待 `cron_reschedule` 替换)。