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:
7
.changeset/fn-9107-mission-trigger.md
Normal file
7
.changeset/fn-9107-mission-trigger.md
Normal 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.
|
||||||
@@ -778,7 +778,7 @@ Mission hierarchy operations are available with the same project-scoped `Mission
|
|||||||
|
|
||||||
## Automatic mission reconciliation
|
## 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
|
### Mission Manager reconcile control
|
||||||
|
|
||||||
|
|||||||
@@ -3,17 +3,23 @@ import type { MissionFeature, MissionStore, TaskStore } from "@fusion/core";
|
|||||||
import { Scheduler } from "../../scheduler.js";
|
import { Scheduler } from "../../scheduler.js";
|
||||||
import { MissionExecutionLoop } from "../../missions/mission-execution-loop.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 {
|
return {
|
||||||
getTask: vi.fn(async (taskId: string) => ({
|
getTask: vi.fn(async () => task),
|
||||||
id: taskId,
|
listTasks: vi.fn(async () => [task]),
|
||||||
title: "Mission task",
|
|
||||||
description: "desc",
|
|
||||||
column: taskColumn,
|
|
||||||
status: taskColumn === "in-progress" ? "in-progress" : "done",
|
|
||||||
sliceId: "SL-001",
|
|
||||||
log: [],
|
|
||||||
})),
|
|
||||||
getRootDir: vi.fn(() => "/test/project"),
|
getRootDir: vi.fn(() => "/test/project"),
|
||||||
getSettings: vi.fn(async () => ({})),
|
getSettings: vi.fn(async () => ({})),
|
||||||
on: vi.fn(),
|
on: vi.fn(),
|
||||||
@@ -69,6 +75,12 @@ describe("FN-5715 reliability: mission validation trigger gap", () => {
|
|||||||
const feature = makeFeature();
|
const feature = makeFeature();
|
||||||
const missionStore = {
|
const missionStore = {
|
||||||
getFeatureByTaskId: vi.fn(() => feature),
|
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(() => []),
|
listAssertionsForFeature: vi.fn(() => []),
|
||||||
reconcileSupersededGeneratedFixFeatures: vi.fn(async () => ({ supersededCount: 0, featureIds: [] as string[] })),
|
reconcileSupersededGeneratedFixFeatures: vi.fn(async () => ({ supersededCount: 0, featureIds: [] as string[] })),
|
||||||
listFeatures: vi.fn(async () => [feature]),
|
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" })),
|
getMilestone: vi.fn(() => ({ id: "MS-001", missionId: "M-001" })),
|
||||||
} as unknown as MissionStore;
|
} as unknown as MissionStore;
|
||||||
|
|
||||||
const scheduler = new Scheduler(makeTaskStore("done"), {
|
const scheduler = new Scheduler(makeTaskStore("done", { missionId: "M-001" }), {
|
||||||
missionStore,
|
missionStore,
|
||||||
missionExecutionLoop: {
|
missionExecutionLoop: {
|
||||||
isRunning: vi.fn(() => true),
|
isRunning: vi.fn(() => true),
|
||||||
@@ -88,7 +100,11 @@ describe("FN-5715 reliability: mission validation trigger gap", () => {
|
|||||||
|
|
||||||
await (scheduler as any).handleMissionTaskMove("FN-001", "done");
|
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 () => {
|
it("recovers implementing features whose task is already done at startup", async () => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3142,13 +3142,17 @@ export class Scheduler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:MissionAutoReconcile 2026-08-11-03:27:
|
FNXC:MissionAutoReconcile 2026-08-15-23:43:
|
||||||
Task moves must enter the reconciliation authority before title-based ownership repair. The
|
Reconciliation is best-effort repair. Its pre-resolution and post-resolution boundaries
|
||||||
authority rejects ambiguous titles within a slice, so duplicate feature titles cannot make a
|
degrade independently so either failure cannot suppress the FN-5715 mission-execution
|
||||||
move attach its task to whichever unlinked feature happened to be listed first.
|
completion trigger, which FN-8948 accidentally stalled.
|
||||||
*/
|
*/
|
||||||
if (task.missionId) {
|
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);
|
const feature = await resolveMissionFeatureForTask(missionStore, task);
|
||||||
if (!feature) {
|
if (!feature) {
|
||||||
@@ -3183,7 +3187,11 @@ export class Scheduler {
|
|||||||
other deterministic ground-truth projection.
|
other deterministic ground-truth projection.
|
||||||
*/
|
*/
|
||||||
if (missionId !== task.missionId) {
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
Reference in New Issue
Block a user