diff --git a/packages/core/src/__tests__/mission-store.test.ts b/packages/core/src/__tests__/mission-store.test.ts index 83a1b537e8..88b2345580 100644 --- a/packages/core/src/__tests__/mission-store.test.ts +++ b/packages/core/src/__tests__/mission-store.test.ts @@ -1719,6 +1719,7 @@ describe("MissionStore", () => { sliceId: null, }); expect(store.computeSliceStatus(slice.id)).toBe("complete"); + expect(store.getSlice(slice.id)).toMatchObject({ status: "complete" }); }); it("reconciles stale generated fix features when a source validator run passes", () => { @@ -1759,14 +1760,16 @@ describe("MissionStore", () => { 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"]); + createTaskInDb(db, "FN-own-passed-fix", "Own passed stale fix task", undefined, { column: "todo" }); store.updateFeature(staleFix.id, { status: "blocked", loopState: "passed", lastValidatorStatus: "passed", - taskId: undefined, + taskId: "FN-own-passed-fix", }); + db.prepare("UPDATE tasks SET missionId = ?, sliceId = ? WHERE id = ?").run(mission.id, slice.id, "FN-own-passed-fix"); - expect(store.computeSliceStatus(slice.id)).toBe("pending"); + expect(store.computeSliceStatus(slice.id)).not.toBe("complete"); const report = store.reconcileSupersededGeneratedFixFeatures(slice.id); @@ -1777,6 +1780,10 @@ describe("MissionStore", () => { loopState: "passed", lastValidatorStatus: "passed", }); + expect(db.prepare("SELECT missionId, sliceId FROM tasks WHERE id = ?").get("FN-own-passed-fix")).toEqual({ + missionId: null, + sliceId: null, + }); }); }); diff --git a/packages/core/src/mission-store.ts b/packages/core/src/mission-store.ts index 752bc4c525..6ea9a2a40d 100644 --- a/packages/core/src/mission-store.ts +++ b/packages/core/src/mission-store.ts @@ -3170,8 +3170,8 @@ export class MissionStore extends EventEmitter { } /** - * Mark generated Fix Features obsolete once an ancestor feature has already - * passed validation. + * Mark generated Fix Features obsolete once an ancestor feature, or the fix's + * own validation evidence, has already passed. * * Validator failures can create a chain of generated features. If the original * feature is later validated successfully, older descendants are no longer @@ -3181,6 +3181,10 @@ export class MissionStore extends EventEmitter { * 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. + * + * FNXC:Missions 2026-07-11-12:35: + * A generated fix can also supersede itself once its own validator/loop evidence has passed. + * Reconciliation treats that as terminal evidence so a completed fix does not stay active only because its ancestor previously failed. */ reconcileSupersededGeneratedFixFeatures(sliceId: string): { supersededCount: number; featureIds: string[] } { const features = this.listFeatures(sliceId); diff --git a/packages/engine/src/__tests__/mission-autopilot.test.ts b/packages/engine/src/__tests__/mission-autopilot.test.ts index 6dc5fb9ba8..384f296365 100644 --- a/packages/engine/src/__tests__/mission-autopilot.test.ts +++ b/packages/engine/src/__tests__/mission-autopilot.test.ts @@ -616,6 +616,49 @@ describe("MissionAutopilot", () => { expect(autopilot.isWatching("M-TEST1")).toBe(false); }); + it("normalizes already-complete autopilot state when completion is checked", async () => { + const mission = createMockMission({ + id: "M-COMPLETE-CHECK", + status: "complete", + autoAdvance: true, + autopilotEnabled: true, + autopilotState: "watching", + }); + const store = createMockMissionStore([mission]); + const ap = new MissionAutopilot(taskStore as any, store as any, { scheduler }); + store.listMilestones.mockReturnValue([createMockMilestone({ missionId: mission.id, status: "complete" })]); + store.getMissionWithHierarchy.mockReturnValue({ + ...mission, + milestones: [{ + ...createMockMilestone({ missionId: mission.id, status: "complete" }), + slices: [{ + ...createMockSlice({ id: "SL-COMPLETE-CHECK", status: "complete" }), + features: [createMockFeature({ id: "F-COMPLETE-CHECK", status: "done" })], + }], + }], + }); + + ap.watchMission(mission.id); + const result = await ap.checkMissionCompletion(mission.id); + + expect(result).toBe(true); + expect(store.updateMission).toHaveBeenCalledWith( + mission.id, + expect.objectContaining({ + autoAdvance: false, + autopilotEnabled: false, + autopilotState: "inactive", + }), + ); + expect(store.logMissionEvent).toHaveBeenCalledWith( + mission.id, + "autopilot_disabled", + expect.stringContaining("Autopilot disabled for already-complete mission"), + expect.objectContaining({ source: "checkMissionCompletion" }), + ); + expect(ap.isWatching(mission.id)).toBe(false); + }); + it("should return false when milestones are not all complete", async () => { const m1 = createMockMilestone({ status: "active" }); missionStore.listMilestones.mockReturnValue([m1]); @@ -689,6 +732,34 @@ describe("MissionAutopilot", () => { expect.objectContaining({ status: "complete" }), ); }); + + it("normalizes autopilot flags when checkMissionCompletion completes the mission", async () => { + missionStore.listMilestones.mockReturnValue([createMockMilestone({ status: "complete" })]); + missionStore.getMissionWithHierarchy.mockReturnValue({ + ...createMockMission(), + milestones: [{ + ...createMockMilestone({ id: "MS-001", status: "complete" }), + slices: [{ + ...createMockSlice({ id: "SL-001", status: "complete" }), + features: [createMockFeature({ id: "F-001", status: "done" })], + }], + }], + }); + + autopilot.watchMission("M-TEST1"); + missionStore.updateMission.mockClear(); + missionStore.logMissionEvent.mockClear(); + const result = await autopilot.checkMissionCompletion("M-TEST1"); + + expect(result).toBe(true); + expect(missionStore.updateMission).toHaveBeenCalledWith("M-TEST1", expect.objectContaining({ status: "complete" })); + expect(missionStore.updateMission).toHaveBeenCalledWith("M-TEST1", expect.objectContaining({ + autoAdvance: false, + autopilotEnabled: false, + autopilotState: "inactive", + })); + expect(autopilot.isWatching("M-TEST1")).toBe(false); + }); }); describe("reconcileMissionConsistency", () => { @@ -933,6 +1004,32 @@ describe("MissionAutopilot", () => { // ── Poll / stale detection ────────────────────────────────────── describe("poll stale detection", () => { + it("normalizes complete missions during poll", async () => { + const completeMission = createMockMission({ + status: "complete", + autoAdvance: true, + autopilotEnabled: true, + autopilotState: "watching", + }); + const store = createMockMissionStore([completeMission]); + const ap = new MissionAutopilot(taskStore as any, store as any, { scheduler }); + + ap.watchMission("M-TEST1"); + store.updateMission.mockClear(); + store.logMissionEvent.mockClear(); + ap.start(); + await vi.advanceTimersByTimeAsync(60_000); + + expect(store.updateMission).toHaveBeenCalledWith("M-TEST1", expect.objectContaining({ + autoAdvance: false, + autopilotEnabled: false, + autopilotState: "inactive", + })); + expect(ap.isWatching("M-TEST1")).toBe(false); + + ap.stop(); + }); + it("recovers stale activating missions back to watching and advances slices", async () => { const staleMission = createMockMission({ autopilotState: "activating", @@ -1094,6 +1191,59 @@ describe("MissionAutopilot", () => { expect(ap.isWatching("M-COMPLETE")).toBe(false); }); + it("normalizes complete missions that still have autopilot watching during poll", async () => { + const mission = createMockMission({ + id: "M-COMPLETE-POLL", + status: "complete", + autoAdvance: true, + autopilotEnabled: true, + autopilotState: "watching", + }); + const store = createMockMissionStore([mission]); + const ap = new MissionAutopilot(taskStore as any, store as any, { scheduler }); + + ap.watchMission(mission.id); + (ap as any).running = true; + await (ap as any).poll(); + + expect(store.updateMission).toHaveBeenCalledWith( + mission.id, + expect.objectContaining({ + autoAdvance: false, + autopilotEnabled: false, + autopilotState: "inactive", + }), + ); + expect(store.logMissionEvent).toHaveBeenCalledWith( + mission.id, + "autopilot_disabled", + expect.stringContaining("Autopilot disabled for already-complete mission"), + expect.objectContaining({ source: "poll" }), + ); + expect(ap.isWatching(mission.id)).toBe(false); + }); + + it("does not clear watched state when normalization is accidentally called for an active mission", () => { + const mission = createMockMission({ + id: "M-ACTIVE", + status: "active", + autoAdvance: true, + autopilotEnabled: true, + autopilotState: "watching", + }); + const store = createMockMissionStore([mission]); + const ap = new MissionAutopilot(taskStore as any, store as any, { scheduler }); + + ap.watchMission("M-ACTIVE"); + store.updateMission.mockClear(); + store.logMissionEvent.mockClear(); + (ap as any).normalizeCompleteMissionAutopilotState("M-ACTIVE", "test"); + + expect(ap.isWatching("M-ACTIVE")).toBe(true); + expect(store.updateMission).not.toHaveBeenCalled(); + expect(store.logMissionEvent).not.toHaveBeenCalled(); + }); + it("recovers missions stuck in activating state", async () => { const mission = createMockMission({ autopilotState: "activating" }); const store = createMockMissionStore([mission]); diff --git a/packages/engine/src/__tests__/reliability-interactions/mission-stranded-feature-retriage.test.ts b/packages/engine/src/__tests__/reliability-interactions/mission-stranded-feature-retriage.test.ts index 7a2021fc7c..b57c9f4a81 100644 --- a/packages/engine/src/__tests__/reliability-interactions/mission-stranded-feature-retriage.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/mission-stranded-feature-retriage.test.ts @@ -287,6 +287,104 @@ describe("FN-5754 reliability: mission stranded feature retriage", () => { expect(missionStore.updateFeature).not.toHaveBeenCalled(); }); + it("keeps ordinary done linked features in startup task-drift reconciliation", async () => { + const tasks = [{ id: "FN-DONE", title: "Completed feature", missionId: "M-001", sliceId: "SL-001", column: "done", status: "queued" }]; + const features = [feature({ id: "F-DONE", title: "Completed feature", status: "done", taskId: "FN-DONE" })]; + const missionStore = { + listMissions: vi.fn(() => [{ id: "M-001", status: "active", autopilotEnabled: true }]), + getMissionWithHierarchy: vi.fn(() => ({ + id: "M-001", + status: "active", + milestones: [{ id: "MS-001", slices: [{ id: "SL-001", status: "active", features }] }], + })), + updateFeatureStatus: vi.fn(), + listAssertionsForFeature: vi.fn(() => []), + }; + const store = createTaskStore(tasks); + + const scheduler = new Scheduler(store, { missionStore: missionStore as any }); + await scheduler.reconcileAllMissionFeatures(); + + expect(store.getTask).toHaveBeenCalledWith("FN-DONE"); + }); + + it("keeps done generated fixes with live task ownership in startup task-drift reconciliation", async () => { + const tasks = [{ id: "FN-GENERATED-DONE", title: "Fix: completed generated remediation", missionId: "M-001", sliceId: "SL-001", column: "done", status: "queued" }]; + const features = [feature({ + id: "F-GENERATED-DONE", + title: "Fix: completed generated remediation", + status: "done", + generatedFromFeatureId: "F-SOURCE", + taskId: "FN-GENERATED-DONE", + })]; + const missionStore = { + listMissions: vi.fn(() => [{ id: "M-001", status: "active", autopilotEnabled: true }]), + getMissionWithHierarchy: vi.fn(() => ({ + id: "M-001", + status: "active", + milestones: [{ id: "MS-001", slices: [{ id: "SL-001", status: "active", features }] }], + })), + updateFeatureStatus: vi.fn(), + listAssertionsForFeature: vi.fn(() => []), + }; + const store = createTaskStore(tasks); + + const scheduler = new Scheduler(store, { missionStore: missionStore as any }); + await scheduler.reconcileAllMissionFeatures(); + + expect(store.getTask).toHaveBeenCalledWith("FN-GENERATED-DONE"); + }); + + it("does not block superseded done generated fixes during refreshed startup reconciliation", async () => { + const supersededFix = feature({ + id: "F-SUPERSEDED-FIX", + title: "Fix: already validated remediation", + status: "done", + loopState: "passed", + lastValidatorStatus: "passed", + generatedFromFeatureId: "F-SOURCE", + taskId: undefined, + }); + const remainingFeature = feature({ id: "F-REMAINING", title: "Remaining feature", status: "defined", taskId: undefined }); + let features = [supersededFix, remainingFeature]; + const tasks: any[] = [{ + id: "FN-STALE-FIX", + title: "Fix: already validated remediation", + missionId: "M-001", + sliceId: "SL-001", + column: "todo", + status: "queued", + }]; + const missionStore = { + listMissions: vi.fn(() => [{ id: "M-001", status: "active", autopilotEnabled: true }]), + getMissionWithHierarchy: vi.fn(() => ({ + id: "M-001", + status: "active", + milestones: [{ id: "MS-001", slices: [{ id: "SL-001", status: "active", features }] }], + })), + reconcileSupersededGeneratedFixFeatures: vi.fn(() => ({ supersededCount: 1, featureIds: ["F-SUPERSEDED-FIX"] })), + listFeatures: vi.fn(() => features), + getSlice: vi.fn(() => ({ id: "SL-001", milestoneId: "MS-001", status: "active", features })), + triageFeature: vi.fn(async (featureId: string) => { + const taskId = `FN-${featureId}`; + tasks.push({ id: taskId, title: "Remaining feature", missionId: "M-001", sliceId: "SL-001", column: "todo", status: "queued" }); + features = features.map((candidate) => candidate.id === featureId ? { ...candidate, taskId, status: "triaged" } : candidate); + return features.find((candidate) => candidate.id === featureId); + }), + linkFeatureToTask: vi.fn(), + updateFeature: vi.fn(), + updateFeatureStatus: vi.fn(), + listAssertionsForFeature: vi.fn(() => []), + }; + + const scheduler = new Scheduler(createTaskStore(tasks), { missionStore: missionStore as any }); + await scheduler.reconcileAllMissionFeatures(); + + expect(missionStore.updateFeature).not.toHaveBeenCalledWith("F-SUPERSEDED-FIX", expect.objectContaining({ status: "blocked" })); + expect(missionStore.linkFeatureToTask).not.toHaveBeenCalledWith("F-SUPERSEDED-FIX", "FN-STALE-FIX"); + expect(missionStore.triageFeature).toHaveBeenCalledWith("F-REMAINING"); + }); + it("leaves non-autopilot and blocked features untouched", async () => { const autopilotOffFeature = feature({ id: "F-001", status: "defined", taskId: undefined }); const blockedFeature = feature({ id: "F-002", status: "blocked", taskId: undefined, title: "Blocked feature" }); diff --git a/packages/engine/src/mission-autopilot.ts b/packages/engine/src/mission-autopilot.ts index 5c1b5bb54f..ed1482cdd0 100644 --- a/packages/engine/src/mission-autopilot.ts +++ b/packages/engine/src/mission-autopilot.ts @@ -834,6 +834,15 @@ export class MissionAutopilot { return; } + if (mission.status !== "complete") { + /* + FNXC:Missions 2026-07-11-12:35: + Autopilot cleanup is only safe for missions that are already complete. + Active missions may still need watched-state and retry memory even if a future caller reaches this helper by mistake. + */ + return; + } + this.watchedMissions.delete(missionId); this.perMissionTaskRetries.delete(missionId); diff --git a/packages/engine/src/scheduler.ts b/packages/engine/src/scheduler.ts index 2798fd898d..07a44ca138 100644 --- a/packages/engine/src/scheduler.ts +++ b/packages/engine/src/scheduler.ts @@ -3073,7 +3073,8 @@ export class Scheduler { for (const slice of activeSlices) { const missionAutoTriageEnabled = mission.autopilotEnabled === true || mission.autoAdvance === true; - const supersededFixes = missionStore.reconcileSupersededGeneratedFixFeatures(slice.id); + const supersededFixes = missionStore.reconcileSupersededGeneratedFixFeatures?.(slice.id) + ?? { supersededCount: 0, featureIds: [] }; if (supersededFixes.supersededCount > 0) { totalFixed += supersededFixes.supersededCount; schedulerLog.warn( @@ -3083,10 +3084,40 @@ export class Scheduler { const features = supersededFixes.supersededCount > 0 ? missionStore.listFeatures(slice.id) : slice.features; + const supersededFeatureIds = new Set(supersededFixes.featureIds); + + if (supersededFixes.supersededCount > 0) { + const refreshedSlice = missionStore.getSlice?.(slice.id); + if (refreshedSlice?.status === "complete") { + /* + FNXC:Missions 2026-07-11-12:35: + Startup reconciliation can terminalize every stale generated-fix feature in an active slice before the scheduler loop runs no-task recovery. + Treat the recomputed complete slice as complete immediately so stale fixes do not keep an active slice from advancing after restart. + */ + await this.onSliceComplete(refreshedSlice); + continue; + } + } for (const feature of features) { let featureForReconciliation = feature; let task: Task | undefined; + if (supersededFeatureIds.has(feature.id)) { + /* + FNXC:Missions 2026-07-11-12:35: + The title-to-task map is built before generated-fix supersedence detaches stale task ownership. + Skip freshly superseded features so pre-reconciliation task links cannot be restored in the same startup sweep. + */ + continue; + } + if (feature.status === "done" && this.isGeneratedFixFeature(feature) && !feature.taskId) { + /* + FNXC:Missions 2026-07-11-12:35: + Done generated-fix features with no task ownership are terminal after supersedence clears their board links. + Keep any done feature that still has task ownership in startup self-healing so linked task drift can still be repaired. + */ + continue; + } if (feature.taskId) { task = await this.store.getTask(feature.taskId); } else {