# AUTOPILOT-54 — fleet "occupied" is a leaking counter (diagnosis + fix scope)

Three compounding defects were reported live, most recent first:

1. **(3) `fleet.occupied` only increments, never decrements** — observed
   climbing 12→13→15→17 across four consecutive `queue_list` checks while the
   independently-verified live-process count was ZERO every time. **This is
   the defect this pass fixes.**
2. **(1) every worker occupies TWO fleet slots** (parent+child) — FIXED by
   AUTOPILOT-95 (`docs/AUTOPILOT-95-fleet-runs-vs-entries.md`): the fleet
   inventory now counts RUNS (a wrapper + its child is ONE) instead of run
   dirs/entries. The ledger-attribution root cause assumed here does not apply
   — the double came from the inventory scan counting both DIRECTORIES.
3. **(2) cross-profile leakage** — runs from an unrelated repo/profile counted
   against this queue's cap — NOT addressed in this pass (state-dir/profile
   scoping investigation, separate root cause, out of budget).

## Where "occupied" comes from

`queue_list`'s `fleet.occupied` field (`src/tools/queue-ops.ts`) is
`ctx.autopilot().status().running`, which is `Autopilot.ledger.size`
(`src/core.ts`). The ledger is a `Map<runId, LedgerEntry>`:

- **Incremented**: `handleAsyncStarted(runId)` — `ledger.set(runId, ...)`.
  Called from `queue_dispatch` (`src/tools/queue-ops.ts:439`) **only after** a
  successful spawn (a real `runId` came back) and **only after** the capacity
  gate already returned early on a refusal. Verified by reading the refusal
  path: `capacityRefusal` returns *before* `ctx.backend.spawn` is ever called,
  so **a refused dispatch attempt never reaches `handleAsyncStarted` — this
  channel is already correct** (see `test/tools/queue-dispatch-capacity.test.ts`,
  extended below with an explicit ledger assertion).
- **Decremented**: `handleAsyncComplete(ev)` — `ledger.delete(topRunId)`. Fires
  on the `subagent:async-complete` event and correctly covers BOTH run
  completion and run failure (`ev.success === false || ev.timedOut` routes to
  the `failed` outcome; both paths still hit `ledger.delete` at the top of the
  handler, unconditionally).

## The bug: a stopped/zombie run never fires `async-complete`, so it never decrements

`zombieReconcile` and `deadRunReconcile` are the safety nets for **lost
completion events** (crash, timeout while nothing was listening, a killed
run) — precisely "a stopped run". Both flip the QUEUE STORE item from
`active` to `failed` (freeing the store-derived occupied count), but **neither
one touches `this.ledger`**. Since the run's completion event was lost by
definition (that's why the reconciliation net exists at all), `handleAsyncComplete`
never runs for it, so its ledger entry is never deleted.

Result: every zombie/dead-run reconciliation leaks exactly one permanent
ledger entry. `ledger.size` (`status().running`, i.e. `fleet.occupied`) only
ever grows across such events, even while the fleet is authoritatively idle
(that's the very precondition `zombieReconcile(fleetTotalActive: 0)` requires)
— which is exactly the reported symptom: occupied climbing while live
processes are independently verified as zero.

## Reproduction (before fix)

```
const ap = new Autopilot({ stateDir: dir });
ap.handleAsyncStarted("deadfeed", "worker");     // dispatch recorded the run
ap.zombieReconcile(0);                            // fleet authoritatively idle
ap.status().running                               // → 1 (should be 0: item is now `failed`, nothing is running)
```

Confirmed locally before writing the fix (see commit history / test additions
below): `zombieReconcile` returns `flippedKeys: ["Z1"]` (item correctly failed)
while `status().running` stays at 1.

## Fix

`zombieReconcile` and `deadRunReconcile` now also delete the flipped run's
entry from `this.ledger`, since the fleet-authoritative evidence that let them
condemn the item (fleet reports 0 / the run's own status.json says terminal)
is exactly the evidence that the ledger's "still running" belief is stale.

## Scope note

Defects (1) and (2) are real per the report but were NOT investigated or
touched in this pass — only (3) was reproduced firsthand before any fix was
written, per the task's instruction not to fix unverified defects. They need
their own root-cause passes.
