FN-8719: fix stale milestone validation badges

Keep persisted milestone validation rollups and dashboard badges synchronized after assertion changes.

- Reconcile assertion mutations and validation rollups atomically within each project.
- Prevent stale rollup and telemetry responses from overwriting newer milestone state.
- Cover repaired and removed failure states with PostgreSQL and dashboard tests.

Files changed:
 .changeset/fn-8719-milestone-validation-rollup.md  |   7 +
 docs/missions.md                                   |   4 +
 .../__tests__/postgres/mission-store.pg.test.ts    |  61 +++++++
 packages/core/src/async-mission-store.ts           | 142 ++++++++++------
 .../dashboard/app/components/MissionManager.tsx    | 108 ++++++++----
 .../MissionManager.validation-rollup.test.tsx      | 181 +++++++++++++++++++++
 6 files changed, 416 insertions(+), 87 deletions(-)

Fusion-Task-Id: FN-8719
Fusion-Task-Lineage: c505211c-935a-4ad3-b35d-81c486e56121
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-01 14:38:45 -07:00
parent 3b633512d8
commit 54c2c1e666
6 changed files with 416 additions and 87 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Keep milestone validation badges current after assertion repairs or removals.
category: fix
dev: Reconciles project-scoped PostgreSQL rollups before events and discards stale dashboard refresh responses by milestone generation.

View File

@@ -492,6 +492,10 @@ interface MilestoneValidationRollup {
5. `failed` — at least one assertion failed
6. `blocked` — at least one assertion is blocked
**Current-state reconciliation:** after every successful assertion create, repair, removal, or feature-link change, the PostgreSQL store recomputes this rollup from current assertions, persists the resulting `milestones.validationState` within the same project partition, then emits the validation refresh event. A repaired final failure therefore cannot leave a persisted `failed` badge behind; a remaining failed assertion still wins the current rollup.
**Dashboard refresh freshness:** rollup and validation-telemetry requests share one monotonically increasing generation per milestone. A response writes badge/panel state only when its captured generation is still current, including initial selection, expansion, mutation refreshes, and SSE events. This is request ordering, not validation-state precedence: a newer response is allowed to legitimately transition a milestone back to `failed`.
#### Completion Gate Contract
Canonical authored feature criteria live on `MissionFeature.acceptanceCriteria`, and each feature validator derives its verdict only from its **linked feature-scoped assertions**. Validator prompts list each authoritative assertion ID in brackets; responses must return exactly one result keyed by each listed ID. To recover older model output safely, only an exact-count response with zero recognized IDs is matched positionally and recorded in diagnostics. Partial matches, duplicate IDs, and count mismatches remain fail-closed. Model summary prose, milestone prose, and behavioral results that are not mapped to a linked behavioral assertion cannot override that verdict.

View File

@@ -35,6 +35,7 @@ import {
listMilestones as listMilestoneRows,
listMissionEvents,
listMissions as listMissionRows,
updateMilestoneValidationState,
} from "../../async-mission-store.js";
import { BUILTIN_CODING_WORKFLOW_IR } from "../../index.js";
@@ -113,6 +114,13 @@ pgTest("MissionStore (PostgreSQL backend mode)", () => {
expect(projectB.events.map(({ description }) => description)).toEqual(["Project B event"]);
expect(await listMissionRows(db)).toEqual([]);
await db.transaction(async (tx) => {
await tx.execute(sql`SELECT set_config('fusion.project_id', 'project-a', true)`);
await updateMilestoneValidationState(tx, "MS-SHARED", "failed");
});
expect((await readProject("project-a")).milestones[0]?.validationState).toBe("failed");
expect((await readProject("project-b")).milestones[0]?.validationState).toBe("not_started");
await db.transaction(async (tx) => {
await tx.execute(sql`SELECT set_config('fusion.project_id', 'project-a', true)`);
expect(await deleteMissionRow(tx, "M-SHARED")).toBe(true);
@@ -683,6 +691,59 @@ pgTest("MissionStore (PostgreSQL backend mode)", () => {
expect(list.find((a) => a.id === created.id)!.assertion).toBe("GET /x returns 200");
});
it("reconciles repaired and deleted failed assertions before publishing validation updates", async () => {
const m = missions();
const mission = await m.createMission({ title: "Current assertion rollup" });
const milestone = await m.addMilestone(mission.id, { title: "MS" });
const validationEvents: Array<{ state: string; rollup: { state: string } }> = [];
m.on("milestone:validation:updated", (payload) => {
if (payload.milestoneId === milestone.id) validationEvents.push(payload);
});
const failed = await m.addContractAssertion(milestone.id, {
title: "Repair me", assertion: "works", status: "failed", scope: "milestone",
});
const expectCurrentState = async (state: "ready" | "passed" | "blocked" | "not_started") => {
expect((await m.getMilestoneValidationRollup(milestone.id)).state).toBe(state);
expect((await m.getMilestone(milestone.id))?.validationState).toBe(state);
expect(validationEvents.at(-1)).toMatchObject({ state, rollup: { state } });
};
expect(await m.getMilestoneValidationRollup(milestone.id)).toMatchObject({ state: "failed", failedAssertions: 1 });
expect((await m.getMilestone(milestone.id))?.validationState).toBe("failed");
await m.updateContractAssertion(failed.id, { status: "pending" });
await expectCurrentState("ready");
await m.updateContractAssertion(failed.id, { status: "passed" });
await expectCurrentState("passed");
await m.updateContractAssertion(failed.id, { status: "blocked" });
await expectCurrentState("blocked");
await m.deleteContractAssertion(failed.id);
await expectCurrentState("not_started");
const remainingFailure = await m.addContractAssertion(milestone.id, {
title: "Still failing", assertion: "fails", status: "failed", scope: "milestone",
});
const repairedFailure = await m.addContractAssertion(milestone.id, {
title: "Repairable", assertion: "also fails", status: "failed", scope: "milestone",
});
await m.updateContractAssertion(repairedFailure.id, { status: "passed" });
expect(await m.getMilestoneValidationRollup(milestone.id)).toMatchObject({ state: "failed", failedAssertions: 1 });
expect((await m.getMilestone(milestone.id))?.validationState).toBe("failed");
await m.deleteContractAssertion(remainingFailure.id);
const concurrentFirst = await m.addContractAssertion(milestone.id, {
title: "Concurrent first", assertion: "first fails", status: "failed", scope: "milestone",
});
const concurrentSecond = await m.addContractAssertion(milestone.id, {
title: "Concurrent second", assertion: "second fails", status: "failed", scope: "milestone",
});
await Promise.all([
m.updateContractAssertion(concurrentFirst.id, { status: "passed" }),
m.updateContractAssertion(concurrentSecond.id, { status: "passed" }),
]);
await expectCurrentState("passed");
});
it("startValidatorRun is returned by getValidatorRunsByFeature", async () => {
const m = missions();
const mission = await m.createMission({ title: "Validated" });

View File

@@ -1958,38 +1958,38 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
// ════════════════ CONTRACT ASSERTIONS ════════════════
async addContractAssertion(milestoneId: string, input: ContractAssertionCreateInput): Promise<MissionContractAssertion> {
const milestone = await getMilestone(this.db, milestoneId);
if (!milestone) throw new Error(`Milestone ${milestoneId} not found`);
const origin = input.origin ?? "authored";
const existing = await listContractAssertions(this.db, milestoneId);
if (origin === "derived_milestone_acceptance"
&& existing.some((assertion) => assertion.origin === "derived_milestone_acceptance")) {
/*
FNXC:MissionValidation 2026-07-23-17:20:
Reject duplicate canonical provenance before insert; PostgreSQL also
enforces this at rest, while authored/imported rows stay non-unique.
*/
throw new Error(`Milestone ${milestoneId} already has a derived milestone acceptance assertion`);
}
const now = new Date().toISOString();
const orderIndex = existing.length > 0 ? Math.max(...existing.map((a) => a.orderIndex)) + 1 : 0;
const assertion: MissionContractAssertion = {
id: this.generateId("CA"),
milestoneId,
sourceFeatureId: input.sourceFeatureId,
scope: input.scope ?? "feature",
origin,
title: input.title,
assertion: input.assertion,
status: input.status || "pending",
type: normalizeMissionAssertionType(input.type),
orderIndex,
createdAt: now,
updatedAt: now,
};
const created = await createContractAssertion(this.db, assertion);
const created = await this.mutateMilestoneAssertions(milestoneId, async (tx) => {
const milestone = await getMilestone(tx, milestoneId);
if (!milestone) throw new Error(`Milestone ${milestoneId} not found`);
const existing = await listContractAssertions(tx, milestoneId);
if (origin === "derived_milestone_acceptance"
&& existing.some((assertion) => assertion.origin === "derived_milestone_acceptance")) {
/*
FNXC:MissionValidation 2026-07-23-17:20:
Reject duplicate canonical provenance before insert; PostgreSQL also
enforces this at rest, while authored/imported rows stay non-unique.
*/
throw new Error(`Milestone ${milestoneId} already has a derived milestone acceptance assertion`);
}
const now = new Date().toISOString();
const orderIndex = existing.length > 0 ? Math.max(...existing.map((a) => a.orderIndex)) + 1 : 0;
return createContractAssertion(tx, {
id: this.generateId("CA"),
milestoneId,
sourceFeatureId: input.sourceFeatureId,
scope: input.scope ?? "feature",
origin,
title: input.title,
assertion: input.assertion,
status: input.status || "pending",
type: normalizeMissionAssertionType(input.type),
orderIndex,
createdAt: now,
updatedAt: now,
});
});
this.emit("assertion:created", created);
await this.recomputeMilestoneValidation(milestoneId);
return created;
}
@@ -2004,26 +2004,32 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
async updateContractAssertion(id: string, updates: ContractAssertionUpdateInput): Promise<MissionContractAssertion> {
const assertion = await getContractAssertion(this.db, id);
if (!assertion) throw new Error(`Assertion ${id} not found`);
const updated: MissionContractAssertion = {
...assertion,
title: updates.title ?? assertion.title,
assertion: updates.assertion ?? assertion.assertion,
status: updates.status ?? assertion.status,
updatedAt: new Date().toISOString(),
};
await updateContractAssertion(this.db, updated);
const updated = await this.mutateMilestoneAssertions(assertion.milestoneId, async (tx) => {
const current = await getContractAssertion(tx, id);
if (!current) throw new Error(`Assertion ${id} not found`);
const next: MissionContractAssertion = {
...current,
title: updates.title ?? current.title,
assertion: updates.assertion ?? current.assertion,
status: updates.status ?? current.status,
updatedAt: new Date().toISOString(),
};
await updateContractAssertion(tx, next);
return next;
});
this.emit("assertion:updated", updated);
await this.recomputeMilestoneValidation(updated.milestoneId);
return updated;
}
async deleteContractAssertion(id: string): Promise<void> {
const assertion = await getContractAssertion(this.db, id);
if (!assertion) throw new Error(`Assertion ${id} not found`);
const milestoneId = assertion.milestoneId;
await deleteContractAssertion(this.db, id);
await this.mutateMilestoneAssertions(assertion.milestoneId, async (tx) => {
const current = await getContractAssertion(tx, id);
if (!current) throw new Error(`Assertion ${id} not found`);
await deleteContractAssertion(tx, id);
});
this.emit("assertion:deleted", id);
await this.recomputeMilestoneValidation(milestoneId);
}
async reorderContractAssertions(milestoneId: string, orderedIds: string[]): Promise<void> {
@@ -2049,8 +2055,8 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
throw new Error(`Feature ${featureId} is already linked to assertion ${assertionId}`);
}
await linkFeatureToAssertion(this.db, featureId, assertionId, new Date().toISOString());
this.emit("assertion:linked", { featureId, assertionId });
await this.recomputeMilestoneValidation(assertion.milestoneId);
this.emit("assertion:linked", { featureId, assertionId });
}
async unlinkFeatureFromAssertion(featureId: string, assertionId: string): Promise<void> {
@@ -2058,9 +2064,9 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
throw new Error(`Feature ${featureId} is not linked to assertion ${assertionId}`);
}
await unlinkFeatureFromAssertion(this.db, featureId, assertionId);
this.emit("assertion:unlinked", { featureId, assertionId });
const assertion = await getContractAssertion(this.db, assertionId);
if (assertion) await this.recomputeMilestoneValidation(assertion.milestoneId);
this.emit("assertion:unlinked", { featureId, assertionId });
}
async listAssertionsForFeature(featureId: string): Promise<MissionContractAssertion[]> {
@@ -2159,15 +2165,15 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
}
// ════════════════ VALIDATION ROLLUP ════════════════
async getMilestoneValidationRollup(milestoneId: string): Promise<MilestoneValidationRollup> {
const milestone = await getMilestone(this.db, milestoneId);
async getMilestoneValidationRollup(milestoneId: string, handle: QueryHandle = this.db): Promise<MilestoneValidationRollup> {
const milestone = await getMilestone(handle, milestoneId);
if (!milestone) throw new Error(`Milestone ${milestoneId} not found`);
const assertions = await listContractAssertions(this.db, milestoneId);
const assertions = await listContractAssertions(handle, milestoneId);
const totalAssertions = assertions.length;
const proseOnMilestone = (milestone.acceptanceCriteria ?? "").trim().length > 0;
const [milestoneFeatures, linkedAssertionIds] = await Promise.all([
listFeaturesForMilestone(this.db, milestoneId),
listLinkedAssertionIds(this.db, assertions.map((assertion) => assertion.id)),
listFeaturesForMilestone(handle, milestoneId),
listLinkedAssertionIds(handle, assertions.map((assertion) => assertion.id)),
]);
const proseOnFeatures = milestoneFeatures.some((feature) => (feature.acceptanceCriteria ?? "").trim().length > 0);
const hasProseButNoAssertions = totalAssertions === 0 && (proseOnMilestone || proseOnFeatures);
@@ -2496,10 +2502,38 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
if (mission && mission.status !== newStatus) await this.updateMission(missionId, { status: newStatus });
}
/*
FNXC:MilestoneValidationReconciliation 2026-08-01-20:42:
Assertion mutations must persist the authoritative current rollup before they publish refresh events. This keeps an operator repair or final-failure deletion from exposing a stale failed milestone state to SSE consumers.
*/
private async recomputeMilestoneValidation(milestoneId: string): Promise<void> {
const rollup = await this.getMilestoneValidationRollup(milestoneId);
await updateMilestoneValidationState(this.db, milestoneId, rollup.state);
await this.mutateMilestoneAssertions(milestoneId, async () => undefined);
}
/*
FNXC:MilestoneValidationReconciliation 2026-08-01-21:02:
Assertion writes and their denormalized milestone rollup share one project-scoped
advisory transaction lock. PostgreSQL READ COMMITTED alone permits two repairs to
publish snapshots in reverse order; the lock makes the committed current rollup
the only state emitted to dashboard refresh consumers.
*/
private async mutateMilestoneAssertions<T>(
milestoneId: string,
mutation: (tx: QueryHandle) => Promise<T>,
): Promise<T> {
let result!: T;
let rollup!: MilestoneValidationRollup;
await this.layer.transactionImmediate(async (tx) => {
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(
CONCAT('mission-validation:', COALESCE(NULLIF(current_setting('fusion.project_id', true), ''), '__legacy_unscoped__'), ':', CAST(${milestoneId} AS text)),
0
))`);
result = await mutation(tx);
rollup = await this.getMilestoneValidationRollup(milestoneId, tx);
await updateMilestoneValidationState(tx, milestoneId, rollup.state);
});
this.emit("milestone:validation:updated", { milestoneId, state: rollup.state, rollup });
return result;
}
private deriveFeatureAssertion(feature: MissionFeature): { assertionText: string; textSource: MissionAssertionTextSource } {
@@ -2581,5 +2615,9 @@ export async function updateMilestoneValidationState(
await handle
.update(schema.project.milestones)
.set({ validationState: state, updatedAt: new Date().toISOString() })
.where(eq(schema.project.milestones.id, milestoneId));
// FNXC:MilestoneValidationReconciliation 2026-08-01-20:42: Shared PostgreSQL milestone IDs must never let one project's validation repair overwrite another project's rollup.
.where(and(
eq(schema.project.milestones.projectId, missionProjectId()),
eq(schema.project.milestones.id, milestoneId),
));
}

View File

@@ -194,6 +194,25 @@ const validationStateColors: Record<string, { bg: string; text: string }> = {
};
const featureRetryBudgetMax = 3;
/**
* FNXC:MilestoneValidationFreshness 2026-08-01-20:42:
* Every rollup and telemetry response for a milestone shares this request generation. Only the newest request may update a badge, including when the newest legitimate state regresses to failed.
*/
export class MilestoneValidationFreshnessCoordinator {
private readonly generations = new Map<string, number>();
begin(milestoneId: string): number {
const generation = (this.generations.get(milestoneId) ?? 0) + 1;
this.generations.set(milestoneId, generation);
return generation;
}
isCurrent(milestoneId: string, generation: number): boolean {
return this.generations.get(milestoneId) === generation;
}
}
const missionInterviewListStatuses: ReadonlySet<AiSessionSummary["status"]> = new Set([
"generating",
"awaiting_input",
@@ -986,6 +1005,8 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
const missionsRef = useRef<MissionWithSummary[]>([]);
const selectedMissionRef = useRef<MissionWithHierarchy | null>(null);
const selectedMilestoneIdRef = useRef<string | null>(null);
// FNXC:MilestoneValidationFreshness 2026-08-01-20:42: Rollup and telemetry responses share one per-milestone generation so an older request cannot restore a repaired failed badge, while a newer failure remains valid.
const validationRequestGenerationRef = useRef(new MilestoneValidationFreshnessCoordinator());
const activeTabRef = useRef<"structure" | "activity">("structure");
const eventsFilterRef = useRef<"all" | "errors" | "state_changes" | "tasks" | "slices" | "autopilot">("all");
const [eventsLoading, setEventsLoading] = useState(false);
@@ -1023,6 +1044,27 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
activeTabRef.current = activeTab;
eventsFilterRef.current = eventsFilter;
const beginValidationRequest = useCallback((milestoneId: string): number =>
validationRequestGenerationRef.current.begin(milestoneId), []);
const isCurrentValidationRequest = useCallback((milestoneId: string, generation: number): boolean =>
validationRequestGenerationRef.current.isCurrent(milestoneId, generation), []);
const loadValidationRollup = useCallback(async (milestoneId: string) => {
const generation = beginValidationRequest(milestoneId);
try {
const rollup = await fetchMilestoneValidation(milestoneId, projectId);
if (!isCurrentValidationRequest(milestoneId, generation)) return;
setValidationRollupByMilestone((prev) => {
const next = new Map(prev);
next.set(milestoneId, rollup);
return next;
});
} catch {
// Silently fail
}
}, [beginValidationRequest, isCurrentValidationRequest, projectId]);
const scrollActivityToLatest = useCallback((behavior: ScrollBehavior = "auto") => {
const endNode = activityEventsEndRef.current;
if (endNode && typeof endNode.scrollIntoView === "function") {
@@ -1152,13 +1194,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
// Load assertions and validation rollup for the selected milestone.
void loadAssertionsForMilestone(nextSelectedMilestoneId);
fetchMilestoneValidation(nextSelectedMilestoneId, projectId).then((rollup) => {
setValidationRollupByMilestone((prev) => {
const next = new Map(prev);
next.set(nextSelectedMilestoneId, rollup);
return next;
});
}).catch(() => { /* silently fail */ });
void loadValidationRollup(nextSelectedMilestoneId);
} else {
setSelectedMilestoneId(null);
setValidationTelemetry(null);
@@ -1169,7 +1205,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
} finally {
setDetailLoading(false);
}
}, [addToast, loadAssertionsForMilestone, projectId]);
}, [addToast, loadAssertionsForMilestone, loadValidationRollup, projectId]);
useEffect(() => {
let cancelled = false;
@@ -1213,15 +1249,23 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
}
let cancelled = false;
const generation = beginValidationRequest(selectedMilestoneId);
setValidationTelemetry(null);
fetchMilestoneValidationTelemetry(selectedMilestoneId, projectId)
.then((telemetry) => {
if (cancelled) {
if (cancelled || !isCurrentValidationRequest(selectedMilestoneId, generation)) {
return;
}
if (!isMilestoneValidationTelemetry(telemetry)) {
setValidationTelemetry(null);
/*
FNXC:MilestoneValidationFreshness 2026-08-01-21:17:
Telemetry is optional, but its request still supersedes every shared badge writer.
Renew the authoritative rollup when telemetry is absent so the discarded older rollup
cannot leave a repaired milestone's previous failed badge in the map.
*/
void loadValidationRollup(selectedMilestoneId);
return;
}
@@ -1233,15 +1277,16 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
});
})
.catch(() => {
if (!cancelled) {
if (!cancelled && isCurrentValidationRequest(selectedMilestoneId, generation)) {
setValidationTelemetry(null);
void loadValidationRollup(selectedMilestoneId);
}
});
return () => {
cancelled = true;
};
}, [isActive, selectedMilestoneId, projectId]);
}, [beginValidationRequest, isActive, isCurrentValidationRequest, loadValidationRollup, selectedMilestoneId, projectId]);
useEffect(() => {
setValidationRoundsExpanded(true);
@@ -1263,10 +1308,17 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
if (!milestoneId || milestoneId !== selectedMilestoneIdRef.current) {
return;
}
const generation = beginValidationRequest(milestoneId);
void fetchMilestoneValidationTelemetry(milestoneId, projectId)
.then((telemetry) => {
if (selectedMilestoneIdRef.current !== milestoneId || !isMilestoneValidationTelemetry(telemetry)) {
if (selectedMilestoneIdRef.current !== milestoneId
|| !isCurrentValidationRequest(milestoneId, generation)) {
return;
}
if (!isMilestoneValidationTelemetry(telemetry)) {
setValidationTelemetry(null);
void loadValidationRollup(milestoneId);
return;
}
setValidationTelemetry(telemetry);
@@ -1277,9 +1329,14 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
});
})
.catch(() => {
// Silently fail - telemetry is supplemental
// Telemetry is supplemental, but the shared generation requires a fresh rollup fallback.
if (selectedMilestoneIdRef.current === milestoneId
&& isCurrentValidationRequest(milestoneId, generation)) {
setValidationTelemetry(null);
void loadValidationRollup(milestoneId);
}
});
}, [projectId]);
}, [beginValidationRequest, isCurrentValidationRequest, loadValidationRollup, projectId]);
const loadMissionEvents = useCallback(async (
missionId: string,
@@ -1908,19 +1965,13 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
next.add(milestoneId);
// Load assertions and validation rollup when expanding milestone
void loadAssertionsForMilestone(milestoneId);
fetchMilestoneValidation(milestoneId, projectId).then((rollup) => {
setValidationRollupByMilestone((prev) => {
const next = new Map(prev);
next.set(milestoneId, rollup);
return next;
});
}).catch(() => { /* silently fail */ });
void loadValidationRollup(milestoneId);
} else {
next.delete(milestoneId);
}
return next;
});
}, [loadAssertionsForMilestone, projectId]);
}, [loadAssertionsForMilestone, loadValidationRollup]);
// Slice handlers
const handleCreateSlice = useCallback((milestoneId: string) => {
@@ -2176,19 +2227,6 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
// ── Assertion handlers ──
const loadValidationRollup = useCallback(async (milestoneId: string) => {
try {
const rollup = await fetchMilestoneValidation(milestoneId, projectId);
setValidationRollupByMilestone((prev) => {
const next = new Map(prev);
next.set(milestoneId, rollup);
return next;
});
} catch {
// Silently fail
}
}, [projectId]);
const handleCreateAssertion = useCallback(async (milestoneId: string) => {
if (!assertionForm.title.trim() || !assertionForm.assertion.trim()) {
addToast(t("missions.assertionFieldsRequired", "Title and assertion text are required"), "error");

View File

@@ -0,0 +1,181 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { MissionManager, MilestoneValidationFreshnessCoordinator } from "../MissionManager";
const mockFetchMissions = vi.fn();
const mockFetchMission = vi.fn();
const mockFetchMissionsHealth = vi.fn();
const mockFetchAssertions = vi.fn();
const mockFetchMilestoneValidation = vi.fn();
const mockFetchMilestoneValidationTelemetry = vi.fn();
const mockFetchAiSessions = vi.fn();
const mockFetchMissionInterviewDrafts = vi.fn();
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../hooks/useNavigationHistory")>();
return {
...actual,
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
};
});
vi.mock("../../api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../api")>();
return {
...actual,
fetchMissions: (...args: unknown[]) => mockFetchMissions(...args),
fetchMission: (...args: unknown[]) => mockFetchMission(...args),
fetchMissionsHealth: (...args: unknown[]) => mockFetchMissionsHealth(...args),
fetchAssertions: (...args: unknown[]) => mockFetchAssertions(...args),
fetchMilestoneValidation: (...args: unknown[]) => mockFetchMilestoneValidation(...args),
fetchMilestoneValidationTelemetry: (...args: unknown[]) => mockFetchMilestoneValidationTelemetry(...args),
fetchAiSessions: (...args: unknown[]) => mockFetchAiSessions(...args),
fetchMissionInterviewDrafts: (...args: unknown[]) => mockFetchMissionInterviewDrafts(...args),
};
});
const failedRollup = {
milestoneId: "MS-1",
totalAssertions: 1,
passedAssertions: 0,
failedAssertions: 1,
blockedAssertions: 0,
pendingAssertions: 0,
unlinkedAssertions: 0,
hasProseButNoAssertions: false,
state: "failed" as const,
};
const passedRollup = {
...failedRollup,
passedAssertions: 1,
failedAssertions: 0,
state: "passed" as const,
};
const mission = {
id: "M-1",
title: "Validation mission",
description: "",
status: "active",
interviewState: "completed",
createdAt: "2026-08-01T00:00:00.000Z",
updatedAt: "2026-08-01T00:00:00.000Z",
milestones: [{
id: "MS-1",
missionId: "M-1",
title: "Validated milestone",
status: "active",
interviewState: "completed",
orderIndex: 0,
dependencies: [],
slices: [],
createdAt: "2026-08-01T00:00:00.000Z",
updatedAt: "2026-08-01T00:00:00.000Z",
}],
};
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((nextResolve) => { resolve = nextResolve; });
return { promise, resolve };
}
function setViewport(width: number) {
Object.defineProperty(window, "innerWidth", { value: width, configurable: true });
Object.defineProperty(window, "matchMedia", {
configurable: true,
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: query.includes("max-width: 768px") ? width <= 768 : false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
}
describe("MilestoneValidationFreshnessCoordinator", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
mockFetchMissions.mockResolvedValue([{ ...mission, milestones: [] }]);
mockFetchMission.mockResolvedValue(mission);
mockFetchMissionsHealth.mockResolvedValue({});
mockFetchAssertions.mockResolvedValue([]);
mockFetchAiSessions.mockResolvedValue([]);
mockFetchMissionInterviewDrafts.mockResolvedValue([]);
});
it("keeps the newer SSE refresh authoritative when an older failed response settles last", () => {
const coordinator = new MilestoneValidationFreshnessCoordinator();
const oldRequest = coordinator.begin("MS-1");
const sseRequest = coordinator.begin("MS-1");
expect(coordinator.isCurrent("MS-1", sseRequest)).toBe(true);
expect(coordinator.isCurrent("MS-1", oldRequest)).toBe(false);
});
it.each([
["desktop", 1280],
["mobile", 375],
])("renders the repaired fallback rollup at both badge sites on %s after an older failed response settles", async (_surface, width) => {
setViewport(width);
const oldRollup = deferred<typeof failedRollup>();
const repairedRollup = deferred<typeof passedRollup>();
mockFetchMilestoneValidation
.mockReturnValueOnce(oldRollup.promise)
.mockReturnValueOnce(repairedRollup.promise);
// The newer telemetry request intentionally has no payload, requiring the component fallback.
mockFetchMilestoneValidationTelemetry.mockResolvedValue(undefined);
render(
<MissionManager
isInline
isOpen
onClose={() => {}}
addToast={() => {}}
projectId="p1"
targetMissionId="M-1"
/>,
);
await waitFor(() => expect(mockFetchMilestoneValidationTelemetry).toHaveBeenCalledWith("MS-1", "p1"));
await waitFor(() => expect(mockFetchMilestoneValidation).toHaveBeenCalledTimes(2));
repairedRollup.resolve(passedRollup);
await waitFor(() => {
expect(screen.getByTitle("Validation state")).toHaveTextContent("Passed");
expect(screen.getAllByText("Passed")).toHaveLength(2);
});
oldRollup.resolve(failedRollup);
await waitFor(() => {
expect(screen.getByTitle("Validation state")).toHaveTextContent("Passed");
expect(screen.getAllByText("Passed")).toHaveLength(2);
expect(screen.queryByText("Failed")).not.toBeInTheDocument();
});
});
it("permits a subsequent newest-generation transition back to failed", () => {
const coordinator = new MilestoneValidationFreshnessCoordinator();
const repairedRequest = coordinator.begin("MS-1");
const laterFailureRequest = coordinator.begin("MS-1");
expect(coordinator.isCurrent("MS-1", repairedRequest)).toBe(false);
expect(coordinator.isCurrent("MS-1", laterFailureRequest)).toBe(true);
});
it("isolates concurrent milestone refreshes", () => {
const coordinator = new MilestoneValidationFreshnessCoordinator();
const firstMilestone = coordinator.begin("MS-1");
const secondMilestone = coordinator.begin("MS-2");
expect(coordinator.isCurrent("MS-1", firstMilestone)).toBe(true);
expect(coordinator.isCurrent("MS-2", secondMilestone)).toBe(true);
});
});