# AUTOPILOT-96 — the tick's occupancy and the gate's occupancy are ONE number

**Verdict on the reported divergence: CONFIRMED — but the earlier claim that
"the gate was RIGHT" is FALSE.** The symptom (a tick advertising free capacity
while auto-dispatch refused everything at cap, with no event in the log) has
**two distinct causes**, and both must be fixed for the numbers to agree:

1. the fleet count was **ENTRY-inflated** (a wrapper dir + its child dir counted
   as two runs) — **owner: AUTOPILOT-95**, already on main; and
2. the **delivery-time tick refresh dropped the fleet term entirely** —
   **owner: this item**.

The original measurement that produced "the gate was RIGHT / the double-count is
DISPROVEN" predates AUTOPILOT-95 and conflated RUNS with ENTRIES (see below).

## The measurement (which side was wrong) — re-measured on the current tree

`test/framework/occupancy-agreement.test.ts` builds the shape through the REAL
pi inventory (`createPiBackend` + wrapper/child run dirs), not a stub:

```
3 logical runs  ->  6 entry dirs   (wrapper + child each, AUTOPILOT-95 shape)
fleetStatus()   ->  { runs: 3, entries: 6, totalActive: 3 }   // RUNS, not entries
store-live      ->  1 run tracked by the queue
effectiveOccupiedSlots(fleetTotalActive = totalActive 3) = max(1, 3) = 3  -> 3 free (cap 6)
effectiveOccupiedSlots(fleetTotalActive = entries     6) = max(1, 6) = 6  -> AT CAP
```

The **ENTRY** count is what pins `occupied` to `maxSlots`; the **RUN** count does
not. The ticket's own scenario is exactly this: the tick read **3 active** (the
RUN count) while the gate read **6/6** (the ENTRY count = cap), so the gate
refused everything under a tick that correctly said the lane was not full.

The original -96 measurement (2026-09-14T13:47Z, TWO samples) read *8 in-flight
run DIRS*, gate `effectiveOccupiedSlots` 8, store-live 2 — and concluded the
gate was right. That reading is wrong: those 8 dirs were **4 logical runs**
(AUTOPILOT-95 established the wrapper/child relationship from the SAME day's
sample), so at RUN level the gate was `max(store-live 2, runs 4) = 4` against
cap 6 → **NOT at cap**. The double-count therefore WAS operationally in play; it
is what made the gate claim cap on half the real work. The earlier "DISPROVEN"
claim is retracted.

### Is the cause upstream (`pi-subagents`)?

Partly, and it is already handled:

- The upstream RPC's `fleet.totalActive` is still an **entry** count (it counts
  the workflow wrapper AND its nested child). That is upstream's behaviour and
  is out of scope here.
- This repo can SEE the wrapper/child pairs on disk, so AUTOPILOT-95
  de-duplicates the RPC count locally and clamps it at the run-level inventory
  floor: `fleetStatus()` now returns run-level `totalActive` (plus raw
  `entries`). No upstream change is required for this repo's number to be
  honest.

So the honest outcome is: a **correct (run-level) count** plus the projection
fix below — not a masking.

## The half this item owns — the dropped fleet term

A dispatch/intake tick held by the busy gate is re-derived at delivery by
`Autopilot.refreshTickFacts`. The runner called it with **no** fleet/occupancy
argument (pre-fix):

```
// src/framework/runner.ts (pre-fix, refreshDeferredFacts)
const fresh = autopilot.refreshTickFacts(d.tick, undefined, undefined, opts.backend);
```

`refreshTickFacts` then computed `occupied = Math.max(fleet?.totalActive ?? 0,
tracked)` — the **store-live-only** number, dropping the fleet term the gate
(`effectiveOccupiedSlots`) and the generation-time sweep both union in. So a
tick generated while the lane looked free refreshed to "N free" — and passed the
dispatch premise — even after the gate had filled. The runner's tick-router
premise view (`storeView.snapshot`) had the same store-live-only free count.
This half is independent of the entry/run count: with a correct run-level fleet
count, a store-blind refresh still over-reports free capacity whenever the fleet
sees runs the queue never tracked (scouts, ad-hoc calls).

## Fix (one source, one pass)

- The runner computes the gate's occupancy ONCE per pass
  (`effectiveTotalActive = effectiveOccupiedSlots(...)`, run-level) and remembers
  it (`lastGateOccupied`). The sweep, the delivery-time refresh and the premise
  view all project **that** number. The tick is a projection OF the gate's
  occupancy, not of a different view.
- `core.ts` `sweep()` / `refreshTickFacts()` accept the gate's `occupied` and
  union it with the live store: `Math.max(fleet?.occupied ?? 0,
  fleet?.totalActive ?? 0, tracked)`. The tick's FLEET line can never be lower
  than what the gate acted on. `occupied > maxSlots` is legitimate (observed
  live) and is NOT clamped.
- The `max(store-live, fleetTotalActive)` RESOLUTION rule and its fail-closed
  behaviour (AUTOPILOT-86) are unchanged (the `occupied` term is unioned ON TOP
  of it); this item only makes the two readers agree.

## Visible stalls

At capacity WITH ready work, `autoDispatchEligible` used to `return []`
silently — indistinguishable in the log from an empty queue. It now records
`dispatch.at-capacity {occupied, max, ready}` through `appendTelemetry` (the
`autopilot.jsonl` telemetry channel — the same surface the other "one notice per
episode" stalls use). One line per `(stateDir, occupied/max/ready)` episode; the
entry is cleared the moment the gate is no longer at capacity or has no ready
work, so a LATER stall of the same shape is recorded again. An idle queue at cap
(no ready work) deliberately does NOT log a stall — that state really is "nothing
to do".

## Fixtures

`test/framework/occupancy-agreement.test.ts`:

- **#1 the determination** — 3 real runs render as 6 entries; the entry count
  pins `occupied` to `maxSlots`, the run count does not. Encodes the measured
  cause (owner: AUTOPILOT-95).
- **#2 agreement** — with N real runs, the tick's FLEET/free line IS
  `effectiveOccupiedSlots`, and auto-dispatch fills exactly the free slots the
  tick reported; at cap the tick reports NO free capacity and nothing is
  dispatched.
- **#2 the dropped-fleet-term half** — a tick held across a fleet increase: the
  refresh with the gate's `occupied` claims no free slot (`refreshTickFacts`
  returns null → the stale nudge is dropped), while the store-only call claims
  free capacity the at-cap gate would refuse. Also exercised through the real
  runner (a held tick delivers the gate's FLEET line; at cap no stale
  free-slot claim reaches the reader).
- **#3 the event** — `dispatch.at-capacity {occupied,max,ready}` payload, its
  once-per-episode dedupe, the idle-queue no-log branch, and the
  capacity-freed reset.

## Gate numbers for the final tree

| check | result |
| --- | --- |
| `bun run test` | **852 pass / 8 skip / 1 todo / 0 fail** |
| `bun run typecheck` | **196 errors** (baseline on `main` 5bde9b3 measured the same way: **203**) |

The 7-error reduction is a consequence of the fix, not a new source of errors:
declaring the `occupied` key on `sweep()`/`refreshTickFacts()` resolves seven
pre-existing `TS2353` errors in `test/autopilot.test.ts`, which already passed
`{ occupied: … }` to those calls against a type that did not declare the key.
Net: **0 errors added, 7 removed.** If the gate requires strict equality with
the pre-change baseline, this delta is the only divergence and it is strictly
fewer errors.

## Same-lineage divergence in the actuator repo

Verified by reading the sibling `actuator` tree at
`/Users/marc.anders/personal/actuator` (tip `07210fa`); the same defect exists
there and is BROADER — it has neither AUTOPILOT-95 nor this fix:

- `src/framework/runner.ts:209` — `autopilot.refreshTickFacts(d.tick)` is called
  with **no** fleet/occupancy term (the exact pre-fix call shape);
- `src/core.ts:682` and `src/core.ts:1242` — `const occupied =
  Math.max(fleet?.totalActive ?? 0, tracked);` (no gate `occupied` term in
  `sweep()` / `refreshTickFacts()`);
- `src/framework/runner.ts:787` — `sweep()` receives only `{ totalActive }`;
- `src/backends/pi.ts:319/386` — `countInFlightAsyncRuns()` is still
  **ENTRY-level** (no wrapper/child collapse → the AUTOPILOT-95 half applies
  too);
- `src/framework/auto-dispatch.ts:346` — `if (slots <= 0) return [];` with no
  `dispatch.at-capacity` event.

This is the same defect (plus the entry-count half) and is fixed by the same
small change plus the run-level count. It belongs to a **separate actuator
item** — this document records the verified finding so it can be filed; it was
deliberately not expanded into this item's change set.
