From 283f689d8ac8cd2638505bbea17f818bbb8a4da3 Mon Sep 17 00:00:00 2001 From: Phil Larson Date: Sun, 14 Jun 2026 19:19:22 -0700 Subject: [PATCH] fix(engine): retriage stale mission feature links --- .../repair-stale-mission-feature-links.md | 5 + .../mission-stranded-feature-retriage.test.ts | 185 +++++++++++++++++- packages/engine/src/scheduler.ts | 36 +++- 3 files changed, 220 insertions(+), 6 deletions(-) create mode 100644 .changeset/repair-stale-mission-feature-links.md diff --git a/.changeset/repair-stale-mission-feature-links.md b/.changeset/repair-stale-mission-feature-links.md new file mode 100644 index 0000000000..d2ff7202cc --- /dev/null +++ b/.changeset/repair-stale-mission-feature-links.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Repair mission autopilot reconciliation so stale triaged/in-progress features without live task cards are retriaged, while generated fix-loop debris is blocked instead of recreating duplicate tasks. 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 8ab3659385..7a2021fc7c 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 @@ -87,8 +87,51 @@ describe("FN-5754 reliability: mission stranded feature retriage", () => { expect(missionStore.triageFeature).not.toHaveBeenCalled(); }); - it("skips inconsistent non-defined stranded features without title match", async () => { - const features = [feature({ id: "F-001", status: "triaged", taskId: undefined })]; + it("resets and retriages inconsistent non-defined stranded features without title match", async () => { + let features = [feature({ id: "F-001", status: "triaged", taskId: undefined })]; + const tasks: any[] = []; + 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 }] }], + })), + triageFeature: vi.fn(async (featureId: string) => { + const taskId = `FN-${featureId}`; + tasks.push({ id: taskId, title: "Feature one", missionId: "M-001", sliceId: "SL-001", column: "todo", status: "queued" }); + features = [{ ...features[0], taskId, status: "triaged" }]; + return features[0]; + }), + linkFeatureToTask: vi.fn(), + updateFeature: vi.fn((featureId: string, updates: Partial) => { + features = [{ ...features[0], id: featureId, ...updates }]; + return features[0]; + }), + updateFeatureStatus: vi.fn(), + listAssertionsForFeature: vi.fn(() => []), + }; + + const scheduler = new Scheduler(createTaskStore(tasks), { missionStore: missionStore as any }); + await scheduler.reconcileAllMissionFeatures(); + + expect(missionStore.updateFeature).toHaveBeenCalledWith("F-001", { + status: "defined", + loopState: "idle", + taskId: undefined, + }); + expect(missionStore.triageFeature).toHaveBeenCalledWith("F-001"); + expect(features[0].taskId).toBe("FN-F-001"); + }); + + it("blocks stranded generated fix features instead of recreating fix-loop tasks", async () => { + let features = [feature({ + id: "F-FIX", + title: "Fix: Fix: Mobile read/browse MVP", + status: "triaged", + generatedFromFeatureId: "F-ORIGINAL", + taskId: undefined, + })]; const missionStore = { listMissions: vi.fn(() => [{ id: "M-001", status: "active", autopilotEnabled: true }]), getMissionWithHierarchy: vi.fn(() => ({ @@ -98,14 +141,150 @@ describe("FN-5754 reliability: mission stranded feature retriage", () => { })), triageFeature: vi.fn(), linkFeatureToTask: vi.fn(), + updateFeature: vi.fn((featureId: string, updates: Partial) => { + features = [{ ...features[0], id: featureId, ...updates }]; + return features[0]; + }), updateFeatureStatus: vi.fn(), + listAssertionsForFeature: vi.fn(() => []), }; const scheduler = new Scheduler(createTaskStore([]), { missionStore: missionStore as any }); await scheduler.reconcileAllMissionFeatures(); expect(missionStore.triageFeature).not.toHaveBeenCalled(); - expect(missionStore.linkFeatureToTask).not.toHaveBeenCalled(); + expect(missionStore.updateFeature).toHaveBeenCalledWith("F-FIX", { + status: "blocked", + loopState: "blocked", + taskId: undefined, + }); + expect(features[0].status).toBe("blocked"); + }); + + it("blocks stranded validator-run generated features", async () => { + let features = [feature({ + id: "F-FIX-RUN", + title: "Generated follow-up", + status: "triaged", + generatedFromRunId: "MVR-001", + taskId: undefined, + })]; + 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 }] }], + })), + triageFeature: vi.fn(), + linkFeatureToTask: vi.fn(), + updateFeature: vi.fn((featureId: string, updates: Partial) => { + features = [{ ...features[0], id: featureId, ...updates }]; + return features[0]; + }), + updateFeatureStatus: vi.fn(), + listAssertionsForFeature: vi.fn(() => []), + }; + + const scheduler = new Scheduler(createTaskStore([]), { missionStore: missionStore as any }); + await scheduler.reconcileAllMissionFeatures(); + + expect(missionStore.triageFeature).not.toHaveBeenCalled(); + expect(features[0].status).toBe("blocked"); + }); + + it("retriages user-authored Fix-prefixed features when no generated marker is present", async () => { + let features = [feature({ id: "F-USER-FIX", title: "Fix: login redirect loop", status: "triaged", taskId: undefined })]; + const tasks: any[] = []; + 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 }] }], + })), + triageFeature: vi.fn(async (featureId: string) => { + const taskId = `FN-${featureId}`; + tasks.push({ id: taskId, title: "Fix: login redirect loop", missionId: "M-001", sliceId: "SL-001", column: "todo", status: "queued" }); + features = [{ ...features[0], taskId, status: "triaged" }]; + return features[0]; + }), + linkFeatureToTask: vi.fn(), + updateFeature: vi.fn((featureId: string, updates: Partial) => { + features = [{ ...features[0], id: featureId, ...updates }]; + return features[0]; + }), + updateFeatureStatus: vi.fn(), + listAssertionsForFeature: vi.fn(() => []), + }; + + const scheduler = new Scheduler(createTaskStore(tasks), { missionStore: missionStore as any }); + await scheduler.reconcileAllMissionFeatures(); + + expect(missionStore.triageFeature).toHaveBeenCalledWith("F-USER-FIX"); + expect(features[0].taskId).toBe("FN-F-USER-FIX"); + }); + + it("triages newly-created generated fix features that are still defined", async () => { + let features = [feature({ + id: "F-NEW-FIX", + title: "Fix: Mobile read/browse MVP", + status: "defined", + generatedFromFeatureId: "F-ORIGINAL", + taskId: undefined, + })]; + const tasks: any[] = []; + 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 }] }], + })), + triageFeature: vi.fn(async (featureId: string) => { + const taskId = `FN-${featureId}`; + tasks.push({ id: taskId, title: "Fix: Mobile read/browse MVP", missionId: "M-001", sliceId: "SL-001", column: "todo", status: "queued" }); + features = [{ ...features[0], taskId, status: "triaged" }]; + return features[0]; + }), + linkFeatureToTask: vi.fn(), + updateFeature: vi.fn((featureId: string, updates: Partial) => { + features = [{ ...features[0], id: featureId, ...updates }]; + return features[0]; + }), + updateFeatureStatus: vi.fn(), + listAssertionsForFeature: vi.fn(() => []), + }; + + const scheduler = new Scheduler(createTaskStore(tasks), { missionStore: missionStore as any }); + await scheduler.reconcileAllMissionFeatures(); + + expect(missionStore.triageFeature).toHaveBeenCalledWith("F-NEW-FIX"); + expect(missionStore.updateFeature).not.toHaveBeenCalledWith("F-NEW-FIX", expect.objectContaining({ status: "blocked" })); + expect(features[0].taskId).toBe("FN-F-NEW-FIX"); + }); + + it("does not reopen done features that no longer have task links", async () => { + const features = [feature({ id: "F-DONE", title: "Completed feature", status: "done", taskId: undefined })]; + 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 }] }], + })), + triageFeature: vi.fn(), + linkFeatureToTask: vi.fn(), + updateFeature: vi.fn(), + updateFeatureStatus: vi.fn(), + listAssertionsForFeature: vi.fn(() => []), + }; + + const scheduler = new Scheduler(createTaskStore([]), { missionStore: missionStore as any }); + await scheduler.reconcileAllMissionFeatures(); + + expect(missionStore.triageFeature).not.toHaveBeenCalled(); + expect(missionStore.updateFeature).not.toHaveBeenCalled(); }); it("leaves non-autopilot and blocked features untouched", async () => { diff --git a/packages/engine/src/scheduler.ts b/packages/engine/src/scheduler.ts index a18a6e62a5..f8d036d852 100644 --- a/packages/engine/src/scheduler.ts +++ b/packages/engine/src/scheduler.ts @@ -2501,9 +2501,35 @@ export class Scheduler { missionAutoTriageEnabled && feature.status !== "blocked" ) { - if (feature.status === "defined") { + if (feature.status !== "defined" && this.isGeneratedFixFeature(feature)) { try { - featureForReconciliation = await missionStore.triageFeature(feature.id); + schedulerLog.warn( + `Blocking stranded generated fix feature ${feature.id}: no linked task and no title-matched task available`, + ); + missionStore.updateFeature(feature.id, { + status: "blocked", + loopState: "blocked", + taskId: undefined, + }); + totalFixed++; + } catch (error) { + schedulerLog.warn( + `Failed to block stranded fix feature ${feature.id} during reconciliation: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } else if (feature.status === "defined" || feature.status === "triaged" || feature.status === "in-progress") { + try { + const featureToTriage = feature.status === "defined" + ? feature + : missionStore.updateFeature(feature.id, { + status: "defined", + loopState: "idle", + taskId: undefined, + }); + if (featureToTriage.status !== feature.status) { + totalFixed++; + } + featureForReconciliation = await missionStore.triageFeature(featureToTriage.id); task = featureForReconciliation.taskId ? await this.store.getTask(featureForReconciliation.taskId) : undefined; @@ -2523,7 +2549,7 @@ export class Scheduler { } } else { schedulerLog.warn( - `Skipping stranded feature ${feature.id} with status ${feature.status}: no linked task and no title-matched task available`, + `Skipping stranded feature ${feature.id} with terminal status ${feature.status}: no linked task and no title-matched task available`, ); } } @@ -2605,4 +2631,8 @@ export class Scheduler { private getMissionFeatureTitleKey(sliceId: string, title: string): string { return `${sliceId}\0${this.normalizeMissionFeatureTitle(title)}`; } + + private isGeneratedFixFeature(feature: Pick): boolean { + return Boolean(feature.generatedFromFeatureId || feature.generatedFromRunId); + } }