# AUTOPILOT-95 — the fleet view counted ENTRIES, not RUNS (diagnosis + fix)

One logical run rendered as TWO entries in the fleet view (wrapper + child), so
a 3-run fleet read as 6 and one worker looked like two competing workers on the
same item. AUTOPILOT-54 already flagged this as its unaddressed defect (1)
("every worker occupies TWO fleet slots (parent+child)"); this is that fix.

## STEP 1 — WHERE the double-count originates

**Answer: this harness counts the pair twice in its OWN inventory scan, and the
upstream `pi-subagents` RPC does the same thing on the code path it exercises.**

The runtime shape of ONE harness spawn (measured live on 2026-09-14):

```
$ ls <asyncRoot>  # ~1484 dirs; 8 in-flight at measurement time
in-flight dirs: 8
wrappers: 4 children: 4
 child bce4f2fb-... mode single parent ec157d97-...  parentPresent True
 child 991a9ead-... mode single parent 0839170c-...  parentPresent True
 child 51b4dec5-... mode single parent 82b22d3b-...  parentPresent True
 child f1c89bdb-... mode single parent 7f0d5ae2-...  parentPresent True
wrapper sessionId == child sessionId: True
```

Every harness spawn is a one-step workflow (`runs.run("main", …)`), which
pi-subagents materializes as TWO run DIRECTORIES that share one logical run and
one session id:

- the workflow **WRAPPER** — `status.mode === "workflow"`, a top-level run; and
- its nested **CHILD** — `status.mode === "single"`,
  `status.parentWorkflowRunId === <wrapper id>`, which is the run that actually
  does the work.

So 4 live workers produce 8 in-flight directories.

### Side A — this harness (the fixable half)

`src/backends/pi.ts` — `countInFlightAsyncRuns()` (the AUTOPILOT-6
FLEET-vs-INVENTORY inventory) incremented once per in-flight run DIR:

```
inFlight += 1;   // one per dir → counts the wrapper AND its child
```

That number flows, un-deduped, into `fleetStatus().totalActive`
(`Math.max(rpcTotalActive, countInFlightAsyncRuns())`), which is the fleet
floor in `effectiveOccupiedSlots` (`src/framework/auto-dispatch.ts`), the tick's
`FLEET:` line, and `queue_list`'s `fleet.occupied`. That is why the tick's
`FLEET 2/6` and a `(fleet status shows 4 active — 3 not tracked)` note on the
SAME status disagreed by exactly the wrapper/child factor.

### Side B — upstream `pi-subagents` (report-only)

Traced the live running build's jiti cache
(`$TMPDIR/jiti/extension-rpc.*.mjs`, `buildFleetStatus`):

- a job with `mode === "workflow"` adds ONE candidate and `continue`s
  (does not descend into the wrapper's children); but
- any OTHER registered job — including the nested child, whose own
  `async-started` carries `parentWorkflowRunId` and which is restored from the
  shared `.active-runs` index — is counted as its own run of `steps`.

Because the child shares the wrapper's `sessionId`, the RPC's session gate does
NOT filter it out, so the RPC's `fleet.totalActive` is an ENTRY count too
(wrapper + child = 2). The upstream TUI FleetView likewise renders both (it
lists every dir via `listAsyncRuns`). Upstream ownership of the RPC count is
unchanged here — but it does not have to be fixed for this repo's number to be
honest, because this repo can SEE the pairs locally and de-duplicate them.

(Not verified this session: the RPC's exact numeric output, which could not be
invoked from a worker shell. The code path above and the shared-session/
active-index registration are what the conclusion rests on.)

## The fix (RUNS, NOT ENTRIES)

`src/backends/pi.ts`:

- `countInFlightAsyncRuns()` → `scanAsyncRunInventory()`, returning
  `{ entries, runs }`. `entries` counts run DIRS (the fleet rows); `runs` counts
  LOGICAL runs.
- RUN-LEVEL RULE: a non-child in-flight dir that OWNS at least one in-flight
  child is a **container** — its own row yields to its children's rows. So
  **one wrapper + one child = ONE run**, and **two children under one wrapper =
  TWO runs** (the duplicate-worker condition is never hidden). A child whose
  wrapper is already terminal (parent absent) still counts on its own, so an
  orphaned live worker is never hidden either.
- `fleetStatus()` returns `{ totalActive: runs, runs, entries }`. `totalActive`
  is run-level (the count every consumer uses); `entries` keeps the raw row
  count visible alongside it. The RPC's entry-level `totalActive` is de-duped by
  the locally-observed duplicate-child count before the union, and clamped at
  the run-level inventory floor (so an undercounting, session-gated RPC can
  still never invent free capacity — AUTOPILOT-6 preserved).

`src/backends/types.ts`: the `fleetStatus()` contract documents RUNS vs ENTRIES
and exposes `runs?: number` / `entries?: number`.

`src/tools/queue-ops.ts`: `queue_list`'s fleet block now carries **`runs`**
(run-level — what the cap and `occupied` use) alongside **`entries`**
(entry-level — the raw rows), both labeled, so a reader cannot mistake one run
for two.

The underlying run model and the wrapper mechanism are untouched; this is a
count/render fix. The occupancy DERIVATION (AUTOPILOT-86) is unchanged — only
the fleet count it unions is now run-level.

## Tests

`test/backends/pi-fleet.test.ts` (AUTOPILOT-95 block):

- one wrapper + one child → `runs` 1, `entries` 2;
- an entry-level RPC count of 2 for that pair → `totalActive` 1;
- two independent children under one wrapper → 2 (never collapsed);
- two wrappers each with a child → 2;
- an orphaned live child (wrapper terminal) → 1.

## Related measurement (NOT fixed here — AUTOPILOT-96)

The delivered tick's `FLEET:` number can read LOWER than the sweep's logged
`fleetTotalActive` — it is not a subset by design, it is a missing input:
`src/framework/runner.ts:228` calls
`autopilot.refreshTickFacts(d.tick, undefined, undefined, opts.backend)` with
`fleet === undefined`, so the delivery-time `occupied` is
`max(0, store-tracked)` (`src/core.ts` `refreshTickFacts`) — the store's
live-item count only, WITHOUT the fleet union the generation-time sweep used
(`src/core.ts` `sweep`). That explains a tick reading 4 while the sweep's
`fleetTotalActive` matched the live-run count. The gauge question is owned by
AUTOPILOT-96; this pass only records the fact.
