From dfb9ca66309ffd031e455f84c4ecf23fa57d2180 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 23 Jul 2026 16:10:20 -0700 Subject: [PATCH] FN-8543: enforce bounded generated-fix remediation Keep generated fix chains within their root retry budget and preserve intervention stops. - Track retry counts and stop reasons on the canonical root feature. - Persist project-isolated lineage stops across generated-fix removal and archive paths. - Serialize stop recording with generated-fix admission and expose conflict-safe mission resume behavior. Files changed: .changeset/fn-8543-bounded-fix-lineage.md | 7 + docs/missions.md | 6 +- .../__tests__/postgres/mission-store.pg.test.ts | 72 +++++++ packages/core/src/async-mission-store-queries.ts | 91 ++++++++- packages/core/src/async-mission-store.ts | 206 ++++++++++++++++++--- packages/core/src/index.ts | 2 +- packages/core/src/mission-store.ts | 10 + packages/core/src/mission-types.ts | 7 + .../0035_fn_8543_mission_lineage_stop.sql | 31 ++++ packages/core/src/postgres/schema-applier.ts | 18 +- packages/core/src/postgres/schema/project.ts | 21 +++ .../core/src/task-store/archive-lifecycle-2.ts | 15 +- .../core/src/task-store/async-archive-lineage.ts | 10 +- packages/dashboard/src/mission-routes.ts | 14 +- packages/engine/src/mission-execution-loop.ts | 30 ++- 15 files changed, 495 insertions(+), 45 deletions(-) Fusion-Task-Id: FN-8543 Fusion-Task-Lineage: 24c03d63-5914-4072-aa17-16862432fc78 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-8543-bounded-fix-lineage.md | 7 + docs/missions.md | 6 +- .../postgres/mission-store.pg.test.ts | 72 ++++++ .../core/src/async-mission-store-queries.ts | 91 +++++++- packages/core/src/async-mission-store.ts | 206 +++++++++++++++--- packages/core/src/index.ts | 2 +- packages/core/src/mission-store.ts | 10 + packages/core/src/mission-types.ts | 7 + .../0035_fn_8543_mission_lineage_stop.sql | 31 +++ packages/core/src/postgres/schema-applier.ts | 18 +- packages/core/src/postgres/schema/project.ts | 21 ++ .../src/task-store/archive-lifecycle-2.ts | 15 +- .../src/task-store/async-archive-lineage.ts | 10 +- packages/dashboard/src/mission-routes.ts | 14 +- packages/engine/src/mission-execution-loop.ts | 30 ++- 15 files changed, 495 insertions(+), 45 deletions(-) create mode 100644 .changeset/fn-8543-bounded-fix-lineage.md create mode 100644 packages/core/src/postgres/migrations/0035_fn_8543_mission_lineage_stop.sql diff --git a/.changeset/fn-8543-bounded-fix-lineage.md b/.changeset/fn-8543-bounded-fix-lineage.md new file mode 100644 index 0000000000..8cfc6ef23f --- /dev/null +++ b/.changeset/fn-8543-bounded-fix-lineage.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Bound generated mission fixes to one root feature retry budget. +category: fix +dev: Fix lineages retain durable stops through removal and never resume exhausted budgets. diff --git a/docs/missions.md b/docs/missions.md index 36aa887a0e..261bfb671a 100644 --- a/docs/missions.md +++ b/docs/missions.md @@ -586,9 +586,9 @@ interface MissionFixFeatureLineage { } ``` -An authorized fix feature is **auto-planned** (converted to tasks) for immediate execution. Each fix increments `implementationAttemptCount`. +The fix feature is **auto-planned** (converted to tasks) for immediate execution. Each fix increments the **canonical root feature's** `implementationAttemptCount`; generated fixes never receive a fresh budget. With the default retry budget of 3 (`DEFAULT_IMPLEMENTATION_RETRY_BUDGET`), requests 1–3 mint one remediation each and request 4 mints nothing, records `budget-exhausted`, and blocks the root. -**Default retry budget:** 3 (`DEFAULT_IMPLEMENTATION_RETRY_BUDGET`). When `implementationAttemptCount >= maxRetryBudget`, the feature transitions to `blocked`. +Missing, cyclic, or legacy blocked lineage fails closed. A pre-migration blocked root without an explicit stop reason cannot mint remediation and cannot be implicitly resumed. ### Phase 6: Blocked Handoff @@ -601,6 +601,8 @@ A feature transitions to `blocked` when: - Autopilot stops advancing the slice containing the blocked feature - `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. 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/packages/core/src/__tests__/postgres/mission-store.pg.test.ts b/packages/core/src/__tests__/postgres/mission-store.pg.test.ts index f9e644f490..74a2777333 100644 --- a/packages/core/src/__tests__/postgres/mission-store.pg.test.ts +++ b/packages/core/src/__tests__/postgres/mission-store.pg.test.ts @@ -568,6 +568,78 @@ pgTest("MissionStore (PostgreSQL backend mode)", () => { expect((await m.getFeature(fix.id))?.loopState).toBe("needs_fix"); }); + it("shares the root retry budget across fix-of-fix lineage", async () => { + /* FNXC:MissionLineageBudget 2026-07-22-12:00: deterministic remediation chains must exhaust the original feature, never restart at each child. */ + const m = missions(); + const mission = await m.createMission({ title: "Root budget" }); + 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: "F" }); + let source = root; + for (let attempt = 1; attempt <= 3; attempt++) { + await m.transitionLoopState(source.id, "implementing"); + const run = await m.startValidatorRun(source.id, "scheduled"); + await m.completeValidatorRun(run.id, "failed", "deterministic failure"); + source = await m.createGeneratedFixFeature(source.id, run.id, [], "deterministic failure"); + expect((await m.getFeature(root.id))?.implementationAttemptCount).toBe(attempt); + } + await m.transitionLoopState(source.id, "implementing"); + const fourthRun = await m.startValidatorRun(source.id, "scheduled"); + await m.completeValidatorRun(fourthRun.id, "failed", "deterministic failure"); + await expect(m.createGeneratedFixFeature(source.id, fourthRun.id, [], "deterministic failure")) + .rejects.toThrow("MISSION_REMEDIATION_STOPPED: budget-exhausted"); + expect(await m.getFeature(root.id)).toMatchObject({ loopState: "blocked", implementationStopReason: "budget-exhausted", implementationAttemptCount: 3 }); + }); + + it("records generated-task archive as a durable root stop before unlinking", async () => { + /* + FNXC:MissionLineageBudget 2026-07-22-15:30: + Task archive is a supported removal surface. Its archive transaction must + retain the root stop even though it clears the generated feature's task link. + */ + const m = missions(); + const mission = await m.createMission({ title: "Generated task stop" }); + 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: "F" }); + const run = await m.startValidatorRun(root.id, "scheduled"); + await m.completeValidatorRun(run.id, "failed", "repair"); + const fix = await m.createGeneratedFixFeature(root.id, run.id, [], "repair"); + const task = await h.store().createTask({ description: "Generated fix task" }); + await m.linkFeatureToTask(fix.id, task.id); + + await h.store().archiveTask(task.id, { cleanup: false }); + + expect(await m.getFeature(root.id)).toMatchObject({ + loopState: "blocked", + implementationStopReason: "operator-intervention", + }); + expect(await m.getFeature(fix.id)).toMatchObject({ taskId: undefined }); + const stops = await h.layer().db.select().from(schema.project.missionLineageStops) + .where(sql`${schema.project.missionLineageStops.rootFeatureId} = ${root.id}`); + expect(stops).toMatchObject([{ reason: "operator-intervention", origin: "task-archive" }]); + }); + + 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" }); + 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: "F" }); + await m.transitionLoopState(root.id, "implementing"); + const run = await m.startValidatorRun(root.id, "scheduled"); + await m.completeValidatorRun(run.id, "failed", "repair"); + const fix = await m.createGeneratedFixFeature(root.id, run.id, [], "repair"); + await m.deleteFeature(fix.id); + expect(await m.getFeature(root.id)).toMatchObject({ loopState: "blocked", implementationStopReason: "operator-intervention", implementationAttemptCount: 1 }); + const stops = await h.layer().db.select().from(schema.project.missionLineageStops) + .where(sql`${schema.project.missionLineageStops.rootFeatureId} = ${root.id}`); + expect(stops).toHaveLength(1); + await m.updateMission(mission.id, { status: "blocked" }); + await expect(m.resumeMission(mission.id)).resolves.toMatchObject({ status: "active" }); + expect(await m.getFeature(root.id)).toMatchObject({ loopState: "needs_fix", implementationAttemptCount: 1, implementationStopReason: undefined }); + }); + it("allows startup recovery to move an interrupted validation back to implementing", async () => { const m = missions(); const mission = await m.createMission({ title: "Interrupted validation" }); diff --git a/packages/core/src/async-mission-store-queries.ts b/packages/core/src/async-mission-store-queries.ts index 62de492bd1..e4e54dff59 100644 --- a/packages/core/src/async-mission-store-queries.ts +++ b/packages/core/src/async-mission-store-queries.ts @@ -106,7 +106,7 @@ export type QueryHandle = AsyncDataLayer["db"] | DbTransaction; FNXC:MissionProjectIsolation 2026-07-14-21:35: Mission data lives in the shared PostgreSQL project schema, so every mission-owned insert and predicate must use the session's authoritative project partition even when an administrative connection bypasses row-level security. An unbound maintenance session is confined to the explicit legacy quarantine rather than becoming a cross-project reader. */ -function missionProjectId(): SQL { +export function missionProjectId(): SQL { return sql`COALESCE(NULLIF(current_setting('fusion.project_id', true), ''), '__legacy_unscoped__')`; } @@ -178,6 +178,9 @@ interface FeatureRow { loopState: string | null; implementationAttemptCount: number | null; validatorAttemptCount: number | null; + implementationStopReason: string | null; + implementationStoppedAt: string | null; + implementationStopOrigin: string | null; lastValidatorRunId: string | null; lastValidatorStatus: string | null; generatedFromFeatureId: string | null; @@ -335,6 +338,9 @@ const featureColumns = { loopState: schema.project.missionFeatures.loopState, implementationAttemptCount: schema.project.missionFeatures.implementationAttemptCount, validatorAttemptCount: schema.project.missionFeatures.validatorAttemptCount, + implementationStopReason: schema.project.missionFeatures.implementationStopReason, + implementationStoppedAt: schema.project.missionFeatures.implementationStoppedAt, + implementationStopOrigin: schema.project.missionFeatures.implementationStopOrigin, lastValidatorRunId: schema.project.missionFeatures.lastValidatorRunId, lastValidatorStatus: schema.project.missionFeatures.lastValidatorStatus, generatedFromFeatureId: schema.project.missionFeatures.generatedFromFeatureId, @@ -496,6 +502,9 @@ function rowToFeature(row: FeatureRow): MissionFeature { loopState: (row.loopState as FeatureLoopState) || "idle", implementationAttemptCount: row.implementationAttemptCount ?? 0, validatorAttemptCount: row.validatorAttemptCount ?? 0, + implementationStopReason: (row.implementationStopReason ?? undefined) as MissionFeature["implementationStopReason"], + implementationStoppedAt: row.implementationStoppedAt ?? undefined, + implementationStopOrigin: row.implementationStopOrigin ?? undefined, lastValidatorRunId: row.lastValidatorRunId ?? undefined, lastValidatorStatus: (row.lastValidatorStatus as ValidatorRunStatus) ?? undefined, generatedFromFeatureId: row.generatedFromFeatureId ?? undefined, @@ -941,6 +950,9 @@ export async function createFeature(handle: QueryHandle, feature: MissionFeature loopState: feature.loopState ?? "idle", implementationAttemptCount: feature.implementationAttemptCount ?? 0, validatorAttemptCount: feature.validatorAttemptCount ?? 0, + implementationStopReason: feature.implementationStopReason ?? null, + implementationStoppedAt: feature.implementationStoppedAt ?? null, + implementationStopOrigin: feature.implementationStopOrigin ?? null, lastValidatorRunId: feature.lastValidatorRunId ?? null, lastValidatorStatus: feature.lastValidatorStatus ?? null, generatedFromFeatureId: feature.generatedFromFeatureId ?? null, @@ -1036,6 +1048,9 @@ export async function updateFeature(handle: QueryHandle, feature: MissionFeature loopState: feature.loopState ?? "idle", implementationAttemptCount: feature.implementationAttemptCount ?? 0, validatorAttemptCount: feature.validatorAttemptCount ?? 0, + implementationStopReason: feature.implementationStopReason ?? null, + implementationStoppedAt: feature.implementationStoppedAt ?? null, + implementationStopOrigin: feature.implementationStopOrigin ?? null, lastValidatorRunId: feature.lastValidatorRunId ?? null, lastValidatorStatus: feature.lastValidatorStatus ?? null, generatedFromFeatureId: feature.generatedFromFeatureId ?? null, @@ -1056,6 +1071,74 @@ export async function deleteFeature(handle: QueryHandle, id: string): Promise 0; } +/** + * FNXC:MissionLineageBudget 2026-07-22-14:30: + * Removal of a generated remediation is explicit operator intervention for its + * canonical root. This transaction-scoped helper records that fact before any + * feature/task unlink or hierarchy cascade can erase the ancestry needed to + * resolve the root; its standalone stop row deliberately survives root removal. + */ +export async function recordGeneratedFixOperatorStop( + handle: QueryHandle, + feature: MissionFeature, + origin: "feature-delete" | "task-archive" | "task-delete", +): Promise { + if (!feature.generatedFromFeatureId) return false; + + const seen = new Set(); + let root = feature; + while (root.generatedFromFeatureId) { + if (seen.has(root.id)) throw new Error("MISSION_LINEAGE_UNRESOLVED: cyclic generated-fix lineage"); + seen.add(root.id); + const parent = await getFeature(handle, root.generatedFromFeatureId); + if (!parent) throw new Error("MISSION_LINEAGE_UNRESOLVED: missing generated-fix ancestor"); + root = parent; + } + if (seen.has(root.id)) throw new Error("MISSION_LINEAGE_UNRESOLVED: cyclic generated-fix lineage"); + + /* + FNXC:MissionLineageBudget 2026-08-03-00:00: + Deletion/archive must acquire the same project-scoped canonical-root lock as + generated-fix creation before recording its durable stop. This serializes an + intervention with remediation admission, so a waiting creator re-reads the + committed stop instead of minting a child from a stale no-stop observation. + */ + const rootLocked = await handle + .select({ id: schema.project.missionFeatures.id }) + .from(schema.project.missionFeatures) + .where(and( + missionProjectScope(schema.project.missionFeatures.projectId), + eq(schema.project.missionFeatures.id, root.id), + )) + .for("update"); + if (rootLocked.length !== 1) { + throw new Error("MISSION_LINEAGE_UNRESOLVED: canonical root disappeared"); + } + const lockedRoot = await getFeature(handle, root.id); + if (!lockedRoot) throw new Error("MISSION_LINEAGE_UNRESOLVED: canonical root disappeared"); + + const slice = await getSlice(handle, lockedRoot.sliceId); + const milestone = slice ? await getMilestone(handle, slice.milestoneId) : undefined; + const now = new Date().toISOString(); + await handle.insert(schema.project.missionLineageStops).values({ + projectId: missionProjectId(), + rootFeatureId: root.id, + missionId: milestone?.missionId ?? null, + reason: "operator-intervention", + stoppedAt: now, + origin, + }).onConflictDoNothing(); + await updateFeature(handle, { + ...lockedRoot, + loopState: "blocked", + implementationStopReason: "operator-intervention", + implementationStoppedAt: now, + implementationStopOrigin: origin, + updatedAt: now, + }); + return true; +} + /** Return a different feature already using the task, if one exists. */ export async function getConflictingFeatureByTaskId( handle: QueryHandle, @@ -1914,6 +1997,9 @@ export async function upsertFeature(handle: QueryHandle, feature: MissionFeature loopState: feature.loopState ?? "idle", implementationAttemptCount: feature.implementationAttemptCount ?? 0, validatorAttemptCount: feature.validatorAttemptCount ?? 0, + implementationStopReason: feature.implementationStopReason ?? null, + implementationStoppedAt: feature.implementationStoppedAt ?? null, + implementationStopOrigin: feature.implementationStopOrigin ?? null, lastValidatorRunId: feature.lastValidatorRunId ?? null, lastValidatorStatus: feature.lastValidatorStatus ?? null, generatedFromFeatureId: feature.generatedFromFeatureId ?? null, @@ -1934,6 +2020,9 @@ export async function upsertFeature(handle: QueryHandle, feature: MissionFeature loopState: sql`excluded.loop_state`, implementationAttemptCount: sql`excluded.implementation_attempt_count`, validatorAttemptCount: sql`excluded.validator_attempt_count`, + implementationStopReason: sql`excluded.implementation_stop_reason`, + implementationStoppedAt: sql`excluded.implementation_stopped_at`, + implementationStopOrigin: sql`excluded.implementation_stop_origin`, lastValidatorRunId: sql`excluded.last_validator_run_id`, lastValidatorStatus: sql`excluded.last_validator_status`, generatedFromFeatureId: sql`excluded.generated_from_feature_id`, diff --git a/packages/core/src/async-mission-store.ts b/packages/core/src/async-mission-store.ts index 4b891fa002..a0d5134927 100644 --- a/packages/core/src/async-mission-store.ts +++ b/packages/core/src/async-mission-store.ts @@ -67,6 +67,7 @@ export * from "./async-mission-store-queries.js"; import { DEFAULT_IMPLEMENTATION_RETRY_BUDGET, missionBranchStrategyDefaults, + missionProjectId, QueryHandle, AssertionRow, assertionColumns, @@ -150,6 +151,7 @@ import { setTaskMissionLinkage, clearTaskMissionLinkage, listFailedTaskIds, + recordGeneratedFixOperatorStop, } from "./async-mission-store-queries.js"; // ════════════════════════════════════════════════════════════════════ @@ -203,6 +205,22 @@ export class TerminalTaskReconciliationError extends Error { } } +/** Typed no-mint result used by the execution loop instead of parsing errors. */ +export class MissionRemediationStoppedError extends Error { + constructor(public readonly reason: "budget-exhausted" | "operator-intervention" | "legacy-unknown-stop") { + super(`MISSION_REMEDIATION_STOPPED: ${reason}`); + this.name = "MissionRemediationStoppedError"; + } +} + +/** Stable mission-wide conflict payload for the sole explicit lineage-stop resume seam. */ +export class MissionResumeConflictError extends Error { + constructor(public readonly blockers: Array<{ id: string; reason: string }>) { + super("Mission resume is blocked by non-resumable lineage stops"); + this.name = "MissionResumeConflictError"; + } +} + export class AsyncMissionStore extends EventEmitter { private idSequence = 0; private readonly milestonesMissingStructuredAssertions = new Set(); @@ -599,10 +617,62 @@ export class AsyncMissionStore extends EventEmitter { async deleteMission(id: string): Promise { const mission = await getMission(this.db, id); if (!mission) throw new Error(`Mission ${id} not found`); - await deleteMission(this.db, id); + const features: MissionFeature[] = []; + for (const milestone of await listMilestones(this.db, id)) { + for (const slice of await listSlices(this.db, milestone.id)) features.push(...await listFeatures(this.db, slice.id)); + } + await this.layer.transactionImmediate(async (tx) => { + /* FNXC:MissionLineageBudget 2026-07-22-14:55: Mission-level cascades retain generated-fix stops even though every hierarchy row is about to disappear. */ + for (const feature of features) { + if (feature.generatedFromFeatureId) await recordGeneratedFixOperatorStop(tx, feature, "feature-delete"); + } + await deleteMission(tx, id); + }); this.emit("mission:deleted", id); } + /** + * FNXC:MissionLineageBudget 2026-07-22-12:00: + * Resume is the only seam that clears operator intervention. Classify every + * stopped root before mutating so a budget or unknown legacy root cannot + * partially reactivate a mission. + */ + async resumeMission(id: string): Promise { + const result = await this.layer.transactionImmediate(async (tx) => { + const mission = await getMission(tx, id); + if (!mission) throw new Error(`Mission ${id} not found`); + const allFeatures = await listAllFeatures(tx); + const featureMission = new Map(); + for (const feature of allFeatures) { + const slice = await getSlice(tx, feature.sliceId); + const milestone = slice ? await getMilestone(tx, slice.milestoneId) : undefined; + if (milestone) featureMission.set(feature.id, milestone.missionId); + } + 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); + } + for (const root of roots) { + if (root.implementationStopReason === "operator-intervention" || stopIds.has(root.id)) { + await updateFeature(tx, { ...root, loopState: "needs_fix", implementationStopReason: undefined, implementationStoppedAt: undefined, implementationStopOrigin: undefined, updatedAt: new Date().toISOString() }); + } + } + if (stops.length > 0) await tx.delete(schema.project.missionLineageStops).where(and(eq(schema.project.missionLineageStops.projectId, missionProjectId()), eq(schema.project.missionLineageStops.missionId, id))); + const updated = { ...mission, status: "active" as MissionStatus, updatedAt: new Date().toISOString() }; + await updateMission(tx, updated); + return updated; + }); + this.emit("mission:updated", result); + return result; + } + async updateMissionInterviewState(id: string, state: InterviewState): Promise { return this.updateMission(id, { interviewState: state }); } @@ -767,13 +837,19 @@ export class AsyncMissionStore extends EventEmitter { `Milestone ${id} has features linked to live tasks: ${blockingLinks.map((link) => `${link.featureId}->${link.taskId}`).join(", ")}; pass force to delete anyway`, ); } - if (force) { - for (const link of blockingLinks) { - await unlinkFeatureFromTaskId(this.db, link.featureId); - await clearTaskMissionLinkage(this.db, link.taskId); + await this.layer.transactionImmediate(async (tx) => { + /* FNXC:MissionLineageBudget 2026-07-22-14:50: Cascade deletion records every generated descendant's root stop before FK cascades erase lineage. */ + for (const feature of features) { + if (feature.generatedFromFeatureId) await recordGeneratedFixOperatorStop(tx, feature, "feature-delete"); } - } - await deleteMilestone(this.db, id); + if (force) { + for (const link of blockingLinks) { + await unlinkFeatureFromTaskId(tx, link.featureId); + await clearTaskMissionLinkage(tx, link.taskId); + } + } + await deleteMilestone(tx, id); + }); this.emit("milestone:deleted", id); await this.recomputeMissionStatus(missionId); } @@ -863,13 +939,19 @@ export class AsyncMissionStore extends EventEmitter { `Slice ${id} has features linked to live tasks: ${blockingLinks.map((link) => `${link.featureId}->${link.taskId}`).join(", ")}; pass force to delete anyway`, ); } - if (force) { - for (const link of blockingLinks) { - await unlinkFeatureFromTaskId(this.db, link.featureId); - await clearTaskMissionLinkage(this.db, link.taskId); + await this.layer.transactionImmediate(async (tx) => { + /* FNXC:MissionLineageBudget 2026-07-22-14:50: Cascade deletion records every generated descendant's root stop before FK cascades erase lineage. */ + for (const feature of features) { + if (feature.generatedFromFeatureId) await recordGeneratedFixOperatorStop(tx, feature, "feature-delete"); } - } - await deleteSlice(this.db, id); + if (force) { + for (const link of blockingLinks) { + await unlinkFeatureFromTaskId(tx, link.featureId); + await clearTaskMissionLinkage(tx, link.taskId); + } + } + await deleteSlice(tx, id); + }); this.emit("slice:deleted", id); await this.recomputeMilestoneStatus(milestoneId); } @@ -1001,15 +1083,24 @@ export class AsyncMissionStore extends EventEmitter { const sliceId = feature.sliceId; const slice = await getSlice(this.db, sliceId); const milestoneId = slice?.milestoneId; - if (force && feature.taskId) { - await unlinkFeatureFromTaskId(this.db, id); - await clearTaskMissionLinkage(this.db, feature.taskId); - } - if (milestoneId) { - const managed = (await listContractAssertions(this.db, milestoneId)).find((a) => a.sourceFeatureId === feature.id); - if (managed) await this.deleteContractAssertion(managed.id); - } - await deleteFeature(this.db, id); + await this.layer.transactionImmediate(async (tx) => { + /* + FNXC:MissionLineageBudget 2026-07-22-14:45: + Feature removal and its durable intervention record share one transaction. + Force-unlinking and assertion cleanup therefore cannot leave a deletion + committed after the generated-fix ancestry has been discarded. + */ + if (feature.generatedFromFeatureId) await recordGeneratedFixOperatorStop(tx, feature, "feature-delete"); + if (force && feature.taskId) { + await unlinkFeatureFromTaskId(tx, id); + await clearTaskMissionLinkage(tx, feature.taskId); + } + if (milestoneId) { + const managed = (await listContractAssertions(tx, milestoneId)).find((a) => a.sourceFeatureId === feature.id); + if (managed) await deleteContractAssertion(tx, managed.id); + } + await deleteFeature(tx, id); + }); this.emit("feature:deleted", id); await this.recomputeSliceStatus(sliceId); } @@ -1453,6 +1544,31 @@ export class AsyncMissionStore extends EventEmitter { return ids.map((id) => featuresById.get(id)).find((feature) => feature && feature.status !== "done" && feature.status !== "blocked"); } + /** + * FNXC:MissionLineageBudget 2026-07-22-12:00: + * A generated fix is never a new budget owner. Resolve its parent chain while + * the caller transaction is open; missing or cyclic evidence fails closed. + */ + private async resolveFixRoot(handle: QueryHandle, feature: MissionFeature): Promise { + const seen = new Set(); + let current = feature; + while (current.generatedFromFeatureId) { + if (seen.has(current.id)) throw new Error("MISSION_LINEAGE_UNRESOLVED: cyclic generated-fix lineage"); + seen.add(current.id); + const parent = await getFeature(handle, current.generatedFromFeatureId); + if (!parent) throw new Error("MISSION_LINEAGE_UNRESOLVED: missing generated-fix ancestor"); + current = parent; + } + if (seen.has(current.id)) throw new Error("MISSION_LINEAGE_UNRESOLVED: cyclic generated-fix lineage"); + return current; + } + + private async getRootStop(handle: QueryHandle, rootFeatureId: string) { + const rows = await handle.select().from(schema.project.missionLineageStops) + .where(and(eq(schema.project.missionLineageStops.projectId, missionProjectId()), eq(schema.project.missionLineageStops.rootFeatureId, rootFeatureId))); + return rows[0]; + } + async createGeneratedFixFeature( sourceFeatureId: string, runId: string, @@ -1476,15 +1592,34 @@ export class AsyncMissionStore extends EventEmitter { | { kind: "existing"; feature: MissionFeature } | { kind: "created"; feature: MissionFeature } | { kind: "exhausted" } + | { kind: "stopped"; reason: string } > => { const locked = await tx .select({ id: schema.project.missionFeatures.id }) .from(schema.project.missionFeatures) - .where(eq(schema.project.missionFeatures.id, sourceFeatureId)) + .where(and( + eq(schema.project.missionFeatures.projectId, missionProjectId()), + eq(schema.project.missionFeatures.id, sourceFeatureId), + )) .for("update"); if (locked.length === 0) throw new Error(`Feature ${sourceFeatureId} not found`); const source = await getFeature(tx, sourceFeatureId); if (!source) throw new Error(`Feature ${sourceFeatureId} not found`); + const root = await this.resolveFixRoot(tx, source); + // Lock the canonical owner, not the generated child that happened to fail. + const rootLocked = await tx.select({ id: schema.project.missionFeatures.id }).from(schema.project.missionFeatures) + .where(and( + eq(schema.project.missionFeatures.projectId, missionProjectId()), + eq(schema.project.missionFeatures.id, root.id), + )).for("update"); + if (rootLocked.length !== 1) throw new Error("MISSION_LINEAGE_UNRESOLVED: canonical root disappeared"); + const lockedRoot = await getFeature(tx, root.id); + if (!lockedRoot) throw new Error("MISSION_LINEAGE_UNRESOLVED: canonical root disappeared"); + const durableStop = await this.getRootStop(tx, root.id); + if (durableStop) return { kind: "stopped", reason: durableStop.reason }; + if (lockedRoot.loopState === "blocked") { + return { kind: "stopped", reason: lockedRoot.implementationStopReason ?? "legacy-unknown-stop" }; + } const exactId = await findFixFeatureId(tx, sourceFeatureId, runId); if (exactId) { @@ -1496,8 +1631,15 @@ export class AsyncMissionStore extends EventEmitter { const open = openFeatures.find((candidate) => candidate.status !== "done" && candidate.status !== "blocked"); if (open) return { kind: "existing", feature: open }; - if ((source.implementationAttemptCount ?? 0) >= DEFAULT_IMPLEMENTATION_RETRY_BUDGET) { - await updateFeature(tx, { ...source, loopState: "blocked", updatedAt: now }); + if ((lockedRoot.implementationAttemptCount ?? 0) >= DEFAULT_IMPLEMENTATION_RETRY_BUDGET) { + await updateFeature(tx, { + ...lockedRoot, + loopState: "blocked", + implementationStopReason: "budget-exhausted", + implementationStoppedAt: now, + implementationStopOrigin: "retry-budget", + updatedAt: now, + }); return { kind: "exhausted" }; } @@ -1526,18 +1668,26 @@ export class AsyncMissionStore extends EventEmitter { updatedAt: now, }) .where(and( - eq(schema.project.missionFeatures.id, sourceFeatureId), + eq(schema.project.missionFeatures.projectId, missionProjectId()), + eq(schema.project.missionFeatures.id, root.id), sql`${schema.project.missionFeatures.implementationAttemptCount} < ${DEFAULT_IMPLEMENTATION_RETRY_BUDGET}`, )) .returning({ id: schema.project.missionFeatures.id }); - if (bumped.length !== 1) throw new Error(`Feature ${sourceFeatureId} retry budget changed while creating its generated fix`); + if (bumped.length !== 1) throw new Error(`Feature ${root.id} retry budget changed while creating its generated fix`); return { kind: "created", feature }; }); if (outcome.kind === "existing") return outcome.feature; if (outcome.kind === "exhausted") { const updatedSource = await getFeature(this.db, sourceFeatureId); if (updatedSource) this.emit("feature:updated", updatedSource); - throw new Error(`Feature ${sourceFeatureId} has exhausted its retry budget (${DEFAULT_IMPLEMENTATION_RETRY_BUDGET} attempts). Transitioning to 'blocked' state.`); + throw new MissionRemediationStoppedError("budget-exhausted"); + } + if (outcome.kind === "stopped") { + throw new MissionRemediationStoppedError( + outcome.reason === "budget-exhausted" || outcome.reason === "operator-intervention" + ? outcome.reason + : "legacy-unknown-stop", + ); } const feature = outcome.feature; this.emit("feature:created", feature); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 587b7b327a..d76a90c9c7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1744,7 +1744,7 @@ export type { } from "./mission-types.js"; export { MissionStore } from "./mission-store.js"; export type { MissionStoreEvents, MissionSummary } from "./mission-store.js"; -export { AsyncMissionStore, TerminalTaskReconciliationError } from "./async-mission-store.js"; +export { AsyncMissionStore, MissionRemediationStoppedError, MissionResumeConflictError, TerminalTaskReconciliationError } from "./async-mission-store.js"; export type { TerminalTaskReconciliationErrorCode } from "./async-mission-store.js"; export { AsyncIdeationStore } from "./async-ideation-store.js"; export { IDEATION_SESSION_STATUSES, IDEATION_CANDIDATE_ORIGINS } from "./ideation-types.js"; diff --git a/packages/core/src/mission-store.ts b/packages/core/src/mission-store.ts index 80d3d1d511..9f4f3a0aa9 100644 --- a/packages/core/src/mission-store.ts +++ b/packages/core/src/mission-store.ts @@ -1370,6 +1370,16 @@ export class MissionStore extends EventEmitter { return updated; } + /** + * FNXC:MissionLineageBudget 2026-07-22-12:00: + * The production PostgreSQL store owns durable lineage-stop classification. + * Keep the legacy synchronous facade API-compatible for callers that inject it + * in isolated tests; it has no PostgreSQL tombstone backend. + */ + resumeMission(id: string): Mission { + return this.updateMission(id, { status: "active" }); + } + /** * Delete a mission. * Cascades to delete all milestones, slices, and features. diff --git a/packages/core/src/mission-types.ts b/packages/core/src/mission-types.ts index af23305036..e5c6fcc8be 100644 --- a/packages/core/src/mission-types.ts +++ b/packages/core/src/mission-types.ts @@ -409,6 +409,8 @@ export interface ResearchFeatureProvenance { sourceUrls: string[]; } +export type ImplementationStopReason = "budget-exhausted" | "operator-intervention"; + export interface MissionFeature { /** Unique identifier (e.g., "F-J6K9AB-G7H3") */ id: string; @@ -436,6 +438,11 @@ export interface MissionFeature { implementationAttemptCount?: number; /** Number of validation attempts made for this feature */ validatorAttemptCount?: number; + /** Why this root remediation loop was terminally stopped; absent legacy values fail closed. */ + implementationStopReason?: ImplementationStopReason; + /** Timestamp and authority that recorded the terminal stop. */ + implementationStoppedAt?: string; + implementationStopOrigin?: string; /** ID of the last validator run for this feature */ lastValidatorRunId?: string; /** Status of the last validator run (passed, failed, blocked, error) */ diff --git a/packages/core/src/postgres/migrations/0035_fn_8543_mission_lineage_stop.sql b/packages/core/src/postgres/migrations/0035_fn_8543_mission_lineage_stop.sql new file mode 100644 index 0000000000..8dfe94e4b6 --- /dev/null +++ b/packages/core/src/postgres/migrations/0035_fn_8543_mission_lineage_stop.sql @@ -0,0 +1,31 @@ +-- FNXC:MissionLineageBudget 2026-07-22-12:00: +-- Root budget ownership and operator intervention must survive cascading feature deletion. +ALTER TABLE project.mission_features ADD COLUMN IF NOT EXISTS implementation_stop_reason text; +ALTER TABLE project.mission_features ADD COLUMN IF NOT EXISTS implementation_stopped_at text; +ALTER TABLE project.mission_features ADD COLUMN IF NOT EXISTS implementation_stop_origin text; + +CREATE TABLE IF NOT EXISTS project.mission_lineage_stops ( + project_id text NOT NULL DEFAULT COALESCE(NULLIF(current_setting('fusion.project_id', true), ''), '__legacy_unscoped__'), + root_feature_id text NOT NULL, + mission_id text, + reason text NOT NULL, + stopped_at text NOT NULL, + origin text NOT NULL, + PRIMARY KEY (project_id, root_feature_id) +); +CREATE INDEX IF NOT EXISTS idx_mission_lineage_stops_mission_id + ON project.mission_lineage_stops (project_id, mission_id); + +-- FNXC:MissionLineageBudget 2026-08-03-00:00: +-- Durable remediation stops are project-owned evidence. Apply the full shared +-- schema isolation contract so an admin-bypass session cannot cross partitions. +ALTER TABLE project.mission_lineage_stops ENABLE ROW LEVEL SECURITY; +ALTER TABLE project.mission_lineage_stops FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS fusion_project_isolation ON project.mission_lineage_stops; +CREATE POLICY fusion_project_isolation ON project.mission_lineage_stops + USING (current_setting('fusion.project_bypass', true) = 'on' OR project_id = current_setting('fusion.project_id', true)) + WITH CHECK (current_setting('fusion.project_bypass', true) = 'on' OR project_id = current_setting('fusion.project_id', true)); +DROP TRIGGER IF EXISTS fusion_assign_project_id ON project.mission_lineage_stops; +CREATE TRIGGER fusion_assign_project_id + BEFORE INSERT OR UPDATE OF project_id ON project.mission_lineage_stops + FOR EACH ROW EXECUTE FUNCTION project.fusion_assign_project_id(); diff --git a/packages/core/src/postgres/schema-applier.ts b/packages/core/src/postgres/schema-applier.ts index 0ac2c31637..46bdd304a9 100644 --- a/packages/core/src/postgres/schema-applier.ts +++ b/packages/core/src/postgres/schema-applier.ts @@ -50,7 +50,7 @@ Advance the PostgreSQL schema ceiling for the durable wedge episode column. The forward migration must run before TaskStore writes the new field on fresh and upgraded databases. */ -export const SCHEMA_BASELINE_VERSION = "0034"; +export const SCHEMA_BASELINE_VERSION = "0035"; /** FNXC:SymbolLock 2026-07-31-10:00: upgrades need durable task declarations before admission resolves symbols. */ export const TASK_DECLARED_SYMBOLS_VERSION = "0028"; const INITIAL_SCHEMA_VERSION = "0000"; @@ -148,6 +148,8 @@ export const LEGACY_ADOPTION_DRAINED_MARKER_RUNTIME_GRANTS_VERSION = "0032"; export const TASK_WEDGE_NOTIFICATION_VERSION = "0033"; /** FNXC:MissionValidation 2026-07-23-14:30: provenance-safe milestone criteria require an explicit upgrade. */ export const MILESTONE_ASSERTION_PROVENANCE_VERSION = "0034"; +/** FNXC:MissionLineageBudget 2026-07-22-12:00: migration is explicit because upgraded clusters need durable root stop tombstones. */ +export const MISSION_LINEAGE_STOP_VERSION = "0035"; /** SECURITY DEFINER helper that only inserts LEGACY_ADOPTION_DRAINED_MARKER. */ export const LEGACY_ADOPTION_DRAINED_MARKER_FUNCTION = "fusion_mark_legacy_adoption_drained"; @@ -356,6 +358,7 @@ const MILESTONE_ASSERTION_PROVENANCE_MIGRATION_PATH = join( MIGRATIONS_DIR, "0034_milestone_assertion_provenance.sql", ); +const MISSION_LINEAGE_STOP_MIGRATION_PATH = join(MIGRATIONS_DIR, "0035_fn_8543_mission_lineage_stop.sql"); /** * Ensure the migration bookkeeping table exists. Lives in the public schema so @@ -460,6 +463,7 @@ export async function applySchemaBaseline( ); const taskWedgeNotificationAlreadyApplied = applied.includes(TASK_WEDGE_NOTIFICATION_VERSION); const milestoneAssertionProvenanceAlreadyApplied = applied.includes(MILESTONE_ASSERTION_PROVENANCE_VERSION); + const missionLineageStopAlreadyApplied = applied.includes(MISSION_LINEAGE_STOP_VERSION); assertBinaryNotOlderThanDatabase(applied); let schemaChanged = false; @@ -961,6 +965,18 @@ export async function applySchemaBaseline( schemaChanged = true; } + /* + FNXC:MissionLineageBudget 2026-07-22-12:00: + PostgreSQL migrations are deliberately registered, never discovered. Apply the + durable tombstone before any store can make a generated-fix decision. + */ + if (!missionLineageStopAlreadyApplied) { + const migrationSql = await readFile(MISSION_LINEAGE_STOP_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(migrationSql)); + await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${MISSION_LINEAGE_STOP_VERSION}) ON CONFLICT (version) DO NOTHING`); + schemaChanged = true; + } + return { applied: schemaChanged, pluginHooksRun: pluginHooks.length }; }); } diff --git a/packages/core/src/postgres/schema/project.ts b/packages/core/src/postgres/schema/project.ts index 2df9213bb6..fd1c656720 100644 --- a/packages/core/src/postgres/schema/project.ts +++ b/packages/core/src/postgres/schema/project.ts @@ -1467,6 +1467,10 @@ export const missionFeatures = projectSchema.table("mission_features", { loopState: text("loop_state").notNull().default("idle"), implementationAttemptCount: integer("implementation_attempt_count").notNull().default(0), validatorAttemptCount: integer("validator_attempt_count").notNull().default(0), + // FNXC:MissionLineageBudget 2026-07-22-12:00: Stops are explicit so legacy blocked roots never receive an inferred operator resume. + implementationStopReason: text("implementation_stop_reason"), + implementationStoppedAt: text("implementation_stopped_at"), + implementationStopOrigin: text("implementation_stop_origin"), lastValidatorRunId: text("last_validator_run_id"), lastValidatorStatus: text("last_validator_status"), generatedFromFeatureId: text("generated_from_feature_id"), @@ -2060,6 +2064,23 @@ export const missionFixFeatureLineage = projectSchema.table("mission_fix_feature index("idxFixLineageRunId").on(t.runId), ]); +/* +FNXC:MissionLineageBudget 2026-07-22-12:00: +A root identity can outlive its hierarchy. Keep intervention evidence outside +cascading mission rows so deleting a generated fix cannot silently authorize a sibling. +*/ +export const missionLineageStops = projectSchema.table("mission_lineage_stops", { + projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`), + rootFeatureId: text("root_feature_id").notNull(), + missionId: text("mission_id"), + reason: text("reason").notNull(), + stoppedAt: text("stopped_at").notNull(), + origin: text("origin").notNull(), +}, (t) => [ + primaryKey({ columns: [t.projectId, t.rootFeatureId] }), + index("idxMissionLineageStopsMissionId").on(t.projectId, t.missionId), +]); + export const verificationCache = projectSchema.table("verification_cache", { treeSha: text("tree_sha").notNull(), testCommand: text("test_command").notNull().default(""), diff --git a/packages/core/src/task-store/archive-lifecycle-2.ts b/packages/core/src/task-store/archive-lifecycle-2.ts index 33ed0ace01..66d7db3472 100644 --- a/packages/core/src/task-store/archive-lifecycle-2.ts +++ b/packages/core/src/task-store/archive-lifecycle-2.ts @@ -7,7 +7,7 @@ * instance as its first parameter and performs byte-identical work. */ import {TaskStore, storeLog} from "../store.js"; -import {getFeatureByTaskId as getMissionFeatureByTaskId, unlinkFeatureFromTaskId as unlinkMissionFeatureFromTaskId} from "../async-mission-store-queries.js"; +import {getFeatureByTaskId as getMissionFeatureByTaskId, unlinkFeatureFromTaskId as unlinkMissionFeatureFromTaskId, recordGeneratedFixOperatorStop} from "../async-mission-store-queries.js"; import {TaskHasLineageChildrenError, TaskSelfDeleteError} from "./errors.js"; import {mkdir, writeFile} from "node:fs/promises"; import {join} from "node:path"; @@ -151,6 +151,12 @@ export async function deleteTaskBackendImpl(store: TaskStore, id: string, option */ const linkedFeature = await getMissionFeatureByTaskId(tx, id); if (linkedFeature) { + /* + FNXC:MissionLineageBudget 2026-07-22-15:00: + Generated remediation task removal is operator intervention, recorded + before clearing the feature task edge in this same soft-delete transaction. + */ + await recordGeneratedFixOperatorStop(tx, linkedFeature, "task-delete"); await unlinkMissionFeatureFromTaskId(tx, linkedFeature.id); } // Soft-delete the task row. @@ -248,6 +254,13 @@ export async function archiveTaskBackendImpl(store: TaskStore, id: string, optio result = await archiveParentTaskWithLineageGate(layer, id, entry, { removeLineageReferences: removeLineageRefs, now: archivedAt, + beforeArchive: async (tx) => { + const linkedFeature = await getMissionFeatureByTaskId(tx, id); + if (linkedFeature) { + await recordGeneratedFixOperatorStop(tx, linkedFeature, "task-archive"); + await unlinkMissionFeatureFromTaskId(tx, linkedFeature.id); + } + }, }); } catch (error) { if (preparedWorkspace) await releasePreparedWorkspaceArchiveDisposal(preparedWorkspace); diff --git a/packages/core/src/task-store/async-archive-lineage.ts b/packages/core/src/task-store/async-archive-lineage.ts index 444f23dd17..621b766f3e 100644 --- a/packages/core/src/task-store/async-archive-lineage.ts +++ b/packages/core/src/task-store/async-archive-lineage.ts @@ -256,7 +256,7 @@ export async function archiveParentTaskWithLineageGate( layer: AsyncDataLayer, taskId: string, entry: ArchivedTaskEntry, - options: { removeLineageReferences?: boolean; now?: string } = {}, + options: { removeLineageReferences?: boolean; now?: string; beforeArchive?: (tx: DbTransaction) => Promise } = {}, ): Promise<{ archived: true } | { archived: false; liveChildIds: string[] }> { const now = options.now ?? new Date().toISOString(); @@ -272,6 +272,14 @@ export async function archiveParentTaskWithLineageGate( await removeLineageReferences(tx, taskId, liveChildIds, now, layer.projectId); } + /* + FNXC:MissionLineageBudget 2026-07-22-15:00: + Cross-store intervention recording must happen after the lineage gate but + before archival clears the task from live mission recovery. It is part of + this transaction, so an archive rollback cannot leave a phantom stop. + */ + await options.beforeArchive?.(tx); + // 3. Archive snapshot to cold storage (VAL-CROSS-015 — preserves for restore). // FNXC:MultiProjectIsolation 2026-07-12: stamped with the bound project. await upsertArchivedTaskEntry(tx, entry, layer.projectId); diff --git a/packages/dashboard/src/mission-routes.ts b/packages/dashboard/src/mission-routes.ts index 7fd8a5ad83..788d41624d 100644 --- a/packages/dashboard/src/mission-routes.ts +++ b/packages/dashboard/src/mission-routes.ts @@ -19,6 +19,7 @@ import { resolvePlanningSettingsModel, AgentStore, THINKING_LEVELS, + MissionResumeConflictError, TerminalTaskReconciliationError, } from "@fusion/core"; import type { Goal, Settings, ThinkingLevel } from "@fusion/core"; @@ -2960,7 +2961,18 @@ export function createMissionRouter( throw badRequest("Mission is not paused (status must be 'blocked' to resume)"); } - await missionStore.updateMission(missionId, { status: "active" }, { actor: DASHBOARD_MISSION_ACTOR }); +// FNXC:MissionLineageBudget 2026-07-22-15:45: resumeMission performs the all-or-nothing root-stop classification; generic activation must not clear a lineage stop. + try { + await missionStore.resumeMission(missionId); + } catch (error) { + if (error instanceof MissionResumeConflictError) { + throw conflict("Mission has non-resumable lineage stops", { + code: "MISSION_RESUME_CONFLICT", + blockers: error.blockers, + }); + } + throw error; + } // Re-engage autopilot if enabled and autopilot instance is available. // The autopilot may have been stopped or the mission unwatched during diff --git a/packages/engine/src/mission-execution-loop.ts b/packages/engine/src/mission-execution-loop.ts index de7ea62b39..5fc784b2bf 100644 --- a/packages/engine/src/mission-execution-loop.ts +++ b/packages/engine/src/mission-execution-loop.ts @@ -25,7 +25,7 @@ import type { Mission, ValidationDiagnostics, } from "@fusion/core"; -import { normalizeMissionAssertionType, normalizeValidationDiagnostics, renderValidationFailureDescription } from "@fusion/core"; +import { MissionRemediationStoppedError, normalizeMissionAssertionType, normalizeValidationDiagnostics, renderValidationFailureDescription } from "@fusion/core"; import { GitCheckoutMaterializer, type CheckoutMaterializer, type VerificationOutcome } from "./mission-verification.js"; import { createFnAgent, promptWithFallback, type AgentResult } from "./pi.js"; import { mergeEffectiveSettings } from "./effective-settings.js"; @@ -1747,14 +1747,26 @@ ${taskContext ? `\n\nImplementation context:\n${taskContext}` : ""}`; }); } catch (fixErr) { const message = fixErr instanceof Error ? fixErr.message : String(fixErr); - if (message.includes("retry budget exhausted") || message.includes("exhausted its retry budget")) { - loopLog.warn(`Feature ${featureId} retry budget exhausted; marking as blocked`); - // completeValidatorRun already handles the blocked transition when budget is exhausted - terminalStatus = "blocked"; - await this.logFeatureMissionEvent(featureId, "error", "retry_budget_exhausted", `Feature ${featureId} exhausted its retry budget`, { - runId: runId ?? null, - }); - this.emit("validation:budget_exhausted", { featureId, runId }); + if (fixErr instanceof MissionRemediationStoppedError) { + /* + FNXC:MissionLineageBudget 2026-07-22-15:15: + The root-budget store result is typed: every durable stop prevents + further triage, while only exhaustion enters the Blocked Handoff. + Never infer lifecycle state by matching implementation error prose. + */ + if (fixErr.reason === "budget-exhausted") { + loopLog.warn(`Feature ${featureId} retry budget exhausted; marking as blocked`); + terminalStatus = "blocked"; + await this.logFeatureMissionEvent(featureId, "error", "retry_budget_exhausted", `Feature ${featureId} exhausted its retry budget`, { + runId: runId ?? null, + }); + this.emit("validation:budget_exhausted", { featureId, runId }); + } else { + await this.logFeatureMissionEvent(featureId, "warning", "remediation_stopped_by_operator", "Validation remediation is stopped pending explicit mission resume.", { + runId: runId ?? null, + reason: fixErr.reason, + }); + } } else { loopLog.error(`Error creating fix feature for ${featureId}:`, message); // R16 — a swallowed Fix-Feature creation error is durably recorded.