@@ -270,6 +270,116 @@ describe("CronService restart catch-up", () => {
270270 }
271271 } ) ;
272272
273+ it ( "does not defer an isolated cron agent-turn whose persisted due slot already succeeded" , async ( ) => {
274+ const store = await makeStorePath ( ) ;
275+ const startNow = Date . parse ( "2025-12-13T11:00:00.000Z" ) ;
276+ const dueAt = Date . parse ( "2025-12-13T09:10:00.000Z" ) ;
277+ const completedAt = Date . parse ( "2025-12-13T09:10:30.000Z" ) ;
278+ const runIsolatedAgentJob = vi . fn ( async ( ) => ( { status : "ok" as const } ) ) ;
279+ const enqueueSystemEvent = vi . fn ( ) ;
280+ const requestHeartbeat = vi . fn ( ) ;
281+
282+ await writeStoreJobs ( store . storePath , [
283+ {
284+ id : "startup-isolated-agent-already-ok" ,
285+ name : "startup isolated agent already ok" ,
286+ enabled : true ,
287+ createdAtMs : Date . parse ( "2025-12-10T12:00:00.000Z" ) ,
288+ updatedAtMs : completedAt ,
289+ schedule : { kind : "cron" , expr : "10 9 * * *" , tz : "UTC" } ,
290+ sessionTarget : "isolated" ,
291+ wakeMode : "next-heartbeat" ,
292+ payload : { kind : "agentTurn" , message : "daily reminder" } ,
293+ state : {
294+ nextRunAtMs : dueAt ,
295+ lastRunAtMs : completedAt ,
296+ lastRunStatus : "ok" ,
297+ } ,
298+ } ,
299+ ] ) ;
300+
301+ const cron = createRestartCronService ( {
302+ storePath : store . storePath ,
303+ enqueueSystemEvent,
304+ requestHeartbeat,
305+ runIsolatedAgentJob,
306+ nowMs : ( ) => startNow ,
307+ startupDeferredMissedAgentJobDelayMs : 120_000 ,
308+ } ) ;
309+
310+ try {
311+ await cron . start ( ) ;
312+
313+ expect ( runIsolatedAgentJob ) . not . toHaveBeenCalled ( ) ;
314+ expect ( enqueueSystemEvent ) . not . toHaveBeenCalled ( ) ;
315+ expect ( requestHeartbeat ) . not . toHaveBeenCalled ( ) ;
316+
317+ const listedJobs = await cron . list ( { includeDisabled : true } ) ;
318+ const updated = listedJobs . find ( ( job ) => job . id === "startup-isolated-agent-already-ok" ) ;
319+ expect ( updated ?. state . lastRunStatus ) . toBe ( "ok" ) ;
320+ expect ( updated ?. state . nextRunAtMs ) . toBe ( Date . parse ( "2025-12-14T09:10:00.000Z" ) ) ;
321+ } finally {
322+ cron . stop ( ) ;
323+ await store . cleanup ( ) ;
324+ }
325+ } ) ;
326+
327+ it ( "replays a newer missed cron slot behind a completed persisted slot" , async ( ) => {
328+ vi . setSystemTime ( new Date ( "2025-12-13T04:10:00.000Z" ) ) ;
329+ await withRestartedCron (
330+ [
331+ {
332+ id : "restart-completed-slot-newer-miss" ,
333+ name : "completed slot with newer miss" ,
334+ enabled : true ,
335+ createdAtMs : Date . parse ( "2025-12-10T12:00:00.000Z" ) ,
336+ updatedAtMs : Date . parse ( "2025-12-13T04:01:30.000Z" ) ,
337+ schedule : { kind : "cron" , expr : "* * * * *" , tz : "UTC" } ,
338+ sessionTarget : "main" ,
339+ wakeMode : "next-heartbeat" ,
340+ payload : { kind : "systemEvent" , text : "newer slot missed" } ,
341+ state : {
342+ nextRunAtMs : Date . parse ( "2025-12-13T04:01:00.000Z" ) ,
343+ lastRunAtMs : Date . parse ( "2025-12-13T04:01:00.000Z" ) ,
344+ lastRunStatus : "ok" ,
345+ } ,
346+ } ,
347+ ] ,
348+ async ( { enqueueSystemEvent, requestHeartbeat } ) => {
349+ expectQueuedSystemEvent ( enqueueSystemEvent , "newer slot missed" ) ;
350+ expect ( requestHeartbeat ) . toHaveBeenCalled ( ) ;
351+ } ,
352+ ) ;
353+ } ) ;
354+
355+ it ( "replays a cron slot due exactly at restart behind a completed persisted slot" , async ( ) => {
356+ vi . setSystemTime ( new Date ( "2025-12-13T04:02:00.000Z" ) ) ;
357+ await withRestartedCron (
358+ [
359+ {
360+ id : "restart-completed-slot-boundary-miss" ,
361+ name : "completed slot with boundary miss" ,
362+ enabled : true ,
363+ createdAtMs : Date . parse ( "2025-12-10T12:00:00.000Z" ) ,
364+ updatedAtMs : Date . parse ( "2025-12-13T04:01:30.000Z" ) ,
365+ schedule : { kind : "cron" , expr : "* * * * *" , tz : "UTC" } ,
366+ sessionTarget : "main" ,
367+ wakeMode : "next-heartbeat" ,
368+ payload : { kind : "systemEvent" , text : "boundary slot missed" } ,
369+ state : {
370+ nextRunAtMs : Date . parse ( "2025-12-13T04:01:00.000Z" ) ,
371+ lastRunAtMs : Date . parse ( "2025-12-13T04:01:00.000Z" ) ,
372+ lastRunStatus : "ok" ,
373+ } ,
374+ } ,
375+ ] ,
376+ async ( { enqueueSystemEvent, requestHeartbeat } ) => {
377+ expectQueuedSystemEvent ( enqueueSystemEvent , "boundary slot missed" ) ;
378+ expect ( requestHeartbeat ) . toHaveBeenCalled ( ) ;
379+ } ,
380+ ) ;
381+ } ) ;
382+
273383 it ( "marks interrupted recurring jobs failed instead of replaying them on startup" , async ( ) => {
274384 const dueAt = Date . parse ( "2025-12-13T16:00:00.000Z" ) ;
275385 const staleRunningAt = Date . parse ( "2025-12-13T16:30:00.000Z" ) ;
0 commit comments