FN-5902: make mission validation AI-run all criteria

Route every mission feature through validator-backed completion checks.

- lazily restore a managed feature assertion before validation instead of auto-passing zero-assertion features
- thread milestone acceptance criteria into validator prompts and system instructions as enforced requirements
- update MissionManager copy/tests to present criteria as AI-validated runtime gates and remove informational-only/zero-assertion warnings
- document the all-criteria AI-run contract and add a changeset for @runfusion/fusion

Files changed:
 .changeset/fn-5902-mission-validation-ai-run.md    |   5 +
 AGENTS.md                                          |   2 +-
 docs/architecture.md                               |   2 +-
 docs/missions-completion-contract.md               | 198 ++++++---------------
 docs/missions.md                                   |   5 +-
 packages/core/src/__tests__/mission-store.test.ts  |  23 ++-
 packages/core/src/mission-store.ts                 |  10 ++
 packages/dashboard/app/components/MissionManager.css    |  31 ----
 packages/dashboard/app/components/MissionManager.tsx    |  86 +++------
 packages/dashboard/app/components/__tests__/MissionManager.test.tsx   |  60 +++++--
 packages/engine/src/__tests__/mission-execution-loop.test.ts   | 111 +++++++++---
 packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts         |  57 +++---
 packages/engine/src/mission-execution-loop.ts      |  78 ++++----
 13 files changed, 318 insertions(+), 350 deletions(-)

Fusion-Task-Id: FN-5902

Fusion-Task-Lineage: 5f25caad-33c9-42ff-822b-1ea092afc29f
This commit is contained in:
gsxdsm
2026-06-02 18:11:26 -07:00
parent abbeaec0a8
commit cc18206bc5
13 changed files with 318 additions and 350 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Mission validation now AI-validates all mission criteria by lazily ensuring a per-feature managed assertion at runtime and removing the zero-assertion auto-pass path. Milestone acceptance criteria are threaded into validator prompts, and the dashboard now presents mission criteria as AI-validated instead of informational-only.

View File

@@ -169,7 +169,7 @@ Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignme
- FN-5403 backstop: `packages/engine/src/__tests__/reliability-interactions/engine-stop-aborts-execution.test.ts` locks stop-ordering behavior so engine shutdown aborts executor AI sessions before drain wait and preserves task-row lifecycle semantics.
- FN-5704 backstop: `packages/engine/src/__tests__/reliability-interactions/reclaim-self-owned-resume-limbo-escalation.test.ts` guards reclaim/unpause no-progress oscillation recovery by capping repeated no-progress resumes, escalating to preserve-work `todo` rebound, and emitting `task:resume-limbo-escalated` audit metadata while exempting progress/user-paused/autoMerge-off cases.
- FN-5715 backstop: `packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts` guards mission validation trigger continuity so done task completion and startup recovery both route assertion-linked features through validator runs before completion.
- FN-5738 backstop: `packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts` extends mission-loop coverage so zero-assertion auto-pass deterministically advances to `loopState="passed"` and emits `validation_auto_passed_no_assertions` without duplicate recovery re-fire.
- FN-5738 backstop (superseded by FN-5902): `packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts` now guards the inverted contract so legacy zero-link mission features lazily restore a managed assertion, route through validator runs, and never emit `validation_auto_passed_no_assertions` during recovery replays.
- FN-5741 backstop: `packages/engine/src/__tests__/reliability-interactions/merge-request-shadow-handoff.test.ts` guards Phase-1 write-only-shadow merge-request record + handoff-accepted marker seam (flag OFF = no-op, ON = shadow-only non-authoritative).
- FN-5742 backstop: `packages/engine/src/__tests__/reliability-interactions/dual-observe-merge-seam.test.ts` guards Phase-2 dual-observe parity (dependency + lease diffs, shadow dequeue parity, manual-required shadow skip) while legacy behavior remains authoritative.
- FN-5743 backstop: `packages/engine/src/__tests__/reliability-interactions/merge-request-cancel-on-hard-cancel.test.ts` plus `packages/core/src/__tests__/merge-request-record.test.ts` guard Phase-3 cutover semantics (merge-request retry state transitions, authoritative user hard-cancel tombstone, and non-user rebound no-op cancel semantics).

View File

@@ -1779,7 +1779,7 @@ Reliability-layer changes are in scope. Interaction regression backstops live in
- FN-5830 backstop: `packages/engine/src/__tests__/reliability-interactions/branch-group-promotion.test.ts` guards branch-group completion-gate + promotion lifecycle so promotion happens exactly once after all members land, re-calls are idempotent, and gated paths emit `merge:branch-group-promotion-gated` without default-branch promotion.
- FN-5819/FN-5846 backstop: `packages/engine/src/__tests__/reliability-interactions/shared-group-member-integration.test.ts` and `shared-branch-group-lifecycle.test.ts` guard the scoped autoMerge-off exception and deterministic finalize path so shared members integrate into the single group branch, produce `mergeTargetSource: "branch-group-integration"`/`mergeTargetBranch`, do not land on main, and are not moved backward by self-healing maintenance.
- FN-5901 backstop: `packages/engine/src/__tests__/reliability-interactions/mission-validator-run-reaper.test.ts` guards stale mission-validator-run recovery across manual and automatic trigger types, verifies `mission:validator-run-reaped` audit metadata, ensures archived/complete parents keep their terminal feature state untouched, and proves reaped active features resume validation instead of staying wedged behind abandoned `running` rows.
- FN-5738 backstop: `packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts` extends coverage so zero-assertion auto-pass deterministically advances `loopState` to `passed`, sets `lastValidatorStatus="passed"`, emits `validation_auto_passed_no_assertions`, and does not re-fire on repeated recovery passes.
- FN-5738 backstop (superseded by FN-5902): `packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts` no longer permits zero-assertion auto-pass. Current coverage proves legacy zero-link features lazily restore a managed assertion, route through validator runs, and do not emit `validation_auto_passed_no_assertions` during recovery replays.
- FN-5741 backstop: `packages/engine/src/__tests__/reliability-interactions/merge-request-shadow-handoff.test.ts` guards Phase-1 merge-request contract shadow writes: flag OFF is a no-op, flag ON writes marker/record strictly after legacy handoff, and `autoMerge:false` remains `manual-required` without shadow running transitions.
- FN-5742 backstop: `packages/engine/src/__tests__/reliability-interactions/dual-observe-merge-seam.test.ts` guards Phase-2 dual-observe invariants: legacy dependency satisfaction remains authoritative while parity diffs emit, and shadow dequeue selection never advances `manual-required` rows.
- FN-5743 backstop: `packages/engine/src/__tests__/reliability-interactions/merge-request-cancel-on-hard-cancel.test.ts` and `packages/core/src/__tests__/merge-request-record.test.ts` guard Phase-3 cutover invariants: transient merge retries mutate merge-request state (no column rebound), user hard-cancel after accepted handoff cancels pending merge requests, and non-user rebounds preserve legacy fail-soft semantics.

View File

@@ -2,175 +2,93 @@
## Status
- **Decision date:** 2026-05-30
- **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)
- **Decision date:** 2026-06-02
- **Current contract task:** FN-5902
- **Supersedes:** FN-5718 baseline contract
- **Depends on runtime trigger/recovery behavior from:** FN-5715
- **Implementation status:** Realized by FN-5902
## Problem
## Decision
Mission validation has had a recurring ambiguity: users can see feature acceptance text and milestone "completion criteria" text, but autopilot enforcement is actually driven by assertion linkage + validator outcomes. This document defines the canonical enforced gate so mission completion cannot silently stall or be misread.
Mission completion now uses an **all-criteria AI-run contract**:
## Canonical Completion Gate (Enforced)
### Decision
A feature is autopilot-complete only when **its linked contract assertions are satisfied**.
Canonical authored source and enforcement path:
1. `MissionFeature.acceptanceCriteria` is the canonical authored criteria text (authoring surface).
2. MissionStore must maintain a **store-managed per-feature `MissionContractAssertion`** derived from feature content, with text priority:
1. `MissionFeature.acceptanceCriteria` is the canonical authored feature criteria text.
2. MissionStore must maintain or lazily restore **one store-managed per-feature `MissionContractAssertion`** derived from feature content with text priority:
- `feature.acceptanceCriteria`
- `feature.description`
- `Verify implementation of: {feature.title}`
3. The mission validator enforces completion using the feature's **linked assertions** (including its store-managed assertion and any additional linked milestone assertions).
4. Feature/slice/mission advance is gated by the validator outcome (or explicit no-assertions auto-pass behavior defined below).
3. The mission validator must run for every feature completion trigger. Runtime validation may lazily call `ensureFeatureAssertionLinked(feature.id)` before starting the validator so legacy missing-link rows still become validator-backed.
4. `milestone.acceptanceCriteria` is also part of the enforced gate by being threaded into the validator prompt for every feature in that milestone.
5. Feature, slice, milestone, and mission advancement are gated by the validator result — **not** by an informational-only path.
### Precedence and interpretation rules
## Enforcement Model
- `MissionFeature.acceptanceCriteria` is the canonical authoring field for feature-level intent.
- The **linked assertion set** is the canonical enforcement set.
- Milestone `MissionContractAssertion` rows are additive contract rows. They are enforced **only when linked to a feature**.
- `milestone.acceptanceCriteria` is descriptive/informational milestone text and is not directly executed by the validator.
### Feature-level enforcement
### Worked examples
A feature is autopilot-complete only when the validator passes after evaluating:
1. **Feature has acceptance criteria; store-managed assertion linked; validator passes**
- Result: feature may move to done/passed and contribute to slice completion.
2. **Feature has acceptance criteria; additive milestone assertion also linked; one linked assertion fails**
- Result: feature is not complete; no slice advance.
3. **Feature has acceptance criteria visible, but no linked assertions (legacy FN-5696 shape)**
- Result: data inconsistency; must not be interpreted by operators as a separate enforced gate. Repair links (FN-5696 backfill) so enforcement matches displayed intent.
- the feature's linked contract assertions, including its store-managed assertion, and
- the parent milestone's `acceptanceCriteria` text when present.
## Enforced vs. Informational Surfaces
### Milestone-level enforcement
| Surface | Category | Contract meaning |
|---|---|---|
| `MissionFeature.acceptanceCriteria` | Informational authoring source | Canonical authored feature criteria text; enforcement happens through derived/linked assertions |
| Store-managed per-feature `MissionContractAssertion` | Enforced | Primary validator gate for the feature |
| Additive milestone `MissionContractAssertion` (linked to feature) | Enforced | Additional validator gate for that linked feature |
| Additive milestone `MissionContractAssertion` (unlinked) | Informational until linked | Contract candidate, not yet a feature gate |
| `milestone.acceptanceCriteria` | Informational | Milestone summary/pass-bar text for humans; not directly validator-executed |
| MissionManager `milestone-feature-acceptance-rollup` UI (`data-testid="milestone-feature-acceptance-rollup"`) | Informational display | Display-only rendering of feature acceptance text, not a separate enforcement mechanism |
`milestone.acceptanceCriteria` is no longer informational-only. FN-5902 enforces it by threading the milestone pass-bar text into the validator prompt for each feature under that milestone.
## Zero-Assertion Behavior and FN-5696 Failure Shape
This is intentionally **prompt-threading**, not per-feature milestone assertion row synthesis:
### Zero-assertions runtime behavior (canonical FN-5738 path)
- store-managed per-feature assertions remain the canonical feature assertion rows,
- milestone acceptance text remains milestone-authored prose,
- the validator sees both and must satisfy both.
When a feature reaches completion trigger points and has **zero linked assertions**, mission execution must take exactly one canonical auto-pass path (not a silent stall and not a competing behavior):
### Legacy data and lazy repair
- mark feature terminal as `status="done"`, `loopState="passed"`, `lastValidatorStatus="passed"`,
- emit explicit observability/audit evidence with mission event code `validation_auto_passed_no_assertions`,
- continue normal slice/mission advancement checks idempotently (no duplicate re-fire on repeated recovery).
Legacy missions can still contain features with missing assertion links. Runtime enforcement no longer depends on pre-running backfill:
### FN-5696 legacy shape clarification
- mission execution lazily restores the store-managed feature assertion just before validation, and
- `fn_mission_backfill_assertions` / `backfillFeatureAssertions()` remain available as operator repair tooling for data hygiene and visibility.
A feature can show acceptance text while links are missing (legacy pre-repair data). This must be treated as a **linkage/data integrity problem**, not as proof that milestone text alone is enforced. Assertion authoring/backfill (FN-5696) is outside the execution loop; the loop must not synthesize `mission_feature_assertions` rows. The contract prevents ambiguity by separating:
## Removed behavior (FN-5902 inversion)
- authored/informational text surfaces, from
- linked assertion enforcement surfaces.
FN-5718's zero-assertion auto-pass behavior is superseded.
Operators should use the mission assertion backfill operator path to restore expected store-managed linkage for FN-5696 legacy rows:
Removed contract:
- Agent/tool: `fn_mission_backfill_assertions` with `{ missionId?, dryRun? }` (defaults to dry-run).
- API: `POST /api/missions/:missionId/backfill-assertions` with body `{ dryRun?: boolean }` (defaults to `true`).
- Run dry-run first, then apply (`dryRun=false`) once repaired rows look correct.
- This remediation is additive: it derives/links one store-managed assertion per unlinked feature so runtime enforcement uses validator-linked assertions rather than the zero-assertion auto-pass branch.
- no `validation_auto_passed_no_assertions` completion path,
- no silent or explicit rubber-stamp pass because assertions were missing,
- no informational-only feature criteria bucket in MissionManager.
## Slice Status and Mission Autopilot Advance Derivation
Instead, features are routed through validator execution after lazy assertion ensure.
Autopilot may advance only when each active-slice feature is resolved under this contract:
## Worked examples
- Feature with linked assertions: all linked assertions must pass.
- Feature with zero linked assertions: explicit auto-pass path completes it.
- Feature with failed/blocked validation: slice remains incomplete.
- Feature stranded without a task link in an active autopilot slice (`taskId == null`): startup + maintenance reconciliation must repair it (title-match link first, otherwise defined-status re-triage) so `allDone` remains reachable instead of stalling on never-triaged features.
1. **Feature has acceptance criteria; no linked assertion row is present yet**
- Runtime calls `ensureFeatureAssertionLinked(feature.id)`.
- Validator runs against the restored managed assertion.
- Result gates completion normally.
Then:
2. **Feature has acceptance criteria and milestone acceptance criteria**
- Validator evaluates the linked feature assertion(s).
- Validator also evaluates the milestone acceptance text in the prompt.
- Feature passes only when both are satisfied.
1. All features resolved complete → slice flips to `complete`.
2. Completed active slice with pending next slice → next slice activates.
3. All milestone slices complete → milestone complete.
4. All mission milestones complete → mission complete.
3. **Operator runs backfill on legacy data**
- Backfill pre-restores missing managed assertions for visibility/reporting.
- Runtime behavior is unchanged because lazy ensure already guarantees validator-backed enforcement.
This keeps completion logic deterministic and consistent with FN-5715 trigger/recovery behavior.
## UI contract
## UI Reconciliation Requirements (for follow-on engineering task)
MissionManager must present mission criteria as **AI-validated** rather than informational:
✅ 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`
- assertion heading text reflects AI validation,
- informational / not-enforced labels are removed,
- zero-assertion warning guard is removed,
- fallback feature-criteria rollups, when shown for missing loaded assertions, describe runtime AI validation rather than non-enforced prose.
Target surface: `packages/dashboard/app/components/MissionManager.tsx`
## Success invariant
1. **Disambiguate labels**
- Use distinct wording for:
- feature-authored acceptance text, and
- milestone contract assertions.
- Do not reuse "Completion criteria" to refer to both categories.
- Required wording baseline (or semantically equivalent copy):
- Feature rollup heading: `Feature acceptance criteria (informational source)`
- Assertion list heading: `Contract assertions (validator-enforced when linked)`
For any mission feature that reaches validation trigger points:
2. **Per-row enforcement indicator**
- Every displayed row in the assertions/criteria area must show whether it is:
- `Enforced gate` (validator-blocking when linked), or
- `Informational` (display-only).
3. **Empty-state contract-correct copy**
- Replace the current implication that completion criteria are absent when assertion rows are empty.
- Empty-state text must acknowledge when feature acceptance text exists but no assertion rows are defined/linked.
- Required behavior:
- If feature acceptance text exists but no assertion rows are present, show copy equivalent to: `No contract assertions are linked yet. Feature acceptance criteria are present below and remain informational until assertions are linked.`
- If neither feature acceptance text nor assertions exist, show copy equivalent to: `No feature acceptance criteria or contract assertions defined yet.`
4. **No button/mobile scope expansion**
- No button touch-target/mobile-reflow requirements (standing directive).
## Engineering Acceptance Criteria (follow-on implementation)
✅ Implemented in FN-5733:
- Auto-pass path now advances `loopState` to `passed` and emits mission event code `validation_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.
2. **Validator/loop behavior**
- Maintain FN-5715 invariants:
- done mission-linked tasks with linked assertions trigger validation,
- completion-trigger starts loop if needed,
- startup recovery replays done-implementing features with unpassed assertions,
- periodic self-heal maintenance replays the same `recoverActiveMissions` path so historically stranded `implementing` features recover without restart,
- zero-linked-assertions path remains explicit canonical auto-pass.
3. **UI behavior**
- Implement the Step-2 label reconciliation and per-row indicator requirements.
- Ensure no shared ambiguous terminology remains between feature acceptance text and assertion rows.
4. **Regression coverage**
- Add at least one regression test pinning the Goals-mission shape:
- feature has `acceptanceCriteria`,
- parent milestone has zero `MissionContractAssertion` rows / no links,
- autopilot behavior is deterministic and observable (explicit auto-pass path, no silent stall).
5. **Operational observability**
- Ensure mission/audit surfaces make no-assertions auto-pass and subsequent advance decisions queryable in logs/events.
## Success Metric
For 30 days after the follow-on implementation ships:
- **Primary metric:** zero autopilot stalls of the FN-5715 class (done mission task + unresolved validation trigger gap) in production mission runs.
- **Evidence source:** mission audit/event stream (`feature_completed`, `slice_completed`, `mission_completed`) plus `mission_validator_runs` records showing:
- explicit no-assertions auto-pass evidence (summary/reason path such as `No assertions linked` when no validator run is started), and
- downstream advancement evidence without stalled active slices.
## Follow-on Task Requirement
Implementation must land in a separate engineering task that references this document and FN-5715 as the enforcement baseline.
- a validator run must occur,
- the feature must not auto-pass due to missing assertion links,
- milestone acceptance text must be visible to the validator when present,
- advancement decisions must derive from validator outcomes only.

View File

@@ -512,7 +512,7 @@ A feature transitions to `blocked` when:
On engine restart, `recoverActiveMissions()` re-enqueues features in `validating` or `needs_fix` states, 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. When the stale-run reaper has already converted an abandoned validator run into `needs_fix`, `processTaskOutcome()` promotes the feature back through `implementing` and re-validates instead of skipping it. The same recovery path is replayed during periodic self-heal maintenance, so historically stranded `implementing` features can self-heal without requiring an engine restart.
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 `validation_auto_passed_no_assertions`. Contract details (including canonical no-assertions behavior and FN-5696 assertion-authoring separation) are defined in [Mission Completion Gate Contract](./missions-completion-contract.md).
For features with missing linked assertions, the completion path is now validator-first: the loop lazily restores the store-managed per-feature assertion just before validation, then runs the AI validator instead of auto-passing. Milestone `acceptanceCriteria` is threaded into the validator prompt for every feature in that milestone, so all mission criteria are AI-evaluated. Contract details are defined in [Mission Completion Gate Contract](./missions-completion-contract.md).
### Autopilot / Scheduler Interplay
@@ -543,8 +543,7 @@ 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`
- `validation_auto_passed_no_assertions` (reason: `"No assertions linked"`)
- `milestone_missing_structured_assertions` (warning when prose criteria exist with zero structured assertions)
- `milestone_missing_structured_assertions` (legacy-data warning surface; enforcement still lazy-restores managed assertions at runtime)
- `fix_feature:created`, `feature:blocked`
**Validator run telemetry:**

View File

@@ -3338,14 +3338,33 @@ describe("MissionStore", () => {
expect(linked[0].sourceFeatureId).toBe(feature.id);
});
it("lazily re-links exactly one managed assertion for legacy acceptance-criteria features", () => {
const mission = store.createMission({ title: "M" });
const milestone = store.addMilestone(mission.id, { title: "MS" });
const slice = store.addSlice(milestone.id, { title: "SL" });
const feature = store.addFeature(slice.id, { title: "Feature", acceptanceCriteria: "AC text" });
const [managed] = store.listAssertionsForFeature(feature.id);
store.unlinkFeatureFromAssertion(feature.id, managed.id);
store.deleteContractAssertion(managed.id);
const first = store.ensureFeatureAssertionLinked(feature.id);
const second = store.ensureFeatureAssertionLinked(feature.id);
expect(first).toHaveLength(1);
expect(first[0].assertion).toBe("AC text");
expect(second).toHaveLength(1);
expect(second[0].id).toBe(first[0].id);
expect(store.listAssertionsForFeature(feature.id)).toHaveLength(1);
});
it("derives managed assertion text from description or fallback", () => {
const mission = store.createMission({ title: "M" });
const milestone = store.addMilestone(mission.id, { title: "MS" });
const slice = store.addSlice(milestone.id, { title: "SL" });
const fromDescription = store.addFeature(slice.id, { title: "Desc Feature", description: "Desc text" });
const fallback = store.addFeature(slice.id, { title: "Fallback Feature" });
expect(store.listAssertionsForFeature(fromDescription.id)[0].assertion).toBe("Desc text");
expect(store.listAssertionsForFeature(fallback.id)[0].assertion).toBe("Verify implementation of: Fallback Feature");
expect(store.ensureFeatureAssertionLinked(fromDescription.id)[0].assertion).toBe("Desc text");
expect(store.ensureFeatureAssertionLinked(fallback.id)[0].assertion).toBe("Verify implementation of: Fallback Feature");
});
it("syncs managed assertion in place on acceptanceCriteria update", () => {

View File

@@ -2197,6 +2197,16 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
}
}
ensureFeatureAssertionLinked(featureId: string): MissionContractAssertion[] {
const feature = this.getFeature(featureId);
if (!feature) {
throw new Error(`Feature ${featureId} not found`);
}
this.ensureFeatureAssertion(feature);
return this.listAssertionsForFeature(featureId);
}
/**
* Idempotently seed authored contract assertions for specific features.
*

View File

@@ -1770,15 +1770,6 @@
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;
@@ -1840,28 +1831,6 @@
margin-bottom: var(--space-sm);
}
.mission-assertion__enforcement {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
font-size: calc(var(--space-sm) + var(--space-xs) * 0.5);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: calc(var(--space-xs) * 0.5) var(--space-sm);
color: var(--text-dim);
background: color-mix(in srgb, var(--surface) 85%, var(--bg));
flex-shrink: 0;
}
.mission-assertion__enforcement--enforced {
color: var(--color-success);
border-color: color-mix(in srgb, var(--color-success) 40%, var(--border));
}
.mission-assertion__enforcement--informational {
color: var(--text-muted);
}
.mission-assertion__linked-count {
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
color: var(--text-dim);

View File

@@ -900,7 +900,6 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
const [missionHealthById, setMissionHealthById] = useState<Map<string, MissionHealth>>(new Map());
const [activeTab, setActiveTab] = useState<"structure" | "activity">("structure");
const milestoneAssertionGapSignatureRef = useRef<Map<string, string>>(new Map());
const [missionEvents, setMissionEvents] = useState<MissionEvent[]>([]);
const missionEventsRef = useRef<MissionEvent[]>([]);
const missionsRef = useRef<MissionWithSummary[]>([]);
@@ -917,30 +916,6 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
const activityEventsContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!selectedMission) return;
const nextSignatures = new Map<string, string>();
for (const milestone of selectedMission.milestones) {
const featuresWithAcceptanceCriteria = milestone.slices
.flatMap((slice) => slice.features)
.filter((feature) => (feature.acceptanceCriteria ?? "").trim().length > 0);
const assertionCount = assertionsByMilestone.get(milestone.id)?.length ?? 0;
const hasZeroAssertionGuard = featuresWithAcceptanceCriteria.length > 0 && assertionCount === 0;
const signature = `${hasZeroAssertionGuard}:${featuresWithAcceptanceCriteria.length}:${assertionCount}`;
const previousSignature = milestoneAssertionGapSignatureRef.current.get(milestone.id);
if (hasZeroAssertionGuard && previousSignature !== signature) {
console.warn("[MissionManager] milestone_zero_assertion_guard", {
milestoneId: milestone.id,
featureAcceptanceCriteriaCount: featuresWithAcceptanceCriteria.length,
assertionCount,
});
}
nextSignatures.set(milestone.id, signature);
}
milestoneAssertionGapSignatureRef.current = nextSignatures;
}, [assertionsByMilestone, selectedMission]);
const activityEventsEndRef = useRef<HTMLDivElement>(null);
// Keep latest state available to long-lived SSE handlers without reconnect churn.
@@ -2802,7 +2777,6 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
const milestoneAssertions = Array.isArray(assertionsByMilestone.get(milestone.id))
? assertionsByMilestone.get(milestone.id)!
: [] as MissionContractAssertion[];
const hasZeroAssertionGuard = featuresWithAcceptanceCriteria.length > 0 && milestoneAssertions.length === 0;
return (
<div key={milestone.id} className="mission-milestone">
@@ -3613,17 +3587,11 @@ 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">Contract assertions (validator-enforced when linked)</span>
<span className="mission-assertions__title">Contract assertions (AI-validated)</span>
<span className="mission-assertions__mode-tag" data-testid="milestone-assertions-enforced-indicator">
<span className="status-dot status-dot--running" />
Enforced by autopilot
AI-validated mission gate
</span>
{hasZeroAssertionGuard && (
<span className="mission-assertions__mode-tag mission-assertions__mode-tag--warning" data-testid="milestone-zero-assertion-guard">
<span className="status-dot status-dot--pending" />
Feature criteria present but no enforced contract assertions linked
</span>
)}
{milestoneRollup && (
<span
className="mission-status-badge mission-status-badge--sm"
@@ -3757,23 +3725,11 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
{(() => {
const linked = linkedFeaturesByAssertion.get(assertion.id);
const count = linked?.length ?? 0;
const isEnforced = count > 0;
return (
<>
<span
className={`mission-assertion__enforcement ${isEnforced ? "mission-assertion__enforcement--enforced" : "mission-assertion__enforcement--informational"}`}
data-testid={`mission-assertion-enforcement-${assertion.id}`}
>
<span className={`status-dot ${isEnforced ? "status-dot--running" : "status-dot--pending"}`} />
{isEnforced ? "Enforced gate" : "Informational"}
</span>
{count > 0 ? (
<span className="mission-assertion__linked-count" title={`${count} linked feature${count !== 1 ? "s" : ""}`}>
({count} linked)
</span>
) : null}
</>
);
return count > 0 ? (
<span className="mission-assertion__linked-count" title={`${count} linked feature${count !== 1 ? "s" : ""}`}>
({count} linked)
</span>
) : null;
})()}
<button
className="mission-icon-btn"
@@ -3882,22 +3838,19 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
{(milestoneAssertions.length === 0)
&& !isCreatingAssertion
&& (
// Render from feature prose presence directly. Legacy
// hasProseButNoAssertions telemetry is no longer the gate.
featuresWithAcceptanceCriteria.length > 0 ? (
// Product contract source of truth: docs/missions-completion-contract.md (FN-5718).
// MissionFeature.acceptanceCriteria is informational authored intent; linked
// MissionContractAssertion rows are the validator-enforced completion gate.
// When criteria prose exists but assertions are absent, keep criteria visible
// and warn that the surface is informational until assertions are linked.
<>
<div className="mission-manager__empty mission-assertions__empty">
<span>No contract assertions are linked yet. Feature acceptance criteria are present below and remain informational until assertions are linked.</span>
<span>No linked contract assertions are loaded yet. Feature criteria below will still be AI-validated when mission validation runs.</span>
</div>
<div className="mission-assertions__list" data-testid="milestone-feature-acceptance-rollup">
<div className="mission-assertions__rollup-header">
<span className="mission-assertions__title">Feature acceptance criteria (informational source)</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 className="mission-assertions__title">Feature criteria awaiting assertion sync</span>
<span className="mission-assertions__mode-tag" data-testid="milestone-feature-acceptance-ai-validated-indicator">
<span className="status-dot status-dot--running" />
AI-validated at runtime
</span>
</div>
{featuresWithAcceptanceCriteria.map((feature) => (
@@ -3905,11 +3858,14 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
<div className="mission-assertion__header">
<span className="mission-assertion__title">{feature.title}</span>
<span
className="mission-assertion__enforcement mission-assertion__enforcement--informational"
data-testid={`mission-feature-acceptance-enforcement-${feature.id}`}
className="mission-status-badge mission-status-badge--sm"
data-testid={`mission-feature-acceptance-status-${feature.id}`}
style={{
backgroundColor: featureStatusColors[feature.status].bg,
color: featureStatusColors[feature.status].text,
}}
>
<span className="status-dot status-dot--pending" />
Informational
{feature.status}
</span>
</div>
<div className="mission-assertion__text">

View File

@@ -5009,7 +5009,7 @@ describe("MissionManager", () => {
});
describe("milestone assertions empty-state", () => {
const emptyAssertionsWithFeaturesCopy = "No contract assertions are linked yet. Feature acceptance criteria are present below and remain informational until assertions are linked.";
const emptyAssertionsWithFeaturesCopy = "No linked contract assertions are loaded yet. Feature criteria below will still be AI-validated when mission validation runs.";
const emptyAssertionsNoFeaturesCopy = "No feature acceptance criteria or contract assertions defined yet.";
it("keeps empty-state nudge when assertions and feature acceptance criteria are both missing", async () => {
@@ -5090,13 +5090,47 @@ describe("MissionManager", () => {
expect(screen.queryByText(emptyAssertionsNoFeaturesCopy)).not.toBeInTheDocument();
expect(screen.getByText(emptyAssertionsWithFeaturesCopy)).toBeInTheDocument();
const rollup = screen.getByTestId("milestone-feature-acceptance-rollup");
expect(within(rollup).getByText("Feature acceptance criteria (informational source)")).toBeInTheDocument();
expect(within(rollup).getByTestId("milestone-feature-acceptance-informational-indicator")).toHaveTextContent("Not enforced by autopilot");
expect(within(rollup).getByText("Feature criteria awaiting assertion sync")).toBeInTheDocument();
expect(within(rollup).getByTestId("milestone-feature-acceptance-ai-validated-indicator")).toHaveTextContent("AI-validated at runtime");
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-zero-assertion-guard")).toHaveTextContent("Feature criteria present but no enforced contract assertions linked");
expect(screen.queryByTestId("milestone-zero-assertion-guard")).not.toBeInTheDocument();
expect(screen.queryByText(/informational/i)).not.toBeInTheDocument();
expect(screen.queryByText(/Not enforced by autopilot/i)).not.toBeInTheDocument();
});
it("shows feature acceptance rollup even when legacy gap telemetry is false", async () => {
const missionDetail = JSON.parse(JSON.stringify(mockMissionDetail)) as typeof mockMissionDetail;
missionDetail.milestones[0].acceptanceCriteria = "";
missionDetail.milestones[0].slices[0].features = [
{
...missionDetail.milestones[0].slices[0].features[0],
id: "F-ROLLUP-FALSE",
title: "Runtime validation",
acceptanceCriteria: "Validator still checks this feature",
},
];
const telemetryOverride = {
...mockMilestoneValidationTelemetry,
rollup: {
...mockMilestoneValidationRollup,
hasProseButNoAssertions: false,
},
};
globalThis.fetch = createDetailFetchMockForMissionDetail(missionDetail, telemetryOverride);
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
fireEvent.click(await screen.findByText("Build Auth System"));
await waitForDetailLoaded();
const rollup = screen.getByTestId("milestone-feature-acceptance-rollup");
expect(within(rollup).getByText("Feature criteria awaiting assertion sync")).toBeInTheDocument();
expect(within(rollup).getByText("Runtime validation")).toBeInTheDocument();
expect(within(rollup).getByText("Validator still checks this feature", { exact: false })).toBeInTheDocument();
});
it("keeps structured assertions precedence and hides rollup when assertions exist", async () => {
@@ -5111,12 +5145,14 @@ describe("MissionManager", () => {
await waitForDetailLoaded();
expect(screen.getByText("Auth works")).toBeInTheDocument();
expect(screen.getByText("Contract assertions (validator-enforced when linked)")).toBeInTheDocument();
expect(screen.getByTestId("milestone-assertions-enforced-indicator")).toHaveTextContent("Enforced by autopilot");
expect(screen.getByText("Contract assertions (AI-validated)")).toBeInTheDocument();
expect(screen.getByTestId("milestone-assertions-enforced-indicator")).toHaveTextContent("AI-validated mission gate");
expect(screen.queryByTestId("milestone-feature-acceptance-rollup")).not.toBeInTheDocument();
expect(screen.queryByText(/informational/i)).not.toBeInTheDocument();
expect(screen.queryByText(/Not enforced by autopilot/i)).not.toBeInTheDocument();
});
it("shows per-row informational and enforced indicators", async () => {
it("shows validator status without informational enforcement labels", async () => {
const missionDetail = JSON.parse(JSON.stringify(mockMissionDetail)) as typeof mockMissionDetail;
missionDetail.milestones[0].acceptanceCriteria = "";
const assertion = {
@@ -5152,7 +5188,7 @@ describe("MissionManager", () => {
await waitForDetailLoaded();
await waitFor(() => {
expect(screen.getByTestId("mission-assertion-enforcement-CA-ENF-1")).toHaveTextContent("Enforced gate");
expect(screen.queryByTestId("mission-assertion-enforcement-CA-ENF-1")).not.toBeInTheDocument();
});
const noAssertionMission = JSON.parse(JSON.stringify(missionDetail)) as typeof missionDetail;
@@ -5163,10 +5199,12 @@ describe("MissionManager", () => {
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
fireEvent.click(await screen.findByText("Build Auth System"));
await waitForDetailLoaded();
expect(await screen.findByTestId("mission-feature-acceptance-enforcement-F-INFO-1")).toHaveTextContent("Informational");
expect(await screen.findByTestId("mission-feature-acceptance-status-F-INFO-1")).toHaveTextContent("defined");
expect(screen.queryByText(/Informational/i)).not.toBeInTheDocument();
expect(screen.queryByText(/Not enforced/i)).not.toBeInTheDocument();
});
it("shows zero-assertion guard only when milestone has feature acceptance criteria and no assertions", async () => {
it("never renders the zero-assertion guard after lazy assertion ensure contract", async () => {
const missionDetail = JSON.parse(JSON.stringify(mockMissionDetail)) as typeof mockMissionDetail;
missionDetail.milestones[0].acceptanceCriteria = "";
@@ -5175,7 +5213,7 @@ describe("MissionManager", () => {
fireEvent.click(await screen.findByText("Build Auth System"));
await waitForDetailLoaded();
expect(screen.getByTestId("milestone-zero-assertion-guard")).toBeInTheDocument();
expect(screen.queryByTestId("milestone-zero-assertion-guard")).not.toBeInTheDocument();
cleanup();
globalThis.fetch = createDetailFetchMockForMissionDetail(

View File

@@ -204,6 +204,16 @@ function createMockMissionStore() {
return updated;
}),
listAssertionsForFeature: vi.fn((featureId: string) => assertionsByFeature.get(featureId) ?? []),
ensureFeatureAssertionLinked: vi.fn((featureId: string) => {
const feature = features.get(featureId);
if (!feature) {
throw new Error(`Feature ${featureId} not found`);
}
if ((assertionsByFeature.get(featureId) ?? []).length === 0) {
store._addFeatureWithManagedAssertion(feature);
}
return assertionsByFeature.get(featureId) ?? [];
}),
getAssertionsForFeature: vi.fn((featureId: string) => assertionsByFeature.get(featureId) ?? []),
getSlice: vi.fn((id: string) => {
// Return a mock slice with milestoneId for the hierarchy
@@ -764,12 +774,21 @@ describe("MissionExecutionLoop", () => {
expect(missionStore.completeValidatorRun).toHaveBeenCalledWith(expect.any(String), "passed", "Recovered validation passed");
});
it("should auto-pass if feature has no linked assertions", async () => {
const feature = createMockFeature({ loopState: "implementing", taskId: "FN-001" });
it("lazy-ensures a managed assertion and routes zero-assertion features through validation", async () => {
const feature = createMockFeature({
id: "F-001",
loopState: "implementing",
taskId: "FN-001",
title: "Feature from prose",
acceptanceCriteria: "Feature must validate through AI",
});
missionStore._setFeature(feature);
taskStore._setTask({ id: "FN-001", title: "Test", description: "Test task", log: [] });
missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature);
missionStore.listAssertionsForFeature = vi.fn().mockReturnValue([]);
missionStore.listAssertionsForFeature = vi
.fn()
.mockReturnValueOnce([])
.mockImplementation((featureId: string) => (missionStore as any).getAssertionsForFeature(featureId));
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
@@ -777,41 +796,39 @@ describe("MissionExecutionLoop", () => {
rootDir: "/tmp",
});
const emitSpy = vi.spyOn(loop, "emit");
vi.spyOn(loop as any, "runValidation").mockResolvedValue({ status: "pass", summary: "ok" });
loop.start();
await loop.processTaskOutcome("FN-001");
// When there are no assertions, we skip starting a validator run
expect(missionStore.startValidatorRun).not.toHaveBeenCalled();
// But the passed event should be emitted
expect(missionStore.ensureFeatureAssertionLinked).toHaveBeenCalledWith("F-001");
expect(missionStore.startValidatorRun).toHaveBeenCalledWith("F-001", "task_completion");
expect(emitSpy).toHaveBeenCalledWith(
"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: "validation_auto_passed_no_assertions",
featureId: "F-001",
reason: "No assertions linked",
taskId: "FN-001",
}),
const noAssertionEvents = missionStore.logMissionEvent.mock.calls.filter(
([, , , payload]) => payload?.code === "validation_auto_passed_no_assertions",
);
expect(noAssertionEvents).toHaveLength(0);
expectNoValidationBoardTaskMutation(taskStore);
});
it("emits no-assertions auto-pass event exactly once across re-entry", async () => {
const feature = createMockFeature({ loopState: "implementing", taskId: "FN-001" });
it("does not emit auto-pass evidence across re-entry after lazy assertion ensure", async () => {
const feature = createMockFeature({
id: "F-001",
loopState: "implementing",
taskId: "FN-001",
title: "Feature from prose",
acceptanceCriteria: "Feature must validate through AI",
});
missionStore._setFeature(feature);
taskStore._setTask({ id: "FN-001", title: "Test", description: "Test task", log: [] });
missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature);
missionStore.listAssertionsForFeature = vi.fn().mockReturnValue([]);
missionStore.listAssertionsForFeature = vi
.fn()
.mockReturnValueOnce([])
.mockImplementation((featureId: string) => (missionStore as any).getAssertionsForFeature(featureId));
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
@@ -823,10 +840,11 @@ describe("MissionExecutionLoop", () => {
await loop.processTaskOutcome("FN-001");
await loop.processTaskOutcome("FN-001");
expect(missionStore.ensureFeatureAssertionLinked).toHaveBeenCalledTimes(1);
const noAssertionEvents = missionStore.logMissionEvent.mock.calls.filter(
([, , , payload]) => payload?.code === "validation_auto_passed_no_assertions",
);
expect(noAssertionEvents).toHaveLength(1);
expect(noAssertionEvents).toHaveLength(0);
});
it("uses validator path for later-added feature with managed assertion", async () => {
@@ -854,6 +872,44 @@ describe("MissionExecutionLoop", () => {
expect(missionStore.startValidatorRun).toHaveBeenCalledWith("F-LATER", "task_completion");
});
it("threads milestone acceptance criteria into validator prompts", () => {
const feature = createMockFeature({
id: "F-MILESTONE",
title: "Feature under milestone",
acceptanceCriteria: "Feature criteria",
});
const milestone = createMockMilestone({
id: "MS-MILESTONE",
acceptanceCriteria: "Milestone pass bar text",
});
const assertions = [
{
id: "CA-1",
milestoneId: milestone.id,
title: "Managed assertion",
assertion: "Feature criteria",
status: "pending" as const,
orderIndex: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
];
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
const prompt = (loop as any).buildValidationPrompt(feature, assertions, milestone);
const systemPrompt = (loop as any).buildValidationSystemPrompt(feature, assertions, "Task context", milestone);
expect(prompt).toContain("Milestone pass bar text");
expect(prompt).toContain("must also be satisfied for this feature to pass");
expect(systemPrompt).toContain("Milestone pass bar text");
expect(systemPrompt).toContain("validator-executed requirements");
});
it("does NOT create a board task for single-feature validation", async () => {
const feature = createMockFeature({ loopState: "implementing", taskId: "FN-001", sliceId: "SL-001" });
missionStore._setFeature(feature);
@@ -1464,7 +1520,7 @@ describe("MissionExecutionLoop", () => {
});
missionStore._setFeature(feature);
missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature);
missionStore.listAssertionsForFeature = vi.fn().mockReturnValue([]); // No assertions = auto-pass
missionStore.listAssertionsForFeature = vi.fn().mockReturnValue([]);
taskStore._setTask({ id: "FN-001", title: "Test", description: "Test", log: [] });
const notifySpy = vi.fn();
@@ -1477,12 +1533,13 @@ describe("MissionExecutionLoop", () => {
},
});
const emitSpy = vi.spyOn(loop, "emit");
vi.spyOn(loop as any, "runValidation").mockResolvedValue({ status: "pass", summary: "ok" });
loop.start();
await loop.processTaskOutcome("FN-001");
// No validator run started (no assertions)
expect(missionStore.startValidatorRun).not.toHaveBeenCalled();
expect(missionStore.ensureFeatureAssertionLinked).toHaveBeenCalledWith("F-001");
expect(missionStore.startValidatorRun).toHaveBeenCalledWith("F-001", "task_completion");
// validation:passed event emitted
expect(emitSpy).toHaveBeenCalledWith(

View File

@@ -186,9 +186,10 @@ describe("FN-5715 reliability: mission validation trigger gap", () => {
loop.stop();
});
it("periodic recovery pass replays implementing done tasks with zero assertions and advances loop state", async () => {
const feature = makeFeature({ status: "done", lastValidatorStatus: undefined, loopState: "implementing" });
it("periodic recovery lazily ensures assertions and AI-validates zero-link legacy features", async () => {
const feature = makeFeature({ status: "done", lastValidatorStatus: undefined, loopState: "implementing", acceptanceCriteria: "must pass" });
const currentFeature = { ...feature };
const linkedAssertions: Array<{ id: string }> = [];
const missionStore = {
listMissions: vi.fn(() => [{ id: "M-001", status: "active" }]),
getMissionWithHierarchy: vi.fn(() => ({
@@ -203,11 +204,21 @@ describe("FN-5715 reliability: mission validation trigger gap", () => {
Object.assign(currentFeature, patch);
return { ...currentFeature };
}),
listAssertionsForFeature: vi.fn(() => []),
listAssertionsForFeature: vi.fn(() => linkedAssertions),
ensureFeatureAssertionLinked: vi.fn(() => {
if (linkedAssertions.length === 0) {
linkedAssertions.push({ id: "CA-ENSURED" });
}
return linkedAssertions;
}),
startValidatorRun: vi.fn(() => ({ id: "VR-001", featureId: "F-001" })),
completeValidatorRun: 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(),
setFeatureCurrentTaskRunId: vi.fn(),
getFailuresForRun: vi.fn(() => []),
};
const taskStore = {
getTask: vi.fn(async () => ({ id: "FN-001", column: "done", status: "done" })),
@@ -220,25 +231,23 @@ describe("FN-5715 reliability: mission validation trigger gap", () => {
taskStore: taskStore as any,
rootDir: process.cwd(),
});
vi.spyOn(loop as any, "runValidation").mockResolvedValue({ status: "pass", summary: "ok" });
loop.start();
const periodicMaintenancePass = async () => loop.recoverActiveMissions();
await periodicMaintenancePass();
await periodicMaintenancePass();
expect(missionStore.updateFeature).toHaveBeenCalledTimes(1);
expect(missionStore.updateFeature).toHaveBeenCalledWith(
"F-001",
expect.objectContaining({ loopState: "passed", lastValidatorStatus: "passed" }),
);
expect(missionStore.ensureFeatureAssertionLinked).toHaveBeenCalledWith("F-001");
expect(missionStore.startValidatorRun).toHaveBeenCalledWith("F-001", "task_completion");
const noAssertionEvents = missionStore.logMissionEvent.mock.calls.filter(
([, type, , payload]) => type === "warning" && payload?.code === "validation_auto_passed_no_assertions",
);
expect(noAssertionEvents).toHaveLength(1);
expect(noAssertionEvents).toHaveLength(0);
loop.stop();
});
it("routes through validator after assertion backfill instead of no-assertion auto-pass", async () => {
it("keeps backfill optional because runtime lazy-ensure routes through validator", async () => {
const feature = makeFeature({ status: "done", acceptanceCriteria: "must pass", loopState: "implementing" });
const currentFeature = { ...feature };
const linkedAssertions: Array<{ id: string }> = [];
@@ -258,6 +267,12 @@ describe("FN-5715 reliability: mission validation trigger gap", () => {
return { ...currentFeature };
}),
listAssertionsForFeature: vi.fn(() => linkedAssertions),
ensureFeatureAssertionLinked: vi.fn(() => {
if (linkedAssertions.length === 0) {
linkedAssertions.push({ id: "CA-001" });
}
return linkedAssertions;
}),
startValidatorRun: vi.fn(() => ({ id: "VR-001", featureId: "F-001" })),
completeValidatorRun: vi.fn(),
getSlice: vi.fn(() => ({ id: "SL-001", milestoneId: "MS-001", status: "active" })),
@@ -279,27 +294,13 @@ describe("FN-5715 reliability: mission validation trigger gap", () => {
await loop.recoverActiveMissions();
const noAssertionEventsBefore = missionStore.logMissionEvent.mock.calls.filter(
([, type, , payload]) => type === "warning" && payload?.code === "validation_auto_passed_no_assertions",
);
expect(noAssertionEventsBefore).toHaveLength(1);
expect(missionStore.startValidatorRun).not.toHaveBeenCalled();
linkedAssertions.push({ id: "CA-001" });
currentFeature.loopState = "implementing";
currentFeature.lastValidatorStatus = undefined;
await loop.processTaskOutcome("FN-001");
expect(missionStore.ensureFeatureAssertionLinked).toHaveBeenCalledWith("F-001");
expect(missionStore.startValidatorRun).toHaveBeenCalledWith("F-001", "task_completion");
const noAssertionEventsAfter = missionStore.logMissionEvent.mock.calls.filter(
const noAssertionEvents = missionStore.logMissionEvent.mock.calls.filter(
([, type, , payload]) => type === "warning" && payload?.code === "validation_auto_passed_no_assertions",
);
expect(noAssertionEventsAfter).toHaveLength(1);
expect(missionStore.updateFeature).toHaveBeenCalledWith(
"F-001",
expect.objectContaining({ loopState: "passed", lastValidatorStatus: "passed" }),
);
expect(noAssertionEvents).toHaveLength(0);
expect(missionStore.completeValidatorRun).toHaveBeenCalledWith("VR-001", "passed", "ok");
loop.stop();
});

View File

@@ -20,6 +20,7 @@ import type {
MissionValidatorRun,
AgentStore,
Settings,
Milestone,
} from "@fusion/core";
import {
TEST_MODE_RESOLVED,
@@ -353,13 +354,12 @@ export class MissionExecutionLoop extends EventEmitter {
return;
}
// Get linked assertions for this feature
const assertions = this.missionStore.listAssertionsForFeature(feature.id);
// Lazily guarantee a linked assertion before validation so every feature
// is evaluated by the validator even when legacy data is missing links.
let assertions = this.missionStore.listAssertionsForFeature(feature.id);
if (assertions.length === 0) {
loopLog.log(`Feature ${feature.id} has no linked assertions; marking as passed`);
// No assertions = automatically pass
await this.handleValidationPass(feature.id, undefined, "No assertions linked");
return;
loopLog.log(`Feature ${feature.id} has no linked assertions; lazily ensuring store-managed assertion linkage`);
assertions = this.missionStore.ensureFeatureAssertionLinked(feature.id);
}
// Mark feature as being validated
@@ -408,8 +408,10 @@ export class MissionExecutionLoop extends EventEmitter {
): Promise<ValidationResult> {
loopLog.log(`Running validation for feature ${feature.id} with ${assertions.length} assertions`);
const milestone = this.resolveFeatureMilestone(feature);
// Build the validation prompt
const prompt = this.buildValidationPrompt(feature, assertions);
const prompt = this.buildValidationPrompt(feature, assertions, milestone);
// Get task context for validation
const task = feature.taskId ? await this.taskStore.getTask(feature.taskId) : null;
@@ -441,7 +443,7 @@ export class MissionExecutionLoop extends EventEmitter {
runtimeHint: validationRuntimeHint,
pluginRunner: this.pluginRunner,
cwd: this.rootDir,
systemPrompt: this.buildValidationSystemPrompt(feature, assertions, taskContext),
systemPrompt: this.buildValidationSystemPrompt(feature, assertions, taskContext, milestone),
tools: "readonly",
defaultProvider: validationSessionModel.provider,
defaultModelId: validationSessionModel.modelId,
@@ -796,19 +798,27 @@ export class MissionExecutionLoop extends EventEmitter {
/**
* Build the validation prompt sent to the AI agent.
*/
private buildValidationPrompt(feature: MissionFeature, assertions: MissionContractAssertion[]): string {
private buildValidationPrompt(
feature: MissionFeature,
assertions: MissionContractAssertion[],
milestone?: Milestone,
): string {
const assertionTexts = assertions
.map((a, i) => `${i + 1}. **${a.title}**: ${a.assertion}`)
.join("\n");
const milestoneAcceptanceCriteria = milestone?.acceptanceCriteria?.trim();
const milestoneContext = milestoneAcceptanceCriteria
? `\nMilestone acceptance criteria (must also be satisfied for this feature to pass):\n${milestoneAcceptanceCriteria}\n`
: "";
return `Evaluate the implementation for feature "${feature.title}" against the following contract assertions:
${assertionTexts}
${assertionTexts}${milestoneContext}
For each assertion:
- Determine if the implementation satisfies the assertion (pass/fail/blocked)
- If failed, explain what was expected vs what was actually observed
- If blocked, explain what external factor prevented validation
- Also verify that the implementation satisfies any milestone acceptance criteria provided above
Respond with a JSON object in this format:
{
@@ -833,22 +843,25 @@ Be thorough and objective. If any assertion fails, the overall status should be
* Build the system prompt for the validation agent.
*/
private buildValidationSystemPrompt(
feature: MissionFeature,
_feature: MissionFeature,
_assertions: MissionContractAssertion[],
taskContext: string,
milestone?: Milestone,
): string {
const milestoneAcceptanceCriteria = milestone?.acceptanceCriteria?.trim();
return `You are a validation agent responsible for evaluating whether an implementation satisfies its contract assertions.
You will receive:
1. A feature description with its acceptance criteria
2. Contract assertions to evaluate against
3. Task context including the implementation details
3. Task context including the implementation details${milestoneAcceptanceCriteria ? `\n4. Milestone acceptance criteria text that also applies to this feature: ${milestoneAcceptanceCriteria}` : ""}
Your job is to:
1. Carefully review the implementation as described in the task context
2. Evaluate each contract assertion objectively
3. Determine if the implementation fully satisfies each assertion
4. Return a structured JSON response with your findings
4. Verify the implementation also satisfies any milestone acceptance criteria provided for the parent milestone
5. Return a structured JSON response with your findings
Be thorough and precise. A contract assertion represents a commitment made during planning - the implementation must fully satisfy it or it is considered failed.
@@ -857,6 +870,7 @@ Evaluation guidance:
- "fail" means one or more assertions are unmet or only partially satisfied.
- "blocked" means you cannot evaluate due to missing/insufficient evidence or external constraints.
- Partial satisfaction must be marked as failed with clear expected vs actual details.
- Milestone acceptance criteria are validator-executed requirements, not informational context.
Response format: Return ONLY a JSON object (no additional text) with this structure:
{
@@ -898,6 +912,15 @@ ${taskContext ? `\n\nImplementation context:\n${taskContext}` : ""}`;
return lines.join("\n");
}
private resolveFeatureMilestone(feature: MissionFeature): Milestone | undefined {
const slice = this.missionStore.getSlice(feature.sliceId);
if (!slice) {
return undefined;
}
return this.missionStore.getMilestone(slice.milestoneId);
}
private completeValidatorRunIfStillRunning(
runId: string | undefined,
status: "passed" | "failed" | "blocked" | "error",
@@ -938,33 +961,6 @@ ${taskContext ? `\n\nImplementation context:\n${taskContext}` : ""}`;
this.missionStore.updateFeatureStatus(featureId, "done");
}
if (!runId && feature) {
const alreadyAutoPassed =
feature.status === "done" &&
feature.loopState === "passed" &&
feature.lastValidatorStatus === "passed";
if (!alreadyAutoPassed) {
// Auto-pass path has no validator run, so we must advance loop bookkeeping here.
if (feature.loopState !== "passed" || feature.lastValidatorStatus !== "passed") {
this.missionStore.updateFeature(featureId, {
loopState: "passed",
lastValidatorStatus: "passed",
});
}
this.logFeatureWarningEvent(
featureId,
"validation_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