Files
fusion/packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts
gsxdsm acad46cc10 FN-5764: expose mission assertion backfill through API and CLI
Expose mission assertion backfill controls and run paths across engine, API, and CLI surfaces.

- add mission assertion backfill gating classification and reliability coverage for assertion-linked validation recovery
- add dashboard mission routes plus legacy API coverage and e2e checks for running assertion backfill
- expose new extension tool support and update Fusion skill docs/capability references
- add a changeset for @runfusion/fusion and refresh mission documentation

Files changed:
 .changeset/fn-5764-mission-backfill-assertions.md  |   9 ++
 docs/missions-completion-contract.md               |   7 +-
 docs/missions.md                                   |   7 +-
 packages/cli/skill/fusion/SKILL.md                 |   2 +-
 .../cli/skill/fusion/references/extension-tools.md |   9 ++
 .../skill/fusion/references/fusion-capabilities.md |   1 +
 packages/cli/src/__tests__/extension.test.ts       |  41 +++++++
 packages/cli/src/extension.ts                      |  43 +++++++
 .../dashboard/app/__tests__/api-missions.test.ts   |  44 +++++++
 packages/dashboard/app/api/legacy.ts               |  34 ++++++
 .../dashboard/src/__tests__/mission-e2e.test.ts    | 127 +++++++++++++++++++++
 packages/dashboard/src/mission-routes.ts           |  29 +++++
 .../mission-validation-trigger-gap.test.ts         |  66 +++++++++++
 .../workflow-step-readonly-allowlist.test.ts       |   1 +
 packages/engine/src/gating-classifications.ts      |   1 +
 15 files changed, 418 insertions(+), 3 deletions(-)

Fusion-Task-Id: FN-5764

Fusion-Task-Lineage: b16123c2-7cfe-4b17-a899-4e30044a7a49
2026-05-31 08:32:01 -07:00

307 lines
12 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import type { MissionFeature, MissionStore, TaskStore } from "@fusion/core";
import { Scheduler } from "../../scheduler.js";
import { MissionExecutionLoop } from "../../mission-execution-loop.js";
function makeTaskStore(taskColumn: "done" | "archived" | "in-progress" = "done") {
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: [],
})),
getRootDir: vi.fn(() => "/test/project"),
getSettings: vi.fn(async () => ({})),
on: vi.fn(),
off: vi.fn(),
} as unknown as TaskStore;
}
function makeFeature(overrides: Partial<MissionFeature> = {}): MissionFeature {
return {
id: "F-001",
sliceId: "SL-001",
title: "Feature",
status: "in-progress",
loopState: "implementing",
implementationAttemptCount: 0,
validatorAttemptCount: 0,
taskId: "FN-001",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
describe("FN-5715 reliability: mission validation trigger gap", () => {
it("starts mission loop + processes completion when done task lands while loop is stopped", async () => {
const feature = makeFeature();
const missionStore = {
getFeatureByTaskId: vi.fn(() => feature),
listAssertionsForFeature: vi.fn(() => [{ id: "CA-1" }]),
updateFeatureStatus: vi.fn(),
getSlice: vi.fn(() => ({ id: "SL-001", milestoneId: "MS-001", status: "active" })),
getMilestone: vi.fn(() => ({ id: "MS-001", missionId: "M-001" })),
} as unknown as MissionStore;
const missionExecutionLoop = {
isRunning: vi.fn(() => false),
start: vi.fn(),
processTaskOutcome: vi.fn(async () => undefined),
};
const scheduler = new Scheduler(makeTaskStore("done"), {
missionStore,
missionExecutionLoop: missionExecutionLoop as any,
});
await (scheduler as any).handleMissionTaskMove("FN-001", "done");
expect(missionExecutionLoop.start).toHaveBeenCalledTimes(1);
expect(missionExecutionLoop.processTaskOutcome).toHaveBeenCalledWith("FN-001");
expect(missionStore.updateFeatureStatus).not.toHaveBeenCalledWith("F-001", "done");
});
it("keeps assertion-linked completion path unchanged", async () => {
const feature = makeFeature();
const missionStore = {
getFeatureByTaskId: vi.fn(() => feature),
listAssertionsForFeature: vi.fn(() => []),
updateFeatureStatus: vi.fn(),
getSlice: vi.fn(() => ({ id: "SL-001", milestoneId: "MS-001", status: "active" })),
getMilestone: vi.fn(() => ({ id: "MS-001", missionId: "M-001" })),
} as unknown as MissionStore;
const scheduler = new Scheduler(makeTaskStore("done"), {
missionStore,
missionExecutionLoop: {
isRunning: vi.fn(() => true),
start: vi.fn(),
processTaskOutcome: vi.fn(async () => undefined),
} as any,
});
await (scheduler as any).handleMissionTaskMove("FN-001", "done");
expect(missionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "done");
});
it("recovers implementing features whose task is already done at startup", async () => {
const feature = makeFeature({ status: "done", lastValidatorStatus: undefined });
const missionStore = {
listMissions: vi.fn(() => [{ id: "M-001", status: "active" }]),
getMissionWithHierarchy: vi.fn(() => ({
id: "M-001",
status: "active",
milestones: [{ status: "active", slices: [{ status: "active", features: [feature] }] }],
})),
listAssertionsForFeature: vi.fn(() => [{ id: "CA-1" }]),
getFeature: vi.fn(() => feature),
transitionLoopState: vi.fn(),
};
const taskStore = {
getTask: vi.fn(async () => ({ id: "FN-001", column: "done" })),
};
const loop = new MissionExecutionLoop({
missionStore: missionStore as any,
taskStore: taskStore as any,
rootDir: process.cwd(),
});
const processSpy = vi.spyOn(loop, "processTaskOutcome").mockResolvedValue(undefined);
loop.start();
await loop.recoverActiveMissions();
expect(processSpy).toHaveBeenCalledWith("FN-001");
loop.stop();
});
it("recovery trigger for done implementing feature is idempotent across subsequent passes", async () => {
const feature = makeFeature({ status: "done", lastValidatorStatus: undefined, loopState: "implementing" });
const missionStore = {
listMissions: vi.fn(() => [{ id: "M-001", status: "active" }]),
getMissionWithHierarchy: vi.fn(() => ({
id: "M-001",
status: "active",
milestones: [{ status: "active", slices: [{ status: "active", features: [feature] }] }],
})),
listAssertionsForFeature: vi.fn(() => [{ id: "CA-1" }]),
getFeature: vi.fn(() => feature),
transitionLoopState: vi.fn(),
};
const taskStore = {
getTask: vi.fn(async () => ({ id: "FN-001", column: "done" })),
};
const loop = new MissionExecutionLoop({
missionStore: missionStore as any,
taskStore: taskStore as any,
rootDir: process.cwd(),
});
const processSpy = vi.spyOn(loop, "processTaskOutcome").mockImplementation(async () => {
feature.lastValidatorStatus = "passed";
feature.loopState = "passed";
});
loop.start();
await loop.recoverActiveMissions();
await loop.recoverActiveMissions();
expect(processSpy).toHaveBeenCalledTimes(1);
loop.stop();
});
it("is idempotent for already-passed implementing features", async () => {
const feature = makeFeature({ status: "done", lastValidatorStatus: "passed" });
const missionStore = {
listMissions: vi.fn(() => [{ id: "M-001", status: "active" }]),
getMissionWithHierarchy: vi.fn(() => ({
id: "M-001",
status: "active",
milestones: [{ status: "active", slices: [{ status: "active", features: [feature] }] }],
})),
listAssertionsForFeature: vi.fn(() => [{ id: "CA-1" }]),
getFeature: vi.fn(() => feature),
transitionLoopState: vi.fn(),
};
const taskStore = {
getTask: vi.fn(async () => ({ id: "FN-001", column: "done" })),
};
const loop = new MissionExecutionLoop({
missionStore: missionStore as any,
taskStore: taskStore as any,
rootDir: process.cwd(),
});
const processSpy = vi.spyOn(loop, "processTaskOutcome").mockResolvedValue(undefined);
loop.start();
await loop.recoverActiveMissions();
expect(processSpy).not.toHaveBeenCalled();
loop.stop();
});
it("periodic recovery pass replays implementing done tasks with zero assertions and advances loop state", async () => {
const feature = makeFeature({ status: "done", lastValidatorStatus: undefined, loopState: "implementing" });
const currentFeature = { ...feature };
const missionStore = {
listMissions: vi.fn(() => [{ id: "M-001", status: "active" }]),
getMissionWithHierarchy: vi.fn(() => ({
id: "M-001",
status: "active",
milestones: [{ status: "active", slices: [{ status: "active", features: [feature] }] }],
})),
getFeatureByTaskId: vi.fn(() => currentFeature),
getFeature: vi.fn(() => currentFeature),
updateFeatureStatus: vi.fn((featureId: string, status: "done") => ({ ...currentFeature, id: featureId, status })),
updateFeature: vi.fn((_featureId: string, patch: Partial<MissionFeature>) => {
Object.assign(currentFeature, patch);
return { ...currentFeature };
}),
listAssertionsForFeature: vi.fn(() => []),
getSlice: vi.fn(() => ({ id: "SL-001", milestoneId: "MS-001", status: "active" })),
getMilestone: vi.fn(() => ({ id: "MS-001", missionId: "M-001" })),
logMissionEvent: vi.fn(),
transitionLoopState: vi.fn(),
};
const taskStore = {
getTask: vi.fn(async () => ({ id: "FN-001", column: "done", status: "done" })),
on: vi.fn(),
off: vi.fn(),
};
const loop = new MissionExecutionLoop({
missionStore: missionStore as any,
taskStore: taskStore as any,
rootDir: process.cwd(),
});
loop.start();
const periodicMaintenancePass = async () => loop.recoverActiveMissions();
await periodicMaintenancePass();
await periodicMaintenancePass();
expect(missionStore.updateFeature).toHaveBeenCalledTimes(1);
expect(missionStore.updateFeature).toHaveBeenCalledWith(
"F-001",
expect.objectContaining({ loopState: "passed", lastValidatorStatus: "passed" }),
);
const noAssertionEvents = missionStore.logMissionEvent.mock.calls.filter(
([, type, , payload]) => type === "warning" && payload?.code === "validation_auto_passed_no_assertions",
);
expect(noAssertionEvents).toHaveLength(1);
loop.stop();
});
it("routes through validator after assertion backfill instead of no-assertion auto-pass", async () => {
const feature = makeFeature({ status: "done", acceptanceCriteria: "must pass", loopState: "implementing" });
const currentFeature = { ...feature };
const linkedAssertions: Array<{ id: string }> = [];
const missionStore = {
listMissions: vi.fn(() => [{ id: "M-001", status: "active" }]),
getMissionWithHierarchy: vi.fn(() => ({
id: "M-001",
status: "active",
milestones: [{ status: "active", slices: [{ status: "active", features: [feature] }] }],
})),
getFeatureByTaskId: vi.fn(() => currentFeature),
getFeature: vi.fn(() => currentFeature),
updateFeatureStatus: vi.fn((_featureId: string, status: "done") => ({ ...currentFeature, status })),
updateFeature: vi.fn((_featureId: string, patch: Partial<MissionFeature>) => {
Object.assign(currentFeature, patch);
return { ...currentFeature };
}),
listAssertionsForFeature: vi.fn(() => linkedAssertions),
startValidatorRun: vi.fn(() => ({ id: "VR-001", featureId: "F-001" })),
completeValidatorRun: vi.fn(),
getSlice: vi.fn(() => ({ id: "SL-001", milestoneId: "MS-001", status: "active" })),
getMilestone: vi.fn(() => ({ id: "MS-001", missionId: "M-001" })),
logMissionEvent: vi.fn(),
transitionLoopState: vi.fn(),
setFeatureCurrentTaskRunId: vi.fn(),
getFailuresForRun: vi.fn(() => []),
};
const taskStore = {
getTask: vi.fn(async () => ({ id: "FN-001", column: "done", status: "done" })),
on: vi.fn(),
off: vi.fn(),
};
const loop = new MissionExecutionLoop({ missionStore: missionStore as any, taskStore: taskStore as any, rootDir: process.cwd() });
vi.spyOn(loop as any, "runValidation").mockResolvedValue({ status: "pass", summary: "ok" });
loop.start();
await loop.recoverActiveMissions();
const noAssertionEventsBefore = missionStore.logMissionEvent.mock.calls.filter(
([, type, , payload]) => type === "warning" && payload?.code === "validation_auto_passed_no_assertions",
);
expect(noAssertionEventsBefore).toHaveLength(1);
expect(missionStore.startValidatorRun).not.toHaveBeenCalled();
linkedAssertions.push({ id: "CA-001" });
currentFeature.loopState = "implementing";
currentFeature.lastValidatorStatus = undefined;
await loop.processTaskOutcome("FN-001");
expect(missionStore.startValidatorRun).toHaveBeenCalledWith("F-001", "task_completion");
const noAssertionEventsAfter = missionStore.logMissionEvent.mock.calls.filter(
([, type, , payload]) => type === "warning" && payload?.code === "validation_auto_passed_no_assertions",
);
expect(noAssertionEventsAfter).toHaveLength(1);
expect(missionStore.updateFeature).toHaveBeenCalledWith(
"F-001",
expect.objectContaining({ loopState: "passed", lastValidatorStatus: "passed" }),
);
loop.stop();
});
});