diff --git a/docs/cron_reschedule原语详设.md b/docs/cron_reschedule原语详设.md new file mode 100644 index 0000000..ab7eaaf --- /dev/null +++ b/docs/cron_reschedule原语详设.md @@ -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` 替换)。 diff --git a/lib/host.js b/lib/host.js index 6544993..1d0e631 100644 --- a/lib/host.js +++ b/lib/host.js @@ -766,7 +766,7 @@ export function createHostService(options = {}) { // one-shot 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' 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) { await withState((current) => { if (!getJob(current, jobId)) { @@ -1135,7 +1235,7 @@ export function createHostService(options = {}) { 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) { const jobId = decodeURIComponent(jobMatch[1]) const rest = jobMatch[2] || '' @@ -1170,6 +1270,12 @@ export function createHostService(options = {}) { write(200, result) 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') { const job = await pauseJob(jobId, false, identity) write(200, { ok: true, job }) @@ -1325,7 +1431,7 @@ export function createHostService(options = {}) { if (code === 'ALREADY_RUNNING') { 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 }) } if (code === 'PAYLOAD_TOO_LARGE') return write(413, apiError('payload_too_large', locale)) @@ -1498,6 +1604,7 @@ export function createHostService(options = {}) { updateJob, pauseJob, retriggerJob, + rescheduleJob, deleteJob, updateSettings, dispatchRun, diff --git a/lib/i18n.js b/lib/i18n.js index f696213..b580edc 100644 --- a/lib/i18n.js +++ b/lib/i18n.js @@ -32,6 +32,7 @@ export const MESSAGES = { 'tool.pause': '暂停定时任务', 'tool.resume': '恢复定时任务', 'tool.retrigger': '重新触发一次性任务', + 'tool.reschedule': '改期一次性任务', 'tool.delete': '删除定时任务', }, en: { @@ -53,6 +54,7 @@ export const MESSAGES = { 'tool.pause': 'Pause scheduled task', 'tool.resume': 'Resume scheduled task', 'tool.retrigger': 'Retrigger one-shot task', + 'tool.reschedule': 'Reschedule pending one-shot', 'tool.delete': 'Delete scheduled task', }, } diff --git a/lib/tools.js b/lib/tools.js index 2c65723..b0aa3fa 100644 --- a/lib/tools.js +++ b/lib/tools.js @@ -746,7 +746,7 @@ export function cronToolDefinitions(service, deps = {}) { }, { 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: { type: 'object', additionalProperties: false, @@ -796,6 +796,58 @@ export function cronToolDefinitions(service, deps = {}) { 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', 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.', '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.', - '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.', - 'When the user asks to look at, create, pause, resume, retrigger, 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.', + '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, reschedule, or delete 定时任务 / scheduled tasks / cron jobs:', + '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.', '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.', @@ -865,8 +917,8 @@ export const CRON_GUIDANCE = cronGuidanceText() export function makeCronSkill() { return { name: 'scheduled-tasks', - 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.', + 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/reschedule a job, or mentions cron in DeepSeek Harness.', source: 'runtime', provider: 'runtime', content: `${cronGuidanceText()} @@ -877,7 +929,7 @@ Tools: - cron_runs — run history for a task_id - 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_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). `, } } diff --git a/test/host.test.js b/test/host.test.js index df7d367..df1879f 100644 --- a/test/host.test.js +++ b/test/host.test.js @@ -409,6 +409,110 @@ test('retriggerJob fires recurring immediately and re-arms consumed oneshot', as 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) => { const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-')) t.after(() => rm(dir, { recursive: true, force: true })) diff --git a/test/tools.test.js b/test/tools.test.js index 49d297b..1b3e872 100644 --- a/test/tools.test.js +++ b/test/tools.test.js @@ -530,6 +530,7 @@ test('registerCronTools registers each definition and disposer unregisters', () 'cron_pause', 'cron_resume', 'cron_retrigger', + 'cron_reschedule', 'cron_delete', ]) off() @@ -546,6 +547,7 @@ test('cron tool output schemas never use type arrays (Host rejects them)', () => async pauseJob() { return {} }, async resumeJob() { return {} }, async retriggerJob() { return {} }, + async rescheduleJob() { return {} }, async deleteJob() { return {} }, }) const bad = []