fix(engine): retriage stale mission feature links

This commit is contained in:
Phil Larson
2026-06-14 19:19:22 -07:00
parent f7635faf37
commit 283f689d8a
3 changed files with 220 additions and 6 deletions

View File

@@ -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.

View File

@@ -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<MissionFeature>) => {
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<MissionFeature>) => {
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<MissionFeature>) => {
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<MissionFeature>) => {
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<MissionFeature>) => {
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 () => {

View File

@@ -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<MissionFeature, "generatedFromFeatureId" | "generatedFromRunId">): boolean {
return Boolean(feature.generatedFromFeatureId || feature.generatedFromRunId);
}
}