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:
7
.changeset/fn-8543-bounded-fix-lineage.md
Normal file
7
.changeset/fn-8543-bounded-fix-lineage.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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();
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(""),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user