fix(engine): retriage stale mission feature links
This commit is contained in:
5
.changeset/repair-stale-mission-feature-links.md
Normal file
5
.changeset/repair-stale-mission-feature-links.md
Normal 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.
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user