FN-8962: add mission blocked status repair controls

Add explicit diagnostics and repair paths for stale mission blocked badges.

- Expose audited core and REST APIs that recompute and clear stale mission blocked status without resuming automation.
- Add mission-manager controls, blocker diagnostics, responsive styling, and operator documentation.
- Cover clear conflicts, persistence, routes, and desktop/mobile UI behavior.

Files changed: .changeset/fn-8962-mission-blocked-clear.md        |   7 ++
 docs/missions.md                                   |  12 ++-
 .../src/__tests__/mission-blocked-clear.test.ts    |  40 ++++++++
 .../__tests__/postgres/mission-store.pg.test.ts    |  71 ++++++++++++++
 .../core/src/async-stores/async-mission-store.ts   |  90 +++++++++++++++--
 packages/core/src/index.gate.ts                    |   4 +
 packages/core/src/index.ts                         |   5 +-
 packages/core/src/missions/mission-types.ts        |  62 ++++++++++++
 packages/dashboard/app/api/legacy.ts               |   3 +
 packages/dashboard/app/api/missions/missions.ts    |  29 ++++++
 .../dashboard/app/components/MissionManager.css    |   6 ++
 .../dashboard/app/components/MissionManager.tsx    |  90 ++++++++++++++++-
 .../MissionManager.blocked-repair.test.tsx         | 106 +++++++++++++++++++++
 .../__tests__/MissionManager.mobile-css.test.ts    |   8 ++
 .../__tests__/mission-blocked-clear-routes.test.ts |  71 ++++++++++++++
 packages/dashboard/src/mission-routes.ts           |  49 ++++++++++
 16 files changed, 638 insertions(+), 15 deletions(-)

Fusion-Task-Id: FN-8962

Fusion-Task-Lineage: 38cab277-a0c3-4c60-af3e-dd25d2f88dea

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-10 20:41:11 -07:00
parent cef07527f6
commit 5dd3031e34
16 changed files with 638 additions and 15 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Let operators clear stale mission blocked badges without resuming automation.
category: feature
dev: Adds the audited clearMissionBlockedStatus primitive and REST pair; new surfaces use canonical blocker descriptors while resume retains its legacy payload.

View File

@@ -150,6 +150,14 @@ Mission detail refreshes now preserve expanded milestone/slice state and keep th
Mission, milestone, slice, and feature read-only text surfaces in Mission Manager render Markdown (GFM) for descriptions, verification, and acceptance criteria; edit forms continue to use raw plain-text `<textarea>` inputs.
### Clearing a stale mission blocked badge
Use **Clear blocked status** when the mission-level `blocked` badge is stale. It recomputes and records the mission status with an attributed audit event, but does **not** resume the mission, unpause linked tasks, re-arm autopilot, or clear lineage stops. **Resume** remains the separate operation that reactivates execution.
`MissionBlockerDescriptor` is the canonical diagnosis shape: `{ featureId, reason, source }`, where `source` is `feature-stop` or `lineage-stop`. The diagnostics and clear responses use this deduplicated shape. For backward compatibility, a `POST /api/missions/:missionId/resume` `409 MISSION_RESUME_CONFLICT` continues to return its legacy undeduplicated `{ id, reason }[]` blockers.
Feature-validation repair controls repair feature state only; they intentionally do not modify a mission-level status badge.
### CLI
```bash
@@ -179,6 +187,8 @@ Fusion surfaces the persisted mission↔goal linkage through REST, CLI, and pi-e
| `PUT /api/missions/:missionId/goals` | Replace the full linked-goal set with body `{ goalIds: string[] }`. Duplicate ids are deduplicated before reconciliation. |
| `POST /api/missions/:missionId/goals/:goalId` | Idempotently link one goal to a mission. |
| `DELETE /api/missions/:missionId/goals/:goalId` | Idempotently unlink one goal from a mission. |
| `GET /api/missions/:missionId/blocked-diagnostics` | Return the read-only blocked-badge diagnosis: mission status, recomputed status, clearability, resumability, and canonical blockers. |
| `POST /api/missions/:missionId/clear-blocked` | Clear only a stale mission `blocked` badge. Optional `{ reason }` is bounded and audit-attributed; it returns `{ mission, blockers }`. |
The mission detail payload keeps `linkedGoals` separate from the milestone tree so read paths can surface strategy context without traversing slices/features. All goal-link write endpoints preserve the same invariant: missing goals on link write paths (`POST /api/missions`, `PATCH /api/missions/:missionId`, `PUT /api/missions/:missionId/goals`, `POST /api/missions/:missionId/goals/:goalId`) reject with `400 { code: "GOAL_NOT_FOUND" }`, archived goals reject with `400 { code: "GOAL_ARCHIVED" }`, duplicate/relinked ids are no-ops, and the `DELETE /api/missions/:missionId/goals/:goalId` unlink path treats unknown goals as a `404` while remaining allowed even after a goal is archived.
@@ -629,7 +639,7 @@ A feature transitions to `blocked` when:
- `MilestoneValidationRollup.state` reflects `blocked` assertions
- 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.
- `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 canonical root IDs/reason categories 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 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.
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

@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { classifyMissionResumeBlockers } from "../missions/mission-types.js";
describe("classifyMissionResumeBlockers", () => {
it("keeps the legacy resume projection while offering deduplicated canonical diagnostics", () => {
const result = classifyMissionResumeBlockers({
rootFeatures: [
{ id: "f-budget", implementationStopReason: "budget-exhausted" },
{ id: "f-operator", implementationStopReason: "operator-intervention" },
{ id: "f-legacy", implementationStopReason: undefined },
],
lineageStops: [
{ rootFeatureId: "f-budget", reason: "budget-exhausted" },
{ rootFeatureId: "f-budget", reason: "other-stop" },
{ rootFeatureId: "f-lineage", reason: "budget-exhausted" },
{ rootFeatureId: "f-operator", reason: "operator-intervention" },
],
});
expect(result.blockers).toEqual([
{ featureId: "f-budget", reason: "budget-exhausted", source: "feature-stop" },
{ featureId: "f-budget", reason: "other-stop", source: "lineage-stop" },
{ featureId: "f-legacy", reason: "legacy-unknown-stop", source: "feature-stop" },
{ featureId: "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"]);
});
it("returns empty projections when there are no stops", () => {
expect(classifyMissionResumeBlockers({ rootFeatures: [], lineageStops: [] })).toEqual({
blockers: [], resumeConflictBlockers: [], clearableFeatureIds: [],
});
});
});

View File

@@ -28,6 +28,8 @@ import {
import * as schema from "../../postgres/schema/index.js";
import {
AsyncMissionStore,
MissionBlockedClearConflictError,
MissionResumeConflictError,
createMission as createMissionRow,
createMilestone as createMilestoneRow,
deleteMission as deleteMissionRow,
@@ -1052,6 +1054,75 @@ pgTest("MissionStore (PostgreSQL backend mode)", () => {
expect(stops).toMatchObject([{ reason: "operator-intervention", origin: "task-archive" }]);
});
it("clears only a stale blocked mission badge with one attributed audit event", async () => {
const m = missions();
const mission = await m.createMission({ title: "Clear stale blocked badge" });
await m.updateMission(mission.id, { status: "blocked" });
const before = (await m.getMissionEvents(mission.id, { limit: 20 })).events.length;
const cleared = await m.clearMissionBlockedStatus(mission.id, { actor: { type: "operator", id: "operator", source: "dashboard" }, reason: "resolved" });
expect(cleared.mission.status).toBe("planning");
expect(cleared.blockers).toEqual([]);
const events = (await m.getMissionEvents(mission.id, { limit: 20 })).events;
expect(events).toHaveLength(before + 1);
expect(events[0]).toMatchObject({ eventType: "mission_status_changed", metadata: expect.objectContaining({ from: "blocked", to: "planning", repairAction: "clear-blocked", reason: "resolved", actor: { type: "operator", id: "operator", source: "dashboard" } }) });
await expect(m.clearMissionBlockedStatus(mission.id, { actor: { type: "operator", id: "operator", source: "dashboard" } })).rejects.toBeInstanceOf(MissionBlockedClearConflictError);
});
/*
FNXC:MissionBlockedRepair 2026-08-11-03:24:
Clearing a stale mission badge is deliberately non-destructive. Exercise the persisted
feature-stop and lineage-stop fixture so this transaction cannot accidentally launder the
records that still make Resume unsafe, while the legacy conflict wire remains duplicated.
*/
it("clears a blocked badge without laundering persisted stop records", async () => {
const m = missions();
const mission = await m.createMission({ title: "Persisted blocked repair" });
const milestone = await m.addMilestone(mission.id, { title: "MS" });
const slice = await m.addSlice(milestone.id, { title: "SL" });
const root = await m.addFeature(slice.id, { title: "Budget-exhausted root" });
const stoppedAt = "2026-08-11T03:24:00.000Z";
await h.layer().db.update(schema.project.missionFeatures)
.set({
loopState: "blocked",
implementationStopReason: "budget-exhausted",
implementationStoppedAt: stoppedAt,
implementationStopOrigin: "validator-budget",
})
.where(sql`${schema.project.missionFeatures.id} = ${root.id}`);
await h.layer().db.insert(schema.project.missionLineageStops).values({
projectId: "mission-store-pg-test",
rootFeatureId: root.id,
missionId: mission.id,
reason: "budget-exhausted",
stoppedAt,
origin: "validator-budget",
});
await m.updateMission(mission.id, { status: "blocked" });
const featureBefore = await m.getFeature(root.id);
const stopsBefore = await h.layer().db.select().from(schema.project.missionLineageStops)
.where(sql`${schema.project.missionLineageStops.rootFeatureId} = ${root.id}`);
const cleared = await m.clearMissionBlockedStatus(mission.id, {
actor: { type: "operator", id: "operator", source: "dashboard" },
reason: "badge is stale",
});
expect(cleared.mission.status).toBe(await m.computeMissionStatus(mission.id));
expect(cleared.blockers).toEqual([
{ featureId: root.id, reason: "budget-exhausted", source: "feature-stop" },
]);
expect(await m.getFeature(root.id)).toEqual(featureBefore);
expect(await h.layer().db.select().from(schema.project.missionLineageStops)
.where(sql`${schema.project.missionLineageStops.rootFeatureId} = ${root.id}`)).toEqual(stopsBefore);
await expect(m.resumeMission(mission.id)).rejects.toMatchObject({
blockers: [
{ 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 () => {
const m = missions();
const mission = await m.createMission({ title: "Operator stop" });

View File

@@ -14,7 +14,7 @@ import { EventEmitter } from "node:events";
import { and, desc, eq, inArray, notInArray, sql } from "drizzle-orm";
import * as schema from "../postgres/schema/index.js";
import type { AsyncDataLayer } from "../postgres/data-layer.js";
import { boundMissionEventReason, FEATURE_LOOP_REPAIR_TRANSITIONS, buildMissionStatusEventMetadata, featureValidationRepairEligibility, FEATURE_LOOP_TRANSITIONS, normalizeMissionAssertionType, normalizeMissionTransitionActorForEvent, renderValidationCause, selectNextSerialMissionSlice } from "../missions/mission-types.js";
import { boundMissionEventReason, classifyMissionResumeBlockers, FEATURE_LOOP_REPAIR_TRANSITIONS, buildMissionStatusEventMetadata, featureValidationRepairEligibility, FEATURE_LOOP_TRANSITIONS, normalizeMissionAssertionType, normalizeMissionTransitionActorForEvent, renderValidationCause, selectNextSerialMissionSlice } from "../missions/mission-types.js";
import type {
Mission,
Milestone,
@@ -50,6 +50,8 @@ import type {
MissionTransitionActor,
MissionUpdateOptions,
MissionFeatureRepairGroundTruth,
MissionBlockerDescriptor,
MissionBlockedDiagnostics,
} from "../missions/mission-types.js";
import type { Goal } from "../goals/goal-types.js";
import {
@@ -231,6 +233,14 @@ export class MissionResumeConflictError extends Error {
}
}
/** Raised when a clear request races a prior clear or targets a non-blocked mission. */
export class MissionBlockedClearConflictError extends Error {
constructor(public readonly status: MissionStatus) {
super(`Mission is not blocked (status: ${status})`);
this.name = "MissionBlockedClearConflictError";
}
}
/** Raised when a stale caller view offers an action no longer supported by the locked feature. */
export class RepairNotEligibleError extends Error {
constructor(featureId: string, action: string) {
@@ -680,6 +690,65 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
this.emit("mission:deleted", id);
}
private async getMissionBlockedDescriptorsWithHandle(handle: QueryHandle, missionId: string, lockStops = false): Promise<MissionBlockerDescriptor[]> {
const allFeatures = await listAllFeatures(handle);
const featureMission = new Map<string, string>();
for (const feature of allFeatures) {
const slice = await getSlice(handle, feature.sliceId);
const milestone = slice ? await getMilestone(handle, slice.milestoneId) : undefined;
if (milestone) featureMission.set(feature.id, milestone.missionId);
}
const stopsQuery = handle.select().from(schema.project.missionLineageStops)
.where(and(eq(schema.project.missionLineageStops.projectId, missionProjectId()), eq(schema.project.missionLineageStops.missionId, missionId)));
const stops = lockStops ? await stopsQuery.for("update") : await stopsQuery;
const roots = allFeatures.filter((feature) => featureMission.get(feature.id) === missionId && !feature.generatedFromFeatureId && feature.loopState === "blocked");
return classifyMissionResumeBlockers({ rootFeatures: roots, lineageStops: stops }).blockers;
}
async getMissionBlockedDiagnostics(missionId: string): Promise<MissionBlockedDiagnostics> {
const mission = await getMission(this.db, missionId);
if (!mission) throw new Error(`Mission ${missionId} not found`);
const [recomputedStatus, blockers] = await Promise.all([
this.computeMissionStatusWithHandle(this.db, missionId),
this.getMissionBlockedDescriptorsWithHandle(this.db, missionId),
]);
return { missionId, status: mission.status, recomputedStatus, clearable: mission.status === "blocked", resumable: mission.status === "blocked" && blockers.length === 0, blockers };
}
/**
* FNXC:MissionBlockedRepair 2026-08-11-02:56:
* Clearing repairs only a stale mission badge. It never resumes automation, unpauses tasks, or
* launders feature and lineage stops; Resume remains the sole path that changes those states.
* The legacy synchronous MissionStore is not constructed at runtime, so it intentionally has no
* parallel primitive.
*/
async clearMissionBlockedStatus(missionId: string, options: { actor: MissionTransitionActor; reason?: string }): Promise<{ mission: Mission; blockers: MissionBlockerDescriptor[] }> {
const result = await this.layer.transactionImmediate(async (tx) => {
const mission = await getMission(tx, missionId);
if (!mission) throw new Error(`Mission ${missionId} not found`);
// Match resume's lock before deciding whether the stale badge can be cleared.
await tx.select().from(schema.project.missions).where(eq(schema.project.missions.id, missionId)).for("update");
const locked = await getMission(tx, missionId);
if (!locked) throw new Error(`Mission ${missionId} not found`);
if (locked.status !== "blocked") throw new MissionBlockedClearConflictError(locked.status);
const blockers = await this.getMissionBlockedDescriptorsWithHandle(tx, missionId, true);
const status = await this.computeMissionStatusWithHandle(tx, missionId);
const updated = { ...locked, status, updatedAt: new Date().toISOString() };
await updateMission(tx, updated);
const event: MissionEvent = {
id: this.generateId("ME"), missionId, eventType: "mission_status_changed",
description: "Mission blocked status cleared",
metadata: buildMissionStatusEventMetadata({ entity: "mission", field: "status", from: "blocked", to: status, ids: { missionId, repairAction: "clear-blocked" }, actor: options.actor, reason: options.reason }),
timestamp: new Date().toISOString(), seq: (await getMaxEventSeq(tx)) + 1,
};
await insertMissionEvent(tx, event);
return { mission: updated, blockers, event };
});
this.emit("mission:updated", result.mission);
this.emit("mission:event", result.event);
return { mission: result.mission, blockers: result.blockers };
}
/**
* FNXC:MissionLineageBudget 2026-07-22-12:00:
* Resume is the only seam that clears operator intervention. Classify every
@@ -700,16 +769,13 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
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");
const roots = allFeatures.filter((feature) => featureMission.get(feature.id) === id && !feature.generatedFromFeatureId && feature.loopState === "blocked");
const stopIds = new Set(stops.map((stop) => stop.rootFeatureId));
const blockers = roots.filter((root) => root.implementationStopReason !== "operator-intervention")
.map((root) => ({ id: root.id, reason: root.implementationStopReason ?? "legacy-unknown-stop" }));
for (const stop of stops) if (stop.reason !== "operator-intervention") blockers.push({ id: stop.rootFeatureId, reason: stop.reason });
if (blockers.length > 0) {
const stable = blockers.sort((a, b) => a.id.localeCompare(b.id));
throw new MissionResumeConflictError(stable);
const classified = classifyMissionResumeBlockers({ rootFeatures: roots, lineageStops: stops });
if (classified.resumeConflictBlockers.length > 0) {
throw new MissionResumeConflictError(classified.resumeConflictBlockers);
}
const clearableFeatureIds = new Set(classified.clearableFeatureIds);
for (const root of roots) {
if (root.implementationStopReason === "operator-intervention" || stopIds.has(root.id)) {
if (clearableFeatureIds.has(root.id)) {
await updateFeature(tx, { ...root, loopState: "needs_fix", implementationStopReason: undefined, implementationStoppedAt: undefined, implementationStopOrigin: undefined, updatedAt: new Date().toISOString() });
}
}
@@ -2779,7 +2845,11 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
}
async computeMissionStatus(missionId: string): Promise<MissionStatus> {
const milestones = await listMilestones(this.db, missionId);
return this.computeMissionStatusWithHandle(this.db, missionId);
}
private async computeMissionStatusWithHandle(handle: QueryHandle, missionId: string): Promise<MissionStatus> {
const milestones = await listMilestones(handle, missionId);
if (milestones.length === 0) return "planning";
const allComplete = milestones.every((m) => m.status === "complete");
if (allComplete) return "complete";

View File

@@ -1641,6 +1641,9 @@ export type {
MissionTransitionActorType,
MissionTransitionActor,
MissionUpdateOptions,
MissionBlockerSource,
MissionBlockerDescriptor,
MissionBlockedDiagnostics,
AutopilotStatus,
Mission,
MissionBranchStrategy,
@@ -1689,6 +1692,7 @@ export type {
MilestoneValidationUpdatedPayload,
} from "./missions/mission-types.js";
export { MissionStore } from "./missions/mission-store.js";
export { MissionBlockedClearConflictError } from "./async-stores/async-mission-store.js";
export type { MissionStoreEvents, MissionSummary } from "./missions/mission-store.js";
export { ACTIVE_GOAL_LIMIT, ActiveGoalLimitExceededError } from "./goals/goal-types.js";
export type { Goal, GoalCreateInput, GoalListFilter, GoalStatus, GoalUpdateInput } from "./goals/goal-types.js";

View File

@@ -1816,6 +1816,9 @@ export type {
MissionTransitionActorType,
MissionTransitionActor,
MissionUpdateOptions,
MissionBlockerSource,
MissionBlockerDescriptor,
MissionBlockedDiagnostics,
AutopilotStatus,
Mission,
MissionBranchStrategy,
@@ -1865,7 +1868,7 @@ export type {
} from "./missions/mission-types.js";
export { MissionStore } from "./missions/mission-store.js";
export type { MissionStoreEvents, MissionSummary } from "./missions/mission-store.js";
export { AsyncMissionStore, MissionRemediationStoppedError, MissionResumeConflictError, 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";
export type { TerminalTaskReconciliationErrorCode } from "./async-stores/async-mission-store.js";
export { AsyncIdeationStore } from "./async-stores/async-ideation-store.js";
export { IDEATION_SESSION_STATUSES, IDEATION_CANDIDATE_ORIGINS } from "./ideation/ideation-types.js";

View File

@@ -18,6 +18,68 @@ import { redactSecrets } from "../secrets/redact-secrets.js";
export const MISSION_STATUSES = ["planning", "active", "blocked", "complete", "archived"] as const;
export type MissionStatus = (typeof MISSION_STATUSES)[number];
/** The persisted source that prevents a mission from resuming automatically. */
export type MissionBlockerSource = "feature-stop" | "lineage-stop" | "unspecified";
/** Canonical, display-safe explanation for a mission-level blocked status. */
export interface MissionBlockerDescriptor {
featureId: string;
reason: string;
source: MissionBlockerSource;
}
export interface MissionBlockedDiagnostics {
missionId: string;
status: MissionStatus;
recomputedStatus: MissionStatus;
clearable: boolean;
resumable: boolean;
blockers: MissionBlockerDescriptor[];
}
/**
* FNXC:MissionBlockedRepair 2026-08-11-02:56:
* Diagnostics and the resume gate share this pure classifier so their answer to "why blocked"
* cannot drift. New surfaces consume its deduped descriptors, while resume retains its historical
* undeduplicated { id, reason } payload because that 409 response is an existing wire contract.
*/
export function classifyMissionResumeBlockers(input: {
rootFeatures: ReadonlyArray<Pick<MissionFeature, "id" | "implementationStopReason">>;
lineageStops: ReadonlyArray<{ rootFeatureId: string; reason: string }>;
}): {
blockers: MissionBlockerDescriptor[];
resumeConflictBlockers: Array<{ id: string; reason: string }>;
clearableFeatureIds: string[];
} {
const featureStops = input.rootFeatures
.filter((root) => root.implementationStopReason !== "operator-intervention")
.map((root) => ({ id: root.id, reason: root.implementationStopReason ?? "legacy-unknown-stop" }));
const lineageStops = input.lineageStops
.filter((stop) => stop.reason !== "operator-intervention")
.map((stop) => ({ id: stop.rootFeatureId, reason: stop.reason }));
// 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 */
export const MILESTONE_STATUSES = ["planning", "active", "blocked", "complete"] as const;
export type MilestoneStatus = (typeof MILESTONE_STATUSES)[number];

View File

@@ -1072,6 +1072,9 @@ export {
fetchMilestoneValidation,
fetchMilestoneValidationTelemetry,
fetchMission,
fetchMissionBlockedDiagnostics,
clearMissionBlockedStatus,
normalizeMissionBlockers,
fetchMissionAutopilotStatus,
fetchMissionEvents,
fetchMissionHealth,

View File

@@ -632,6 +632,35 @@ export function fetchValidationRun(runId: string, projectId?: string): Promise<M
return api(withProjectId(`/missions/validation-runs/${encodeURIComponent(runId)}`, projectId));
}
export type MissionBlockerSource = "feature-stop" | "lineage-stop" | "unspecified";
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[] {
if (!Array.isArray(input)) return [];
const descriptors = input.flatMap((entry): MissionBlockerDescriptor[] => {
if (!entry || typeof entry !== "object") return [];
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 }];
if (typeof value.id === "string" && typeof value.reason === "string") return [{ featureId: value.id, reason: value.reason, source: "unspecified" }];
return [];
});
const seen = new Set<string>();
return 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));
}
export function fetchMissionBlockedDiagnostics(missionId: string, projectId?: string): Promise<{ missionId: string; status: MissionStatus; recomputedStatus: MissionStatus; clearable: boolean; resumable: boolean; blockers: MissionBlockerDescriptor[] }> {
return api(withProjectId(`/missions/${encodeURIComponent(missionId)}/blocked-diagnostics`, projectId));
}
export function clearMissionBlockedStatus(missionId: string, options: { reason?: string } = {}, projectId?: string): Promise<{ mission: Mission; blockers: MissionBlockerDescriptor[] }> {
return api(withProjectId(`/missions/${encodeURIComponent(missionId)}/clear-blocked`, projectId), { method: "POST", body: JSON.stringify(options.reason ? { reason: options.reason } : {}) });
}
/** Pause a mission (sets status to "blocked", in-flight tasks continue) */
export function pauseMission(missionId: string, projectId?: string): Promise<Mission> {
return api<Mission>(withProjectId(`/missions/${encodeURIComponent(missionId)}/pause`, projectId), {

View File

@@ -2992,3 +2992,9 @@ Generated fix features can carry the same stale blocked state as ordinary rows.
[data-theme="light"] .mission-manager__header {
background: color-mix(in srgb, var(--surface) 80%, transparent);
}
/* FNXC:MissionBlockedRepair 2026-08-11-02:56: Keep stale-badge diagnostics adjacent to existing run controls without creating a parallel button system. */
.mission-blocked-repair { display: grid; gap: var(--space-2); color: var(--text-muted); }
.mission-blocked-repair ul { margin: 0; padding-inline-start: var(--space-4); }
.mission-blocked-repair .input { max-inline-size: 100%; }
@media (max-width: 768px) { .mission-manager__body--stacked .mission-blocked-repair { inline-size: 100%; } }

View File

@@ -8,6 +8,7 @@ import {
getErrorMessage,
type DriftAlignment,
type Goal,
type MissionBlockerDescriptor,
} from "@fusion/core";
import {
X,
@@ -86,6 +87,9 @@ import {
previewEnrichedDescription,
resumeMission,
stopMission,
clearMissionBlockedStatus,
fetchMissionBlockedDiagnostics,
normalizeMissionBlockers,
startMission,
updateMissionAutopilot,
fetchMissionsHealth,
@@ -241,6 +245,15 @@ function getInterviewStatusLabel(status: AiSessionSummary["status"], t: (key: st
}
}
/**
* FNXC:MissionBlockedRepair 2026-08-11-02:56:
* Feature-validation repair intentionally does not change mission status. Both badge surfaces use
* this shared eligibility helper so a stale mission badge always has the same explicit repair path.
*/
export function getMissionBlockedRepairState(mission: Pick<Mission, "status">, blockers: MissionBlockerDescriptor[]): { showClear: boolean; blockers: MissionBlockerDescriptor[] } {
return { showClear: mission.status === "blocked", blockers: mission.status === "blocked" ? blockers : [] };
}
function getMissionRunHelperText(status: MissionStatus, t: (key: string, fallback: string) => string): string | null {
switch (status) {
case "planning":
@@ -248,7 +261,7 @@ function getMissionRunHelperText(status: MissionStatus, t: (key: string, fallbac
case "active":
return t("missions.runHelperActive", "Stopping pauses linked tasks and marks the mission blocked.");
case "blocked":
return t("missions.runHelperBlocked", "Resuming re-activates the mission and continues execution.");
return t("missions.runHelperBlocked", "Resume re-activates execution; Clear blocked status repairs only a stale badge.");
default:
return null;
}
@@ -1071,6 +1084,10 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
const [validationRoundsExpanded, setValidationRoundsExpanded] = useState(true);
const [validatingFeatures, setValidatingFeatures] = useState<Set<string>>(new Set());
const [repairingValidationFeatures, setRepairingValidationFeatures] = useState<Set<string>>(new Set());
const [missionBlockers, setMissionBlockers] = useState<MissionBlockerDescriptor[]>([]);
const [missionBlockedDiagnosticsError, setMissionBlockedDiagnosticsError] = useState(false);
const [clearingBlockedMissionId, setClearingBlockedMissionId] = useState<string | null>(null);
const [missionBlockedReason, setMissionBlockedReason] = useState("");
// Feature loop state
const [featureLoopStates, setFeatureLoopStates] = useState<Map<string, MissionFeatureLoopSnapshot>>(new Map());
@@ -2681,6 +2698,35 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
}
}, [projectId]);
useEffect(() => {
if (!selectedMission || selectedMission.status !== "blocked") {
setMissionBlockers([]); setMissionBlockedDiagnosticsError(false); return;
}
let cancelled = false;
fetchMissionBlockedDiagnostics(selectedMission.id, projectId).then((diagnostics) => {
if (!cancelled) {
const blockers = diagnostics?.blockers;
// FNXC:MissionBlockedRepair 2026-08-11-03:15:
// A malformed diagnostics response must retain the clear control but identify its blocker
// explanation as unavailable instead of presenting an authoritative-looking empty list.
setMissionBlockers(normalizeMissionBlockers(blockers));
setMissionBlockedDiagnosticsError(!Array.isArray(blockers));
}
}).catch(() => { if (!cancelled) { setMissionBlockers([]); setMissionBlockedDiagnosticsError(true); } });
return () => { cancelled = true; };
}, [projectId, selectedMission?.id, selectedMission?.status]);
const handleClearMissionBlockedStatus = useCallback(async (missionId: string, reason?: string) => {
try {
setClearingBlockedMissionId(missionId);
const result = await clearMissionBlockedStatus(missionId, reason?.trim() ? { reason: reason.trim() } : {}, projectId);
setMissionBlockers(normalizeMissionBlockers(result.blockers));
addToast(result.blockers.length > 0 ? t("missions.blockedClearedStillGated", "Blocked status cleared; automation remains gated until Resume.") : t("missions.blockedCleared", "Blocked status cleared"), result.blockers.length > 0 ? "warning" : "success");
await loadMissionDetail(missionId); loadMissions();
} catch (err) { addToast(getErrorMessage(err) || t("missions.clearBlockedFailed", "Failed to clear blocked status"), "error"); }
finally { setClearingBlockedMissionId(null); }
}, [addToast, loadMissionDetail, loadMissions, projectId, t]);
// Toggle feature expansion to show run history
const toggleFeatureExpanded = useCallback(async (featureId: string) => {
if (expandedFeatureId === featureId) {
@@ -2711,9 +2757,13 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
await loadMissionDetail(missionId);
loadMissions();
} catch (err) {
addToast(getErrorMessage(err) || t("missions.resumeFailed", "Failed to resume mission"), "error");
if (err instanceof ApiRequestError && err.status === 409 && (err.details as { code?: string } | undefined)?.code === "MISSION_RESUME_CONFLICT") {
setMissionBlockers(normalizeMissionBlockers((err.details as { blockers?: unknown }).blockers));
setMissionBlockedDiagnosticsError(false);
addToast(t("missions.resumeBlocked", "Mission cannot resume until its recorded blockers are resolved."), "error");
} else addToast(getErrorMessage(err) || t("missions.resumeFailed", "Failed to resume mission"), "error");
}
}, [addToast, loadMissionDetail, loadMissions, projectId]);
}, [addToast, loadMissionDetail, loadMissions, projectId, t]);
// Stop mission — set status to "blocked" and pause all linked tasks
const handleStopMission = useCallback(async (missionId: string) => {
@@ -3204,6 +3254,28 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
<span>{t("missions.resumeMission", "Resume mission")}</span>
</button>
)}
{getMissionBlockedRepairState(selectedMission, missionBlockers).showClear && (
<>
{/* FNXC:MissionBlockedRepair 2026-08-11-02:56: This mission-level control is separate from feature validation repair because that repair never alters the durable mission badge. */}
<button
className="mission-btn mission-btn--ghost"
onClick={() => handleClearMissionBlockedStatus(selectedMission.id, missionBlockedReason)}
title={t("missions.clearBlockedStatus", "Clear blocked status")}
aria-label={t("missions.clearBlockedStatus", "Clear blocked status")}
disabled={clearingBlockedMissionId === selectedMission.id}
>
<Check size={14} />
<span>{t("missions.clearBlockedStatus", "Clear blocked status")}</span>
</button>
<div className="mission-blocked-repair" aria-label={t("missions.whyBlocked", "Why blocked")}>
<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> : (
<ul>{missionBlockers.map((blocker) => <li key={`${blocker.featureId}\u0000${blocker.reason}`}>{blocker.featureId}: {blocker.reason}{blocker.source !== "unspecified" ? ` (${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")} />
</div>
</>
)}
{selectedMission.status === "planning" && (
<button
className="mission-btn mission-btn--primary"
@@ -4979,6 +5051,18 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
<span>{t("missions.resumeMission", "Resume mission")}</span>
</button>
)}
{getMissionBlockedRepairState(m, []).showClear && (
<button
className="mission-btn mission-btn--ghost mission-btn--sm"
onClick={() => handleClearMissionBlockedStatus(m.id)}
title={t("missions.clearBlockedStatus", "Clear blocked status")}
aria-label={t("missions.clearBlockedStatus", "Clear blocked status")}
disabled={clearingBlockedMissionId === m.id}
>
<Check size={14} />
<span>{t("missions.clearBlockedStatus", "Clear blocked status")}</span>
</button>
)}
{m.status === "planning" && (
<button
className="mission-btn mission-btn--primary mission-btn--sm"

View File

@@ -0,0 +1,106 @@
import fs from "node:fs";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { ApiRequestError } from "../../api/client/client";
import { normalizeMissionBlockers } from "../../api/missions/missions";
import { MissionManager } from "../MissionManager";
const fetchMissions = vi.fn();
const fetchMission = vi.fn();
const fetchMissionsHealth = vi.fn();
const fetchMissionBlockedDiagnostics = vi.fn();
const clearMissionBlockedStatus = vi.fn();
const resumeMission = vi.fn();
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => ({
...await importOriginal<typeof import("../../hooks/useNavigationHistory")>(),
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
}));
vi.mock("../../sse-bus", () => ({ subscribeSse: vi.fn(() => () => {}) }));
vi.mock("../../api", async (importOriginal) => ({
...await importOriginal<typeof import("../../api")>(),
fetchMissions: (...args: unknown[]) => fetchMissions(...args),
fetchMission: (...args: unknown[]) => fetchMission(...args),
fetchMissionsHealth: (...args: unknown[]) => fetchMissionsHealth(...args),
fetchMissionBlockedDiagnostics: (...args: unknown[]) => fetchMissionBlockedDiagnostics(...args),
clearMissionBlockedStatus: (...args: unknown[]) => clearMissionBlockedStatus(...args),
resumeMission: (...args: unknown[]) => resumeMission(...args),
}));
const blockedMission = {
id: "M-1", title: "Blocked mission", description: "", status: "blocked" as const,
interviewState: "completed", autoAdvance: false, autopilotEnabled: false, autopilotState: "inactive",
createdAt: "2026-08-11T00:00:00.000Z", updatedAt: "2026-08-11T00:00:00.000Z", milestones: [],
};
const blockedSummary = { ...blockedMission, summary: { totalMilestones: 0, totalFeatures: 0, completedMilestones: 0, completedFeatures: 0, progressPercent: 0 } };
function renderBlocked() {
return render(<MissionManager isOpen isInline onClose={() => {}} addToast={() => {}} projectId="P-1" targetMissionId="M-1" />);
}
describe("MissionManager blocked repair", () => {
it("keeps linked-mission status in GoalsView read-only", () => {
const goalsView = fs.readFileSync(path.resolve(import.meta.dirname, "../GoalsView.tsx"), "utf8");
const linkedStatusRegion = goalsView.slice(goalsView.indexOf("goals-linked-mission-status"), goalsView.indexOf("goals-linked-mission-status") + 800);
expect(linkedStatusRegion).not.toContain("Clear blocked status");
expect(linkedStatusRegion).not.toContain("clearMissionBlockedStatus");
});
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([
{ featureId: "F-1", reason: "legacy", source: "unspecified" },
{ featureId: "F-2", reason: "later", source: "lineage-stop" },
]);
expect(normalizeMissionBlockers(undefined)).toEqual([]);
expect(normalizeMissionBlockers(null)).toEqual([]);
expect(normalizeMissionBlockers({ blockers: [] })).toEqual([]);
});
beforeEach(() => {
vi.clearAllMocks(); localStorage.clear();
fetchMissions.mockResolvedValue([blockedSummary]);
fetchMission.mockResolvedValue(blockedMission);
fetchMissionsHealth.mockResolvedValue({});
fetchMissionBlockedDiagnostics.mockResolvedValue({ blockers: [{ featureId: "F-1", reason: "budget-exhausted", source: "feature-stop" }] });
clearMissionBlockedStatus.mockResolvedValue({ mission: { ...blockedMission, status: "planning" }, blockers: [] });
});
it("renders the clear control on both owning blocked badge surfaces and refreshes it away", async () => {
renderBlocked();
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)"));
fetchMission.mockResolvedValueOnce({ ...blockedMission, status: "planning" });
fetchMissions.mockResolvedValueOnce([{ ...blockedSummary, status: "planning" }]);
fireEvent.click(screen.getAllByRole("button", { name: "Clear blocked status" })[0]);
await waitFor(() => expect(clearMissionBlockedStatus).toHaveBeenCalledWith("M-1", {}, "P-1"));
await waitFor(() => expect(screen.queryByRole("button", { name: "Clear blocked status" })).not.toBeInTheDocument());
});
it.each(["planning", "active", "complete", "archived"] as const)("does not render an orphan repair shell for %s", async (status) => {
fetchMissions.mockResolvedValue([{ ...blockedSummary, status }]);
fetchMission.mockResolvedValue({ ...blockedMission, status });
renderBlocked();
await waitFor(() => expect(fetchMission).toHaveBeenCalled());
expect(screen.queryByRole("button", { name: "Clear blocked status" })).not.toBeInTheDocument();
expect(screen.queryByLabelText("Why blocked")).not.toBeInTheDocument();
});
it("keeps clearing available when diagnostics fail or are malformed and normalizes duplicate resume conflicts", async () => {
fetchMissionBlockedDiagnostics.mockRejectedValueOnce(new Error("offline"));
renderBlocked();
await waitFor(() => expect(screen.getByText("Blocker diagnostics are unavailable.")).toBeInTheDocument());
expect(screen.getAllByRole("button", { name: "Clear blocked status" })).toHaveLength(2);
resumeMission.mockRejectedValueOnce(new ApiRequestError("conflict", 409, { code: "MISSION_RESUME_CONFLICT", blockers: [{ id: "F-1", reason: "budget-exhausted" }, { id: "F-1", reason: "budget-exhausted" }] }));
fireEvent.click(screen.getAllByRole("button", { name: "Resume mission" })[0]);
await waitFor(() => expect(screen.getByLabelText("Why blocked")).toHaveTextContent("F-1: budget-exhausted"));
expect(screen.getByLabelText("Why blocked")).not.toHaveTextContent("undefined");
});
it("treats a malformed diagnostics payload as unavailable while leaving clear operable", async () => {
fetchMissionBlockedDiagnostics.mockResolvedValueOnce({ blockers: { malformed: true } });
renderBlocked();
await waitFor(() => expect(screen.getByText("Blocker diagnostics are unavailable.")).toBeInTheDocument());
expect(screen.getAllByRole("button", { name: "Clear blocked status" })).toHaveLength(2);
});
});

View File

@@ -252,3 +252,11 @@ describe("Mission view overscroll containment", () => {
expect(eventsRule).toContain("-webkit-overflow-scrolling: touch;");
});
});
describe("MissionManager blocked repair mobile styles", () => {
it("keeps the blocked diagnostics usable in stacked layout", () => {
const css = loadAllAppCss();
expect(css).toContain(".mission-manager__body--stacked .mission-blocked-repair");
expect(css).toContain("inline-size: 100%;");
});
});

View File

@@ -0,0 +1,71 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from "vitest";
import express from "express";
import { MissionBlockedClearConflictError, type TaskStore } from "@fusion/core";
import { createMissionRouter } from "../mission-routes.js";
import { request } from "../test-request.js";
const mission = {
id: "M-1", title: "Blocked mission", status: "planning", interviewState: "completed",
autoAdvance: false, autopilotEnabled: false, autopilotState: "inactive",
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 };
function fixture() {
const missionStore = {
getMission: vi.fn(async (id: string) => id === mission.id ? mission : undefined),
getMissionBlockedDiagnostics: vi.fn(async (id: string) => {
if (id !== mission.id) throw new Error(`Mission ${id} not found`);
return { missionId: id, status: "blocked", recomputedStatus: "planning", clearable: true, resumable: false, blockers: [canonicalBlocker] };
}),
clearMissionBlockedStatus: vi.fn(async () => ({ mission, blockers: [canonicalBlocker] })),
resumeMission: vi.fn(async () => { throw new Error("unused"); }),
on: vi.fn(), off: vi.fn(),
};
const store = {
getMissionStore: () => missionStore,
getGoalStore: () => ({ getGoal: vi.fn(), listGoals: vi.fn() }),
getRootDir: () => "/tmp/mission-blocked-clear-routes",
getSettings: vi.fn(async () => ({})),
backendMode: true,
} as unknown as TaskStore;
const autopilot = { watchMission: vi.fn(), unwatchMission: vi.fn(), isWatching: vi.fn(), getAutopilotStatus: vi.fn(), checkAndStartMission: vi.fn(), recoverStaleMission: vi.fn(), start: vi.fn(), stop: vi.fn() };
const app = express();
app.use(express.json());
app.use("/api/missions", createMissionRouter(store, autopilot));
return { app, missionStore, autopilot };
}
describe("mission blocked-clear routes", () => {
let setup: ReturnType<typeof fixture>;
beforeEach(() => { setup = fixture(); });
it("clears with the dashboard actor and never re-arms autopilot", async () => {
const response = await request(setup.app, "POST", "/api/missions/M-1/clear-blocked", JSON.stringify({ reason: "stale badge" }), { "content-type": "application/json" });
expect(response.status).toBe(200);
expect(response.body).toMatchObject({ mission, blockers: [canonicalBlocker] });
expect(setup.missionStore.clearMissionBlockedStatus).toHaveBeenCalledWith("M-1", expect.objectContaining({ reason: "stale badge", actor: expect.objectContaining({ type: "operator", id: "dashboard" }) }));
expect(setup.autopilot.watchMission).not.toHaveBeenCalled();
expect(setup.autopilot.recoverStaleMission).not.toHaveBeenCalled();
});
it("maps missing, malformed, and non-blocked clear requests without a blocker payload", async () => {
const missing = await request(setup.app, "POST", "/api/missions/M-404/clear-blocked", undefined, { "content-type": "application/json" });
expect(missing.status).toBe(404);
const malformed = await request(setup.app, "POST", "/api/missions/not-a-mission/clear-blocked");
expect(malformed.status).toBe(400);
setup.missionStore.clearMissionBlockedStatus.mockRejectedValueOnce(new MissionBlockedClearConflictError("active"));
const conflict = await request(setup.app, "POST", "/api/missions/M-1/clear-blocked");
expect(conflict.status).toBe(409);
expect(conflict.body).toMatchObject({ details: { code: "MISSION_NOT_BLOCKED", status: "active" } });
expect((conflict.body as { details: Record<string, unknown> }).details).not.toHaveProperty("blockers");
});
it("returns diagnostics verbatim without writes", async () => {
const response = await request(setup.app, "GET", "/api/missions/M-1/blocked-diagnostics");
expect(response.status).toBe(200);
expect(response.body).toMatchObject({ blockers: [canonicalBlocker] });
expect(setup.missionStore.clearMissionBlockedStatus).not.toHaveBeenCalled();
});
});

View File

@@ -20,6 +20,7 @@ import {
resolvePlanningSettingsModel,
THINKING_LEVELS,
MissionResumeConflictError,
MissionBlockedClearConflictError,
TerminalTaskReconciliationError,
featureValidationRepairEligibility,
RepairGroundTruthStaleError,
@@ -476,6 +477,8 @@ export function createMissionRouter(
return typeof value === "function" ? value.bind(target) : value;
},
});
// These PostgreSQL-only repair operations intentionally have no legacy synchronous-store twin.
const asyncMissionStore = missionStore as AsyncMissionStore;
router.use(async (req, _res, next) => {
try {
@@ -3090,6 +3093,52 @@ export function createMissionRouter(
// ── Mission Pause/Stop/Resume Endpoints ─────────────────────────────────────
/**
* GET /api/missions/:missionId/blocked-diagnostics
* Returns the canonical blocker descriptors without changing mission state.
*/
router.get(
"/:missionId/blocked-diagnostics",
catchTypedHandler(async (req, res) => {
const { missionId } = req.params;
if (!validateMissionId(missionId)) throw badRequest("Invalid mission ID format");
try {
res.json(await asyncMissionStore.getMissionBlockedDiagnostics(missionId));
} catch (error) {
if (error instanceof Error && error.message === `Mission ${missionId} not found`) throw notFound("Mission not found");
throw error;
}
}),
);
/**
* FNXC:MissionBlockedRepair 2026-08-11-02:56:
* This clear route repairs a stale badge only. Unlike resume it deliberately does not watch the
* mission, recover stale work, unpause tasks, or alter lineage stops.
*/
router.post(
"/:missionId/clear-blocked",
catchTypedHandler(async (req, res) => {
const { missionId } = req.params;
if (!validateMissionId(missionId)) throw badRequest("Invalid mission ID format");
const reason = validateDescription(req.body?.reason);
// FNXC:MissionBlockedRepair 2026-08-11-03:15:
// Check existence before the mutation so an unknown id is consistently a 404 even when a
// store implementation cannot distinguish a missing row from another clear precondition.
if (!await missionStore.getMission(missionId)) throw notFound("Mission not found");
try {
const result = await asyncMissionStore.clearMissionBlockedStatus(missionId, { actor: DASHBOARD_MISSION_ACTOR, reason });
res.json(result);
} catch (error) {
if (error instanceof MissionBlockedClearConflictError) {
throw conflict("Mission is not blocked", { code: "MISSION_NOT_BLOCKED", status: error.status });
}
if (error instanceof Error && error.message === `Mission ${missionId} not found`) throw notFound("Mission not found");
throw error;
}
}),
);
/**
* POST /api/missions/:missionId/pause
* Pause a mission by setting status to "blocked".