Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/fuzzy-missions-heal.md
Original file line number Diff line number Diff line change
@@ -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.
65 changes: 65 additions & 0 deletions packages/core/src/__tests__/mission-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
77 changes: 76 additions & 1 deletion packages/core/src/mission-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2750,9 +2750,15 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {

// 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);
}
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

return updatedRun;
}

Expand Down Expand Up @@ -3163,6 +3169,75 @@ 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.
*
* 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<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);

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.
*
Expand Down
68 changes: 68 additions & 0 deletions packages/engine/src/__tests__/mission-execution-loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down Expand Up @@ -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", () => {
Expand Down
14 changes: 14 additions & 0 deletions packages/engine/src/mission-execution-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Comment thread
plarson marked this conversation as resolved.
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}`);
Expand Down
Loading