diff --git a/docs/missions.md b/docs/missions.md index 2dc019f861..d4cde2e7fa 100644 --- a/docs/missions.md +++ b/docs/missions.md @@ -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` diff --git a/packages/core/src/__tests__/mission-rollup-status-writer-ratchet.test.ts b/packages/core/src/__tests__/mission-rollup-status-writer-ratchet.test.ts new file mode 100644 index 0000000000..4e00f776d0 --- /dev/null +++ b/packages/core/src/__tests__/mission-rollup-status-writer-ratchet.test.ts @@ -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(); + 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([]); + }); + }); +}); diff --git a/packages/core/src/__tests__/mission-status-recompute-guard.test.ts b/packages/core/src/__tests__/mission-status-recompute-guard.test.ts index eb9433d9b4..035c2a1e32 100644 --- a/packages/core/src/__tests__/mission-status-recompute-guard.test.ts +++ b/packages/core/src/__tests__/mission-status-recompute-guard.test.ts @@ -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) { diff --git a/packages/core/src/__tests__/postgres/mission-store.pg.test.ts b/packages/core/src/__tests__/postgres/mission-store.pg.test.ts index eec8b94799..f5f732f5c6 100644 --- a/packages/core/src/__tests__/postgres/mission-store.pg.test.ts +++ b/packages/core/src/__tests__/postgres/mission-store.pg.test.ts @@ -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); }); }); diff --git a/packages/core/src/missions/mission-types.ts b/packages/core/src/missions/mission-types.ts index 0b90802e40..7d3bacd64f 100644 --- a/packages/core/src/missions/mission-types.ts +++ b/packages/core/src/missions/mission-types.ts @@ -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( current: T, diff --git a/packages/engine/src/__tests__/mission-autopilot-rollup-guard.test.ts b/packages/engine/src/__tests__/mission-autopilot-rollup-guard.test.ts new file mode 100644 index 0000000000..ac8e5062f2 --- /dev/null +++ b/packages/engine/src/__tests__/mission-autopilot-rollup-guard.test.ts @@ -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(); + }); +});