diff --git a/packages/core/src/__tests__/mission-store.test.ts b/packages/core/src/__tests__/mission-store.test.ts index 0e07a37401..41abb73979 100644 --- a/packages/core/src/__tests__/mission-store.test.ts +++ b/packages/core/src/__tests__/mission-store.test.ts @@ -1699,17 +1699,25 @@ describe("MissionStore", () => { store.completeValidatorRun(fix1FailRun.id, "failed", "still missing evidence"); const fix2 = store.createGeneratedFixFeature(fix1.id, fix1FailRun.id, ["CA-fix1"]); + createTaskInDb(db, "FN-fix1", "Stale fix task", undefined, { column: "todo" }); + store.updateFeature(fix1.id, { taskId: "FN-fix1" }); + db.prepare("UPDATE tasks SET missionId = ?, sliceId = ? WHERE id = ?").run(mission.id, slice.id, "FN-fix1"); + store.updateFeature(fix1.id, { status: "blocked", loopState: "blocked", lastValidatorStatus: "failed" }); store.updateFeature(fix2.id, { status: "blocked", loopState: "blocked", lastValidatorStatus: "error" }); store.updateFeature(source.id, { status: "done", loopState: "passed", lastValidatorStatus: "passed" }); - expect(store.computeSliceStatus(slice.id)).toBe("pending"); + expect(store.computeSliceStatus(slice.id)).not.toBe("complete"); const report = store.reconcileSupersededGeneratedFixFeatures(slice.id); expect(report).toEqual({ supersededCount: 2, featureIds: [fix1.id, fix2.id] }); - expect(store.getFeature(fix1.id)).toMatchObject({ status: "done", loopState: "passed", lastValidatorStatus: "passed" }); + expect(store.getFeature(fix1.id)).toMatchObject({ status: "done", taskId: undefined, loopState: "passed", lastValidatorStatus: "passed" }); expect(store.getFeature(fix2.id)).toMatchObject({ status: "done", loopState: "passed", lastValidatorStatus: "passed" }); + expect(db.prepare("SELECT missionId, sliceId FROM tasks WHERE id = ?").get("FN-fix1")).toEqual({ + missionId: null, + sliceId: null, + }); expect(store.computeSliceStatus(slice.id)).toBe("complete"); }); @@ -1727,9 +1735,16 @@ describe("MissionStore", () => { expect(store.computeSliceStatus(slice.id)).toBe("pending"); + const events: string[] = []; + store.on("validator-run:completed", () => events.push("validator-run:completed")); + store.on("feature:updated", (feature: MissionFeature) => { + if (feature.id === staleFix.id) events.push("stale-fix:updated"); + }); + const passingRun = store.startValidatorRun(source.id, "task_completion"); store.completeValidatorRun(passingRun.id, "passed", "evidence now complete"); + expect(events).toEqual(["validator-run:completed", "stale-fix:updated"]); expect(store.getFeature(staleFix.id)).toMatchObject({ status: "done", loopState: "passed", lastValidatorStatus: "passed" }); expect(store.computeSliceStatus(slice.id)).toBe("complete"); }); diff --git a/packages/core/src/mission-store.ts b/packages/core/src/mission-store.ts index 4ec0db99c8..68ad8a0976 100644 --- a/packages/core/src/mission-store.ts +++ b/packages/core/src/mission-store.ts @@ -2750,6 +2750,7 @@ export class MissionStore extends EventEmitter { // Re-read the run to get the updated state const updatedRun = this.getValidatorRun(runId)!; + this.emit("validator-run:completed", updatedRun, result, durationMs); if (result === "passed") { const passedFeature = this.getFeature(run.featureId); @@ -2758,8 +2759,6 @@ export class MissionStore extends EventEmitter { } } - this.emit("validator-run:completed", updatedRun, result, durationMs); - return updatedRun; } @@ -3178,6 +3177,10 @@ export class MissionStore extends EventEmitter { * feature is later validated successfully, older descendants are no longer * actionable remediation work. Leaving them blocked/defined keeps the slice * pending forever even though the authoritative source feature has passed. + * + * FNXC:Missions 2026-07-05-22:09: + * Superseded generated Fix Features must become terminal and lose live board-task ownership. + * Otherwise mission recovery can keep resuming stale remediation tasks after the source feature is already validated. */ reconcileSupersededGeneratedFixFeatures(sliceId: string): { supersededCount: number; featureIds: string[] } { const features = this.listFeatures(sliceId); @@ -3211,17 +3214,22 @@ export class MissionStore extends EventEmitter { .filter((feature) => feature.status !== "done" || feature.loopState !== "passed" || feature.lastValidatorStatus !== "passed") .map((feature) => feature.id); - for (const featureId of supersededFeatureIds) { - this.updateFeature(featureId, { - status: "done", - loopState: "passed", - lastValidatorStatus: "passed", - }); - } - if (supersededFeatureIds.length > 0) { - this.recomputeSliceStatus(sliceId); - this.db.bumpLastModified(); + this.db.transaction(() => { + for (const featureId of supersededFeatureIds) { + const feature = this.getFeature(featureId); + if (!feature) continue; + this.updateFeature(featureId, { + status: "done", + taskId: undefined, + loopState: "passed", + lastValidatorStatus: "passed", + }); + if (feature.taskId) { + this.db.prepare("UPDATE tasks SET missionId = NULL, sliceId = NULL WHERE id = ? AND \"deletedAt\" IS NULL").run(feature.taskId); + } + } + }); } return { diff --git a/packages/engine/src/__tests__/mission-execution-loop.test.ts b/packages/engine/src/__tests__/mission-execution-loop.test.ts index 9f5ac3e84b..3a754b77fc 100644 --- a/packages/engine/src/__tests__/mission-execution-loop.test.ts +++ b/packages/engine/src/__tests__/mission-execution-loop.test.ts @@ -663,6 +663,47 @@ describe("MissionExecutionLoop", () => { expect(missionStore.startValidatorRun).not.toHaveBeenCalled(); }); + + it("skips superseded generated fixes from the stale recovery snapshot", async () => { + const mission = createMockMission({ id: "M-SUPERSEDE", status: "active" }); + missionStore._clear(); + missionStore._setMission(mission); + + const slice = createMockSlice({ id: "SL-SUPERSEDE", milestoneId: "MS-001", status: "active" }); + const source = createMockFeature({ + id: "F-SOURCE", + sliceId: slice.id, + status: "done", + loopState: "passed", + lastValidatorStatus: "passed", + }); + const staleFix = createMockFeature({ + id: "F-STALE-FIX", + sliceId: slice.id, + status: "defined", + loopState: "validating", + generatedFromFeatureId: source.id, + taskId: "FN-stale-fix", + }); + missionStore._setFeature(source); + missionStore._setFeature(staleFix); + taskStore._setTask({ id: "FN-stale-fix", column: "done" }); + wireHierarchy(slice, [source, staleFix]); + + loop = new MissionExecutionLoop({ + taskStore: taskStore as any, + missionStore: missionStore as any, + rootDir: "/tmp", + }); + loop.start(); + + const result = await loop.recoverActiveMissions(); + + expect(missionStore.reconcileSupersededGeneratedFixFeatures).toHaveBeenCalledWith(slice.id); + expect(missionStore.transitionLoopState).not.toHaveBeenCalledWith(staleFix.id, "implementing"); + expect(taskStore.getTask).not.toHaveBeenCalledWith("FN-stale-fix"); + expect(result).toEqual({ recoveredCount: 1 }); + }); }); describe("reapStaleValidatorRuns", () => { diff --git a/packages/engine/src/mission-execution-loop.ts b/packages/engine/src/mission-execution-loop.ts index 56a1ae65b8..f2ed7f564c 100644 --- a/packages/engine/src/mission-execution-loop.ts +++ b/packages/engine/src/mission-execution-loop.ts @@ -250,6 +250,7 @@ export class MissionExecutionLoop extends EventEmitter { if (slice.status !== "active") continue; const supersededFixes = this.missionStore.reconcileSupersededGeneratedFixFeatures(slice.id); + const supersededFeatureIds = new Set(supersededFixes.featureIds); if (supersededFixes.supersededCount > 0) { loopLog.warn( `Recovery: superseded ${supersededFixes.supersededCount} generated Fix Features in slice ${slice.id} ` @@ -259,6 +260,10 @@ export class MissionExecutionLoop extends EventEmitter { } for (const feature of slice.features) { + if (supersededFeatureIds.has(feature.id)) { + continue; + } + // Features in validating state need to be re-validated if (feature.loopState === "validating") { loopLog.log(`Recovery: re-queuing validating feature ${feature.id}`);