fix(missions): supersede stale generated fix features
This commit is contained in:
7
.changeset/fuzzy-missions-heal.md
Normal file
7
.changeset/fuzzy-missions-heal.md
Normal 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.
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user