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
|
||||
- **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)
|
||||
- **Implementation status:** Realized by FN-5733 (loop auto-pass advancement, mission/store guard telemetry, MissionManager label reconciliation)
|
||||
|
||||
## 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)
|
||||
|
||||
✅ 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`
|
||||
|
||||
1. **Disambiguate labels**
|
||||
@@ -120,6 +126,11 @@ Target surface: `packages/dashboard/app/components/MissionManager.tsx`
|
||||
|
||||
## 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**
|
||||
- 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
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:**
|
||||
- `slice_activated`, `feature_planned`, `feature_completed`
|
||||
- `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`
|
||||
|
||||
**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].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;
|
||||
}
|
||||
|
||||
@@ -7249,6 +7249,7 @@ export interface MilestoneValidationRollup {
|
||||
blockedAssertions: number;
|
||||
pendingAssertions: number;
|
||||
unlinkedAssertions: number;
|
||||
hasProseButNoAssertions: boolean;
|
||||
state: "not_started" | "needs_coverage" | "ready" | "passed" | "failed" | "blocked";
|
||||
}
|
||||
|
||||
|
||||
@@ -1708,14 +1708,44 @@
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mission-assertions__title {
|
||||
font-size: 13px;
|
||||
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
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 {
|
||||
@@ -2742,8 +2772,12 @@
|
||||
|
||||
/* Assertions coverage bar responsive */
|
||||
.mission-assertions__coverage-bar {
|
||||
min-width: 30px;
|
||||
max-width: 60px;
|
||||
min-width: calc(var(--space-lg) * 2);
|
||||
max-width: calc(var(--space-lg) * 4);
|
||||
}
|
||||
|
||||
.mission-assertions__rollup-header {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* Feature lineage and retry budget wrap on narrow screens */
|
||||
|
||||
@@ -3486,7 +3486,17 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
{/* Assertions Panel */}
|
||||
<div className="mission-assertions">
|
||||
<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 && (
|
||||
<span
|
||||
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).
|
||||
// If FN-4578/4579/4580 (or successors) change the model, update this.
|
||||
<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) => (
|
||||
<div key={feature.id} className="mission-assertion">
|
||||
<span className="mission-assertion__title">{feature.title}</span>
|
||||
@@ -3755,7 +3771,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
) : (
|
||||
!milestone.acceptanceCriteria?.trim() ? (
|
||||
<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>
|
||||
) : null
|
||||
)
|
||||
|
||||
@@ -173,6 +173,7 @@ const mockMilestoneValidationRollup = {
|
||||
blockedAssertions: 0,
|
||||
pendingAssertions: 0,
|
||||
unlinkedAssertions: 0,
|
||||
hasProseButNoAssertions: false,
|
||||
state: "not_started" as const,
|
||||
};
|
||||
|
||||
@@ -236,6 +237,7 @@ const mockMilestoneValidationTelemetryWithRounds = {
|
||||
blockedAssertions: 0,
|
||||
pendingAssertions: 0,
|
||||
unlinkedAssertions: 0,
|
||||
hasProseButNoAssertions: false,
|
||||
state: "failed" as const,
|
||||
},
|
||||
};
|
||||
@@ -276,6 +278,7 @@ const mockBlockedMilestoneTelemetry = {
|
||||
blockedAssertions: 1,
|
||||
pendingAssertions: 0,
|
||||
unlinkedAssertions: 0,
|
||||
hasProseButNoAssertions: false,
|
||||
state: "blocked" as const,
|
||||
},
|
||||
};
|
||||
@@ -4635,7 +4638,7 @@ describe("MissionManager", () => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
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()} />);
|
||||
|
||||
fireEvent.click(await screen.findByText("Build Auth System"));
|
||||
@@ -4706,11 +4717,13 @@ describe("MissionManager", () => {
|
||||
|
||||
expect(screen.queryByText(emptyAssertionsCopy)).not.toBeInTheDocument();
|
||||
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 refresh succeeds without logout", { exact: false })).toBeInTheDocument();
|
||||
expect(within(rollup).getByText("Token storage")).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 () => {
|
||||
@@ -4725,6 +4738,7 @@ describe("MissionManager", () => {
|
||||
await waitForDetailLoaded();
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -133,6 +133,7 @@ export interface MilestoneValidationRollup {
|
||||
blockedAssertions: number;
|
||||
pendingAssertions: number;
|
||||
unlinkedAssertions: number;
|
||||
hasProseButNoAssertions: boolean;
|
||||
state: MilestoneValidationState;
|
||||
}
|
||||
|
||||
|
||||
@@ -174,6 +174,13 @@ function createMockMissionStore() {
|
||||
features.set(id, 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"]) => {
|
||||
const feature = features.get(id);
|
||||
if (!feature) throw new Error(`Feature ${id} not found`);
|
||||
@@ -507,6 +514,21 @@ describe("MissionExecutionLoop", () => {
|
||||
"validation:passed",
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -818,6 +840,7 @@ describe("MissionExecutionLoop", () => {
|
||||
"passed",
|
||||
expect.any(String),
|
||||
);
|
||||
expect(missionStore.updateFeature).not.toHaveBeenCalled();
|
||||
expectNoValidationBoardTaskMutation(taskStore);
|
||||
});
|
||||
|
||||
|
||||
@@ -148,4 +148,45 @@ describe("FN-5715 reliability: mission validation trigger gap", () => {
|
||||
expect(processSpy).not.toHaveBeenCalled();
|
||||
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.taskId
|
||||
&& feature.lastValidatorStatus !== "passed"
|
||||
&& this.missionStore.listAssertionsForFeature(feature.id).length > 0
|
||||
) {
|
||||
try {
|
||||
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");
|
||||
}
|
||||
|
||||
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`);
|
||||
|
||||
// Notify autopilot if configured
|
||||
|
||||
Reference in New Issue
Block a user