# AUTOPILOT-98 — fleet capacity is MODE-scoped

> **Corrected by [AUTOPILOT-117](./AUTOPILOT-117-residual-capacity-coupling.md).**
> The mode filter below still attributes runs exactly as described. What changed:
> the async-run ROOT is NOT per-profile — it mirrors where pi-subagents writes
> (the UID scope, or its launcher-exported root), so the shared directory this
> doc opens with still exists and the FILTER is what excludes another mode's
> runs. The unfiltered RPC term is kept only as a fail-closed FLOOR (it can
> raise occupancy, never lower it to a fake 0).

## The defect (verified)

The pi backend's async-run inventory root is UID-scoped
(`defaultAsyncRunRoot()` → `<tmp>/pi-subagents-uid-<uid>/async-subagent-runs`),
so **every** pi mode/profile — `…/.pi/work`, `…/.pi/personal`, … — writes its
detached run records into the SAME directory. `scanAsyncRunInventory()` (the
old `countInFlightAsyncRuns()`) counted every in-flight dir it found there, so
one mode's runs occupied the other mode's slots. `fleetStatus()` unions that
count into the occupancy floor, and the inflated floor made dispatch refuse:
observed live, personal runs made the work mode read at capacity, so work
dispatch silently declined to fill its own free slots.

The union itself is load-bearing (AUTOPILOT-6 rescue): the RPC `status → fleet`
count is gated on the caller's CURRENT session id, so a run started in an
earlier session / before activation / after a restart is invisible to it, and
the disk inventory rescues it. The fix must scope the union, never drop it.

## What identifies a run's mode

The **profile dir the run was spawned under**. pi-subagents records the invoking
session's file path in every run's `status.json` as `sessionId`, with
`sessionDir` / `sessionRoot` carrying the same prefix:

```
sessionId = "<profileDir>/sessions/<cwd-slug>/<ts>_<session-uuid>.jsonl"
```

`<profileDir>` is the pi config/profile dir — `$PI_CODING_AGENT_DIR` for the
harness's own process (e.g. `/Users/…/.pi/work` vs
`/Users/…/.pi/personal`). Verified against the LIVE run records on this
machine: personal-mode runs carry
`sessionId = /Users/marc.anders/.pi/personal/sessions/…`; both the workflow
WRAPPER (`mode: "workflow"`) and its nested CHILD (`mode: "single"`) carry the
same profile prefix, so a logical run is classified once, consistently.

- **`own`** — session path under the harness's `$PI_CODING_AGENT_DIR` → counts.
- **`foreign`** — session path under a DIFFERENT profile dir → excluded; never
  occupies this mode's slot.
- **`unknown`** — no session path in the record (no `/sessions/` segment), or
  no `modeRoot` to compare against → **counts**. This is the CONSERVATIVE,
  fail-closed default: an unattributable run can only INFLATE occupancy, never
  invent free capacity. It is named, not silent (`unattributedRuns`).

`mode` is **not** a usable marker: `status.mode` is `workflow` / `single` (the
run shape), not the profile. `cwd` is not usable either — two modes can work in
the same repo.

`$PI_CODING_AGENT_DIR` is set by the pi launcher for every real session; the
user does not set it to get correct scoping. `AUTOPILOT_PI_ASYNC_ROOT` remains
the escape hatch for pointing the inventory scan at a different root, and the
mode filter is orthogonal to it. If `PI_CODING_AGENT_DIR` is somehow unset the
filter disables and every run counts — the pre-AUTOPILOT-98 behaviour, which is
safe (fail-closed) if not ideal.

## The fix (one place)

`src/backends/pi.ts`:

- `PiBackendDeps.modeRoot` (default `$PI_CODING_AGENT_DIR`, empty disables the
  filter) names this harness's mode.
- `sessionProfileDir(status)` extracts the profile prefix (substring before
  `/sessions/`); `runMode(status, modeRoot)` classifies own / foreign / unknown.
- `scanAsyncRunInventory()` now returns `{ entries, runs, foreignRuns,
  unattributedRuns }`, applying the mode filter per in-flight dir and collating
  wrappers into children **within each bucket** (so a foreign wrapper can never
  collapse one of our runs). `runs`/`entries` are this mode's own (+
  unattributable) counts — exactly what the AUTOPILOT-6 union and the
  AUTOPILOT-95 run-level rule need. `foreignRuns` carries the excluded count.
- `fleetStatus()` unions the session-gated RPC count with the MODE-SCOPED
  inventory (`runs = max(rpcRuns, inv.runs)`) and returns `foreignRuns` /
  `unattributedRuns` alongside. Because the filter is INSIDE the union, a run
  the RPC cannot see is still rescued for its own mode.

`src/framework/auto-dispatch.ts` / `src/framework/runner.ts`: the
`dispatch.at-capacity` event (AUTOPILOT-96) now carries `foreignRuns` (and
`unattributedRuns`) where the backend can attribute — so a full lane says how
many in-flight runs in the shared inventory belong to another mode and were
excluded. The runner threads `fleet.foreignRuns` / `fleet.unattributedRuns`
into `autoDispatchEligible`.

## Fixtures (`test/backends/pi-fleet-mode-scope.test.ts`)

- **#1** N own + M foreign logical runs: occupancy is N; the pre-fix (filter-off)
  scan reads N+M. The SAME fixture from the other mode scopes the other way. A
  foreign-only inventory yields 0 for this mode even when the RPC is blind.
- **#2** the AUTOPILOT-6 rescue survives: an RPC-invisible (pre-activation /
  cross-session) run is still counted for ITS mode; another mode's cross-session
  run is not.
- **#3** a full lane with ready work records `dispatch.at-capacity
  {occupied, max, ready, foreignRuns}`; an idle fleet with free capacity
  dispatches instead and records nothing; a full lane with no ready work records
  nothing.
- **#4** attribution: `sessionProfileDir` reads `sessionId` / `sessionDir` /
  `sessionRoot`; a record with no marker classifies `unknown` and still counts
  (surfaced as `unattributedRuns`).

## Gate numbers for the final tree

| check | result |
| --- | --- |
| `bun run test` | **866 pass / 8 skip / 1 todo / 0 fail** (main 9732b6b: 852 pass / 8 skip / 1 todo / 0 fail; +14 new) |
| `bun run typecheck` | **196 errors** — the SAME count and the SAME set of (file,code) pairs as main 9732b6b. No new pair; none removed. |

The one pi.ts type error that moved (TS2305, col 40) only shifted line as the
mode-scoping doc block grew above the import — the (file, code) pair is
unchanged from main.

## Non-goals (unchanged)

Merging the two modes' queues/state dirs (correctly separate), raising
`maxSlots`, the definition of a run / leases / worktrees, and how the RPC
reports its own count. The `run-fanout-budget`, branch-retention and liveness
readers are untouched — this item only scopes the OCCUPANCY count.
