Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .ai/contexts/schedule-runner.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/main/schedules/schedule-runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
28 changes: 28 additions & 0 deletions test/schedule-reenable-skip.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) });
Expand Down
Loading