Allow cron_retrigger to immediately fire recurring jobs.

Recurring tasks can start one extra run now without changing the cron schedule; delay remains oneshot-only.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-10 21:22:06 +08:00
parent c11221606a
commit 3ce60b200c
5 changed files with 58 additions and 21 deletions

View file

@ -701,9 +701,10 @@ export function createHostService(options = {}) {
} }
/** /**
* Re-arm a consumed one-shot job. * Fire an extra run, or re-arm a consumed one-shot.
* afterMinutes omitted/0 → immediate run-now; >0 → reschedule next and wait for tick. * - recurring (cron): after_minutes must be 0/omitted → enable + immediate run-now (keeps cron next)
* Auto-enables paused jobs. Rejects cron jobs and in-flight/pending-next oneshots. * - one-shot (at): consumed only; 0 → run-now; >0 → reschedule next
* Auto-enables paused jobs. Rejects when a run is already pending/running.
*/ */
async function retriggerJob(jobId, opts = {}, identity = null) { async function retriggerJob(jobId, opts = {}, identity = null) {
const afterRaw = opts.afterMinutes ?? opts.after_minutes const afterRaw = opts.afterMinutes ?? opts.after_minutes
@ -727,8 +728,9 @@ export function createHostService(options = {}) {
throw error throw error
} }
if (identity) assertCanAccessJob(job, identity) if (identity) assertCanAccessJob(job, identity)
if (job.schedule?.kind !== 'at') { const kind = job.schedule?.kind
const error = new Error('cron_retrigger is only for one-shot (at) jobs; use pause/resume for recurring jobs') if (kind !== 'at' && kind !== 'cron') {
const error = new Error('cron_retrigger requires a one-shot (at) or recurring (cron) job')
error.code = 'INVALID_RETRIGGER' error.code = 'INVALID_RETRIGGER'
throw error throw error
} }
@ -738,6 +740,20 @@ export function createHostService(options = {}) {
error.code = 'ALREADY_RUNNING' error.code = 'ALREADY_RUNNING'
throw error throw error
} }
if (kind === 'cron') {
if (delayMs > 0) {
const error = new Error('after_minutes delay is only for one-shot jobs; omit it to fire a recurring job immediately')
error.code = 'INVALID_RETRIGGER'
throw error
}
if (job.enabled === false) {
return upsertJob(current, { ...job, enabled: true, updatedAt: t })
}
return current
}
// 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; wait or edit the schedule instead of retrigger')
error.code = 'INVALID_RETRIGGER' error.code = 'INVALID_RETRIGGER'

View file

@ -227,21 +227,23 @@ export function persistHistoryLimit(policy, settingsLimit = 200) {
} }
/** /**
* One-shot job whose schedule has been consumed (next cleared) and has a terminal lastStatus. * Whether cron_retrigger may start a new run now.
* Used by listeners to decide cron_retrigger vs wait. * - recurring (cron): idle (no pending/running run)
* - one-shot (at): consumed (next=n/a) + terminal lastStatus + idle
* @param {object|null} job * @param {object|null} job
* @param {object[]} [runs] * @param {object[]} [runs]
*/ */
export function isRetriggerable(job, runs = []) { export function isRetriggerable(job, runs = []) {
if (!job || job.schedule?.kind !== 'at') return false if (!job) return false
const list = Array.isArray(runs) ? runs : []
if (list.some((run) => run && ACTIVE_RUN_STATUSES.has(run.status))) return false
if (job.schedule?.kind === 'cron') return true
if (job.schedule?.kind !== 'at') return false
if (job.nextRunAt != null) return false if (job.nextRunAt != null) return false
const terminal = job.lastStatus === 'succeeded' const terminal = job.lastStatus === 'succeeded'
|| job.lastStatus === 'failed' || job.lastStatus === 'failed'
|| job.lastStatus === 'skipped' || job.lastStatus === 'skipped'
if (!terminal) return false return terminal
const list = Array.isArray(runs) ? runs : []
if (list.some((run) => run && ACTIVE_RUN_STATUSES.has(run.status))) return false
return true
} }
/** /**

View file

@ -710,14 +710,14 @@ export function cronToolDefinitions(service, deps = {}) {
}, },
{ {
name: 'cron_retrigger', name: 'cron_retrigger',
description: 'Re-fire a consumed one-shot job (succeeded/failed/skipped with next=n/a) as a new run. Auto-enables if paused. Use after_minutes>0 to delay. For recurring jobs use pause/resume. Listeners: when retriggerable=true and progress done_flag is false, call this instead of creating a new 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. 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,
properties: { properties: {
task_id: { type: 'string', description: 'One-shot job id (alias: id).' }, task_id: { type: 'string', description: 'Job id (alias: id).' },
id: { type: 'string', description: 'Alias of task_id.' }, id: { type: 'string', description: 'Alias of task_id.' },
after_minutes: { type: 'number', description: 'Delay before fire. Omit or 0 = immediate run-now.' }, after_minutes: { type: 'number', description: 'One-shot only: delay before fire. Omit/0 = immediate. Not allowed for recurring jobs.' },
}, },
}, },
output: { output: {
@ -812,7 +812,7 @@ 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 to start a new run on the same job id — do not create a duplicate executor.', '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:', '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.', '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.',
'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.',
@ -838,7 +838,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 (not resume) to re-fire a consumed one-shot worker. - 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.
`, `,
} }
} }

View file

@ -285,7 +285,7 @@ test('overlap skip writes a skipped history row instead of a second session', as
assert.equal(inflight, 1) assert.equal(inflight, 1)
}) })
test('retriggerJob re-fires consumed oneshot; rejects cron / pending / in-flight', async (t) => { test('retriggerJob fires recurring immediately and re-arms consumed oneshot', 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 }))
let clock = Date.parse('2026-09-10T08:00:00.000Z') let clock = Date.parse('2026-09-10T08:00:00.000Z')
@ -307,7 +307,17 @@ test('retriggerJob re-fires consumed oneshot; rejects cron / pending / in-flight
prompt: 'loop', prompt: 'loop',
schedule: { kind: 'cron', expr: '0 9 * * *', timezone: 'UTC' }, schedule: { kind: 'cron', expr: '0 9 * * *', timezone: 'UTC' },
}) })
await assert.rejects(() => service.retriggerJob(cron.id), (err) => err.code === 'INVALID_RETRIGGER') const cronNext = cron.nextRunAt
assert.equal(cron.retriggerable, true)
await assert.rejects(
() => service.retriggerJob(cron.id, { after_minutes: 5 }),
(err) => err.code === 'INVALID_RETRIGGER',
)
const cronHit = await service.retriggerJob(cron.id)
assert.equal(cronHit.mode, 'immediate')
assert.equal(cronHit.run.status, 'succeeded')
assert.equal(cronHit.job.nextRunAt, cronNext)
assert.equal(fires, 1)
const waiting = await service.createJob({ const waiting = await service.createJob({
name: 'waiting', name: 'waiting',
@ -359,7 +369,7 @@ test('retriggerJob re-fires consumed oneshot; rejects cron / pending / in-flight
assert.equal(again.mode, 'immediate') assert.equal(again.mode, 'immediate')
assert.ok(again.run) assert.ok(again.run)
assert.equal(again.run.status, 'succeeded') assert.equal(again.run.status, 'succeeded')
assert.equal(fires, 2) assert.equal(fires, 3)
// In-flight reject // In-flight reject
await service.store.mutate((state) => ({ await service.store.mutate((state) => ({

View file

@ -35,7 +35,16 @@ test('isRetriggerable / publicJob.retriggerable for consumed oneshots', () => {
assert.equal(isRetriggerable(base, []), true) assert.equal(isRetriggerable(base, []), true)
assert.equal(publicJob(base, []).retriggerable, true) assert.equal(publicJob(base, []).retriggerable, true)
assert.equal(isRetriggerable({ ...base, schedule: { kind: 'cron', expr: '0 9 * * *', timezone: 'UTC' } }, []), false) const cron = {
...base,
schedule: { kind: 'cron', expr: '0 9 * * *', timezone: 'UTC' },
nextRunAt: now + 60_000,
lastStatus: null,
}
assert.equal(isRetriggerable(cron, []), true)
assert.equal(publicJob(cron, []).retriggerable, true)
assert.equal(isRetriggerable(cron, [{ id: 'r1', jobId: 'j1', status: 'running' }]), false)
assert.equal(isRetriggerable({ ...base, nextRunAt: now + 60_000 }, []), false) assert.equal(isRetriggerable({ ...base, nextRunAt: now + 60_000 }, []), false)
assert.equal(isRetriggerable({ ...base, lastStatus: null }, []), false) assert.equal(isRetriggerable({ ...base, lastStatus: null }, []), false)
assert.equal(isRetriggerable(base, [{ id: 'r1', jobId: 'j1', status: 'running' }]), false) assert.equal(isRetriggerable(base, [{ id: 'r1', jobId: 'j1', status: 'running' }]), false)