@@ -5,6 +5,13 @@ import {
55 HEARTBEAT_SKIP_REQUESTS_IN_FLIGHT ,
66 type HeartbeatRunResult ,
77} from "../infra/heartbeat-wake.js" ;
8+ import {
9+ consumeSelectedSystemEventEntries ,
10+ drainSystemEventEntries ,
11+ enqueueSystemEventEntry ,
12+ peekSystemEventEntries ,
13+ resetSystemEventsForTest ,
14+ } from "../infra/system-events.js" ;
815import type { CronEvent , CronServiceDeps } from "./service.js" ;
916import { CronService } from "./service.js" ;
1017import {
@@ -60,14 +67,34 @@ type CronHarnessOptions = {
6067 runIsolatedAgentJob ?: CronServiceDeps [ "runIsolatedAgentJob" ] ;
6168 runHeartbeatOnce ?: NonNullable < CronServiceDeps [ "runHeartbeatOnce" ] > ;
6269 nowMs ?: ( ) => number ;
70+ cronConfig ?: CronServiceDeps [ "cronConfig" ] ;
71+ useRemovableSystemEventQueue ?: boolean ;
6372 wakeNowHeartbeatBusyMaxWaitMs ?: number ;
6473 wakeNowHeartbeatBusyRetryDelayMs ?: number ;
6574 withEvents ?: boolean ;
6675} ;
6776
6877async function createCronHarness ( options : CronHarnessOptions = { } ) {
6978 const store = await makeStorePath ( ) ;
70- const enqueueSystemEvent = vi . fn ( ) ;
79+ const enqueueSystemEvent = options . useRemovableSystemEventQueue
80+ ? vi . fn ( ( text : string , opts ?: Parameters < CronServiceDeps [ "enqueueSystemEvent" ] > [ 1 ] ) => {
81+ if ( ! opts ?. sessionKey ) {
82+ throw new Error ( "test removable queue requires a sessionKey" ) ;
83+ }
84+ const event = enqueueSystemEventEntry ( text , {
85+ sessionKey : opts . sessionKey ,
86+ contextKey : opts . contextKey ,
87+ deliveryContext : opts . deliveryContext ,
88+ } ) ;
89+ return event
90+ ? {
91+ accepted : true ,
92+ remove : ( ) =>
93+ consumeSelectedSystemEventEntries ( opts . sessionKey as string , [ event ] ) . length > 0 ,
94+ }
95+ : { accepted : false } ;
96+ } )
97+ : vi . fn ( ) ;
7198 const requestHeartbeat = vi . fn ( ) ;
7299 const events = options . withEvents === false ? undefined : createCronEventHarness ( ) ;
73100
@@ -76,6 +103,7 @@ async function createCronHarness(options: CronHarnessOptions = {}) {
76103 cronEnabled : true ,
77104 log : noopLogger ,
78105 ...( options . nowMs ? { nowMs : options . nowMs } : { } ) ,
106+ ...( options . cronConfig ? { cronConfig : options . cronConfig } : { } ) ,
79107 ...( options . wakeNowHeartbeatBusyMaxWaitMs !== undefined
80108 ? { wakeNowHeartbeatBusyMaxWaitMs : options . wakeNowHeartbeatBusyMaxWaitMs }
81109 : { } ) ,
@@ -236,10 +264,23 @@ function expectQueuedCronHeartbeat(
236264 expectCronRunSessionKey ( request ?. sessionKey , params . jobId ) ;
237265}
238266
267+ function getPostedSystemEventSessionKeys ( enqueueSystemEvent : ReturnType < typeof vi . fn > ) {
268+ return enqueueSystemEvent . mock . calls
269+ . map ( ( [ , options ] ) => ( options as { sessionKey ?: string } | undefined ) ?. sessionKey )
270+ . filter ( ( sessionKey ) : sessionKey is string => Boolean ( sessionKey ) ) ;
271+ }
272+
273+ function expectNoQueuedEvents ( sessionKeys : readonly string [ ] ) {
274+ for ( const sessionKey of sessionKeys ) {
275+ expect ( peekSystemEventEntries ( sessionKey ) ) . toHaveLength ( 0 ) ;
276+ }
277+ }
278+
239279async function stopCronAndCleanup ( cron : CronService , store : { cleanup : ( ) => Promise < void > } ) {
240280 await cron . status ( ) ;
241281 cron . stop ( ) ;
242282 await store . cleanup ( ) ;
283+ resetSystemEventsForTest ( ) ;
243284}
244285
245286function createStartedCronService (
@@ -449,6 +490,101 @@ describe("CronService", () => {
449490 await stopCronAndCleanup ( cron , store ) ;
450491 } ) ;
451492
493+ it ( "retries disabled one-shot main wakes without leaving failed-attempt system events" , async ( ) => {
494+ resetSystemEventsForTest ( ) ;
495+ const atMs = Date . parse ( "2025-12-13T00:00:02.000Z" ) ;
496+ let now = atMs ;
497+ const consumedTexts : string [ ] = [ ] ;
498+ const runHeartbeatOnce = vi . fn (
499+ async ( opts ?: Parameters < NonNullable < CronServiceDeps [ "runHeartbeatOnce" ] > > [ 0 ] ) => {
500+ if ( runHeartbeatOnce . mock . calls . length < 3 ) {
501+ return { status : "skipped" as const , reason : "disabled" } ;
502+ }
503+ const sessionKey = opts ?. sessionKey ;
504+ if ( sessionKey ) {
505+ consumedTexts . push ( ...drainSystemEventEntries ( sessionKey ) . map ( ( event ) => event . text ) ) ;
506+ }
507+ return { status : "ran" as const , durationMs : 1 } ;
508+ } ,
509+ ) ;
510+ const { store, cron, enqueueSystemEvent, requestHeartbeat } = await createCronHarness ( {
511+ runHeartbeatOnce,
512+ nowMs : ( ) => now ,
513+ useRemovableSystemEventQueue : true ,
514+ withEvents : false ,
515+ } ) ;
516+ const job = await addMainOneShotHelloJob ( cron , {
517+ atMs,
518+ name : "one-shot disabled heartbeat retries cleanly" ,
519+ } ) ;
520+
521+ await cron . run ( job . id , "due" ) ;
522+ let jobs = await cron . list ( { includeDisabled : true } ) ;
523+ let updated = jobs . find ( ( j ) => j . id === job . id ) ;
524+ expect ( updated ?. enabled ) . toBe ( true ) ;
525+ expect ( updated ?. state . lastStatus ) . toBe ( "skipped" ) ;
526+ expect ( updated ?. state . lastError ) . toBe ( "disabled" ) ;
527+ expect ( updated ?. state . consecutiveSkipped ) . toBe ( 1 ) ;
528+ expect ( updated ?. state . nextRunAtMs ) . toBe ( atMs + 30_000 ) ;
529+ expectNoQueuedEvents ( getPostedSystemEventSessionKeys ( enqueueSystemEvent ) ) ;
530+
531+ now = updated ?. state . nextRunAtMs ?? now ;
532+ await cron . run ( job . id , "due" ) ;
533+ jobs = await cron . list ( { includeDisabled : true } ) ;
534+ updated = jobs . find ( ( j ) => j . id === job . id ) ;
535+ expect ( updated ?. enabled ) . toBe ( true ) ;
536+ expect ( updated ?. state . consecutiveSkipped ) . toBe ( 2 ) ;
537+ expect ( updated ?. state . nextRunAtMs ) . toBe ( atMs + 90_000 ) ;
538+ expectNoQueuedEvents ( getPostedSystemEventSessionKeys ( enqueueSystemEvent ) ) ;
539+
540+ now = updated ?. state . nextRunAtMs ?? now ;
541+ await cron . run ( job . id , "due" ) ;
542+
543+ jobs = await cron . list ( { includeDisabled : true } ) ;
544+ expect ( jobs . find ( ( j ) => j . id === job . id ) ) . toBeUndefined ( ) ;
545+ expect ( runHeartbeatOnce ) . toHaveBeenCalledTimes ( 3 ) ;
546+ expect ( requestHeartbeat ) . not . toHaveBeenCalled ( ) ;
547+ expect ( consumedTexts ) . toEqual ( [ "hello" ] ) ;
548+ expectNoQueuedEvents ( getPostedSystemEventSessionKeys ( enqueueSystemEvent ) ) ;
549+
550+ await stopCronAndCleanup ( cron , store ) ;
551+ } ) ;
552+
553+ it ( "disables exhausted disabled-heartbeat one-shots without leaving queued events" , async ( ) => {
554+ resetSystemEventsForTest ( ) ;
555+ const atMs = Date . parse ( "2025-12-13T00:00:02.000Z" ) ;
556+ const runHeartbeatOnce = vi . fn ( async ( ) => ( {
557+ status : "skipped" as const ,
558+ reason : "disabled" ,
559+ } ) ) ;
560+ const { store, cron, enqueueSystemEvent, requestHeartbeat } = await createCronHarness ( {
561+ runHeartbeatOnce,
562+ nowMs : ( ) => atMs ,
563+ cronConfig : { retry : { maxAttempts : 0 , backoffMs : [ 30_000 ] } } ,
564+ useRemovableSystemEventQueue : true ,
565+ withEvents : false ,
566+ } ) ;
567+ const job = await addMainOneShotHelloJob ( cron , {
568+ atMs,
569+ name : "one-shot disabled heartbeat exhausted" ,
570+ } ) ;
571+
572+ await cron . run ( job . id , "due" ) ;
573+
574+ const jobs = await cron . list ( { includeDisabled : true } ) ;
575+ const updated = jobs . find ( ( j ) => j . id === job . id ) ;
576+ expect ( updated ?. enabled ) . toBe ( false ) ;
577+ expect ( updated ?. state . lastStatus ) . toBe ( "skipped" ) ;
578+ expect ( updated ?. state . lastError ) . toBe ( "disabled" ) ;
579+ expect ( updated ?. state . consecutiveSkipped ) . toBe ( 1 ) ;
580+ expect ( updated ?. state . nextRunAtMs ) . toBeUndefined ( ) ;
581+ expect ( runHeartbeatOnce ) . toHaveBeenCalledTimes ( 1 ) ;
582+ expect ( requestHeartbeat ) . not . toHaveBeenCalled ( ) ;
583+ expectNoQueuedEvents ( getPostedSystemEventSessionKeys ( enqueueSystemEvent ) ) ;
584+
585+ await stopCronAndCleanup ( cron , store ) ;
586+ } ) ;
587+
452588 it ( "runs an isolated job without posting a fallback summary to main" , async ( ) => {
453589 const runIsolatedAgentJob = vi . fn ( async ( ) => ( { status : "ok" as const , summary : "done" } ) ) ;
454590 const { store, cron, enqueueSystemEvent, requestHeartbeat, events } =
0 commit comments