@@ -152,11 +152,17 @@ function createTaskRegistryMaintenanceHarness(params: {
152152 status : patch . status ,
153153 endedAt : patch . endedAt ,
154154 lastEventAt : patch . lastEventAt ?? patch . endedAt ,
155- ...( patch . error !== undefined ? { error : patch . error } : { } ) ,
156155 ...( patch . terminalSummary !== undefined
157156 ? { terminalSummary : patch . terminalSummary ?? undefined }
158157 : { } ) ,
159158 } satisfies TaskRecord ;
159+ if ( Object . hasOwn ( patch , "error" ) ) {
160+ if ( patch . error === undefined ) {
161+ delete next . error ;
162+ } else {
163+ next . error = patch . error ;
164+ }
165+ }
160166 currentTasks . set ( patch . taskId , next ) ;
161167 return next ;
162168 } ,
@@ -532,6 +538,134 @@ describe("task-registry maintenance issue #60299", () => {
532538 expectTaskStatus ( currentTasks , task . taskId , "lost" ) ;
533539 } ) ;
534540
541+ it ( "recovers terminal lost cron tasks from durable run logs" , async ( ) => {
542+ const startedAt = Date . now ( ) - GRACE_EXPIRED_MS ;
543+ const task = makeStaleTask ( {
544+ runtime : "cron" ,
545+ sourceId : "cron-job-terminal-lost-ok" ,
546+ runId : `cron:cron-job-terminal-lost-ok:${ startedAt } ` ,
547+ status : "lost" ,
548+ error : "backing session missing" ,
549+ startedAt,
550+ endedAt : startedAt + 60_000 ,
551+ lastEventAt : startedAt + 60_000 ,
552+ cleanupAfter : Date . now ( ) + 60_000 ,
553+ } ) ;
554+
555+ const { currentTasks } = createTaskRegistryMaintenanceHarness ( {
556+ tasks : [ task ] ,
557+ cronRunLogEntries : {
558+ "cron-job-terminal-lost-ok" : [
559+ {
560+ ts : startedAt + 1250 ,
561+ jobId : "cron-job-terminal-lost-ok" ,
562+ action : "finished" ,
563+ status : "ok" ,
564+ summary : "done" ,
565+ runAtMs : startedAt ,
566+ durationMs : 1250 ,
567+ } ,
568+ ] ,
569+ } ,
570+ } ) ;
571+
572+ const reconciled = reconcileInspectableTasks ( ) ;
573+ expect ( reconciled ) . toEqual ( [
574+ expect . objectContaining ( {
575+ taskId : task . taskId ,
576+ status : "succeeded" ,
577+ endedAt : startedAt + 1250 ,
578+ terminalSummary : "done" ,
579+ } ) ,
580+ ] ) ;
581+ expect ( reconciled [ 0 ] ) . not . toHaveProperty ( "error" ) ;
582+ expect ( previewTaskRegistryMaintenance ( ) ) . toMatchObject ( { reconciled : 0 , recovered : 1 } ) ;
583+ expect ( await runTaskRegistryMaintenance ( ) ) . toMatchObject ( { reconciled : 0 , recovered : 1 } ) ;
584+ const recoveredTask = currentTasks . get ( task . taskId ) ;
585+ expect ( recoveredTask ) . toMatchObject ( {
586+ status : "succeeded" ,
587+ endedAt : startedAt + 1250 ,
588+ terminalSummary : "done" ,
589+ } ) ;
590+ expect ( recoveredTask ) . not . toHaveProperty ( "error" ) ;
591+ } ) ;
592+
593+ it ( "does not recover terminal lost cron tasks without a backing-session error" , async ( ) => {
594+ const startedAt = Date . now ( ) - GRACE_EXPIRED_MS ;
595+ const task = makeStaleTask ( {
596+ runtime : "cron" ,
597+ sourceId : "cron-job-terminal-lost-no-error" ,
598+ runId : `cron:cron-job-terminal-lost-no-error:${ startedAt } ` ,
599+ status : "lost" ,
600+ startedAt,
601+ endedAt : startedAt + 60_000 ,
602+ lastEventAt : startedAt + 60_000 ,
603+ cleanupAfter : Date . now ( ) + 60_000 ,
604+ } ) ;
605+
606+ const { currentTasks } = createTaskRegistryMaintenanceHarness ( {
607+ tasks : [ task ] ,
608+ cronRunLogEntries : {
609+ "cron-job-terminal-lost-no-error" : [
610+ {
611+ ts : startedAt + 1250 ,
612+ jobId : "cron-job-terminal-lost-no-error" ,
613+ action : "finished" ,
614+ status : "ok" ,
615+ summary : "done" ,
616+ runAtMs : startedAt ,
617+ durationMs : 1250 ,
618+ } ,
619+ ] ,
620+ } ,
621+ } ) ;
622+
623+ expect ( previewTaskRegistryMaintenance ( ) ) . toMatchObject ( { recovered : 0 } ) ;
624+ expect ( await runTaskRegistryMaintenance ( ) ) . toMatchObject ( { recovered : 0 } ) ;
625+ expect ( currentTasks . get ( task . taskId ) ) . toMatchObject ( {
626+ status : "lost" ,
627+ } ) ;
628+ } ) ;
629+
630+ it ( "does not recover terminal lost cron tasks with non-backing-session errors" , async ( ) => {
631+ const startedAt = Date . now ( ) - GRACE_EXPIRED_MS ;
632+ const task = makeStaleTask ( {
633+ runtime : "cron" ,
634+ sourceId : "cron-job-terminal-lost-other-error" ,
635+ runId : `cron:cron-job-terminal-lost-other-error:${ startedAt } ` ,
636+ status : "lost" ,
637+ error : "operator marked lost" ,
638+ startedAt,
639+ endedAt : startedAt + 60_000 ,
640+ lastEventAt : startedAt + 60_000 ,
641+ cleanupAfter : Date . now ( ) + 60_000 ,
642+ } ) ;
643+
644+ const { currentTasks } = createTaskRegistryMaintenanceHarness ( {
645+ tasks : [ task ] ,
646+ cronRunLogEntries : {
647+ "cron-job-terminal-lost-other-error" : [
648+ {
649+ ts : startedAt + 1250 ,
650+ jobId : "cron-job-terminal-lost-other-error" ,
651+ action : "finished" ,
652+ status : "ok" ,
653+ summary : "done" ,
654+ runAtMs : startedAt ,
655+ durationMs : 1250 ,
656+ } ,
657+ ] ,
658+ } ,
659+ } ) ;
660+
661+ expect ( previewTaskRegistryMaintenance ( ) ) . toMatchObject ( { recovered : 0 } ) ;
662+ expect ( await runTaskRegistryMaintenance ( ) ) . toMatchObject ( { recovered : 0 } ) ;
663+ expect ( currentTasks . get ( task . taskId ) ) . toMatchObject ( {
664+ status : "lost" ,
665+ error : "operator marked lost" ,
666+ } ) ;
667+ } ) ;
668+
535669 it ( "recovers interrupted cron tasks from durable cron job state when run logs are absent" , async ( ) => {
536670 const startedAt = Date . now ( ) - GRACE_EXPIRED_MS ;
537671 const task = makeStaleTask ( {
0 commit comments