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:
7
.changeset/fn-8962-mission-blocked-clear.md
Normal file
7
.changeset/fn-8962-mission-blocked-clear.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
40
packages/core/src/__tests__/mission-blocked-clear.test.ts
Normal file
40
packages/core/src/__tests__/mission-blocked-clear.test.ts
Normal 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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -1072,6 +1072,9 @@ export {
|
||||
fetchMilestoneValidation,
|
||||
fetchMilestoneValidationTelemetry,
|
||||
fetchMission,
|
||||
fetchMissionBlockedDiagnostics,
|
||||
clearMissionBlockedStatus,
|
||||
normalizeMissionBlockers,
|
||||
fetchMissionAutopilotStatus,
|
||||
fetchMissionEvents,
|
||||
fetchMissionHealth,
|
||||
|
||||
@@ -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), {
|
||||
|
||||
@@ -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%; } }
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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%;");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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".
|
||||
|
||||
Reference in New Issue
Block a user