FN-8694: add content-addressed validation memoization

Memoizes mission validation inputs and bounds repeat failures.

- Persist validator input fingerprints and atomically admit validation runs.
- Reuse unchanged static passes while enforcing per-fingerprint failure budgets.
- Add migration coverage, execution-loop tests, and operator documentation.

Files changed:
 .changeset/fn-8694-validation-memoization.md       |   7 +
 docs/missions.md                                   |   6 +
 docs/storage.md                                    |   6 +
 .../__tests__/postgres/mission-store.pg.test.ts    |  40 +++-
 packages/core/src/async-mission-store-queries.ts   |  24 ++-
 packages/core/src/async-mission-store.ts           |  65 +++++-
 packages/core/src/mission-store.ts                 |  10 +-
 packages/core/src/mission-types.ts                 |  19 ++
 .../0042_fn_8694_validator_input_fingerprint.sql   |   9 +
 packages/core/src/postgres/schema-applier.ts       |  13 +-
 packages/core/src/postgres/schema/project.ts       |   5 +
 .../src/__tests__/mission-execution-loop.test.ts   |  72 ++++++-
 packages/engine/src/mission-execution-loop.ts      | 217 +++++++++++++++++----
 13 files changed, 443 insertions(+), 50 deletions(-)

Fusion-Task-Id: FN-8694

Fusion-Task-Lineage: 92a790d5-f2fc-4e71-b9ee-8334ff38b20d

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-01 12:40:24 -07:00
parent 7dc6c4b4ec
commit 39368f4bb8
13 changed files with 442 additions and 49 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Bound repeated mission validator runs using content-addressed input memoization.
category: feature
dev: Stores per-project feature fingerprints and durable validation-budget block provenance.

View File

@@ -749,3 +749,9 @@ A completed cited research finding may become a normal Mission Feature. Its feat
### Autonomous mission admission
Autonomous no-task heartbeat agents may create or delegate implementation work only with an approved Feature → Slice → Milestone → Mission lineage. Interactive and task-scoped calls remain governed by `task_agent_mutation` policy as described in [Agent task-creation admission](#agent-task-creation-admission). The created task stores that lineage as task metadata; it does not replace the canonical feature `taskId` link except at the documented `defined`-feature first-task bootstrap. Missing or invalid autonomous lineage is rejected before a task is persisted. Roadmap reconciliation marks done tasks done, returns cancelled/requeued tasks to triaged, keeps failed work non-complete, and treats archives as non-promoting no-ops.
## Validator memoization and failure budget (FN-8694)
Automatic feature validation is content-addressed by landed SHA, resolved judge provider/model, and exact built prompts. Admission is atomic per project, feature, and fingerprint: a matching running run is not duplicated; the latest terminal history is selected deterministically; static-only passes are reused; and matching failures permit at most three dispatched runs before the feature is blocked. Behavioral or mixed assertions never reuse a pass, but failures are still budgeted.
Every automatic suppression appends one visible `validation memoized` activity event (`running`, `reuse-pass`, or `budget-exhausted`) with fingerprint and referenced run ID where available. Initial exhaustion additionally appends one `validation-stuck` event; later unchanged sweeps append only their memoized event. No synthetic validator run or verdict is created for reuse or exhaustion. Missing landed SHA, fallback checkout, unknown judge identity, and preparation failures fail open to ordinary validation; `error`/`blocked` outcomes are transient. Manual validation bypasses memoization and the budget. Recovery revisits only a feature bearing FN-8694's budget-block provenance: unchanged inputs remain blocked, while a changed prepared fingerprint can be admitted; unrelated blocked/remediation/operator states stay closed.

View File

@@ -814,3 +814,9 @@ Direct chat tags are stored in the project PostgreSQL schema as `chat_tags` and
Events use `(project_id, seq)` and a deterministic `evt_` SHA-256 identity over project, event type, task ID, and deletion timestamp. `project.task_lifecycle_event_seq` allocates that per-project sequence through a transactional counter upsert. Its lock is held until commit, so allocation order equals commit order, committed rows are in-order and gap-free, and rollback reverts the counter without consuming a number. Payloads contain only task IDs, previous lane/status, deletion timestamp, resurrection/issue action, and actor ID fields.
FN-8685 adds `task_lifecycle_consumer_registrations`, `task_lifecycle_consumer_cursors`, `task_lifecycle_consumer_receipts`, and `task_lifecycle_consumer_dead_letters`. Every table is scoped by project and consumer; receipts and dead letters use `(project_id, consumer_id, event_id)` uniqueness. Registrations provide durable liveness for retention. Retention runs from self-healing at most every six hours per project, is bounded to 5,000 rows per sweep, and does not prune rows unacknowledged by live consumers. If no identity is live, it only age-prunes events older than 30 days so a within-bound restart can catch up.
## Mission validator input memoization (FN-8694)
`project.mission_validator_runs.input_fingerprint` stores a nullable SHA-256 content address for eligible automatic validator runs. The address hashes UTF-8 bytes of `JSON.stringify(["mission-validation-input-v1", landedSha, provider, modelId, systemPrompt, userPrompt])`; the versioned array avoids delimiter ambiguity and ordinary prompt-template changes invalidate naturally. The `(project_id, feature_id, input_fingerprint)` index scopes lookup and admission, so history never crosses projects or features.
Automatic admission locks the project-scoped feature row, records a running row only for an admitted dispatch, and writes one `validation memoized` mission activity event for each suppressed running/pass/budget decision. Matching static passes are reused without fabricating a run. Matching failed rows consume the per-fingerprint budget; exhaustion records `loop_state = blocked` plus fingerprint/run/timestamp provenance and emits exactly one additional `validation-stuck` event for that feature/fingerprint. Repeated unchanged suppressions remain individually auditable but do not repeat the stuck event. Reaped `error` and `blocked` runs are transient and do not seed reuse or the failure count.

View File

@@ -690,8 +690,9 @@ pgTest("MissionStore (PostgreSQL backend mode)", () => {
const slice = await m.addSlice(milestone.id, { title: "SL" });
const feature = await m.addFeature(slice.id, { title: "F" });
const run = await m.startValidatorRun(feature.id, "manual");
const run = await m.startValidatorRun(feature.id, "manual", undefined, "fingerprint-round-trip");
expect(run.status).toBe("running");
expect(run.inputFingerprint).toBe("fingerprint-round-trip");
expect(run.validatorAttempt).toBe(1);
const runs = await m.getValidatorRunsByFeature(feature.id);
@@ -699,6 +700,43 @@ pgTest("MissionStore (PostgreSQL backend mode)", () => {
const fetched = await m.getValidatorRun(run.id);
expect(fetched?.id).toBe(run.id);
expect(fetched?.inputFingerprint).toBe("fingerprint-round-trip");
});
it("clears all validation-budget provenance when a changed fingerprint is admitted", async () => {
const m = missions();
const mission = await m.createMission({ title: "Changed validation input" });
const milestone = await m.addMilestone(mission.id, { title: "MS" });
const slice = await m.addSlice(milestone.id, { title: "SL" });
const feature = await m.addFeature(slice.id, { title: "F" });
const exhaustedFingerprint = "a".repeat(64);
const changedFingerprint = "b".repeat(64);
for (let attempt = 0; attempt < 3; attempt++) {
const run = await m.startValidatorRun(feature.id, "task_completion", undefined, exhaustedFingerprint);
await m.completeValidatorRun(run.id, "failed", "deterministic failure");
}
await expect(m.admitValidatorRun(feature.id, {
inputFingerprint: exhaustedFingerprint,
failureBudget: 3,
reusePass: true,
})).resolves.toMatchObject({ outcome: "budget-exhausted" });
expect(await m.getFeature(feature.id)).toMatchObject({
loopState: "blocked",
validationBudgetFingerprint: exhaustedFingerprint,
});
await expect(m.admitValidatorRun(feature.id, {
inputFingerprint: changedFingerprint,
failureBudget: 3,
reusePass: true,
})).resolves.toMatchObject({ outcome: "start", run: { inputFingerprint: changedFingerprint } });
expect(await m.getFeature(feature.id)).toMatchObject({
loopState: "validating",
validationBudgetFingerprint: undefined,
validationBudgetRunId: undefined,
validationBudgetBlockedAt: undefined,
});
});
it("runs the validator/fix lifecycle and reaps stale runs in PostgreSQL", async () => {

View File

@@ -183,6 +183,9 @@ interface FeatureRow {
implementationStopReason: string | null;
implementationStoppedAt: string | null;
implementationStopOrigin: string | null;
validationBudgetFingerprint: string | null;
validationBudgetRunId: string | null;
validationBudgetBlockedAt: string | null;
lastValidatorRunId: string | null;
lastValidatorStatus: string | null;
generatedFromFeatureId: string | null;
@@ -248,6 +251,7 @@ interface ValidatorRunRow {
implementationAttempt: number | null;
validatorAttempt: number | null;
taskId: string | null;
inputFingerprint: string | null;
summary: string | null;
blockedReason: string | null;
startedAt: string;
@@ -344,6 +348,9 @@ const featureColumns = {
implementationStopReason: schema.project.missionFeatures.implementationStopReason,
implementationStoppedAt: schema.project.missionFeatures.implementationStoppedAt,
implementationStopOrigin: schema.project.missionFeatures.implementationStopOrigin,
validationBudgetFingerprint: schema.project.missionFeatures.validationBudgetFingerprint,
validationBudgetRunId: schema.project.missionFeatures.validationBudgetRunId,
validationBudgetBlockedAt: schema.project.missionFeatures.validationBudgetBlockedAt,
lastValidatorRunId: schema.project.missionFeatures.lastValidatorRunId,
lastValidatorStatus: schema.project.missionFeatures.lastValidatorStatus,
generatedFromFeatureId: schema.project.missionFeatures.generatedFromFeatureId,
@@ -394,6 +401,7 @@ const validatorRunColumns = {
implementationAttempt: schema.project.missionValidatorRuns.implementationAttempt,
validatorAttempt: schema.project.missionValidatorRuns.validatorAttempt,
taskId: schema.project.missionValidatorRuns.taskId,
inputFingerprint: schema.project.missionValidatorRuns.inputFingerprint,
summary: schema.project.missionValidatorRuns.summary,
blockedReason: schema.project.missionValidatorRuns.blockedReason,
startedAt: schema.project.missionValidatorRuns.startedAt,
@@ -510,6 +518,9 @@ function rowToFeature(row: FeatureRow): MissionFeature {
implementationStopReason: (row.implementationStopReason ?? undefined) as MissionFeature["implementationStopReason"],
implementationStoppedAt: row.implementationStoppedAt ?? undefined,
implementationStopOrigin: row.implementationStopOrigin ?? undefined,
validationBudgetFingerprint: row.validationBudgetFingerprint ?? undefined,
validationBudgetRunId: row.validationBudgetRunId ?? undefined,
validationBudgetBlockedAt: row.validationBudgetBlockedAt ?? undefined,
lastValidatorRunId: row.lastValidatorRunId ?? undefined,
lastValidatorStatus: (row.lastValidatorStatus as ValidatorRunStatus) ?? undefined,
generatedFromFeatureId: row.generatedFromFeatureId ?? undefined,
@@ -569,7 +580,7 @@ function rowToFeatureAssertionLink(row: FeatureAssertionLinkRow): FeatureAsserti
return { featureId: row.featureId, assertionId: row.assertionId, createdAt: row.createdAt };
}
function rowToValidatorRun(row: ValidatorRunRow): MissionValidatorRun {
export function rowToValidatorRun(row: ValidatorRunRow): MissionValidatorRun {
return {
id: row.id,
featureId: row.featureId,
@@ -580,6 +591,7 @@ function rowToValidatorRun(row: ValidatorRunRow): MissionValidatorRun {
implementationAttempt: row.implementationAttempt ?? 0,
validatorAttempt: row.validatorAttempt ?? 0,
taskId: row.taskId ?? undefined,
inputFingerprint: row.inputFingerprint ?? undefined,
summary: row.summary ?? undefined,
blockedReason: row.blockedReason ?? undefined,
startedAt: row.startedAt,
@@ -1060,6 +1072,9 @@ export async function updateFeature(handle: QueryHandle, feature: MissionFeature
implementationStopReason: feature.implementationStopReason ?? null,
implementationStoppedAt: feature.implementationStoppedAt ?? null,
implementationStopOrigin: feature.implementationStopOrigin ?? null,
validationBudgetFingerprint: feature.validationBudgetFingerprint ?? null,
validationBudgetRunId: feature.validationBudgetRunId ?? null,
validationBudgetBlockedAt: feature.validationBudgetBlockedAt ?? null,
lastValidatorRunId: feature.lastValidatorRunId ?? null,
lastValidatorStatus: feature.lastValidatorStatus ?? null,
generatedFromFeatureId: feature.generatedFromFeatureId ?? null,
@@ -1648,6 +1663,7 @@ export async function createValidatorRun(handle: QueryHandle, run: MissionValida
implementationAttempt: run.implementationAttempt,
validatorAttempt: run.validatorAttempt,
taskId: run.taskId ?? null,
inputFingerprint: run.inputFingerprint ?? null,
summary: run.summary ?? null,
blockedReason: run.blockedReason ?? null,
startedAt: run.startedAt,
@@ -2011,6 +2027,9 @@ export async function upsertFeature(handle: QueryHandle, feature: MissionFeature
implementationStopReason: feature.implementationStopReason ?? null,
implementationStoppedAt: feature.implementationStoppedAt ?? null,
implementationStopOrigin: feature.implementationStopOrigin ?? null,
validationBudgetFingerprint: feature.validationBudgetFingerprint ?? null,
validationBudgetRunId: feature.validationBudgetRunId ?? null,
validationBudgetBlockedAt: feature.validationBudgetBlockedAt ?? null,
lastValidatorRunId: feature.lastValidatorRunId ?? null,
lastValidatorStatus: feature.lastValidatorStatus ?? null,
generatedFromFeatureId: feature.generatedFromFeatureId ?? null,
@@ -2034,6 +2053,9 @@ export async function upsertFeature(handle: QueryHandle, feature: MissionFeature
implementationStopReason: sql`excluded.implementation_stop_reason`,
implementationStoppedAt: sql`excluded.implementation_stopped_at`,
implementationStopOrigin: sql`excluded.implementation_stop_origin`,
validationBudgetFingerprint: sql`excluded.validation_budget_fingerprint`,
validationBudgetRunId: sql`excluded.validation_budget_run_id`,
validationBudgetBlockedAt: sql`excluded.validation_budget_blocked_at`,
lastValidatorRunId: sql`excluded.last_validator_run_id`,
lastValidatorStatus: sql`excluded.last_validator_status`,
generatedFromFeatureId: sql`excluded.generated_from_feature_id`,

View File

@@ -11,7 +11,7 @@ const severityAuditLog = createLogger("core-async-mission-store");
* events; reusable SQL and row mapping live in async-mission-store-queries.ts.
*/
import { EventEmitter } from "node:events";
import { and, eq, inArray, notInArray, sql } from "drizzle-orm";
import { and, desc, eq, inArray, notInArray, sql } from "drizzle-orm";
import * as schema from "./postgres/schema/index.js";
import type { AsyncDataLayer } from "./postgres/data-layer.js";
import { FEATURE_LOOP_TRANSITIONS, normalizeMissionAssertionType, renderValidationCause } from "./mission-types.js";
@@ -21,6 +21,8 @@ import type {
Slice,
MissionFeature,
MissionValidatorRun,
ValidatorRunAdmission,
ValidatorRunAdmissionInput,
MissionAssertionFailureRecord,
MissionFeatureLoopSnapshot,
MissionCreateInput,
@@ -136,6 +138,7 @@ import {
linkFeatureToAssertion,
unlinkFeatureFromAssertion,
createValidatorRun,
rowToValidatorRun,
getValidatorRun,
listValidatorRunsByFeature,
listStaleRunningValidatorRuns,
@@ -1471,7 +1474,7 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
}
// ════════════════ VALIDATOR RUNS ════════════════
async startValidatorRun(featureId: string, triggerType?: string, taskId?: string): Promise<MissionValidatorRun> {
async startValidatorRun(featureId: string, triggerType?: string, taskId?: string, inputFingerprint?: string): Promise<MissionValidatorRun> {
const feature = await getFeature(this.db, featureId);
if (!feature) throw new Error(`Feature ${featureId} not found`);
const slice = await getSlice(this.db, feature.sliceId);
@@ -1490,6 +1493,7 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
implementationAttempt: feature.implementationAttemptCount ?? 0,
validatorAttempt: newValidatorAttemptCount,
taskId,
inputFingerprint,
startedAt: now,
createdAt: now,
updatedAt: now,
@@ -1504,6 +1508,63 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
return run;
}
/**
* Atomically admit or suppress an automatic validator dispatch. The feature
* row lock serializes one project+feature+fingerprint decision without
* holding a database transaction while a model session executes.
*/
async admitValidatorRun(featureId: string, input: ValidatorRunAdmissionInput): Promise<ValidatorRunAdmission> {
return this.layer.transactionImmediate(async (tx) => {
const locked = await tx.select().from(schema.project.missionFeatures).where(and(
eq(schema.project.missionFeatures.projectId, missionProjectId()),
eq(schema.project.missionFeatures.id, featureId),
)).for("update");
const feature = locked[0] ? await getFeature(tx, featureId) : undefined;
if (!feature) throw new Error(`Feature ${featureId} not found`);
const rows = await tx.select().from(schema.project.missionValidatorRuns).where(and(
eq(schema.project.missionValidatorRuns.projectId, missionProjectId()),
eq(schema.project.missionValidatorRuns.featureId, featureId),
eq(schema.project.missionValidatorRuns.inputFingerprint, input.inputFingerprint),
)).orderBy(desc(schema.project.missionValidatorRuns.completedAt), desc(schema.project.missionValidatorRuns.startedAt), desc(schema.project.missionValidatorRuns.createdAt), desc(schema.project.missionValidatorRuns.id)).for("update");
const runs = rows.map((row) => rowToValidatorRun(row as never));
const running = runs.find((run) => run.status === "running");
const terminal = runs.find((run) => run.status === "passed" || run.status === "failed");
const failed = runs.filter((run) => run.status === "failed");
const slice = await getSlice(tx, feature.sliceId);
const milestone = slice ? await getMilestone(tx, slice.milestoneId) : undefined;
const mission = milestone ? await getMission(tx, milestone.missionId) : undefined;
const append = async (outcome: ValidatorRunAdmission["outcome"], run?: MissionValidatorRun, stuck = false) => {
if (!mission) return;
const seq = (await getMaxEventSeq(tx)) + 1;
await insertMissionEvent(tx, { id: this.generateId("ME"), missionId: mission.id, eventType: "warning", description: "validation memoized", metadata: { outcome, featureId, fingerprint: input.inputFingerprint, ...(run ? { runId: run.id } : {}) }, timestamp: new Date().toISOString(), seq });
if (stuck) await insertMissionEvent(tx, { id: this.generateId("ME"), missionId: mission.id, eventType: "warning", description: "validation-stuck", metadata: { featureId, fingerprint: input.inputFingerprint, ...(run ? { runId: run.id } : {}) }, timestamp: new Date().toISOString(), seq: seq + 1 });
};
if (running) { await append("running", running); return { outcome: "running", run: running }; }
if (terminal?.status === "passed" && input.reusePass) {
await updateFeature(tx, { ...feature, status: "done", loopState: "passed", lastValidatorStatus: "passed", lastValidatorRunId: terminal.id, updatedAt: new Date().toISOString() });
await append("reuse-pass", terminal);
return { outcome: "reuse-pass", run: terminal };
}
if (failed.length >= input.failureBudget) {
const alreadyBlocked = feature.loopState === "blocked" && feature.validationBudgetFingerprint === input.inputFingerprint;
const latest = terminal?.status === "failed" ? terminal : failed[0];
if (!alreadyBlocked) await updateFeature(tx, { ...feature, loopState: "blocked", validationBudgetFingerprint: input.inputFingerprint, validationBudgetRunId: latest?.id, validationBudgetBlockedAt: new Date().toISOString(), lastValidatorRunId: latest?.id ?? feature.lastValidatorRunId, lastValidatorStatus: "failed", updatedAt: new Date().toISOString() });
await append("budget-exhausted", latest, !alreadyBlocked);
return { outcome: "budget-exhausted", run: latest };
}
if (!slice || !milestone) throw new Error(`Feature ${featureId} has incomplete hierarchy`);
const now = new Date().toISOString();
const run: MissionValidatorRun = { id: this.generateId("VR"), featureId, milestoneId: milestone.id, sliceId: slice.id, status: "running", triggerType: "task_completion", implementationAttempt: feature.implementationAttemptCount ?? 0, validatorAttempt: (feature.validatorAttemptCount ?? 0) + 1, taskId: input.taskId, inputFingerprint: input.inputFingerprint, startedAt: now, createdAt: now, updatedAt: now };
await createValidatorRun(tx, run);
// FNXC:MissionValidation 2026-08-01-16:40:
// Starting a changed fingerprint reopens only the FN-8694 budget block.
// Clear every companion field so a later ordinary block cannot be mistaken
// for the exhausted fingerprint that admission has just superseded.
await updateFeature(tx, { ...feature, validatorAttemptCount: run.validatorAttempt, lastValidatorRunId: run.id, loopState: "validating", validationBudgetFingerprint: feature.validationBudgetFingerprint !== input.inputFingerprint ? undefined : feature.validationBudgetFingerprint, validationBudgetRunId: feature.validationBudgetFingerprint !== input.inputFingerprint ? undefined : feature.validationBudgetRunId, validationBudgetBlockedAt: feature.validationBudgetFingerprint !== input.inputFingerprint ? undefined : feature.validationBudgetBlockedAt, updatedAt: now });
return { outcome: "start", run };
});
}
async getValidatorRun(id: string): Promise<MissionValidatorRun | undefined> {
return getValidatorRun(this.db, id);
}

View File

@@ -359,6 +359,7 @@ interface ValidatorRunRow {
implementationAttempt: number | null;
validatorAttempt: number | null;
taskId: string | null;
inputFingerprint: string | null;
summary: string | null;
blockedReason: string | null;
startedAt: string;
@@ -664,6 +665,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
implementationAttempt: row.implementationAttempt ?? 0,
validatorAttempt: row.validatorAttempt ?? 0,
taskId: row.taskId || undefined,
inputFingerprint: row.inputFingerprint || undefined,
summary: row.summary || undefined,
blockedReason: row.blockedReason || undefined,
startedAt: row.startedAt,
@@ -2761,7 +2763,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
* @returns The created validator run
* @throws Error if feature not found
*/
startValidatorRun(featureId: string, triggerType?: string, taskId?: string): MissionValidatorRun {
startValidatorRun(featureId: string, triggerType?: string, taskId?: string, inputFingerprint?: string): MissionValidatorRun {
const feature = this.getFeature(featureId);
if (!feature) {
throw new Error(`Feature ${featureId} not found`);
@@ -2794,6 +2796,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
implementationAttempt: feature.implementationAttemptCount ?? 0,
validatorAttempt: newValidatorAttemptCount,
taskId,
inputFingerprint,
startedAt: now,
createdAt: now,
updatedAt: now,
@@ -2802,8 +2805,8 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
this.db.transaction(() => {
// Insert the validator run
this.db.prepare(`
INSERT INTO mission_validator_runs (id, featureId, milestoneId, sliceId, status, triggerType, implementationAttempt, validatorAttempt, taskId, startedAt, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO mission_validator_runs (id, featureId, milestoneId, sliceId, status, triggerType, implementationAttempt, validatorAttempt, taskId, inputFingerprint, startedAt, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
run.id,
run.featureId,
@@ -2814,6 +2817,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
run.implementationAttempt,
run.validatorAttempt,
run.taskId ?? null,
run.inputFingerprint ?? null,
run.startedAt,
run.createdAt,
run.updatedAt,

View File

@@ -448,6 +448,10 @@ export interface MissionFeature {
/** Timestamp and authority that recorded the terminal stop. */
implementationStoppedAt?: string;
implementationStopOrigin?: string;
/** FN-8694 provenance for a content-addressed validation failure budget block. */
validationBudgetFingerprint?: string;
validationBudgetRunId?: string;
validationBudgetBlockedAt?: string;
/** ID of the last validator run for this feature */
lastValidatorRunId?: string;
/** Status of the last validator run (passed, failed, blocked, error) */
@@ -460,6 +464,19 @@ export interface MissionFeature {
// ── Validator Run & Loop Types ──────────────────────────────────────
/** Atomic admission result for an automatic content-addressed validator dispatch. */
export type ValidatorRunAdmissionOutcome = "start" | "running" | "reuse-pass" | "budget-exhausted";
export interface ValidatorRunAdmission {
outcome: ValidatorRunAdmissionOutcome;
run?: MissionValidatorRun;
}
export interface ValidatorRunAdmissionInput {
inputFingerprint: string;
taskId?: string;
reusePass: boolean;
failureBudget: number;
}
/**
* A validator run represents a single execution of the validation phase
* for a feature within the mission execution loop.
@@ -477,6 +494,8 @@ export interface MissionValidatorRun {
status: ValidatorRunStatus;
/** What triggered this run (e.g., "task_completion", "manual", "scheduled") */
triggerType?: string;
/** SHA-256 fingerprint of the canonical validator input, when eligible. */
inputFingerprint?: string;
/** Which implementation attempt this run corresponds to */
implementationAttempt: number;
/** Which validation attempt this run corresponds to */

View File

@@ -0,0 +1,9 @@
-- FNXC:MissionValidation 2026-08-01-16:21:
-- FN-8694 records the exact content-addressed validator input and narrow blocked
-- provenance so recovery can stop deterministic repeat spend without reopening
-- unrelated operator/remediation blocks.
ALTER TABLE project.mission_validator_runs ADD COLUMN IF NOT EXISTS input_fingerprint text;
CREATE INDEX IF NOT EXISTS "idxValidatorRunsFeatureFingerprint" ON project.mission_validator_runs(project_id, feature_id, input_fingerprint);
ALTER TABLE project.mission_features ADD COLUMN IF NOT EXISTS validation_budget_fingerprint text;
ALTER TABLE project.mission_features ADD COLUMN IF NOT EXISTS validation_budget_run_id text;
ALTER TABLE project.mission_features ADD COLUMN IF NOT EXISTS validation_budget_blocked_at text;

View File

@@ -55,7 +55,8 @@ SCHEMA_BASELINE_VERSION advances to 0038 for optional per-mission task_prefix
capacity-model table drop that landed while this PR was open.
*/
/* FNXC:CrossProcessDeleteObservation 2026-08-01-11:39: advance the schema ceiling so durable consumer state exists before observers begin polling FN-8684's outbox. */
export const SCHEMA_BASELINE_VERSION = "0041";
/* FNXC:MissionValidation 2026-08-01-16:21: advance the schema ceiling before validator admission reads durable content fingerprints. */
export const SCHEMA_BASELINE_VERSION = "0042";
/** FNXC:SymbolLock 2026-07-20-10:00: upgrades need durable task declarations before admission resolves symbols. */
export const TASK_DECLARED_SYMBOLS_VERSION = "0028";
const INITIAL_SCHEMA_VERSION = "0000";
@@ -180,6 +181,8 @@ export const CREDENTIAL_INSTANCE_SELECTION_VERSION = "0039";
export const TASK_LIFECYCLE_OUTBOX_VERSION = "0040";
/** FNXC:CrossProcessDeleteObservation 2026-08-01-11:39: 0041 creates project-scoped consumer registration, cursor, receipt, and dead-letter state. */
export const TASK_LIFECYCLE_CONSUMERS_VERSION = "0041";
/** FNXC:MissionValidation 2026-08-01-16:21: durable input fingerprints and budget-block provenance. */
export const VALIDATOR_INPUT_FINGERPRINT_VERSION = "0042";
/** SECURITY DEFINER helper that only inserts LEGACY_ADOPTION_DRAINED_MARKER. */
export const LEGACY_ADOPTION_DRAINED_MARKER_FUNCTION = "fusion_mark_legacy_adoption_drained";
@@ -395,6 +398,7 @@ const MISSION_TASK_PREFIX_MIGRATION_PATH = join(MIGRATIONS_DIR, "0038_mission_ta
const CREDENTIAL_INSTANCE_SELECTION_MIGRATION_PATH = join(MIGRATIONS_DIR, "0039_fn_8660_credential_instance_selection.sql");
const TASK_LIFECYCLE_OUTBOX_MIGRATION_PATH = join(MIGRATIONS_DIR, "0040_fn_8684_task_lifecycle_outbox.sql");
const TASK_LIFECYCLE_CONSUMERS_MIGRATION_PATH = join(MIGRATIONS_DIR, "0041_fn_8685_task_lifecycle_consumers.sql");
const VALIDATOR_INPUT_FINGERPRINT_MIGRATION_PATH = join(MIGRATIONS_DIR, "0042_fn_8694_validator_input_fingerprint.sql");
/**
* Ensure the migration bookkeeping table exists. Lives in the public schema so
@@ -506,6 +510,7 @@ export async function applySchemaBaseline(
const credentialInstanceSelectionAlreadyApplied = applied.includes(CREDENTIAL_INSTANCE_SELECTION_VERSION);
const taskLifecycleOutboxAlreadyApplied = applied.includes(TASK_LIFECYCLE_OUTBOX_VERSION);
const taskLifecycleConsumersAlreadyApplied = applied.includes(TASK_LIFECYCLE_CONSUMERS_VERSION);
const validatorInputFingerprintAlreadyApplied = applied.includes(VALIDATOR_INPUT_FINGERPRINT_VERSION);
assertBinaryNotOlderThanDatabase(applied);
let schemaChanged = false;
@@ -1078,6 +1083,12 @@ export async function applySchemaBaseline(
await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${TASK_LIFECYCLE_CONSUMERS_VERSION}) ON CONFLICT (version) DO NOTHING`);
schemaChanged = true;
}
if (!validatorInputFingerprintAlreadyApplied) {
const migrationSql = await readFile(VALIDATOR_INPUT_FINGERPRINT_MIGRATION_PATH, "utf8");
await tx.execute(sql.raw(migrationSql));
await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${VALIDATOR_INPUT_FINGERPRINT_VERSION}) ON CONFLICT (version) DO NOTHING`);
schemaChanged = true;
}
return { applied: schemaChanged, pluginHooksRun: pluginHooks.length };
});

View File

@@ -1552,6 +1552,9 @@ export const missionFeatures = projectSchema.table("mission_features", {
implementationStopReason: text("implementation_stop_reason"),
implementationStoppedAt: text("implementation_stopped_at"),
implementationStopOrigin: text("implementation_stop_origin"),
validationBudgetFingerprint: text("validation_budget_fingerprint"),
validationBudgetRunId: text("validation_budget_run_id"),
validationBudgetBlockedAt: text("validation_budget_blocked_at"),
lastValidatorRunId: text("last_validator_run_id"),
lastValidatorStatus: text("last_validator_status"),
generatedFromFeatureId: text("generated_from_feature_id"),
@@ -2139,12 +2142,14 @@ export const missionValidatorRuns = projectSchema.table("mission_validator_runs"
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
taskId: text("task_id"),
inputFingerprint: text("input_fingerprint"),
}, (t) => [
primaryKey({ columns: [t.projectId, t.id] }),
index("idxValidatorRunsFeatureId").on(t.featureId),
index("idxValidatorRunsMilestoneId").on(t.milestoneId),
index("idxValidatorRunsSliceId").on(t.sliceId),
index("idxValidatorRunsStatus").on(t.status),
index("idxValidatorRunsFeatureFingerprint").on(t.projectId, t.featureId, t.inputFingerprint),
]);
export const missionValidatorFailures = projectSchema.table("mission_validator_failures", {

View File

@@ -70,7 +70,7 @@ function resetMockSession() {
// Import AFTER vi.mock so the mock is applied
import { createResolvedAgentSession } from "../agent-session-helpers.js";
import { MissionExecutionLoop, loopLog } from "../mission-execution-loop.js";
import { MissionExecutionLoop, loopLog, fingerprintMissionValidationInput } from "../mission-execution-loop.js";
// ── Mock Factories ──────────────────────────────────────────────────────────
@@ -229,8 +229,8 @@ function createMockMissionStore() {
}),
// Validator run methods
startValidatorRun: vi.fn((featureId: string, _triggerType?: string, _taskId?: string) => {
const run = createMockValidatorRun({ featureId });
startValidatorRun: vi.fn((featureId: string, _triggerType?: string, _taskId?: string, inputFingerprint?: string) => {
const run = createMockValidatorRun({ featureId, inputFingerprint });
validatorRuns.set(run.id, run);
return run;
}),
@@ -523,6 +523,13 @@ function initGitRepo(): string {
}
describe("MissionExecutionLoop", () => {
it("uses canonical UTF-8 tuple hashing for delimiter-bearing and Unicode validator inputs", () => {
const baseline = fingerprintMissionValidationInput("sha|one", "provider", "model", "system|π", "user|日本語");
expect(baseline).toMatch(/^[a-f0-9]{64}$/);
expect(baseline).toBe(fingerprintMissionValidationInput("sha|one", "provider", "model", "system|π", "user|日本語"));
expect(baseline).not.toBe(fingerprintMissionValidationInput("sha", "one|provider", "model", "system|π", "user|日本語"));
expect(baseline).not.toBe(fingerprintMissionValidationInput("sha|one", "provider", "other", "system|π", "user|日本語"));
});
let loop: MissionExecutionLoop;
let missionStore: ReturnType<typeof createMockMissionStore>;
let taskStore: ReturnType<typeof createMockTaskStore>;
@@ -659,6 +666,56 @@ describe("MissionExecutionLoop", () => {
expect(result.recoveredCount).toBeGreaterThanOrEqual(1);
});
it("passes the budget-block recovery checkout into its single validation execution", async () => {
const mission = createMockMission({ id: "M-BUDGET", status: "active" });
missionStore._setMission(mission);
const slice = createMockSlice({ id: "SL-BUDGET", milestoneId: "MS-001", status: "active" });
const blocked = createMockFeature({
id: "F-BUDGET",
sliceId: slice.id,
taskId: "FN-BUDGET",
status: "done",
loopState: "blocked",
validationBudgetFingerprint: "previous-fingerprint",
});
(missionStore as any)._addFeatureWithManagedAssertion(blocked);
wireHierarchy(slice, [missionStore.getFeature(blocked.id) as MissionFeature]);
taskStore._setTask({
id: "FN-BUDGET", title: "Budget task", description: "d", log: [], column: "done",
mergeDetails: { commitSha: "landed-budget-sha" },
} as any);
taskStore.getSettings.mockResolvedValue({
missionStaleThresholdMs: 600_000,
missionMaxTaskRetries: 3,
defaultProvider: "memo-provider",
defaultModelId: "memo-model",
});
const dispose = vi.fn().mockResolvedValue(undefined);
const materialize = vi.fn().mockResolvedValue({ dir: "/inspection/budget", dispose });
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/ambient-root",
checkoutMaterializer: { materialize, assertSourceClean: vi.fn() },
});
loop.start();
const runFeatureValidation = vi.spyOn(loop as any, "runFeatureValidation").mockResolvedValue(undefined);
await loop.recoverActiveMissions();
expect(materialize).toHaveBeenCalledWith("/ambient-root", "landed-budget-sha");
const budgetRecoveryCall = runFeatureValidation.mock.calls.find(
([candidate, prepared]) => candidate.id === blocked.id && prepared,
);
expect(budgetRecoveryCall?.[1]).toEqual(expect.objectContaining({
fingerprint: expect.stringMatching(/^[a-f0-9]{64}$/),
checkout: expect.objectContaining({ dir: "/inspection/budget" }),
}));
// The real execution path owns this checkout; the stub releases it here.
await budgetRecoveryCall![1].checkout.dispose();
expect(dispose).toHaveBeenCalledOnce();
});
it("leaves an already-validated done feature untouched", async () => {
const mission = createMockMission({ id: "M-OK", status: "active" });
missionStore._setMission(mission);
@@ -2497,12 +2554,20 @@ describe("MissionExecutionLoop", () => {
rootDir: "/ambient-root",
checkoutMaterializer: { materialize, assertSourceClean: vi.fn() },
});
taskStore.getSettings.mockResolvedValue({
missionStaleThresholdMs: 600_000,
missionMaxTaskRetries: 3,
defaultProvider: "memo-provider",
defaultModelId: "memo-model",
});
const staleCheck = vi.spyOn(loop as any, "isValidationWorkspaceStale").mockResolvedValue({ workspaceStale: false });
loop.start();
await loop.processTaskOutcome("FN-001");
expect(materialize).toHaveBeenCalledOnce();
expect(materialize).toHaveBeenCalledWith("/ambient-root", "landed-sha");
expect(missionStore.startValidatorRun).toHaveBeenCalledWith("F-001", "task_completion", "FN-001", expect.stringMatching(/^[a-f0-9]{64}$/));
expect(createResolvedAgentSession).toHaveBeenCalledWith(expect.objectContaining({ cwd: "/inspection/landed" }));
expect(staleCheck).toHaveBeenCalledWith("landed-sha", "/inspection/landed");
expect(dispose).toHaveBeenCalledOnce();
@@ -2533,6 +2598,7 @@ describe("MissionExecutionLoop", () => {
await loop.processTaskOutcome("FN-001");
expect(createResolvedAgentSession).toHaveBeenCalledWith(expect.objectContaining({ cwd: "/ambient-root" }));
expect(missionStore.startValidatorRun).toHaveBeenCalledWith("F-001", "task_completion", "FN-001");
expect(staleCheck).toHaveBeenCalledWith("landed-sha", "/ambient-root");
expect(inconclusiveHandler).toHaveBeenCalled();
expect(failHandler).not.toHaveBeenCalled();

View File

@@ -12,6 +12,7 @@
*/
import { EventEmitter } from "node:events";
import { createHash } from "node:crypto";
import type {
TaskStore,
MissionStore,
@@ -28,7 +29,7 @@ import type {
import { MissionRemediationStoppedError, normalizeMissionAssertionType, normalizeValidationDiagnostics, renderValidationFailureDescription,
resolveTaskLifecycleColumns, resolveWorkflowIrForTask, columnsWithFlag,
} from "@fusion/core";
import { GitCheckoutMaterializer, type CheckoutMaterializer, type VerificationOutcome } from "./mission-verification.js";
import { GitCheckoutMaterializer, type CheckoutMaterializer, type DisposableCheckout, type VerificationOutcome } from "./mission-verification.js";
import { createFnAgent, promptWithFallback, type AgentResult } from "./pi.js";
import { mergeEffectiveSettings } from "./effective-settings.js";
import {
@@ -58,6 +59,21 @@ export const loopLog = createLogger("mission-loop");
/** Maximum time (ms) to wait for a validation session to complete. */
const VALIDATION_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
/**
* FNXC:MissionValidation 2026-08-01-16:21:
* FN-8694 hashes the fixed JSON UTF-8 tuple (landed SHA, judge identity, and exact
* prompts), never delimiter-concatenated text and never a TTL. No SHA, fallback,
* unknown identity, or preparation error fails open. Static passes may reuse while
* behavioral assertions re-execute; every project+feature atomic suppression is
* audited, and only a changed fingerprint can reopen this explicit budget block.
*/
export const VALIDATION_FAILURE_BUDGET_PER_FINGERPRINT = 3;
/** Canonical, delimiter-safe content address for one exact judge execution input. */
export function fingerprintMissionValidationInput(landedSha: string, provider: string, modelId: string, systemPrompt: string, userPrompt: string): string {
return createHash("sha256").update(Buffer.from(JSON.stringify(["mission-validation-input-v1", landedSha, provider, modelId, systemPrompt, userPrompt]), "utf8")).digest("hex");
}
/**
* Validation result returned by the AI agent.
* The agent evaluates each linked assertion and returns pass/fail/blocked
@@ -82,6 +98,24 @@ interface ValidationExecution {
inspection: ValidationInspection;
}
/** Exact execution inputs prepared before atomic validator-run admission. */
interface PreparedValidationMemoization {
fingerprint: string;
hasBehavioralAssertions: boolean;
landedSha: string;
provider: string;
modelId: string;
credentialInstanceId?: string;
systemPrompt: string;
userPrompt: string;
taskId?: string;
taskTitle?: string;
taskContext: string;
runtimeHint: ReturnType<typeof extractRuntimeHint>;
settings?: Settings;
checkout: DisposableCheckout;
}
export interface ValidationResult {
/**
* Overall validation status.
@@ -336,6 +370,23 @@ export class MissionExecutionLoop extends EventEmitter {
}
}
// FNXC:MissionValidation 2026-08-01-16:21:
// A budget block is the only blocked state recovery may revisit. It
// stays closed for unknown/fallback preparation, emits an audited
// suppression for unchanged bytes, and lets atomic admission reopen
// only when a newly prepared fingerprint differs.
if (feature.loopState === "blocked" && feature.validationBudgetFingerprint && !this.activeValidations.has(feature.id)) {
const prepared = await this.prepareValidationMemoization(feature, await this.missionStore.listAssertionsForFeature(feature.id));
if (prepared) {
try {
await this.runFeatureValidation(feature, prepared);
recoveredCount++;
} catch (err) {
loopLog.error(`Recovery failed for validation-budget-blocked feature ${feature.id}:`, err);
}
}
}
// Features in needs_fix state with completed tasks need to continue
if (feature.loopState === "needs_fix") {
loopLog.log(`Recovery: feature ${feature.id} awaiting fix implementation`);
@@ -573,13 +624,23 @@ export class MissionExecutionLoop extends EventEmitter {
* method handles lazy assertion linkage, validator run bookkeeping, and
* dispatch of the validation result.
*/
private async runFeatureValidation(feature: MissionFeature): Promise<void> {
private async runFeatureValidation(
feature: MissionFeature,
preparedMemo?: PreparedValidationMemoization,
): Promise<void> {
/*
FNXC:MissionValidation 2026-08-01-17:14:
Budget-block recovery hands its prepared checkout and canonical bytes into
this path so admission and execution cannot rebuild divergent inputs or
leak the first checkout. The execution path owns disposal exactly once.
*/
/*
FNXC:MissionValidation 2026-07-17-16:40:
Claim validation before any asynchronous assertion lookup. Concurrent task
completion events must share one validator run, including the lazy-link path.
*/
this.activeValidations.add(feature.id);
let memoToDispose = preparedMemo;
try {
// Lazily guarantee a linked assertion before validation so every feature
@@ -594,6 +655,10 @@ export class MissionExecutionLoop extends EventEmitter {
// contract can complete, but it must still trigger the direct milestone
// path. That path independently proves all sibling work is done before
// grading parent-only assertions; parent prose never becomes this feature's fail.
if (memoToDispose) {
await memoToDispose.checkout.dispose().catch((error) => loopLog.warn(`Error disposing unused validation checkout for ${feature.id}:`, error));
memoToDispose = undefined;
}
await this.handleValidationPass(feature.id, undefined, "No assertions linked to feature");
await this.runMilestoneValidationIfReady(feature);
return;
@@ -601,15 +666,47 @@ export class MissionExecutionLoop extends EventEmitter {
loopLog.log(`Running internal validation for feature ${feature.id} — no board task created (policy: docs/missions.md)`);
// FNXC:MissionValidation 2026-07-16-12:00:
// Validator runs retain task linkage, while routing consumes inspection
// provenance calculated in the exact root the judge read.
const run = feature.taskId
? await this.missionStore.startValidatorRun(feature.id, "task_completion", feature.taskId)
: await this.missionStore.startValidatorRun(feature.id, "task_completion");
// The asynchronous store owns cross-process admission; legacy synchronous
// stores retain the existing fail-open path.
const memo = memoToDispose ?? await this.prepareValidationMemoization(feature, assertions);
memoToDispose = memo;
const disposeUnusedMemo = async () => {
if (memoToDispose) {
await memoToDispose.checkout.dispose().catch((error) => loopLog.warn(`Error disposing unused validation checkout for ${feature.id}:`, error));
memoToDispose = undefined;
}
};
const admissionStore = this.missionStore as typeof this.missionStore & { admitValidatorRun?: (featureId: string, input: { inputFingerprint: string; taskId?: string; reusePass: boolean; failureBudget: number }) => Promise<{ outcome: "start" | "running" | "reuse-pass" | "budget-exhausted"; run?: MissionValidatorRun }> };
let run: MissionValidatorRun;
if (memo && admissionStore.admitValidatorRun) {
const admission = await admissionStore.admitValidatorRun(feature.id, { inputFingerprint: memo.fingerprint, taskId: feature.taskId, reusePass: !memo.hasBehavioralAssertions, failureBudget: VALIDATION_FAILURE_BUDGET_PER_FINGERPRINT });
if (admission.outcome === "running" || admission.outcome === "budget-exhausted") {
await disposeUnusedMemo();
return;
}
if (admission.outcome === "reuse-pass") {
await disposeUnusedMemo();
await this.handleValidationPass(feature.id, admission.run?.id, "Reused content-addressed validator pass");
await this.runMilestoneValidationIfReady(feature);
return;
}
if (!admission.run) {
await disposeUnusedMemo();
throw new Error(`Validator admission for ${feature.id} started without a run`);
}
run = admission.run;
} else {
run = memo
? await this.missionStore.startValidatorRun(feature.id, "task_completion", feature.taskId, memo.fingerprint)
: feature.taskId
? await this.missionStore.startValidatorRun(feature.id, "task_completion", feature.taskId)
: await this.missionStore.startValidatorRun(feature.id, "task_completion");
}
loopLog.log(`Started validator run ${run.id} for feature ${feature.id}`);
const { result, inspection } = await this.runValidation(feature, assertions, run);
// runValidation takes sole ownership of a started run's checkout.
memoToDispose = undefined;
const { result, inspection } = await this.runValidation(feature, assertions, run, "feature", memo);
// A fail is not durable evidence until its inspection root is trusted.
// Do this before mutating assertion state: a pre-merge or stale checkout
@@ -679,6 +776,9 @@ export class MissionExecutionLoop extends EventEmitter {
await this.handleValidationError(feature.id, run.id, result.summary);
}
} finally {
if (memoToDispose) {
await memoToDispose.checkout.dispose().catch((error) => loopLog.warn(`Error disposing abandoned validation checkout for ${feature.id}:`, error));
}
this.activeValidations.delete(feature.id);
}
}
@@ -730,6 +830,47 @@ export class MissionExecutionLoop extends EventEmitter {
}
}
/** Prepare the exact static bytes and landed checkout that an admitted run executes. */
private async prepareValidationMemoization(feature: MissionFeature, assertions: MissionContractAssertion[]): Promise<PreparedValidationMemoization | undefined> {
try {
const landedSha = await this.resolveIntegrationSha(feature);
if (!landedSha) return undefined;
const task = feature.taskId ? await this.taskStore.getTask(feature.taskId) : null;
const baseSettings = await this.taskStore.getSettings().catch(() => undefined);
const settings = task && baseSettings ? await mergeEffectiveSettings(this.taskStore, task, baseSettings) : baseSettings;
const assignedAgent = task?.assignedAgentId && this.agentStore ? await this.agentStore.getAgent(task.assignedAgentId).catch(() => null) : null;
const model = this.resolveValidationSessionModel(task, settings, assignedAgent?.runtimeConfig);
if (!model.provider || !model.modelId) return undefined;
const userPrompt = this.buildValidationPrompt(feature, assertions, "feature");
const taskContext = task ? this.buildTaskContext(task) : "";
const systemPrompt = this.buildValidationSystemPrompt(feature, assertions, taskContext, "feature");
// FNXC:MissionValidation 2026-08-01-16:40:
// FN-8694 admits a fingerprint only after its landed checkout is ready.
// An ambient-root fallback has ill-defined code inputs, so it follows the
// legacy fail-open path rather than attaching a stale fingerprint to a run.
const checkout = await this.checkoutMaterializer.materialize(this.rootDir, landedSha);
return {
fingerprint: fingerprintMissionValidationInput(landedSha, model.provider, model.modelId, systemPrompt, userPrompt),
hasBehavioralAssertions: assertions.some((assertion) => normalizeMissionAssertionType(assertion.type) === "behavioral"),
landedSha,
provider: model.provider,
modelId: model.modelId,
credentialInstanceId: model.credentialInstanceId,
systemPrompt,
userPrompt,
taskId: task?.id,
taskTitle: task?.title,
taskContext,
runtimeHint: extractRuntimeHint(assignedAgent?.runtimeConfig),
settings,
checkout,
};
} catch (error) {
loopLog.warn(`Validation memoization preparation failed open for ${feature.id}:`, error);
return undefined;
}
}
/**
* Run the validation AI session for a feature.
*
@@ -742,6 +883,7 @@ export class MissionExecutionLoop extends EventEmitter {
assertions: MissionContractAssertion[],
_run: MissionValidatorRun,
scope: "feature" | "milestone" = "feature",
prepared?: PreparedValidationMemoization,
): Promise<ValidationExecution> {
loopLog.log(`Running validation for feature ${feature.id} with ${assertions.length} assertions`);
@@ -749,41 +891,38 @@ export class MissionExecutionLoop extends EventEmitter {
// FN-8542 confines an individual feature verdict to its linked feature
// assertions. Parent milestone criteria are evaluated by the rollup lane,
// so they are deliberately not supplied to this feature-validation session.
const prompt = this.buildValidationPrompt(feature, assertions, scope);
const prompt = prepared?.userPrompt ?? this.buildValidationPrompt(feature, assertions, scope);
// Get task context for validation
const task = feature.taskId ? await this.taskStore.getTask(feature.taskId) : null;
const taskContext = task ? this.buildTaskContext(task) : "";
// An admitted run must use the exact inputs that were fingerprinted. Manual
// and milestone runs retain the legacy preparation path and fail open.
const task = prepared ? null : feature.taskId ? await this.taskStore.getTask(feature.taskId) : null;
const taskContext = prepared?.taskContext ?? (task ? this.buildTaskContext(task) : "");
const assignedAgent = task?.assignedAgentId && this.agentStore
? await this.agentStore.getAgent(task.assignedAgentId).catch(() => null)
: null;
const validationRuntimeHint = extractRuntimeHint(assignedAgent?.runtimeConfig);
const validationRuntimeHint = prepared?.runtimeHint ?? extractRuntimeHint(assignedAgent?.runtimeConfig);
// Merge per-task effective workflow settings (U3, KTD-3) so the validator
// model-lane reads pick up workflow values; skip when there is no task in
// scope (mission-level validation has no per-task workflow). Behavior-inert by
// default.
const baseSettings = await this.taskStore.getSettings().catch(() => undefined);
const settings = task && baseSettings
const baseSettings = prepared ? undefined : await this.taskStore.getSettings().catch(() => undefined);
const settings = prepared?.settings ?? (task && baseSettings
? await mergeEffectiveSettings(this.taskStore, task, baseSettings)
: baseSettings;
const validationSessionModel = this.resolveValidationSessionModel(
task,
settings,
assignedAgent?.runtimeConfig,
);
: baseSettings);
const validationSessionModel = prepared
? { provider: prepared.provider, modelId: prepared.modelId, credentialInstanceId: prepared.credentialInstanceId }
: this.resolveValidationSessionModel(task, settings, assignedAgent?.runtimeConfig);
let session: AgentResult | null = null;
let checkout: Awaited<ReturnType<CheckoutMaterializer["materialize"]>> | undefined;
const landedSha = await this.resolveIntegrationSha(feature);
let inspectionRoot = this.rootDir;
let fallbackUsed = !landedSha;
let checkout: DisposableCheckout | undefined = prepared?.checkout;
const landedSha = prepared?.landedSha ?? await this.resolveIntegrationSha(feature);
let inspectionRoot = checkout?.dir ?? this.rootDir;
let fallbackUsed = !checkout;
// FNXC:MissionValidation 2026-07-16-12:00:
// Issue #2168 requires the read-only judge to inspect the landed merge
// checkout, not ambient rootDir whose branch can diverge. If checkout
// materialization fails, retain rootDir behavior and evaluate staleness in
// that exact fallback root before the disposable checkout is disposed.
if (landedSha) {
// Manual and milestone validation preserve the pre-FN-8694 fallback posture.
// Prepared automatic runs cannot reach this branch: failed materialization
// makes them memoization-ineligible before atomic admission.
if (!prepared && landedSha) {
try {
checkout = await this.checkoutMaterializer.materialize(this.rootDir, landedSha);
inspectionRoot = checkout.dir;
@@ -798,7 +937,7 @@ export class MissionExecutionLoop extends EventEmitter {
const runAuditor = createRunAuditor(this.taskStore, {
runId: generateSyntheticRunId("mission", feature.taskId ?? feature.id),
agentId: "reviewer",
taskId: task?.id,
taskId: prepared?.taskId ?? task?.id,
phase: "mission",
source: "mission-execution-loop",
});
@@ -807,7 +946,7 @@ export class MissionExecutionLoop extends EventEmitter {
runtimeHint: validationRuntimeHint,
pluginRunner: this.pluginRunner,
cwd: inspectionRoot,
systemPrompt: this.buildValidationSystemPrompt(feature, assertions, taskContext, scope),
systemPrompt: prepared?.systemPrompt ?? this.buildValidationSystemPrompt(feature, assertions, taskContext, scope),
tools: "readonly",
defaultProvider: validationSessionModel.provider,
defaultModelId: validationSessionModel.modelId,
@@ -822,14 +961,14 @@ export class MissionExecutionLoop extends EventEmitter {
onText: (_delta) => {
// Could stream this to a log entry if needed
},
taskId: task?.id,
taskTitle: task?.title,
taskId: prepared?.taskId ?? task?.id,
taskTitle: prepared?.taskTitle ?? task?.title,
onFallbackModelUsed: createFallbackModelObserver({
agent: "reviewer",
label: "mission validator",
store: this.taskStore,
taskId: task?.id,
taskTitle: task?.title,
taskId: prepared?.taskId ?? task?.id,
taskTitle: prepared?.taskTitle ?? task?.title,
}),
});
session = { session: sessionResult.session, sessionFile: sessionResult.sessionFile };