diff --git a/assistant/src/__tests__/schedule-store.test.ts b/assistant/src/__tests__/schedule-store.test.ts index 7d221958e1f..9038d85822e 100644 --- a/assistant/src/__tests__/schedule-store.test.ts +++ b/assistant/src/__tests__/schedule-store.test.ts @@ -434,6 +434,35 @@ describe('claimDueSchedules', () => { expect(claimed.length).toBe(0); }); + test('claims exhausted RRULE schedule and disables it', () => { + // COUNT=1 means only one occurrence — the DTSTART itself + const rrule = 'DTSTART:20250101T000000Z\nRRULE:FREQ=DAILY;COUNT=1'; + const job = createSchedule({ + name: 'Finite RRULE', + cronExpression: rrule, + message: 'one-shot', + syntax: 'rrule', + expression: rrule, + }); + + // Force the schedule to be due (past the only occurrence) + getRawDb().run('UPDATE cron_jobs SET next_run_at = ? WHERE id = ?', [Date.now() - 1000, job.id]); + + const claimed = claimDueSchedules(Date.now()); + expect(claimed.length).toBe(1); + expect(claimed[0].id).toBe(job.id); + expect(claimed[0].enabled).toBe(false); + expect(claimed[0].nextRunAt).toBe(0); + + // Verify the schedule is disabled in the DB + const persisted = getSchedule(job.id); + expect(persisted!.enabled).toBe(false); + + // A subsequent claim should not pick it up + const again = claimDueSchedules(Date.now()); + expect(again.length).toBe(0); + }); + test('optimistic lock prevents double-claiming', () => { const job = createSchedule({ name: 'Double claim', diff --git a/assistant/src/schedule/schedule-store.ts b/assistant/src/schedule/schedule-store.ts index 03b0e5b2cb2..25eb680430b 100644 --- a/assistant/src/schedule/schedule-store.ts +++ b/assistant/src/schedule/schedule-store.ts @@ -207,7 +207,8 @@ export function claimDueSchedules(now: number): ScheduleJob[] { const claimed: ScheduleJob[] = []; for (const row of candidates) { - let newNextRunAt: number; + let newNextRunAt: number | null; + let exhausted = false; try { const syntax = (row.scheduleSyntax as ScheduleSyntax) ?? 'cron'; newNextRunAt = computeNextRunAtEngine({ @@ -216,18 +217,27 @@ export function claimDueSchedules(now: number): ScheduleJob[] { timezone: row.timezone, }); } catch { - // Expression has no future runs — skip - continue; + // Finite schedule with no future runs — still claim the current due + // run but disable the schedule so it doesn't fire again. + newNextRunAt = null; + exhausted = true; } // Optimistic lock: only update if nextRunAt hasn't changed + const updates: Record = { + lastRunAt: now, + updatedAt: now, + }; + if (exhausted) { + updates.nextRunAt = 0; + updates.enabled = false; + } else { + updates.nextRunAt = newNextRunAt!; + } + const result = db .update(scheduleJobs) - .set({ - nextRunAt: newNextRunAt, - lastRunAt: now, - updatedAt: now, - }) + .set(updates) .where(and(eq(scheduleJobs.id, row.id), eq(scheduleJobs.nextRunAt, row.nextRunAt))) .run() as unknown as { changes?: number }; @@ -235,9 +245,10 @@ export function claimDueSchedules(now: number): ScheduleJob[] { claimed.push(parseJobRow({ ...row, - nextRunAt: newNextRunAt, + nextRunAt: exhausted ? 0 : newNextRunAt!, lastRunAt: now, updatedAt: now, + enabled: exhausted ? false : row.enabled, })); } return claimed;