From c68b05316241c93a0db20e9af72fad14f7981924 Mon Sep 17 00:00:00 2001 From: Phil Larson Date: Sat, 11 Jul 2026 12:28:58 -0700 Subject: [PATCH] fix(missions): reconcile lifecycle invariants --- .changeset/mission-lifecycle-invariants.md | 7 +++ .../core/src/__tests__/mission-store.test.ts | 30 ++++++++++++ packages/core/src/mission-store.ts | 2 +- .../src/__tests__/mission-autopilot.test.ts | 30 ++++++++++++ packages/engine/src/mission-autopilot.ts | 49 +++++++++++++++++-- packages/engine/src/scheduler.ts | 12 ++++- 6 files changed, 123 insertions(+), 7 deletions(-) create mode 100644 .changeset/mission-lifecycle-invariants.md diff --git a/.changeset/mission-lifecycle-invariants.md b/.changeset/mission-lifecycle-invariants.md new file mode 100644 index 0000000000..787adf19ee --- /dev/null +++ b/.changeset/mission-lifecycle-invariants.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Reconcile completed and stale generated-fix mission invariants. +category: fix +dev: Completed missions now normalize autopilot/auto-advance to inactive during autopilot completion, polling, and restart recovery. Mission reconciliation also supersedes generated fix features whose own validator state is already passed, and the scheduler startup sweep runs stale generated-fix reconciliation before trying to relink or retriage active slice features. This prevents complete missions from remaining watched and prevents stale generated fix rows from keeping otherwise-drained missions administratively active. diff --git a/packages/core/src/__tests__/mission-store.test.ts b/packages/core/src/__tests__/mission-store.test.ts index 41abb73979..83a1b537e8 100644 --- a/packages/core/src/__tests__/mission-store.test.ts +++ b/packages/core/src/__tests__/mission-store.test.ts @@ -1748,6 +1748,36 @@ describe("MissionStore", () => { expect(store.getFeature(staleFix.id)).toMatchObject({ status: "done", loopState: "passed", lastValidatorStatus: "passed" }); expect(store.computeSliceStatus(slice.id)).toBe("complete"); }); + + it("reconciles generated fix features whose own validator already passed", () => { + const mission = store.createMission({ title: "Mission" }); + const milestone = store.addMilestone(mission.id, { title: "Milestone" }); + const slice = store.addSlice(milestone.id, { title: "Slice" }); + const source = store.addFeature(slice.id, { title: "Source" }); + + store.updateFeature(source.id, { status: "done" }); + const failedRun = store.startValidatorRun(source.id, "task_completion"); + store.completeValidatorRun(failedRun.id, "failed", "missing evidence"); + const staleFix = store.createGeneratedFixFeature(source.id, failedRun.id, ["CA-source"]); + store.updateFeature(staleFix.id, { + status: "blocked", + loopState: "passed", + lastValidatorStatus: "passed", + taskId: undefined, + }); + + expect(store.computeSliceStatus(slice.id)).toBe("pending"); + + const report = store.reconcileSupersededGeneratedFixFeatures(slice.id); + + expect(report).toEqual({ supersededCount: 1, featureIds: [staleFix.id] }); + expect(store.getFeature(staleFix.id)).toMatchObject({ + status: "done", + taskId: undefined, + loopState: "passed", + lastValidatorStatus: "passed", + }); + }); }); describe("computeMilestoneStatus", () => { diff --git a/packages/core/src/mission-store.ts b/packages/core/src/mission-store.ts index 68ad8a0976..752bc4c525 100644 --- a/packages/core/src/mission-store.ts +++ b/packages/core/src/mission-store.ts @@ -3210,7 +3210,7 @@ export class MissionStore extends EventEmitter { }; const supersededFeatureIds = features - .filter((feature) => feature.generatedFromFeatureId && hasPassedAncestor(feature)) + .filter((feature) => feature.generatedFromFeatureId && (featureHasPassed(feature) || hasPassedAncestor(feature))) .filter((feature) => feature.status !== "done" || feature.loopState !== "passed" || feature.lastValidatorStatus !== "passed") .map((feature) => feature.id); diff --git a/packages/engine/src/__tests__/mission-autopilot.test.ts b/packages/engine/src/__tests__/mission-autopilot.test.ts index 99b805f0bd..6dc5fb9ba8 100644 --- a/packages/engine/src/__tests__/mission-autopilot.test.ts +++ b/packages/engine/src/__tests__/mission-autopilot.test.ts @@ -1064,6 +1064,36 @@ describe("MissionAutopilot", () => { expect(ap.isWatching("M-FOUR")).toBe(false); }); + it("normalizes complete missions that still have autopilot watching", async () => { + const mission = createMockMission({ + id: "M-COMPLETE", + status: "complete", + autoAdvance: true, + autopilotEnabled: true, + autopilotState: "watching", + }); + const store = createMockMissionStore([mission]); + const ap = new MissionAutopilot(taskStore as any, store as any, { scheduler }); + + await ap.recoverMissions(store as any); + + expect(store.updateMission).toHaveBeenCalledWith( + "M-COMPLETE", + expect.objectContaining({ + autoAdvance: false, + autopilotEnabled: false, + autopilotState: "inactive", + }), + ); + expect(store.logMissionEvent).toHaveBeenCalledWith( + "M-COMPLETE", + "autopilot_disabled", + expect.stringContaining("Autopilot disabled for already-complete mission"), + expect.objectContaining({ source: "recoverMissions" }), + ); + expect(ap.isWatching("M-COMPLETE")).toBe(false); + }); + it("recovers missions stuck in activating state", async () => { const mission = createMockMission({ autopilotState: "activating" }); const store = createMockMissionStore([mission]); diff --git a/packages/engine/src/mission-autopilot.ts b/packages/engine/src/mission-autopilot.ts index b52aff576b..5c1b5bb54f 100644 --- a/packages/engine/src/mission-autopilot.ts +++ b/packages/engine/src/mission-autopilot.ts @@ -528,9 +528,7 @@ export class MissionAutopilot { { milestoneCount: milestones.length }, ); this.updateActivity(missionId); - this.setAutopilotState(missionId, "inactive"); - this.watchedMissions.delete(missionId); - this.perMissionTaskRetries.delete(missionId); + this.normalizeCompleteMissionAutopilotState(missionId, "checkMissionCompletion"); return true; } @@ -580,8 +578,13 @@ export class MissionAutopilot { const missions = this.missionStore.listMissions(); for (const mission of missions) { + if (mission.status === "complete") { + this.normalizeCompleteMissionAutopilotState(mission.id, "poll"); + continue; + } + // Auto-watch missions with autopilot enabled that aren't being watched - if (mission.autopilotEnabled && !this.isWatching(mission.id) && mission.status !== "complete" && mission.status !== "archived") { + if (mission.autopilotEnabled && !this.isWatching(mission.id) && mission.status !== "archived") { autopilotLog.log(`Poll: auto-watching mission ${mission.id}`); this.watchMission(mission.id); } @@ -776,7 +779,12 @@ export class MissionAutopilot { let inconsistencyFixes = 0; for (const mission of missions) { - if (!mission.autopilotEnabled || mission.status === "complete" || mission.status === "archived") { + if (mission.status === "complete") { + this.normalizeCompleteMissionAutopilotState(mission.id, "recoverMissions"); + continue; + } + + if (!mission.autopilotEnabled || mission.status === "archived") { continue; } @@ -820,6 +828,37 @@ export class MissionAutopilot { } } + private normalizeCompleteMissionAutopilotState(missionId: string, source: string): void { + const mission = this.missionStore.getMission(missionId); + if (!mission) { + return; + } + + this.watchedMissions.delete(missionId); + this.perMissionTaskRetries.delete(missionId); + + if (!mission.autopilotEnabled && !mission.autoAdvance && mission.autopilotState === "inactive") { + return; + } + + this.missionStore.updateMission(missionId, { + autoAdvance: false, + autopilotEnabled: false, + autopilotState: "inactive", + }); + this.logMissionEventSafe( + missionId, + "autopilot_disabled", + `Autopilot disabled for already-complete mission ${mission.title}`, + { + source, + previousAutoAdvance: mission.autoAdvance, + previousAutopilotEnabled: mission.autopilotEnabled, + previousAutopilotState: mission.autopilotState ?? "inactive", + }, + ); + } + private async reconcileMissionConsistency( mission: MissionWithHierarchy, ): Promise { diff --git a/packages/engine/src/scheduler.ts b/packages/engine/src/scheduler.ts index 72c6ce0ed2..2798fd898d 100644 --- a/packages/engine/src/scheduler.ts +++ b/packages/engine/src/scheduler.ts @@ -3073,8 +3073,18 @@ export class Scheduler { for (const slice of activeSlices) { const missionAutoTriageEnabled = mission.autopilotEnabled === true || mission.autoAdvance === true; + const supersededFixes = missionStore.reconcileSupersededGeneratedFixFeatures(slice.id); + if (supersededFixes.supersededCount > 0) { + totalFixed += supersededFixes.supersededCount; + schedulerLog.warn( + `Superseded ${supersededFixes.supersededCount} stale generated fix feature(s) during mission reconciliation for slice ${slice.id}`, + ); + } + const features = supersededFixes.supersededCount > 0 + ? missionStore.listFeatures(slice.id) + : slice.features; - for (const feature of slice.features) { + for (const feature of features) { let featureForReconciliation = feature; let task: Task | undefined; if (feature.taskId) {