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
|
### Phase 6: Blocked Handoff
|
||||||
|
|
||||||
@@ -601,6 +601,8 @@ A feature transitions to `blocked` when:
|
|||||||
- Autopilot stops advancing the slice containing the blocked feature
|
- Autopilot stops advancing the slice containing the blocked feature
|
||||||
- `MilestoneValidationRollup.state` reflects `blocked` assertions
|
- `MilestoneValidationRollup.state` reflects `blocked` assertions
|
||||||
- The feature remains in `blocked` state until operator intervention
|
- 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.
|
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");
|
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 () => {
|
it("allows startup recovery to move an interrupted validation back to implementing", async () => {
|
||||||
const m = missions();
|
const m = missions();
|
||||||
const mission = await m.createMission({ title: "Interrupted validation" });
|
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:
|
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.
|
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__')`;
|
return sql<string>`COALESCE(NULLIF(current_setting('fusion.project_id', true), ''), '__legacy_unscoped__')`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +178,9 @@ interface FeatureRow {
|
|||||||
loopState: string | null;
|
loopState: string | null;
|
||||||
implementationAttemptCount: number | null;
|
implementationAttemptCount: number | null;
|
||||||
validatorAttemptCount: number | null;
|
validatorAttemptCount: number | null;
|
||||||
|
implementationStopReason: string | null;
|
||||||
|
implementationStoppedAt: string | null;
|
||||||
|
implementationStopOrigin: string | null;
|
||||||
lastValidatorRunId: string | null;
|
lastValidatorRunId: string | null;
|
||||||
lastValidatorStatus: string | null;
|
lastValidatorStatus: string | null;
|
||||||
generatedFromFeatureId: string | null;
|
generatedFromFeatureId: string | null;
|
||||||
@@ -335,6 +338,9 @@ const featureColumns = {
|
|||||||
loopState: schema.project.missionFeatures.loopState,
|
loopState: schema.project.missionFeatures.loopState,
|
||||||
implementationAttemptCount: schema.project.missionFeatures.implementationAttemptCount,
|
implementationAttemptCount: schema.project.missionFeatures.implementationAttemptCount,
|
||||||
validatorAttemptCount: schema.project.missionFeatures.validatorAttemptCount,
|
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,
|
lastValidatorRunId: schema.project.missionFeatures.lastValidatorRunId,
|
||||||
lastValidatorStatus: schema.project.missionFeatures.lastValidatorStatus,
|
lastValidatorStatus: schema.project.missionFeatures.lastValidatorStatus,
|
||||||
generatedFromFeatureId: schema.project.missionFeatures.generatedFromFeatureId,
|
generatedFromFeatureId: schema.project.missionFeatures.generatedFromFeatureId,
|
||||||
@@ -496,6 +502,9 @@ function rowToFeature(row: FeatureRow): MissionFeature {
|
|||||||
loopState: (row.loopState as FeatureLoopState) || "idle",
|
loopState: (row.loopState as FeatureLoopState) || "idle",
|
||||||
implementationAttemptCount: row.implementationAttemptCount ?? 0,
|
implementationAttemptCount: row.implementationAttemptCount ?? 0,
|
||||||
validatorAttemptCount: row.validatorAttemptCount ?? 0,
|
validatorAttemptCount: row.validatorAttemptCount ?? 0,
|
||||||
|
implementationStopReason: (row.implementationStopReason ?? undefined) as MissionFeature["implementationStopReason"],
|
||||||
|
implementationStoppedAt: row.implementationStoppedAt ?? undefined,
|
||||||
|
implementationStopOrigin: row.implementationStopOrigin ?? undefined,
|
||||||
lastValidatorRunId: row.lastValidatorRunId ?? undefined,
|
lastValidatorRunId: row.lastValidatorRunId ?? undefined,
|
||||||
lastValidatorStatus: (row.lastValidatorStatus as ValidatorRunStatus) ?? undefined,
|
lastValidatorStatus: (row.lastValidatorStatus as ValidatorRunStatus) ?? undefined,
|
||||||
generatedFromFeatureId: row.generatedFromFeatureId ?? undefined,
|
generatedFromFeatureId: row.generatedFromFeatureId ?? undefined,
|
||||||
@@ -941,6 +950,9 @@ export async function createFeature(handle: QueryHandle, feature: MissionFeature
|
|||||||
loopState: feature.loopState ?? "idle",
|
loopState: feature.loopState ?? "idle",
|
||||||
implementationAttemptCount: feature.implementationAttemptCount ?? 0,
|
implementationAttemptCount: feature.implementationAttemptCount ?? 0,
|
||||||
validatorAttemptCount: feature.validatorAttemptCount ?? 0,
|
validatorAttemptCount: feature.validatorAttemptCount ?? 0,
|
||||||
|
implementationStopReason: feature.implementationStopReason ?? null,
|
||||||
|
implementationStoppedAt: feature.implementationStoppedAt ?? null,
|
||||||
|
implementationStopOrigin: feature.implementationStopOrigin ?? null,
|
||||||
lastValidatorRunId: feature.lastValidatorRunId ?? null,
|
lastValidatorRunId: feature.lastValidatorRunId ?? null,
|
||||||
lastValidatorStatus: feature.lastValidatorStatus ?? null,
|
lastValidatorStatus: feature.lastValidatorStatus ?? null,
|
||||||
generatedFromFeatureId: feature.generatedFromFeatureId ?? null,
|
generatedFromFeatureId: feature.generatedFromFeatureId ?? null,
|
||||||
@@ -1036,6 +1048,9 @@ export async function updateFeature(handle: QueryHandle, feature: MissionFeature
|
|||||||
loopState: feature.loopState ?? "idle",
|
loopState: feature.loopState ?? "idle",
|
||||||
implementationAttemptCount: feature.implementationAttemptCount ?? 0,
|
implementationAttemptCount: feature.implementationAttemptCount ?? 0,
|
||||||
validatorAttemptCount: feature.validatorAttemptCount ?? 0,
|
validatorAttemptCount: feature.validatorAttemptCount ?? 0,
|
||||||
|
implementationStopReason: feature.implementationStopReason ?? null,
|
||||||
|
implementationStoppedAt: feature.implementationStoppedAt ?? null,
|
||||||
|
implementationStopOrigin: feature.implementationStopOrigin ?? null,
|
||||||
lastValidatorRunId: feature.lastValidatorRunId ?? null,
|
lastValidatorRunId: feature.lastValidatorRunId ?? null,
|
||||||
lastValidatorStatus: feature.lastValidatorStatus ?? null,
|
lastValidatorStatus: feature.lastValidatorStatus ?? null,
|
||||||
generatedFromFeatureId: feature.generatedFromFeatureId ?? null,
|
generatedFromFeatureId: feature.generatedFromFeatureId ?? null,
|
||||||
@@ -1056,6 +1071,74 @@ export async function deleteFeature(handle: QueryHandle, id: string): Promise<bo
|
|||||||
return result.length > 0;
|
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. */
|
/** Return a different feature already using the task, if one exists. */
|
||||||
export async function getConflictingFeatureByTaskId(
|
export async function getConflictingFeatureByTaskId(
|
||||||
handle: QueryHandle,
|
handle: QueryHandle,
|
||||||
@@ -1914,6 +1997,9 @@ export async function upsertFeature(handle: QueryHandle, feature: MissionFeature
|
|||||||
loopState: feature.loopState ?? "idle",
|
loopState: feature.loopState ?? "idle",
|
||||||
implementationAttemptCount: feature.implementationAttemptCount ?? 0,
|
implementationAttemptCount: feature.implementationAttemptCount ?? 0,
|
||||||
validatorAttemptCount: feature.validatorAttemptCount ?? 0,
|
validatorAttemptCount: feature.validatorAttemptCount ?? 0,
|
||||||
|
implementationStopReason: feature.implementationStopReason ?? null,
|
||||||
|
implementationStoppedAt: feature.implementationStoppedAt ?? null,
|
||||||
|
implementationStopOrigin: feature.implementationStopOrigin ?? null,
|
||||||
lastValidatorRunId: feature.lastValidatorRunId ?? null,
|
lastValidatorRunId: feature.lastValidatorRunId ?? null,
|
||||||
lastValidatorStatus: feature.lastValidatorStatus ?? null,
|
lastValidatorStatus: feature.lastValidatorStatus ?? null,
|
||||||
generatedFromFeatureId: feature.generatedFromFeatureId ?? null,
|
generatedFromFeatureId: feature.generatedFromFeatureId ?? null,
|
||||||
@@ -1934,6 +2020,9 @@ export async function upsertFeature(handle: QueryHandle, feature: MissionFeature
|
|||||||
loopState: sql`excluded.loop_state`,
|
loopState: sql`excluded.loop_state`,
|
||||||
implementationAttemptCount: sql`excluded.implementation_attempt_count`,
|
implementationAttemptCount: sql`excluded.implementation_attempt_count`,
|
||||||
validatorAttemptCount: sql`excluded.validator_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`,
|
lastValidatorRunId: sql`excluded.last_validator_run_id`,
|
||||||
lastValidatorStatus: sql`excluded.last_validator_status`,
|
lastValidatorStatus: sql`excluded.last_validator_status`,
|
||||||
generatedFromFeatureId: sql`excluded.generated_from_feature_id`,
|
generatedFromFeatureId: sql`excluded.generated_from_feature_id`,
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export * from "./async-mission-store-queries.js";
|
|||||||
import {
|
import {
|
||||||
DEFAULT_IMPLEMENTATION_RETRY_BUDGET,
|
DEFAULT_IMPLEMENTATION_RETRY_BUDGET,
|
||||||
missionBranchStrategyDefaults,
|
missionBranchStrategyDefaults,
|
||||||
|
missionProjectId,
|
||||||
QueryHandle,
|
QueryHandle,
|
||||||
AssertionRow,
|
AssertionRow,
|
||||||
assertionColumns,
|
assertionColumns,
|
||||||
@@ -150,6 +151,7 @@ import {
|
|||||||
setTaskMissionLinkage,
|
setTaskMissionLinkage,
|
||||||
clearTaskMissionLinkage,
|
clearTaskMissionLinkage,
|
||||||
listFailedTaskIds,
|
listFailedTaskIds,
|
||||||
|
recordGeneratedFixOperatorStop,
|
||||||
} from "./async-mission-store-queries.js";
|
} 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> {
|
export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
|
||||||
private idSequence = 0;
|
private idSequence = 0;
|
||||||
private readonly milestonesMissingStructuredAssertions = new Set<string>();
|
private readonly milestonesMissingStructuredAssertions = new Set<string>();
|
||||||
@@ -599,10 +617,62 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
async deleteMission(id: string): Promise<void> {
|
async deleteMission(id: string): Promise<void> {
|
||||||
const mission = await getMission(this.db, id);
|
const mission = await getMission(this.db, id);
|
||||||
if (!mission) throw new Error(`Mission ${id} not found`);
|
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);
|
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> {
|
async updateMissionInterviewState(id: string, state: InterviewState): Promise<Mission> {
|
||||||
return this.updateMission(id, { interviewState: state });
|
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`,
|
`Milestone ${id} has features linked to live tasks: ${blockingLinks.map((link) => `${link.featureId}->${link.taskId}`).join(", ")}; pass force to delete anyway`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (force) {
|
await this.layer.transactionImmediate(async (tx) => {
|
||||||
for (const link of blockingLinks) {
|
/* FNXC:MissionLineageBudget 2026-07-22-14:50: Cascade deletion records every generated descendant's root stop before FK cascades erase lineage. */
|
||||||
await unlinkFeatureFromTaskId(this.db, link.featureId);
|
for (const feature of features) {
|
||||||
await clearTaskMissionLinkage(this.db, link.taskId);
|
if (feature.generatedFromFeatureId) await recordGeneratedFixOperatorStop(tx, feature, "feature-delete");
|
||||||
}
|
}
|
||||||
}
|
if (force) {
|
||||||
await deleteMilestone(this.db, id);
|
for (const link of blockingLinks) {
|
||||||
|
await unlinkFeatureFromTaskId(tx, link.featureId);
|
||||||
|
await clearTaskMissionLinkage(tx, link.taskId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await deleteMilestone(tx, id);
|
||||||
|
});
|
||||||
this.emit("milestone:deleted", id);
|
this.emit("milestone:deleted", id);
|
||||||
await this.recomputeMissionStatus(missionId);
|
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`,
|
`Slice ${id} has features linked to live tasks: ${blockingLinks.map((link) => `${link.featureId}->${link.taskId}`).join(", ")}; pass force to delete anyway`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (force) {
|
await this.layer.transactionImmediate(async (tx) => {
|
||||||
for (const link of blockingLinks) {
|
/* FNXC:MissionLineageBudget 2026-07-22-14:50: Cascade deletion records every generated descendant's root stop before FK cascades erase lineage. */
|
||||||
await unlinkFeatureFromTaskId(this.db, link.featureId);
|
for (const feature of features) {
|
||||||
await clearTaskMissionLinkage(this.db, link.taskId);
|
if (feature.generatedFromFeatureId) await recordGeneratedFixOperatorStop(tx, feature, "feature-delete");
|
||||||
}
|
}
|
||||||
}
|
if (force) {
|
||||||
await deleteSlice(this.db, id);
|
for (const link of blockingLinks) {
|
||||||
|
await unlinkFeatureFromTaskId(tx, link.featureId);
|
||||||
|
await clearTaskMissionLinkage(tx, link.taskId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await deleteSlice(tx, id);
|
||||||
|
});
|
||||||
this.emit("slice:deleted", id);
|
this.emit("slice:deleted", id);
|
||||||
await this.recomputeMilestoneStatus(milestoneId);
|
await this.recomputeMilestoneStatus(milestoneId);
|
||||||
}
|
}
|
||||||
@@ -1001,15 +1083,24 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
const sliceId = feature.sliceId;
|
const sliceId = feature.sliceId;
|
||||||
const slice = await getSlice(this.db, sliceId);
|
const slice = await getSlice(this.db, sliceId);
|
||||||
const milestoneId = slice?.milestoneId;
|
const milestoneId = slice?.milestoneId;
|
||||||
if (force && feature.taskId) {
|
await this.layer.transactionImmediate(async (tx) => {
|
||||||
await unlinkFeatureFromTaskId(this.db, id);
|
/*
|
||||||
await clearTaskMissionLinkage(this.db, feature.taskId);
|
FNXC:MissionLineageBudget 2026-07-22-14:45:
|
||||||
}
|
Feature removal and its durable intervention record share one transaction.
|
||||||
if (milestoneId) {
|
Force-unlinking and assertion cleanup therefore cannot leave a deletion
|
||||||
const managed = (await listContractAssertions(this.db, milestoneId)).find((a) => a.sourceFeatureId === feature.id);
|
committed after the generated-fix ancestry has been discarded.
|
||||||
if (managed) await this.deleteContractAssertion(managed.id);
|
*/
|
||||||
}
|
if (feature.generatedFromFeatureId) await recordGeneratedFixOperatorStop(tx, feature, "feature-delete");
|
||||||
await deleteFeature(this.db, id);
|
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);
|
this.emit("feature:deleted", id);
|
||||||
await this.recomputeSliceStatus(sliceId);
|
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");
|
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(
|
async createGeneratedFixFeature(
|
||||||
sourceFeatureId: string,
|
sourceFeatureId: string,
|
||||||
runId: string,
|
runId: string,
|
||||||
@@ -1476,15 +1592,34 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
| { kind: "existing"; feature: MissionFeature }
|
| { kind: "existing"; feature: MissionFeature }
|
||||||
| { kind: "created"; feature: MissionFeature }
|
| { kind: "created"; feature: MissionFeature }
|
||||||
| { kind: "exhausted" }
|
| { kind: "exhausted" }
|
||||||
|
| { kind: "stopped"; reason: string }
|
||||||
> => {
|
> => {
|
||||||
const locked = await tx
|
const locked = await tx
|
||||||
.select({ id: schema.project.missionFeatures.id })
|
.select({ id: schema.project.missionFeatures.id })
|
||||||
.from(schema.project.missionFeatures)
|
.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");
|
.for("update");
|
||||||
if (locked.length === 0) throw new Error(`Feature ${sourceFeatureId} not found`);
|
if (locked.length === 0) throw new Error(`Feature ${sourceFeatureId} not found`);
|
||||||
const source = await getFeature(tx, sourceFeatureId);
|
const source = await getFeature(tx, sourceFeatureId);
|
||||||
if (!source) throw new Error(`Feature ${sourceFeatureId} not found`);
|
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);
|
const exactId = await findFixFeatureId(tx, sourceFeatureId, runId);
|
||||||
if (exactId) {
|
if (exactId) {
|
||||||
@@ -1496,8 +1631,15 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
const open = openFeatures.find((candidate) => candidate.status !== "done" && candidate.status !== "blocked");
|
const open = openFeatures.find((candidate) => candidate.status !== "done" && candidate.status !== "blocked");
|
||||||
if (open) return { kind: "existing", feature: open };
|
if (open) return { kind: "existing", feature: open };
|
||||||
|
|
||||||
if ((source.implementationAttemptCount ?? 0) >= DEFAULT_IMPLEMENTATION_RETRY_BUDGET) {
|
if ((lockedRoot.implementationAttemptCount ?? 0) >= DEFAULT_IMPLEMENTATION_RETRY_BUDGET) {
|
||||||
await updateFeature(tx, { ...source, loopState: "blocked", updatedAt: now });
|
await updateFeature(tx, {
|
||||||
|
...lockedRoot,
|
||||||
|
loopState: "blocked",
|
||||||
|
implementationStopReason: "budget-exhausted",
|
||||||
|
implementationStoppedAt: now,
|
||||||
|
implementationStopOrigin: "retry-budget",
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
return { kind: "exhausted" };
|
return { kind: "exhausted" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1526,18 +1668,26 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
.where(and(
|
.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}`,
|
sql`${schema.project.missionFeatures.implementationAttemptCount} < ${DEFAULT_IMPLEMENTATION_RETRY_BUDGET}`,
|
||||||
))
|
))
|
||||||
.returning({ id: schema.project.missionFeatures.id });
|
.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 };
|
return { kind: "created", feature };
|
||||||
});
|
});
|
||||||
if (outcome.kind === "existing") return outcome.feature;
|
if (outcome.kind === "existing") return outcome.feature;
|
||||||
if (outcome.kind === "exhausted") {
|
if (outcome.kind === "exhausted") {
|
||||||
const updatedSource = await getFeature(this.db, sourceFeatureId);
|
const updatedSource = await getFeature(this.db, sourceFeatureId);
|
||||||
if (updatedSource) this.emit("feature:updated", updatedSource);
|
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;
|
const feature = outcome.feature;
|
||||||
this.emit("feature:created", feature);
|
this.emit("feature:created", feature);
|
||||||
|
|||||||
@@ -1744,7 +1744,7 @@ export type {
|
|||||||
} from "./mission-types.js";
|
} from "./mission-types.js";
|
||||||
export { MissionStore } from "./mission-store.js";
|
export { MissionStore } from "./mission-store.js";
|
||||||
export type { MissionStoreEvents, MissionSummary } 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 type { TerminalTaskReconciliationErrorCode } from "./async-mission-store.js";
|
||||||
export { AsyncIdeationStore } from "./async-ideation-store.js";
|
export { AsyncIdeationStore } from "./async-ideation-store.js";
|
||||||
export { IDEATION_SESSION_STATUSES, IDEATION_CANDIDATE_ORIGINS } from "./ideation-types.js";
|
export { IDEATION_SESSION_STATUSES, IDEATION_CANDIDATE_ORIGINS } from "./ideation-types.js";
|
||||||
|
|||||||
@@ -1370,6 +1370,16 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
return updated;
|
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.
|
* Delete a mission.
|
||||||
* Cascades to delete all milestones, slices, and features.
|
* Cascades to delete all milestones, slices, and features.
|
||||||
|
|||||||
@@ -409,6 +409,8 @@ export interface ResearchFeatureProvenance {
|
|||||||
sourceUrls: string[];
|
sourceUrls: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ImplementationStopReason = "budget-exhausted" | "operator-intervention";
|
||||||
|
|
||||||
export interface MissionFeature {
|
export interface MissionFeature {
|
||||||
/** Unique identifier (e.g., "F-J6K9AB-G7H3") */
|
/** Unique identifier (e.g., "F-J6K9AB-G7H3") */
|
||||||
id: string;
|
id: string;
|
||||||
@@ -436,6 +438,11 @@ export interface MissionFeature {
|
|||||||
implementationAttemptCount?: number;
|
implementationAttemptCount?: number;
|
||||||
/** Number of validation attempts made for this feature */
|
/** Number of validation attempts made for this feature */
|
||||||
validatorAttemptCount?: number;
|
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 */
|
/** ID of the last validator run for this feature */
|
||||||
lastValidatorRunId?: string;
|
lastValidatorRunId?: string;
|
||||||
/** Status of the last validator run (passed, failed, blocked, error) */
|
/** 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
|
forward migration must run before TaskStore writes the new field on fresh and
|
||||||
upgraded databases.
|
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. */
|
/** FNXC:SymbolLock 2026-07-31-10:00: upgrades need durable task declarations before admission resolves symbols. */
|
||||||
export const TASK_DECLARED_SYMBOLS_VERSION = "0028";
|
export const TASK_DECLARED_SYMBOLS_VERSION = "0028";
|
||||||
const INITIAL_SCHEMA_VERSION = "0000";
|
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";
|
export const TASK_WEDGE_NOTIFICATION_VERSION = "0033";
|
||||||
/** FNXC:MissionValidation 2026-07-23-14:30: provenance-safe milestone criteria require an explicit upgrade. */
|
/** FNXC:MissionValidation 2026-07-23-14:30: provenance-safe milestone criteria require an explicit upgrade. */
|
||||||
export const MILESTONE_ASSERTION_PROVENANCE_VERSION = "0034";
|
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. */
|
/** SECURITY DEFINER helper that only inserts LEGACY_ADOPTION_DRAINED_MARKER. */
|
||||||
export const LEGACY_ADOPTION_DRAINED_MARKER_FUNCTION = "fusion_mark_legacy_adoption_drained";
|
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,
|
MIGRATIONS_DIR,
|
||||||
"0034_milestone_assertion_provenance.sql",
|
"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
|
* 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 taskWedgeNotificationAlreadyApplied = applied.includes(TASK_WEDGE_NOTIFICATION_VERSION);
|
||||||
const milestoneAssertionProvenanceAlreadyApplied = applied.includes(MILESTONE_ASSERTION_PROVENANCE_VERSION);
|
const milestoneAssertionProvenanceAlreadyApplied = applied.includes(MILESTONE_ASSERTION_PROVENANCE_VERSION);
|
||||||
|
const missionLineageStopAlreadyApplied = applied.includes(MISSION_LINEAGE_STOP_VERSION);
|
||||||
assertBinaryNotOlderThanDatabase(applied);
|
assertBinaryNotOlderThanDatabase(applied);
|
||||||
let schemaChanged = false;
|
let schemaChanged = false;
|
||||||
|
|
||||||
@@ -961,6 +965,18 @@ export async function applySchemaBaseline(
|
|||||||
schemaChanged = true;
|
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 };
|
return { applied: schemaChanged, pluginHooksRun: pluginHooks.length };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1467,6 +1467,10 @@ export const missionFeatures = projectSchema.table("mission_features", {
|
|||||||
loopState: text("loop_state").notNull().default("idle"),
|
loopState: text("loop_state").notNull().default("idle"),
|
||||||
implementationAttemptCount: integer("implementation_attempt_count").notNull().default(0),
|
implementationAttemptCount: integer("implementation_attempt_count").notNull().default(0),
|
||||||
validatorAttemptCount: integer("validator_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"),
|
lastValidatorRunId: text("last_validator_run_id"),
|
||||||
lastValidatorStatus: text("last_validator_status"),
|
lastValidatorStatus: text("last_validator_status"),
|
||||||
generatedFromFeatureId: text("generated_from_feature_id"),
|
generatedFromFeatureId: text("generated_from_feature_id"),
|
||||||
@@ -2060,6 +2064,23 @@ export const missionFixFeatureLineage = projectSchema.table("mission_fix_feature
|
|||||||
index("idxFixLineageRunId").on(t.runId),
|
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", {
|
export const verificationCache = projectSchema.table("verification_cache", {
|
||||||
treeSha: text("tree_sha").notNull(),
|
treeSha: text("tree_sha").notNull(),
|
||||||
testCommand: text("test_command").notNull().default(""),
|
testCommand: text("test_command").notNull().default(""),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* instance as its first parameter and performs byte-identical work.
|
* instance as its first parameter and performs byte-identical work.
|
||||||
*/
|
*/
|
||||||
import {TaskStore, storeLog} from "../store.js";
|
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 {TaskHasLineageChildrenError, TaskSelfDeleteError} from "./errors.js";
|
||||||
import {mkdir, writeFile} from "node:fs/promises";
|
import {mkdir, writeFile} from "node:fs/promises";
|
||||||
import {join} from "node:path";
|
import {join} from "node:path";
|
||||||
@@ -151,6 +151,12 @@ export async function deleteTaskBackendImpl(store: TaskStore, id: string, option
|
|||||||
*/
|
*/
|
||||||
const linkedFeature = await getMissionFeatureByTaskId(tx, id);
|
const linkedFeature = await getMissionFeatureByTaskId(tx, id);
|
||||||
if (linkedFeature) {
|
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);
|
await unlinkMissionFeatureFromTaskId(tx, linkedFeature.id);
|
||||||
}
|
}
|
||||||
// Soft-delete the task row.
|
// Soft-delete the task row.
|
||||||
@@ -248,6 +254,13 @@ export async function archiveTaskBackendImpl(store: TaskStore, id: string, optio
|
|||||||
result = await archiveParentTaskWithLineageGate(layer, id, entry, {
|
result = await archiveParentTaskWithLineageGate(layer, id, entry, {
|
||||||
removeLineageReferences: removeLineageRefs,
|
removeLineageReferences: removeLineageRefs,
|
||||||
now: archivedAt,
|
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) {
|
} catch (error) {
|
||||||
if (preparedWorkspace) await releasePreparedWorkspaceArchiveDisposal(preparedWorkspace);
|
if (preparedWorkspace) await releasePreparedWorkspaceArchiveDisposal(preparedWorkspace);
|
||||||
|
|||||||
@@ -256,7 +256,7 @@ export async function archiveParentTaskWithLineageGate(
|
|||||||
layer: AsyncDataLayer,
|
layer: AsyncDataLayer,
|
||||||
taskId: string,
|
taskId: string,
|
||||||
entry: ArchivedTaskEntry,
|
entry: ArchivedTaskEntry,
|
||||||
options: { removeLineageReferences?: boolean; now?: string } = {},
|
options: { removeLineageReferences?: boolean; now?: string; beforeArchive?: (tx: DbTransaction) => Promise<void> } = {},
|
||||||
): Promise<{ archived: true } | { archived: false; liveChildIds: string[] }> {
|
): Promise<{ archived: true } | { archived: false; liveChildIds: string[] }> {
|
||||||
const now = options.now ?? new Date().toISOString();
|
const now = options.now ?? new Date().toISOString();
|
||||||
|
|
||||||
@@ -272,6 +272,14 @@ export async function archiveParentTaskWithLineageGate(
|
|||||||
await removeLineageReferences(tx, taskId, liveChildIds, now, layer.projectId);
|
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).
|
// 3. Archive snapshot to cold storage (VAL-CROSS-015 — preserves for restore).
|
||||||
// FNXC:MultiProjectIsolation 2026-07-12: stamped with the bound project.
|
// FNXC:MultiProjectIsolation 2026-07-12: stamped with the bound project.
|
||||||
await upsertArchivedTaskEntry(tx, entry, layer.projectId);
|
await upsertArchivedTaskEntry(tx, entry, layer.projectId);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
resolvePlanningSettingsModel,
|
resolvePlanningSettingsModel,
|
||||||
AgentStore,
|
AgentStore,
|
||||||
THINKING_LEVELS,
|
THINKING_LEVELS,
|
||||||
|
MissionResumeConflictError,
|
||||||
TerminalTaskReconciliationError,
|
TerminalTaskReconciliationError,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import type { Goal, Settings, ThinkingLevel } 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)");
|
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.
|
// Re-engage autopilot if enabled and autopilot instance is available.
|
||||||
// The autopilot may have been stopped or the mission unwatched during
|
// The autopilot may have been stopped or the mission unwatched during
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import type {
|
|||||||
Mission,
|
Mission,
|
||||||
ValidationDiagnostics,
|
ValidationDiagnostics,
|
||||||
} from "@fusion/core";
|
} 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 { GitCheckoutMaterializer, type CheckoutMaterializer, type VerificationOutcome } from "./mission-verification.js";
|
||||||
import { createFnAgent, promptWithFallback, type AgentResult } from "./pi.js";
|
import { createFnAgent, promptWithFallback, type AgentResult } from "./pi.js";
|
||||||
import { mergeEffectiveSettings } from "./effective-settings.js";
|
import { mergeEffectiveSettings } from "./effective-settings.js";
|
||||||
@@ -1747,14 +1747,26 @@ ${taskContext ? `\n\nImplementation context:\n${taskContext}` : ""}`;
|
|||||||
});
|
});
|
||||||
} catch (fixErr) {
|
} catch (fixErr) {
|
||||||
const message = fixErr instanceof Error ? fixErr.message : String(fixErr);
|
const message = fixErr instanceof Error ? fixErr.message : String(fixErr);
|
||||||
if (message.includes("retry budget exhausted") || message.includes("exhausted its retry budget")) {
|
if (fixErr instanceof MissionRemediationStoppedError) {
|
||||||
loopLog.warn(`Feature ${featureId} retry budget exhausted; marking as blocked`);
|
/*
|
||||||
// completeValidatorRun already handles the blocked transition when budget is exhausted
|
FNXC:MissionLineageBudget 2026-07-22-15:15:
|
||||||
terminalStatus = "blocked";
|
The root-budget store result is typed: every durable stop prevents
|
||||||
await this.logFeatureMissionEvent(featureId, "error", "retry_budget_exhausted", `Feature ${featureId} exhausted its retry budget`, {
|
further triage, while only exhaustion enters the Blocked Handoff.
|
||||||
runId: runId ?? null,
|
Never infer lifecycle state by matching implementation error prose.
|
||||||
});
|
*/
|
||||||
this.emit("validation:budget_exhausted", { featureId, runId });
|
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 {
|
} else {
|
||||||
loopLog.error(`Error creating fix feature for ${featureId}:`, message);
|
loopLog.error(`Error creating fix feature for ${featureId}:`, message);
|
||||||
// R16 — a swallowed Fix-Feature creation error is durably recorded.
|
// R16 — a swallowed Fix-Feature creation error is durably recorded.
|
||||||
|
|||||||
Reference in New Issue
Block a user