FN-8541: expose actionable validator diagnostics

Surface normalized, secret-safe validator evidence and assertion verdicts across mission events and remediation work.

- Normalize, redact, bound, and render per-assertion validation diagnostics in shared core APIs.
- Attach verdict-driven diagnostics to failure events and generated fixes while suppressing duplicate triage noise.
- Display validator evidence in Mission activity and document the operator workflow.
- Add core and engine coverage for evidence, mixed blocked verdicts, and triage behavior.

Files changed:
 .changeset/fn-8541-validator-diagnostics.md        |   7 ++
 docs/missions.md                                   |  17 +++
 .../mission-store.validation-diagnostics.test.ts   |  65 ++++++++++
 packages/core/src/async-mission-store.ts           |   8 +-
 packages/core/src/index.gate.ts                    |  10 ++
 packages/core/src/index.ts                         |  10 ++
 packages/core/src/mission-store.ts                 |  10 +-
 packages/core/src/mission-types.ts                 | 136 +++++++++++++++++++++
 .../dashboard/app/components/MissionManager.css    |  21 ++++
 .../dashboard/app/components/MissionManager.tsx    |  56 +++++++++
 packages/dashboard/app/components/mission-types.ts |   3 +
 .../src/__tests__/mission-execution-loop.test.ts   |  44 +++++++
 .../mission-validator-behavioral-posture.test.ts   |  37 +++++-
 packages/engine/src/mission-execution-loop.ts      | 117 +++++++++++++-----
 14 files changed, 501 insertions(+), 40 deletions(-)

Fusion-Task-Id: FN-8541

Fusion-Task-Lineage: b226fc34-f35c-4ce5-bcb5-e418529caaa7

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-23 13:21:10 -07:00
parent 47d2d176ef
commit e734ed8a48
14 changed files with 500 additions and 39 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Show failed mission assertions and safe validator evidence in remediation work.
category: fix
dev: Validation diagnostics are normalized consistently across SQLite and PostgreSQL mission stores.

View File

@@ -657,6 +657,23 @@ interface MissionAssertionFailureRecord {
**Full state snapshots:** `MissionFeatureLoopSnapshot` captures complete loop state including all validator runs and lineage chains for post-mortem analysis.
### Validation failure diagnostics
A `validation_failed` Mission activity event includes `metadata.validationDiagnostics`, the typed source of truth for failure reporting. It contains the validator `runId`, `sourceFeatureId`, overall outcome, next action, and ordered per-assertion verdicts with expected, observed, message, and evidence references. The visible event text is derived from this object—not an AI summary—so a failed event always names failed assertion IDs and labels any separately blocked assertion IDs as blocked (never as failed).
Evidence is secret-redacted before persistence. Each assertion retains at most 16 evidence entries and every message, expected, observed, and evidence text field is capped at 4,096 UTF-8 bytes. Bounded fields carry `truncated: true`, excess evidence is reported as `omittedEvidenceCount`, project paths become project-relative, and external or disposable absolute paths become `[external path omitted]`.
Generated fix features and their triaged tasks include the same **Validation cause** section with source feature, validator run, failed assertion IDs, bounded observations, and evidence. SQLite `MissionStore` and PostgreSQL `AsyncMissionStore` use the shared renderer, so a retry does not produce backend-specific causes or duplicate sections. A fix that is already linked to a canonical task is an idempotent race; otherwise Mission activity tells the operator to inspect and retry triage rather than exposing internal exception/loop-state prose.
The loop state is internal scheduling context, not an operator diagnosis. Its public meanings and actions are:
| Public state | Meaning | Operator action |
|---|---|---|
| validating | A validator run is evaluating the landed implementation. | Inspect the run only if it remains active beyond the stale-run window. |
| needs_fix | A validator found a remediable assertion failure. | Review the event’s Validation diagnostics and triage the generated Fix feature/task. |
| blocked | Validation could not obtain sufficient proof, or retry budget is exhausted. | Resolve the stated external constraint or root cause, then retry/triage the feature. |
| implementing | A task is carrying out the feature or its generated remediation. | Follow the linked task; duplicate validator triggers with a canonical task are ignored. |
### Operator Troubleshooting
| Symptom | Diagnosis | Resolution |

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import {
normalizeValidationDiagnostics,
renderValidationCause,
renderValidationFailureDescription,
VALIDATION_DIAGNOSTICS_MAX_EVIDENCE_PER_ASSERTION,
VALIDATION_DIAGNOSTICS_MAX_TEXT_BYTES,
} from "../mission-types.js";
describe("validation diagnostics normalization", () => {
it("preserves order, limits evidence, and records omissions", () => {
const result = normalizeValidationDiagnostics({
runId: "VR-1", sourceFeatureId: "F-1", outcome: "fail",
assertions: [{ assertionId: "CA-1", passed: false, evidence: Array.from({ length: 17 }, (_, index) => ({ text: `evidence-${index}` })) }],
});
expect(result.assertions[0].evidence).toHaveLength(VALIDATION_DIAGNOSTICS_MAX_EVIDENCE_PER_ASSERTION);
expect(result.assertions[0].evidence[0].text).toBe("evidence-0");
expect(result.assertions[0].omittedEvidenceCount).toBe(1);
});
it("redacts before safely truncating multibyte evidence and fields", () => {
const result = normalizeValidationDiagnostics({
runId: "VR-1", sourceFeatureId: "F-1", outcome: "fail",
projectRoot: "/repo",
assertions: [{ assertionId: "CA-1", passed: false, message: `token=secret-value ${"😀".repeat(2000)}`, expected: "/repo/src/example.ts", actual: "/private/tmp/secret.txt", evidence: [{ text: "Authorization: Bearer sk-live-ABCDEFG1234567890abcdef" }] }],
});
const assertion = result.assertions[0];
expect(assertion.message).toContain("[REDACTED]");
expect(Buffer.byteLength(assertion.message!, "utf8")).toBeLessThanOrEqual(VALIDATION_DIAGNOSTICS_MAX_TEXT_BYTES);
expect(assertion.expected).toBe("src/example.ts");
expect(assertion.actual).toBe("[external path omitted]");
expect(assertion.evidence[0].text).toContain("[REDACTED]");
expect(assertion.message).toContain("… [truncated]");
});
it("omits Windows absolute paths outside the project root", () => {
const result = normalizeValidationDiagnostics({
runId: "VR-1", sourceFeatureId: "F-1", outcome: "fail",
projectRoot: "C:\\repo",
assertions: [{ assertionId: "CA-1", passed: false, evidence: [{ text: "C:\\external\\secret.txt" }, { text: "C:\\repo\\src\\proof.test.ts" }] }],
});
expect(result.assertions[0].evidence.map((entry) => entry.text)).toEqual(["[external path omitted]", "src/proof.test.ts"]);
});
it("renders outcome-consistent event and remediation prose", () => {
const diagnostics = normalizeValidationDiagnostics({
runId: "VR-1", sourceFeatureId: "F-1", outcome: "fail",
assertions: [
{ assertionId: "CA-pass", verdict: "pass", passed: true },
{ assertionId: "CA-fail", verdict: "fail", passed: false, expected: "green", actual: "red" },
{ assertionId: "CA-blocked", verdict: "blocked", passed: false, message: "Service unavailable" },
],
});
const eventDescription = renderValidationFailureDescription(diagnostics);
expect(eventDescription).toContain("CA-fail");
expect(eventDescription).toContain("CA-blocked");
expect(eventDescription).toContain("1 assertion failed");
expect(eventDescription).toContain("1 assertion is blocked");
expect(eventDescription).not.toContain("CA-pass");
const remediationCause = renderValidationCause(diagnostics);
expect(remediationCause).toContain("Validator run: VR-1");
expect(remediationCause).toContain("Blocked assertions: CA-blocked");
expect(remediationCause).toContain("### CA-blocked (blocked)");
});
});

View File

@@ -9,7 +9,7 @@ import { EventEmitter } from "node:events";
import { and, eq, inArray, 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 } from "./mission-types.js";
import { FEATURE_LOOP_TRANSITIONS, normalizeMissionAssertionType, renderValidationCause } from "./mission-types.js";
import type {
Mission,
Milestone,
@@ -39,6 +39,7 @@ import type {
ContractAssertionCreateInput,
ContractAssertionUpdateInput,
FeatureLoopState,
ValidationDiagnostics,
} from "./mission-types.js";
import type { Goal } from "./goal-types.js";
import {
@@ -1201,12 +1202,15 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
failedAssertionIds: string[],
failureReason?: string,
title?: string,
diagnostics?: ValidationDiagnostics,
): Promise<MissionFeature> {
const run = await getValidatorRun(this.db, runId);
if (!run) throw new Error(`Validator run ${runId} not found`);
if (run.featureId !== sourceFeatureId) throw new Error(`Validator run ${runId} belongs to feature ${run.featureId}, expected ${sourceFeatureId}`);
const now = new Date().toISOString();
const reasonText = failureReason?.trim();
// FNXC:MissionValidationDiagnostics 2026-07-23-12:00: PostgreSQL remediation uses the identical shared cause renderer as SQLite to prevent backend-specific operator diagnostics.
const causeText = diagnostics ? renderValidationCause(diagnostics) : undefined;
/*
FNXC:MissionFixIdempotency 2026-07-14-18:45:
Generated remediation is one source/run operation. Lock the source feature, re-check lineage/open fixes under that lock, and increment the retry counter in the same transaction so concurrent validator workers cannot create duplicates or consume two attempts.
@@ -1244,7 +1248,7 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
id: this.generateId("F"),
sliceId: source.sliceId,
title: title ?? `Fix: ${source.title}`,
description: reasonText ? `${source.description ? `${source.description}\n\n` : ""}## Verification failure detail\n${reasonText}` : source.description,
description: [source.description, causeText ?? (reasonText ? `## Verification failure detail\n${reasonText}` : undefined)].filter(Boolean).join("\n\n") || undefined,
acceptanceCriteria: source.acceptanceCriteria,
status: "defined",
createdAt: now,

View File

@@ -1526,6 +1526,11 @@ export {
SLICE_PLAN_STATES,
FEATURE_LOOP_STATES,
VALIDATOR_RUN_STATUSES,
VALIDATION_DIAGNOSTICS_MAX_EVIDENCE_PER_ASSERTION,
VALIDATION_DIAGNOSTICS_MAX_TEXT_BYTES,
normalizeValidationDiagnostics,
renderValidationFailureDescription,
renderValidationCause,
MISSION_ASSERTION_STATUSES,
MISSION_ASSERTION_TYPES,
DEFAULT_MISSION_ASSERTION_TYPE,
@@ -1542,6 +1547,11 @@ export type {
SlicePlanState,
FeatureLoopState,
ValidatorRunStatus,
ValidationAssertionVerdict,
ValidationEvidenceReference,
ValidationAssertionDiagnostic,
ValidationDiagnostics,
ValidationDiagnosticsInput,
MissionEventType,
AutopilotStatus,
Mission,

View File

@@ -1665,6 +1665,11 @@ export {
SLICE_PLAN_STATES,
FEATURE_LOOP_STATES,
VALIDATOR_RUN_STATUSES,
VALIDATION_DIAGNOSTICS_MAX_EVIDENCE_PER_ASSERTION,
VALIDATION_DIAGNOSTICS_MAX_TEXT_BYTES,
normalizeValidationDiagnostics,
renderValidationFailureDescription,
renderValidationCause,
MISSION_ASSERTION_STATUSES,
MISSION_ASSERTION_TYPES,
DEFAULT_MISSION_ASSERTION_TYPE,
@@ -1681,6 +1686,11 @@ export type {
SlicePlanState,
FeatureLoopState,
ValidatorRunStatus,
ValidationAssertionVerdict,
ValidationEvidenceReference,
ValidationAssertionDiagnostic,
ValidationDiagnostics,
ValidationDiagnosticsInput,
MissionEventType,
AutopilotStatus,
Mission,

View File

@@ -14,7 +14,7 @@
import { EventEmitter } from "node:events";
import type { Database } from "./db.js";
import { fromJson, toJson, toJsonNullable } from "./db.js";
import { FEATURE_LOOP_TRANSITIONS, normalizeMissionAssertionType } from "./mission-types.js";
import { FEATURE_LOOP_TRANSITIONS, normalizeMissionAssertionType, renderValidationCause } from "./mission-types.js";
import type { Goal, GoalStatus } from "./goal-types.js";
import type {
Mission,
@@ -51,6 +51,7 @@ import type {
MilestoneValidationState,
ValidatorRunStatus,
FeatureLoopState,
ValidationDiagnostics,
} from "./mission-types.js";
import { reconcileDeterministicDuplicate, runDeterministicDuplicateGuard } from "./duplicate-guard.js";
import { resolveEntryPointBranchAssignment } from "./branch-assignment.js";
@@ -2978,6 +2979,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
failedAssertionIds: string[],
failureReason?: string,
title?: string,
diagnostics?: ValidationDiagnostics,
): MissionFeature {
const sourceFeature = this.getFeature(sourceFeatureId);
if (!sourceFeature) {
@@ -3039,9 +3041,9 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
// R6 — surface the observed-vs-expected reason to the remediation agent.
const reasonText = failureReason?.trim();
const fixDescription = reasonText
? `${sourceFeature.description ? `${sourceFeature.description}\n\n` : ""}## Verification failure detail\n${reasonText}`
: sourceFeature.description;
// FNXC:MissionValidationDiagnostics 2026-07-23-12:00: Generated remediation carries the same normalized cause as its event so operators and executors never need to reconstruct a validator failure.
const causeText = diagnostics ? renderValidationCause(diagnostics) : undefined;
const fixDescription = [sourceFeature.description, causeText ?? (reasonText ? `## Verification failure detail\n${reasonText}` : undefined)].filter(Boolean).join("\n\n") || undefined;
const fixFeature: MissionFeature = {
id: fixFeatureId,

View File

@@ -10,6 +10,7 @@
*/
import type { Goal } from "./goal-types.js";
import { redactSecrets } from "./redact-secrets.js";
// ── Status Enums ─────────────────────────────────────────────────────
@@ -54,6 +55,141 @@ export const FEATURE_LOOP_TRANSITIONS: Readonly<Record<FeatureLoopState, readonl
export const VALIDATOR_RUN_STATUSES = ["running", "passed", "failed", "blocked", "error"] as const;
export type ValidatorRunStatus = (typeof VALIDATOR_RUN_STATUSES)[number];
/**
* FNXC:MissionValidationDiagnostics 2026-07-23-12:00:
* Validator failures cross engine, both stores, and dashboard activity. This
* normalized contract is the only persisted diagnostic source so prose cannot
* drift from the verdict and unbounded/secret-bearing evidence cannot escape.
*/
export const VALIDATION_DIAGNOSTICS_MAX_EVIDENCE_PER_ASSERTION = 16;
export const VALIDATION_DIAGNOSTICS_MAX_TEXT_BYTES = 4096;
export type ValidationAssertionVerdict = "pass" | "fail" | "blocked";
export interface ValidationEvidenceReference {
kind?: string;
text?: string;
/** True when text was bounded before persistence. */
truncated?: boolean;
}
export interface ValidationAssertionDiagnostic {
assertionId: string;
verdict: ValidationAssertionVerdict;
message?: string;
expected?: string;
actual?: string;
evidence: ValidationEvidenceReference[];
omittedEvidenceCount?: number;
}
export interface ValidationDiagnostics {
runId: string;
sourceFeatureId: string;
outcome: "pass" | "fail" | "blocked" | "error" | "inconclusive";
assertions: ValidationAssertionDiagnostic[];
nextAction: string;
}
export interface ValidationDiagnosticsInput {
runId: string;
sourceFeatureId: string;
outcome: ValidationDiagnostics["outcome"];
assertions: Array<{
assertionId: string;
verdict?: ValidationAssertionVerdict;
passed?: boolean;
message?: unknown;
expected?: unknown;
actual?: unknown;
evidence?: Array<{ kind?: unknown; text?: unknown }>;
}>;
projectRoot?: string;
}
function boundValidationText(value: unknown, projectRoot?: string): { value?: string; truncated?: boolean } {
if (typeof value !== "string") return {};
let text = redactSecrets(value);
// Paths from the project are useful evidence; disposable/external paths are not.
text = text.replace(/(?:[A-Za-z]:\\|\/)[^\s'"`]+/g, (path) => {
const normalizedRoot = projectRoot?.replace(/\\/g, "/").replace(/\/+$/, "");
const normalizedPath = path.replace(/\\/g, "/");
return normalizedRoot && (normalizedPath === normalizedRoot || normalizedPath.startsWith(`${normalizedRoot}/`))
? normalizedPath.slice(normalizedRoot.length).replace(/^\//, "") || "."
: "[external path omitted]";
});
const bytes = Buffer.byteLength(text, "utf8");
if (bytes <= VALIDATION_DIAGNOSTICS_MAX_TEXT_BYTES) return { value: text };
const marker = "… [truncated]";
const limit = VALIDATION_DIAGNOSTICS_MAX_TEXT_BYTES - Buffer.byteLength(marker, "utf8");
let end = 0;
let used = 0;
for (const character of text) {
const size = Buffer.byteLength(character, "utf8");
if (used + size > limit) break;
used += size;
end += character.length;
}
return { value: `${text.slice(0, end)}${marker}`, truncated: true };
}
/** Normalize and redact validation evidence before any mission artifact persists it. */
export function normalizeValidationDiagnostics(input: ValidationDiagnosticsInput): ValidationDiagnostics {
return {
runId: input.runId,
sourceFeatureId: input.sourceFeatureId,
outcome: input.outcome,
nextAction: input.outcome === "fail" ? "Review the failed assertions and triage the generated fix work." : "Review the validator run and retry or triage the feature when ready.",
assertions: input.assertions.map((assertion) => {
const evidence = (assertion.evidence ?? []).slice(0, VALIDATION_DIAGNOSTICS_MAX_EVIDENCE_PER_ASSERTION).map((item) => {
const bounded = boundValidationText(item.text, input.projectRoot);
return { ...(typeof item.kind === "string" ? { kind: item.kind } : {}), ...(bounded.value !== undefined ? { text: bounded.value } : {}), ...(bounded.truncated ? { truncated: true } : {}) };
});
const message = boundValidationText(assertion.message, input.projectRoot);
const expected = boundValidationText(assertion.expected, input.projectRoot);
const actual = boundValidationText(assertion.actual, input.projectRoot);
return {
assertionId: assertion.assertionId,
verdict: assertion.verdict ?? (assertion.passed ? "pass" : "fail"),
...(message.value !== undefined ? { message: message.value } : {}),
...(expected.value !== undefined ? { expected: expected.value } : {}),
...(actual.value !== undefined ? { actual: actual.value } : {}),
evidence,
...((assertion.evidence?.length ?? 0) > evidence.length ? { omittedEvidenceCount: (assertion.evidence?.length ?? 0) - evidence.length } : {}),
};
}),
};
}
/** Render failure prose from normalized data only; never from non-authoritative judge summaries. */
export function renderValidationFailureDescription(diagnostics: ValidationDiagnostics): string {
const failed = diagnostics.assertions.filter((assertion) => assertion.verdict === "fail");
const blocked = diagnostics.assertions.filter((assertion) => assertion.verdict === "blocked");
const failedText = `${failed.length} assertion${failed.length === 1 ? "" : "s"} failed (${failed.map((assertion) => assertion.assertionId).join(", ") || "no assertion identity"})`;
const blockedText = blocked.length > 0
? `; ${blocked.length} assertion${blocked.length === 1 ? " is" : "s are"} blocked (${blocked.map((assertion) => assertion.assertionId).join(", ")})`
: "";
return `Validation failed for feature ${diagnostics.sourceFeatureId}: ${failedText}${blockedText}. ${diagnostics.nextAction}`;
}
/** Stable remediation context used by both store implementations and their task descriptions. */
export function renderValidationCause(diagnostics: ValidationDiagnostics): string {
const nonPassing = diagnostics.assertions.filter((assertion) => assertion.verdict !== "pass");
const failed = nonPassing.filter((assertion) => assertion.verdict === "fail");
const blocked = nonPassing.filter((assertion) => assertion.verdict === "blocked");
const lines = [
"## Validation cause",
`Source feature: ${diagnostics.sourceFeatureId}`,
`Validator run: ${diagnostics.runId}`,
`Failed assertions: ${failed.map((assertion) => assertion.assertionId).join(", ") || "none recorded"}`,
...(blocked.length > 0 ? [`Blocked assertions: ${blocked.map((assertion) => assertion.assertionId).join(", ")}`] : []),
];
for (const assertion of nonPassing) {
lines.push(`### ${assertion.assertionId} (${assertion.verdict})`, ...(assertion.expected ? [`Expected: ${assertion.expected}`] : []), ...(assertion.actual ? [`Observed: ${assertion.actual}`] : []), ...(assertion.message ? [`Details: ${assertion.message}`] : []), ...assertion.evidence.map((evidence) => `Evidence: ${evidence.text ?? evidence.kind ?? "recorded"}${evidence.truncated ? " (truncated)" : ""}`), ...(assertion.omittedEvidenceCount ? [`Additional evidence omitted: ${assertion.omittedEvidenceCount}`] : []));
}
return lines.join("\n");
}
/** Interview state for AI-assisted specification */
export const INTERVIEW_STATES = ["not_started", "in_progress", "completed", "needs_update"] as const;
export type InterviewState = (typeof INTERVIEW_STATES)[number];

View File

@@ -1461,6 +1461,27 @@ Narrow/mobile Missions puts Plan New Mission at the bottom of the list, using th
color: var(--text-dim);
}
.mission-event__diagnostics,
.mission-event__diagnostic {
display: flex;
flex-direction: column;
gap: var(--space-xs);
overflow-wrap: anywhere;
}
.mission-event__diagnostics {
padding: var(--space-sm);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text-muted);
}
.mission-event__diagnostic + .mission-event__diagnostic {
padding-top: var(--space-sm);
border-top: 1px solid var(--border);
}
.mission-event__metadata {
display: flex;
flex-direction: column;

View File

@@ -55,6 +55,7 @@ import type {
MilestoneValidationTelemetry,
MissionFeatureLoopSnapshot,
MissionValidatorRun,
ValidationDiagnostics,
} from "./mission-types";
import {
fetchMissions,
@@ -545,6 +546,46 @@ function matchesEventFilter(
}
}
function getValidationDiagnostics(metadata: Record<string, unknown> | null): ValidationDiagnostics | undefined {
const candidate = metadata?.validationDiagnostics;
if (!candidate || typeof candidate !== "object") return undefined;
const diagnostics = candidate as Partial<ValidationDiagnostics>;
if (typeof diagnostics.runId !== "string" || typeof diagnostics.sourceFeatureId !== "string" || !Array.isArray(diagnostics.assertions)) {
return undefined;
}
// FNXC:MissionValidationDiagnostics 2026-07-23-13:15: Mission events are
// durable and can predate this contract. Normalize partial JSON at the UI
// boundary so malformed legacy metadata cannot crash activity on any viewport.
return {
runId: diagnostics.runId,
sourceFeatureId: diagnostics.sourceFeatureId,
outcome: diagnostics.outcome === "pass" || diagnostics.outcome === "fail" || diagnostics.outcome === "blocked" || diagnostics.outcome === "error" || diagnostics.outcome === "inconclusive" ? diagnostics.outcome : "error",
nextAction: typeof diagnostics.nextAction === "string" ? diagnostics.nextAction : "Review the validator run before retrying or triaging the feature.",
assertions: diagnostics.assertions.map((value, index) => {
const assertion = value && typeof value === "object" ? value as Partial<ValidationDiagnostics["assertions"][number]> : {};
const evidence = Array.isArray(assertion.evidence) ? assertion.evidence.flatMap((item) => {
if (!item || typeof item !== "object") return [];
const reference = item as { kind?: unknown; text?: unknown; truncated?: unknown };
return [{
...(typeof reference.kind === "string" ? { kind: reference.kind } : {}),
...(typeof reference.text === "string" ? { text: reference.text } : {}),
...(reference.truncated === true ? { truncated: true } : {}),
}];
}) : [];
return {
assertionId: typeof assertion.assertionId === "string" ? assertion.assertionId : `Assertion ${index + 1}`,
verdict: assertion.verdict === "pass" || assertion.verdict === "fail" || assertion.verdict === "blocked" ? assertion.verdict : "blocked",
...(typeof assertion.message === "string" ? { message: assertion.message } : {}),
...(typeof assertion.expected === "string" ? { expected: assertion.expected } : {}),
...(typeof assertion.actual === "string" ? { actual: assertion.actual } : {}),
evidence,
...(typeof assertion.omittedEvidenceCount === "number" && assertion.omittedEvidenceCount > 0 ? { omittedEvidenceCount: assertion.omittedEvidenceCount } : {}),
};
}),
};
}
function getEventTypeClassName(eventType: MissionEventType): string {
if (eventType === "error" || eventType === "warning") {
return "mission-event__type--error";
@@ -4260,6 +4301,21 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
<span className="mission-event__timestamp">
{new Date(event.timestamp).toLocaleString()}
</span>
{(() => {
const diagnostics = getValidationDiagnostics(event.metadata);
if (!diagnostics) return null;
return <section className="mission-event__diagnostics" aria-label={t("missions.validationDiagnostics", "Validation diagnostics")}>
<strong>{t("missions.validatorRun", "Validator run")}: {diagnostics.runId}</strong>
<span>{t("missions.nextAction", "Next action")}: {diagnostics.nextAction}</span>
{diagnostics.assertions.map((assertion) => <div className="mission-event__diagnostic" key={assertion.assertionId}>
<strong>{assertion.assertionId}: {assertion.verdict}</strong>
{assertion.expected && <span>{t("missions.expected", "Expected")}: {assertion.expected}</span>}
{assertion.actual && <span>{t("missions.observed", "Observed")}: {assertion.actual}</span>}
{assertion.evidence.map((evidence, index) => <span key={index}>{t("missions.evidence", "Evidence")}: {evidence.text ?? evidence.kind ?? t("missions.recorded", "recorded")}{evidence.truncated ? ` (${t("missions.truncated", "truncated")})` : ""}</span>)}
{assertion.omittedEvidenceCount ? <span>{t("missions.evidenceOmitted", "Additional evidence omitted")}: {assertion.omittedEvidenceCount}</span> : null}
</div>)}
</section>;
})()}
{hasMetadata && (
<div className="mission-event__metadata">
<button

View File

@@ -5,8 +5,11 @@ import type {
MissionEvent as CoreMissionEvent,
MissionEventType as CoreMissionEventType,
MissionHealth as CoreMissionHealth,
ValidationDiagnostics as CoreValidationDiagnostics,
} from "@fusion/core";
export type ValidationDiagnostics = CoreValidationDiagnostics;
export type MissionStatus = "planning" | "active" | "blocked" | "complete" | "archived";
export type MilestoneStatus = "planning" | "active" | "blocked" | "complete";
export type SliceStatus = "pending" | "active" | "complete";

View File

@@ -1694,6 +1694,50 @@ describe("MissionExecutionLoop", () => {
expectNoValidationBoardTaskMutation(taskStore);
});
it("preserves judge-provided evidence references for normalized diagnostics", async () => {
const assertions = makeAssertions(1);
mockSessionHolder.session.state.messages = [{
role: "assistant",
content: JSON.stringify({
status: "fail",
assertions: [{
assertionId: "CA-1",
passed: false,
evidence: [{ kind: "test-output", text: "pnpm test: CA-1 failed" }],
}],
summary: "misleading summary",
}),
}];
loop = new MissionExecutionLoop({ taskStore: taskStore as any, missionStore: missionStore as any, rootDir: "/tmp" });
await expect((loop as any).parseValidationResult(mockSessionHolder.session, assertions)).resolves.toMatchObject({
assertions: [{ evidence: [{ kind: "test-output", text: "pnpm test: CA-1 failed" }] }],
});
});
it("preserves blocked assertion verdicts in mixed failed results", async () => {
const assertions = makeAssertions(2);
mockSessionHolder.session.state.messages = [{
role: "assistant",
content: JSON.stringify({
status: "fail",
assertions: [
{ assertionId: "CA-1", verdict: "fail", passed: false, message: "Observed failure" },
{ assertionId: "CA-2", verdict: "blocked", passed: false, message: "Service unavailable" },
],
summary: "misleading fully satisfied summary",
}),
}];
loop = new MissionExecutionLoop({ taskStore: taskStore as any, missionStore: missionStore as any, rootDir: "/tmp" });
await expect((loop as any).parseValidationResult(mockSessionHolder.session, assertions)).resolves.toMatchObject({
assertions: [
{ assertionId: "CA-1", verdict: "fail", passed: false },
{ assertionId: "CA-2", verdict: "blocked", passed: false },
],
});
});
it("should handle malformed JSON gracefully", async () => {
const assertions = makeAssertions(1);
// Malformed JSON with trailing comma

View File

@@ -458,6 +458,12 @@ describe("Validator behavioral posture (U2 + U3)", () => {
expect(call[2]).toEqual(["CA-1"]);
expect(typeof call[3]).toBe("string");
expect(call[3]).toContain("defect still reproduces");
expect(call[5]).toMatchObject({
assertions: [{
assertionId: "CA-1",
evidence: [{ kind: "behavioral-verification", text: expect.stringContaining("button still does nothing") }],
}],
});
});
it("U6/R16: a verification FAILURE emits a persisted mission event with outcome=fail", async () => {
@@ -529,10 +535,35 @@ describe("Validator behavioral posture (U2 + U3)", () => {
await loop.processTaskOutcome("FN-TRIAGE");
const triageEvent = (missionStore.logMissionEvent as any).mock.calls.find(
(c: any[]) => c[3]?.code === "fix_feature_triage_failed",
(c: any[]) => c[3]?.code === "fix_feature_triage_needs_attention",
);
expect(triageEvent).toBeDefined();
expect(triageEvent[1]).toBe("error");
expect(triageEvent[3]?.error).toContain("triage boom");
expect(triageEvent[1]).toBe("warning");
expect(triageEvent[2]).not.toContain("triage boom");
expect(triageEvent[3]).toMatchObject({ state: "needs-triage" });
});
it("silently reuses an already-triaged fix with its canonical linked task", async () => {
proveLandedInspection();
const verify = vi.fn(async (req): Promise<VerificationOutcome> => ({ verdict: "fail", assertionId: req.assertionId, reason: "defect still reproduces" }));
const feature = createMockFeature({ loopState: "implementing", taskId: "FN-DUPLICATE", status: "in-progress" });
const existingFix = createMockFeature({
id: "FIX-F-001", taskId: "TASK-FIX-F-001", status: "in-progress", loopState: "implementing",
generatedFromFeatureId: "F-001", generatedFromRunId: "VR-existing",
});
missionStore._setFeature(feature);
missionStore._setFeature(existingFix);
missionStore._setAssertions("F-001", [assertionRow({ id: "CA-1", type: "behavioral" })]);
taskStore._setTask(landedTask("FN-DUPLICATE", "duplicate trigger"));
taskStore._setTask({ id: "TASK-FIX-F-001", title: "existing remediation" });
missionStore.createGeneratedFixFeature = vi.fn(() => existingFix) as any;
judgePass(["CA-1"]);
loop = new MissionExecutionLoop({ taskStore: taskStore as any, missionStore: missionStore as any, rootDir: "/tmp", verificationCapability: { verifyBehavioralAssertion: verify } });
loop.start();
await loop.processTaskOutcome("FN-DUPLICATE");
expect(missionStore.triageFeature).not.toHaveBeenCalled();
expect((missionStore.logMissionEvent as any).mock.calls.some((call: any[]) => call[3]?.code === "fix_feature_triage_needs_attention")).toBe(false);
});
});

View File

@@ -23,8 +23,9 @@ import type {
Settings,
Milestone,
Mission,
ValidationDiagnostics,
} from "@fusion/core";
import { normalizeMissionAssertionType } from "@fusion/core";
import { normalizeMissionAssertionType, normalizeValidationDiagnostics, renderValidationFailureDescription } from "@fusion/core";
import { GitCheckoutMaterializer, type CheckoutMaterializer, type VerificationOutcome } from "./mission-verification.js";
import { createFnAgent, promptWithFallback, type AgentResult } from "./pi.js";
import { mergeEffectiveSettings } from "./effective-settings.js";
@@ -93,10 +94,13 @@ export interface ValidationResult {
/** Per-assertion results */
assertions: Array<{
assertionId: string;
/** Per-assertion outcome; `passed` remains for legacy judge responses. */
verdict: "pass" | "fail" | "blocked";
passed: boolean;
message?: string;
expected?: string;
actual?: string;
evidence?: Array<{ kind?: string; text?: string }>;
}>;
/** Summary message for overall result */
summary: string;
@@ -794,6 +798,7 @@ export class MissionExecutionLoop extends EventEmitter {
status: "error",
assertions: assertions.map((a) => ({
assertionId: a.id,
verdict: "fail",
passed: false,
message: `Validation error: ${message}`,
})),
@@ -871,7 +876,7 @@ export class MissionExecutionLoop extends EventEmitter {
let inconclusiveReason: string | undefined;
const newAssertionResults = await Promise.all(
judgeResult.assertions.map(async (judged) => {
judgeResult.assertions.map(async (judged): Promise<ValidationResult["assertions"][number]> => {
const type = typeById.get(judged.assertionId) ?? "static";
if (type !== "behavioral") {
// Static: keep judge verdict verbatim.
@@ -882,6 +887,7 @@ export class MissionExecutionLoop extends EventEmitter {
if (!this.verificationCapability) {
return {
...judged,
verdict: "fail",
passed: false,
message: "Behavioral assertion defaults to fail: no verification evidence (advisory judge verdict is not authoritative).",
expected: judged.expected ?? "Behavior confirmed by a verification run",
@@ -904,27 +910,36 @@ export class MissionExecutionLoop extends EventEmitter {
outcome = { verdict: "inconclusive", assertionId: judged.assertionId, reason: `verification error: ${message}` };
}
// FNXC:MissionValidationDiagnostics 2026-07-23-12:30: Behavioral verification is an authoritative execution path, so its reason/detail must join judge evidence before the shared normalizer bounds and redacts it.
const behavioralEvidence = [{
kind: "behavioral-verification",
text: outcome.detail ? `${outcome.reason}\n${outcome.detail}` : outcome.reason,
}];
if (outcome.verdict === "pass") {
return { ...judged, passed: true, message: outcome.reason };
return { ...judged, verdict: "pass", passed: true, message: outcome.reason, evidence: [...(judged.evidence ?? []), ...behavioralEvidence] };
}
if (outcome.verdict === "inconclusive") {
sawInconclusive = true;
inconclusiveReason = inconclusiveReason ?? outcome.reason;
return {
...judged,
verdict: "blocked",
passed: false,
message: `Behavioral verification inconclusive: ${outcome.reason}`,
expected: judged.expected ?? "Behavior confirmed by a verification run",
actual: outcome.detail ?? "Verification could not conclude",
evidence: [...(judged.evidence ?? []), ...behavioralEvidence],
};
}
// fail
return {
...judged,
verdict: "fail",
passed: false,
message: outcome.reason,
expected: judged.expected ?? "Behavior confirmed by a verification run",
actual: outcome.detail ?? judged.actual ?? "Behavior not confirmed",
evidence: [...(judged.evidence ?? []), ...behavioralEvidence],
};
}),
);
@@ -1175,14 +1190,8 @@ export class MissionExecutionLoop extends EventEmitter {
private extractAssertionResults(
parsed: Record<string, unknown>,
assertions: MissionContractAssertion[],
): Array<{ assertionId: string; passed: boolean; message?: string; expected?: string; actual?: string }> {
const results: Array<{
assertionId: string;
passed: boolean;
message?: string;
expected?: string;
actual?: string;
}> = [];
): ValidationResult["assertions"] {
const results: ValidationResult["assertions"] = [];
// If assertions array is provided in the response, use it
if (Array.isArray(parsed.assertions)) {
@@ -1196,14 +1205,31 @@ export class MissionExecutionLoop extends EventEmitter {
? assertionItem.id
: undefined;
const passed = typeof assertionItem.passed === "boolean" ? assertionItem.passed : false;
// FNXC:MissionValidationDiagnostics 2026-07-23-13:15: A validation
// run may fail while an individual assertion is blocked. Preserve that
// identity instead of collapsing every non-pass into a failed assertion.
const verdict = assertionItem.verdict === "pass" || assertionItem.verdict === "fail" || assertionItem.verdict === "blocked"
? assertionItem.verdict
: assertionItem.passed === true ? "pass" : "fail";
const passed = verdict === "pass";
const evidence = Array.isArray(assertionItem.evidence)
? assertionItem.evidence.flatMap((entry) => {
if (typeof entry !== "object" || entry === null) return [];
const candidate = entry as Record<string, unknown>;
const kind = typeof candidate.kind === "string" ? candidate.kind : undefined;
const text = typeof candidate.text === "string" ? candidate.text : undefined;
return kind || text ? [{ ...(kind ? { kind } : {}), ...(text ? { text } : {}) }] : [];
})
: undefined;
results.push({
assertionId: assertionId || "unknown",
verdict,
passed,
message: typeof assertionItem.message === "string" ? assertionItem.message : undefined,
expected: typeof assertionItem.expected === "string" ? assertionItem.expected : undefined,
actual: typeof assertionItem.actual === "string" ? assertionItem.actual : undefined,
...(evidence ? { evidence } : {}),
});
}
}
@@ -1220,6 +1246,7 @@ export class MissionExecutionLoop extends EventEmitter {
if (seen.has(assertion.id)) continue;
results.push({
assertionId: assertion.id,
verdict: overallPassed ? "pass" : "fail",
passed: overallPassed,
message: overallPassed ? "Passed" : "Failed",
});
@@ -1240,6 +1267,7 @@ export class MissionExecutionLoop extends EventEmitter {
status: "error",
assertions: assertions.map((a) => ({
assertionId: a.id,
verdict: "fail",
passed: false,
message: errorMessage,
})),
@@ -1278,10 +1306,12 @@ Respond with a JSON object in this format:
"assertions": [
{
"assertionId": "CA-...",
"verdict": "pass|fail|blocked",
"passed": true|false,
"message": "Explanation if failed",
"message": "Explanation for this verdict",
"expected": "What was expected",
"actual": "What was observed"
"actual": "What was observed",
"evidence": [{ "kind": "file|command|test-output|other", "text": "Concise file, command, or test-output reference used for this verdict" }]
}
],
"summary": "Overall summary of validation",
@@ -1323,6 +1353,7 @@ Evaluation guidance:
- "blocked" means you cannot evaluate due to missing/insufficient evidence or external constraints.
- Partial satisfaction must be marked as failed with clear expected vs actual details.
- Milestone acceptance criteria are validator-executed requirements, not informational context.
- For every assertion, include the concrete evidence you considered. Evidence must identify the relevant file, command, or concise test output; do not include secrets or full unbounded command output.
Response format: Return ONLY a JSON object (no additional text) with this structure:
{
@@ -1333,7 +1364,8 @@ Response format: Return ONLY a JSON object (no additional text) with this struct
"passed": true|false,
"message": "Explanation of your evaluation",
"expected": "What the assertion required",
"actual": "What you observed in the implementation"
"actual": "What you observed in the implementation",
"evidence": [{ "kind": "file|command|test-output|other", "text": "Concise file, command, or test-output reference used for this verdict" }]
}
],
"summary": "A concise summary of your overall evaluation",
@@ -1469,16 +1501,27 @@ ${taskContext ? `\n\nImplementation context:\n${taskContext}` : ""}`;
loopLog.log(`Feature ${featureId} failed validation with ${failures.length} failures`);
// R6 — build an observed-vs-expected reason so the remediation agent sees
// what behavior was wrong, not just which assertion ids failed.
const failureReason = this.buildFailureReason(failures, result.summary);
// R16 — durable observability: a verification/validation failure is a
// persisted mission event, not just a log line.
await this.logFeatureMissionEvent(featureId, "error", "validation_failed", `Validation failed for feature ${featureId}: ${result.summary}`, {
runId: runId ?? null,
// FNXC:MissionValidationDiagnostics 2026-07-23-12:00: The normalized verdict—not an LLM summary—drives every persisted failure surface.
const diagnostics: ValidationDiagnostics = normalizeValidationDiagnostics({
runId: runId ?? "unknown",
sourceFeatureId: featureId,
outcome: "fail",
projectRoot: this.rootDir,
assertions: result.assertions.map((assertion) => ({
assertionId: assertion.assertionId,
verdict: assertion.verdict,
passed: assertion.passed,
message: assertion.message,
expected: assertion.expected,
actual: assertion.actual,
evidence: assertion.evidence,
})),
});
const failureReason = this.buildFailureReason(failures, "");
await this.logFeatureMissionEvent(featureId, "error", "validation_failed", renderValidationFailureDescription(diagnostics), {
validationDiagnostics: diagnostics,
runId: diagnostics.runId,
failedAssertionIds: failures.map((f) => f.assertionId),
reason: failureReason,
outcome: "fail",
});
@@ -1489,11 +1532,23 @@ ${taskContext ? `\n\nImplementation context:\n${taskContext}` : ""}`;
runId || "unknown",
failures.map((f) => f.assertionId),
failureReason,
undefined,
diagnostics,
);
loopLog.log(`Created fix feature ${fixFeature.id} for ${featureId}`);
// Auto-triage the fix feature so the retry loop can continue
try {
// Auto-triage only a newly untriaged fix. createGeneratedFixFeature is
// deliberately idempotent and can return an existing in-progress fix;
// its durable task link is the canonical proof that triage already won.
// FNXC:MissionValidationDiagnostics 2026-07-23-12:35: Duplicate validator triggers must silently reuse a fix feature with a linked board task instead of surfacing a false triage failure.
const linkedFixTask = fixFeature.taskId ? await this.taskStore.getTask(fixFeature.taskId).catch(() => undefined) : undefined;
// FNXC:MissionValidationDiagnostics 2026-07-23-13:15: A stale task ID
// is not proof that remediation is live. Only an open, non-deleted task
// makes duplicate triage safe to suppress; otherwise persist an action.
const hasLiveFixTask = Boolean(linkedFixTask && !linkedFixTask.deletedAt && linkedFixTask.column !== "done" && linkedFixTask.column !== "archived" && linkedFixTask.status !== "failed");
if (hasLiveFixTask) {
loopLog.log(`Fix feature ${fixFeature.id} already has canonical task ${fixFeature.taskId}; skipping duplicate triage`);
} else try {
await this.missionStore.triageFeature(fixFeature.id);
loopLog.log(`Auto-triaged fix feature ${fixFeature.id}`);
} catch (triageErr) {
@@ -1503,10 +1558,10 @@ ${taskContext ? `\n\nImplementation context:\n${taskContext}` : ""}`;
// logged. The branch-group-collision learning: silent triage stalls
// are invisible mission deadlocks. The Fix Feature was created and can
// be triaged manually, so we continue, but the failure is persisted.
await this.logFeatureMissionEvent(featureId, "error", "fix_feature_triage_failed", `Auto-triage of fix feature ${fixFeature.id} failed: ${triageMessage}`, {
await this.logFeatureMissionEvent(featureId, "warning", "fix_feature_triage_needs_attention", `Fix feature ${fixFeature.id} was created but needs operator triage. Inspect the feature and retry triage.`, {
runId: runId ?? null,
fixFeatureId: fixFeature.id,
error: triageMessage,
state: "needs-triage",
});
}
@@ -1529,9 +1584,9 @@ ${taskContext ? `\n\nImplementation context:\n${taskContext}` : ""}`;
} else {
loopLog.error(`Error creating fix feature for ${featureId}:`, message);
// R16 — a swallowed Fix-Feature creation error is durably recorded.
await this.logFeatureMissionEvent(featureId, "error", "fix_feature_creation_failed", `Failed to create fix feature for ${featureId}: ${message}`, {
await this.logFeatureMissionEvent(featureId, "error", "fix_feature_creation_needs_attention", `Validation remediation could not be created for feature ${featureId}. Inspect the validator run and retry validation or triage.`, {
runId: runId ?? null,
error: message,
state: "remediation-creation-failed",
});
}
}