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:
9
.changeset/fix-stranded-done-feature-recovery.md
Normal file
9
.changeset/fix-stranded-done-feature-recovery.md
Normal 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.
|
||||
@@ -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", () => {
|
||||
it("reaps stale runs across trigger types and records audit metadata", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -292,6 +292,42 @@ export class MissionExecutionLoop extends EventEmitter {
|
||||
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;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
await this.runFeatureValidation(feature);
|
||||
} catch (err) {
|
||||
loopLog.error(`Error processing task outcome for ${taskId}:`, err);
|
||||
// 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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user