FN-5733: enforce mission completion-gate assertions for live Goals mission
Realize the mission completion-gate contract by surfacing and enforcing structured assertion coverage for milestone completion. - add milestone rollup signaling for prose-only acceptance criteria and wire it into mission-store completion gating - update mission execution loop reliability paths so validator-trigger continuity is preserved across completion and startup recovery - expose the new rollup flag in dashboard mission types/API and show an autopilot warning badge in MissionManager when prose exists without structured assertions - extend core, engine, and dashboard tests plus mission docs, and add a patch changeset for @runfusion/fusion Files changed: .changeset/fn-5733-mission-completion-gate.md | 10 +++ docs/missions-completion-contract.md | 11 +++ docs/missions.md | 4 + packages/core/src/__tests__/mission-store.test.ts | 81 ++++++++++++++++++++ packages/core/src/mission-store.ts | 89 ++++++++++++++++++++++ packages/core/src/mission-types.ts | 2 + packages/dashboard/app/api/legacy.ts | 1 + .../dashboard/app/components/MissionManager.css | 42 +++++++++- .../dashboard/app/components/MissionManager.tsx | 22 +++++- .../components/__tests__/MissionManager.test.tsx | 20 ++++- packages/dashboard/app/components/mission-types.ts | 1 + .../src/__tests__/mission-execution-loop.test.ts | 23 ++++++ .../mission-validation-trigger-gap.test.ts | 41 ++++++++++ packages/engine/src/mission-execution-loop.ts | 21 ++++- 14 files changed, 357 insertions(+), 11 deletions(-) Fusion-Task-Id: FN-5733 Fusion-Task-Lineage: 259418c6-5404-4773-8fd2-db8d7e9cadd3
This commit is contained in:
@@ -3032,6 +3032,49 @@ describe("MissionStore", () => {
|
||||
expect(events[events.length - 1].state).toBe("ready"); // linked but not passed
|
||||
expect(events[events.length - 1].rollup.unlinkedAssertions).toBe(0);
|
||||
});
|
||||
|
||||
it("flags rollup when milestone prose exists but no assertions are linked", () => {
|
||||
const updatedMission = store.updateMission(mission.id, { status: "active" });
|
||||
expect(updatedMission.status).toBe("active");
|
||||
store.updateMilestone(milestone.id, { acceptanceCriteria: "Milestone prose" });
|
||||
|
||||
const warningEvents: Array<{ id: string; code: unknown }> = [];
|
||||
store.on("mission:event", (event) => {
|
||||
if (event.eventType === "warning") {
|
||||
warningEvents.push({ id: event.id, code: event.metadata?.code });
|
||||
}
|
||||
});
|
||||
|
||||
const rollup = store.getMilestoneValidationRollup(milestone.id);
|
||||
expect(rollup.hasProseButNoAssertions).toBe(true);
|
||||
expect(store.milestoneHasProseButNoAssertions(milestone.id)).toBe(true);
|
||||
|
||||
const assertion = store.addContractAssertion(milestone.id, { title: "A1", assertion: "Temp" });
|
||||
store.deleteContractAssertion(assertion.id);
|
||||
|
||||
expect(warningEvents.some((event) => event.code === "milestone_missing_structured_assertions")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not flag rollup when assertions exist", () => {
|
||||
store.updateMilestone(milestone.id, { acceptanceCriteria: "Milestone prose" });
|
||||
const assertion = store.addContractAssertion(milestone.id, { title: "A1", assertion: "Test" });
|
||||
const slice = store.addSlice(milestone.id, { title: "Slice" });
|
||||
const feature = store.addFeature(slice.id, { title: "Feature" });
|
||||
store.linkFeatureToAssertion(feature.id, assertion.id);
|
||||
|
||||
const rollup = store.getMilestoneValidationRollup(milestone.id);
|
||||
expect(rollup.hasProseButNoAssertions).toBe(false);
|
||||
expect(store.milestoneHasProseButNoAssertions(milestone.id)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not flag rollup when neither milestone nor features have prose", () => {
|
||||
const slice = store.addSlice(milestone.id, { title: "Slice" });
|
||||
store.addFeature(slice.id, { title: "Feature" });
|
||||
|
||||
const rollup = store.getMilestoneValidationRollup(milestone.id);
|
||||
expect(rollup.hasProseButNoAssertions).toBe(false);
|
||||
expect(store.milestoneHasProseButNoAssertions(milestone.id)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── buildEnrichedDescription with Assertions Tests ────────────────────
|
||||
@@ -3149,6 +3192,44 @@ describe("MissionStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("seedContractAssertionsForFeatures", () => {
|
||||
it("seeds and links authored assertions idempotently", () => {
|
||||
const mission = store.createMission({ title: "Seed mission" });
|
||||
const milestone = store.addMilestone(mission.id, { title: "M1" });
|
||||
const slice = store.addSlice(milestone.id, { title: "S1" });
|
||||
const feature = store.addFeature(slice.id, { title: "F1", acceptanceCriteria: "AC" });
|
||||
|
||||
const beforeManaged = store.listAssertionsForFeature(feature.id).length;
|
||||
|
||||
const first = store.seedContractAssertionsForFeatures([
|
||||
{
|
||||
featureId: feature.id,
|
||||
milestoneId: milestone.id,
|
||||
title: "Authored assertion",
|
||||
assertion: "Feature output is deterministic",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(first.created).toBe(1);
|
||||
expect(first.linked).toBe(1);
|
||||
expect(first.skippedExisting).toBe(0);
|
||||
|
||||
const second = store.seedContractAssertionsForFeatures([
|
||||
{
|
||||
featureId: feature.id,
|
||||
milestoneId: milestone.id,
|
||||
title: "Authored assertion",
|
||||
assertion: "Feature output is deterministic",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(second.created).toBe(0);
|
||||
expect(second.linked).toBe(0);
|
||||
expect(second.skippedExisting).toBe(1);
|
||||
expect(store.listAssertionsForFeature(feature.id).length).toBe(beforeManaged + 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("backfillFeatureAssertions", () => {
|
||||
const makeLegacyFeature = (sliceId: string, input: { title: string; description?: string; acceptanceCriteria?: string }) => {
|
||||
const feature = store.addFeature(sliceId, input);
|
||||
|
||||
@@ -143,6 +143,20 @@ export interface MissionAssertionBackfillReport {
|
||||
skippedErrors: MissionAssertionBackfillErrorRow[];
|
||||
}
|
||||
|
||||
export interface MissionAssertionSeedInput {
|
||||
featureId: string;
|
||||
milestoneId: string;
|
||||
title: string;
|
||||
assertion: string;
|
||||
}
|
||||
|
||||
export interface MissionAssertionSeedReport {
|
||||
scanned: number;
|
||||
created: number;
|
||||
linked: number;
|
||||
skippedExisting: number;
|
||||
}
|
||||
|
||||
// ── Event Types ─────────────────────────────────────────────────────
|
||||
|
||||
export interface MissionStoreEvents {
|
||||
@@ -367,6 +381,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
}
|
||||
|
||||
private _eventSeq = 0;
|
||||
private _milestonesMissingStructuredAssertions = new Set<string>();
|
||||
|
||||
// ── Row-to-Object Converters ───────────────────────────────────────
|
||||
|
||||
@@ -1967,6 +1982,48 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotently seed authored contract assertions for specific features.
|
||||
*
|
||||
* Re-running this method is safe: existing equivalent feature-linked assertions are skipped.
|
||||
*/
|
||||
seedContractAssertionsForFeatures(inputs: MissionAssertionSeedInput[]): MissionAssertionSeedReport {
|
||||
let created = 0;
|
||||
let linked = 0;
|
||||
let skippedExisting = 0;
|
||||
|
||||
for (const input of inputs) {
|
||||
const existingLinked = this.listAssertionsForFeature(input.featureId).find((assertion) =>
|
||||
assertion.milestoneId === input.milestoneId
|
||||
&& assertion.title.trim() === input.title.trim()
|
||||
&& assertion.assertion.trim() === input.assertion.trim(),
|
||||
);
|
||||
|
||||
if (existingLinked) {
|
||||
skippedExisting += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const createdAssertion = this.addContractAssertion(input.milestoneId, {
|
||||
title: input.title,
|
||||
assertion: input.assertion,
|
||||
status: "pending",
|
||||
sourceFeatureId: input.featureId,
|
||||
});
|
||||
created += 1;
|
||||
|
||||
this.linkFeatureToAssertion(input.featureId, createdAssertion.id);
|
||||
linked += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
scanned: inputs.length,
|
||||
created,
|
||||
linked,
|
||||
skippedExisting,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill assertion links for legacy features that predate the FN-5695 creation-path fix.
|
||||
* Reuses deriveFeatureAssertion()/ensureFeatureAssertion text-source rules so create/update
|
||||
@@ -3102,6 +3159,11 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
|
||||
const assertions = this.listContractAssertions(milestoneId);
|
||||
const totalAssertions = assertions.length;
|
||||
const proseOnMilestone = (milestone.acceptanceCriteria ?? "").trim().length > 0;
|
||||
const proseOnFeatures = this.listSlices(milestoneId)
|
||||
.flatMap((slice) => this.listFeatures(slice.id))
|
||||
.some((feature) => (feature.acceptanceCriteria ?? "").trim().length > 0);
|
||||
const hasProseButNoAssertions = totalAssertions === 0 && (proseOnMilestone || proseOnFeatures);
|
||||
|
||||
// Count by status
|
||||
let passedAssertions = 0;
|
||||
@@ -3156,6 +3218,8 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
state = "ready";
|
||||
}
|
||||
|
||||
this.reconcileMissingStructuredAssertionsSignal(milestone, hasProseButNoAssertions);
|
||||
|
||||
return {
|
||||
milestoneId,
|
||||
totalAssertions,
|
||||
@@ -3164,10 +3228,35 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
blockedAssertions,
|
||||
pendingAssertions,
|
||||
unlinkedAssertions,
|
||||
hasProseButNoAssertions,
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
milestoneHasProseButNoAssertions(milestoneId: string): boolean {
|
||||
return this.getMilestoneValidationRollup(milestoneId).hasProseButNoAssertions;
|
||||
}
|
||||
|
||||
private reconcileMissingStructuredAssertionsSignal(milestone: Milestone, hasProseButNoAssertions: boolean): void {
|
||||
if (hasProseButNoAssertions) {
|
||||
// Debounce per process: emit on first transition into this condition so
|
||||
// operators can detect regressions without flooding every recompute cycle.
|
||||
if (!this._milestonesMissingStructuredAssertions.has(milestone.id)) {
|
||||
const mission = this.getMission(milestone.missionId);
|
||||
if (mission) {
|
||||
this.logMissionEvent(mission.id, "warning", `Milestone ${milestone.id} has prose acceptance criteria but no structured assertions.`, {
|
||||
code: "milestone_missing_structured_assertions",
|
||||
milestoneId: milestone.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
this._milestonesMissingStructuredAssertions.add(milestone.id);
|
||||
return;
|
||||
}
|
||||
|
||||
this._milestonesMissingStructuredAssertions.delete(milestone.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute and persist the milestone's validation state.
|
||||
* This is called automatically after assertion or link changes.
|
||||
|
||||
@@ -615,6 +615,8 @@ export interface MilestoneValidationRollup {
|
||||
pendingAssertions: number;
|
||||
/** Number of assertions not linked to any feature */
|
||||
unlinkedAssertions: number;
|
||||
/** True when milestone/feature prose criteria exist but no structured assertions are linked */
|
||||
hasProseButNoAssertions: boolean;
|
||||
/** The computed validation state */
|
||||
state: MilestoneValidationState;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user