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:
gsxdsm
2026-05-30 14:07:20 -07:00
parent 90a23e9fc1
commit 9c4e8edf39
14 changed files with 357 additions and 11 deletions

View File

@@ -0,0 +1,10 @@
---
"@runfusion/fusion": patch
---
Realize the mission completion-gate contract for live Goals mission workflows.
- Fix mission execution auto-pass behavior so zero-assertion features move to `loopState: "passed"` (not stuck in `implementing`) and emit `feature_auto_passed_no_assertions` telemetry while preserving `validation:passed` emission.
- Add milestone guard signaling for prose acceptance criteria with zero structured assertions via `hasProseButNoAssertions` rollup and warning event `milestone_missing_structured_assertions`.
- Add an idempotent `seedContractAssertionsForFeatures(...)` helper for operator-run assertion persistence and coverage tests.
- Reconcile MissionManager labels/copy to clearly separate enforced contract assertions from informational feature acceptance criteria, including warning badge and indicators.

View File

@@ -6,6 +6,7 @@
- **Task:** FN-5718 - **Task:** FN-5718
- **Depends on enforcement behavior from:** FN-5715 (reference implementation of the trigger/recovery path) - **Depends on enforcement behavior from:** FN-5715 (reference implementation of the trigger/recovery path)
- **Scope:** Product contract and implementation requirements only (no code changes in this task) - **Scope:** Product contract and implementation requirements only (no code changes in this task)
- **Implementation status:** Realized by FN-5733 (loop auto-pass advancement, mission/store guard telemetry, MissionManager label reconciliation)
## Problem ## Problem
@@ -92,6 +93,11 @@ This keeps completion logic deterministic and consistent with FN-5715 trigger/re
## UI Reconciliation Requirements (for follow-on engineering task) ## UI Reconciliation Requirements (for follow-on engineering task)
✅ Implemented in FN-5733 with MissionManager labels:
- `Contract assertions (autopilot gate)` + enforced indicator
- `Feature acceptance criteria (informational)` + not-enforced indicator
- warning badge when `hasProseButNoAssertions === true`
Target surface: `packages/dashboard/app/components/MissionManager.tsx` Target surface: `packages/dashboard/app/components/MissionManager.tsx`
1. **Disambiguate labels** 1. **Disambiguate labels**
@@ -120,6 +126,11 @@ Target surface: `packages/dashboard/app/components/MissionManager.tsx`
## Engineering Acceptance Criteria (follow-on implementation) ## Engineering Acceptance Criteria (follow-on implementation)
✅ Implemented in FN-5733:
- Auto-pass path now advances `loopState` to `passed` and emits mission event code `feature_auto_passed_no_assertions` while preserving the `validation:passed` emit contract (`"No assertions linked"` summary).
- Milestone rollup/store guard now exposes `hasProseButNoAssertions` and emits warning mission event code `milestone_missing_structured_assertions` (debounced on transition into condition).
- MissionManager UI now distinguishes enforced assertion gate vs informational feature acceptance criteria.
1. **Data/model contract** 1. **Data/model contract**
- Preserve the canonical relationship: feature-authored criteria -> store-managed assertion -> linked assertion enforcement. - Preserve the canonical relationship: feature-authored criteria -> store-managed assertion -> linked assertion enforcement.
- If any model/UI metadata is added for enforced-vs-informational badges, it must be backward compatible with existing mission rows. - If any model/UI metadata is added for enforced-vs-informational badges, it must be backward compatible with existing mission rows.

View File

@@ -396,6 +396,8 @@ A feature transitions to `blocked` when:
On engine restart, `recoverActiveMissions()` re-enqueues features in `validating` or `needs_fix` states from the `activeValidations` set, ensuring no validation work is lost. It also re-triggers `implementing` features whose linked task is already `done`/`archived` and whose assertion validation has not passed yet. On engine restart, `recoverActiveMissions()` re-enqueues features in `validating` or `needs_fix` states from the `activeValidations` set, ensuring no validation work is lost. It also re-triggers `implementing` features whose linked task is already `done`/`archived` and whose assertion validation has not passed yet.
For features with zero linked assertions, the completion path is explicit: the loop marks the feature `done`, advances `loopState` to `passed`, emits `validation:passed` with summary `"No assertions linked"`, and records mission event code `feature_auto_passed_no_assertions`.
### Autopilot / Scheduler Interplay ### Autopilot / Scheduler Interplay
The scheduler and autopilot collaborate through a carefully ordered call sequence: The scheduler and autopilot collaborate through a carefully ordered call sequence:
@@ -425,6 +427,8 @@ These are independent tracking mechanisms — autopilot monitors mission progres
**MissionEvent audit types:** **MissionEvent audit types:**
- `slice_activated`, `feature_planned`, `feature_completed` - `slice_activated`, `feature_planned`, `feature_completed`
- `validation:started`, `validation:passed`, `validation:failed`, `validation:blocked` - `validation:started`, `validation:passed`, `validation:failed`, `validation:blocked`
- `feature_auto_passed_no_assertions` (reason: `no_assertions_linked`)
- `milestone_missing_structured_assertions` (warning when prose criteria exist with zero structured assertions)
- `fix_feature:created`, `feature:blocked` - `fix_feature:created`, `feature:blocked`
**Validator run telemetry:** **Validator run telemetry:**

View File

@@ -3032,6 +3032,49 @@ describe("MissionStore", () => {
expect(events[events.length - 1].state).toBe("ready"); // linked but not passed expect(events[events.length - 1].state).toBe("ready"); // linked but not passed
expect(events[events.length - 1].rollup.unlinkedAssertions).toBe(0); 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 ──────────────────── // ── 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", () => { describe("backfillFeatureAssertions", () => {
const makeLegacyFeature = (sliceId: string, input: { title: string; description?: string; acceptanceCriteria?: string }) => { const makeLegacyFeature = (sliceId: string, input: { title: string; description?: string; acceptanceCriteria?: string }) => {
const feature = store.addFeature(sliceId, input); const feature = store.addFeature(sliceId, input);

View File

@@ -143,6 +143,20 @@ export interface MissionAssertionBackfillReport {
skippedErrors: MissionAssertionBackfillErrorRow[]; 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 ───────────────────────────────────────────────────── // ── Event Types ─────────────────────────────────────────────────────
export interface MissionStoreEvents { export interface MissionStoreEvents {
@@ -367,6 +381,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
} }
private _eventSeq = 0; private _eventSeq = 0;
private _milestonesMissingStructuredAssertions = new Set<string>();
// ── Row-to-Object Converters ─────────────────────────────────────── // ── 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. * Backfill assertion links for legacy features that predate the FN-5695 creation-path fix.
* Reuses deriveFeatureAssertion()/ensureFeatureAssertion text-source rules so create/update * 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 assertions = this.listContractAssertions(milestoneId);
const totalAssertions = assertions.length; 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 // Count by status
let passedAssertions = 0; let passedAssertions = 0;
@@ -3156,6 +3218,8 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
state = "ready"; state = "ready";
} }
this.reconcileMissingStructuredAssertionsSignal(milestone, hasProseButNoAssertions);
return { return {
milestoneId, milestoneId,
totalAssertions, totalAssertions,
@@ -3164,10 +3228,35 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
blockedAssertions, blockedAssertions,
pendingAssertions, pendingAssertions,
unlinkedAssertions, unlinkedAssertions,
hasProseButNoAssertions,
state, 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. * Recompute and persist the milestone's validation state.
* This is called automatically after assertion or link changes. * This is called automatically after assertion or link changes.

View File

@@ -615,6 +615,8 @@ export interface MilestoneValidationRollup {
pendingAssertions: number; pendingAssertions: number;
/** Number of assertions not linked to any feature */ /** Number of assertions not linked to any feature */
unlinkedAssertions: number; unlinkedAssertions: number;
/** True when milestone/feature prose criteria exist but no structured assertions are linked */
hasProseButNoAssertions: boolean;
/** The computed validation state */ /** The computed validation state */
state: MilestoneValidationState; state: MilestoneValidationState;
} }

View File

@@ -7249,6 +7249,7 @@ export interface MilestoneValidationRollup {
blockedAssertions: number; blockedAssertions: number;
pendingAssertions: number; pendingAssertions: number;
unlinkedAssertions: number; unlinkedAssertions: number;
hasProseButNoAssertions: boolean;
state: "not_started" | "needs_coverage" | "ready" | "passed" | "failed" | "blocked"; state: "not_started" | "needs_coverage" | "ready" | "passed" | "failed" | "blocked";
} }

View File

@@ -1708,14 +1708,44 @@
align-items: center; align-items: center;
gap: var(--space-sm); gap: var(--space-sm);
margin-bottom: var(--space-sm); margin-bottom: var(--space-sm);
flex-wrap: wrap;
} }
.mission-assertions__title { .mission-assertions__title {
font-size: 13px; font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
font-weight: 600; font-weight: 600;
color: var(--text-muted); color: var(--text-muted);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: calc(var(--btn-border-width) * 2);
}
.mission-assertions__mode-tag {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
font-size: calc(var(--space-sm) + var(--space-xs) * 0.5);
color: var(--text-dim);
background: color-mix(in srgb, var(--surface) 85%, var(--bg));
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: calc(var(--space-xs) * 0.5) var(--space-sm);
}
.mission-assertions__mode-tag--warning {
color: var(--color-warning);
border-color: color-mix(in srgb, var(--color-warning) 40%, var(--border));
}
.mission-assertions__mode-tag--informational {
color: var(--text-muted);
}
.mission-assertions__rollup-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
flex-wrap: wrap;
} }
.mission-assertions__list { .mission-assertions__list {
@@ -2742,8 +2772,12 @@
/* Assertions coverage bar responsive */ /* Assertions coverage bar responsive */
.mission-assertions__coverage-bar { .mission-assertions__coverage-bar {
min-width: 30px; min-width: calc(var(--space-lg) * 2);
max-width: 60px; max-width: calc(var(--space-lg) * 4);
}
.mission-assertions__rollup-header {
align-items: flex-start;
} }
/* Feature lineage and retry budget wrap on narrow screens */ /* Feature lineage and retry budget wrap on narrow screens */

View File

@@ -3486,7 +3486,17 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
{/* Assertions Panel */} {/* Assertions Panel */}
<div className="mission-assertions"> <div className="mission-assertions">
<div className="mission-assertions__header"> <div className="mission-assertions__header">
<span className="mission-assertions__title">Assertions</span> <span className="mission-assertions__title">Contract assertions (autopilot gate)</span>
<span className="mission-assertions__mode-tag" data-testid="milestone-assertions-enforced-indicator">
<span className="status-dot status-dot--running" />
Enforced by autopilot
</span>
{milestoneRollup?.hasProseButNoAssertions && (
<span className="mission-assertions__mode-tag mission-assertions__mode-tag--warning" data-testid="milestone-missing-structured-assertions-badge">
<span className="status-dot status-dot--pending" />
Prose criteria found; add contract assertions
</span>
)}
{milestoneRollup && ( {milestoneRollup && (
<span <span
className="mission-status-badge mission-status-badge--sm" className="mission-status-badge mission-status-badge--sm"
@@ -3741,7 +3751,13 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
// even when milestone.acceptanceCriteria is also populated (FN-4613/FN-4652). // even when milestone.acceptanceCriteria is also populated (FN-4613/FN-4652).
// If FN-4578/4579/4580 (or successors) change the model, update this. // If FN-4578/4579/4580 (or successors) change the model, update this.
<div className="mission-assertions__list" data-testid="milestone-feature-acceptance-rollup"> <div className="mission-assertions__list" data-testid="milestone-feature-acceptance-rollup">
<span className="mission-assertions__title">Completion criteria (from features)</span> <div className="mission-assertions__rollup-header">
<span className="mission-assertions__title">Feature acceptance criteria (informational)</span>
<span className="mission-assertions__mode-tag mission-assertions__mode-tag--informational" data-testid="milestone-feature-acceptance-informational-indicator">
<span className="status-dot status-dot--pending" />
Not enforced by autopilot
</span>
</div>
{featuresWithAcceptanceCriteria.map((feature) => ( {featuresWithAcceptanceCriteria.map((feature) => (
<div key={feature.id} className="mission-assertion"> <div key={feature.id} className="mission-assertion">
<span className="mission-assertion__title">{feature.title}</span> <span className="mission-assertion__title">{feature.title}</span>
@@ -3755,7 +3771,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
) : ( ) : (
!milestone.acceptanceCriteria?.trim() ? ( !milestone.acceptanceCriteria?.trim() ? (
<div className="mission-manager__empty mission-assertions__empty"> <div className="mission-manager__empty mission-assertions__empty">
<span>No assertions defined. Add one to define completion criteria.</span> <span>No contract assertions defined yet. Feature acceptance criteria are informational until assertions are added.</span>
</div> </div>
) : null ) : null
) )

View File

@@ -173,6 +173,7 @@ const mockMilestoneValidationRollup = {
blockedAssertions: 0, blockedAssertions: 0,
pendingAssertions: 0, pendingAssertions: 0,
unlinkedAssertions: 0, unlinkedAssertions: 0,
hasProseButNoAssertions: false,
state: "not_started" as const, state: "not_started" as const,
}; };
@@ -236,6 +237,7 @@ const mockMilestoneValidationTelemetryWithRounds = {
blockedAssertions: 0, blockedAssertions: 0,
pendingAssertions: 0, pendingAssertions: 0,
unlinkedAssertions: 0, unlinkedAssertions: 0,
hasProseButNoAssertions: false,
state: "failed" as const, state: "failed" as const,
}, },
}; };
@@ -276,6 +278,7 @@ const mockBlockedMilestoneTelemetry = {
blockedAssertions: 1, blockedAssertions: 1,
pendingAssertions: 0, pendingAssertions: 0,
unlinkedAssertions: 0, unlinkedAssertions: 0,
hasProseButNoAssertions: false,
state: "blocked" as const, state: "blocked" as const,
}, },
}; };
@@ -4635,7 +4638,7 @@ describe("MissionManager", () => {
}); });
describe("milestone assertions empty-state", () => { describe("milestone assertions empty-state", () => {
const emptyAssertionsCopy = "No assertions defined. Add one to define completion criteria."; const emptyAssertionsCopy = "No contract assertions defined yet. Feature acceptance criteria are informational until assertions are added.";
it("keeps empty-state nudge when assertions and feature acceptance criteria are both missing", async () => { it("keeps empty-state nudge when assertions and feature acceptance criteria are both missing", async () => {
const missionDetail = JSON.parse(JSON.stringify(mockMissionDetail)) as typeof mockMissionDetail; const missionDetail = JSON.parse(JSON.stringify(mockMissionDetail)) as typeof mockMissionDetail;
@@ -4698,7 +4701,15 @@ describe("MissionManager", () => {
}, },
]; ];
globalThis.fetch = createDetailFetchMockForMissionDetail(missionDetail); const telemetryOverride = {
...mockMilestoneValidationTelemetry,
rollup: {
...mockMilestoneValidationRollup,
hasProseButNoAssertions: true,
},
};
globalThis.fetch = createDetailFetchMockForMissionDetail(missionDetail, telemetryOverride);
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />); render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
fireEvent.click(await screen.findByText("Build Auth System")); fireEvent.click(await screen.findByText("Build Auth System"));
@@ -4706,11 +4717,13 @@ describe("MissionManager", () => {
expect(screen.queryByText(emptyAssertionsCopy)).not.toBeInTheDocument(); expect(screen.queryByText(emptyAssertionsCopy)).not.toBeInTheDocument();
const rollup = screen.getByTestId("milestone-feature-acceptance-rollup"); const rollup = screen.getByTestId("milestone-feature-acceptance-rollup");
expect(within(rollup).getByText("Completion criteria (from features)")).toBeInTheDocument(); expect(within(rollup).getByText("Feature acceptance criteria (informational)")).toBeInTheDocument();
expect(within(rollup).getByTestId("milestone-feature-acceptance-informational-indicator")).toHaveTextContent("Not enforced by autopilot");
expect(within(rollup).getByText("Session handling")).toBeInTheDocument(); expect(within(rollup).getByText("Session handling")).toBeInTheDocument();
expect(within(rollup).getByText("Session refresh succeeds without logout", { exact: false })).toBeInTheDocument(); expect(within(rollup).getByText("Session refresh succeeds without logout", { exact: false })).toBeInTheDocument();
expect(within(rollup).getByText("Token storage")).toBeInTheDocument(); expect(within(rollup).getByText("Token storage")).toBeInTheDocument();
expect(within(rollup).getByText("Tokens remain encrypted at rest", { exact: false })).toBeInTheDocument(); expect(within(rollup).getByText("Tokens remain encrypted at rest", { exact: false })).toBeInTheDocument();
expect(screen.getByTestId("milestone-missing-structured-assertions-badge")).toHaveTextContent("Prose criteria found; add contract assertions");
}); });
it("keeps structured assertions precedence and hides rollup when assertions exist", async () => { it("keeps structured assertions precedence and hides rollup when assertions exist", async () => {
@@ -4725,6 +4738,7 @@ describe("MissionManager", () => {
await waitForDetailLoaded(); await waitForDetailLoaded();
expect(screen.getByText("Auth works")).toBeInTheDocument(); expect(screen.getByText("Auth works")).toBeInTheDocument();
expect(screen.getByTestId("milestone-assertions-enforced-indicator")).toHaveTextContent("Enforced by autopilot");
expect(screen.queryByTestId("milestone-feature-acceptance-rollup")).not.toBeInTheDocument(); expect(screen.queryByTestId("milestone-feature-acceptance-rollup")).not.toBeInTheDocument();
}); });
}); });

View File

@@ -133,6 +133,7 @@ export interface MilestoneValidationRollup {
blockedAssertions: number; blockedAssertions: number;
pendingAssertions: number; pendingAssertions: number;
unlinkedAssertions: number; unlinkedAssertions: number;
hasProseButNoAssertions: boolean;
state: MilestoneValidationState; state: MilestoneValidationState;
} }

View File

@@ -174,6 +174,13 @@ function createMockMissionStore() {
features.set(id, updated); features.set(id, updated);
return updated; return updated;
}), }),
updateFeature: vi.fn((id: string, updates: Partial<MissionFeature>) => {
const feature = features.get(id);
if (!feature) throw new Error(`Feature ${id} not found`);
const updated = { ...feature, ...updates, updatedAt: new Date().toISOString() };
features.set(id, updated);
return updated;
}),
transitionLoopState: vi.fn((id: string, newState: MissionFeature["loopState"]) => { transitionLoopState: vi.fn((id: string, newState: MissionFeature["loopState"]) => {
const feature = features.get(id); const feature = features.get(id);
if (!feature) throw new Error(`Feature ${id} not found`); if (!feature) throw new Error(`Feature ${id} not found`);
@@ -507,6 +514,21 @@ describe("MissionExecutionLoop", () => {
"validation:passed", "validation:passed",
expect.objectContaining({ featureId: "F-001" }), expect.objectContaining({ featureId: "F-001" }),
); );
expect(missionStore.updateFeature).toHaveBeenCalledWith(
"F-001",
expect.objectContaining({ loopState: "passed", lastValidatorStatus: "passed" }),
);
expect(missionStore.logMissionEvent).toHaveBeenCalledWith(
expect.any(String),
"warning",
expect.stringContaining("auto-passed"),
expect.objectContaining({
code: "feature_auto_passed_no_assertions",
featureId: "F-001",
reason: "no_assertions_linked",
taskId: "FN-001",
}),
);
expectNoValidationBoardTaskMutation(taskStore); expectNoValidationBoardTaskMutation(taskStore);
}); });
@@ -818,6 +840,7 @@ describe("MissionExecutionLoop", () => {
"passed", "passed",
expect.any(String), expect.any(String),
); );
expect(missionStore.updateFeature).not.toHaveBeenCalled();
expectNoValidationBoardTaskMutation(taskStore); expectNoValidationBoardTaskMutation(taskStore);
}); });

View File

@@ -148,4 +148,45 @@ describe("FN-5715 reliability: mission validation trigger gap", () => {
expect(processSpy).not.toHaveBeenCalled(); expect(processSpy).not.toHaveBeenCalled();
loop.stop(); loop.stop();
}); });
it("recovery replays implementing done tasks with zero assertions and advances loop state", async () => {
const feature = makeFeature({ status: "done", lastValidatorStatus: undefined, loopState: "implementing" });
const missionStore = {
listMissions: vi.fn(() => [{ id: "M-001", status: "active" }]),
getMissionWithHierarchy: vi.fn(() => ({
id: "M-001",
status: "active",
milestones: [{ status: "active", slices: [{ status: "active", features: [feature] }] }],
})),
getFeatureByTaskId: vi.fn(() => feature),
getFeature: vi.fn(() => feature),
updateFeatureStatus: vi.fn(),
updateFeature: vi.fn(),
listAssertionsForFeature: vi.fn(() => []),
getSlice: vi.fn(() => ({ id: "SL-001", milestoneId: "MS-001", status: "active" })),
getMilestone: vi.fn(() => ({ id: "MS-001", missionId: "M-001" })),
logMissionEvent: vi.fn(),
transitionLoopState: vi.fn(),
};
const taskStore = {
getTask: vi.fn(async () => ({ id: "FN-001", column: "done", status: "done" })),
on: vi.fn(),
off: vi.fn(),
};
const loop = new MissionExecutionLoop({
missionStore: missionStore as any,
taskStore: taskStore as any,
rootDir: process.cwd(),
});
loop.start();
await loop.recoverActiveMissions();
expect(missionStore.updateFeature).toHaveBeenCalledWith(
"F-001",
expect.objectContaining({ loopState: "passed", lastValidatorStatus: "passed" }),
);
loop.stop();
});
}); });

View File

@@ -214,7 +214,6 @@ export class MissionExecutionLoop extends EventEmitter {
feature.loopState === "implementing" feature.loopState === "implementing"
&& feature.taskId && feature.taskId
&& feature.lastValidatorStatus !== "passed" && feature.lastValidatorStatus !== "passed"
&& this.missionStore.listAssertionsForFeature(feature.id).length > 0
) { ) {
try { try {
const linkedTask = await this.taskStore.getTask(feature.taskId).catch(() => null); const linkedTask = await this.taskStore.getTask(feature.taskId).catch(() => null);
@@ -803,6 +802,26 @@ ${taskContext ? `\n\nImplementation context:\n${taskContext}` : ""}`;
this.missionStore.updateFeatureStatus(featureId, "done"); this.missionStore.updateFeatureStatus(featureId, "done");
} }
if (!runId && feature) {
// Auto-pass path has no validator run, so we must advance the loop state directly.
if (feature.loopState !== "passed" || feature.lastValidatorStatus !== "passed") {
this.missionStore.updateFeature(featureId, {
loopState: "passed",
lastValidatorStatus: "passed",
});
}
this.logFeatureMissionEvent(
featureId,
"warning",
"feature_auto_passed_no_assertions",
`Feature ${featureId} auto-passed because no assertions were linked.`,
{
taskId: feature.taskId,
reason: "no_assertions_linked",
},
);
}
loopLog.log(`Feature ${featureId} passed validation`); loopLog.log(`Feature ${featureId} passed validation`);
// Notify autopilot if configured // Notify autopilot if configured