From b22048d37727539bb953d8a60bd611fcf7d6301f Mon Sep 17 00:00:00 2001 From: oliver Date: Fri, 11 Sep 2026 07:00:29 +0800 Subject: [PATCH] Fix one-shot jobs that never auto-fire after restart or slow ticks. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/host.js | 17 +++++++++--- test/host.test.js | 68 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/lib/host.js b/lib/host.js index 4443c98..6544993 100644 --- a/lib/host.js +++ b/lib/host.js @@ -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 { diff --git a/test/host.test.js b/test/host.test.js index 0a0a0b6..df7d367 100644 --- a/test/host.test.js +++ b/test/host.test.js @@ -718,6 +718,74 @@ test('one-shot at job fires once then later ticks wait instead of retriggering', assert.equal(misfires.length, 0) }) +test('recover re-arms past-due oneshot instead of clearing nextRunAt', async (t) => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-')) + t.after(() => rm(dir, { recursive: true, force: true })) + const at = Date.parse('2026-09-11T00:00:00.000Z') + let clock = at - 30_000 + let sessions = 0 + const service = createTestHost({ + filePath: join(dir, 'store.json'), + now: () => clock, + sessionPort: { + async createAndPrompt() { + sessions += 1 + return { sessionId: `catchup-${sessions}`, status: 'succeeded', summary: 'catch-up' } + }, + }, + }) + const job = await service.createJob({ + name: 'catchup-at', + prompt: 'ping', + schedule: { kind: 'at', at: new Date(at).toISOString(), timezone: 'UTC' }, + }) + assert.equal(job.nextRunAt, at) + + // Simulate Host restart after the due time (previously wiped nextRunAt via nextFire→null). + clock = at + 90_000 + await service.recover() + const after = await service.getJob(job.id) + assert.equal(after.nextRunAt, clock) + + const fired = await service.tick() + assert.equal(fired.length, 1) + assert.equal(fired[0].run.status, 'succeeded') + assert.equal(sessions, 1) +}) + +test('tick evaluates misfire against tick-start time so a slow prior job cannot age out oneshots', async (t) => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-')) + t.after(() => rm(dir, { recursive: true, force: true })) + const due = Date.parse('2026-09-11T01:00:00.000Z') + let clock = due + let sessions = 0 + const service = createTestHost({ + filePath: join(dir, 'store.json'), + now: () => clock, + sessionPort: { + async createAndPrompt({ job }) { + sessions += 1 + // First job stretches wall clock past oneshot grace (60s). + if (job.name === 'slow') clock = due + 90_000 + return { sessionId: `s-${sessions}`, status: 'succeeded', summary: 'ok' } + }, + }, + }) + await service.createJob({ + name: 'slow', + prompt: 'first', + schedule: { kind: 'at', at: new Date(due).toISOString(), timezone: 'UTC' }, + }) + await service.createJob({ + name: 'peer', + prompt: 'second', + schedule: { kind: 'at', at: new Date(due).toISOString(), timezone: 'UTC' }, + }) + const fired = await service.tick() + assert.equal(fired.filter((row) => row.run?.status === 'succeeded').length, 2) + assert.equal(sessions, 2) +}) + test('unarchiveSession drops the id from archivedSessionIds', async () => { const state = { workspaceIds: ['w'], archivedSessionIds: ['s1', 's2'], initialized: true } const ctx = {