diff --git a/.changeset/fix-stranded-done-feature-recovery.md b/.changeset/fix-stranded-done-feature-recovery.md new file mode 100644 index 0000000000..222c1d437d --- /dev/null +++ b/.changeset/fix-stranded-done-feature-recovery.md @@ -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. diff --git a/packages/engine/src/__tests__/mission-execution-loop.test.ts b/packages/engine/src/__tests__/mission-execution-loop.test.ts index 41b9182d12..e25a50ada8 100644 --- a/packages/engine/src/__tests__/mission-execution-loop.test.ts +++ b/packages/engine/src/__tests__/mission-execution-loop.test.ts @@ -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(); diff --git a/packages/engine/src/mission-execution-loop.ts b/packages/engine/src/mission-execution-loop.ts index 9d7b864348..6c34e4dbe1 100644 --- a/packages/engine/src/mission-execution-loop.ts +++ b/packages/engine/src/mission-execution-loop.ts @@ -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 { + // 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. *