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();
|
expect(missionStore.triageFeature).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips inconsistent non-defined stranded features without title match", async () => {
|
it("resets and retriages inconsistent non-defined stranded features without title match", async () => {
|
||||||
const features = [feature({ id: "F-001", status: "triaged", taskId: undefined })];
|
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 = {
|
const missionStore = {
|
||||||
listMissions: vi.fn(() => [{ id: "M-001", status: "active", autopilotEnabled: true }]),
|
listMissions: vi.fn(() => [{ id: "M-001", status: "active", autopilotEnabled: true }]),
|
||||||
getMissionWithHierarchy: vi.fn(() => ({
|
getMissionWithHierarchy: vi.fn(() => ({
|
||||||
@@ -98,14 +141,150 @@ describe("FN-5754 reliability: mission stranded feature retriage", () => {
|
|||||||
})),
|
})),
|
||||||
triageFeature: vi.fn(),
|
triageFeature: vi.fn(),
|
||||||
linkFeatureToTask: 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(),
|
updateFeatureStatus: vi.fn(),
|
||||||
|
listAssertionsForFeature: vi.fn(() => []),
|
||||||
};
|
};
|
||||||
|
|
||||||
const scheduler = new Scheduler(createTaskStore([]), { missionStore: missionStore as any });
|
const scheduler = new Scheduler(createTaskStore([]), { missionStore: missionStore as any });
|
||||||
await scheduler.reconcileAllMissionFeatures();
|
await scheduler.reconcileAllMissionFeatures();
|
||||||
|
|
||||||
expect(missionStore.triageFeature).not.toHaveBeenCalled();
|
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 () => {
|
it("leaves non-autopilot and blocked features untouched", async () => {
|
||||||
|
|||||||
@@ -2501,9 +2501,35 @@ export class Scheduler {
|
|||||||
missionAutoTriageEnabled
|
missionAutoTriageEnabled
|
||||||
&& feature.status !== "blocked"
|
&& feature.status !== "blocked"
|
||||||
) {
|
) {
|
||||||
if (feature.status === "defined") {
|
if (feature.status !== "defined" && this.isGeneratedFixFeature(feature)) {
|
||||||
try {
|
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
|
task = featureForReconciliation.taskId
|
||||||
? await this.store.getTask(featureForReconciliation.taskId)
|
? await this.store.getTask(featureForReconciliation.taskId)
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -2523,7 +2549,7 @@ export class Scheduler {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
schedulerLog.warn(
|
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 {
|
private getMissionFeatureTitleKey(sliceId: string, title: string): string {
|
||||||
return `${sliceId}\0${this.normalizeMissionFeatureTitle(title)}`;
|
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