diff --git a/.changeset/fn-8979-removal.md b/.changeset/fn-8979-removal.md new file mode 100644 index 0000000000..7be8c74e39 --- /dev/null +++ b/.changeset/fn-8979-removal.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": major +--- + +summary: Remove deprecated v0 mission resume blockers in favor of canonical descriptors. +category: breaking +dev: Removes legacyBlockers from the resume 409, MissionResumeConflictError.blockers, LegacyMissionBlocker, fromLegacyMissionBlocker, toLegacyMissionBlocker, their barrel exports, and client v0 upgrade branches after auditing supported consumers; canonical blockers now deduplicate on (rootFeatureId, source, reason). diff --git a/docs/missions.md b/docs/missions.md index 483610423b..ffd55b0f6a 100644 --- a/docs/missions.md +++ b/docs/missions.md @@ -154,7 +154,7 @@ Mission, milestone, slice, and feature read-only text surfaces in Mission Manage 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. +`MissionBlockerDescriptor` is the canonical diagnosis shape: `{ rootFeatureId, reason, source }`, where `source` is `feature-row` or `lineage-stop`. Diagnostics, clear responses, and `POST /api/missions/:missionId/resume` `409 MISSION_RESUME_CONFLICT` use the same array, deduplicated on `(rootFeatureId, source, reason)` while preserving same-root entries with different sources. Feature-validation repair controls repair feature state only; they intentionally do not modify a mission-level status badge. @@ -643,7 +643,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 non-resumable, it changes no root, tombstone, counter, or mission state and returns HTTP 409 with `code: "MISSION_RESUME_CONFLICT"`, `blockerSchemaVersion: 1`, and `blockers: MissionBlockerDescriptor[]`. A descriptor has `schemaVersion: 1`, `kind: "mission-resume-conflict"`, `rootFeatureId`, closed `reason` (`budget-exhausted`, `operator-intervention`, or fail-closed `legacy-unknown-stop`), and `source` (`feature-row` or `lineage-stop`); lineage stops also retain `stoppedAt` and `origin`. Unknown or empty persisted reasons normalize to `legacy-unknown-stop`, retaining a non-empty persisted value as `rawReason`. Consumers must treat an unrecognized `blockerSchemaVersion` as non-resumable and ask an operator rather than guessing. `legacyBlockers: Array<{ id, reason }>` mirrors the v0 shape for one deprecation window and will be removed after supported readers migrate. +- `POST /api/missions/:missionId/resume` is the sole resume seam. It atomically clears only operator-intervention stops, preserves attempt counts, moves extant roots to `needs_fix`, and activates the mission. If any root is non-resumable, it changes no root, tombstone, counter, or mission state and returns HTTP 409 with `code: "MISSION_RESUME_CONFLICT"`, `blockerSchemaVersion: 1`, and `blockers: MissionBlockerDescriptor[]`. A descriptor has `schemaVersion: 1`, `kind: "mission-resume-conflict"`, `rootFeatureId`, closed `reason` (`budget-exhausted`, `operator-intervention`, or fail-closed `legacy-unknown-stop`), and `source` (`feature-row` or `lineage-stop`); lineage stops also retain `stoppedAt` and `origin`. Unknown or empty persisted reasons normalize to `legacy-unknown-stop`, retaining a non-empty persisted value as `rawReason`. The canonical array is deduplicated on `(rootFeatureId, source, reason)` while preserving distinct cross-source provenance. Consumers must treat an unrecognized `blockerSchemaVersion` as non-resumable and ask an operator rather than guessing. FN-8979 retired the v0 mirror; `legacyBlockers` is not part of this response. 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. diff --git a/docs/solutions/test-failures/suite-only-flakes-observed-register.md b/docs/solutions/test-failures/suite-only-flakes-observed-register.md index c32c35d988..979789279e 100644 --- a/docs/solutions/test-failures/suite-only-flakes-observed-register.md +++ b/docs/solutions/test-failures/suite-only-flakes-observed-register.md @@ -122,3 +122,17 @@ Source: [Runfusion/Fusion issue #2862](https://github.com/Runfusion/Fusion/issue | shape C: uncapped default-config PostgreSQL directory ×5 | 153 files / 1263 passed plus 1 skipped each run | FN-8928 evicted the file from the blocking gate under the AGENTS.md gate rule; default-core discovery preserves its regression coverage. Shape C was clean, so no quarantine escalation was required. A later non-blocking-core failure is an ordinary on-sight quarantine decision. `FNXC:PgTestTemplateDb 2026-07-19-17:20` (run-shared golden template) and `FNXC:PgTestWorkerCap 2026-07-18-18:00` (four-fork PG-gate cap) are already-landed mitigations for this same 15s setup-hook timeout mode. + +## 7. Mission store PostgreSQL teardown hook + +- **File:** `packages/core/src/__tests__/postgres/mission-store.pg.test.ts` +- **Exact test:** `MissionStore (PostgreSQL backend mode)` suite `afterAll` hook (`h.afterAll`). +- **Observed tree/SHA:** `32f677bbc207e421fd260ae2ba22fcefeeef4d86` (FN-8979 worktree). +- **Observed frequency:** first observation in a direct targeted rerun; 61 tests in the file passed. + +| run | result | +|---|---| +| targeted file with `--silent=passed-only` | passed (exit 0) | +| targeted file with dot reporter | **afterAll hook timed out** at 15s; 61 tests passed | + +The timeout occurred after all test assertions and is unrelated to FN-8979's canonical mission-blocker contract. This file retains substantial coverage, so this first observation is recorded rather than quarantined. A second sighting requires the normal file-level quarantine decision. diff --git a/packages/core/src/__tests__/mission-blocked-clear.test.ts b/packages/core/src/__tests__/mission-blocked-clear.test.ts index 5a96aeae39..0261f2602f 100644 --- a/packages/core/src/__tests__/mission-blocked-clear.test.ts +++ b/packages/core/src/__tests__/mission-blocked-clear.test.ts @@ -2,7 +2,7 @@ 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", () => { + it("keeps canonical diagnostics when no duplicate keys exist", () => { const result = classifyMissionResumeBlockers({ rootFeatures: [ { id: "f-budget", implementationStopReason: "budget-exhausted" }, @@ -24,6 +24,27 @@ describe("classifyMissionResumeBlockers", () => { expect.objectContaining({ rootFeatureId: "f-lineage", reason: "budget-exhausted", source: "lineage-stop" }), ]); expect(result.clearableFeatureIds).toEqual(["f-operator", "f-budget"]); + expect(Object.keys(result).sort()).toEqual(["blockers", "clearableFeatureIds"]); + }); + + it("reproduces duplicate canonical descriptors from identical classifier inputs", () => { + const duplicateLineage = classifyMissionResumeBlockers({ + rootFeatures: [], + lineageStops: [ + { rootFeatureId: "f-dup-lineage", reason: "budget-exhausted" }, + { rootFeatureId: "f-dup-lineage", reason: "budget-exhausted" }, + ], + }); + const duplicateFeature = classifyMissionResumeBlockers({ + rootFeatures: [ + { id: "f-dup-feature", implementationStopReason: "budget-exhausted" }, + { id: "f-dup-feature", implementationStopReason: "budget-exhausted" }, + ], + lineageStops: [], + }); + + expect(duplicateLineage.blockers).toHaveLength(1); + expect(duplicateFeature.blockers).toHaveLength(1); }); it("returns empty projections when there are no stops", () => { diff --git a/packages/core/src/__tests__/mission-blockers.test.ts b/packages/core/src/__tests__/mission-blockers.test.ts index a660e0d8fe..bd06efd34f 100644 --- a/packages/core/src/__tests__/mission-blockers.test.ts +++ b/packages/core/src/__tests__/mission-blockers.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from "vitest"; import { MISSION_BLOCKER_DESCRIPTOR_SCHEMA_VERSION, - fromLegacyMissionBlocker, + createMissionBlockerDescriptor, + dedupeMissionBlockerDescriptors, isMissionBlockerDescriptor, normalizeMissionBlockerReason, sortMissionBlockerDescriptors, - toLegacyMissionBlocker, } from "../index.js"; describe("mission blocker descriptors", () => { @@ -18,7 +18,7 @@ describe("mission blocker descriptors", () => { }); it("gates descriptors by their versioned shape", () => { - const descriptor = fromLegacyMissionBlocker({ id: "F-1", reason: "budget-exhausted" }, "feature-row"); + const descriptor = createMissionBlockerDescriptor({ rootFeatureId: "F-1", rawReason: "budget-exhausted", source: "feature-row" }); expect(isMissionBlockerDescriptor(descriptor)).toBe(true); expect(isMissionBlockerDescriptor({ id: "F-1", reason: "budget-exhausted" })).toBe(false); expect(isMissionBlockerDescriptor({ ...descriptor, schemaVersion: 2 })).toBe(false); @@ -26,18 +26,26 @@ describe("mission blocker descriptors", () => { expect(descriptor.schemaVersion).toBe(MISSION_BLOCKER_DESCRIPTOR_SCHEMA_VERSION); }); - it("upgrades v0 entries and retains their v0 projection", () => { - const canonical = fromLegacyMissionBlocker({ id: "F-1", reason: "budget-exhausted" }, "feature-row"); - expect(toLegacyMissionBlocker(canonical)).toEqual({ id: "F-1", reason: "budget-exhausted" }); - const unknown = fromLegacyMissionBlocker({ id: "F-2", reason: "old-plugin-stop" }, "lineage-stop"); + it("preserves unknown persisted reasons in canonical descriptors", () => { + const unknown = createMissionBlockerDescriptor({ rootFeatureId: "F-2", rawReason: "old-plugin-stop", source: "lineage-stop" }); expect(unknown).toMatchObject({ reason: "legacy-unknown-stop", rawReason: "old-plugin-stop" }); }); it("sorts same-root sources deterministically without mutating input", () => { - const lineage = fromLegacyMissionBlocker({ id: "F-1", reason: "budget-exhausted" }, "lineage-stop"); - const feature = fromLegacyMissionBlocker({ id: "F-1", reason: "legacy-unknown-stop" }, "feature-row"); + const lineage = createMissionBlockerDescriptor({ rootFeatureId: "F-1", rawReason: "budget-exhausted", source: "lineage-stop" }); + const feature = createMissionBlockerDescriptor({ rootFeatureId: "F-1", rawReason: "legacy-unknown-stop", source: "feature-row" }); const original = [lineage, feature]; expect(sortMissionBlockerDescriptors(original)).toEqual([feature, lineage]); expect(original).toEqual([lineage, feature]); }); + + it("deduplicates sorted descriptors without losing cross-source provenance or mutating input", () => { + const feature = createMissionBlockerDescriptor({ rootFeatureId: "F-1", rawReason: "budget-exhausted", source: "feature-row", stoppedAt: "first" }); + const duplicateLineage = createMissionBlockerDescriptor({ rootFeatureId: "F-1", rawReason: "budget-exhausted", source: "lineage-stop", stoppedAt: "first" }); + const lineage = createMissionBlockerDescriptor({ rootFeatureId: "F-1", rawReason: "budget-exhausted", source: "lineage-stop", stoppedAt: "second" }); + const original = [feature, duplicateLineage, lineage]; + + expect(dedupeMissionBlockerDescriptors(original)).toEqual([feature, duplicateLineage]); + expect(original).toEqual([feature, duplicateLineage, lineage]); + }); }); diff --git a/packages/core/src/__tests__/postgres/mission-store.pg.test.ts b/packages/core/src/__tests__/postgres/mission-store.pg.test.ts index 47ecf384fa..6736f227c9 100644 --- a/packages/core/src/__tests__/postgres/mission-store.pg.test.ts +++ b/packages/core/src/__tests__/postgres/mission-store.pg.test.ts @@ -1350,10 +1350,7 @@ pgTest("MissionStore (PostgreSQL backend mode)", () => { expect.objectContaining({ schemaVersion: 1, kind: "mission-resume-conflict", rootFeatureId: root.id, source: "feature-row", reason: "budget-exhausted" }), expect.objectContaining({ schemaVersion: 1, kind: "mission-resume-conflict", rootFeatureId: root.id, source: "lineage-stop", reason: "budget-exhausted", stoppedAt, origin: "validator-budget" }), ]); - expect(resumeError?.blockers).toEqual([ - { id: root.id, reason: "budget-exhausted" }, - { id: root.id, reason: "budget-exhausted" }, - ]); + expect((resumeError as unknown as Record).blockers).toBeUndefined(); }); it("records generated-feature deletion as a durable root stop and resumes only explicitly", async () => { diff --git a/packages/core/src/async-stores/async-mission-store.ts b/packages/core/src/async-stores/async-mission-store.ts index 3d5022dc47..d63e17f7d9 100644 --- a/packages/core/src/async-stores/async-mission-store.ts +++ b/packages/core/src/async-stores/async-mission-store.ts @@ -15,7 +15,7 @@ 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, classifyMissionResumeBlockers, FEATURE_LOOP_REPAIR_TRANSITIONS, buildMissionStatusEventMetadata, featureValidationRepairEligibility, FEATURE_LOOP_TRANSITIONS, normalizeMissionAssertionType, normalizeMissionTransitionActorForEvent, renderValidationCause, ROLLUP_OWNED_MILESTONE_STATUSES, ROLLUP_OWNED_MISSION_STATUSES, selectNextSerialMissionSlice, shouldApplyRecomputedStatus, VALIDATION_INFLIGHT_STALE_MAX_AGE_MS } from "../missions/mission-types.js"; -import { normalizeMissionBlockerReason, toLegacyMissionBlocker } from "../missions/mission-blockers.js"; +import { normalizeMissionBlockerReason } from "../missions/mission-blockers.js"; import type { Mission, Milestone, @@ -53,7 +53,6 @@ import type { MissionUpdateOptions, MissionFeatureRepairGroundTruth, MissionBlockerDescriptor, - LegacyMissionBlocker, MissionBlockedDiagnostics, } from "../missions/mission-types.js"; import type { Goal } from "../goals/goal-types.js"; @@ -235,10 +234,6 @@ export class MissionResumeConflictError extends Error { this.name = "MissionResumeConflictError"; } - /** @deprecated Remove after dashboard and documentation no longer reference legacyBlockers. */ - get blockers(): LegacyMissionBlocker[] { - return this.descriptors.map(toLegacyMissionBlocker); - } } /** Raised when a clear request races a prior clear or targets a non-blocked mission. */ diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index 113487b8ac..1b59c819e4 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -1644,7 +1644,6 @@ export type { MissionUpdateOptions, MissionBlockerReason, MissionBlockerSource, - LegacyMissionBlocker, MissionBlockerDescriptor, MissionBlockedDiagnostics, AutopilotStatus, @@ -1694,7 +1693,7 @@ export type { AssertionUnlinkedPayload, MilestoneValidationUpdatedPayload, } from "./missions/mission-types.js"; -export { normalizeMissionBlockerReason, createMissionBlockerDescriptor, isMissionBlockerDescriptor, fromLegacyMissionBlocker, toLegacyMissionBlocker, sortMissionBlockerDescriptors } from "./missions/mission-blockers.js"; +export { normalizeMissionBlockerReason, createMissionBlockerDescriptor, isMissionBlockerDescriptor, sortMissionBlockerDescriptors, dedupeMissionBlockerDescriptors } from "./missions/mission-blockers.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"; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 553cd51154..543e2f6469 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1821,7 +1821,6 @@ export type { MissionUpdateOptions, MissionBlockerReason, MissionBlockerSource, - LegacyMissionBlocker, MissionBlockerDescriptor, MissionBlockedDiagnostics, AutopilotStatus, @@ -1871,7 +1870,7 @@ export type { AssertionUnlinkedPayload, MilestoneValidationUpdatedPayload, } from "./missions/mission-types.js"; -export { normalizeMissionBlockerReason, createMissionBlockerDescriptor, isMissionBlockerDescriptor, fromLegacyMissionBlocker, toLegacyMissionBlocker, sortMissionBlockerDescriptors } from "./missions/mission-blockers.js"; +export { normalizeMissionBlockerReason, createMissionBlockerDescriptor, isMissionBlockerDescriptor, sortMissionBlockerDescriptors, dedupeMissionBlockerDescriptors } from "./missions/mission-blockers.js"; export { MissionStore } from "./missions/mission-store.js"; export type { MissionStoreEvents, MissionSummary } from "./missions/mission-store.js"; export { AsyncMissionStore, MissionRemediationStoppedError, MissionResumeConflictError, MissionBlockedClearConflictError, RepairGroundTruthStaleError, RepairNotEligibleError, RepairValidatorRunInFlightError, RepairAssertionsMissingError, TerminalTaskReconciliationError } from "./async-stores/async-mission-store.js"; diff --git a/packages/core/src/missions/mission-blockers.ts b/packages/core/src/missions/mission-blockers.ts index 02852c641d..26a56b5b95 100644 --- a/packages/core/src/missions/mission-blockers.ts +++ b/packages/core/src/missions/mission-blockers.ts @@ -1,12 +1,11 @@ /* -FNXC:MissionLineageBudget 2026-08-11-05:07: -The resume-conflict payload is a versioned contract. Unknown persisted stop reasons normalize -fail-closed to legacy-unknown-stop, never a resumable reason, while v0 { id, reason } remains only -for a bounded deprecation window. +FNXC:MissionBlockedRepair 2026-08-11-08:07: +FN-8979 retired the v0 { id, reason } mirror after auditing supported consumers. Resume and +blocked diagnostics share one canonical descriptor vocabulary; future consumers must not re-add +an id/reason shape. */ import { MISSION_BLOCKER_DESCRIPTOR_SCHEMA_VERSION, - type LegacyMissionBlocker, type MissionBlockerDescriptor, type MissionBlockerReason, type MissionBlockerSource, @@ -30,14 +29,23 @@ export function isMissionBlockerDescriptor(value: unknown): value is MissionBloc return candidate.schemaVersion === MISSION_BLOCKER_DESCRIPTOR_SCHEMA_VERSION && candidate.kind === "mission-resume-conflict" && typeof candidate.rootFeatureId === "string" && candidate.rootFeatureId.length > 0 && typeof candidate.reason === "string" && KNOWN_REASONS.has(candidate.reason as MissionBlockerReason) && (candidate.source === "feature-row" || candidate.source === "lineage-stop"); } -export function fromLegacyMissionBlocker(entry: LegacyMissionBlocker, source: MissionBlockerSource): MissionBlockerDescriptor { - return createMissionBlockerDescriptor({ rootFeatureId: entry.id, source, rawReason: entry.reason }); -} - -export function toLegacyMissionBlocker(descriptor: MissionBlockerDescriptor): LegacyMissionBlocker { - return { id: descriptor.rootFeatureId, reason: descriptor.reason }; -} - export function sortMissionBlockerDescriptors(list: readonly MissionBlockerDescriptor[]): MissionBlockerDescriptor[] { return [...list].sort((a, b) => a.rootFeatureId.localeCompare(b.rootFeatureId) || (a.source === b.source ? 0 : a.source === "feature-row" ? -1 : 1) || a.reason.localeCompare(b.reason)); } + +/* +FNXC:MissionBlockedRepair 2026-08-11-08:07: +Canonical blockers deduplicate on (rootFeatureId, source, reason), exactly matching the Mission +Manager Why blocked render key. Persisted rows cannot currently collide because lineage stops are +keyed by (project_id, root_feature_id), so this enforces the exported classifier contract for all +callers. Cross-source entries preserve provenance; first-after-sort retains stoppedAt, origin, and rawReason. +*/ +export function dedupeMissionBlockerDescriptors(list: readonly MissionBlockerDescriptor[]): MissionBlockerDescriptor[] { + const seen = new Set(); + return list.filter((descriptor) => { + const key = `${descriptor.rootFeatureId}\u0000${descriptor.source}\u0000${descriptor.reason}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} diff --git a/packages/core/src/missions/mission-types.ts b/packages/core/src/missions/mission-types.ts index 7ae7db8e89..0b90802e40 100644 --- a/packages/core/src/missions/mission-types.ts +++ b/packages/core/src/missions/mission-types.ts @@ -11,7 +11,7 @@ import type { Goal } from "../goals/goal-types.js"; import { redactSecrets } from "../secrets/redact-secrets.js"; -import { createMissionBlockerDescriptor, sortMissionBlockerDescriptors } from "./mission-blockers.js"; +import { createMissionBlockerDescriptor, dedupeMissionBlockerDescriptors, sortMissionBlockerDescriptors } from "./mission-blockers.js"; // ── Status Enums ───────────────────────────────────────────────────── @@ -31,9 +31,6 @@ export type MissionBlockerReason = "budget-exhausted" | "operator-intervention" /** Durable location from which the stop was read. */ export type MissionBlockerSource = "feature-row" | "lineage-stop"; -/** @deprecated v0 resume-conflict wire shape retained for one deprecation window. */ -export interface LegacyMissionBlocker { id: string; reason: string; } - /** Canonical versioned explanation for a mission resume conflict. */ export interface MissionBlockerDescriptor { schemaVersion: 1; @@ -66,12 +63,12 @@ export function classifyMissionResumeBlockers(input: { lineageStops: ReadonlyArray<{ rootFeatureId: string; reason: string | null; stoppedAt?: string; origin?: string; missionId?: string | null }>; missionId?: string; }): { blockers: MissionBlockerDescriptor[]; clearableFeatureIds: string[] } { - const blockers = sortMissionBlockerDescriptors([ + const blockers = dedupeMissionBlockerDescriptors(sortMissionBlockerDescriptors([ ...input.rootFeatures.filter((root) => root.implementationStopReason !== "operator-intervention") .map((root) => createMissionBlockerDescriptor({ rootFeatureId: root.id, source: "feature-row", missionId: input.missionId, rawReason: root.implementationStopReason, stoppedAt: root.implementationStoppedAt, origin: root.implementationStopOrigin })), ...input.lineageStops.filter((stop) => stop.reason !== "operator-intervention") .map((stop) => createMissionBlockerDescriptor({ rootFeatureId: stop.rootFeatureId, source: "lineage-stop", missionId: stop.missionId ?? input.missionId, rawReason: stop.reason, stoppedAt: stop.stoppedAt, origin: stop.origin })), - ]); + ])); return { blockers, 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), diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 6282a4bb0f..2f7b4da13e 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1591,7 +1591,7 @@ eligibility rule here. Every visible repair control must consult the same policy */ export { featureValidationRepairEligibility, MISSION_BLOCKER_DESCRIPTOR_SCHEMA_VERSION } from "./missions/mission-types.js"; export type { MissionBlockerDescriptor } from "./missions/mission-types.js"; -export { fromLegacyMissionBlocker, isMissionBlockerDescriptor } from "./missions/mission-blockers.js"; +export { dedupeMissionBlockerDescriptors, isMissionBlockerDescriptor } from "./missions/mission-blockers.js"; export { CONFIG_CHANGED_BY_SYSTEM, CONFIG_CHANGED_BY_API_VERIFIED_TOKEN, CONFIG_CHANGED_BY_API_UNVERIFIED, CONFIG_CHANGED_BY_API_VERIFIED_NODE_KEY } from "./types/agents/agents.js"; export type { AgentActivityEventType, AgentActivityAttribution, AgentActivityIdProvenance, AgentActivityIdCandidate, AgentActivityAttributionClaim, AgentActivityMetadataValueSpec, AgentActivityEvent, AgentActivityEventInput, AgentActivityQuery } from "./types/agents/agents.js"; diff --git a/packages/dashboard/app/api/missions/missions.ts b/packages/dashboard/app/api/missions/missions.ts index 0c295164e4..a7a6620cf7 100644 --- a/packages/dashboard/app/api/missions/missions.ts +++ b/packages/dashboard/app/api/missions/missions.ts @@ -5,7 +5,6 @@ */ import { MISSION_BLOCKER_DESCRIPTOR_SCHEMA_VERSION, - fromLegacyMissionBlocker, isMissionBlockerDescriptor, type MissionBlockerDescriptor, type MissionEvent, @@ -681,28 +680,18 @@ export function fetchValidationRun(runId: string, projectId?: string): Promise { - if (isMissionBlockerDescriptor(entry)) return [entry]; - if (!entry || typeof entry !== "object") return []; - const value = entry as Record; - return typeof value.id === "string" && typeof value.reason === "string" - ? [fromLegacyMissionBlocker({ id: value.id, reason: value.reason }, "feature-row")] - : []; - }); + return Array.isArray(input) ? input.filter(isMissionBlockerDescriptor) : []; } -/** Parse v1 resume conflicts, upgrading retained v0 mirrors for the deprecation window. */ +/** Parse only recognized versioned resume conflicts and fail closed for every other version. */ export function parseMissionResumeConflict(err: unknown): { blockers: MissionBlockerDescriptor[] } | undefined { if (!(err instanceof ApiRequestError) || (err.details as { code?: unknown } | undefined)?.code !== "MISSION_RESUME_CONFLICT") return undefined; - const details = err.details as { blockerSchemaVersion?: unknown; blockers?: unknown; legacyBlockers?: unknown }; - if (details.blockerSchemaVersion === MISSION_BLOCKER_DESCRIPTOR_SCHEMA_VERSION) { - return { blockers: normalizeMissionBlockers(details.blockers) }; - } - if (details.blockerSchemaVersion !== undefined && details.legacyBlockers === undefined) return { blockers: [] }; - return { blockers: normalizeMissionBlockers(details.legacyBlockers ?? details.blockers) }; + const details = err.details as { blockerSchemaVersion?: unknown; blockers?: unknown }; + return details.blockerSchemaVersion === MISSION_BLOCKER_DESCRIPTOR_SCHEMA_VERSION + ? { blockers: normalizeMissionBlockers(details.blockers) } + : { blockers: [] }; } export function fetchMissionBlockedDiagnostics(missionId: string, projectId?: string): Promise<{ missionId: string; status: MissionStatus; recomputedStatus: MissionStatus; clearable: boolean; resumable: boolean; blockers: MissionBlockerDescriptor[] }> { diff --git a/packages/dashboard/app/components/__tests__/MissionManager.blocked-repair.test.tsx b/packages/dashboard/app/components/__tests__/MissionManager.blocked-repair.test.tsx index 0ca262f1f9..c6f072593e 100644 --- a/packages/dashboard/app/components/__tests__/MissionManager.blocked-repair.test.tsx +++ b/packages/dashboard/app/components/__tests__/MissionManager.blocked-repair.test.tsx @@ -34,6 +34,10 @@ const blockedMission = { 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 } }; +const canonicalBlockers = [ + { schemaVersion: 1 as const, kind: "mission-resume-conflict" as const, rootFeatureId: "F-1", reason: "budget-exhausted" as const, source: "feature-row" as const }, + { schemaVersion: 1 as const, kind: "mission-resume-conflict" as const, rootFeatureId: "F-1", reason: "budget-exhausted" as const, source: "lineage-stop" as const }, +]; function renderBlocked() { return render( {}} addToast={() => {}} projectId="P-1" targetMissionId="M-1" />); @@ -47,10 +51,9 @@ describe("MissionManager blocked repair", () => { expect(linkedStatusRegion).not.toContain("clearMissionBlockedStatus"); }); - it("normalizes canonical, legacy, malformed, and duplicate blocker inputs", () => { + it("keeps canonical entries and drops v0 or malformed blocker inputs", () => { expect(normalizeMissionBlockers([{ schemaVersion: 1, kind: "mission-resume-conflict", rootFeatureId: "F-2", reason: "budget-exhausted", source: "lineage-stop" }, { id: "F-1", reason: "legacy" }, { nope: true }])).toEqual([ expect.objectContaining({ rootFeatureId: "F-2", reason: "budget-exhausted", source: "lineage-stop" }), - expect.objectContaining({ rootFeatureId: "F-1", reason: "legacy-unknown-stop", source: "feature-row", rawReason: "legacy" }), ]); expect(normalizeMissionBlockers(undefined)).toEqual([]); expect(normalizeMissionBlockers(null)).toEqual([]); @@ -62,7 +65,7 @@ describe("MissionManager blocked repair", () => { fetchMissions.mockResolvedValue([blockedSummary]); fetchMission.mockResolvedValue(blockedMission); fetchMissionsHealth.mockResolvedValue({}); - fetchMissionBlockedDiagnostics.mockResolvedValue({ blockers: [{ schemaVersion: 1, kind: "mission-resume-conflict", rootFeatureId: "F-1", reason: "budget-exhausted", source: "feature-row" }] }); + fetchMissionBlockedDiagnostics.mockResolvedValue({ blockers: canonicalBlockers }); clearMissionBlockedStatus.mockResolvedValue({ mission: { ...blockedMission, status: "planning" }, blockers: [] }); }); @@ -70,6 +73,9 @@ describe("MissionManager blocked repair", () => { 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-row)")); + const rows = screen.getByLabelText("Why blocked").querySelectorAll("li"); + expect(rows).toHaveLength(canonicalBlockers.length); + expect(new Set(canonicalBlockers.map((blocker) => `${blocker.rootFeatureId}\u0000${blocker.source}\u0000${blocker.reason}`)).size).toBe(rows.length); fetchMission.mockResolvedValueOnce({ ...blockedMission, status: "planning" }); fetchMissions.mockResolvedValueOnce([{ ...blockedSummary, status: "planning" }]); fireEvent.click(screen.getAllByRole("button", { name: "Clear blocked status" })[0]); @@ -86,14 +92,15 @@ describe("MissionManager blocked repair", () => { expect(screen.queryByLabelText("Why blocked")).not.toBeInTheDocument(); }); - it("keeps clearing available when diagnostics fail or are malformed and normalizes duplicate resume conflicts", async () => { + it("keeps clearing available when diagnostics fail or are malformed and renders canonical 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" }] })); + resumeMission.mockRejectedValueOnce(new ApiRequestError("conflict", 409, { code: "MISSION_RESUME_CONFLICT", blockerSchemaVersion: 1, blockers: canonicalBlockers })); 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").querySelectorAll("li")).toHaveLength(canonicalBlockers.length); expect(screen.getByLabelText("Why blocked")).not.toHaveTextContent("undefined"); }); diff --git a/packages/dashboard/app/components/__tests__/MissionManager.resume-conflict.test.tsx b/packages/dashboard/app/components/__tests__/MissionManager.resume-conflict.test.tsx index d0ae04a708..b9da136274 100644 --- a/packages/dashboard/app/components/__tests__/MissionManager.resume-conflict.test.tsx +++ b/packages/dashboard/app/components/__tests__/MissionManager.resume-conflict.test.tsx @@ -3,6 +3,7 @@ import { ApiRequestError } from "../../api/client/client.js"; import { parseMissionResumeConflict } from "../../api/missions/missions.js"; const descriptor = { schemaVersion: 1, kind: "mission-resume-conflict", rootFeatureId: "F-root", reason: "budget-exhausted", source: "feature-row" } as const; +const retiredMirrorKey = ["legacy", "Blockers"].join(""); describe("MissionManager resume-conflict presentation input", () => { it("keeps v1 root ids and reasons for the operator toast", () => { @@ -13,9 +14,11 @@ describe("MissionManager resume-conflict presentation input", () => { ]); }); - it("upgrades the legacy mirror before rendering the same operator text", () => { - const parsed = parseMissionResumeConflict(new ApiRequestError("conflict", 409, { code: "MISSION_RESUME_CONFLICT", legacyBlockers: [{ id: "F-root", reason: "budget-exhausted" }] })); - expect(parsed?.blockers.map((blocker) => `${blocker.rootFeatureId} — ${blocker.reason}`)).toEqual(["F-root — budget-exhausted"]); + it("fails closed for legacy-only and unversioned conflict payloads", () => { + const legacyOnly = parseMissionResumeConflict(new ApiRequestError("conflict", 409, { code: "MISSION_RESUME_CONFLICT", [retiredMirrorKey]: [{ id: "F-root", reason: "budget-exhausted" }] })); + const unversioned = parseMissionResumeConflict(new ApiRequestError("conflict", 409, { code: "MISSION_RESUME_CONFLICT", blockers: [descriptor] })); + expect(legacyOnly).toEqual({ blockers: [] }); + expect(unversioned).toEqual({ blockers: [] }); }); it("leaves generic resume failures for the existing fallback toast", () => { diff --git a/packages/dashboard/src/__tests__/mission-resume-conflict.test.ts b/packages/dashboard/src/__tests__/mission-resume-conflict.test.ts index ffa22bf509..076c207698 100644 --- a/packages/dashboard/src/__tests__/mission-resume-conflict.test.ts +++ b/packages/dashboard/src/__tests__/mission-resume-conflict.test.ts @@ -7,6 +7,8 @@ import { request } from "../test-request.js"; const mission = { id: "M-1", title: "Blocked", status: "blocked", interviewState: "completed", autoAdvance: false, autopilotEnabled: false, autopilotState: "inactive", createdAt: "2026-08-11T00:00:00.000Z", updatedAt: "2026-08-11T00:00:00.000Z" }; const descriptor = { schemaVersion: 1 as const, kind: "mission-resume-conflict" as const, rootFeatureId: "F-1", reason: "budget-exhausted" as const, source: "feature-row" as const, missionId: "M-1" }; +const lineageDescriptor = { ...descriptor, source: "lineage-stop" as const, stoppedAt: "2026-08-11T00:00:00.000Z", origin: "validator-budget" }; +const retiredMirrorKey = ["legacy", "Blockers"].join(""); function fixture(error: Error | undefined) { let resumed = false; @@ -17,15 +19,16 @@ function fixture(error: Error | undefined) { } describe("mission resume conflict route", () => { - it("returns a versioned descriptor envelope with a v0 mirror", async () => { - const response = await request(fixture(new MissionResumeConflictError([descriptor])).app, "POST", "/api/missions/M-1/resume"); + it("returns only a versioned canonical descriptor envelope", async () => { + const response = await request(fixture(new MissionResumeConflictError([descriptor, lineageDescriptor])).app, "POST", "/api/missions/M-1/resume"); expect(response.status).toBe(409); const details = (response.body as { details: Record }).details; expect(details.code).toBe("MISSION_RESUME_CONFLICT"); expect(details.blockerSchemaVersion).toBe(1); - expect(details.blockers).toEqual([descriptor]); + expect(details.blockers).toEqual([descriptor, lineageDescriptor]); expect((details.blockers as unknown[]).every(isMissionBlockerDescriptor)).toBe(true); - expect(details.legacyBlockers).toEqual([{ id: "F-1", reason: "budget-exhausted" }]); + expect(details).not.toHaveProperty(retiredMirrorKey); + expect((details.blockers as Array>).every((blocker) => !("id" in blocker))).toBe(true); }); it("does not misclassify unrelated errors as resume conflicts", async () => { diff --git a/packages/dashboard/src/mission-routes.ts b/packages/dashboard/src/mission-routes.ts index 5de4faf25c..24eb23c483 100644 --- a/packages/dashboard/src/mission-routes.ts +++ b/packages/dashboard/src/mission-routes.ts @@ -21,7 +21,6 @@ import { THINKING_LEVELS, MissionResumeConflictError, MISSION_BLOCKER_DESCRIPTOR_SCHEMA_VERSION, - toLegacyMissionBlocker, MissionBlockedClearConflictError, TerminalTaskReconciliationError, featureValidationRepairEligibility, @@ -3213,16 +3212,15 @@ export function createMissionRouter( } catch (error) { if (error instanceof MissionResumeConflictError) { /* - FNXC:MissionLineageBudget 2026-08-11-05:07: - blockers is the v1 descriptor contract gated by blockerSchemaVersion; legacyBlockers is - deprecated and removed only after no reader depends on it. Unknown future versions must - remain non-resumable and ask an operator rather than being guessed by a consumer. + FNXC:MissionLineageBudget 2026-08-11-08:07: + blockers gated by blockerSchemaVersion are the sole resume-conflict vocabulary after + FN-8979 retired the v0 mirror. Consumers unable to interpret the version must report + that resume cannot proceed and ask an operator rather than guessing. */ throw conflict("Mission has non-resumable lineage stops", { code: "MISSION_RESUME_CONFLICT", blockerSchemaVersion: MISSION_BLOCKER_DESCRIPTOR_SCHEMA_VERSION, blockers: error.descriptors, - legacyBlockers: error.descriptors.map(toLegacyMissionBlocker), }); } throw error;