Add cron_retrigger for consumed one-shot jobs.

Resume only unpauses; listeners need a native way to re-fire the same job id with a new run without creating duplicate executors.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-10 17:40:39 +08:00
parent 6724332114
commit c11221606a
9 changed files with 324 additions and 7 deletions

View file

@ -11,6 +11,7 @@ import { claimOccurrence, executeClaimedRun, extractAssistantText, interruptActi
import { assertDeliveryAllowedForIdentity, deliverRunToIm, mergeDeliveryMention, mirrorRunToSession, normalizeDelivery, normalizeOrigin } from './delivery.js'
import { apiError, resolveLocale } from './i18n.js'
import { workspaceVisibleIds } from './isolation.js'
import { ACTIVE_RUN_STATUSES } from './fire-status.js'
import {
formatWatchReport,
labelsMatch,
@ -699,6 +700,101 @@ export function createHostService(options = {}) {
return updateJob(jobId, { enabled }, identity)
}
/**
* Re-arm a consumed one-shot job.
* afterMinutes omitted/0 → immediate run-now; >0 → reschedule next and wait for tick.
* Auto-enables paused jobs. Rejects cron jobs and in-flight/pending-next oneshots.
*/
async function retriggerJob(jobId, opts = {}, identity = null) {
const afterRaw = opts.afterMinutes ?? opts.after_minutes
const afterMinutes = afterRaw === undefined || afterRaw === null || afterRaw === ''
? 0
: Number(afterRaw)
if (!Number.isFinite(afterMinutes) || afterMinutes < 0) {
const error = new Error('after_minutes must be >= 0')
error.code = 'INVALID_RETRIGGER'
throw error
}
const delayMs = Math.round(afterMinutes * 60_000)
const t = now()
let mode = delayMs > 0 ? 'scheduled' : 'immediate'
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_retrigger is only for one-shot (at) jobs; use pause/resume for recurring jobs')
error.code = 'INVALID_RETRIGGER'
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
}
if (job.nextRunAt != null) {
const error = new Error('one-shot still has a pending next run; wait or edit the schedule instead of retrigger')
error.code = 'INVALID_RETRIGGER'
throw error
}
const terminal = job.lastStatus === 'succeeded'
|| job.lastStatus === 'failed'
|| job.lastStatus === 'skipped'
if (!terminal) {
const error = new Error('one-shot has not finished a run yet; nothing to retrigger')
error.code = 'INVALID_RETRIGGER'
throw error
}
if (delayMs > 0) {
const atMs = t + delayMs
return upsertJob(current, {
...job,
enabled: true,
schedule: {
kind: 'at',
at: new Date(atMs).toISOString(),
timezone: job.schedule?.timezone || current.settings?.timezone || 'Asia/Shanghai',
},
nextRunAt: atMs,
updatedAt: t,
})
}
if (job.enabled === false) {
return upsertJob(current, { ...job, enabled: true, updatedAt: t })
}
return current
})
if (mode === 'scheduled') {
const state = await snapshot()
const job = getJob(state, jobId)
return {
ok: true,
mode,
job: jobView(job, state.runs),
run: null,
nextRunAt: job?.nextRunAt ?? null,
}
}
const result = await dispatchRun(jobId, 'run-now')
return {
ok: true,
mode,
job: result.job,
run: result.run,
decision: result.decision,
nextRunAt: result.job?.nextRunAt ?? null,
}
}
async function deleteJob(jobId) {
await withState((current) => {
if (!getJob(current, jobId)) {
@ -1001,7 +1097,7 @@ export function createHostService(options = {}) {
return
}
const jobMatch = path.match(new RegExp(`^${API_PREFIX}/jobs/([^/]+)(/run|/pause|/resume)?$`))
const jobMatch = path.match(new RegExp(`^${API_PREFIX}/jobs/([^/]+)(/run|/pause|/resume|/retrigger)?$`))
if (jobMatch) {
const jobId = decodeURIComponent(jobMatch[1])
const rest = jobMatch[2] || ''
@ -1030,6 +1126,12 @@ export function createHostService(options = {}) {
write(200, { ok: true, ...result })
return
}
if (method === 'POST' && rest === '/retrigger') {
const body = await readJsonBody(req).catch(() => ({}))
const result = await retriggerJob(jobId, body || {}, identity)
write(200, result)
return
}
if (method === 'POST' && rest === '/pause') {
const job = await pauseJob(jobId, false, identity)
write(200, { ok: true, job })
@ -1182,7 +1284,10 @@ export function createHostService(options = {}) {
const locale = resolveLocale(req)
const code = error && error.code
if (code === 'NOT_FOUND') return write(404, { ...apiError('not_found', locale), error: error.message || 'not_found' })
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') {
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') {
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))
@ -1354,6 +1459,7 @@ export function createHostService(options = {}) {
createJob,
updateJob,
pauseJob,
retriggerJob,
deleteJob,
updateSettings,
dispatchRun,