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:
10
.changeset/fn-5733-mission-completion-gate.md
Normal file
10
.changeset/fn-5733-mission-completion-gate.md
Normal 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.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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:**
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user