diff --git a/.changeset/fn-9107-mission-trigger.md b/.changeset/fn-9107-mission-trigger.md new file mode 100644 index 0000000000..9ab96f8007 --- /dev/null +++ b/.changeset/fn-9107-mission-trigger.md @@ -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. diff --git a/docs/missions.md b/docs/missions.md index d4cde2e7fa..14dffee1a5 100644 --- a/docs/missions.md +++ b/docs/missions.md @@ -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:` 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:` and API/tool calls retain their operator or agent actor. ### Mission Manager reconcile control diff --git a/packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts b/packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts index 169c94e595..657ee0d7df 100644 --- a/packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts @@ -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 = {}, +) { + 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 () => { diff --git a/packages/engine/src/__tests__/scheduler-mission-move-trigger.test.ts b/packages/engine/src/__tests__/scheduler-mission-move-trigger.test.ts new file mode 100644 index 0000000000..d1c67b0f1b --- /dev/null +++ b/packages/engine/src/__tests__/scheduler-mission-move-trigger.test.ts @@ -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 = {}) { + return { + id: "FN-001", + title: "Mission task", + description: "desc", + column: "done", + status: "done", + sliceId: "SL-001", + log: [], + ...overrides, + }; +} + +function feature(overrides: Partial = {}): 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, overrides: Record = {}) { + 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 = {}) { + 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, + 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((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(); + }); +}); diff --git a/packages/engine/src/scheduler.ts b/packages/engine/src/scheduler.ts index 4a0ba7fbe3..2a4e3da9a4 100644 --- a/packages/engine/src/scheduler.ts +++ b/packages/engine/src/scheduler.ts @@ -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); + } } /*