FN-9013: fix mission feature reconciliation
Preserve mission-store method receivers so active mission reconciliation completes reliably. - Invoke optional listFeatures with its mission-store receiver. - Contain reconciliation failures to individual slices and continue remaining work. - Cover synchronous, asynchronous, fallback, and failing-slice behavior. Files changed: .changeset/fn-9013-scheduler-listfeatures-receiver.md | 7 ++ .../mission-stranded-feature-retriage.test.ts | 109 +++++++++++++++++++++ packages/engine/src/scheduler.ts | 51 ++++++---- 3 files changed, 146 insertions(+), 21 deletions(-) Fusion-Task-Id: FN-9013 Fusion-Task-Lineage: 2745383a-3780-4fa4-bf07-bc75419d3384 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-9013-scheduler-listfeatures-receiver.md
Normal file
7
.changeset/fn-9013-scheduler-listfeatures-receiver.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Fix mission reconciliation failing every cycle with an internal scheduler error.
|
||||||
|
category: fix
|
||||||
|
dev: Preserves the listFeatures receiver and contains per-slice failures in Scheduler.reconcileActiveMissionAutomation.
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { MissionFeature, TaskStore } from "@fusion/core";
|
import type { MissionFeature, TaskStore } from "@fusion/core";
|
||||||
|
import { schedulerLog } from "../../logger.js";
|
||||||
import { Scheduler } from "../../scheduler.js";
|
import { Scheduler } from "../../scheduler.js";
|
||||||
|
|
||||||
function createTaskStore(tasks: any[] = []): TaskStore {
|
function createTaskStore(tasks: any[] = []): TaskStore {
|
||||||
@@ -446,3 +447,111 @@ describe("FN-5754 reliability: mission stranded feature retriage", () => {
|
|||||||
expect((store.recordRunAuditEvent as any).mock.calls.some(([event]: any[]) => event.mutationType === "mission:stranded-feature-triaged" && event.metadata?.featureId === "F-001" && event.metadata?.taskId === "FN-001")).toBe(true);
|
expect((store.recordRunAuditEvent as any).mock.calls.some(([event]: any[]) => event.mutationType === "mission:stranded-feature-triaged" && event.metadata?.featureId === "F-001" && event.metadata?.taskId === "FN-001")).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("FN-9013 regression: mission listFeatures receiver and containment", () => {
|
||||||
|
class ReceiverDependentMissionStore {
|
||||||
|
readonly db: { features: (sliceId: string) => MissionFeature[] | Promise<MissionFeature[]> };
|
||||||
|
readonly listMissions: ReturnType<typeof vi.fn>;
|
||||||
|
readonly getMissionWithHierarchy: ReturnType<typeof vi.fn>;
|
||||||
|
readonly reconcileSupersededGeneratedFixFeatures = vi.fn(async () => ({ supersededCount: 0, featureIds: [] as string[] }));
|
||||||
|
readonly triageFeature: ReturnType<typeof vi.fn>;
|
||||||
|
readonly updateFeature = vi.fn();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
featuresBySlice: Record<string, MissionFeature[]>,
|
||||||
|
options: { asyncListFeatures: boolean; failSliceId?: string; missions?: Array<{ id: string; status: string; autopilotEnabled: boolean }>; hierarchyByMission?: Record<string, unknown> },
|
||||||
|
) {
|
||||||
|
this.db = {
|
||||||
|
features: (sliceId) => {
|
||||||
|
if (sliceId === options.failSliceId) throw new Error(`slice ${sliceId} failed`);
|
||||||
|
const features = featuresBySlice[sliceId] ?? [];
|
||||||
|
return options.asyncListFeatures ? Promise.resolve(features) : features;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
this.listMissions = vi.fn(() => options.missions ?? [{ id: "M-001", status: "active", autopilotEnabled: true }]);
|
||||||
|
this.getMissionWithHierarchy = vi.fn((missionId: string) => options.hierarchyByMission?.[missionId] ?? {
|
||||||
|
id: missionId,
|
||||||
|
status: "active",
|
||||||
|
milestones: [{ id: `MS-${missionId}`, slices: [{ id: "SL-001", status: "active", features: featuresBySlice["SL-001"] ?? [] }] }],
|
||||||
|
});
|
||||||
|
this.triageFeature = vi.fn(async (featureId: string) => {
|
||||||
|
const candidate = Object.values(featuresBySlice).flat().find((item) => item.id === featureId)!;
|
||||||
|
return { ...candidate, taskId: `FN-${featureId}`, status: "triaged" };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
listFeatures(sliceId: string): MissionFeature[] | Promise<MissionFeature[]> {
|
||||||
|
return this.db.features(sliceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it.each([true, false])("preserves the receiver for %s listFeatures stores and retriages stranded features", async (asyncListFeatures) => {
|
||||||
|
const features = { "SL-001": [feature({ id: asyncListFeatures ? "F-ASYNC" : "F-SYNC" })] };
|
||||||
|
const missionStore = new ReceiverDependentMissionStore(features, { asyncListFeatures });
|
||||||
|
const errorSpy = vi.spyOn(schedulerLog, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fixed = await new Scheduler(createTaskStore(), { missionStore: missionStore as any }).reconcileAllMissionFeatures();
|
||||||
|
|
||||||
|
expect(missionStore.triageFeature).toHaveBeenCalledWith(features["SL-001"][0].id);
|
||||||
|
expect(fixed).toBeGreaterThan(0);
|
||||||
|
expect(errorSpy.mock.calls.some(([, error]) => error instanceof TypeError)).toBe(false);
|
||||||
|
} finally {
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to hierarchy features when the optional capability is absent", async () => {
|
||||||
|
const features = [feature({ id: "F-FALLBACK" })];
|
||||||
|
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(async () => ({ supersededCount: 0, featureIds: [] as string[] })),
|
||||||
|
triageFeature: vi.fn(async () => ({ ...features[0], taskId: "FN-FALLBACK", status: "triaged" })),
|
||||||
|
};
|
||||||
|
|
||||||
|
await new Scheduler(createTaskStore(), { missionStore: missionStore as any }).reconcileAllMissionFeatures();
|
||||||
|
|
||||||
|
expect(missionStore.triageFeature).toHaveBeenCalledWith("F-FALLBACK");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs one failing slice and continues remaining slices and missions", async () => {
|
||||||
|
const featuresBySlice = {
|
||||||
|
"SL-001": [feature({ id: "F-FAILED", sliceId: "SL-001" })],
|
||||||
|
"SL-002": [feature({ id: "F-SECOND-SLICE", sliceId: "SL-002" })],
|
||||||
|
"SL-003": [feature({ id: "F-SECOND-MISSION", sliceId: "SL-003" })],
|
||||||
|
"SL-004": [feature({ id: "F-SECOND-MISSION-SECOND-SLICE", sliceId: "SL-004" })],
|
||||||
|
};
|
||||||
|
const missionStore = new ReceiverDependentMissionStore(featuresBySlice, {
|
||||||
|
asyncListFeatures: true,
|
||||||
|
failSliceId: "SL-001",
|
||||||
|
missions: [
|
||||||
|
{ id: "M-001", status: "active", autopilotEnabled: true },
|
||||||
|
{ id: "M-002", status: "active", autopilotEnabled: true },
|
||||||
|
],
|
||||||
|
hierarchyByMission: {
|
||||||
|
"M-001": { id: "M-001", status: "active", milestones: [{ id: "MS-001", slices: [{ id: "SL-001", status: "active", features: featuresBySlice["SL-001"] }, { id: "SL-002", status: "active", features: featuresBySlice["SL-002"] }] }] },
|
||||||
|
"M-002": { id: "M-002", status: "active", milestones: [{ id: "MS-002", slices: [{ id: "SL-003", status: "active", features: featuresBySlice["SL-003"] }, { id: "SL-004", status: "active", features: featuresBySlice["SL-004"] }] }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const errorSpy = vi.spyOn(schedulerLog, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fixed = await new Scheduler(createTaskStore(), { missionStore: missionStore as any }).reconcileAllMissionFeatures();
|
||||||
|
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("SL-001"), expect.any(Error));
|
||||||
|
expect(missionStore.triageFeature).toHaveBeenCalledWith("F-SECOND-SLICE");
|
||||||
|
expect(missionStore.triageFeature).toHaveBeenCalledWith("F-SECOND-MISSION");
|
||||||
|
expect(missionStore.triageFeature).toHaveBeenCalledWith("F-SECOND-MISSION-SECOND-SLICE");
|
||||||
|
expect(missionStore.triageFeature).not.toHaveBeenCalledWith("F-FAILED");
|
||||||
|
expect(fixed).toBeGreaterThan(0);
|
||||||
|
} finally {
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -3392,29 +3392,38 @@ export class Scheduler {
|
|||||||
const hierarchy = await missionStore.getMissionWithHierarchy(mission.id);
|
const hierarchy = await missionStore.getMissionWithHierarchy(mission.id);
|
||||||
if (!hierarchy) continue;
|
if (!hierarchy) continue;
|
||||||
for (const slice of hierarchy.milestones.flatMap((milestone) => milestone.slices).filter((slice) => slice.status === "active")) {
|
for (const slice of hierarchy.milestones.flatMap((milestone) => milestone.slices).filter((slice) => slice.status === "active")) {
|
||||||
const superseded = await missionStore.reconcileSupersededGeneratedFixFeatures(slice.id);
|
try {
|
||||||
fixed += superseded.supersededCount;
|
const superseded = await missionStore.reconcileSupersededGeneratedFixFeatures(slice.id);
|
||||||
if (superseded.supersededCount > 0) {
|
fixed += superseded.supersededCount;
|
||||||
const refreshed = await missionStore.getSlice(slice.id);
|
if (superseded.supersededCount > 0) {
|
||||||
if (refreshed?.status === "complete") { await this.onSliceComplete(refreshed); continue; }
|
const refreshed = await missionStore.getSlice(slice.id);
|
||||||
}
|
if (refreshed?.status === "complete") { await this.onSliceComplete(refreshed); continue; }
|
||||||
const listFeatures = (missionStore as unknown as { listFeatures?: (sliceId: string) => Promise<MissionFeature[]> }).listFeatures;
|
|
||||||
const features = listFeatures ? await listFeatures(slice.id) : slice.features;
|
|
||||||
const supersededFeatureIds = new Set(superseded.featureIds ?? []);
|
|
||||||
const autoTriage = mission.autopilotEnabled === true || mission.autoAdvance === true;
|
|
||||||
for (const feature of features) {
|
|
||||||
if (supersededFeatureIds.has(feature.id) || feature.taskId || !autoTriage || feature.status === "blocked") continue;
|
|
||||||
if (feature.status !== "defined" && this.isGeneratedFixFeature(feature)) {
|
|
||||||
await missionStore.updateFeature(feature.id, { status: "blocked", loopState: "blocked", taskId: undefined });
|
|
||||||
fixed++;
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
if (feature.status !== "defined" && feature.status !== "triaged" && feature.status !== "in-progress") continue;
|
const listFeatures = (missionStore as unknown as { listFeatures?: (sliceId: string) => Promise<MissionFeature[]> | MissionFeature[] }).listFeatures;
|
||||||
const ready = feature.status === "defined" ? feature : await missionStore.updateFeature(feature.id, { status: "defined", loopState: "idle", taskId: undefined });
|
/*
|
||||||
if (ready !== feature) fixed++;
|
FNXC:MissionAutoReconcile 2026-08-12-21:58:
|
||||||
const triaged = await missionStore.triageFeature(ready.id);
|
Mission-store methods use their receiver, so optional-capability probing must invoke listFeatures with missionStore.
|
||||||
fixed++;
|
Per-slice containment logs a failed slice but lets remaining slices and missions reconcile in the same pass.
|
||||||
if (triaged.taskId) await this.emitStrandedFeatureTriageAudit(mission.id, slice.id, triaged.id, triaged.taskId);
|
*/
|
||||||
|
const features = listFeatures ? await listFeatures.call(missionStore, slice.id) : slice.features;
|
||||||
|
const supersededFeatureIds = new Set(superseded.featureIds ?? []);
|
||||||
|
const autoTriage = mission.autopilotEnabled === true || mission.autoAdvance === true;
|
||||||
|
for (const feature of features) {
|
||||||
|
if (supersededFeatureIds.has(feature.id) || feature.taskId || !autoTriage || feature.status === "blocked") continue;
|
||||||
|
if (feature.status !== "defined" && this.isGeneratedFixFeature(feature)) {
|
||||||
|
await missionStore.updateFeature(feature.id, { status: "blocked", loopState: "blocked", taskId: undefined });
|
||||||
|
fixed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (feature.status !== "defined" && feature.status !== "triaged" && feature.status !== "in-progress") continue;
|
||||||
|
const ready = feature.status === "defined" ? feature : await missionStore.updateFeature(feature.id, { status: "defined", loopState: "idle", taskId: undefined });
|
||||||
|
if (ready !== feature) fixed++;
|
||||||
|
const triaged = await missionStore.triageFeature(ready.id);
|
||||||
|
fixed++;
|
||||||
|
if (triaged.taskId) await this.emitStrandedFeatureTriageAudit(mission.id, slice.id, triaged.id, triaged.taskId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
schedulerLog.error(`Error during active mission recovery reconciliation for slice ${slice.id}:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user