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:
gsxdsm
2026-08-12 15:14:32 -07:00
parent 02f7b589e2
commit b0f623b100
3 changed files with 147 additions and 22 deletions

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

View File

@@ -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();
}
});
});

View File

@@ -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);
} }
} }
} }