fix(engine): recover missions wedged by stranded done features

A mission feature could be left status="done" while its loopState never
advanced past "implementing" and it had no linked board task, so it was
never validated. The slice-completion gate (computeSliceStatus) correctly
refuses to count an assertion-linked done feature until its validator
passes, but nothing re-drove a task-less feature — so the slice, milestone,
and whole mission could never auto-progress.

Active-mission recovery now detects these stranded done features and re-runs
assertion validation directly (read-only judge, no board task): on pass the
feature becomes legitimately complete, on fail the normal fix-feature flow
takes over. Extracted the feature-validation path into a shared
runFeatureValidation helper used by both task-completion and recovery.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-03 07:06:30 -07:00
parent 6c7ed1e1fc
commit c2604d5e52
3 changed files with 174 additions and 35 deletions

View File

@@ -0,0 +1,9 @@
---
"@runfusion/fusion": patch
---
Fix missions stalling when a feature is marked `done` but stranded mid-loop.
A mission feature could be left `status: "done"` while its `loopState` never advanced past `"implementing"` and it had no linked board task (so it was never validated). The slice-completion gate (`MissionStore.computeSliceStatus`) correctly refuses to count an assertion-linked `done` feature until its validator passes, but nothing re-drove a task-less feature, so the slice — and the whole mission — could never auto-progress.
Active-mission recovery now detects these stranded `done` features and re-runs assertion validation directly (no board task), so the gate can resolve: on pass the feature becomes legitimately complete, on fail the normal fix-feature flow takes over. The feature-validation path was extracted into a shared `runFeatureValidation` helper used by both task-completion and recovery.

View File

@@ -546,6 +546,87 @@ describe("MissionExecutionLoop", () => {
}); });
}); });
describe("recoverActiveMissions stranded done features", () => {
function wireHierarchy(slice: Slice, features: MissionFeature[]) {
missionStore.getMissionWithHierarchy = vi.fn((id: string) => {
const mission = missionStore.getMission(id);
if (!mission) return undefined;
return {
...mission,
milestones: [
{
...createMockMilestone({ missionId: id }),
slices: [{ ...slice, features }],
},
],
};
}) as any;
}
it("re-validates a done feature stranded in 'implementing' with no linked task", async () => {
// Regression: a feature marked "done" whose loopState never left
// "implementing" (and which was never validated and has no board task)
// can never validate on its own — the prior recovery loop only re-drove
// implementing features that still had a taskId. The slice-completion
// gate then refuses to count it, wedging the whole mission. Recovery
// must re-drive validation so the slice can eventually complete.
const mission = createMockMission({ id: "M-STRAND", status: "active" });
missionStore._setMission(mission);
const slice = createMockSlice({ id: "SL-STRAND", milestoneId: "MS-001", status: "active" });
const orphan = createMockFeature({
id: "F-STRAND",
sliceId: "SL-STRAND",
status: "done",
loopState: "implementing",
lastValidatorStatus: undefined,
taskId: undefined,
});
(missionStore as any)._addFeatureWithManagedAssertion(orphan);
wireHierarchy(slice, [missionStore.getFeature("F-STRAND") as MissionFeature]);
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
loop.start();
const result = await loop.recoverActiveMissions();
expect(missionStore.startValidatorRun).toHaveBeenCalledWith("F-STRAND", "task_completion");
expect(result.recoveredCount).toBeGreaterThanOrEqual(1);
});
it("leaves an already-validated done feature untouched", async () => {
const mission = createMockMission({ id: "M-OK", status: "active" });
missionStore._setMission(mission);
const slice = createMockSlice({ id: "SL-OK", milestoneId: "MS-001", status: "active" });
const validated = createMockFeature({
id: "F-OK",
sliceId: "SL-OK",
status: "done",
loopState: "passed",
lastValidatorStatus: "passed",
taskId: undefined,
});
(missionStore as any)._addFeatureWithManagedAssertion(validated);
wireHierarchy(slice, [missionStore.getFeature("F-OK") as MissionFeature]);
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
loop.start();
await loop.recoverActiveMissions();
expect(missionStore.startValidatorRun).not.toHaveBeenCalled();
});
});
describe("reapStaleValidatorRuns", () => { describe("reapStaleValidatorRuns", () => {
it("reaps stale runs across trigger types and records audit metadata", async () => { it("reaps stale runs across trigger types and records audit metadata", async () => {
vi.useFakeTimers(); vi.useFakeTimers();

View File

@@ -292,6 +292,42 @@ export class MissionExecutionLoop extends EventEmitter {
loopLog.error(`Recovery failed for implementing feature ${feature.id}:`, err); loopLog.error(`Recovery failed for implementing feature ${feature.id}:`, err);
} }
} }
// Features marked "done" but stranded in "implementing" with no
// linked task can never validate on their own: the branches above
// only re-drive features that still carry a taskId. Meanwhile the
// slice-completion gate (MissionStore.computeSliceStatus) refuses
// to count an assertion-linked "done" feature until its validator
// passes — so the slice, milestone, and mission can never
// auto-progress. Re-drive validation directly so the gate can
// resolve. Validation is a read-only judge (no board task, no code
// changes); on pass the feature becomes legitimately complete, on
// fail the normal fix-feature flow takes over.
if (
feature.loopState === "implementing"
&& !feature.taskId
&& feature.status === "done"
&& feature.lastValidatorStatus !== "passed"
&& !this.activeValidations.has(feature.id)
) {
const currentFeature = this.missionStore.getFeature(feature.id) ?? feature;
if (
currentFeature.loopState === "passed"
|| currentFeature.lastValidatorStatus === "passed"
) {
continue;
}
try {
loopLog.warn(
`Recovery: re-validating stranded "done" feature ${feature.id} `
+ `(loopState=${feature.loopState}, no linked task) so its slice can complete`,
);
recoveredCount++;
await this.runFeatureValidation(currentFeature);
} catch (err) {
loopLog.error(`Recovery failed for stranded done feature ${feature.id}:`, err);
}
}
} }
} }
} }
@@ -353,47 +389,60 @@ export class MissionExecutionLoop extends EventEmitter {
return; return;
} }
// Get linked assertions for this feature await this.runFeatureValidation(feature);
const assertions = this.missionStore.listAssertionsForFeature(feature.id);
if (assertions.length === 0) {
loopLog.log(`Feature ${feature.id} has no linked assertions; marking as passed`);
// No assertions = automatically pass
await this.handleValidationPass(feature.id, undefined, "No assertions linked");
return;
}
// Mark feature as being validated
this.activeValidations.add(feature.id);
try {
loopLog.log(`Running internal validation for feature ${feature.id} — no board task created (policy: docs/missions.md)`);
// Start the validator run (no board task per docs/missions.md)
const run = this.missionStore.startValidatorRun(feature.id, "task_completion");
loopLog.log(`Started validator run ${run.id} for feature ${feature.id}`);
// Run the validation
const result = await this.runValidation(feature, assertions, run);
// Handle the result
if (result.status === "pass") {
await this.handleValidationPass(feature.id, run.id, result.summary);
} else if (result.status === "fail") {
await this.handleValidationFail(feature.id, run.id, result);
} else if (result.status === "blocked") {
await this.handleValidationBlocked(feature.id, run.id, result.blockedReason);
} else if (result.status === "error") {
await this.handleValidationError(feature.id, run.id, result.summary);
}
} finally {
this.activeValidations.delete(feature.id);
}
} catch (err) { } catch (err) {
loopLog.error(`Error processing task outcome for ${taskId}:`, err); loopLog.error(`Error processing task outcome for ${taskId}:`, err);
// Don't crash the loop - log and continue // Don't crash the loop - log and continue
} }
} }
/**
* Run assertion validation for a feature and apply the outcome.
*
* Shared by processTaskOutcome (task-triggered) and recoverActiveMissions
* (self-healing for features stranded mid-loop with no board task). Callers
* are responsible for confirming the feature is eligible to validate; this
* method handles the no-assertion auto-pass, validator run bookkeeping, and
* dispatch of the validation result.
*/
private async runFeatureValidation(feature: MissionFeature): Promise<void> {
// Get linked assertions for this feature
const assertions = this.missionStore.listAssertionsForFeature(feature.id);
if (assertions.length === 0) {
loopLog.log(`Feature ${feature.id} has no linked assertions; marking as passed`);
// No assertions = automatically pass
await this.handleValidationPass(feature.id, undefined, "No assertions linked");
return;
}
// Mark feature as being validated
this.activeValidations.add(feature.id);
try {
loopLog.log(`Running internal validation for feature ${feature.id} — no board task created (policy: docs/missions.md)`);
// Start the validator run (no board task per docs/missions.md)
const run = this.missionStore.startValidatorRun(feature.id, "task_completion");
loopLog.log(`Started validator run ${run.id} for feature ${feature.id}`);
// Run the validation
const result = await this.runValidation(feature, assertions, run);
// Handle the result
if (result.status === "pass") {
await this.handleValidationPass(feature.id, run.id, result.summary);
} else if (result.status === "fail") {
await this.handleValidationFail(feature.id, run.id, result);
} else if (result.status === "blocked") {
await this.handleValidationBlocked(feature.id, run.id, result.blockedReason);
} else if (result.status === "error") {
await this.handleValidationError(feature.id, run.id, result.summary);
}
} finally {
this.activeValidations.delete(feature.id);
}
}
/** /**
* Run the validation AI session for a feature. * Run the validation AI session for a feature.
* *