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:
7
.changeset/fn-8973-mission-blocker-descriptor.md
Normal file
7
.changeset/fn-8973-mission-blocker-descriptor.md
Normal 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.
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
43
packages/core/src/__tests__/mission-blockers.test.ts
Normal file
43
packages/core/src/__tests__/mission-blockers.test.ts
Normal 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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
43
packages/core/src/missions/mission-blockers.ts
Normal file
43
packages/core/src/missions/mission-blockers.ts
Normal 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));
|
||||||
|
}
|
||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -1075,6 +1075,7 @@ export {
|
|||||||
fetchMissionBlockedDiagnostics,
|
fetchMissionBlockedDiagnostics,
|
||||||
clearMissionBlockedStatus,
|
clearMissionBlockedStatus,
|
||||||
normalizeMissionBlockers,
|
normalizeMissionBlockers,
|
||||||
|
parseMissionResumeConflict,
|
||||||
fetchMissionAutopilotStatus,
|
fetchMissionAutopilotStatus,
|
||||||
fetchMissionEvents,
|
fetchMissionEvents,
|
||||||
fetchMissionHealth,
|
fetchMissionHealth,
|
||||||
|
|||||||
@@ -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[] }> {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user