Skip to content

Commit 2869e81

Browse files
committed
fix: extend safety net to recover stopped jobs as failed in plan state
1 parent c054731 commit 2869e81

2 files changed

Lines changed: 49 additions & 1 deletion

File tree

src/lib/orchestrator.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -465,7 +465,7 @@ export class Orchestrator {
465465
if (stateJob.status === 'completed') {
466466
await updatePlanJob(plan.id, planJob.name, { status: 'completed' });
467467
planJob.status = 'completed';
468-
} else if (stateJob.status === 'failed') {
468+
} else if (stateJob.status === 'failed' || stateJob.status === 'stopped') {
469469
await updatePlanJob(plan.id, planJob.name, {
470470
status: 'failed',
471471
error: 'recovered from missed completion event',

tests/lib/orchestrator.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -906,6 +906,54 @@ describe('orchestrator', () => {
906906
// Safety net should have corrected the stuck job status
907907
expect(planStateMod.updatePlanJob).toHaveBeenCalledWith('plan-1', 'stuck-job', { status: 'completed' });
908908
});
909+
910+
it('reconciliation safety net recovers stopped jobs as failed', async () => {
911+
planState = makePlan({
912+
status: 'running',
913+
jobs: [
914+
makeJob('killed-job', { status: 'running', mergeOrder: 0 }),
915+
makeJob('merged-job', { status: 'merged', mergeOrder: 1 }),
916+
],
917+
});
918+
919+
runningJobs = [];
920+
921+
spyOn(jobStateMod, 'loadJobState').mockImplementation(async () => ({
922+
version: 2,
923+
jobs: [
924+
{
925+
id: 'killed-job-id',
926+
name: 'killed-job',
927+
planId: 'plan-1',
928+
status: 'stopped' as const,
929+
worktreePath: '/tmp/w1',
930+
branch: 'mc/killed-job',
931+
tmuxTarget: 'mc-killed-job',
932+
placement: 'session' as const,
933+
prompt: 'do killed-job',
934+
mode: 'vanilla' as const,
935+
createdAt: '2026-01-01T00:00:00.000Z',
936+
completedAt: '2026-01-01T00:01:00.000Z',
937+
},
938+
],
939+
updatedAt: new Date().toISOString(),
940+
}));
941+
942+
const orchestrator = new Orchestrator(monitor as any, {
943+
defaultPlacement: 'session',
944+
pollInterval: 10000,
945+
idleThreshold: 300000,
946+
worktreeBasePath: '/tmp',
947+
omo: { enabled: false, defaultMode: 'vanilla' },
948+
} as any);
949+
950+
await (orchestrator as any).reconcile();
951+
952+
expect(planStateMod.updatePlanJob).toHaveBeenCalledWith('plan-1', 'killed-job', {
953+
status: 'failed',
954+
error: 'recovered from missed completion event',
955+
});
956+
});
909957
});
910958

911959
describe('orchestrator reconcile pending (dirty re-reconcile)', () => {

0 commit comments

Comments
 (0)