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:
gsxdsm
2026-08-13 16:01:57 -07:00
parent d39c0ae9eb
commit cb4ab3b6da
6 changed files with 331 additions and 14 deletions

View File

@@ -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`

View File

@@ -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([]);
});
});
});

View File

@@ -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) {

View File

@@ -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);
});
});

View File

@@ -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,

View File

@@ -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();
});
});