FN-8973: add versioned mission resume blockers

Expose versioned mission resume blockers across API and UI.

- Add versioned blocker descriptors with fail-closed legacy normalization and v0 compatibility projections.
- Return typed resume-conflict envelopes and render root stop details in Mission Manager.
- Cover descriptor behavior, route conflicts, UI handling, and PostgreSQL persistence.

Files changed:
 .changeset/fn-8973-mission-blocker-descriptor.md   |  7 ++
 docs/missions.md                                   |  2 +-
 .../src/__tests__/mission-blocked-clear.test.ts    | 18 ++---
 .../core/src/__tests__/mission-blockers.test.ts    | 43 ++++++++++++
 .../__tests__/postgres/mission-store.pg.test.ts    | 22 ++++--
 .../core/src/async-stores/async-mission-store.ts   | 23 +++---
 packages/core/src/index.gate.ts                    |  4 ++
 packages/core/src/index.ts                         |  4 ++
 packages/core/src/missions/mission-blockers.ts     | 43 ++++++++++++
 packages/core/src/missions/mission-types.ts        | 81 ++++++++++------------
 packages/core/src/types.ts                         |  4 +-
 packages/dashboard/app/api/legacy.ts               |  1 +
 packages/dashboard/app/api/missions/missions.ts    | 46 +++++++-----
 .../dashboard/app/components/MissionManager.tsx    | 11 +--
 .../MissionManager.blocked-repair.test.tsx         | 10 +--
 .../MissionManager.resume-conflict.test.tsx        | 24 +++++++
 .../__tests__/mission-blocked-clear-routes.test.ts |  2 +-
 .../src/__tests__/mission-resume-conflict.test.ts  | 42 +++++++++++
 packages/dashboard/src/mission-routes.ts           | 12 +++-
 19 files changed, 297 insertions(+), 102 deletions(-)

Fusion-Task-Id: FN-8973
Fusion-Task-Lineage: 5d36c9c9-dc0f-4cb0-bd0c-ad41c8dde5c0
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-10 22:35:48 -07:00
parent 79206c3f31
commit 918a4c4bfa
19 changed files with 296 additions and 101 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Show versioned mission resume blockers with clear root-stop details.
category: feature
dev: Adds the versioned blockers/legacyBlockers conflict envelope; the v0 mirror remains for one deprecation window.

View File

@@ -643,7 +643,7 @@ A feature transitions to `blocked` when:
- `MilestoneValidationRollup.state` reflects `blocked` assertions - `MilestoneValidationRollup.state` reflects `blocked` assertions
- The feature remains in `blocked` state until operator intervention - The feature remains in `blocked` state until operator intervention
- Deleting a generated fix, or archiving/deleting its generated task, records a durable root-scoped `operator-intervention` stop in the same transaction as unlink/removal. Recovery, duplicate delivery, unarchive, task/root recreation, and relinking cannot mint a sibling. The stop remains even if a hierarchy cascade removes root and lineage rows. - Deleting a generated fix, or archiving/deleting its generated task, records a durable root-scoped `operator-intervention` stop in the same transaction as unlink/removal. Recovery, duplicate delivery, unarchive, task/root recreation, and relinking cannot mint a sibling. The stop remains even if a hierarchy cascade removes root and lineage rows.
- `POST /api/missions/:missionId/resume` is the sole resume seam. It atomically clears only operator-intervention stops, preserves attempt counts, moves extant roots to `needs_fix`, and activates the mission. If any root is budget-exhausted or legacy-unknown, it returns a typed `MISSION_RESUME_CONFLICT` with its legacy `{ id, reason }[]` blocker entries and changes no root, tombstone, counter, or mission state. - `POST /api/missions/:missionId/resume` is the sole resume seam. It atomically clears only operator-intervention stops, preserves attempt counts, moves extant roots to `needs_fix`, and activates the mission. If any root is non-resumable, it changes no root, tombstone, counter, or mission state and returns HTTP 409 with `code: "MISSION_RESUME_CONFLICT"`, `blockerSchemaVersion: 1`, and `blockers: MissionBlockerDescriptor[]`. A descriptor has `schemaVersion: 1`, `kind: "mission-resume-conflict"`, `rootFeatureId`, closed `reason` (`budget-exhausted`, `operator-intervention`, or fail-closed `legacy-unknown-stop`), and `source` (`feature-row` or `lineage-stop`); lineage stops also retain `stoppedAt` and `origin`. Unknown or empty persisted reasons normalize to `legacy-unknown-stop`, retaining a non-empty persisted value as `rawReason`. Consumers must treat an unrecognized `blockerSchemaVersion` as non-resumable and ask an operator rather than guessing. `legacyBlockers: Array<{ id, reason }>` mirrors the v0 shape for one deprecation window and will be removed after supported readers migrate.
On engine restart, `recoverActiveMissions()` re-enqueues features in `validating` or `needs_fix` states, ensuring no validation work is lost. It also re-triggers `implementing` features whose linked task is already `done`/`archived` and whose assertion validation has not passed yet. When the stale-run reaper has already converted an abandoned validator run into `needs_fix`, `processTaskOutcome()` promotes the feature back through `implementing` and re-validates instead of skipping it. The same recovery path is replayed during periodic self-heal maintenance, so historically stranded `implementing` features can self-heal without requiring an engine restart. On engine restart, `recoverActiveMissions()` re-enqueues features in `validating` or `needs_fix` states, ensuring no validation work is lost. It also re-triggers `implementing` features whose linked task is already `done`/`archived` and whose assertion validation has not passed yet. When the stale-run reaper has already converted an abandoned validator run into `needs_fix`, `processTaskOutcome()` promotes the feature back through `implementing` and re-validates instead of skipping it. The same recovery path is replayed during periodic self-heal maintenance, so historically stranded `implementing` features can self-heal without requiring an engine restart.

View File

@@ -17,24 +17,18 @@ describe("classifyMissionResumeBlockers", () => {
], ],
}); });
expect(result.blockers).toEqual([ expect(result.blockers).toEqual([
{ featureId: "f-budget", reason: "budget-exhausted", source: "feature-stop" }, expect.objectContaining({ rootFeatureId: "f-budget", reason: "budget-exhausted", source: "feature-row" }),
{ featureId: "f-budget", reason: "other-stop", source: "lineage-stop" }, expect.objectContaining({ rootFeatureId: "f-budget", reason: "budget-exhausted", source: "lineage-stop" }),
{ featureId: "f-legacy", reason: "legacy-unknown-stop", source: "feature-stop" }, expect.objectContaining({ rootFeatureId: "f-budget", reason: "legacy-unknown-stop", source: "lineage-stop", rawReason: "other-stop" }),
{ featureId: "f-lineage", reason: "budget-exhausted", source: "lineage-stop" }, expect.objectContaining({ rootFeatureId: "f-legacy", reason: "legacy-unknown-stop", source: "feature-row" }),
]); expect.objectContaining({ rootFeatureId: "f-lineage", reason: "budget-exhausted", source: "lineage-stop" }),
expect(result.resumeConflictBlockers).toEqual([
{ id: "f-budget", reason: "budget-exhausted" },
{ id: "f-budget", reason: "budget-exhausted" },
{ id: "f-budget", reason: "other-stop" },
{ id: "f-legacy", reason: "legacy-unknown-stop" },
{ id: "f-lineage", reason: "budget-exhausted" },
]); ]);
expect(result.clearableFeatureIds).toEqual(["f-operator", "f-budget"]); expect(result.clearableFeatureIds).toEqual(["f-operator", "f-budget"]);
}); });
it("returns empty projections when there are no stops", () => { it("returns empty projections when there are no stops", () => {
expect(classifyMissionResumeBlockers({ rootFeatures: [], lineageStops: [] })).toEqual({ expect(classifyMissionResumeBlockers({ rootFeatures: [], lineageStops: [] })).toEqual({
blockers: [], resumeConflictBlockers: [], clearableFeatureIds: [], blockers: [], clearableFeatureIds: [],
}); });
}); });
}); });

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import {
MISSION_BLOCKER_DESCRIPTOR_SCHEMA_VERSION,
fromLegacyMissionBlocker,
isMissionBlockerDescriptor,
normalizeMissionBlockerReason,
sortMissionBlockerDescriptors,
toLegacyMissionBlocker,
} from "../index.js";
describe("mission blocker descriptors", () => {
it("normalizes known and unknown persisted stop reasons fail-closed", () => {
for (const reason of ["budget-exhausted", "operator-intervention", "legacy-unknown-stop"] as const) expect(normalizeMissionBlockerReason(reason)).toEqual({ reason });
expect(normalizeMissionBlockerReason(undefined)).toEqual({ reason: "legacy-unknown-stop" });
expect(normalizeMissionBlockerReason(null)).toEqual({ reason: "legacy-unknown-stop" });
expect(normalizeMissionBlockerReason("")).toEqual({ reason: "legacy-unknown-stop" });
expect(normalizeMissionBlockerReason("garbage-from-a-plugin")).toEqual({ reason: "legacy-unknown-stop", rawReason: "garbage-from-a-plugin" });
});
it("gates descriptors by their versioned shape", () => {
const descriptor = fromLegacyMissionBlocker({ id: "F-1", reason: "budget-exhausted" }, "feature-row");
expect(isMissionBlockerDescriptor(descriptor)).toBe(true);
expect(isMissionBlockerDescriptor({ id: "F-1", reason: "budget-exhausted" })).toBe(false);
expect(isMissionBlockerDescriptor({ ...descriptor, schemaVersion: 2 })).toBe(false);
expect(isMissionBlockerDescriptor(null)).toBe(false);
expect(descriptor.schemaVersion).toBe(MISSION_BLOCKER_DESCRIPTOR_SCHEMA_VERSION);
});
it("upgrades v0 entries and retains their v0 projection", () => {
const canonical = fromLegacyMissionBlocker({ id: "F-1", reason: "budget-exhausted" }, "feature-row");
expect(toLegacyMissionBlocker(canonical)).toEqual({ id: "F-1", reason: "budget-exhausted" });
const unknown = fromLegacyMissionBlocker({ id: "F-2", reason: "old-plugin-stop" }, "lineage-stop");
expect(unknown).toMatchObject({ reason: "legacy-unknown-stop", rawReason: "old-plugin-stop" });
});
it("sorts same-root sources deterministically without mutating input", () => {
const lineage = fromLegacyMissionBlocker({ id: "F-1", reason: "budget-exhausted" }, "lineage-stop");
const feature = fromLegacyMissionBlocker({ id: "F-1", reason: "legacy-unknown-stop" }, "feature-row");
const original = [lineage, feature];
expect(sortMissionBlockerDescriptors(original)).toEqual([feature, lineage]);
expect(original).toEqual([lineage, feature]);
});
});

View File

@@ -1239,17 +1239,25 @@ pgTest("MissionStore (PostgreSQL backend mode)", () => {
expect(cleared.mission.status).toBe(await m.computeMissionStatus(mission.id)); expect(cleared.mission.status).toBe(await m.computeMissionStatus(mission.id));
expect(cleared.blockers).toEqual([ expect(cleared.blockers).toEqual([
{ featureId: root.id, reason: "budget-exhausted", source: "feature-stop" }, expect.objectContaining({ schemaVersion: 1, kind: "mission-resume-conflict", rootFeatureId: root.id, reason: "budget-exhausted", source: "feature-row", missionId: mission.id, stoppedAt, origin: "validator-budget" }),
expect.objectContaining({ schemaVersion: 1, kind: "mission-resume-conflict", rootFeatureId: root.id, reason: "budget-exhausted", source: "lineage-stop", missionId: mission.id, stoppedAt, origin: "validator-budget" }),
]); ]);
expect(await m.getFeature(root.id)).toEqual(featureBefore); expect(await m.getFeature(root.id)).toEqual(featureBefore);
expect(await h.layer().db.select().from(schema.project.missionLineageStops) expect(await h.layer().db.select().from(schema.project.missionLineageStops)
.where(sql`${schema.project.missionLineageStops.rootFeatureId} = ${root.id}`)).toEqual(stopsBefore); .where(sql`${schema.project.missionLineageStops.rootFeatureId} = ${root.id}`)).toEqual(stopsBefore);
await expect(m.resumeMission(mission.id)).rejects.toMatchObject({ const resumeError = await m.resumeMission(mission.id).then(
blockers: [ () => undefined,
{ id: root.id, reason: "budget-exhausted" }, (error) => error as MissionResumeConflictError,
{ id: root.id, reason: "budget-exhausted" }, );
], expect(resumeError).toBeInstanceOf(MissionResumeConflictError);
}); expect(resumeError?.descriptors).toEqual([
expect.objectContaining({ schemaVersion: 1, kind: "mission-resume-conflict", rootFeatureId: root.id, source: "feature-row", reason: "budget-exhausted" }),
expect.objectContaining({ schemaVersion: 1, kind: "mission-resume-conflict", rootFeatureId: root.id, source: "lineage-stop", reason: "budget-exhausted", stoppedAt, origin: "validator-budget" }),
]);
expect(resumeError?.blockers).toEqual([
{ id: root.id, reason: "budget-exhausted" },
{ id: root.id, reason: "budget-exhausted" },
]);
}); });
it("records generated-feature deletion as a durable root stop and resumes only explicitly", async () => { it("records generated-feature deletion as a durable root stop and resumes only explicitly", async () => {

View File

@@ -15,6 +15,7 @@ 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, ROLLUP_OWNED_MILESTONE_STATUSES, ROLLUP_OWNED_MISSION_STATUSES, selectNextSerialMissionSlice, shouldApplyRecomputedStatus, 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 { normalizeMissionBlockerReason, toLegacyMissionBlocker } from "../missions/mission-blockers.js";
import type { import type {
Mission, Mission,
Milestone, Milestone,
@@ -52,6 +53,7 @@ import type {
MissionUpdateOptions, MissionUpdateOptions,
MissionFeatureRepairGroundTruth, MissionFeatureRepairGroundTruth,
MissionBlockerDescriptor, MissionBlockerDescriptor,
LegacyMissionBlocker,
MissionBlockedDiagnostics, MissionBlockedDiagnostics,
} from "../missions/mission-types.js"; } from "../missions/mission-types.js";
import type { Goal } from "../goals/goal-types.js"; import type { Goal } from "../goals/goal-types.js";
@@ -228,10 +230,15 @@ export class MissionRemediationStoppedError extends Error {
/** Stable mission-wide conflict payload for the sole explicit lineage-stop resume seam. */ /** Stable mission-wide conflict payload for the sole explicit lineage-stop resume seam. */
export class MissionResumeConflictError extends Error { export class MissionResumeConflictError extends Error {
constructor(public readonly blockers: Array<{ id: string; reason: string }>) { constructor(public readonly descriptors: MissionBlockerDescriptor[]) {
super("Mission resume is blocked by non-resumable lineage stops"); super("Mission resume is blocked by non-resumable lineage stops");
this.name = "MissionResumeConflictError"; this.name = "MissionResumeConflictError";
} }
/** @deprecated Remove after dashboard and documentation no longer reference legacyBlockers. */
get blockers(): LegacyMissionBlocker[] {
return this.descriptors.map(toLegacyMissionBlocker);
}
} }
/** Raised when a clear request races a prior clear or targets a non-blocked mission. */ /** Raised when a clear request races a prior clear or targets a non-blocked mission. */
@@ -703,7 +710,7 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
.where(and(eq(schema.project.missionLineageStops.projectId, missionProjectId()), eq(schema.project.missionLineageStops.missionId, missionId))); .where(and(eq(schema.project.missionLineageStops.projectId, missionProjectId()), eq(schema.project.missionLineageStops.missionId, missionId)));
const stops = lockStops ? await stopsQuery.for("update") : await stopsQuery; const stops = lockStops ? await stopsQuery.for("update") : await stopsQuery;
const roots = allFeatures.filter((feature) => featureMission.get(feature.id) === missionId && !feature.generatedFromFeatureId && feature.loopState === "blocked"); const roots = allFeatures.filter((feature) => featureMission.get(feature.id) === missionId && !feature.generatedFromFeatureId && feature.loopState === "blocked");
return classifyMissionResumeBlockers({ rootFeatures: roots, lineageStops: stops }).blockers; return classifyMissionResumeBlockers({ rootFeatures: roots, lineageStops: stops, missionId }).blockers;
} }
async getMissionBlockedDiagnostics(missionId: string): Promise<MissionBlockedDiagnostics> { async getMissionBlockedDiagnostics(missionId: string): Promise<MissionBlockedDiagnostics> {
@@ -770,9 +777,9 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
const stops = await tx.select().from(schema.project.missionLineageStops) const stops = await tx.select().from(schema.project.missionLineageStops)
.where(and(eq(schema.project.missionLineageStops.projectId, missionProjectId()), eq(schema.project.missionLineageStops.missionId, id))).for("update"); .where(and(eq(schema.project.missionLineageStops.projectId, missionProjectId()), eq(schema.project.missionLineageStops.missionId, id))).for("update");
const roots = allFeatures.filter((feature) => featureMission.get(feature.id) === id && !feature.generatedFromFeatureId && feature.loopState === "blocked"); const roots = allFeatures.filter((feature) => featureMission.get(feature.id) === id && !feature.generatedFromFeatureId && feature.loopState === "blocked");
const classified = classifyMissionResumeBlockers({ rootFeatures: roots, lineageStops: stops }); const classified = classifyMissionResumeBlockers({ rootFeatures: roots, lineageStops: stops, missionId: id });
if (classified.resumeConflictBlockers.length > 0) { if (classified.blockers.length > 0) {
throw new MissionResumeConflictError(classified.resumeConflictBlockers); throw new MissionResumeConflictError(classified.blockers);
} }
const clearableFeatureIds = new Set(classified.clearableFeatureIds); const clearableFeatureIds = new Set(classified.clearableFeatureIds);
for (const root of roots) { for (const root of roots) {
@@ -2222,11 +2229,7 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
throw new MissionRemediationStoppedError("budget-exhausted"); throw new MissionRemediationStoppedError("budget-exhausted");
} }
if (outcome.kind === "stopped") { if (outcome.kind === "stopped") {
throw new MissionRemediationStoppedError( throw new MissionRemediationStoppedError(normalizeMissionBlockerReason(outcome.reason).reason);
outcome.reason === "budget-exhausted" || outcome.reason === "operator-intervention"
? outcome.reason
: "legacy-unknown-stop",
);
} }
const feature = outcome.feature; const feature = outcome.feature;
this.emit("feature:created", feature); this.emit("feature:created", feature);

View File

@@ -1620,6 +1620,7 @@ export {
DEFAULT_MISSION_ASSERTION_TYPE, DEFAULT_MISSION_ASSERTION_TYPE,
normalizeMissionAssertionType, normalizeMissionAssertionType,
MILESTONE_VALIDATION_STATES, MILESTONE_VALIDATION_STATES,
MISSION_BLOCKER_DESCRIPTOR_SCHEMA_VERSION,
} from "./missions/mission-types.js"; } from "./missions/mission-types.js";
export type { export type {
MissionStatus, MissionStatus,
@@ -1641,7 +1642,9 @@ export type {
MissionTransitionActorType, MissionTransitionActorType,
MissionTransitionActor, MissionTransitionActor,
MissionUpdateOptions, MissionUpdateOptions,
MissionBlockerReason,
MissionBlockerSource, MissionBlockerSource,
LegacyMissionBlocker,
MissionBlockerDescriptor, MissionBlockerDescriptor,
MissionBlockedDiagnostics, MissionBlockedDiagnostics,
AutopilotStatus, AutopilotStatus,
@@ -1691,6 +1694,7 @@ export type {
AssertionUnlinkedPayload, AssertionUnlinkedPayload,
MilestoneValidationUpdatedPayload, MilestoneValidationUpdatedPayload,
} from "./missions/mission-types.js"; } from "./missions/mission-types.js";
export { normalizeMissionBlockerReason, createMissionBlockerDescriptor, isMissionBlockerDescriptor, fromLegacyMissionBlocker, toLegacyMissionBlocker, sortMissionBlockerDescriptors } from "./missions/mission-blockers.js";
export { MissionStore } from "./missions/mission-store.js"; export { MissionStore } from "./missions/mission-store.js";
export { MissionBlockedClearConflictError } from "./async-stores/async-mission-store.js"; export { MissionBlockedClearConflictError } from "./async-stores/async-mission-store.js";
export type { MissionStoreEvents, MissionSummary } from "./missions/mission-store.js"; export type { MissionStoreEvents, MissionSummary } from "./missions/mission-store.js";

View File

@@ -1796,6 +1796,7 @@ export {
DEFAULT_MISSION_ASSERTION_TYPE, DEFAULT_MISSION_ASSERTION_TYPE,
normalizeMissionAssertionType, normalizeMissionAssertionType,
MILESTONE_VALIDATION_STATES, MILESTONE_VALIDATION_STATES,
MISSION_BLOCKER_DESCRIPTOR_SCHEMA_VERSION,
} from "./missions/mission-types.js"; } from "./missions/mission-types.js";
export type { export type {
MissionStatus, MissionStatus,
@@ -1818,7 +1819,9 @@ export type {
MissionTransitionActorType, MissionTransitionActorType,
MissionTransitionActor, MissionTransitionActor,
MissionUpdateOptions, MissionUpdateOptions,
MissionBlockerReason,
MissionBlockerSource, MissionBlockerSource,
LegacyMissionBlocker,
MissionBlockerDescriptor, MissionBlockerDescriptor,
MissionBlockedDiagnostics, MissionBlockedDiagnostics,
AutopilotStatus, AutopilotStatus,
@@ -1868,6 +1871,7 @@ export type {
AssertionUnlinkedPayload, AssertionUnlinkedPayload,
MilestoneValidationUpdatedPayload, MilestoneValidationUpdatedPayload,
} from "./missions/mission-types.js"; } from "./missions/mission-types.js";
export { normalizeMissionBlockerReason, createMissionBlockerDescriptor, isMissionBlockerDescriptor, fromLegacyMissionBlocker, toLegacyMissionBlocker, sortMissionBlockerDescriptors } from "./missions/mission-blockers.js";
export { MissionStore } from "./missions/mission-store.js"; export { MissionStore } from "./missions/mission-store.js";
export type { MissionStoreEvents, MissionSummary } from "./missions/mission-store.js"; export type { MissionStoreEvents, MissionSummary } from "./missions/mission-store.js";
export { AsyncMissionStore, MissionRemediationStoppedError, MissionResumeConflictError, MissionBlockedClearConflictError, RepairGroundTruthStaleError, RepairNotEligibleError, RepairValidatorRunInFlightError, RepairAssertionsMissingError, TerminalTaskReconciliationError } from "./async-stores/async-mission-store.js"; export { AsyncMissionStore, MissionRemediationStoppedError, MissionResumeConflictError, MissionBlockedClearConflictError, RepairGroundTruthStaleError, RepairNotEligibleError, RepairValidatorRunInFlightError, RepairAssertionsMissingError, TerminalTaskReconciliationError } from "./async-stores/async-mission-store.js";

View File

@@ -0,0 +1,43 @@
/*
FNXC:MissionLineageBudget 2026-08-11-05:07:
The resume-conflict payload is a versioned contract. Unknown persisted stop reasons normalize
fail-closed to legacy-unknown-stop, never a resumable reason, while v0 { id, reason } remains only
for a bounded deprecation window.
*/
import {
MISSION_BLOCKER_DESCRIPTOR_SCHEMA_VERSION,
type LegacyMissionBlocker,
type MissionBlockerDescriptor,
type MissionBlockerReason,
type MissionBlockerSource,
} from "./mission-types.js";
const KNOWN_REASONS = new Set<MissionBlockerReason>(["budget-exhausted", "operator-intervention", "legacy-unknown-stop"]);
export function normalizeMissionBlockerReason(raw: string | null | undefined): { reason: MissionBlockerReason; rawReason?: string } {
if (raw && KNOWN_REASONS.has(raw as MissionBlockerReason)) return { reason: raw as MissionBlockerReason };
return raw ? { reason: "legacy-unknown-stop", rawReason: raw } : { reason: "legacy-unknown-stop" };
}
export function createMissionBlockerDescriptor(input: { rootFeatureId: string; source: MissionBlockerSource; rawReason: string | null | undefined; missionId?: string; stoppedAt?: string; origin?: string }): MissionBlockerDescriptor {
const normalized = normalizeMissionBlockerReason(input.rawReason);
return { schemaVersion: MISSION_BLOCKER_DESCRIPTOR_SCHEMA_VERSION, kind: "mission-resume-conflict", rootFeatureId: input.rootFeatureId, reason: normalized.reason, source: input.source, ...(input.missionId ? { missionId: input.missionId } : {}), ...(input.stoppedAt ? { stoppedAt: input.stoppedAt } : {}), ...(input.origin ? { origin: input.origin } : {}), ...(normalized.rawReason ? { rawReason: normalized.rawReason } : {}) };
}
export function isMissionBlockerDescriptor(value: unknown): value is MissionBlockerDescriptor {
if (!value || typeof value !== "object") return false;
const candidate = value as Record<string, unknown>;
return candidate.schemaVersion === MISSION_BLOCKER_DESCRIPTOR_SCHEMA_VERSION && candidate.kind === "mission-resume-conflict" && typeof candidate.rootFeatureId === "string" && candidate.rootFeatureId.length > 0 && typeof candidate.reason === "string" && KNOWN_REASONS.has(candidate.reason as MissionBlockerReason) && (candidate.source === "feature-row" || candidate.source === "lineage-stop");
}
export function fromLegacyMissionBlocker(entry: LegacyMissionBlocker, source: MissionBlockerSource): MissionBlockerDescriptor {
return createMissionBlockerDescriptor({ rootFeatureId: entry.id, source, rawReason: entry.reason });
}
export function toLegacyMissionBlocker(descriptor: MissionBlockerDescriptor): LegacyMissionBlocker {
return { id: descriptor.rootFeatureId, reason: descriptor.reason };
}
export function sortMissionBlockerDescriptors(list: readonly MissionBlockerDescriptor[]): MissionBlockerDescriptor[] {
return [...list].sort((a, b) => a.rootFeatureId.localeCompare(b.rootFeatureId) || (a.source === b.source ? 0 : a.source === "feature-row" ? -1 : 1) || a.reason.localeCompare(b.reason));
}

View File

@@ -11,6 +11,7 @@
import type { Goal } from "../goals/goal-types.js"; import type { Goal } from "../goals/goal-types.js";
import { redactSecrets } from "../secrets/redact-secrets.js"; import { redactSecrets } from "../secrets/redact-secrets.js";
import { createMissionBlockerDescriptor, sortMissionBlockerDescriptors } from "./mission-blockers.js";
// ── Status Enums ───────────────────────────────────────────────────── // ── Status Enums ─────────────────────────────────────────────────────
@@ -21,14 +22,29 @@ export type MissionStatus = (typeof MISSION_STATUSES)[number];
/** Statuses that hierarchy rollup can derive for missions. */ /** Statuses that hierarchy rollup can derive for missions. */
export const ROLLUP_OWNED_MISSION_STATUSES = ["planning", "active", "complete"] as const satisfies readonly MissionStatus[]; export const ROLLUP_OWNED_MISSION_STATUSES = ["planning", "active", "complete"] as const satisfies readonly MissionStatus[];
/** The persisted source that prevents a mission from resuming automatically. */ /** Version gate for the public mission-resume blocker contract. */
export type MissionBlockerSource = "feature-stop" | "lineage-stop" | "unspecified"; export const MISSION_BLOCKER_DESCRIPTOR_SCHEMA_VERSION = 1 as const;
/** Canonical, display-safe explanation for a mission-level blocked status. */ /** Closed fail-safe vocabulary for a non-resumable mission root. */
export type MissionBlockerReason = "budget-exhausted" | "operator-intervention" | "legacy-unknown-stop";
/** Durable location from which the stop was read. */
export type MissionBlockerSource = "feature-row" | "lineage-stop";
/** @deprecated v0 resume-conflict wire shape retained for one deprecation window. */
export interface LegacyMissionBlocker { id: string; reason: string; }
/** Canonical versioned explanation for a mission resume conflict. */
export interface MissionBlockerDescriptor { export interface MissionBlockerDescriptor {
featureId: string; schemaVersion: 1;
reason: string; kind: "mission-resume-conflict";
rootFeatureId: string;
reason: MissionBlockerReason;
source: MissionBlockerSource; source: MissionBlockerSource;
missionId?: string;
stoppedAt?: string;
origin?: string;
rawReason?: string;
} }
export interface MissionBlockedDiagnostics { export interface MissionBlockedDiagnostics {
@@ -41,46 +57,25 @@ export interface MissionBlockedDiagnostics {
} }
/** /**
* FNXC:MissionBlockedRepair 2026-08-11-02:56: * FNXC:MissionBlockedRepair 2026-08-11-05:07:
* Diagnostics and the resume gate share this pure classifier so their answer to "why blocked" * Diagnostics and resume share the versioned descriptor so blocked-state repair cannot reinterpret
* cannot drift. New surfaces consume its deduped descriptors, while resume retains its historical * a persisted reason differently from the all-or-nothing resume gate.
* undeduplicated { id, reason } payload because that 409 response is an existing wire contract.
*/ */
export function classifyMissionResumeBlockers(input: { export function classifyMissionResumeBlockers(input: {
rootFeatures: ReadonlyArray<Pick<MissionFeature, "id" | "implementationStopReason">>; rootFeatures: ReadonlyArray<Pick<MissionFeature, "id" | "implementationStopReason" | "implementationStoppedAt" | "implementationStopOrigin">>;
lineageStops: ReadonlyArray<{ rootFeatureId: string; reason: string }>; lineageStops: ReadonlyArray<{ rootFeatureId: string; reason: string | null; stoppedAt?: string; origin?: string; missionId?: string | null }>;
}): { missionId?: string;
blockers: MissionBlockerDescriptor[]; }): { blockers: MissionBlockerDescriptor[]; clearableFeatureIds: string[] } {
resumeConflictBlockers: Array<{ id: string; reason: string }>; const blockers = sortMissionBlockerDescriptors([
clearableFeatureIds: string[]; ...input.rootFeatures.filter((root) => root.implementationStopReason !== "operator-intervention")
} { .map((root) => createMissionBlockerDescriptor({ rootFeatureId: root.id, source: "feature-row", missionId: input.missionId, rawReason: root.implementationStopReason, stoppedAt: root.implementationStoppedAt, origin: root.implementationStopOrigin })),
const featureStops = input.rootFeatures ...input.lineageStops.filter((stop) => stop.reason !== "operator-intervention")
.filter((root) => root.implementationStopReason !== "operator-intervention") .map((stop) => createMissionBlockerDescriptor({ rootFeatureId: stop.rootFeatureId, source: "lineage-stop", missionId: stop.missionId ?? input.missionId, rawReason: stop.reason, stoppedAt: stop.stoppedAt, origin: stop.origin })),
.map((root) => ({ id: root.id, reason: root.implementationStopReason ?? "legacy-unknown-stop" })); ]);
const lineageStops = input.lineageStops return { blockers, clearableFeatureIds: [...new Set([
.filter((stop) => stop.reason !== "operator-intervention") ...input.rootFeatures.filter((root) => root.implementationStopReason === "operator-intervention").map((root) => root.id),
.map((stop) => ({ id: stop.rootFeatureId, reason: stop.reason })); ...input.rootFeatures.filter((root) => input.lineageStops.some((stop) => stop.rootFeatureId === root.id)).map((root) => root.id),
// Preserve the legacy append-then-stable-sort algorithm exactly, including duplicates. ])] };
const resumeConflictBlockers = [...featureStops, ...lineageStops].sort((a, b) => a.id.localeCompare(b.id));
const descriptors = [
...featureStops.map((stop) => ({ featureId: stop.id, reason: stop.reason, source: "feature-stop" as const })),
...lineageStops.map((stop) => ({ featureId: stop.id, reason: stop.reason, source: "lineage-stop" as const })),
];
const seen = new Set<string>();
const blockers = descriptors.filter((descriptor) => {
const key = `${descriptor.featureId}\u0000${descriptor.reason}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
}).sort((a, b) => a.featureId.localeCompare(b.featureId) || a.source.localeCompare(b.source) || a.reason.localeCompare(b.reason));
return {
blockers,
resumeConflictBlockers,
clearableFeatureIds: [...new Set([
...input.rootFeatures.filter((root) => root.implementationStopReason === "operator-intervention").map((root) => root.id),
...input.rootFeatures.filter((root) => input.lineageStops.some((stop) => stop.rootFeatureId === root.id)).map((root) => root.id),
])],
};
} }
/** Status values for a Milestone within a mission */ /** Status values for a Milestone within a mission */

View File

@@ -1589,7 +1589,9 @@ FNXC:MissionValidationRepair 2026-08-11-00:10:
The dashboard aliases `@fusion/core` to this browser-safe leaf, so expose the pure repair The dashboard aliases `@fusion/core` to this browser-safe leaf, so expose the pure repair
eligibility rule here. Every visible repair control must consult the same policy as the store. eligibility rule here. Every visible repair control must consult the same policy as the store.
*/ */
export { featureValidationRepairEligibility } from "./missions/mission-types.js"; export { featureValidationRepairEligibility, MISSION_BLOCKER_DESCRIPTOR_SCHEMA_VERSION } from "./missions/mission-types.js";
export type { MissionBlockerDescriptor } from "./missions/mission-types.js";
export { fromLegacyMissionBlocker, isMissionBlockerDescriptor } from "./missions/mission-blockers.js";
export { CONFIG_CHANGED_BY_SYSTEM, CONFIG_CHANGED_BY_API_VERIFIED_TOKEN, CONFIG_CHANGED_BY_API_UNVERIFIED, CONFIG_CHANGED_BY_API_VERIFIED_NODE_KEY } from "./types/agents/agents.js"; export { CONFIG_CHANGED_BY_SYSTEM, CONFIG_CHANGED_BY_API_VERIFIED_TOKEN, CONFIG_CHANGED_BY_API_UNVERIFIED, CONFIG_CHANGED_BY_API_VERIFIED_NODE_KEY } from "./types/agents/agents.js";
export type { AgentActivityEventType, AgentActivityAttribution, AgentActivityIdProvenance, AgentActivityIdCandidate, AgentActivityAttributionClaim, AgentActivityMetadataValueSpec, AgentActivityEvent, AgentActivityEventInput, AgentActivityQuery } from "./types/agents/agents.js"; export type { AgentActivityEventType, AgentActivityAttribution, AgentActivityIdProvenance, AgentActivityIdCandidate, AgentActivityAttributionClaim, AgentActivityMetadataValueSpec, AgentActivityEvent, AgentActivityEventInput, AgentActivityQuery } from "./types/agents/agents.js";

View File

@@ -1075,6 +1075,7 @@ export {
fetchMissionBlockedDiagnostics, fetchMissionBlockedDiagnostics,
clearMissionBlockedStatus, clearMissionBlockedStatus,
normalizeMissionBlockers, normalizeMissionBlockers,
parseMissionResumeConflict,
fetchMissionAutopilotStatus, fetchMissionAutopilotStatus,
fetchMissionEvents, fetchMissionEvents,
fetchMissionHealth, fetchMissionHealth,

View File

@@ -3,9 +3,18 @@
* Mission hierarchy, contract assertions, validation loop, and autopilot client API peeled from legacy.ts. * Mission hierarchy, contract assertions, validation loop, and autopilot client API peeled from legacy.ts.
* Mission interview SSE streams remain in legacy until createResilientEventSource is shared. * Mission interview SSE streams remain in legacy until createResilientEventSource is shared.
*/ */
import type { MissionEvent, MissionHealth, MissionEventType, CommitAssociationDiffBackfillReport } from "@fusion/core"; import {
MISSION_BLOCKER_DESCRIPTOR_SCHEMA_VERSION,
fromLegacyMissionBlocker,
isMissionBlockerDescriptor,
type MissionBlockerDescriptor,
type MissionEvent,
type MissionHealth,
type MissionEventType,
type CommitAssociationDiffBackfillReport,
} from "@fusion/core";
import type { MilestoneValidationTelemetry } from "../../components/mission-types"; import type { MilestoneValidationTelemetry } from "../../components/mission-types";
import { api } from "../client/client.js"; import { api, ApiRequestError } from "../client/client.js";
import { withProjectId } from "../client/health.js"; import { withProjectId } from "../client/health.js";
// ── Mission API ─────────────────────────────────────────────────────────── // ── Mission API ───────────────────────────────────────────────────────────
@@ -641,25 +650,28 @@ export function fetchValidationRun(runId: string, projectId?: string): Promise<M
return api(withProjectId(`/missions/validation-runs/${encodeURIComponent(runId)}`, projectId)); return api(withProjectId(`/missions/validation-runs/${encodeURIComponent(runId)}`, projectId));
} }
export type MissionBlockerSource = "feature-stop" | "lineage-stop" | "unspecified"; /** Normalize diagnostics and legacy v0 entries into the canonical v1 render shape. */
export interface MissionBlockerDescriptor { featureId: string; reason: string; source: MissionBlockerSource; }
/** Normalize canonical diagnostics and the frozen legacy resume-conflict payload into one render shape. */
export function normalizeMissionBlockers(input: unknown): MissionBlockerDescriptor[] { export function normalizeMissionBlockers(input: unknown): MissionBlockerDescriptor[] {
if (!Array.isArray(input)) return []; if (!Array.isArray(input)) return [];
const descriptors = input.flatMap((entry): MissionBlockerDescriptor[] => { return input.flatMap((entry): MissionBlockerDescriptor[] => {
if (isMissionBlockerDescriptor(entry)) return [entry];
if (!entry || typeof entry !== "object") return []; if (!entry || typeof entry !== "object") return [];
const value = entry as Record<string, unknown>; const value = entry as Record<string, unknown>;
if (typeof value.featureId === "string" && typeof value.reason === "string" && (value.source === "feature-stop" || value.source === "lineage-stop" || value.source === "unspecified")) return [{ featureId: value.featureId, reason: value.reason, source: value.source }]; return typeof value.id === "string" && typeof value.reason === "string"
if (typeof value.id === "string" && typeof value.reason === "string") return [{ featureId: value.id, reason: value.reason, source: "unspecified" }]; ? [fromLegacyMissionBlocker({ id: value.id, reason: value.reason }, "feature-row")]
return []; : [];
}); });
const seen = new Set<string>(); }
return descriptors.filter((descriptor) => {
const key = `${descriptor.featureId}\u0000${descriptor.reason}`; /** Parse v1 resume conflicts, upgrading retained v0 mirrors for the deprecation window. */
if (seen.has(key)) return false; export function parseMissionResumeConflict(err: unknown): { blockers: MissionBlockerDescriptor[] } | undefined {
seen.add(key); return true; if (!(err instanceof ApiRequestError) || (err.details as { code?: unknown } | undefined)?.code !== "MISSION_RESUME_CONFLICT") return undefined;
}).sort((a, b) => a.featureId.localeCompare(b.featureId) || a.source.localeCompare(b.source) || a.reason.localeCompare(b.reason)); const details = err.details as { blockerSchemaVersion?: unknown; blockers?: unknown; legacyBlockers?: unknown };
if (details.blockerSchemaVersion === MISSION_BLOCKER_DESCRIPTOR_SCHEMA_VERSION) {
return { blockers: normalizeMissionBlockers(details.blockers) };
}
if (details.blockerSchemaVersion !== undefined && details.legacyBlockers === undefined) return { blockers: [] };
return { blockers: normalizeMissionBlockers(details.legacyBlockers ?? details.blockers) };
} }
export function fetchMissionBlockedDiagnostics(missionId: string, projectId?: string): Promise<{ missionId: string; status: MissionStatus; recomputedStatus: MissionStatus; clearable: boolean; resumable: boolean; blockers: MissionBlockerDescriptor[] }> { export function fetchMissionBlockedDiagnostics(missionId: string, projectId?: string): Promise<{ missionId: string; status: MissionStatus; recomputedStatus: MissionStatus; clearable: boolean; resumable: boolean; blockers: MissionBlockerDescriptor[] }> {

View File

@@ -90,6 +90,7 @@ import {
clearMissionBlockedStatus, clearMissionBlockedStatus,
fetchMissionBlockedDiagnostics, fetchMissionBlockedDiagnostics,
normalizeMissionBlockers, normalizeMissionBlockers,
parseMissionResumeConflict,
startMission, startMission,
updateMissionAutopilot, updateMissionAutopilot,
fetchMissionsHealth, fetchMissionsHealth,
@@ -2770,10 +2771,12 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
await loadMissionDetail(missionId); await loadMissionDetail(missionId);
loadMissions(); loadMissions();
} catch (err) { } catch (err) {
if (err instanceof ApiRequestError && err.status === 409 && (err.details as { code?: string } | undefined)?.code === "MISSION_RESUME_CONFLICT") { const conflict = parseMissionResumeConflict(err);
setMissionBlockers(normalizeMissionBlockers((err.details as { blockers?: unknown }).blockers)); if (conflict && conflict.blockers.length > 0) {
setMissionBlockers(conflict.blockers);
setMissionBlockedDiagnosticsError(false); setMissionBlockedDiagnosticsError(false);
addToast(t("missions.resumeBlocked", "Mission cannot resume until its recorded blockers are resolved."), "error"); const rendered = conflict.blockers.map((blocker) => `${blocker.rootFeatureId} — ${blocker.reason}`).join(", ");
addToast(t("missions.resumeBlocked", { blockers: rendered, defaultValue: "Mission cannot resume until its recorded blockers are resolved: {{blockers}}" }), "error");
} else addToast(getErrorMessage(err) || t("missions.resumeFailed", "Failed to resume mission"), "error"); } else addToast(getErrorMessage(err) || t("missions.resumeFailed", "Failed to resume mission"), "error");
} }
}, [addToast, loadMissionDetail, loadMissions, projectId, t]); }, [addToast, loadMissionDetail, loadMissions, projectId, t]);
@@ -3283,7 +3286,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
<div className="mission-blocked-repair" aria-label={t("missions.whyBlocked", "Why blocked")}> <div className="mission-blocked-repair" aria-label={t("missions.whyBlocked", "Why blocked")}>
<strong>{t("missions.whyBlocked", "Why blocked")}</strong> <strong>{t("missions.whyBlocked", "Why blocked")}</strong>
{missionBlockedDiagnosticsError ? <span>{t("missions.blockedDiagnosticsUnknown", "Blocker diagnostics are unavailable.")}</span> : missionBlockers.length === 0 ? <span>{t("missions.noRecordedBlockers", "No recorded blockers.")}</span> : ( {missionBlockedDiagnosticsError ? <span>{t("missions.blockedDiagnosticsUnknown", "Blocker diagnostics are unavailable.")}</span> : missionBlockers.length === 0 ? <span>{t("missions.noRecordedBlockers", "No recorded blockers.")}</span> : (
<ul>{missionBlockers.map((blocker) => <li key={`${blocker.featureId}\u0000${blocker.reason}`}>{blocker.featureId}: {blocker.reason}{blocker.source !== "unspecified" ? ` (${blocker.source})` : ""}</li>)}</ul> <ul>{missionBlockers.map((blocker) => <li key={`${blocker.rootFeatureId}\u0000${blocker.source}\u0000${blocker.reason}`}>{blocker.rootFeatureId}: {blocker.reason} ({blocker.source})</li>)}</ul>
)} )}
<input className="input" value={missionBlockedReason} onChange={(event) => setMissionBlockedReason(event.target.value)} placeholder={t("missions.clearBlockedReason", "Optional repair reason")} aria-label={t("missions.clearBlockedReason", "Optional repair reason")} /> <input className="input" value={missionBlockedReason} onChange={(event) => setMissionBlockedReason(event.target.value)} placeholder={t("missions.clearBlockedReason", "Optional repair reason")} aria-label={t("missions.clearBlockedReason", "Optional repair reason")} />
</div> </div>

View File

@@ -48,9 +48,9 @@ describe("MissionManager blocked repair", () => {
}); });
it("normalizes canonical, legacy, malformed, and duplicate blocker inputs", () => { it("normalizes canonical, legacy, malformed, and duplicate blocker inputs", () => {
expect(normalizeMissionBlockers([{ featureId: "F-2", reason: "later", source: "lineage-stop" }, { id: "F-1", reason: "legacy" }, { id: "F-1", reason: "legacy" }, { nope: true }])).toEqual([ expect(normalizeMissionBlockers([{ schemaVersion: 1, kind: "mission-resume-conflict", rootFeatureId: "F-2", reason: "budget-exhausted", source: "lineage-stop" }, { id: "F-1", reason: "legacy" }, { nope: true }])).toEqual([
{ featureId: "F-1", reason: "legacy", source: "unspecified" }, expect.objectContaining({ rootFeatureId: "F-2", reason: "budget-exhausted", source: "lineage-stop" }),
{ featureId: "F-2", reason: "later", source: "lineage-stop" }, expect.objectContaining({ rootFeatureId: "F-1", reason: "legacy-unknown-stop", source: "feature-row", rawReason: "legacy" }),
]); ]);
expect(normalizeMissionBlockers(undefined)).toEqual([]); expect(normalizeMissionBlockers(undefined)).toEqual([]);
expect(normalizeMissionBlockers(null)).toEqual([]); expect(normalizeMissionBlockers(null)).toEqual([]);
@@ -62,14 +62,14 @@ describe("MissionManager blocked repair", () => {
fetchMissions.mockResolvedValue([blockedSummary]); fetchMissions.mockResolvedValue([blockedSummary]);
fetchMission.mockResolvedValue(blockedMission); fetchMission.mockResolvedValue(blockedMission);
fetchMissionsHealth.mockResolvedValue({}); fetchMissionsHealth.mockResolvedValue({});
fetchMissionBlockedDiagnostics.mockResolvedValue({ blockers: [{ featureId: "F-1", reason: "budget-exhausted", source: "feature-stop" }] }); fetchMissionBlockedDiagnostics.mockResolvedValue({ blockers: [{ schemaVersion: 1, kind: "mission-resume-conflict", rootFeatureId: "F-1", reason: "budget-exhausted", source: "feature-row" }] });
clearMissionBlockedStatus.mockResolvedValue({ mission: { ...blockedMission, status: "planning" }, blockers: [] }); clearMissionBlockedStatus.mockResolvedValue({ mission: { ...blockedMission, status: "planning" }, blockers: [] });
}); });
it("renders the clear control on both owning blocked badge surfaces and refreshes it away", async () => { it("renders the clear control on both owning blocked badge surfaces and refreshes it away", async () => {
renderBlocked(); renderBlocked();
await waitFor(() => expect(screen.getAllByRole("button", { name: "Clear blocked status" })).toHaveLength(2)); await waitFor(() => expect(screen.getAllByRole("button", { name: "Clear blocked status" })).toHaveLength(2));
await waitFor(() => expect(screen.getByLabelText("Why blocked")).toHaveTextContent("F-1: budget-exhausted (feature-stop)")); await waitFor(() => expect(screen.getByLabelText("Why blocked")).toHaveTextContent("F-1: budget-exhausted (feature-row)"));
fetchMission.mockResolvedValueOnce({ ...blockedMission, status: "planning" }); fetchMission.mockResolvedValueOnce({ ...blockedMission, status: "planning" });
fetchMissions.mockResolvedValueOnce([{ ...blockedSummary, status: "planning" }]); fetchMissions.mockResolvedValueOnce([{ ...blockedSummary, status: "planning" }]);
fireEvent.click(screen.getAllByRole("button", { name: "Clear blocked status" })[0]); fireEvent.click(screen.getAllByRole("button", { name: "Clear blocked status" })[0]);

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { ApiRequestError } from "../../api/client/client.js";
import { parseMissionResumeConflict } from "../../api/missions/missions.js";
const descriptor = { schemaVersion: 1, kind: "mission-resume-conflict", rootFeatureId: "F-root", reason: "budget-exhausted", source: "feature-row" } as const;
describe("MissionManager resume-conflict presentation input", () => {
it("keeps v1 root ids and reasons for the operator toast", () => {
const parsed = parseMissionResumeConflict(new ApiRequestError("conflict", 409, { code: "MISSION_RESUME_CONFLICT", blockerSchemaVersion: 1, blockers: [descriptor, { ...descriptor, rootFeatureId: "F-second", reason: "legacy-unknown-stop" }] }));
expect(parsed?.blockers.map((blocker) => `${blocker.rootFeatureId} — ${blocker.reason}`)).toEqual([
"F-root — budget-exhausted",
"F-second — legacy-unknown-stop",
]);
});
it("upgrades the legacy mirror before rendering the same operator text", () => {
const parsed = parseMissionResumeConflict(new ApiRequestError("conflict", 409, { code: "MISSION_RESUME_CONFLICT", legacyBlockers: [{ id: "F-root", reason: "budget-exhausted" }] }));
expect(parsed?.blockers.map((blocker) => `${blocker.rootFeatureId} — ${blocker.reason}`)).toEqual(["F-root — budget-exhausted"]);
});
it("leaves generic resume failures for the existing fallback toast", () => {
expect(parseMissionResumeConflict(new Error("resume failed"))).toBeUndefined();
});
});

View File

@@ -10,7 +10,7 @@ const mission = {
autoAdvance: false, autopilotEnabled: false, autopilotState: "inactive", autoAdvance: false, autopilotEnabled: false, autopilotState: "inactive",
createdAt: "2026-08-11T00:00:00.000Z", updatedAt: "2026-08-11T00:00:00.000Z", createdAt: "2026-08-11T00:00:00.000Z", updatedAt: "2026-08-11T00:00:00.000Z",
}; };
const canonicalBlocker = { featureId: "F-1", reason: "budget-exhausted", source: "feature-stop" as const }; const canonicalBlocker = { schemaVersion: 1 as const, kind: "mission-resume-conflict" as const, rootFeatureId: "F-1", reason: "budget-exhausted" as const, source: "feature-row" as const };
function fixture() { function fixture() {
const missionStore = { const missionStore = {

View File

@@ -0,0 +1,42 @@
// @vitest-environment node
import { describe, expect, it, vi } from "vitest";
import express from "express";
import { isMissionBlockerDescriptor, MissionResumeConflictError, type TaskStore } from "@fusion/core";
import { createMissionRouter } from "../mission-routes.js";
import { request } from "../test-request.js";
const mission = { id: "M-1", title: "Blocked", status: "blocked", interviewState: "completed", autoAdvance: false, autopilotEnabled: false, autopilotState: "inactive", createdAt: "2026-08-11T00:00:00.000Z", updatedAt: "2026-08-11T00:00:00.000Z" };
const descriptor = { schemaVersion: 1 as const, kind: "mission-resume-conflict" as const, rootFeatureId: "F-1", reason: "budget-exhausted" as const, source: "feature-row" as const, missionId: "M-1" };
function fixture(error: Error | undefined) {
let resumed = false;
const missionStore = { getMission: vi.fn(async () => resumed ? { ...mission, status: "active" } : mission), resumeMission: vi.fn(async () => { if (error) throw error; resumed = true; return { ...mission, status: "active" }; }), on: vi.fn(), off: vi.fn() };
const store = { getMissionStore: () => missionStore, getGoalStore: () => ({ getGoal: vi.fn(), listGoals: vi.fn() }), getRootDir: () => "/tmp/mission-resume-conflict", getSettings: vi.fn(async () => ({})), backendMode: true } as unknown as TaskStore;
const app = express(); app.use(express.json()); app.use("/api/missions", createMissionRouter(store));
return { app };
}
describe("mission resume conflict route", () => {
it("returns a versioned descriptor envelope with a v0 mirror", async () => {
const response = await request(fixture(new MissionResumeConflictError([descriptor])).app, "POST", "/api/missions/M-1/resume");
expect(response.status).toBe(409);
const details = (response.body as { details: Record<string, unknown> }).details;
expect(details.code).toBe("MISSION_RESUME_CONFLICT");
expect(details.blockerSchemaVersion).toBe(1);
expect(details.blockers).toEqual([descriptor]);
expect((details.blockers as unknown[]).every(isMissionBlockerDescriptor)).toBe(true);
expect(details.legacyBlockers).toEqual([{ id: "F-1", reason: "budget-exhausted" }]);
});
it("does not misclassify unrelated errors as resume conflicts", async () => {
const response = await request(fixture(new Error("database unavailable")).app, "POST", "/api/missions/M-1/resume");
expect(response.status).not.toBe(409);
});
it("returns the refreshed mission on success without blocker fields", async () => {
const response = await request(fixture(undefined).app, "POST", "/api/missions/M-1/resume");
expect(response.status).toBe(200);
expect(response.body).toMatchObject({ id: "M-1", status: "active" });
expect(response.body).not.toHaveProperty("blockers");
});
});

View File

@@ -20,6 +20,8 @@ import {
resolvePlanningSettingsModel, resolvePlanningSettingsModel,
THINKING_LEVELS, THINKING_LEVELS,
MissionResumeConflictError, MissionResumeConflictError,
MISSION_BLOCKER_DESCRIPTOR_SCHEMA_VERSION,
toLegacyMissionBlocker,
MissionBlockedClearConflictError, MissionBlockedClearConflictError,
TerminalTaskReconciliationError, TerminalTaskReconciliationError,
featureValidationRepairEligibility, featureValidationRepairEligibility,
@@ -3210,9 +3212,17 @@ export function createMissionRouter(
await missionStore.resumeMission(missionId); await missionStore.resumeMission(missionId);
} catch (error) { } catch (error) {
if (error instanceof MissionResumeConflictError) { if (error instanceof MissionResumeConflictError) {
/*
FNXC:MissionLineageBudget 2026-08-11-05:07:
blockers is the v1 descriptor contract gated by blockerSchemaVersion; legacyBlockers is
deprecated and removed only after no reader depends on it. Unknown future versions must
remain non-resumable and ask an operator rather than being guessed by a consumer.
*/
throw conflict("Mission has non-resumable lineage stops", { throw conflict("Mission has non-resumable lineage stops", {
code: "MISSION_RESUME_CONFLICT", code: "MISSION_RESUME_CONFLICT",
blockers: error.blockers, blockerSchemaVersion: MISSION_BLOCKER_DESCRIPTOR_SCHEMA_VERSION,
blockers: error.descriptors,
legacyBlockers: error.descriptors.map(toLegacyMissionBlocker),
}); });
} }
throw error; throw error;