FN-8972: preserve blocked mission status during rollups
Protect explicitly blocked or archived hierarchy statuses from automatic recomputation. - Restrict mission and milestone rollups to statuses they own. - Make asynchronous recomputations transactional and preserve status events. - Cover guarded rollups in SQLite and PostgreSQL tests. Files changed: .../fn-8972-mission-recompute-blocked-guard.md | 7 ++ docs/missions.md | 2 + .../mission-status-recompute-guard.test.ts | 85 +++++++++++++++++ .../__tests__/postgres/mission-store.pg.test.ts | 105 +++++++++++++++++++++ .../core/src/async-stores/async-mission-store.ts | 67 +++++++++++-- packages/core/src/missions/mission-store.ts | 12 ++- packages/core/src/missions/mission-types.ts | 21 +++++ 7 files changed, 286 insertions(+), 13 deletions(-) Fusion-Task-Id: FN-8972 Fusion-Task-Lineage: 07ede656-dec0-4d3e-b35c-06e0f6304b74 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8972-mission-recompute-blocked-guard.md
Normal file
7
.changeset/fn-8972-mission-recompute-blocked-guard.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Paused missions no longer un-pause when hierarchy status rolls up.
|
||||||
|
category: fix
|
||||||
|
dev: Guards shouldApplyRecomputedStatus, store recompute helpers, and terminal-task reconcile milestone writes.
|
||||||
@@ -381,6 +381,8 @@ 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.
|
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.
|
||||||
|
|
||||||
## `autopilotEnabled` vs `autoAdvance`
|
## `autopilotEnabled` vs `autoAdvance`
|
||||||
|
|
||||||
- **`autopilotEnabled`**: primary control for autopilot behavior — enables background monitoring, orchestration, and automatic slice activation when a slice completes. Also triggers auto-planning (converting features to tasks) when a slice is activated.
|
- **`autopilotEnabled`**: primary control for autopilot behavior — enables background monitoring, orchestration, and automatic slice activation when a slice completes. Also triggers auto-planning (converting features to tasks) when a slice is activated.
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { Database } from "../db/db.js";
|
||||||
|
import { MissionStore } from "../missions/mission-store.js";
|
||||||
|
import {
|
||||||
|
MILESTONE_STATUSES,
|
||||||
|
MISSION_STATUSES,
|
||||||
|
ROLLUP_OWNED_MILESTONE_STATUSES,
|
||||||
|
ROLLUP_OWNED_MISSION_STATUSES,
|
||||||
|
SLICE_STATUSES,
|
||||||
|
shouldApplyRecomputedStatus,
|
||||||
|
} from "../missions/mission-types.js";
|
||||||
|
|
||||||
|
describe("shouldApplyRecomputedStatus", () => {
|
||||||
|
it("skips identical statuses", () => {
|
||||||
|
expect(shouldApplyRecomputedStatus("active", "active", ROLLUP_OWNED_MISSION_STATUSES)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates each rollup-owned mission and milestone status when the computed value differs", () => {
|
||||||
|
for (const current of ROLLUP_OWNED_MISSION_STATUSES) {
|
||||||
|
for (const computed of ROLLUP_OWNED_MISSION_STATUSES) {
|
||||||
|
expect(shouldApplyRecomputedStatus(current, computed, ROLLUP_OWNED_MISSION_STATUSES))
|
||||||
|
.toBe(current !== computed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const current of ROLLUP_OWNED_MILESTONE_STATUSES) {
|
||||||
|
for (const computed of ROLLUP_OWNED_MILESTONE_STATUSES) {
|
||||||
|
expect(shouldApplyRecomputedStatus(current, computed, ROLLUP_OWNED_MILESTONE_STATUSES))
|
||||||
|
.toBe(current !== computed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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) {
|
||||||
|
expect(shouldApplyRecomputedStatus(current, computed, ROLLUP_OWNED_MISSION_STATUSES)).toBe(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves milestone statuses outside automatic rollup ownership", () => {
|
||||||
|
for (const current of MILESTONE_STATUSES.filter((status) => !ROLLUP_OWNED_MILESTONE_STATUSES.includes(status))) {
|
||||||
|
for (const computed of ROLLUP_OWNED_MILESTONE_STATUSES) {
|
||||||
|
expect(shouldApplyRecomputedStatus(current, computed, ROLLUP_OWNED_MILESTONE_STATUSES)).toBe(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps slice rollup fully owned", () => {
|
||||||
|
expect(SLICE_STATUSES).toEqual(["pending", "active", "complete"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MissionStore synchronous rollup guard", () => {
|
||||||
|
const createStore = () => new MissionStore("/tmp/fusion-mission-store-test", {
|
||||||
|
prepare: vi.fn().mockReturnValue({ get: vi.fn().mockReturnValue(undefined) }),
|
||||||
|
bumpLastModified: vi.fn(),
|
||||||
|
} as unknown as Database);
|
||||||
|
|
||||||
|
it("preserves blocked and archived mission intent while updating rollup-owned rows", () => {
|
||||||
|
const store = createStore();
|
||||||
|
const updateMission = vi.spyOn(store, "updateMission").mockImplementation((id, updates) => ({ id, ...updates } as never));
|
||||||
|
vi.spyOn(store, "computeMissionStatus").mockReturnValue("active");
|
||||||
|
vi.spyOn(store, "getMission").mockReturnValue({ id: "M-1", status: "blocked" } as never);
|
||||||
|
(store as any).recomputeMissionStatus("M-1");
|
||||||
|
vi.spyOn(store, "getMission").mockReturnValue({ id: "M-2", status: "archived" } as never);
|
||||||
|
(store as any).recomputeMissionStatus("M-2");
|
||||||
|
vi.spyOn(store, "getMission").mockReturnValue({ id: "M-3", status: "planning" } as never);
|
||||||
|
(store as any).recomputeMissionStatus("M-3");
|
||||||
|
expect(updateMission).toHaveBeenCalledTimes(1);
|
||||||
|
expect(updateMission).toHaveBeenCalledWith("M-3", { status: "active" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves blocked milestones while updating rollup-owned rows", () => {
|
||||||
|
const store = createStore();
|
||||||
|
const updateMilestone = vi.spyOn(store, "updateMilestone").mockImplementation((id, updates) => ({ id, ...updates } as never));
|
||||||
|
vi.spyOn(store, "computeMilestoneStatus").mockReturnValue("complete");
|
||||||
|
vi.spyOn(store, "getMilestone").mockReturnValue({ id: "MS-1", status: "blocked" } as never);
|
||||||
|
(store as any).recomputeMilestoneStatus("MS-1");
|
||||||
|
vi.spyOn(store, "getMilestone").mockReturnValue({ id: "MS-2", status: "active" } as never);
|
||||||
|
(store as any).recomputeMilestoneStatus("MS-2");
|
||||||
|
expect(updateMilestone).toHaveBeenCalledTimes(1);
|
||||||
|
expect(updateMilestone).toHaveBeenCalledWith("MS-2", { status: "complete" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1501,4 +1501,109 @@ pgTest("MissionStore (PostgreSQL backend mode)", () => {
|
|||||||
expect(triaged.taskId).toMatch(/^ERR-\d+$/);
|
expect(triaged.taskId).toMatch(/^ERR-\d+$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("automatic status rollup ownership", () => {
|
||||||
|
it("preserves blocked and archived missions through hierarchy churn", async () => {
|
||||||
|
const m = missions();
|
||||||
|
const createProtectedHierarchy = async (status: "blocked" | "archived") => {
|
||||||
|
const mission = await m.createMission({ title: `Protected ${status}` });
|
||||||
|
const milestone = await m.addMilestone(mission.id, { title: "MS" });
|
||||||
|
const slice = await m.addSlice(milestone.id, { title: "SL" });
|
||||||
|
await m.updateMission(mission.id, { status });
|
||||||
|
return { mission, milestone, slice };
|
||||||
|
};
|
||||||
|
const renamed = await createProtectedHierarchy("blocked");
|
||||||
|
const eventCount = (await m.getMissionEvents(renamed.mission.id)).events.length;
|
||||||
|
await m.updateMilestone(renamed.milestone.id, { title: "renamed" });
|
||||||
|
expect(await m.getMission(renamed.mission.id)).toMatchObject({ status: "blocked" });
|
||||||
|
expect((await m.getMissionEvents(renamed.mission.id)).events).toHaveLength(eventCount);
|
||||||
|
const sliced = await createProtectedHierarchy("blocked");
|
||||||
|
await m.updateSlice(sliced.slice.id, { title: "edited" });
|
||||||
|
await m.deleteSlice(sliced.slice.id);
|
||||||
|
expect(await m.getMission(sliced.mission.id)).toMatchObject({ status: "blocked" });
|
||||||
|
const deleted = await createProtectedHierarchy("blocked");
|
||||||
|
await m.deleteMilestone(deleted.milestone.id);
|
||||||
|
expect(await m.getMission(deleted.mission.id)).toMatchObject({ status: "blocked" });
|
||||||
|
const archived = await createProtectedHierarchy("archived");
|
||||||
|
await m.updateMilestone(archived.milestone.id, { title: "churn" });
|
||||||
|
expect(await m.getMission(archived.mission.id)).toMatchObject({ status: "archived" });
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:MissionStatusRollup 2026-08-11-04:41:
|
||||||
|
The recompute transaction locks the mission before calculating its derived status. Hold that
|
||||||
|
calculation while an explicit blocked write queues behind the row lock; releasing the rollup
|
||||||
|
must leave the later operator write intact instead of replaying a stale derived status.
|
||||||
|
*/
|
||||||
|
it("does not overwrite an explicit blocked mission queued during a rollup", async () => {
|
||||||
|
const m = missions();
|
||||||
|
const mission = await m.createMission({ title: "Concurrent protected mission" });
|
||||||
|
const milestone = await m.addMilestone(mission.id, { title: "MS" });
|
||||||
|
const originalCompute = (m as any).computeMissionStatusWithHandle.bind(m);
|
||||||
|
let enteredCompute!: () => void;
|
||||||
|
let releaseCompute!: () => void;
|
||||||
|
const computeEntered = new Promise<void>((resolve) => { enteredCompute = resolve; });
|
||||||
|
const computeRelease = new Promise<void>((resolve) => { releaseCompute = resolve; });
|
||||||
|
(m as any).computeMissionStatusWithHandle = async (...args: unknown[]) => {
|
||||||
|
enteredCompute();
|
||||||
|
await computeRelease;
|
||||||
|
return originalCompute(...args);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const rollup = m.updateMilestone(milestone.id, { title: "churn" });
|
||||||
|
await computeEntered;
|
||||||
|
const explicitBlock = m.updateMission(mission.id, { status: "blocked" });
|
||||||
|
releaseCompute();
|
||||||
|
await Promise.all([rollup, explicitBlock]);
|
||||||
|
} finally {
|
||||||
|
(m as any).computeMissionStatusWithHandle = originalCompute;
|
||||||
|
}
|
||||||
|
expect(await m.getMission(mission.id)).toMatchObject({ status: "blocked" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not overwrite an explicit blocked milestone queued during a rollup", async () => {
|
||||||
|
const m = missions();
|
||||||
|
const mission = await m.createMission({ title: "Concurrent protected milestone" });
|
||||||
|
const milestone = await m.addMilestone(mission.id, { title: "MS" });
|
||||||
|
const slice = await m.addSlice(milestone.id, { title: "SL" });
|
||||||
|
const originalCompute = (m as any).computeMilestoneStatusWithHandle.bind(m);
|
||||||
|
let enteredCompute!: () => void;
|
||||||
|
let releaseCompute!: () => void;
|
||||||
|
const computeEntered = new Promise<void>((resolve) => { enteredCompute = resolve; });
|
||||||
|
const computeRelease = new Promise<void>((resolve) => { releaseCompute = resolve; });
|
||||||
|
(m as any).computeMilestoneStatusWithHandle = async (...args: unknown[]) => {
|
||||||
|
enteredCompute();
|
||||||
|
await computeRelease;
|
||||||
|
return originalCompute(...args);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const rollup = m.updateSlice(slice.id, { title: "churn" });
|
||||||
|
await computeEntered;
|
||||||
|
const explicitBlock = m.updateMilestone(milestone.id, { status: "blocked" });
|
||||||
|
releaseCompute();
|
||||||
|
await Promise.all([rollup, explicitBlock]);
|
||||||
|
} finally {
|
||||||
|
(m as any).computeMilestoneStatusWithHandle = originalCompute;
|
||||||
|
}
|
||||||
|
expect(await m.getMilestone(milestone.id)).toMatchObject({ status: "blocked" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves blocked milestones during terminal-task reconcile", 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 milestoneUpdated = vi.fn();
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { EventEmitter } from "node:events";
|
|||||||
import { and, desc, eq, inArray, notInArray, sql } from "drizzle-orm";
|
import { and, desc, eq, inArray, notInArray, sql } from "drizzle-orm";
|
||||||
import * as schema from "../postgres/schema/index.js";
|
import * as schema from "../postgres/schema/index.js";
|
||||||
import type { AsyncDataLayer } from "../postgres/data-layer.js";
|
import type { AsyncDataLayer } from "../postgres/data-layer.js";
|
||||||
import { boundMissionEventReason, classifyMissionResumeBlockers, FEATURE_LOOP_REPAIR_TRANSITIONS, buildMissionStatusEventMetadata, featureValidationRepairEligibility, FEATURE_LOOP_TRANSITIONS, normalizeMissionAssertionType, normalizeMissionTransitionActorForEvent, renderValidationCause, selectNextSerialMissionSlice, VALIDATION_INFLIGHT_STALE_MAX_AGE_MS } from "../missions/mission-types.js";
|
import { boundMissionEventReason, classifyMissionResumeBlockers, FEATURE_LOOP_REPAIR_TRANSITIONS, buildMissionStatusEventMetadata, featureValidationRepairEligibility, FEATURE_LOOP_TRANSITIONS, normalizeMissionAssertionType, normalizeMissionTransitionActorForEvent, renderValidationCause, ROLLUP_OWNED_MILESTONE_STATUSES, ROLLUP_OWNED_MISSION_STATUSES, selectNextSerialMissionSlice, shouldApplyRecomputedStatus, VALIDATION_INFLIGHT_STALE_MAX_AGE_MS } from "../missions/mission-types.js";
|
||||||
import type {
|
import type {
|
||||||
Mission,
|
Mission,
|
||||||
Milestone,
|
Milestone,
|
||||||
@@ -1414,9 +1414,17 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
if (reconciledSlice !== slice) await updateSlice(tx, reconciledSlice);
|
if (reconciledSlice !== slice) await updateSlice(tx, reconciledSlice);
|
||||||
|
|
||||||
const milestoneStatus = await this.computeMilestoneStatusWithHandle(tx, milestone.id);
|
const milestoneStatus = await this.computeMilestoneStatusWithHandle(tx, milestone.id);
|
||||||
const reconciledMilestone = milestone.status === milestoneStatus
|
/*
|
||||||
? milestone
|
FNXC:MissionStatusRollup 2026-08-11-04:27:
|
||||||
: { ...milestone, status: milestoneStatus, updatedAt: now };
|
This automatic writer bypasses recomputeMilestoneStatus because it must persist within this
|
||||||
|
terminal-task transaction via updateMilestone(tx, ...). Dashboard mission-routes and engine
|
||||||
|
mission-state-reconcile call this path, so it independently applies the shared ownership rule.
|
||||||
|
*/
|
||||||
|
const reconciledMilestone = shouldApplyRecomputedStatus(
|
||||||
|
milestone.status,
|
||||||
|
milestoneStatus,
|
||||||
|
ROLLUP_OWNED_MILESTONE_STATUSES,
|
||||||
|
) ? { ...milestone, status: milestoneStatus, updatedAt: now } : milestone;
|
||||||
if (reconciledMilestone !== milestone) await updateMilestone(tx, reconciledMilestone);
|
if (reconciledMilestone !== milestone) await updateMilestone(tx, reconciledMilestone);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -2933,16 +2941,55 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
if (slice && slice.status !== newStatus) await this.updateSlice(sliceId, { status: newStatus });
|
if (slice && slice.status !== newStatus) await this.updateSlice(sliceId, { status: newStatus });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:MissionStatusRollup 2026-08-11-04:27:
|
||||||
|
updateMilestone, deleteMilestone, updateSlice, deleteSlice, slice admission, and the engine's
|
||||||
|
recomputeMissionStatusChain reach this cascade. It must not clear blocked/archived intent: even
|
||||||
|
an all-complete blocked mission stays blocked until an explicit clear or resume. Lock the row,
|
||||||
|
compute, and persist in one transaction so an explicit status write cannot race past the guard.
|
||||||
|
The direct atomic milestone write retains updateMilestone's normal mission cascade after commit.
|
||||||
|
The terminal-task transaction has a second milestone writer guarded with this same predicate below.
|
||||||
|
*/
|
||||||
private async recomputeMilestoneStatus(milestoneId: string): Promise<void> {
|
private async recomputeMilestoneStatus(milestoneId: string): Promise<void> {
|
||||||
const newStatus = await this.computeMilestoneStatus(milestoneId);
|
const updated = await this.layer.transactionImmediate(async (tx) => {
|
||||||
const milestone = await getMilestone(this.db, milestoneId);
|
await tx.select().from(schema.project.milestones).where(eq(schema.project.milestones.id, milestoneId)).for("update");
|
||||||
if (milestone && milestone.status !== newStatus) await this.updateMilestone(milestoneId, { status: newStatus });
|
const milestone = await getMilestone(tx, milestoneId);
|
||||||
|
if (!milestone) return undefined;
|
||||||
|
const newStatus = await this.computeMilestoneStatusWithHandle(tx, milestoneId);
|
||||||
|
if (!shouldApplyRecomputedStatus(milestone.status, newStatus, ROLLUP_OWNED_MILESTONE_STATUSES)) return undefined;
|
||||||
|
const updated = { ...milestone, status: newStatus, updatedAt: new Date().toISOString() };
|
||||||
|
await updateMilestone(tx, updated);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
if (!updated) return;
|
||||||
|
this.emit("milestone:updated", updated);
|
||||||
|
await this.recomputeMissionStatus(updated.missionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async recomputeMissionStatus(missionId: string): Promise<void> {
|
private async recomputeMissionStatus(missionId: string): Promise<void> {
|
||||||
const newStatus = await this.computeMissionStatus(missionId);
|
const outcome = await this.layer.transactionImmediate(async (tx) => {
|
||||||
const mission = await getMission(this.db, missionId);
|
await tx.select().from(schema.project.missions).where(eq(schema.project.missions.id, missionId)).for("update");
|
||||||
if (mission && mission.status !== newStatus) await this.updateMission(missionId, { status: newStatus });
|
const mission = await getMission(tx, missionId);
|
||||||
|
if (!mission) return undefined;
|
||||||
|
const newStatus = await this.computeMissionStatusWithHandle(tx, missionId);
|
||||||
|
if (!shouldApplyRecomputedStatus(mission.status, newStatus, ROLLUP_OWNED_MISSION_STATUSES)) return undefined;
|
||||||
|
const updated = { ...mission, status: newStatus, updatedAt: new Date().toISOString() };
|
||||||
|
await updateMission(tx, updated);
|
||||||
|
const event: MissionEvent = {
|
||||||
|
id: this.generateId("ME"), missionId, eventType: "mission_status_changed",
|
||||||
|
description: `Mission status changed from ${mission.status} to ${updated.status}`,
|
||||||
|
metadata: buildMissionStatusEventMetadata({
|
||||||
|
entity: "mission", field: "status", from: mission.status, to: updated.status, ids: {},
|
||||||
|
actor: { type: "system", id: "mission-store", displayName: "Mission store", source: "mission-store" },
|
||||||
|
}),
|
||||||
|
timestamp: new Date().toISOString(), seq: (await getMaxEventSeq(tx)) + 1,
|
||||||
|
};
|
||||||
|
await insertMissionEvent(tx, event);
|
||||||
|
return { updated, event };
|
||||||
|
});
|
||||||
|
if (!outcome) return;
|
||||||
|
this.emit("mission:updated", outcome.updated);
|
||||||
|
this.emit("mission:event", outcome.event);
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const severityAuditLog = createLogger("core-mission-store");
|
|||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import type { Database } from "../db/db.js";
|
import type { Database } from "../db/db.js";
|
||||||
import { fromJson, toJson, toJsonNullable } from "../db/db.js";
|
import { fromJson, toJson, toJsonNullable } from "../db/db.js";
|
||||||
import { FEATURE_LOOP_TRANSITIONS, normalizeMissionAssertionOrigin, normalizeMissionAssertionScope, normalizeMissionAssertionType, renderValidationCause, selectNextSerialMissionSlice, VALIDATION_INFLIGHT_STALE_MAX_AGE_MS } from "./mission-types.js";
|
import { FEATURE_LOOP_TRANSITIONS, normalizeMissionAssertionOrigin, normalizeMissionAssertionScope, normalizeMissionAssertionType, renderValidationCause, ROLLUP_OWNED_MILESTONE_STATUSES, ROLLUP_OWNED_MISSION_STATUSES, selectNextSerialMissionSlice, shouldApplyRecomputedStatus, VALIDATION_INFLIGHT_STALE_MAX_AGE_MS } from "./mission-types.js";
|
||||||
import type { Goal, GoalStatus } from "../goals/goal-types.js";
|
import type { Goal, GoalStatus } from "../goals/goal-types.js";
|
||||||
import type {
|
import type {
|
||||||
Mission,
|
Mission,
|
||||||
@@ -4607,6 +4607,12 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:MissionStatusRollup 2026-08-11-04:27:
|
||||||
|
FN-8962's audit found this synchronous backend is test-only, but it remains behaviorally aligned
|
||||||
|
with AsyncMissionStore. Its two recompute helpers are its complete rollup-writer set: grep confirms
|
||||||
|
it has no reconcileFeatureDoneWithTerminalTask equivalent, so protected intent cannot drift here.
|
||||||
|
*/
|
||||||
/**
|
/**
|
||||||
* Recompute and update the milestone status.
|
* Recompute and update the milestone status.
|
||||||
* Called automatically after slice changes.
|
* Called automatically after slice changes.
|
||||||
@@ -4615,7 +4621,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
const newStatus = this.computeMilestoneStatus(milestoneId);
|
const newStatus = this.computeMilestoneStatus(milestoneId);
|
||||||
const milestone = this.getMilestone(milestoneId);
|
const milestone = this.getMilestone(milestoneId);
|
||||||
|
|
||||||
if (milestone && milestone.status !== newStatus) {
|
if (milestone && shouldApplyRecomputedStatus(milestone.status, newStatus, ROLLUP_OWNED_MILESTONE_STATUSES)) {
|
||||||
this.updateMilestone(milestoneId, { status: newStatus });
|
this.updateMilestone(milestoneId, { status: newStatus });
|
||||||
// Don't emit here - updateMilestone already emits and triggers mission recompute
|
// Don't emit here - updateMilestone already emits and triggers mission recompute
|
||||||
}
|
}
|
||||||
@@ -4629,7 +4635,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
const newStatus = this.computeMissionStatus(missionId);
|
const newStatus = this.computeMissionStatus(missionId);
|
||||||
const mission = this.getMission(missionId);
|
const mission = this.getMission(missionId);
|
||||||
|
|
||||||
if (mission && mission.status !== newStatus) {
|
if (mission && shouldApplyRecomputedStatus(mission.status, newStatus, ROLLUP_OWNED_MISSION_STATUSES)) {
|
||||||
this.updateMission(missionId, { status: newStatus });
|
this.updateMission(missionId, { status: newStatus });
|
||||||
// Don't emit here - updateMission already emits
|
// Don't emit here - updateMission already emits
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ import { redactSecrets } from "../secrets/redact-secrets.js";
|
|||||||
export const MISSION_STATUSES = ["planning", "active", "blocked", "complete", "archived"] as const;
|
export const MISSION_STATUSES = ["planning", "active", "blocked", "complete", "archived"] as const;
|
||||||
export type MissionStatus = (typeof MISSION_STATUSES)[number];
|
export type MissionStatus = (typeof MISSION_STATUSES)[number];
|
||||||
|
|
||||||
|
/** Statuses that hierarchy rollup can derive for missions. */
|
||||||
|
export const ROLLUP_OWNED_MISSION_STATUSES = ["planning", "active", "complete"] as const satisfies readonly MissionStatus[];
|
||||||
|
|
||||||
/** The persisted source that prevents a mission from resuming automatically. */
|
/** The persisted source that prevents a mission from resuming automatically. */
|
||||||
export type MissionBlockerSource = "feature-stop" | "lineage-stop" | "unspecified";
|
export type MissionBlockerSource = "feature-stop" | "lineage-stop" | "unspecified";
|
||||||
|
|
||||||
@@ -84,6 +87,24 @@ export function classifyMissionResumeBlockers(input: {
|
|||||||
export const MILESTONE_STATUSES = ["planning", "active", "blocked", "complete"] as const;
|
export const MILESTONE_STATUSES = ["planning", "active", "blocked", "complete"] as const;
|
||||||
export type MilestoneStatus = (typeof MILESTONE_STATUSES)[number];
|
export type MilestoneStatus = (typeof MILESTONE_STATUSES)[number];
|
||||||
|
|
||||||
|
/** Statuses that hierarchy rollup can derive for milestones. */
|
||||||
|
export const ROLLUP_OWNED_MILESTONE_STATUSES = ["planning", "active", "complete"] as const satisfies readonly MilestoneStatus[];
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:MissionStatusRollup 2026-08-11-04:27:
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
export function shouldApplyRecomputedStatus<T extends string>(
|
||||||
|
current: T,
|
||||||
|
computed: T,
|
||||||
|
rollupOwned: readonly T[],
|
||||||
|
): boolean {
|
||||||
|
return current !== computed && rollupOwned.includes(current);
|
||||||
|
}
|
||||||
|
|
||||||
/** Status values for a Slice (work unit) */
|
/** Status values for a Slice (work unit) */
|
||||||
export const SLICE_STATUSES = ["pending", "active", "complete"] as const;
|
export const SLICE_STATUSES = ["pending", "active", "complete"] as const;
|
||||||
export type SliceStatus = (typeof SLICE_STATUSES)[number];
|
export type SliceStatus = (typeof SLICE_STATUSES)[number];
|
||||||
|
|||||||
Reference in New Issue
Block a user