# AUTOPILOT-117 — the residual cross-profile capacity coupling

AUTOPILOT-98 scoped the fleet COUNT to the harness's own mode (a run is
attributed by its recorded session path → profile dir). The primary
misattribution bug was fixed and verified. This item is what SURVIVED that fix:
a residual coupling that let one pi profile's work consume another profile's
capacity slots.

The first attempt fixed three causes and **introduced a worse one: it condemned
live workers.** This document records the correction, which was SCOPED to the
defaulting path — the three fixes below are kept, but their interaction with the
real environment is what mattered.

## 0. THE REGRESSION (reproduced end-to-end on the live machine)

The first attempt made `defaultAsyncRunRoot()` **PROFILE-scoped** from
`PI_CODING_AGENT_DIR`:

- Live env: `PI_CODING_AGENT_DIR` set (`…/.pi/personal`), `PI_SUBAGENTS_TEMP_ROOT`
  **unset**.
- pi-subagents actually writes to `$TMPDIR/pi-subagents-uid-<uid>/async-subagent-runs`
  (`resolveTempScopeId()` → `uid-<uid>`, verified against the running build).
- The profile-scoped default resolved to a **different, empty** directory
  (`$TMPDIR/pi-subagents-profile-personal-<hash>/async-subagent-runs`).

An empty directory reads ZERO, and zero is exactly what `zombieReconcile`
(`core.ts`) treats as "authoritatively idle" (`fleetTotalActive === 0`). With the
disk root empty AND the RPC term dropped (see fix 2 below), that gate was always
true, so a store item `active` past its grace with a REAL runId was flipped to
`failed` and a zombie tick delivered — **the duplicate-worker class this item
exists to prevent**. PRE-FIX both sides resolved to the same UID path, so this
was a regression, not a pre-existing gap.

`fleetStatus()` under production wiring (`createPiBackend(pi, {})`) read
`{"totalActive":0,"runs":0,"fleetActive":0}` while thousands of run records sat
in the UID root. The lesson: **a profile-scoped default that diverges from where
pi-subagents writes is worse than the UID root it replaced — it does not read
empty, it reads zero, and zero means "idle".**

## 1. THE INVENTORY ROOT MUST MIRROR THE WRITER

`defaultAsyncRunRoot()` resolution order (all on evidence; `PI_CODING_AGENT_DIR`
is NEVER consulted):

1. `AUTOPILOT_PI_ASYNC_ROOT` — this repo's documented explicit override
   (unchanged; tests and ops rely on it).
2. `PI_SUBAGENTS_TEMP_ROOT` — **pi-subagents' OWN root override**. pi-subagents
   resolves `ASYNC_DIR` to `<PI_SUBAGENTS_TEMP_ROOT>/async-subagent-runs`. When
   the launcher exported this per profile, BOTH sides agree — this is the one
   case where a per-profile root is genuinely in effect.
3. the **UID scope** — `<os.tmpdir()>/pi-subagents-uid-<uid>/async-subagent-runs`.
   This is pi-subagents' own default and therefore matches the writer by
   construction.

There is NO profile-scoped default. `writerAsyncRunRoot()` is the single
authority both sides resolve to; `defaultAsyncRunRoot()` returns it whenever the
launcher has not exported an override. The mode FILTER (AUTOPILOT-98) is what
excludes another profile's runs from the shared root — it is not replaced by
directory scoping.

**Wrapper / launcher (NOT this repo).** If the launcher ever wants a per-profile
runtime root, it must export `PI_SUBAGENTS_TEMP_ROOT` (or
`AUTOPILOT_PI_ASYNC_ROOT`) so pi-subagents and this backend move TOGETHER. The
launcher is a separate, parked repo item.

## 2. THE RPC AGGREGATE IS A FAIL-CLOSED FLOOR

The `status → fleet` reply exposes per-row data that carries NO session path
(only `agent`/`startedAt`/`tokens`), and its one per-row payload
(`asyncSnapshot.runs`) is ITSELF session-gated to the caller's session
(`buildFleetStatus` / `asyncStatusSnapshotJobsForState`). So the RPC aggregate
**cannot be mode-attributed** — it must not be the authoritative occupancy.

The first attempt responded by DROPPING the RPC term entirely
(`totalActive = inv.runs`). That was the second half of the regression: with the
disk root reading empty (fix 1's bug), the dropped term removed the last thing
standing between a stale count and a flipped worker.

**Corrected:** occupancy is

```
totalActive = max(inv.runs, max(0, rpcEntries − duplicateChildEntries))
```

- The mode-scoped inventory (`inv.runs`) is authoritative and **lower-bounds** it
  (a LOW/zero RPC never reads a live run as idle).
- The wrapper/child duplicate entries the RPC counts are subtracted first
  (RUNS, not entries — AUTOPILOT-95).
- The RPC is a **fail-closed FLOOR**: it can only ever RAISE occupancy.
  Over-count is safe (it blocks dispatch); under-count flips LIVE items. The raw
  RPC count stays visible as `fleetActive`, and the RPC is still probed (its
  success/liveness drives the AUTOPILOT-24 degraded-episode logic; a null reply
  still returns `null`).

## 3. A DEGRADED IDENTITY IS LOUD — INCLUDING THE STATE THAT MATTERS

Two independent degraded states are made visible, once per backend:

- **Disabled mode FILTER.** When an in-flight record CARRIES a mode marker (it is
  exactly the run the filter would have attributed) and `modeRoot` is empty, the
  backend warns LOUDLY naming `PI_CODING_AGENT_DIR`, and the fleet result carries
  `modeFilterDisabled: true`. The `unknown ⇒ counts` policy is unchanged
  (fail-closed); only its silence is removed.
- **Inventory root ≠ writer.** The first attempt's §3 warned only about an empty
  `modeRoot`, but the state that actually condemned workers had a NON-empty
  `modeRoot` — the root did not match the writer. The backend now compares its
  resolved inventory root to `writerAsyncRunRoot()` and, when they differ, warns
  LOUDLY once and surfaces `inventoryRootMatchesWriter: false` on the fleet
  result. Fix 1 makes them agree by construction; this keeps any divergence
  (e.g. a mis-set `AUTOPILOT_PI_ASYNC_ROOT`) from being silent. The
  `deps.asyncDirRoot` test seam is exempt from the warning (it is a deliberate
  injection).

## 4. DOCS AND TESTS MUST NOT ASSERT THE OPPOSITE OF THE BEHAVIOUR

Two claims were false under the first attempt and are corrected:

- `docs/queue-model.md` and `test/backends/pi-fleet.test.ts` say "zombie
  reconciliation cannot flip a LIVE parent on an undercount-to-0". That is TRUE
  again with fix 2's restored floor, and is now backed by the acceptance test.
- The tests that asserted the RPC term was dropped from occupancy were rewritten
  to assert the floor semantics (a low RPC never lowers; an active RPC raises).

## Tests (`test/backends/pi-autopilot-117.test.ts`)

- **#1** two profiles with no launcher root resolve to the SAME writer (UID)
  root; a launcher-exported `PI_SUBAGENTS_TEMP_ROOT` gives two roots on BOTH
  sides; with the shared root the MODE FILTER does the separation; the pre-fix
  shared root is shown coupling two profiles.
- **#2** the mode-scoped inventory supplies the count and a zero RPC never lowers
  it; an active RPC raises occupancy (fail-closed) and stays visible as
  `fleetActive`; a foreign run never counts.
- **#3** an empty `modeRoot` + an attributable run warns loudly once and sets
  `modeFilterDisabled`; a present `modeRoot` stays silent.
- **override** `AUTOPILOT_PI_ASYNC_ROOT` wins over `PI_SUBAGENTS_TEMP_ROOT` and
  the writer's UID scope (the documented escape hatch is not regressed).
- **acceptance** PRODUCTION wiring (`createPiBackend(pi, {})`, no `asyncDirRoot`)
  + LIVE env shape (`PI_CODING_AGENT_DIR` set, `PI_SUBAGENTS_TEMP_ROOT` unset): a
  store item `active` past its grace with a REAL runId is NOT flipped by zombie
  reconciliation, and `fleetStatus()` reports occupancy > 0. This exercises the
  DEFAULTING path — a hand-constructed root does not pin it.

Fixtures for AUTOPILOT-6/95/31 that asserted the RPC count as the floor are
restored to the authoritative-inventory + fail-closed-floor semantics.

## Gate numbers (final tree)

| check | result |
| --- | --- |
| `bun run gate` | **exit 0** — `bun test` green; `tsc --noEmit` CLEAN (0 errors) |

## Non-goals

The wrapper/dotfiles repo (parked, separate item); the definition of a run /
leases / worktrees; how pi-subagents computes its own RPC count; and the
deliberate `unknown ⇒ counts` policy.
