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-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-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-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-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-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). - 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-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-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-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-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-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. - 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 ## Status
- **Decision date:** 2026-05-30 - **Decision date:** 2026-06-02
- **Task:** FN-5718 - **Current contract task:** FN-5902
- **Depends on enforcement behavior from:** FN-5715 (reference implementation of the trigger/recovery path) - **Supersedes:** FN-5718 baseline contract
- **Scope:** Product contract and implementation requirements only (no code changes in this task) - **Depends on runtime trigger/recovery behavior from:** FN-5715
- **Implementation status:** Realized by FN-5733 (loop auto-pass advancement, mission/store guard telemetry, MissionManager label reconciliation) - **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) 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:
### 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:
- `feature.acceptanceCriteria` - `feature.acceptanceCriteria`
- `feature.description` - `feature.description`
- `Verify implementation of: {feature.title}` - `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). 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. Feature/slice/mission advance is gated by the validator outcome (or explicit no-assertions auto-pass behavior defined below). 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. ### Feature-level enforcement
- 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.
### 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** - the feature's linked contract assertions, including its store-managed assertion, and
- Result: feature may move to done/passed and contribute to slice completion. - the parent milestone's `acceptanceCriteria` text when present.
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.
## Enforced vs. Informational Surfaces ### Milestone-level enforcement
| Surface | Category | Contract meaning | `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.
|---|---|---|
| `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 |
## 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"`, Legacy missions can still contain features with missing assertion links. Runtime enforcement no longer depends on pre-running backfill:
- 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).
### 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 FN-5718's zero-assertion auto-pass behavior is superseded.
- linked assertion enforcement surfaces.
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). - no `validation_auto_passed_no_assertions` completion path,
- API: `POST /api/missions/:missionId/backfill-assertions` with body `{ dryRun?: boolean }` (defaults to `true`). - no silent or explicit rubber-stamp pass because assertions were missing,
- Run dry-run first, then apply (`dryRun=false`) once repaired rows look correct. - no informational-only feature criteria bucket in MissionManager.
- 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.
## 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. 1. **Feature has acceptance criteria; no linked assertion row is present yet**
- Feature with zero linked assertions: explicit auto-pass path completes it. - Runtime calls `ensureFeatureAssertionLinked(feature.id)`.
- Feature with failed/blocked validation: slice remains incomplete. - Validator runs against the restored managed assertion.
- 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. - 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`. 3. **Operator runs backfill on legacy data**
2. Completed active slice with pending next slice → next slice activates. - Backfill pre-restores missing managed assertions for visibility/reporting.
3. All milestone slices complete → milestone complete. - Runtime behavior is unchanged because lazy ensure already guarantees validator-backed enforcement.
4. All mission milestones complete → mission complete.
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: - assertion heading text reflects AI validation,
- `Contract assertions (autopilot gate)` + enforced indicator - informational / not-enforced labels are removed,
- `Feature acceptance criteria (informational)` + not-enforced indicator - zero-assertion warning guard is removed,
- warning badge when `hasProseButNoAssertions === true` - 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** For any mission feature that reaches validation trigger points:
- 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)`
2. **Per-row enforcement indicator** - a validator run must occur,
- Every displayed row in the assertions/criteria area must show whether it is: - the feature must not auto-pass due to missing assertion links,
- `Enforced gate` (validator-blocking when linked), or - milestone acceptance text must be visible to the validator when present,
- `Informational` (display-only). - advancement decisions must derive from validator outcomes 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.

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. 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 ### Autopilot / Scheduler Interplay
@@ -543,8 +543,7 @@ 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`
- `validation_auto_passed_no_assertions` (reason: `"No assertions linked"`) - `milestone_missing_structured_assertions` (legacy-data warning surface; enforcement still lazy-restores managed assertions at runtime)
- `milestone_missing_structured_assertions` (warning when prose criteria exist with zero structured assertions)
- `fix_feature:created`, `feature:blocked` - `fix_feature:created`, `feature:blocked`
**Validator run telemetry:** **Validator run telemetry:**

View File

@@ -3338,14 +3338,33 @@ describe("MissionStore", () => {
expect(linked[0].sourceFeatureId).toBe(feature.id); 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", () => { it("derives managed assertion text from description or fallback", () => {
const mission = store.createMission({ title: "M" }); const mission = store.createMission({ title: "M" });
const milestone = store.addMilestone(mission.id, { title: "MS" }); const milestone = store.addMilestone(mission.id, { title: "MS" });
const slice = store.addSlice(milestone.id, { title: "SL" }); const slice = store.addSlice(milestone.id, { title: "SL" });
const fromDescription = store.addFeature(slice.id, { title: "Desc Feature", description: "Desc text" }); const fromDescription = store.addFeature(slice.id, { title: "Desc Feature", description: "Desc text" });
const fallback = store.addFeature(slice.id, { title: "Fallback Feature" }); const fallback = store.addFeature(slice.id, { title: "Fallback Feature" });
expect(store.listAssertionsForFeature(fromDescription.id)[0].assertion).toBe("Desc text"); expect(store.ensureFeatureAssertionLinked(fromDescription.id)[0].assertion).toBe("Desc text");
expect(store.listAssertionsForFeature(fallback.id)[0].assertion).toBe("Verify implementation of: Fallback Feature"); expect(store.ensureFeatureAssertionLinked(fallback.id)[0].assertion).toBe("Verify implementation of: Fallback Feature");
}); });
it("syncs managed assertion in place on acceptanceCriteria update", () => { 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. * Idempotently seed authored contract assertions for specific features.
* *

View File

@@ -1770,15 +1770,6 @@
padding: calc(var(--space-xs) * 0.5) var(--space-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 { .mission-assertions__rollup-header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1840,28 +1831,6 @@
margin-bottom: var(--space-sm); 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 { .mission-assertion__linked-count {
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75); font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
color: var(--text-dim); 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 [missionHealthById, setMissionHealthById] = useState<Map<string, MissionHealth>>(new Map());
const [activeTab, setActiveTab] = useState<"structure" | "activity">("structure"); const [activeTab, setActiveTab] = useState<"structure" | "activity">("structure");
const milestoneAssertionGapSignatureRef = useRef<Map<string, string>>(new Map());
const [missionEvents, setMissionEvents] = useState<MissionEvent[]>([]); const [missionEvents, setMissionEvents] = useState<MissionEvent[]>([]);
const missionEventsRef = useRef<MissionEvent[]>([]); const missionEventsRef = useRef<MissionEvent[]>([]);
const missionsRef = useRef<MissionWithSummary[]>([]); const missionsRef = useRef<MissionWithSummary[]>([]);
@@ -917,30 +916,6 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
const activityEventsContainerRef = useRef<HTMLDivElement>(null); 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); const activityEventsEndRef = useRef<HTMLDivElement>(null);
// Keep latest state available to long-lived SSE handlers without reconnect churn. // 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)) const milestoneAssertions = Array.isArray(assertionsByMilestone.get(milestone.id))
? assertionsByMilestone.get(milestone.id)! ? assertionsByMilestone.get(milestone.id)!
: [] as MissionContractAssertion[]; : [] as MissionContractAssertion[];
const hasZeroAssertionGuard = featuresWithAcceptanceCriteria.length > 0 && milestoneAssertions.length === 0;
return ( return (
<div key={milestone.id} className="mission-milestone"> <div key={milestone.id} className="mission-milestone">
@@ -3613,17 +3587,11 @@ 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">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="mission-assertions__mode-tag" data-testid="milestone-assertions-enforced-indicator">
<span className="status-dot status-dot--running" /> <span className="status-dot status-dot--running" />
Enforced by autopilot AI-validated mission gate
</span> </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 && ( {milestoneRollup && (
<span <span
className="mission-status-badge mission-status-badge--sm" 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 linked = linkedFeaturesByAssertion.get(assertion.id);
const count = linked?.length ?? 0; const count = linked?.length ?? 0;
const isEnforced = count > 0; return count > 0 ? (
return ( <span className="mission-assertion__linked-count" title={`${count} linked feature${count !== 1 ? "s" : ""}`}>
<> ({count} linked)
<span </span>
className={`mission-assertion__enforcement ${isEnforced ? "mission-assertion__enforcement--enforced" : "mission-assertion__enforcement--informational"}`} ) : null;
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}
</>
);
})()} })()}
<button <button
className="mission-icon-btn" className="mission-icon-btn"
@@ -3882,22 +3838,19 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
{(milestoneAssertions.length === 0) {(milestoneAssertions.length === 0)
&& !isCreatingAssertion && !isCreatingAssertion
&& ( && (
// Render from feature prose presence directly. Legacy
// hasProseButNoAssertions telemetry is no longer the gate.
featuresWithAcceptanceCriteria.length > 0 ? ( 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"> <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>
<div className="mission-assertions__list" data-testid="milestone-feature-acceptance-rollup"> <div className="mission-assertions__list" data-testid="milestone-feature-acceptance-rollup">
<div className="mission-assertions__rollup-header"> <div className="mission-assertions__rollup-header">
<span className="mission-assertions__title">Feature acceptance criteria (informational source)</span> <span className="mission-assertions__title">Feature criteria awaiting assertion sync</span>
<span className="mission-assertions__mode-tag mission-assertions__mode-tag--informational" data-testid="milestone-feature-acceptance-informational-indicator"> <span className="mission-assertions__mode-tag" data-testid="milestone-feature-acceptance-ai-validated-indicator">
<span className="status-dot status-dot--pending" /> <span className="status-dot status-dot--running" />
Not enforced by autopilot AI-validated at runtime
</span> </span>
</div> </div>
{featuresWithAcceptanceCriteria.map((feature) => ( {featuresWithAcceptanceCriteria.map((feature) => (
@@ -3905,11 +3858,14 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
<div className="mission-assertion__header"> <div className="mission-assertion__header">
<span className="mission-assertion__title">{feature.title}</span> <span className="mission-assertion__title">{feature.title}</span>
<span <span
className="mission-assertion__enforcement mission-assertion__enforcement--informational" className="mission-status-badge mission-status-badge--sm"
data-testid={`mission-feature-acceptance-enforcement-${feature.id}`} 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" /> {feature.status}
Informational
</span> </span>
</div> </div>
<div className="mission-assertion__text"> <div className="mission-assertion__text">

View File

@@ -5009,7 +5009,7 @@ describe("MissionManager", () => {
}); });
describe("milestone assertions empty-state", () => { 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."; 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 () => { 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.queryByText(emptyAssertionsNoFeaturesCopy)).not.toBeInTheDocument();
expect(screen.getByText(emptyAssertionsWithFeaturesCopy)).toBeInTheDocument(); expect(screen.getByText(emptyAssertionsWithFeaturesCopy)).toBeInTheDocument();
const rollup = screen.getByTestId("milestone-feature-acceptance-rollup"); const rollup = screen.getByTestId("milestone-feature-acceptance-rollup");
expect(within(rollup).getByText("Feature acceptance criteria (informational source)")).toBeInTheDocument(); expect(within(rollup).getByText("Feature criteria awaiting assertion sync")).toBeInTheDocument();
expect(within(rollup).getByTestId("milestone-feature-acceptance-informational-indicator")).toHaveTextContent("Not enforced by autopilot"); 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 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-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 () => { it("keeps structured assertions precedence and hides rollup when assertions exist", async () => {
@@ -5111,12 +5145,14 @@ describe("MissionManager", () => {
await waitForDetailLoaded(); await waitForDetailLoaded();
expect(screen.getByText("Auth works")).toBeInTheDocument(); expect(screen.getByText("Auth works")).toBeInTheDocument();
expect(screen.getByText("Contract assertions (validator-enforced when linked)")).toBeInTheDocument(); expect(screen.getByText("Contract assertions (AI-validated)")).toBeInTheDocument();
expect(screen.getByTestId("milestone-assertions-enforced-indicator")).toHaveTextContent("Enforced by autopilot"); expect(screen.getByTestId("milestone-assertions-enforced-indicator")).toHaveTextContent("AI-validated mission gate");
expect(screen.queryByTestId("milestone-feature-acceptance-rollup")).not.toBeInTheDocument(); 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; const missionDetail = JSON.parse(JSON.stringify(mockMissionDetail)) as typeof mockMissionDetail;
missionDetail.milestones[0].acceptanceCriteria = ""; missionDetail.milestones[0].acceptanceCriteria = "";
const assertion = { const assertion = {
@@ -5152,7 +5188,7 @@ describe("MissionManager", () => {
await waitForDetailLoaded(); await waitForDetailLoaded();
await waitFor(() => { 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; 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()} />); 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"));
await waitForDetailLoaded(); 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; const missionDetail = JSON.parse(JSON.stringify(mockMissionDetail)) as typeof mockMissionDetail;
missionDetail.milestones[0].acceptanceCriteria = ""; missionDetail.milestones[0].acceptanceCriteria = "";
@@ -5175,7 +5213,7 @@ describe("MissionManager", () => {
fireEvent.click(await screen.findByText("Build Auth System")); fireEvent.click(await screen.findByText("Build Auth System"));
await waitForDetailLoaded(); await waitForDetailLoaded();
expect(screen.getByTestId("milestone-zero-assertion-guard")).toBeInTheDocument(); expect(screen.queryByTestId("milestone-zero-assertion-guard")).not.toBeInTheDocument();
cleanup(); cleanup();
globalThis.fetch = createDetailFetchMockForMissionDetail( globalThis.fetch = createDetailFetchMockForMissionDetail(

View File

@@ -204,6 +204,16 @@ function createMockMissionStore() {
return updated; return updated;
}), }),
listAssertionsForFeature: vi.fn((featureId: string) => assertionsByFeature.get(featureId) ?? []), 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) ?? []), getAssertionsForFeature: vi.fn((featureId: string) => assertionsByFeature.get(featureId) ?? []),
getSlice: vi.fn((id: string) => { getSlice: vi.fn((id: string) => {
// Return a mock slice with milestoneId for the hierarchy // 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"); expect(missionStore.completeValidatorRun).toHaveBeenCalledWith(expect.any(String), "passed", "Recovered validation passed");
}); });
it("should auto-pass if feature has no linked assertions", async () => { it("lazy-ensures a managed assertion and routes zero-assertion features through validation", async () => {
const feature = createMockFeature({ loopState: "implementing", taskId: "FN-001" }); const feature = createMockFeature({
id: "F-001",
loopState: "implementing",
taskId: "FN-001",
title: "Feature from prose",
acceptanceCriteria: "Feature must validate through AI",
});
missionStore._setFeature(feature); missionStore._setFeature(feature);
taskStore._setTask({ id: "FN-001", title: "Test", description: "Test task", log: [] }); taskStore._setTask({ id: "FN-001", title: "Test", description: "Test task", log: [] });
missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature); 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({ loop = new MissionExecutionLoop({
taskStore: taskStore as any, taskStore: taskStore as any,
@@ -777,41 +796,39 @@ describe("MissionExecutionLoop", () => {
rootDir: "/tmp", rootDir: "/tmp",
}); });
const emitSpy = vi.spyOn(loop, "emit"); const emitSpy = vi.spyOn(loop, "emit");
vi.spyOn(loop as any, "runValidation").mockResolvedValue({ status: "pass", summary: "ok" });
loop.start(); loop.start();
await loop.processTaskOutcome("FN-001"); await loop.processTaskOutcome("FN-001");
// When there are no assertions, we skip starting a validator run expect(missionStore.ensureFeatureAssertionLinked).toHaveBeenCalledWith("F-001");
expect(missionStore.startValidatorRun).not.toHaveBeenCalled(); expect(missionStore.startValidatorRun).toHaveBeenCalledWith("F-001", "task_completion");
// But the passed event should be emitted
expect(emitSpy).toHaveBeenCalledWith( expect(emitSpy).toHaveBeenCalledWith(
"validation:passed", "validation:passed",
expect.objectContaining({ featureId: "F-001" }), expect.objectContaining({ featureId: "F-001" }),
); );
expect(missionStore.updateFeature).toHaveBeenCalledWith( const noAssertionEvents = missionStore.logMissionEvent.mock.calls.filter(
"F-001", ([, , , payload]) => payload?.code === "validation_auto_passed_no_assertions",
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",
}),
); );
expect(noAssertionEvents).toHaveLength(0);
expectNoValidationBoardTaskMutation(taskStore); expectNoValidationBoardTaskMutation(taskStore);
}); });
it("emits no-assertions auto-pass event exactly once across re-entry", async () => { it("does not emit auto-pass evidence across re-entry after lazy assertion ensure", async () => {
const feature = createMockFeature({ loopState: "implementing", taskId: "FN-001" }); const feature = createMockFeature({
id: "F-001",
loopState: "implementing",
taskId: "FN-001",
title: "Feature from prose",
acceptanceCriteria: "Feature must validate through AI",
});
missionStore._setFeature(feature); missionStore._setFeature(feature);
taskStore._setTask({ id: "FN-001", title: "Test", description: "Test task", log: [] }); taskStore._setTask({ id: "FN-001", title: "Test", description: "Test task", log: [] });
missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature); 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({ loop = new MissionExecutionLoop({
taskStore: taskStore as any, taskStore: taskStore as any,
@@ -823,10 +840,11 @@ describe("MissionExecutionLoop", () => {
await loop.processTaskOutcome("FN-001"); await loop.processTaskOutcome("FN-001");
await loop.processTaskOutcome("FN-001"); await loop.processTaskOutcome("FN-001");
expect(missionStore.ensureFeatureAssertionLinked).toHaveBeenCalledTimes(1);
const noAssertionEvents = missionStore.logMissionEvent.mock.calls.filter( const noAssertionEvents = missionStore.logMissionEvent.mock.calls.filter(
([, , , payload]) => payload?.code === "validation_auto_passed_no_assertions", ([, , , 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 () => { 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"); 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 () => { it("does NOT create a board task for single-feature validation", async () => {
const feature = createMockFeature({ loopState: "implementing", taskId: "FN-001", sliceId: "SL-001" }); const feature = createMockFeature({ loopState: "implementing", taskId: "FN-001", sliceId: "SL-001" });
missionStore._setFeature(feature); missionStore._setFeature(feature);
@@ -1464,7 +1520,7 @@ describe("MissionExecutionLoop", () => {
}); });
missionStore._setFeature(feature); missionStore._setFeature(feature);
missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(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: [] }); taskStore._setTask({ id: "FN-001", title: "Test", description: "Test", log: [] });
const notifySpy = vi.fn(); const notifySpy = vi.fn();
@@ -1477,12 +1533,13 @@ describe("MissionExecutionLoop", () => {
}, },
}); });
const emitSpy = vi.spyOn(loop, "emit"); const emitSpy = vi.spyOn(loop, "emit");
vi.spyOn(loop as any, "runValidation").mockResolvedValue({ status: "pass", summary: "ok" });
loop.start(); loop.start();
await loop.processTaskOutcome("FN-001"); await loop.processTaskOutcome("FN-001");
// No validator run started (no assertions) expect(missionStore.ensureFeatureAssertionLinked).toHaveBeenCalledWith("F-001");
expect(missionStore.startValidatorRun).not.toHaveBeenCalled(); expect(missionStore.startValidatorRun).toHaveBeenCalledWith("F-001", "task_completion");
// validation:passed event emitted // validation:passed event emitted
expect(emitSpy).toHaveBeenCalledWith( expect(emitSpy).toHaveBeenCalledWith(

View File

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

View File

@@ -20,6 +20,7 @@ import type {
MissionValidatorRun, MissionValidatorRun,
AgentStore, AgentStore,
Settings, Settings,
Milestone,
} from "@fusion/core"; } from "@fusion/core";
import { import {
TEST_MODE_RESOLVED, TEST_MODE_RESOLVED,
@@ -353,13 +354,12 @@ export class MissionExecutionLoop extends EventEmitter {
return; return;
} }
// Get linked assertions for this feature // Lazily guarantee a linked assertion before validation so every feature
const assertions = this.missionStore.listAssertionsForFeature(feature.id); // is evaluated by the validator even when legacy data is missing links.
let assertions = this.missionStore.listAssertionsForFeature(feature.id);
if (assertions.length === 0) { if (assertions.length === 0) {
loopLog.log(`Feature ${feature.id} has no linked assertions; marking as passed`); loopLog.log(`Feature ${feature.id} has no linked assertions; lazily ensuring store-managed assertion linkage`);
// No assertions = automatically pass assertions = this.missionStore.ensureFeatureAssertionLinked(feature.id);
await this.handleValidationPass(feature.id, undefined, "No assertions linked");
return;
} }
// Mark feature as being validated // Mark feature as being validated
@@ -408,8 +408,10 @@ export class MissionExecutionLoop extends EventEmitter {
): Promise<ValidationResult> { ): Promise<ValidationResult> {
loopLog.log(`Running validation for feature ${feature.id} with ${assertions.length} assertions`); loopLog.log(`Running validation for feature ${feature.id} with ${assertions.length} assertions`);
const milestone = this.resolveFeatureMilestone(feature);
// Build the validation prompt // Build the validation prompt
const prompt = this.buildValidationPrompt(feature, assertions); const prompt = this.buildValidationPrompt(feature, assertions, milestone);
// Get task context for validation // Get task context for validation
const task = feature.taskId ? await this.taskStore.getTask(feature.taskId) : null; const task = feature.taskId ? await this.taskStore.getTask(feature.taskId) : null;
@@ -441,7 +443,7 @@ export class MissionExecutionLoop extends EventEmitter {
runtimeHint: validationRuntimeHint, runtimeHint: validationRuntimeHint,
pluginRunner: this.pluginRunner, pluginRunner: this.pluginRunner,
cwd: this.rootDir, cwd: this.rootDir,
systemPrompt: this.buildValidationSystemPrompt(feature, assertions, taskContext), systemPrompt: this.buildValidationSystemPrompt(feature, assertions, taskContext, milestone),
tools: "readonly", tools: "readonly",
defaultProvider: validationSessionModel.provider, defaultProvider: validationSessionModel.provider,
defaultModelId: validationSessionModel.modelId, defaultModelId: validationSessionModel.modelId,
@@ -796,19 +798,27 @@ export class MissionExecutionLoop extends EventEmitter {
/** /**
* Build the validation prompt sent to the AI agent. * 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 const assertionTexts = assertions
.map((a, i) => `${i + 1}. **${a.title}**: ${a.assertion}`) .map((a, i) => `${i + 1}. **${a.title}**: ${a.assertion}`)
.join("\n"); .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: return `Evaluate the implementation for feature "${feature.title}" against the following contract assertions:
${assertionTexts} ${assertionTexts}${milestoneContext}
For each assertion: For each assertion:
- Determine if the implementation satisfies the assertion (pass/fail/blocked) - Determine if the implementation satisfies the assertion (pass/fail/blocked)
- If failed, explain what was expected vs what was actually observed - If failed, explain what was expected vs what was actually observed
- If blocked, explain what external factor prevented validation - 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: 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. * Build the system prompt for the validation agent.
*/ */
private buildValidationSystemPrompt( private buildValidationSystemPrompt(
feature: MissionFeature, _feature: MissionFeature,
_assertions: MissionContractAssertion[], _assertions: MissionContractAssertion[],
taskContext: string, taskContext: string,
milestone?: Milestone,
): string { ): string {
const milestoneAcceptanceCriteria = milestone?.acceptanceCriteria?.trim();
return `You are a validation agent responsible for evaluating whether an implementation satisfies its contract assertions. return `You are a validation agent responsible for evaluating whether an implementation satisfies its contract assertions.
You will receive: You will receive:
1. A feature description with its acceptance criteria 1. A feature description with its acceptance criteria
2. Contract assertions to evaluate against 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: Your job is to:
1. Carefully review the implementation as described in the task context 1. Carefully review the implementation as described in the task context
2. Evaluate each contract assertion objectively 2. Evaluate each contract assertion objectively
3. Determine if the implementation fully satisfies each assertion 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. 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. - "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. - "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. - 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: 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"); 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( private completeValidatorRunIfStillRunning(
runId: string | undefined, runId: string | undefined,
status: "passed" | "failed" | "blocked" | "error", status: "passed" | "failed" | "blocked" | "error",
@@ -938,33 +961,6 @@ ${taskContext ? `\n\nImplementation context:\n${taskContext}` : ""}`;
this.missionStore.updateFeatureStatus(featureId, "done"); 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`); loopLog.log(`Feature ${featureId} passed validation`);
// Notify autopilot if configured // Notify autopilot if configured