fix(cron): close three MED cron-job defects

1. once-rearm on edit: editing any field of a finished one-time job reset
   completedOnce, silently resurrecting it. Now only a SCHEDULE edit
   (scheduleType/runAt/interval/daily/weekly) re-arms a completed once job;
   cosmetic edits (rename/notes) leave completedOnce intact.

2. update-validation gap: CronJobUpdateSchema = .partial() drops the cross-field
   superRefine, so a PUT switching scheduleType without its dependent field
   produced a dead enabled job (nextRunAt:null). updateJob now re-validates the
   MERGED job against the full CronJobSchema and throws 400 on inconsistency,
   leaving the stored job untouched.

3. concurrency-skip silent starvation: skip_if_same_agent_running advanced the
   schedule but wrote no run record, so a perpetually-skipped job had empty
   history. Now records a 'skipped' run (new CronJobRunStatus) + lastStatus.

Tests updated/added in cron-service.test.ts (37 pass): once non-schedule edit
preserves completedOnce, schedule edit re-arms, inconsistent partial update is
rejected with the stored job untouched, and the skip path records a skipped run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PmvZR12aX2v8K7YhqxPUAU
This commit is contained in:
Kris
2026-06-29 11:50:42 +05:30
co-authored by Claude Opus 4.8
parent 40e26c5422
commit 6082bceee6
3 changed files with 100 additions and 13 deletions
+47 -8
View File
@@ -117,19 +117,54 @@ describe('CronService', () => {
});
describe('updateJob', () => {
it('re-arms the dup-guard and once-completion flags', () => {
const job = svc.service.createJob(
mkInput({ scheduleType: 'once', runAt: Date.now() + 1000, intervalMinutes: undefined })
);
it('clears the dup-guard and preserves createdAt', () => {
const job = svc.service.createJob(mkInput({ intervalMinutes: 10 }));
job.lastDueKey = 'stale';
job.completedOnce = true;
svc.store.setCronJob(job.id, job);
const updated = svc.service.updateJob(job.id, { name: 'renamed' });
expect(updated!.name).toBe('renamed');
expect(updated!.lastDueKey).toBeNull();
expect(updated!.completedOnce).toBe(false);
expect(updated!.createdAt).toBe(job.createdAt);
});
it('does NOT re-fire a completed once-job when editing a non-schedule field', async () => {
const job = svc.service.createJob(
mkInput({ scheduleType: 'once', runAt: Date.now() - 1000, intervalMinutes: undefined })
);
await svc.service.tickDueJobs(Date.now());
await flush();
expect(svc.service.getJob(job.id)!.completedOnce).toBe(true);
const updated = svc.service.updateJob(job.id, { name: 'renamed' });
expect(updated!.name).toBe('renamed');
// Cosmetic edit must not resurrect a fired one-time job.
expect(updated!.completedOnce).toBe(true);
expect(updated!.nextRunAt).toBeNull();
});
it('re-arms a completed once-job when the schedule itself is edited', async () => {
const job = svc.service.createJob(
mkInput({ scheduleType: 'once', runAt: Date.now() - 1000, intervalMinutes: undefined })
);
await svc.service.tickDueJobs(Date.now());
await flush();
expect(svc.service.getJob(job.id)!.completedOnce).toBe(true);
const future = Date.now() + 3_600_000;
const updated = svc.service.updateJob(job.id, { runAt: future, enabled: true });
expect(updated!.completedOnce).toBe(false);
expect(updated!.nextRunAt).toBe(future);
});
it('rejects a partial update that leaves an inconsistent schedule (no dead enabled job)', () => {
const job = svc.service.createJob(mkInput({ scheduleType: 'interval', intervalMinutes: 10 }));
// Switch to 'once' WITHOUT a runAt → would otherwise yield a dead nextRunAt:null.
expect(() => svc.service.updateJob(job.id, { scheduleType: 'once', intervalMinutes: undefined })).toThrow();
// The stored job is left untouched.
const after = svc.service.getJob(job.id)!;
expect(after.scheduleType).toBe('interval');
expect(after.nextRunAt).not.toBeNull();
});
});
describe('deleteJob', () => {
@@ -240,9 +275,13 @@ describe('CronService', () => {
await local.service.tickDueJobs(fireAt);
await flush();
// No run recorded, but the schedule still advanced past the skipped slot.
expect(local.service.listRuns(job.id).length).toBe(0);
// A 'skipped' run is recorded (so the history isn't silently empty), and
// the schedule still advanced past the skipped slot.
const runs = local.service.listRuns(job.id);
expect(runs.length).toBe(1);
expect(runs[0].status).toBe('skipped');
const after = local.service.getJob(job.id)!;
expect(after.lastStatus).toBe('skipped');
expect(after.nextRunAt!).toBeGreaterThan(fireAt);
expect(after.lastDueKey).not.toBeNull();
});