FN-5715: gate mission feature completion on assertion validation

Prevent mission-linked done tasks from bypassing assertion validation and recover stalled validation triggers after restart.

- require assertion-linked done features to have a passed validator result before slice/milestone completion
- keep assertion-linked features in progress when linked tasks move to done/archived until validation passes
- restart mission loop recovery when needed and re-trigger done/archived implementing features that still need validation
- add regression coverage for scheduler, mission loop recovery, mission store completion gating, and reliability trigger continuity
- document the new FN-5715 reliability backstop and recovery behavior

Files changed:
 .changeset/fn-5715-mission-validation-trigger.md   |   7 +
 AGENTS.md                                          |   1 +
 docs/architecture.md                               |   1 +
 docs/missions.md                                   |  11 +-
 packages/core/src/__tests__/mission-integration.test.ts |   2 +-
 packages/core/src/__tests__/mission-store.test.ts  |  30 +++-
 packages/core/src/mission-store.ts                 |  19 ++-
 packages/engine/src/__tests__/mission-execution-loop.test.ts   |  95 +++++++++++++
 packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts         | 151 +++++++++++++++++++++
 packages/engine/src/__tests__/scheduler.test.ts    |  54 ++++++++
 packages/engine/src/mission-autopilot.ts           |   7 +-
 packages/engine/src/mission-execution-loop.ts      |  25 ++++
 packages/engine/src/mission-feature-sync.ts        |  32 ++++-
 packages/engine/src/scheduler.ts                   |  12 +-
 14 files changed, 431 insertions(+), 16 deletions(-)

Fusion-Task-Id: FN-5715

Fusion-Task-Lineage: 98b1cbd6-6b68-40a0-b4b3-c512183adf8b
This commit is contained in:
gsxdsm
2026-05-30 09:43:02 -07:00
parent 98e6a7364e
commit 3255965ad5
14 changed files with 431 additions and 16 deletions

View File

@@ -255,6 +255,9 @@ function createMockMissionStore() {
// Internal setters for test setup
_setMission: (m: Mission) => missions.set(m.id, m),
_setFeature: (f: MissionFeature) => features.set(f.id, f),
_setAssertionsForFeature: (featureId: string, assertions: Array<{ id: string; milestoneId: string; title: string; assertion: string; status: "pending" | "passed" | "failed" | "blocked"; orderIndex: number; createdAt: string; updatedAt: string; sourceFeatureId?: string }>) => {
assertionsByFeature.set(featureId, assertions);
},
_addFeatureWithManagedAssertion: (f: MissionFeature) => {
features.set(f.id, f);
const now = new Date().toISOString();
@@ -1561,6 +1564,98 @@ describe("MissionExecutionLoop", () => {
expect(missionStore.transitionLoopState).toHaveBeenCalledWith("F-VALIDATING-IN-PROGRESS", "implementing");
});
it("should recover implementing features whose linked task is already done and assertions are unpassed", async () => {
const feature = createMockFeature({
id: "F-IMPLEMENTING-DONE",
sliceId: "SL-001",
loopState: "implementing",
status: "done",
taskId: "FN-DONE",
lastValidatorStatus: undefined,
});
missionStore._setFeature(feature);
missionStore._setAssertionsForFeature("F-IMPLEMENTING-DONE", [
{
id: "CA-1",
milestoneId: "MS-001",
title: "Must pass",
assertion: "Assertion",
status: "pending",
orderIndex: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
]);
missionStore.getMissionWithHierarchy = vi.fn().mockReturnValue({
...createMockMission(),
milestones: [
{
...createMockMilestone(),
slices: [{ ...createMockSlice(), features: [feature] }],
},
],
});
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
const processTaskOutcomeSpy = vi.spyOn(loop, "processTaskOutcome");
taskStore._setTask({ id: "FN-DONE", column: "done" });
await loop.recoverActiveMissions();
expect(processTaskOutcomeSpy).toHaveBeenCalledWith("FN-DONE");
});
it("does not re-trigger implementing features when validator already passed", async () => {
const feature = createMockFeature({
id: "F-IMPLEMENTING-PASSED",
sliceId: "SL-001",
loopState: "implementing",
status: "done",
taskId: "FN-PASSED",
lastValidatorStatus: "passed",
});
missionStore._setFeature(feature);
missionStore._setAssertionsForFeature("F-IMPLEMENTING-PASSED", [
{
id: "CA-1",
milestoneId: "MS-001",
title: "Must pass",
assertion: "Assertion",
status: "passed",
orderIndex: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
]);
missionStore.getMissionWithHierarchy = vi.fn().mockReturnValue({
...createMockMission(),
milestones: [
{
...createMockMilestone(),
slices: [{ ...createMockSlice(), features: [feature] }],
},
],
});
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
const processTaskOutcomeSpy = vi.spyOn(loop, "processTaskOutcome");
taskStore._setTask({ id: "FN-PASSED", column: "done" });
await loop.recoverActiveMissions();
expect(processTaskOutcomeSpy).not.toHaveBeenCalled();
});
it("should not call processTaskOutcome for needs_fix features without taskId", async () => {
const feature = createMockFeature({
id: "F-NO-TASK",

View File

@@ -0,0 +1,151 @@
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 no-assertion 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" }]),
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("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" }]),
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();
});
});

View File

@@ -3839,6 +3839,60 @@ describe("Scheduler", () => {
expect(mockAutopilot.handleTaskCompletion).toHaveBeenCalledWith("FN-001");
});
it("keeps assertion-linked features non-done until validator pass", async () => {
const feature = {
id: "F-001",
sliceId: "SL-001",
status: "triaged",
loopState: "implementing",
taskId: "FN-001",
};
const missionStore = {
getFeatureByTaskId: vi.fn().mockReturnValue(feature),
updateFeatureStatus: vi.fn(),
getSlice: vi.fn().mockReturnValue({ id: "SL-001", milestoneId: "MS-001", status: "active" }),
getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }),
listAssertionsForFeature: vi.fn().mockReturnValue([
{
id: "CA-1",
milestoneId: "MS-001",
title: "Must pass",
assertion: "Should pass",
status: "pending",
orderIndex: 0,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
},
]),
};
const missionExecutionLoop = {
isRunning: vi.fn().mockReturnValue(true),
processTaskOutcome: vi.fn().mockResolvedValue(undefined),
start: vi.fn(),
};
const taskStore = createMockStore({
getTask: vi.fn().mockResolvedValue(createMockTask({
id: "FN-001",
title: "Mission task",
description: "done",
column: "done",
sliceId: "SL-001",
log: [],
})),
});
const scheduler = new Scheduler(taskStore, {
missionStore: missionStore as any,
missionExecutionLoop: missionExecutionLoop as any,
});
await (scheduler as any).handleMissionTaskMove("FN-001", "done");
expect(missionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "in-progress");
expect(missionExecutionLoop.processTaskOutcome).toHaveBeenCalledWith("FN-001");
expect(missionStore.updateFeatureStatus).not.toHaveBeenCalledWith("F-001", "done");
});
it("starts validator run through missionExecutionLoop when a linked task moves to done", async () => {
const feature = {
id: "F-001",

View File

@@ -860,7 +860,12 @@ export class MissionAutopilot {
continue;
}
const reconciliation = await reconcileMissionFeatureState(this.taskStore, task, feature);
const hasLinkedAssertions = typeof this.missionStore.listAssertionsForFeature === "function"
? this.missionStore.listAssertionsForFeature(feature.id).length > 0
: false;
const reconciliation = await reconcileMissionFeatureState(this.taskStore, task, feature, {
hasLinkedAssertions,
});
if (reconciliation.kind === "failure") {
await this.handleTaskFailure(feature.taskId);

View File

@@ -141,6 +141,11 @@ export class MissionExecutionLoop extends EventEmitter {
async recoverActiveMissions(): Promise<{ recoveredCount: number }> {
loopLog.log("Starting active mission recovery...");
if (!this.running) {
loopLog.warn("recoverActiveMissions called while loop is stopped; starting loop for recovery");
this.start();
}
try {
const missions = this.missionStore.listMissions();
let recoveredCount = 0;
@@ -202,6 +207,26 @@ export class MissionExecutionLoop extends EventEmitter {
recoveredCount++;
}
}
// Features that remained implementing while their linked task already finished
// can be stranded after restart; recover by re-triggering task outcome.
if (
feature.loopState === "implementing"
&& feature.taskId
&& feature.lastValidatorStatus !== "passed"
&& this.missionStore.listAssertionsForFeature(feature.id).length > 0
) {
try {
const linkedTask = await this.taskStore.getTask(feature.taskId).catch(() => null);
if (linkedTask && (linkedTask.column === "done" || linkedTask.column === "archived")) {
loopLog.log(`Recovery: re-triggering implementing feature ${feature.id} from completed task ${feature.taskId}`);
await this.processTaskOutcome(feature.taskId);
recoveredCount++;
}
} catch (err) {
loopLog.error(`Recovery failed for implementing feature ${feature.id}:`, err);
}
}
}
}
}

View File

@@ -3,6 +3,10 @@ import { getTaskCompletionBlockerForStore } from "./task-completion.js";
export type MissionFeatureSyncTargetStatus = "done" | "in-progress" | "triaged";
export interface MissionFeatureSyncContext {
hasLinkedAssertions?: boolean;
}
export type MissionFeatureSyncDecision =
| { kind: "failure"; reason: string }
| { kind: "blocked"; reason: string }
@@ -12,7 +16,8 @@ export type MissionFeatureSyncDecision =
export async function reconcileMissionFeatureState(
taskStore: Pick<TaskStore, "getTask">,
task: Task,
feature: Pick<MissionFeature, "id" | "status">,
feature: Pick<MissionFeature, "id" | "status" | "lastValidatorStatus">,
context: MissionFeatureSyncContext = {},
): Promise<MissionFeatureSyncDecision> {
if (task.status === "failed" && feature.status === "in-progress") {
return {
@@ -21,12 +26,26 @@ export async function reconcileMissionFeatureState(
};
}
const hasUnvalidatedAssertions = context.hasLinkedAssertions === true
&& feature.lastValidatorStatus !== "passed";
if (task.column === "done") {
const blocker = await getTaskCompletionBlockerForStore(taskStore, task);
if (blocker) {
return { kind: "blocked", reason: blocker };
}
if (hasUnvalidatedAssertions) {
if (feature.status !== "in-progress") {
return {
kind: "update",
status: "in-progress",
reason: `task ${task.id} completed; awaiting assertion validation`,
};
}
return { kind: "noop" };
}
if (feature.status !== "done") {
return {
kind: "update",
@@ -39,6 +58,17 @@ export async function reconcileMissionFeatureState(
}
if (task.column === "archived") {
if (hasUnvalidatedAssertions) {
if (feature.status !== "in-progress") {
return {
kind: "update",
status: "in-progress",
reason: `task ${task.id} archived; awaiting assertion validation`,
};
}
return { kind: "noop" };
}
if (feature.status !== "done") {
return {
kind: "update",

View File

@@ -1751,10 +1751,15 @@ export class Scheduler {
return;
}
const hasLinkedAssertions = typeof missionStore.listAssertionsForFeature === "function"
? missionStore.listAssertionsForFeature(feature.id).length > 0
: false;
const reconciliation = await reconcileMissionFeatureState(
this.store,
{ ...task, column: toColumn },
feature,
{ hasLinkedAssertions },
);
if (reconciliation.kind === "blocked") {
@@ -2050,7 +2055,12 @@ export class Scheduler {
if (!task) continue;
const reconciliation = await reconcileMissionFeatureState(this.store, task, featureForReconciliation);
const hasLinkedAssertions = typeof missionStore.listAssertionsForFeature === "function"
? missionStore.listAssertionsForFeature(featureForReconciliation.id).length > 0
: false;
const reconciliation = await reconcileMissionFeatureState(this.store, task, featureForReconciliation, {
hasLinkedAssertions,
});
if (reconciliation.kind === "failure") {
if (this.options.onTaskFailed) {