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
307 lines
12 KiB
TypeScript
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();
|
|
});
|
|
});
|