Fix one-shot jobs that never auto-fire after restart or slow ticks.

recover was clearing past-due at nextRunAt via nextFire→null; keep and re-arm them, and evaluate schedule misfire at tick start so a slow prior job cannot age peers out of grace.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-11 07:00:29 +08:00
parent 8cf624ce7f
commit b22048d377
2 changed files with 82 additions and 3 deletions

View file

@ -842,9 +842,9 @@ export function createHostService(options = {}) {
return state.settings
}
async function dispatchRun(jobId, trigger) {
async function dispatchRun(jobId, trigger, opts = {}) {
let claimed
const t = now()
const t = Number.isFinite(opts.at) ? opts.at : now()
await withState((current) => {
claimed = claimOccurrence(current, jobId, t, trigger, current.settings)
if (claimed.decision.action === 'wait') return current
@ -897,7 +897,9 @@ export function createHostService(options = {}) {
misfirePolicy: state.settings.misfirePolicy,
})
if (decision.action === 'wait') continue
const result = await dispatchRun(job.id, 'schedule')
// Use tick start time for claim/misfire so a slow earlier job cannot
// push later oneshots past the grace window.
const result = await dispatchRun(job.id, 'schedule', { at: t })
if (result.run) fired.push(result)
}
await tickWatcherReports()
@ -967,6 +969,15 @@ export function createHostService(options = {}) {
try {
if (job.nextRunAt === null) return job
if (Number.isFinite(job.nextRunAt) && job.nextRunAt > t) return job
// Past-due one-shot: do NOT call nextFire (that returns null and
// permanently kills the job). Re-arm to now so the next tick fires
// a catch-up run instead of wiping or grace-misfiring after restart.
if (job.schedule?.kind === 'at') {
if (Number.isFinite(job.nextRunAt) && job.nextRunAt <= t) {
return { ...job, nextRunAt: t }
}
return job
}
const nextRunAt = nextFire(job.schedule, t, job.schedule?.timezone || next.settings.timezone)
return { ...job, nextRunAt }
} catch {