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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-23 16:10:20 -07:00
parent 0c085bf444
commit dfb9ca6630
15 changed files with 495 additions and 45 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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" });

View File

@@ -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<string> {
export function missionProjectId(): SQL<string> {
return sql<string>`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<bo
return result.length > 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<boolean> {
if (!feature.generatedFromFeatureId) return false;
const seen = new Set<string>();
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`,

View File

@@ -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<MissionStoreEvents> {
private idSequence = 0;
private readonly milestonesMissingStructuredAssertions = new Set<string>();
@@ -599,10 +617,62 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
async deleteMission(id: string): Promise<void> {
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<Mission> {
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<string, string>();
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<Mission> {
return this.updateMission(id, { interviewState: state });
}
@@ -767,13 +837,19 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
`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<MissionStoreEvents> {
`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<MissionStoreEvents> {
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<MissionStoreEvents> {
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<MissionFeature> {
const seen = new Set<string>();
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<MissionStoreEvents> {
| { 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<MissionStoreEvents> {
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<MissionStoreEvents> {
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);

View File

@@ -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";

View File

@@ -1370,6 +1370,16 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
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.

View File

@@ -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) */

View File

@@ -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();

View File

@@ -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 };
});
}

View File

@@ -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(""),

View File

@@ -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);

View File

@@ -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<void> } = {},
): 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);

View File

@@ -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

View File

@@ -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.