diff --git a/.changeset/fuzzy-missions-heal.md b/.changeset/fuzzy-missions-heal.md new file mode 100644 index 000000000..e5521a6a6 --- /dev/null +++ b/.changeset/fuzzy-missions-heal.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Clear stale generated mission fix features after their source feature passes validation. +category: fix +dev: Reconciles obsolete generated Fix Feature chains during validator pass handling and active mission recovery. diff --git a/packages/core/src/__tests__/mission-store.test.ts b/packages/core/src/__tests__/mission-store.test.ts index 0c4cdfd82..41abb7397 100644 --- a/packages/core/src/__tests__/mission-store.test.ts +++ b/packages/core/src/__tests__/mission-store.test.ts @@ -1683,6 +1683,71 @@ describe("MissionStore", () => { store.updateFeature(feature.id, { lastValidatorStatus: "passed" }); expect(store.computeSliceStatus(slice.id)).toBe("complete"); }); + + it("supersedes generated fix descendants once an ancestor feature passes", () => { + 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 sourceFailRun = store.startValidatorRun(source.id, "task_completion"); + store.completeValidatorRun(sourceFailRun.id, "failed", "missing evidence"); + const fix1 = store.createGeneratedFixFeature(source.id, sourceFailRun.id, ["CA-source"]); + + const fix1FailRun = store.startValidatorRun(fix1.id, "task_completion"); + 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)).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", 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"); + }); + + it("reconciles stale generated fix features when a source validator run passes", () => { + 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: "blocked", lastValidatorStatus: "failed" }); + + 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"); + }); }); describe("computeMilestoneStatus", () => { diff --git a/packages/core/src/mission-store.ts b/packages/core/src/mission-store.ts index 077fc03ed..68ad8a097 100644 --- a/packages/core/src/mission-store.ts +++ b/packages/core/src/mission-store.ts @@ -2750,9 +2750,15 @@ 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); + if (passedFeature) { + this.reconcileSupersededGeneratedFixFeatures(passedFeature.sliceId); + } + } + return updatedRun; } @@ -3163,6 +3169,75 @@ export class MissionStore extends EventEmitter { return undefined; } + /** + * Mark generated Fix Features obsolete once an ancestor feature has already + * passed validation. + * + * Validator failures can create a chain of generated features. If the original + * 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); + const featureById = new Map(features.map((feature) => [feature.id, feature])); + const ancestorPassedMemo = new Map(); + + const featureHasPassed = (feature: MissionFeature | undefined): boolean => { + if (!feature) return false; + return feature.lastValidatorStatus === "passed" || feature.loopState === "passed"; + }; + + const hasPassedAncestor = (feature: MissionFeature, seen = new Set()): boolean => { + const sourceFeatureId = feature.generatedFromFeatureId; + if (!sourceFeatureId || seen.has(sourceFeatureId)) { + return false; + } + const cached = ancestorPassedMemo.get(feature.id); + if (cached !== undefined) { + return cached; + } + + seen.add(sourceFeatureId); + const sourceFeature = featureById.get(sourceFeatureId) ?? this.getFeature(sourceFeatureId); + const passed = featureHasPassed(sourceFeature) || (sourceFeature ? hasPassedAncestor(sourceFeature, seen) : false); + ancestorPassedMemo.set(feature.id, passed); + return passed; + }; + + const supersededFeatureIds = features + .filter((feature) => feature.generatedFromFeatureId && hasPassedAncestor(feature)) + .filter((feature) => feature.status !== "done" || feature.loopState !== "passed" || feature.lastValidatorStatus !== "passed") + .map((feature) => feature.id); + + if (supersededFeatureIds.length > 0) { + 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 { + supersededCount: supersededFeatureIds.length, + featureIds: supersededFeatureIds, + }; + } + /** * Get a complete loop state snapshot for a feature. * diff --git a/packages/engine/src/__tests__/mission-execution-loop.test.ts b/packages/engine/src/__tests__/mission-execution-loop.test.ts index 640c41ccb..3a754b77f 100644 --- a/packages/engine/src/__tests__/mission-execution-loop.test.ts +++ b/packages/engine/src/__tests__/mission-execution-loop.test.ts @@ -338,6 +338,33 @@ function createMockMissionStore() { return fixFeature; }), + reconcileSupersededGeneratedFixFeatures: vi.fn((sliceId: string) => { + let supersededCount = 0; + const featureIds: string[] = []; + const featureHasPassed = (feature: MissionFeature | undefined) => + feature?.lastValidatorStatus === "passed" || feature?.loopState === "passed"; + const hasPassedAncestor = (feature: MissionFeature, seen = new Set()): boolean => { + const sourceFeatureId = feature.generatedFromFeatureId; + if (!sourceFeatureId || seen.has(sourceFeatureId)) return false; + seen.add(sourceFeatureId); + const sourceFeature = features.get(sourceFeatureId); + return featureHasPassed(sourceFeature) || (sourceFeature ? hasPassedAncestor(sourceFeature, seen) : false); + }; + for (const feature of [...features.values()]) { + if (feature.sliceId !== sliceId || !feature.generatedFromFeatureId || !hasPassedAncestor(feature)) continue; + if (feature.status === "done" && feature.loopState === "passed" && feature.lastValidatorStatus === "passed") continue; + features.set(feature.id, { + ...feature, + status: "done", + loopState: "passed", + lastValidatorStatus: "passed", + updatedAt: new Date().toISOString(), + }); + supersededCount += 1; + featureIds.push(feature.id); + } + return { supersededCount, featureIds }; + }), triageFeature: vi.fn(async (featureId: string) => { const feature = features.get(featureId); if (!feature) throw new Error(`Feature ${featureId} not found`); @@ -636,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 e2265f48d..f2ed7f564 100644 --- a/packages/engine/src/mission-execution-loop.ts +++ b/packages/engine/src/mission-execution-loop.ts @@ -249,7 +249,21 @@ export class MissionExecutionLoop extends EventEmitter { for (const slice of milestone.slices) { 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} ` + + "because an ancestor feature already passed validation", + ); + recoveredCount += supersededFixes.supersededCount; + } + 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}`);