FN-9107: preserve mission completion triggers after reconciliation failures

Keep scheduler mission completion handling active when best-effort reconciliation fails.

- isolate pre- and post-resolution reconciliation failures without suppressing mission execution
- cover custom completion columns, failed in-place updates, fallback behavior, and slice guards
- document the resilience contract and add a patch changeset

Files changed:
 .changeset/fn-9107-mission-trigger.md              |   7 +
 docs/missions.md                                   |   2 +-
 .../mission-validation-trigger-gap.test.ts         |  40 ++++--
 .../scheduler-mission-move-trigger.test.ts         | 153 +++++++++++++++++++++
 packages/engine/src/scheduler.ts                   |  20 ++-
 5 files changed, 203 insertions(+), 19 deletions(-)

Fusion-Task-Id: FN-9107

Fusion-Task-Lineage: a492d0c0-79ca-4dba-a7bc-c8fa54415f28

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-15 17:06:39 -07:00
parent 84bbdadab4
commit 43a42d8b7b
5 changed files with 203 additions and 19 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Resume mission validation when a completed mission task's reconciliation fails.
category: fix
dev: Scheduler task-move reconciliation now fails soft at both boundaries so completion still starts mission execution.

View File

@@ -778,7 +778,7 @@ Mission hierarchy operations are available with the same project-scoped `Mission
## Automatic mission reconciliation
The scheduler startup and self-healing maintenance passes, mission autopilot, task moves, and `fn_mission_reconcile({ id?, dryRun? })` use one idempotent reconciliation authority. `POST /api/missions/:missionId/reconcile` exposes the same pass; `dryRun: true` returns planned changes without mutation. Automatic writes are attributed to `mission-reconcile:<startup|self-healing|autopilot|task-move>` and API/tool calls retain their operator or agent actor.
The scheduler startup and self-healing maintenance passes, mission autopilot, task moves, and `fn_mission_reconcile({ id?, dryRun? })` use one idempotent reconciliation authority. `POST /api/missions/:missionId/reconcile` exposes the same pass; `dryRun: true` returns planned changes without mutation. Task-move reconciliation is best-effort: a reconciliation failure is logged but cannot suppress the mission completion trigger or its validation loop. Automatic writes are attributed to `mission-reconcile:<startup|self-healing|autopilot|task-move>` and API/tool calls retain their operator or agent actor.
### Mission Manager reconcile control

View File

@@ -3,17 +3,23 @@ import type { MissionFeature, MissionStore, TaskStore } from "@fusion/core";
import { Scheduler } from "../../scheduler.js";
import { MissionExecutionLoop } from "../../missions/mission-execution-loop.js";
function makeTaskStore(taskColumn: "done" | "archived" | "in-progress" = "done") {
function makeTaskStore(
taskColumn: "done" | "archived" | "in-progress" = "done",
overrides: Record<string, unknown> = {},
) {
const task = {
id: "FN-001",
title: "Mission task",
description: "desc",
column: taskColumn,
status: taskColumn === "in-progress" ? "in-progress" : "done",
sliceId: "SL-001",
log: [],
...overrides,
};
return {
getTask: vi.fn(async (taskId: string) => ({
id: taskId,
title: "Mission task",
description: "desc",
column: taskColumn,
status: taskColumn === "in-progress" ? "in-progress" : "done",
sliceId: "SL-001",
log: [],
})),
getTask: vi.fn(async () => task),
listTasks: vi.fn(async () => [task]),
getRootDir: vi.fn(() => "/test/project"),
getSettings: vi.fn(async () => ({})),
on: vi.fn(),
@@ -69,6 +75,12 @@ describe("FN-5715 reliability: mission validation trigger gap", () => {
const feature = makeFeature();
const missionStore = {
getFeatureByTaskId: vi.fn(() => feature),
getMission: vi.fn(async () => ({ id: "M-001", status: "active" })),
getMissionWithHierarchy: vi.fn(async () => ({
id: "M-001",
status: "active",
milestones: [{ id: "MS-001", slices: [{ id: "SL-001", features: [feature] }] }],
})),
listAssertionsForFeature: vi.fn(() => []),
reconcileSupersededGeneratedFixFeatures: vi.fn(async () => ({ supersededCount: 0, featureIds: [] as string[] })),
listFeatures: vi.fn(async () => [feature]),
@@ -77,7 +89,7 @@ describe("FN-5715 reliability: mission validation trigger gap", () => {
getMilestone: vi.fn(() => ({ id: "MS-001", missionId: "M-001" })),
} as unknown as MissionStore;
const scheduler = new Scheduler(makeTaskStore("done"), {
const scheduler = new Scheduler(makeTaskStore("done", { missionId: "M-001" }), {
missionStore,
missionExecutionLoop: {
isRunning: vi.fn(() => true),
@@ -88,7 +100,11 @@ describe("FN-5715 reliability: mission validation trigger gap", () => {
await (scheduler as any).handleMissionTaskMove("FN-001", "done");
expect(missionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "done");
expect(missionStore.updateFeatureStatus).toHaveBeenCalledWith(
"F-001",
"done",
expect.objectContaining({ actor: expect.anything() }),
);
});
it("recovers implementing features whose task is already done at startup", async () => {

View File

@@ -0,0 +1,153 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { MissionFeature, MissionStore, TaskStore } from "@fusion/core";
const { reconcileMissionState } = vi.hoisted(() => ({ reconcileMissionState: vi.fn() }));
vi.mock("../missions/mission-state-reconcile.js", () => ({ reconcileMissionState }));
import { Scheduler } from "../scheduler.js";
function task(overrides: Record<string, unknown> = {}) {
return {
id: "FN-001",
title: "Mission task",
description: "desc",
column: "done",
status: "done",
sliceId: "SL-001",
log: [],
...overrides,
};
}
function feature(overrides: Partial<MissionFeature> = {}): MissionFeature {
return {
id: "F-001",
title: "Feature",
sliceId: "SL-001",
status: "in-progress",
loopState: "implementing",
implementationAttemptCount: 0,
validatorAttemptCount: 0,
taskId: "FN-001",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
function storeFor(currentTask: ReturnType<typeof task>, overrides: Record<string, unknown> = {}) {
return {
getTask: vi.fn(async () => currentTask),
getRootDir: vi.fn(() => "/test/project"),
getSettings: vi.fn(async () => ({})),
on: vi.fn(),
off: vi.fn(),
...overrides,
} as unknown as TaskStore;
}
function missionStoreFor(currentFeature = feature(), overrides: Record<string, unknown> = {}) {
return {
getFeatureByTaskId: vi.fn(async () => currentFeature),
getSlice: vi.fn(async () => ({ id: "SL-001", milestoneId: "MS-001", status: "active" })),
getMilestone: vi.fn(async () => ({ id: "MS-001", missionId: "M-001" })),
...overrides,
} as unknown as MissionStore;
}
function loop(running = false) {
return {
isRunning: vi.fn(() => running),
start: vi.fn(),
processTaskOutcome: vi.fn(async () => undefined),
};
}
async function move(
currentTask: ReturnType<typeof task>,
currentMissionStore: MissionStore,
missionExecutionLoop = loop(),
taskStore: TaskStore = storeFor(currentTask),
) {
const scheduler = new Scheduler(taskStore, { missionStore: currentMissionStore, missionExecutionLoop: missionExecutionLoop as any });
await (scheduler as any).handleMissionTaskMove(currentTask.id, currentTask.column);
return { scheduler, missionExecutionLoop };
}
describe("FN-9107 scheduler mission completion trigger", () => {
beforeEach(() => reconcileMissionState.mockReset());
it("continues after the pre-resolution reconciliation boundary fails", async () => {
const currentTask = task({ missionId: "M-001" });
reconcileMissionState.mockRejectedValueOnce(new Error("pre-resolution failure"));
const { missionExecutionLoop } = await move(currentTask, missionStoreFor());
expect(reconcileMissionState).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ missionId: "M-001" }));
expect(missionExecutionLoop.start).toHaveBeenCalledTimes(1);
expect(missionExecutionLoop.processTaskOutcome).toHaveBeenCalledWith("FN-001");
});
it("continues after the post-resolution reconciliation boundary fails without restarting a running loop", async () => {
const currentTask = task();
reconcileMissionState.mockImplementation(async (...args: unknown[]) => {
const options = args[1] as { missionId?: string } | undefined;
if (options?.missionId === "M-001") throw new Error("post-resolution failure");
});
const { missionExecutionLoop } = await move(currentTask, missionStoreFor(), loop(true));
expect(reconcileMissionState).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ missionId: "M-001" }));
expect(missionExecutionLoop.start).not.toHaveBeenCalled();
expect(missionExecutionLoop.processTaskOutcome).toHaveBeenCalledWith("FN-001");
});
it("triggers through a custom completion-role column", async () => {
const currentTask = task({ column: "shipped" });
const taskStore = storeFor(currentTask, {
getTaskWorkflowSelection: vi.fn(() => ({ workflowId: "WF-001", stepIds: [] })),
getWorkflowDefinition: vi.fn(async () => ({
ir: { version: "v2", id: "WF-001", nodes: [], edges: [], columns: [{ id: "shipped", name: "Shipped", traits: [{ trait: "complete" }] }] },
})),
});
const { missionExecutionLoop } = await move(currentTask, missionStoreFor(), loop(), taskStore);
expect(missionExecutionLoop.start).toHaveBeenCalledTimes(1);
expect(missionExecutionLoop.processTaskOutcome).toHaveBeenCalledWith("FN-001");
});
it("keeps the legacy done fallback when workflow resolution fails", async () => {
const currentTask = task();
const taskStore = storeFor(currentTask, {
getTaskWorkflowSelection: vi.fn(() => { throw new Error("workflow unavailable"); }),
});
const { missionExecutionLoop } = await move(currentTask, missionStoreFor(), loop(), taskStore);
expect(missionExecutionLoop.start).toHaveBeenCalledTimes(1);
expect(missionExecutionLoop.processTaskOutcome).toHaveBeenCalledWith("FN-001");
});
it("uses the same trigger when an in-place failure park dispatches task:updated", async () => {
const currentTask = task({ status: "failed" });
const taskStore = storeFor(currentTask);
const missionExecutionLoop = loop();
const scheduler = new Scheduler(taskStore, { missionStore: missionStoreFor(), missionExecutionLoop: missionExecutionLoop as any });
const updatedListener = taskStore.on.mock.calls.find(([event]) => event === "task:updated")?.[1];
updatedListener(currentTask);
await new Promise<void>((resolve) => setImmediate(resolve));
expect(missionExecutionLoop.start).toHaveBeenCalledTimes(1);
expect(missionExecutionLoop.processTaskOutcome).toHaveBeenCalledWith("FN-001");
});
it("preserves the slice mismatch guard", async () => {
const currentTask = task();
const { missionExecutionLoop } = await move(currentTask, missionStoreFor(feature({ sliceId: "SL-OTHER" })));
expect(missionExecutionLoop.start).not.toHaveBeenCalled();
expect(missionExecutionLoop.processTaskOutcome).not.toHaveBeenCalled();
});
});

View File

@@ -3142,13 +3142,17 @@ export class Scheduler {
}
/*
FNXC:MissionAutoReconcile 2026-08-11-03:27:
Task moves must enter the reconciliation authority before title-based ownership repair. The
authority rejects ambiguous titles within a slice, so duplicate feature titles cannot make a
move attach its task to whichever unlinked feature happened to be listed first.
FNXC:MissionAutoReconcile 2026-08-15-23:43:
Reconciliation is best-effort repair. Its pre-resolution and post-resolution boundaries
degrade independently so either failure cannot suppress the FN-5715 mission-execution
completion trigger, which FN-8948 accidentally stalled.
*/
if (task.missionId) {
await reconcileMissionState({ taskStore: this.store, missionStore }, { missionId: task.missionId, source: "task-move" });
try {
await reconcileMissionState({ taskStore: this.store, missionStore }, { missionId: task.missionId, source: "task-move" });
} catch (error) {
schedulerLog.warn(`Mission reconciliation failed before resolving task ${taskId}; continuing mission completion handling:`, error);
}
}
const feature = await resolveMissionFeatureForTask(missionStore, task);
if (!feature) {
@@ -3183,7 +3187,11 @@ export class Scheduler {
other deterministic ground-truth projection.
*/
if (missionId !== task.missionId) {
await reconcileMissionState({ taskStore: this.store, missionStore }, { missionId, source: "task-move" });
try {
await reconcileMissionState({ taskStore: this.store, missionStore }, { missionId, source: "task-move" });
} catch (error) {
schedulerLog.warn(`Mission reconciliation failed after resolving task ${taskId}; continuing mission completion handling:`, error);
}
}
/*