FN-9028: guard automatic mission rollup status writers
Protect automatic mission and milestone status rollups with a durable writer-coverage ratchet. - Add source-level coverage for every computed mission and milestone status writer. - Verify core persistence and engine reconciliation preserve blocked and archived intent. - Document shared rollup-ownership guard requirements. Files changed: docs/missions.md | 2 +- .../mission-rollup-status-writer-ratchet.test.ts | 170 +++++++++++++++++++++ .../mission-status-recompute-guard.test.ts | 11 ++ .../__tests__/postgres/mission-store.pg.test.ts | 43 ++++-- packages/core/src/missions/mission-types.ts | 4 +- .../mission-autopilot-rollup-guard.test.ts | 115 ++++++++++++++ 6 files changed, 331 insertions(+), 14 deletions(-) Fusion-Task-Id: FN-9028 Fusion-Task-Lineage: 89b72b57-fc73-4268-a639-5edac05ff8f8 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -386,7 +386,7 @@ If validation cannot run (unexpected loop state, duplicate trigger, blocked vali
|
||||
|
||||
Mission `status` and `autopilotEnabled` transitions are atomically written with a mission activity event. The event records stable actor type/id, optional display name, source, and before/after values; unchanged values create no transition event. Dashboard controls identify an operator, tools identify an agent when they expose a sensitive mutation, and autonomous engine paths identify the system/autopilot.
|
||||
|
||||
Automatic hierarchy rollup, including terminal-task delivery reconciliation, owns only `planning`, `active`, and `complete` for missions and milestones. It never rewrites intentional `blocked` or `archived` status during hierarchy churn; a blocked mission stays blocked even after all milestones complete. Those statuses change only through resume, an explicit status write, or the mission clear-blocked path.
|
||||
Automatic hierarchy rollup, including terminal-task delivery reconciliation, owns only `planning`, `active`, and `complete` for missions and milestones. It never rewrites intentional `blocked` or `archived` status during hierarchy churn; a blocked mission stays blocked even after all milestones complete. Those statuses change only through resume, an explicit status write, or the mission clear-blocked path. `packages/core/src/__tests__/mission-rollup-status-writer-ratchet.test.ts` inventories first-party computed status writers and requires each automatic writer to use the shared ownership guard.
|
||||
|
||||
## `autopilotEnabled` vs `autoAdvance`
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
FNXC:MissionStatusRollup 2026-08-13-21:59:
|
||||
Automatic hierarchy rollups may only replace statuses that the hierarchy can derive. This
|
||||
cheap source ratchet makes a new computed mission or milestone status writer reviewable before
|
||||
it can bypass shouldApplyRecomputedStatus and clear blocked or archived operator intent.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
const REPO_ROOT = resolve(import.meta.dirname, "../../../..");
|
||||
|
||||
function sourceRoots(base: string = REPO_ROOT): string[] {
|
||||
const roots: string[] = [];
|
||||
for (const tree of ["packages", "plugins"]) {
|
||||
try {
|
||||
for (const name of readdirSync(join(base, tree))) {
|
||||
try {
|
||||
if (statSync(join(base, tree, name, "src")).isDirectory()) roots.push(`${tree}/${name}/src`);
|
||||
} catch { /* not a source package */ }
|
||||
}
|
||||
} catch { /* optional first-party tree */ }
|
||||
}
|
||||
return roots.sort();
|
||||
}
|
||||
|
||||
function sourceFiles(root: string, base: string): string[] {
|
||||
const files: string[] = [];
|
||||
const walk = (dir: string): void => {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
if (!["__tests__", "dist", "node_modules"].includes(entry)) walk(full);
|
||||
} else if (/\.tsx?$/.test(entry) && !entry.endsWith(".d.ts")) files.push(full);
|
||||
}
|
||||
};
|
||||
walk(join(base, root));
|
||||
return files;
|
||||
}
|
||||
|
||||
function executableSource(file: string): string {
|
||||
return readFileSync(file, "utf8").replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "");
|
||||
}
|
||||
|
||||
/** Modules with hierarchy-derived status writes; each must retain the shared guard. */
|
||||
const AUTOMATIC_GUARDED_WRITER_MODULES = [
|
||||
// Sync recomputeMissionStatus and recomputeMilestoneStatus derive hierarchy state.
|
||||
"packages/core/src/missions/mission-store.ts",
|
||||
// Async recomputes and terminal-task transaction derive hierarchy state in one transaction.
|
||||
"packages/core/src/async-stores/async-mission-store.ts",
|
||||
] as const;
|
||||
|
||||
/** Modules whose status writes express explicit lifecycle intent rather than a rollup. */
|
||||
const EXPLICIT_INTENT_WRITER_MODULES = [
|
||||
// Autopilot start/completion owns explicit lifecycle transitions; its slice cascade uses core guards.
|
||||
"packages/engine/src/missions/mission-autopilot.ts",
|
||||
// Operator/tool/dashboard PATCH and block routes deliberately own explicit intent.
|
||||
"packages/cli/src/extension.ts",
|
||||
"packages/engine/src/agent-tools.ts",
|
||||
"packages/dashboard/src/mission-routes.ts",
|
||||
] as const;
|
||||
|
||||
const COMPUTED_STATUS_VALUE = "(?:computed\\w*|recomputed\\w*|missionStatus|milestoneStatus|newStatus)\\b";
|
||||
const COMPUTED_STATUS_WRITE = new RegExp(
|
||||
String.raw`(?:update(?:Mission|Milestone)\s*\([^;\n]*?\bstatus\s*:\s*|\{\s*\.\.\.(?:mission|milestone)\s*,\s*status\s*:\s*)${COMPUTED_STATUS_VALUE}`,
|
||||
);
|
||||
|
||||
function computedStatusWrites(source: string): number[] {
|
||||
return [...source.matchAll(new RegExp(COMPUTED_STATUS_WRITE.source, "g"))]
|
||||
.map((match) => match.index!)
|
||||
.filter((index) => index !== undefined);
|
||||
}
|
||||
|
||||
function enclosingFunction(source: string, writeIndex: number): string {
|
||||
const declarations = [...source.matchAll(/(?:^|\n)\s*(?:export\s+)?(?:private\s+|public\s+|protected\s+)?(?:async\s+)?(?:function\s+)?[\w$]+\s*\([^)]*\)\s*(?::[^={]+)?\{/gm)];
|
||||
for (const declaration of declarations.reverse()) {
|
||||
const brace = source.indexOf("{", declaration.index);
|
||||
let depth = 0;
|
||||
for (let index = brace; index < source.length; index++) {
|
||||
if (source[index] === "{") depth++;
|
||||
if (source[index] === "}" && --depth === 0) {
|
||||
if (writeIndex >= brace && writeIndex <= index) return source.slice(brace, index + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function computedStatusWriters(roots: readonly string[] | undefined = undefined, base = REPO_ROOT): string[] {
|
||||
const hits: string[] = [];
|
||||
for (const root of roots ?? sourceRoots(base)) {
|
||||
for (const file of sourceFiles(root, base)) {
|
||||
if (computedStatusWrites(executableSource(file)).length > 0) hits.push(file.slice(base.length + 1));
|
||||
}
|
||||
}
|
||||
return hits.sort();
|
||||
}
|
||||
|
||||
function unguardedComputedStatusWriters(roots: readonly string[] | undefined = undefined, base = REPO_ROOT): string[] {
|
||||
const hits: string[] = [];
|
||||
for (const root of roots ?? sourceRoots(base)) {
|
||||
for (const file of sourceFiles(root, base)) {
|
||||
const source = executableSource(file);
|
||||
const writesByFunction = new Map<string, number>();
|
||||
for (const index of computedStatusWrites(source)) {
|
||||
const fn = enclosingFunction(source, index);
|
||||
writesByFunction.set(fn, (writesByFunction.get(fn) ?? 0) + 1);
|
||||
}
|
||||
if ([...writesByFunction].some(([fn, writes]) => !fn || (fn.match(/shouldApplyRecomputedStatus/g)?.length ?? 0) < writes)) {
|
||||
hits.push(file.slice(base.length + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
return hits.sort();
|
||||
}
|
||||
|
||||
describe("automatic mission and milestone rollup status writers", () => {
|
||||
it("has exactly the audited automatic writer modules, each using the shared guard", () => {
|
||||
expect(computedStatusWriters()).toEqual([...AUTOMATIC_GUARDED_WRITER_MODULES].sort());
|
||||
expect(unguardedComputedStatusWriters()).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps explicit-intent modules distinct from automatic rollup ownership", () => {
|
||||
expect(EXPLICIT_INTENT_WRITER_MODULES).not.toContain("packages/core/src/missions/mission-store.ts");
|
||||
expect(EXPLICIT_INTENT_WRITER_MODULES).not.toContain("packages/core/src/async-stores/async-mission-store.ts");
|
||||
});
|
||||
|
||||
describe("detector fixtures", () => {
|
||||
let fixtureRoot = "";
|
||||
const fixture = (path: string, content: string): void => {
|
||||
const full = join(fixtureRoot, path);
|
||||
mkdirSync(join(full, ".."), { recursive: true });
|
||||
writeFileSync(full, content);
|
||||
};
|
||||
const newFixtureRoot = (): string => (fixtureRoot = mkdtempSync(join(tmpdir(), "fusion-mission-rollup-ratchet-")));
|
||||
afterEach(() => { if (fixtureRoot) rmSync(fixtureRoot, { recursive: true, force: true }); fixtureRoot = ""; });
|
||||
|
||||
it("fails an unguarded computed mission status write", () => {
|
||||
const base = newFixtureRoot();
|
||||
fixture("packages/new-writer/src/writer.ts", "await updateMission(tx, { ...mission, status: computedStatus });");
|
||||
expect(unguardedComputedStatusWriters(undefined, base)).toEqual(["packages/new-writer/src/writer.ts"]);
|
||||
});
|
||||
|
||||
it("accepts the same computed writer when its function applies the guard", () => {
|
||||
const base = newFixtureRoot();
|
||||
fixture("packages/guarded/src/writer.ts", "async function recompute() { if (shouldApplyRecomputedStatus(mission.status, computedStatus, OWNED)) await updateMission(tx, { ...mission, status: computedStatus }); }");
|
||||
expect(unguardedComputedStatusWriters(undefined, base)).toEqual([]);
|
||||
});
|
||||
|
||||
it("fails an unguarded writer beside a guarded writer in the same function", () => {
|
||||
const base = newFixtureRoot();
|
||||
fixture("packages/mixed/src/writer.ts", "async function recompute() { if (shouldApplyRecomputedStatus(mission.status, computedStatus, OWNED)) await updateMission(tx, { ...mission, status: computedStatus }); await updateMission(tx, { ...mission, status: computedStatus }); }");
|
||||
expect(unguardedComputedStatusWriters(undefined, base)).toEqual(["packages/mixed/src/writer.ts"]);
|
||||
});
|
||||
|
||||
it("does not treat an explicit blocked write as a rollup", () => {
|
||||
const base = newFixtureRoot();
|
||||
fixture("packages/intent/src/writer.ts", "await updateMission(id, { status: 'blocked' });");
|
||||
expect(computedStatusWriters(undefined, base)).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not treat a comment as an executable writer", () => {
|
||||
const base = newFixtureRoot();
|
||||
fixture("packages/comment/src/writer.ts", "// updateMission(id, { status: computedStatus });\nexport const value = 1;");
|
||||
expect(computedStatusWriters(undefined, base)).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,17 @@ describe("shouldApplyRecomputedStatus", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps protected no-op computations and excludes protected statuses from rollup ownership", () => {
|
||||
expect(shouldApplyRecomputedStatus("blocked", "blocked", ROLLUP_OWNED_MISSION_STATUSES)).toBe(false);
|
||||
expect(shouldApplyRecomputedStatus("archived", "archived", ROLLUP_OWNED_MISSION_STATUSES)).toBe(false);
|
||||
expect(shouldApplyRecomputedStatus("blocked", "blocked", ROLLUP_OWNED_MILESTONE_STATUSES)).toBe(false);
|
||||
expect(ROLLUP_OWNED_MISSION_STATUSES).toEqual(["planning", "active", "complete"]);
|
||||
expect(ROLLUP_OWNED_MILESTONE_STATUSES).toEqual(["planning", "active", "complete"]);
|
||||
expect(ROLLUP_OWNED_MISSION_STATUSES).not.toContain("blocked");
|
||||
expect(ROLLUP_OWNED_MISSION_STATUSES).not.toContain("archived");
|
||||
expect(ROLLUP_OWNED_MILESTONE_STATUSES).not.toContain("blocked");
|
||||
});
|
||||
|
||||
it("preserves mission statuses outside automatic rollup ownership", () => {
|
||||
for (const current of MISSION_STATUSES.filter((status) => !ROLLUP_OWNED_MISSION_STATUSES.includes(status))) {
|
||||
for (const computed of ROLLUP_OWNED_MISSION_STATUSES) {
|
||||
|
||||
@@ -1776,21 +1776,40 @@ pgTest("MissionStore (PostgreSQL backend mode)", () => {
|
||||
expect(await m.getMilestone(milestone.id)).toMatchObject({ status: "blocked" });
|
||||
});
|
||||
|
||||
it("preserves blocked milestones during terminal-task reconcile", async () => {
|
||||
it("preserves protected intent and emits only unprotected terminal-reconcile rollups", async () => {
|
||||
const m = missions();
|
||||
const mission = await m.createMission({ title: "Protected reconcile" });
|
||||
const milestone = await m.addMilestone(mission.id, { title: "MS" });
|
||||
const slice = await m.addSlice(milestone.id, { title: "SL" });
|
||||
const feature = await m.addFeature(slice.id, { title: "Delivered" });
|
||||
const task = await h.store().createTask({ description: "done", column: "done" });
|
||||
await m.updateMilestone(milestone.id, { status: "blocked" });
|
||||
const createHierarchy = async (title: string) => {
|
||||
const mission = await m.createMission({ title });
|
||||
const milestone = await m.addMilestone(mission.id, { title: `${title} milestone` });
|
||||
const slice = await m.addSlice(milestone.id, { title: `${title} slice` });
|
||||
const feature = await m.addFeature(slice.id, { title: `${title} feature` });
|
||||
const task = await h.store().createTask({ description: `${title} done`, column: "done" });
|
||||
return { mission, milestone, slice, feature, task };
|
||||
};
|
||||
const blockedMission = await createHierarchy("Blocked mission reconcile");
|
||||
const archivedMission = await createHierarchy("Archived mission reconcile");
|
||||
const blockedMilestone = await createHierarchy("Blocked milestone reconcile");
|
||||
const control = await createHierarchy("Control reconcile");
|
||||
await m.updateMission(blockedMission.mission.id, { status: "blocked" });
|
||||
await m.updateMission(archivedMission.mission.id, { status: "archived" });
|
||||
await m.updateMilestone(blockedMilestone.milestone.id, { status: "blocked" });
|
||||
const missionUpdated = vi.fn();
|
||||
const milestoneUpdated = vi.fn();
|
||||
m.on("mission:updated", missionUpdated);
|
||||
m.on("milestone:updated", milestoneUpdated);
|
||||
await m.reconcileFeatureDoneWithTerminalTask(feature.id, task.id);
|
||||
expect(await m.getFeature(feature.id)).toMatchObject({ status: "done", taskId: task.id });
|
||||
expect(await m.getSlice(slice.id)).toMatchObject({ status: "complete" });
|
||||
expect(await m.getMilestone(milestone.id)).toMatchObject({ status: "blocked" });
|
||||
expect(milestoneUpdated).not.toHaveBeenCalled();
|
||||
|
||||
for (const hierarchy of [blockedMission, archivedMission, blockedMilestone, control]) {
|
||||
await m.reconcileFeatureDoneWithTerminalTask(hierarchy.feature.id, hierarchy.task.id);
|
||||
}
|
||||
|
||||
expect(await m.getMission(blockedMission.mission.id)).toMatchObject({ status: "blocked" });
|
||||
expect(await m.getMission(archivedMission.mission.id)).toMatchObject({ status: "archived" });
|
||||
expect(await m.getMilestone(blockedMilestone.milestone.id)).toMatchObject({ status: "blocked" });
|
||||
expect(await m.getMilestone(control.milestone.id)).toMatchObject({ status: "complete" });
|
||||
expect(missionUpdated).not.toHaveBeenCalled();
|
||||
const updatedMilestoneIds = milestoneUpdated.mock.calls.map(([milestone]) => milestone.id);
|
||||
expect(updatedMilestoneIds).not.toContain(blockedMilestone.milestone.id);
|
||||
expect(updatedMilestoneIds).toContain(control.milestone.id);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -83,11 +83,13 @@ export type MilestoneStatus = (typeof MILESTONE_STATUSES)[number];
|
||||
export const ROLLUP_OWNED_MILESTONE_STATUSES = ["planning", "active", "complete"] as const satisfies readonly MilestoneStatus[];
|
||||
|
||||
/*
|
||||
FNXC:MissionStatusRollup 2026-08-11-04:27:
|
||||
FNXC:MissionStatusRollup 2026-08-13-21:59:
|
||||
Every automatic rollup writer—the recompute helpers in both stores and AsyncMissionStore's
|
||||
in-transaction terminal-task reconcile—may move a row only between statuses it can derive.
|
||||
blocked/archived are operator or system intent from pause, stop, PATCH, and fn_mission_set_status;
|
||||
only explicit resumeMission, clearMissionBlockedStatus, or autopilot completion clears them.
|
||||
`mission-rollup-status-writer-ratchet.test.ts` inventories the first-party writer set so a new
|
||||
computed mission or milestone writer cannot bypass this predicate unnoticed.
|
||||
*/
|
||||
export function shouldApplyRecomputedStatus<T extends string>(
|
||||
current: T,
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
FNXC:MissionStatusRollup 2026-08-13-22:40:
|
||||
Engine cascade and terminal-reconcile tests use a stateful store seam that applies the same core
|
||||
ownership predicate as persistence. This proves both engine entry points preserve blocked and
|
||||
archived intent while an unprotected sibling rollup is still written and emits its status-change event.
|
||||
*/
|
||||
import { ROLLUP_OWNED_MILESTONE_STATUSES, ROLLUP_OWNED_MISSION_STATUSES, shouldApplyRecomputedStatus } from "../../../core/src/missions/mission-types.js";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { MissionAutopilot } from "../missions/mission-autopilot.js";
|
||||
import { reconcileMissionState } from "../missions/mission-state-reconcile.js";
|
||||
|
||||
const taskStore = { on: vi.fn(), off: vi.fn(), getSettings: vi.fn(), listTasks: vi.fn().mockResolvedValue([]) };
|
||||
|
||||
function rollupStore(status: "blocked" | "archived" = "blocked") {
|
||||
const mission = { id: "M-1", title: "Protected", status, autopilotEnabled: true, autopilotState: "inactive" };
|
||||
const milestone = { id: "MS-1", missionId: mission.id, title: "Protected milestone", status: "blocked" };
|
||||
const controlMilestone = { id: "MS-2", missionId: mission.id, title: "Control milestone", status: "planning" };
|
||||
const slice = { id: "S-1", milestoneId: milestone.id, title: "Derived slice", status: "pending", features: [{ id: "F-1", sliceId: "S-1", title: "Protected terminal feature", taskId: "T-1", status: "defined" }] };
|
||||
const controlSlice = { id: "S-2", milestoneId: controlMilestone.id, title: "Control slice", status: "pending", features: [{ id: "F-2", sliceId: "S-2", title: "Control terminal feature", taskId: "T-2", status: "defined" }] };
|
||||
const events: Array<{ entity: "mission" | "milestone"; id: string }> = [];
|
||||
const milestones = [milestone, controlMilestone];
|
||||
const slices = [slice, controlSlice];
|
||||
const recomputeMission = () => {
|
||||
const computed = milestones.every((candidate) => candidate.status === "complete") ? "complete" : milestones.some((candidate) => candidate.status === "active" || candidate.status === "complete") ? "active" : "planning";
|
||||
if (shouldApplyRecomputedStatus(mission.status, computed, ROLLUP_OWNED_MISSION_STATUSES)) {
|
||||
mission.status = computed;
|
||||
events.push({ entity: "mission", id: mission.id });
|
||||
}
|
||||
};
|
||||
const recomputeMilestone = (candidate: typeof milestone) => {
|
||||
const computed = slices.filter((item) => item.milestoneId === candidate.id).every((item) => item.status === "complete") ? "complete" : "planning";
|
||||
if (shouldApplyRecomputedStatus(candidate.status, computed, ROLLUP_OWNED_MILESTONE_STATUSES)) {
|
||||
candidate.status = computed;
|
||||
events.push({ entity: "milestone", id: candidate.id });
|
||||
recomputeMission();
|
||||
}
|
||||
};
|
||||
return {
|
||||
mission,
|
||||
milestone,
|
||||
controlMilestone,
|
||||
events,
|
||||
getMission: vi.fn().mockImplementation(async () => mission),
|
||||
listMissions: vi.fn().mockResolvedValue([mission]),
|
||||
getMissionWithHierarchy: vi.fn().mockImplementation(async () => ({ ...mission, milestones: milestones.map((candidate) => ({ ...candidate, slices: slices.filter((item) => item.milestoneId === candidate.id) })) })),
|
||||
listMilestones: vi.fn().mockResolvedValue(milestones),
|
||||
updateMission: vi.fn(),
|
||||
updateSlice: vi.fn().mockImplementation(async (id: string, updates: { status: string }) => {
|
||||
const target = slices.find((candidate) => candidate.id === id)!;
|
||||
target.status = updates.status;
|
||||
recomputeMilestone(milestones.find((candidate) => candidate.id === target.milestoneId)!);
|
||||
return target;
|
||||
}),
|
||||
computeSliceStatus: vi.fn().mockResolvedValue("complete"),
|
||||
reconcileFeatureDoneWithTerminalTask: vi.fn().mockImplementation(async (featureId: string, taskId: string) => {
|
||||
const target = slices.flatMap((candidate) => candidate.features.map((feature) => ({ slice: candidate, feature }))).find(({ feature }) => feature.id === featureId);
|
||||
if (!target || target.feature.taskId !== taskId) throw new Error("terminal feature mismatch");
|
||||
target.feature.status = "done";
|
||||
target.slice.status = "complete";
|
||||
recomputeMilestone(milestones.find((candidate) => candidate.id === target.slice.milestoneId)!);
|
||||
return target.feature;
|
||||
}),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("MissionAutopilot rollup ownership", () => {
|
||||
it("preserves protected mission and milestone intent while persisting an unprotected cascade", async () => {
|
||||
const store = rollupStore();
|
||||
const autopilot = new MissionAutopilot(taskStore as never, store as never);
|
||||
|
||||
await (autopilot as any).recomputeMissionStatusChain(store.mission.id);
|
||||
|
||||
expect(store.updateSlice).toHaveBeenCalledWith("S-1", { status: "complete" });
|
||||
expect(store.updateSlice).toHaveBeenCalledWith("S-2", { status: "complete" });
|
||||
expect(store.mission.status).toBe("blocked");
|
||||
expect(store.milestone.status).toBe("blocked");
|
||||
expect(store.controlMilestone.status).toBe("complete");
|
||||
expect(store.events).toEqual([{ entity: "milestone", id: store.controlMilestone.id }]);
|
||||
expect(store.updateMission).not.toHaveBeenCalled();
|
||||
autopilot.stop();
|
||||
});
|
||||
|
||||
it.each(["blocked", "archived"] as const)("does not start a protected %s mission", async (status) => {
|
||||
const store = rollupStore(status);
|
||||
const autopilot = new MissionAutopilot(taskStore as never, store as never);
|
||||
|
||||
await autopilot.checkAndStartMission(store.mission.id);
|
||||
|
||||
expect(store.updateMission).not.toHaveBeenCalled();
|
||||
expect(store.mission.status).toBe(status);
|
||||
autopilot.stop();
|
||||
});
|
||||
|
||||
it("reconciles terminal features through the guarded store primitive without clobbering protected intent", async () => {
|
||||
const store = rollupStore();
|
||||
const reconciliationTaskStore = {
|
||||
...taskStore,
|
||||
getTask: vi.fn().mockImplementation(async (id: string) => ({ id, column: "archived" })),
|
||||
};
|
||||
|
||||
const result = await reconcileMissionState({ taskStore: reconciliationTaskStore as never, missionStore: store }, { source: "self-healing" });
|
||||
|
||||
expect(store.reconcileFeatureDoneWithTerminalTask).toHaveBeenCalledTimes(2);
|
||||
expect(store.reconcileFeatureDoneWithTerminalTask).toHaveBeenCalledWith("F-1", "T-1");
|
||||
expect(store.reconcileFeatureDoneWithTerminalTask).toHaveBeenCalledWith("F-2", "T-2");
|
||||
expect(result.terminalRepairs).toBe(2);
|
||||
expect(store.mission.status).toBe("blocked");
|
||||
expect(store.milestone.status).toBe("blocked");
|
||||
expect(store.controlMilestone.status).toBe("complete");
|
||||
expect(store.events).toEqual([{ entity: "milestone", id: store.controlMilestone.id }]);
|
||||
expect(store.updateMission).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user