From 3744fbcc2f0ffe961738dc457441cab000e9e38a Mon Sep 17 00:00:00 2001 From: Phil Larson Date: Sun, 5 Jul 2026 21:50:28 -0700 Subject: [PATCH 1/2] fix(missions): supersede stale generated fix features --- .changeset/fuzzy-missions-heal.md | 7 ++ .../core/src/__tests__/mission-store.test.ts | 50 ++++++++++++++ packages/core/src/mission-store.ts | 67 +++++++++++++++++++ .../__tests__/mission-execution-loop.test.ts | 27 ++++++++ packages/engine/src/mission-execution-loop.ts | 9 +++ 5 files changed, 160 insertions(+) create mode 100644 .changeset/fuzzy-missions-heal.md 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..0e07a3740 100644 --- a/packages/core/src/__tests__/mission-store.test.ts +++ b/packages/core/src/__tests__/mission-store.test.ts @@ -1683,6 +1683,56 @@ 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"]); + + 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"); + + 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(fix2.id)).toMatchObject({ status: "done", loopState: "passed", lastValidatorStatus: "passed" }); + 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 passingRun = store.startValidatorRun(source.id, "task_completion"); + store.completeValidatorRun(passingRun.id, "passed", "evidence now complete"); + + 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..4ec0db99c 100644 --- a/packages/core/src/mission-store.ts +++ b/packages/core/src/mission-store.ts @@ -2751,6 +2751,13 @@ export class MissionStore extends EventEmitter { // Re-read the run to get the updated state const updatedRun = this.getValidatorRun(runId)!; + if (result === "passed") { + const passedFeature = this.getFeature(run.featureId); + if (passedFeature) { + this.reconcileSupersededGeneratedFixFeatures(passedFeature.sliceId); + } + } + this.emit("validator-run:completed", updatedRun, result, durationMs); return updatedRun; @@ -3163,6 +3170,66 @@ 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. + */ + 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); + + for (const featureId of supersededFeatureIds) { + this.updateFeature(featureId, { + status: "done", + loopState: "passed", + lastValidatorStatus: "passed", + }); + } + + if (supersededFeatureIds.length > 0) { + this.recomputeSliceStatus(sliceId); + this.db.bumpLastModified(); + } + + 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..9f5ac3e84 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`); diff --git a/packages/engine/src/mission-execution-loop.ts b/packages/engine/src/mission-execution-loop.ts index e2265f48d..56a1ae65b 100644 --- a/packages/engine/src/mission-execution-loop.ts +++ b/packages/engine/src/mission-execution-loop.ts @@ -249,6 +249,15 @@ export class MissionExecutionLoop extends EventEmitter { for (const slice of milestone.slices) { if (slice.status !== "active") continue; + const supersededFixes = this.missionStore.reconcileSupersededGeneratedFixFeatures(slice.id); + 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) { // Features in validating state need to be re-validated if (feature.loopState === "validating") { From 3d60f67fabd79fd1ea0f109c66f7253bc8054fc0 Mon Sep 17 00:00:00 2001 From: Phil Larson Date: Sun, 5 Jul 2026 22:14:22 -0700 Subject: [PATCH 2/2] fix(missions): address generated fix review blockers --- .../core/src/__tests__/mission-store.test.ts | 19 ++++++++- packages/core/src/mission-store.ts | 32 +++++++++------ .../__tests__/mission-execution-loop.test.ts | 41 +++++++++++++++++++ packages/engine/src/mission-execution-loop.ts | 5 +++ 4 files changed, 83 insertions(+), 14 deletions(-) diff --git a/packages/core/src/__tests__/mission-store.test.ts b/packages/core/src/__tests__/mission-store.test.ts index 0e07a3740..41abb7397 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 4ec0db99c..68ad8a097 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 9f5ac3e84..3a754b77f 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 56a1ae65b..f2ed7f564 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}`);