diff --git a/.ai/contexts/schedule-runner.md b/.ai/contexts/schedule-runner.md index 0031558c8..7001e074d 100644 --- a/.ai/contexts/schedule-runner.md +++ b/.ai/contexts/schedule-runner.md @@ -505,6 +505,7 @@ function's `getLastTickMinute()`. Every check records its minute, after project selection resolves and before schedules are scanned: cron ticks, startup and resume catch-up checks use the same scheduler-owned state. A minute is evaluated once for re-enable purposes, even if the schedule was disabled during that check. +The recorded minute only moves forward, so an older asynchronous project selection resolving after a newer check cannot make re-enabling replay an already evaluated minute. An already enabled schedule still receives its normal startup catch-up run. Main passes its getter through the existing enable and archive deps, and its value to unarchive's skip. The current due minute is diff --git a/src/main/schedules/schedule-runner.js b/src/main/schedules/schedule-runner.js index 37fc3430e..6aeaa473d 100644 --- a/src/main/schedules/schedule-runner.js +++ b/src/main/schedules/schedule-runner.js @@ -675,7 +675,7 @@ function startScheduler(log, runCommand, { resumeSource, stateDir, projects } = function checkProjects(onTick, paths, now) { if (!running) return; - lastTickMinute = Math.floor(now.getTime() / MINUTE_MS) * MINUTE_MS; + lastTickMinute = Math.max(lastTickMinute ?? -Infinity, Math.floor(now.getTime() / MINUTE_MS) * MINUTE_MS); const schedules = scanSchedules(log, paths); let state = null; if (catchUpOn && schedules.some(s => s.catchUp)) { diff --git a/test/schedule-reenable-skip.test.js b/test/schedule-reenable-skip.test.js index f5a7b32ff..3fb91d3e3 100644 --- a/test/schedule-reenable-skip.test.js +++ b/test/schedule-reenable-skip.test.js @@ -37,6 +37,34 @@ function schedulerClock(t, h, runs, projects = () => [h.project], resumeSource) return { stop, tick(time) { t.mock.timers.setTime(time); (interval || initial)(); } }; } +test('a delayed resume resolving after a newer tick does not replay that tick on re-enable', async t => { + t.mock.timers.enable({ apis: ['Date'], now: at(9, 14, 20) }); + const isolation = process.env.SWITCHBOARD_DATA_DIR; + delete process.env.SWITCHBOARD_DATA_DIR; + t.after(() => { if (isolation === undefined) delete process.env.SWITCHBOARD_DATA_DIR; else process.env.SWITCHBOARD_DATA_DIR = isolation; }); + const h = fixture(t); + fs.writeFileSync(h.file, '---\ncron: 16 9 * * *\nenabled: false\ncatch-up: true\n---\nPrompt'); + const runs = []; + const resumeSource = new (require('node:events').EventEmitter)(); + let resolveResume; + const resumeProjects = new Promise(resolve => { resolveResume = resolve; }); + let delaySelection = false; + const clock = schedulerClock(t, h, runs, () => delaySelection ? resumeProjects : [h.project], resumeSource); + t.mock.timers.setTime(at(9, 15, 59, 950)); + delaySelection = true; + resumeSource.emit('resume'); + delaySelection = false; + clock.tick(at(9, 16)); + resolveResume([h.project]); + await resumeProjects; + t.mock.timers.setTime(at(9, 16, 10)); + assert.equal((await runner.setListedScheduleEnabled(h.file, true, { + ...h.deps, getLastTickMinute: () => clock.stop.getLastTickMinute(), + })).ok, true); + clock.tick(at(9, 17)); + assert.equal(runs.length, 0, 'the 9:16 minute evaluated while disabled cannot replay at 9:17 after a delayed resume'); +}); + for (const check of ['startup', 'resume']) { test(`enable after the ${check} check consumed the due minute does not replay it`, async t => { t.mock.timers.enable({ apis: ['Date'], now: at(9, check === 'startup' ? 15 : 10, 20) });