# Autonomy root fix — 2026-07-25

Not autonomy patch #6. The previous five added mechanisms; this one removes a
retry, corrects a classifier, and scopes an existing counter. Success criterion
is a *reduction* in the counts below, not a new feature.

## Measured state of the harness (2026-07-25)

| Metric | Value |
| --- | --- |
| engine LOC (runner.js / daemon.js / src/engine / lib/gates.sh) | 7143 / 4767 / 22932 / 1214 |
| journal event kinds | 45 |
| task states | 7 |
| failClasses | 18 |
| durable state stores per run | ~10 |

## What the daemon-hygiene run actually did (12:25 → 19:25, 7h)

| Signal | Count |
| --- | --- |
| gate0 failures | 24 (t1 8, t3 16) |
| fixer attempts | 12 (t1 4, t3 8) |
| `fixer-scope-violation` | 0 |
| `dispatch.failed` zero-diff | 12 |
| quarantines | 5 |
| tasks landed | 0 |

gate0 failures are exactly 2× fixer attempts — the blind `GATE_MAX_ATTEMPTS = 2`
re-run. The red itself was a load-sensitive flake in this repo's own suite
(`src/control-config.test.js` and siblings use a hardcoded `Date.now() + 2_000`
deadline); it rotated between test names across cycles, and the base commit runs
5/5 green in isolation.

## Root cause: capability without ownership

Three self-repair systems exist, none owns "the run is not progressing":

1. **fixer** (`src/engine/gates.js`) — repairs the *task deliverable*. Its only
   vocabulary is "produce a diff". It has no verdict meaning *this red is not
   attributable to my diff*, so a flake leaves it with nothing to write, which
   surfaces as zero-diff → `dispatch.failed`. 12/12 times.
2. **fix-forward** (`src/engine/fix-forward-registry.js`) — repairs *CI workflow*
   failures across registered repos. No entry point for a local gate red.
3. **watchdog / resolver** — repair *stuck processes*. A 13-minute gate0 that
   exits non-zero on schedule is not stuck; it is working perfectly.

The failure fell in the gap between all three scopes.

## Secondary defects confirmed

- `runner.js:2311 mapGateFailClass` substring-matches `/\btimeout\b/i` over the
  whole stderr blob. The suite prints a test *named* "…plan-level timeout
  overrides…", so every red in this run misclassified as `timeout`.
- `gates.js:124` repeat memory filters `state === "quarantined"`, so within a
  single run identical failures never accumulate evidence.
- The stop-loss in `src/engine/ship.js` never fired, for two independent
  reasons. It counts only `state === "quarantined"`, but the dominant
  recurrence state in this run was `repaired` (t3 recurred as
  `repaired`/`zero-diff` three more times after its single quarantine). And it
  requires `last.base === intHead`, so any integration-head movement caused by
  *another* task resets the counter — a task can loop forever as long as some
  other task keeps making progress.
- `gate0.fail` records the failing check but its message is the generic
  `gate0 failed with exit 1`, and all 24 carried the wrong `failClass:
  "timeout"` from the substring bug above. The durable record was present but
  misleading, which is worse than absent.

## Plan

### 1. Anchor failure classification (`src/runner.js:2311`)

Trust only the `FAILCLASS=` marker `lib/gates.sh` already emits; drop the
freeform `/\btimeout\b/i` fallback (keep `error.code === "WATCHDOG_BREACH"`).
Mirror in `src/engine/gates.js`.

Acceptance: unit test — stderr containing the word "timeout" inside a passing
test name classifies as `check-failed`, not `timeout`.

### 2. Replace the blind gate retry with isolated-check retry

Delete `GATE_MAX_ATTEMPTS = 2`. Generalize `retry_vitest_timeouts_isolated`
(`lib/gates.sh:353`) — already the correct shape, currently Vitest-only — to the
discovered check runner: on a failed check, re-run only the failing file
in isolation. Pass in isolation → flake verdict, gate green, run continues.
Fail in isolation → genuine red, hand to the fixer with the isolated output.

Net LOC negative. Removes one 13-minute re-run per cycle and converts
"two different tests failed across two runs" from discarded evidence into a
flake verdict.

Acceptance: an injected always-red check still fails; an injected
load-sensitive check passes on isolated retry and the gate goes green.

### 3. One owner for run progress

Make `gates.js:124` repeat memory **run-scoped** rather than quarantine-scoped —
read this run's `gate0.fail` entries for this task, not just prior quarantines.
Use the existing `repeated-failure-stop` failClass; do not add a parallel ledger.

Acceptance: an injected always-red gate reaches terminal quarantine within 3
cycles, and the run still reaches `run.done` with a report. Unit test, no live
run required.

### 4. Make the stop-loss count every failed attempt

Count `repaired` as well as `quarantined`, and stop unconditionally once the
same signature recurs 4 times regardless of integration-head movement.

### Knob, not a stage

Harness concurrency `limit: 2` runs two full suites plus cursor agents plus the
daemon on one machine, which is what generates the load the wall-clock tests
trip on. Serializing gate0 removes the noise source directly. Config change.

## Explicitly not in this plan

- Collapsing the ~10 durable state stores into one. It is the correct long-term
  direction and it is where the previous five plans died. Revisit only after
  1–4 are landed and a run completes unattended.
- Any new mechanism, event kind, task state, or failClass.

## Shipped 2026-07-25

Items 1–4 are implemented and green.

`test/gate-attribution.test.sh` pins the contract end to end against a real
gate0 run, including the exact shape of today's failure:

| case | rc | check runs |
| --- | --- | --- |
| red once, green alone (contention) | 0 | 2 |
| red every time, same failure | 1 (`check-failed`) | 2 |
| red every time, different failure | 1 (`flaky-check`) | 2 |
| compile error, no per-test failures | 1 (`check-failed`) | 1 |

Suite: 613 pass / 9 fail, the 9 being a pre-existing `engine-ship.test.js`
breakage (missing `snapshotWorktreeTree` / `appendRunJournal` in that file's
fake deps) with an identical failure set before and after these changes.

One failClass was added (`flaky-check`) against the no-new-mechanism rule.
It is load-bearing: it is the verdict that lets the run continue past a check
no diff can repair, and it replaces a fixer dispatch rather than adding one.

Note: `run-tests.sh` aborts on the first failing file, so the pre-existing
`engine-ship` red means the 41 `test/*.sh` suites never run under `make test`.
Not fixed here — flagged as the next thing worth doing.

## Walk-through against today's failure

With 1–3 landed, t3's first gate0 red re-runs `src/control-config.test.js` alone.
Measured 5/5 green in isolation on the base → flake verdict → gate green → task
proceeds. If isolation still reds under harness load, the run-scoped counter
terminates t3 after 3 cycles (~40 min) with a reported cause, instead of 7 hours
and no report.
