mirror of
https://github.com/hansjone/dsh-ops-cron.git
synced 2026-10-08 22:00:46 +08:00
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:
parent
b22048d377
commit
89ab5f59af
6 changed files with 382 additions and 10 deletions
105
docs/cron_reschedule原语详设.md
Normal file
105
docs/cron_reschedule原语详设.md
Normal 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` 替换)。
|
||||||
113
lib/host.js
113
lib/host.js
|
|
@ -766,7 +766,7 @@ export function createHostService(options = {}) {
|
||||||
|
|
||||||
// one-shot
|
// one-shot
|
||||||
if (job.nextRunAt != null) {
|
if (job.nextRunAt != null) {
|
||||||
const error = new Error('one-shot still has a pending next run; wait or edit the schedule instead of retrigger')
|
const error = new Error('one-shot still has a pending next run; use cron_reschedule (or PATCH schedule) instead of retrigger')
|
||||||
error.code = 'INVALID_RETRIGGER'
|
error.code = 'INVALID_RETRIGGER'
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
@ -822,6 +822,106 @@ export function createHostService(options = {}) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move a pending one-shot's next fire time (does not start a second concurrent run).
|
||||||
|
* Prefer this over delete+create when nextRunAt is still set.
|
||||||
|
* Does not change enabled (paused stays paused). Does not dispatch.
|
||||||
|
*/
|
||||||
|
async function rescheduleJob(jobId, opts = {}, identity = null) {
|
||||||
|
const afterRaw = opts.afterMinutes ?? opts.after_minutes
|
||||||
|
const hasAfter = afterRaw !== undefined && afterRaw !== null && afterRaw !== ''
|
||||||
|
const afterMinutes = hasAfter ? Number(afterRaw) : null
|
||||||
|
const atRaw = typeof opts.at === 'string' ? opts.at.trim() : ''
|
||||||
|
const tzOpt = typeof opts.timezone === 'string' && opts.timezone.trim()
|
||||||
|
? opts.timezone.trim()
|
||||||
|
: (typeof opts.time_zone === 'string' && opts.time_zone.trim() ? opts.time_zone.trim() : '')
|
||||||
|
|
||||||
|
if (hasAfter && (!Number.isFinite(afterMinutes) || afterMinutes < 0)) {
|
||||||
|
const error = new Error('after_minutes must be >= 0')
|
||||||
|
error.code = 'INVALID_RESCHEDULE'
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (!hasAfter && !atRaw) {
|
||||||
|
const error = new Error('provide after_minutes or at')
|
||||||
|
error.code = 'INVALID_RESCHEDULE'
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = now()
|
||||||
|
let previousNextRunAt = null
|
||||||
|
|
||||||
|
await withState((current) => {
|
||||||
|
const job = getJob(current, jobId)
|
||||||
|
if (!job) {
|
||||||
|
const error = new Error('job not found')
|
||||||
|
error.code = 'NOT_FOUND'
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (identity) assertCanAccessJob(job, identity)
|
||||||
|
if (job.schedule?.kind !== 'at') {
|
||||||
|
const error = new Error('cron_reschedule only applies to one-shot (at) jobs')
|
||||||
|
error.code = 'INVALID_RESCHEDULE'
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (job.nextRunAt == null) {
|
||||||
|
const error = new Error('one-shot has no pending next run; use cron_retrigger after it finishes')
|
||||||
|
error.code = 'INVALID_RESCHEDULE'
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const runs = (current.runs || []).filter((run) => run?.jobId === job.id)
|
||||||
|
if (runs.some((run) => ACTIVE_RUN_STATUSES.has(run.status))) {
|
||||||
|
const error = new Error('job already has a pending or running instance')
|
||||||
|
error.code = 'ALREADY_RUNNING'
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const timezone = tzOpt
|
||||||
|
|| job.schedule?.timezone
|
||||||
|
|| current.settings?.timezone
|
||||||
|
|| 'Asia/Shanghai'
|
||||||
|
let atMs
|
||||||
|
if (hasAfter) {
|
||||||
|
atMs = t + Math.round(afterMinutes * 60_000)
|
||||||
|
} else {
|
||||||
|
const validated = validateSchedule({ kind: 'at', at: atRaw, timezone }, timezone)
|
||||||
|
atMs = validated.at
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(atMs)) {
|
||||||
|
const error = new Error('invalid reschedule time')
|
||||||
|
error.code = 'INVALID_RESCHEDULE'
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
// Align with createJobRecord: slight past (≤60s) → fire ASAP; older → reject.
|
||||||
|
if (atMs < t - 60_000) {
|
||||||
|
const error = new Error(`reschedule time is already in the past (now is ${new Date(t).toISOString()})`)
|
||||||
|
error.code = 'INVALID_RESCHEDULE'
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (atMs < t) atMs = t
|
||||||
|
|
||||||
|
previousNextRunAt = job.nextRunAt
|
||||||
|
return upsertJob(current, {
|
||||||
|
...job,
|
||||||
|
schedule: {
|
||||||
|
kind: 'at',
|
||||||
|
at: new Date(atMs).toISOString(),
|
||||||
|
timezone,
|
||||||
|
},
|
||||||
|
nextRunAt: atMs,
|
||||||
|
updatedAt: t,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const state = await snapshot()
|
||||||
|
const job = getJob(state, jobId)
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
job: jobView(job, state.runs),
|
||||||
|
nextRunAt: job?.nextRunAt ?? null,
|
||||||
|
previousNextRunAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteJob(jobId) {
|
async function deleteJob(jobId) {
|
||||||
await withState((current) => {
|
await withState((current) => {
|
||||||
if (!getJob(current, jobId)) {
|
if (!getJob(current, jobId)) {
|
||||||
|
|
@ -1135,7 +1235,7 @@ export function createHostService(options = {}) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const jobMatch = path.match(new RegExp(`^${API_PREFIX}/jobs/([^/]+)(/run|/pause|/resume|/retrigger)?$`))
|
const jobMatch = path.match(new RegExp(`^${API_PREFIX}/jobs/([^/]+)(/run|/pause|/resume|/retrigger|/reschedule)?$`))
|
||||||
if (jobMatch) {
|
if (jobMatch) {
|
||||||
const jobId = decodeURIComponent(jobMatch[1])
|
const jobId = decodeURIComponent(jobMatch[1])
|
||||||
const rest = jobMatch[2] || ''
|
const rest = jobMatch[2] || ''
|
||||||
|
|
@ -1170,6 +1270,12 @@ export function createHostService(options = {}) {
|
||||||
write(200, result)
|
write(200, result)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (method === 'POST' && rest === '/reschedule') {
|
||||||
|
const body = await readJsonBody(req).catch(() => ({}))
|
||||||
|
const result = await rescheduleJob(jobId, body || {}, identity)
|
||||||
|
write(200, result)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (method === 'POST' && rest === '/pause') {
|
if (method === 'POST' && rest === '/pause') {
|
||||||
const job = await pauseJob(jobId, false, identity)
|
const job = await pauseJob(jobId, false, identity)
|
||||||
write(200, { ok: true, job })
|
write(200, { ok: true, job })
|
||||||
|
|
@ -1325,7 +1431,7 @@ export function createHostService(options = {}) {
|
||||||
if (code === 'ALREADY_RUNNING') {
|
if (code === 'ALREADY_RUNNING') {
|
||||||
return write(409, { ok: false, error: error.message, code, message: error.message })
|
return write(409, { ok: false, error: error.message, code, message: error.message })
|
||||||
}
|
}
|
||||||
if (code === 'INVALID_CRON' || code === 'INVALID_AT' || code === 'INVALID_SCHEDULE' || code === 'INVALID_JOB' || code === 'INVALID_TIMEZONE' || code === 'INVALID_CWD' || code === 'INVALID_DELIVERY' || code === 'IM_DELIVERY_FORBIDDEN' || code === 'IM_TARGET_FORBIDDEN' || code === 'IM_JOB_MISSING_CWD' || code === 'INVALID_WATCH' || code === 'INVALID_PROGRESS' || code === 'INVALID_REPORT' || code === 'INVALID_PERSIST_HISTORY' || code === 'INVALID_PROGRESS_PATH' || code === 'INVALID_ARCHIVE' || code === 'ARCHIVE_FAILED' || code === 'ARCHIVE_UNAVAILABLE' || code === 'INVALID_RETRIGGER' || code === 'INVALID_PERMISSION_PRESET') {
|
if (code === 'INVALID_CRON' || code === 'INVALID_AT' || code === 'INVALID_SCHEDULE' || code === 'INVALID_JOB' || code === 'INVALID_TIMEZONE' || code === 'INVALID_CWD' || code === 'INVALID_DELIVERY' || code === 'IM_DELIVERY_FORBIDDEN' || code === 'IM_TARGET_FORBIDDEN' || code === 'IM_JOB_MISSING_CWD' || code === 'INVALID_WATCH' || code === 'INVALID_PROGRESS' || code === 'INVALID_REPORT' || code === 'INVALID_PERSIST_HISTORY' || code === 'INVALID_PROGRESS_PATH' || code === 'INVALID_ARCHIVE' || code === 'ARCHIVE_FAILED' || code === 'ARCHIVE_UNAVAILABLE' || code === 'INVALID_RETRIGGER' || code === 'INVALID_RESCHEDULE' || code === 'INVALID_PERMISSION_PRESET') {
|
||||||
return write(400, { ok: false, error: error.message, code, message: error.message })
|
return write(400, { ok: false, error: error.message, code, message: error.message })
|
||||||
}
|
}
|
||||||
if (code === 'PAYLOAD_TOO_LARGE') return write(413, apiError('payload_too_large', locale))
|
if (code === 'PAYLOAD_TOO_LARGE') return write(413, apiError('payload_too_large', locale))
|
||||||
|
|
@ -1498,6 +1604,7 @@ export function createHostService(options = {}) {
|
||||||
updateJob,
|
updateJob,
|
||||||
pauseJob,
|
pauseJob,
|
||||||
retriggerJob,
|
retriggerJob,
|
||||||
|
rescheduleJob,
|
||||||
deleteJob,
|
deleteJob,
|
||||||
updateSettings,
|
updateSettings,
|
||||||
dispatchRun,
|
dispatchRun,
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ export const MESSAGES = {
|
||||||
'tool.pause': '暂停定时任务',
|
'tool.pause': '暂停定时任务',
|
||||||
'tool.resume': '恢复定时任务',
|
'tool.resume': '恢复定时任务',
|
||||||
'tool.retrigger': '重新触发一次性任务',
|
'tool.retrigger': '重新触发一次性任务',
|
||||||
|
'tool.reschedule': '改期一次性任务',
|
||||||
'tool.delete': '删除定时任务',
|
'tool.delete': '删除定时任务',
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
|
|
@ -53,6 +54,7 @@ export const MESSAGES = {
|
||||||
'tool.pause': 'Pause scheduled task',
|
'tool.pause': 'Pause scheduled task',
|
||||||
'tool.resume': 'Resume scheduled task',
|
'tool.resume': 'Resume scheduled task',
|
||||||
'tool.retrigger': 'Retrigger one-shot task',
|
'tool.retrigger': 'Retrigger one-shot task',
|
||||||
|
'tool.reschedule': 'Reschedule pending one-shot',
|
||||||
'tool.delete': 'Delete scheduled task',
|
'tool.delete': 'Delete scheduled task',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
66
lib/tools.js
66
lib/tools.js
|
|
@ -746,7 +746,7 @@ export function cronToolDefinitions(service, deps = {}) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'cron_retrigger',
|
name: 'cron_retrigger',
|
||||||
description: 'Start an extra run now: for recurring (cron) jobs fires immediately without changing the schedule; for consumed one-shots (next=n/a) re-arms/fires a new run. Auto-enables if paused. after_minutes>0 only for one-shots. Rejects if already pending/running. Listeners: when retriggerable=true and progress done_flag is false, call this instead of creating a duplicate job.',
|
description: 'Start an extra run now: for recurring (cron) jobs fires immediately without changing the schedule; for consumed one-shots (next=n/a) re-arms/fires a new run. Auto-enables if paused. after_minutes>0 only for one-shots. Rejects if already pending/running OR if a one-shot still has a pending next (use cron_reschedule). Listeners: when retriggerable=true and progress done_flag is false, call this instead of creating a duplicate job.',
|
||||||
parameters: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
|
|
@ -796,6 +796,58 @@ export function cronToolDefinitions(service, deps = {}) {
|
||||||
return { ...result, job: forAgentView(result.job) }
|
return { ...result, job: forAgentView(result.job) }
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'cron_reschedule',
|
||||||
|
description: 'Move a pending one-shot next fire time (still has next≠n/a). Prefer after_minutes (0=ASAP / next tick) or at. Does NOT start a second concurrent run; does NOT auto-resume paused jobs; does NOT replace cron_retrigger for consumed oneshots. Use this instead of delete+create when the user wants earlier/later.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
task_id: { type: 'string', description: 'Job id (alias: id).' },
|
||||||
|
id: { type: 'string', description: 'Alias of task_id.' },
|
||||||
|
after_minutes: { type: 'number', description: 'Delay from now before fire. 0 = due immediately. Preferred over at.' },
|
||||||
|
at: { type: 'string', description: 'ISO timestamp or HH:mm in job timezone. Ignored if after_minutes is set.' },
|
||||||
|
timezone: { type: 'string', description: 'Optional IANA tz; default keeps the job timezone.' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: true,
|
||||||
|
properties: {
|
||||||
|
ok: { type: 'boolean' },
|
||||||
|
job: JOB_SCHEMA,
|
||||||
|
nextRunAt: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
||||||
|
previousNextRunAt: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
render: (_args, value) => {
|
||||||
|
const name = value.job?.name || value.job?.id || ''
|
||||||
|
const when = value.nextRunAt ? new Date(value.nextRunAt).toISOString() : 'n/a'
|
||||||
|
return text(`Rescheduled "${name}" → next ${when}.`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
presentCall: (args) => ({
|
||||||
|
card: 'generic',
|
||||||
|
title: t('tool.reschedule'),
|
||||||
|
content: String(args?.task_id || args?.id || ''),
|
||||||
|
}),
|
||||||
|
async execute(args, exec) {
|
||||||
|
aborted(exec)
|
||||||
|
const peer = await resolveCallerPeer(exec, getDshIm())
|
||||||
|
const identity = resolveToolIdentity(exec, service)
|
||||||
|
const id = String(args?.task_id || args?.id || '').trim()
|
||||||
|
if (!id) throw new Error('task_id is required')
|
||||||
|
const owned = await requireOwnedJob(service, id, peer, identity)
|
||||||
|
assertAgentMayMutate(owned)
|
||||||
|
const result = await service.rescheduleJob(id, {
|
||||||
|
after_minutes: args?.after_minutes,
|
||||||
|
at: args?.at,
|
||||||
|
timezone: args?.timezone,
|
||||||
|
}, identity)
|
||||||
|
return { ...result, job: forAgentView(result.job) }
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'cron_delete',
|
name: 'cron_delete',
|
||||||
description: 'Permanently delete a scheduled task by id. History rows for that job remain until pruned.',
|
description: 'Permanently delete a scheduled task by id. History rows for that job remain until pruned.',
|
||||||
|
|
@ -851,9 +903,9 @@ export function cronGuidanceText(nowMs = Date.now(), timeZone = 'Asia/Shanghai')
|
||||||
'Session mirror: by default the run summary is NOT injected into the origin WhatsApp/Web chat. Pass mirror_to_session=true only when the user wants follow-up context in that chat. Full tool traces always stay in run history; IM delivery (when configured) is independent.',
|
'Session mirror: by default the run summary is NOT injected into the origin WhatsApp/Web chat. Pass mirror_to_session=true only when the user wants follow-up context in that chat. Full tool traces always stay in run history; IM delivery (when configured) is independent.',
|
||||||
'Agent preset: omit agent_preset to inherit (WhatsApp chat/group preset → creating session → Host default). Pass agent_preset to pin a preset for every scheduled run.',
|
'Agent preset: omit agent_preset to inherit (WhatsApp chat/group preset → creating session → Host default). Pass agent_preset to pin a preset for every scheduled run.',
|
||||||
'Labels/monitor: pass labels={"role":"worker","task":"theory"} on workers; listeners pass watch={"taskId":"..."} or watch={"labels":{...},"match":"all"}. Use cron_query / cron_progress / cron_runs to poll state and progress.',
|
'Labels/monitor: pass labels={"role":"worker","task":"theory"} on workers; listeners pass watch={"taskId":"..."} or watch={"labels":{...},"match":"all"}. Use cron_query / cron_progress / cron_runs to poll state and progress.',
|
||||||
'One-shot wake: cron_resume only unpauses. For a finished one-shot (retriggerable=true, next=n/a), call cron_retrigger. For a recurring job, cron_retrigger also starts one extra run immediately without changing the cron schedule.',
|
'One-shot wake: cron_resume only unpauses. For a finished one-shot (retriggerable=true, next=n/a), call cron_retrigger. For a still-pending one-shot (has next), call cron_reschedule(after_minutes=N) instead of delete+create. For a recurring job, cron_retrigger starts one extra run immediately without changing the cron schedule.',
|
||||||
'When the user asks to look at, create, pause, resume, retrigger, or delete 定时任务 / scheduled tasks / cron jobs:',
|
'When the user asks to look at, create, pause, resume, retrigger, reschedule, or delete 定时任务 / scheduled tasks / cron jobs:',
|
||||||
'1. If cron_list / cron_create / cron_pause / cron_resume / cron_retrigger / cron_delete / cron_query / cron_runs / cron_progress are in your tool list, call them.',
|
'1. If cron_list / cron_create / cron_pause / cron_resume / cron_retrigger / cron_reschedule / cron_delete / cron_query / cron_runs / cron_progress are in your tool list, call them.',
|
||||||
'2. If they are not listed, load skill "scheduled-tasks" for usage guidance, then look again. skill_load does NOT inject tools — cron_* are registered by the dsh-ops-cron Host plugin at boot.',
|
'2. If they are not listed, load skill "scheduled-tasks" for usage guidance, then look again. skill_load does NOT inject tools — cron_* are registered by the dsh-ops-cron Host plugin at boot.',
|
||||||
'3. If cron_* are still missing after a Host restart with the latest dsh-ops-cron, the plugin failed to register (check Host logs for unsupported JSON schema / tools.register). Tell the user; do not invent crontab workarounds.',
|
'3. If cron_* are still missing after a Host restart with the latest dsh-ops-cron, the plugin failed to register (check Host logs for unsupported JSON schema / tools.register). Tell the user; do not invent crontab workarounds.',
|
||||||
'4. Never run crontab, never read /etc/cron*, and never say there are no tasks until cron_list has returned.',
|
'4. Never run crontab, never read /etc/cron*, and never say there are no tasks until cron_list has returned.',
|
||||||
|
|
@ -865,8 +917,8 @@ export const CRON_GUIDANCE = cronGuidanceText()
|
||||||
export function makeCronSkill() {
|
export function makeCronSkill() {
|
||||||
return {
|
return {
|
||||||
name: 'scheduled-tasks',
|
name: 'scheduled-tasks',
|
||||||
description: '定时任务: 查看、创建、暂停、恢复、删除 DSH 侧栏定时任务(今天晚上几点、一次性执行、scheduled job、cron)。WhatsApp 里创建默认回投 IM;Web 里创建进侧栏会话。不要用系统 crontab,也不是会话内 reminder。',
|
description: '定时任务: 查看、创建、暂停、恢复、改期、删除 DSH 侧栏定时任务(今天晚上几点、一次性执行、scheduled job、cron)。WhatsApp 里创建默认回投 IM;Web 里创建进侧栏会话。不要用系统 crontab,也不是会话内 reminder。',
|
||||||
whenToUse: 'User asks to list or create 定时任务 / scheduled tasks, schedule something for tonight/today, pause a job, or mentions cron in DeepSeek Harness.',
|
whenToUse: 'User asks to list or create 定时任务 / scheduled tasks, schedule something for tonight/today, pause/reschedule a job, or mentions cron in DeepSeek Harness.',
|
||||||
source: 'runtime',
|
source: 'runtime',
|
||||||
provider: 'runtime',
|
provider: 'runtime',
|
||||||
content: `${cronGuidanceText()}
|
content: `${cronGuidanceText()}
|
||||||
|
|
@ -877,7 +929,7 @@ Tools:
|
||||||
- cron_runs — run history for a task_id
|
- cron_runs — run history for a task_id
|
||||||
- cron_progress — read progress.channel.file snapshots
|
- cron_progress — read progress.channel.file snapshots
|
||||||
- cron_create — "一分钟后" → after_minutes=1. Clock time → hour+minute only. Do not send at/expr at the same time. Pass cwd as the current workspace path when creating from a workspace chat. Pass provider+model or inherit the current session model. Delivery and agent preset auto from session, or pass delivery=im / agent_preset explicitly. Session mirror is off by default; pass mirror_to_session=true only if the user wants the summary injected into the origin chat. Optional labels/watch/progress/report/persist_history for monitor workers and listeners.
|
- cron_create — "一分钟后" → after_minutes=1. Clock time → hour+minute only. Do not send at/expr at the same time. Pass cwd as the current workspace path when creating from a workspace chat. Pass provider+model or inherit the current session model. Delivery and agent preset auto from session, or pass delivery=im / agent_preset explicitly. Session mirror is off by default; pass mirror_to_session=true only if the user wants the summary injected into the origin chat. Optional labels/watch/progress/report/persist_history for monitor workers and listeners.
|
||||||
- cron_pause / cron_resume / cron_retrigger / cron_delete — by id from cron_list. Use cron_retrigger to re-fire a consumed one-shot or to immediately start one extra run of a recurring job.
|
- cron_pause / cron_resume / cron_retrigger / cron_reschedule / cron_delete — by id from cron_list. Use cron_retrigger to re-fire a consumed one-shot or to immediately start one extra run of a recurring job. Use cron_reschedule to move a still-pending one-shot (do not delete+create).
|
||||||
`,
|
`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -409,6 +409,110 @@ test('retriggerJob fires recurring immediately and re-arms consumed oneshot', as
|
||||||
assert.ok(httpHit.body.run?.id)
|
assert.ok(httpHit.body.run?.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('rescheduleJob moves pending one-shot next without dispatch', async (t) => {
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
|
||||||
|
t.after(() => rm(dir, { recursive: true, force: true }))
|
||||||
|
let clock = Date.parse('2026-08-24T01:00:00.000Z')
|
||||||
|
let fires = 0
|
||||||
|
const service = createTestHost({
|
||||||
|
filePath: join(dir, 'store.json'),
|
||||||
|
now: () => clock,
|
||||||
|
sessionPort: {
|
||||||
|
async createAndPrompt() {
|
||||||
|
fires += 1
|
||||||
|
return { sessionId: `sess-${fires}`, status: 'succeeded', summary: 'ok' }
|
||||||
|
},
|
||||||
|
async archiveSession() {},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const pending = await service.createJob({
|
||||||
|
name: 'later',
|
||||||
|
prompt: 'do it',
|
||||||
|
enabled: false,
|
||||||
|
schedule: { kind: 'at', at: new Date(clock + 3600_000).toISOString(), timezone: 'UTC' },
|
||||||
|
})
|
||||||
|
const previous = pending.nextRunAt
|
||||||
|
assert.ok(previous > clock)
|
||||||
|
|
||||||
|
const moved = await service.rescheduleJob(pending.id, { after_minutes: 1 })
|
||||||
|
assert.equal(moved.ok, true)
|
||||||
|
assert.equal(moved.previousNextRunAt, previous)
|
||||||
|
assert.equal(moved.nextRunAt, clock + 60_000)
|
||||||
|
assert.equal(moved.job.enabled, false)
|
||||||
|
assert.equal(moved.job.nextRunAt, clock + 60_000)
|
||||||
|
assert.equal(fires, 0)
|
||||||
|
|
||||||
|
const asap = await service.rescheduleJob(pending.id, { after_minutes: 0 })
|
||||||
|
assert.equal(asap.nextRunAt, clock)
|
||||||
|
assert.equal(asap.job.enabled, false)
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => service.rescheduleJob(pending.id, {}),
|
||||||
|
(err) => err.code === 'INVALID_RESCHEDULE',
|
||||||
|
)
|
||||||
|
|
||||||
|
const cron = await service.createJob({
|
||||||
|
name: 'cron',
|
||||||
|
prompt: 'loop',
|
||||||
|
schedule: { kind: 'cron', expr: '0 9 * * *', timezone: 'UTC' },
|
||||||
|
})
|
||||||
|
await assert.rejects(
|
||||||
|
() => service.rescheduleJob(cron.id, { after_minutes: 1 }),
|
||||||
|
(err) => err.code === 'INVALID_RESCHEDULE',
|
||||||
|
)
|
||||||
|
|
||||||
|
await service.store.mutate((state) => ({
|
||||||
|
...state,
|
||||||
|
jobs: state.jobs.map((row) => (row.id === pending.id
|
||||||
|
? { ...row, nextRunAt: null, lastStatus: 'succeeded' }
|
||||||
|
: row)),
|
||||||
|
}))
|
||||||
|
await assert.rejects(
|
||||||
|
() => service.rescheduleJob(pending.id, { after_minutes: 1 }),
|
||||||
|
(err) => err.code === 'INVALID_RESCHEDULE',
|
||||||
|
)
|
||||||
|
|
||||||
|
const waiting = await service.createJob({
|
||||||
|
name: 'waiting2',
|
||||||
|
prompt: 'soon',
|
||||||
|
cwd: '/tmp/user-workspaces/tester',
|
||||||
|
schedule: { kind: 'at', at: new Date(clock + 7200_000).toISOString(), timezone: 'UTC' },
|
||||||
|
}, { empNo: 'tester', displayName: 'Tester', permissions: { canViewAllSessions: false } })
|
||||||
|
await service.store.mutate((state) => ({
|
||||||
|
...state,
|
||||||
|
runs: [
|
||||||
|
{
|
||||||
|
id: 'inflight-rs',
|
||||||
|
jobId: waiting.id,
|
||||||
|
status: 'running',
|
||||||
|
scheduledAt: clock,
|
||||||
|
actualAt: clock,
|
||||||
|
stateEnteredAt: clock,
|
||||||
|
},
|
||||||
|
...(state.runs || []),
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
await assert.rejects(
|
||||||
|
() => service.rescheduleJob(waiting.id, { after_minutes: 1 }),
|
||||||
|
(err) => err.code === 'ALREADY_RUNNING',
|
||||||
|
)
|
||||||
|
|
||||||
|
const http = await listen(service)
|
||||||
|
t.after(() => http.close())
|
||||||
|
await service.store.mutate((state) => ({
|
||||||
|
...state,
|
||||||
|
runs: (state.runs || []).filter((run) => run.id !== 'inflight-rs'),
|
||||||
|
}))
|
||||||
|
const httpHit = await jsonRequest(http.url, `/dsh-ops-cron/jobs/${waiting.id}/reschedule`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ after_minutes: 2 }),
|
||||||
|
})
|
||||||
|
assert.equal(httpHit.status, 200)
|
||||||
|
assert.equal(httpHit.body.nextRunAt, clock + 120_000)
|
||||||
|
assert.equal(httpHit.body.ok, true)
|
||||||
|
})
|
||||||
|
|
||||||
test('http api: anonymous list/run-now is rejected', async (t) => {
|
test('http api: anonymous list/run-now is rejected', async (t) => {
|
||||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
|
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
|
||||||
t.after(() => rm(dir, { recursive: true, force: true }))
|
t.after(() => rm(dir, { recursive: true, force: true }))
|
||||||
|
|
|
||||||
|
|
@ -530,6 +530,7 @@ test('registerCronTools registers each definition and disposer unregisters', ()
|
||||||
'cron_pause',
|
'cron_pause',
|
||||||
'cron_resume',
|
'cron_resume',
|
||||||
'cron_retrigger',
|
'cron_retrigger',
|
||||||
|
'cron_reschedule',
|
||||||
'cron_delete',
|
'cron_delete',
|
||||||
])
|
])
|
||||||
off()
|
off()
|
||||||
|
|
@ -546,6 +547,7 @@ test('cron tool output schemas never use type arrays (Host rejects them)', () =>
|
||||||
async pauseJob() { return {} },
|
async pauseJob() { return {} },
|
||||||
async resumeJob() { return {} },
|
async resumeJob() { return {} },
|
||||||
async retriggerJob() { return {} },
|
async retriggerJob() { return {} },
|
||||||
|
async rescheduleJob() { return {} },
|
||||||
async deleteJob() { return {} },
|
async deleteJob() { return {} },
|
||||||
})
|
})
|
||||||
const bad = []
|
const bad = []
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue