11import { randomUUID } from 'crypto' ;
22import type { MCConfig } from './config' ;
33import type { PlanSpec , JobSpec , PlanStatus , CheckpointType , CheckpointContext } from './plan-types' ;
4- import { loadPlan , savePlan , updatePlanJob , clearPlan , validateGhAuth } from './plan-state' ;
4+ import { loadPlan , savePlan , updatePlanJob , updatePlanFields , clearPlan , validateGhAuth } from './plan-state' ;
55import { getDefaultBranch } from './git' ;
66import { createIntegrationBranch , deleteIntegrationBranch } from './integration' ;
77import { MergeTrain , checkMergeability , type MergeTestReport , validateTouchSet } from './merge-train' ;
@@ -291,7 +291,6 @@ export class Orchestrator {
291291
292292 const plan = await loadPlan ( ) ;
293293 if ( plan && plan . status === 'paused' ) {
294- // Track jobs approved for merge so reconciler doesn't re-checkpoint them
295294 if ( wasPreMerge ) {
296295 for ( const job of plan . jobs ) {
297296 if ( job . status === 'ready_to_merge' ) {
@@ -300,10 +299,11 @@ export class Orchestrator {
300299 }
301300 }
302301
303- plan . status = 'running' ;
304- plan . checkpoint = null ;
305- plan . checkpointContext = null ;
306- await savePlan ( plan ) ;
302+ await updatePlanFields ( plan . id , {
303+ status : 'running' ,
304+ checkpoint : null ,
305+ checkpointContext : null ,
306+ } ) ;
307307 this . showToast ( 'Mission Control' , 'Checkpoint cleared, resuming execution.' , 'info' ) ;
308308
309309 if ( ! this . isRunning ) {
@@ -391,10 +391,11 @@ export class Orchestrator {
391391
392392 private async setCheckpoint ( type : CheckpointType , plan : PlanSpec , context ?: CheckpointContext ) : Promise < void > {
393393 this . checkpoint = type ;
394- plan . status = 'paused' ;
395- plan . checkpoint = type ;
396- plan . checkpointContext = context ?? null ;
397- await savePlan ( plan ) ;
394+ await updatePlanFields ( plan . id , {
395+ status : 'paused' ,
396+ checkpoint : type ,
397+ checkpointContext : context ?? null ,
398+ } ) ;
398399 this . stopReconciler ( ) ;
399400 this . showToast (
400401 'Mission Control' ,
@@ -447,6 +448,32 @@ export class Orchestrator {
447448 const runningJobs = ( await getRunningJobs ( ) ) . filter ( ( job ) => job . planId === plan . id ) ;
448449 let runningCount = runningJobs . length ;
449450
451+ // Safety net: detect plan jobs stuck as 'running' when jobs.json already
452+ // shows them as completed/failed (can happen if a prior savePlan race
453+ // overwrote their status, or if the 'complete' event was missed).
454+ const jobState = await loadJobState ( ) ;
455+ const runningJobNames = new Set ( runningJobs . map ( ( j ) => j . name ) ) ;
456+ for ( const planJob of plan . jobs ) {
457+ if ( planJob . status !== 'running' ) continue ;
458+ if ( runningJobNames . has ( planJob . name ) ) continue ;
459+
460+ const stateJob = jobState . jobs . find (
461+ ( j ) => j . name === planJob . name && j . planId === plan . id ,
462+ ) ;
463+ if ( ! stateJob ) continue ;
464+
465+ if ( stateJob . status === 'completed' ) {
466+ await updatePlanJob ( plan . id , planJob . name , { status : 'completed' } ) ;
467+ planJob . status = 'completed' ;
468+ } else if ( stateJob . status === 'failed' ) {
469+ await updatePlanJob ( plan . id , planJob . name , {
470+ status : 'failed' ,
471+ error : 'recovered from missed completion event' ,
472+ } ) ;
473+ planJob . status = 'failed' ;
474+ }
475+ }
476+
450477 const mergeOrder = [ ...plan . jobs ] . sort (
451478 ( a , b ) => ( a . mergeOrder ?? Number . MAX_SAFE_INTEGER ) - ( b . mergeOrder ?? Number . MAX_SAFE_INTEGER ) ,
452479 ) ;
@@ -654,23 +681,26 @@ export class Orchestrator {
654681 return ;
655682 }
656683
657- latestPlan . status = 'creating_pr' ;
658- await savePlan ( latestPlan ) ;
684+ await updatePlanFields ( latestPlan . id , { status : 'creating_pr' } ) ;
659685
660686 try {
661687 const prUrl = await this . createPR ( ) ;
662- latestPlan . prUrl = prUrl ;
663- latestPlan . status = 'completed' ;
664- latestPlan . completedAt = new Date ( ) . toISOString ( ) ;
665- await savePlan ( latestPlan ) ;
688+ const completedAt = new Date ( ) . toISOString ( ) ;
689+ await updatePlanFields ( latestPlan . id , {
690+ status : 'completed' ,
691+ prUrl,
692+ completedAt,
693+ } ) ;
666694 this . stopReconciler ( ) ;
667695 this . unsubscribeFromMonitorEvents ( ) ;
668696 this . showToast ( 'Mission Control' , `Plan completed! PR: ${ prUrl } ` , 'success' ) ;
669697 this . notify ( `🎉 Plan "${ latestPlan . name } " completed! PR created: ${ prUrl } ` ) ;
670698 } catch ( prError ) {
671- latestPlan . status = 'failed' ;
672- latestPlan . completedAt = new Date ( ) . toISOString ( ) ;
673- await savePlan ( latestPlan ) ;
699+ const completedAt = new Date ( ) . toISOString ( ) ;
700+ await updatePlanFields ( latestPlan . id , {
701+ status : 'failed' ,
702+ completedAt,
703+ } ) ;
674704 this . stopReconciler ( ) ;
675705 this . unsubscribeFromMonitorEvents ( ) ;
676706 const errMsg = prError instanceof Error ? prError . message : String ( prError ) ;
@@ -681,15 +711,17 @@ export class Orchestrator {
681711 }
682712
683713 if ( latestPlan . status !== plan . status ) {
684- latestPlan . status = plan . status ;
714+ const updates : { status : typeof plan . status ; completedAt ?: string } = {
715+ status : plan . status ,
716+ } ;
685717 if ( plan . status === 'failed' ) {
686- latestPlan . completedAt = new Date ( ) . toISOString ( ) ;
718+ updates . completedAt = new Date ( ) . toISOString ( ) ;
687719 this . stopReconciler ( ) ;
688720 this . unsubscribeFromMonitorEvents ( ) ;
689721 this . showToast ( 'Mission Control' , `Plan "${ latestPlan . name } " failed.` , 'error' ) ;
690722 this . notify ( `❌ Plan "${ latestPlan . name } " failed.` ) ;
691723 }
692- await savePlan ( latestPlan ) ;
724+ await updatePlanFields ( latestPlan . id , updates ) ;
693725 }
694726 }
695727
@@ -1154,10 +1186,11 @@ If your work needs human review before it can proceed: mc_report(status: "needs_
11541186 }
11551187
11561188 if ( plan . status === 'paused' ) {
1157- plan . status = 'running' ;
1158- plan . checkpoint = null ;
1159- plan . checkpointContext = null ;
1160- await savePlan ( plan ) ;
1189+ await updatePlanFields ( plan . id , {
1190+ status : 'running' ,
1191+ checkpoint : null ,
1192+ checkpointContext : null ,
1193+ } ) ;
11611194 }
11621195 this . checkpoint = null ;
11631196
@@ -1196,12 +1229,10 @@ If your work needs human review before it can proceed: mc_report(status: "needs_
11961229 }
11971230
11981231 if ( hasDeadRunningJob ) {
1199- const currentPlan = await loadPlan ( ) ;
1200- if ( currentPlan ) {
1201- currentPlan . status = 'failed' ;
1202- currentPlan . completedAt = new Date ( ) . toISOString ( ) ;
1203- await savePlan ( currentPlan ) ;
1204- }
1232+ await updatePlanFields ( plan . id , {
1233+ status : 'failed' ,
1234+ completedAt : new Date ( ) . toISOString ( ) ,
1235+ } ) ;
12051236 return ;
12061237 }
12071238
0 commit comments