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
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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:
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
Reference in New Issue
Block a user