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>
This commit is contained in:
oliver 2026-09-11 19:57:22 +08:00
parent b22048d377
commit 89ab5f59af
6 changed files with 382 additions and 10 deletions

View file

@ -0,0 +1,105 @@
# 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` 替换)。