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" });
|
store.updateFeature(feature.id, { lastValidatorStatus: "passed" });
|
||||||
expect(store.computeSliceStatus(slice.id)).toBe("complete");
|
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", () => {
|
describe("computeMilestoneStatus", () => {
|
||||||
|
|||||||
@@ -2751,6 +2751,13 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
// Re-read the run to get the updated state
|
// Re-read the run to get the updated state
|
||||||
const updatedRun = this.getValidatorRun(runId)!;
|
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);
|
this.emit("validator-run:completed", updatedRun, result, durationMs);
|
||||||
|
|
||||||
return updatedRun;
|
return updatedRun;
|
||||||
@@ -3163,6 +3170,66 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
return undefined;
|
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.
|
* Get a complete loop state snapshot for a feature.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -338,6 +338,33 @@ function createMockMissionStore() {
|
|||||||
|
|
||||||
return fixFeature;
|
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) => {
|
triageFeature: vi.fn(async (featureId: string) => {
|
||||||
const feature = features.get(featureId);
|
const feature = features.get(featureId);
|
||||||
if (!feature) throw new Error(`Feature ${featureId} not found`);
|
if (!feature) throw new Error(`Feature ${featureId} not found`);
|
||||||
|
|||||||
@@ -249,6 +249,15 @@ export class MissionExecutionLoop extends EventEmitter {
|
|||||||
for (const slice of milestone.slices) {
|
for (const slice of milestone.slices) {
|
||||||
if (slice.status !== "active") continue;
|
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) {
|
for (const feature of slice.features) {
|
||||||
// Features in validating state need to be re-validated
|
// Features in validating state need to be re-validated
|
||||||
if (feature.loopState === "validating") {
|
if (feature.loopState === "validating") {
|
||||||
|
|||||||
Reference in New Issue
Block a user