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", () => {
|
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();
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user