fix(missions): supersede stale generated fix features

This commit is contained in:
Phil Larson
2026-07-05 21:50:28 -07:00
parent 6c9989c847
commit 3744fbcc2f
5 changed files with 160 additions and 0 deletions

View File

@@ -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.

View File

@@ -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", () => {

View File

@@ -2751,6 +2751,13 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
// 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<MissionStoreEvents> {
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<string, boolean>();
const featureHasPassed = (feature: MissionFeature | undefined): boolean => {
if (!feature) return false;
return feature.lastValidatorStatus === "passed" || feature.loopState === "passed";
};
const hasPassedAncestor = (feature: MissionFeature, seen = new Set<string>()): 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.
*

View File

@@ -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<string>()): 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`);

View File

@@ -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") {