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

@ -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,

View file

@ -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',
},
}

View file

@ -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).
`,
}
}