# /seats — the admission pool, visible and configurable

audience: AI coding agents first. slug: `seats-page`
source request: owner 2026-08-16, verbatim: "another thing i want is a new page: /seats where i both configure and observer the free seats. there is already partial plan for the seat allocation, and i want it consolidated. tell me how you think tis best to do it and slice it so i can start seeing it asap. the next slice after: Automatic seat dispatch for queue of tasks, according to priority." And: "A Seat is the cap limit of the agents, i have X seats cap per machine, + emergency seat." And: "i want to knwo which seat is occupied by which run/task and click on it should show me the run, and inside the run, click on the agent will show me agent transcript."

## What a seat IS (consolidated from the existing plans — do not re-derive)

A seat is one admission slot of the per-machine agent cap enforced by
`modules/workstation/claude/bin/_agent-session-admission`: at most `AGENT_SESSION_SLOTS`
(default 4) sessions run at once; launches above the cap wait in the FIFO at
`/run/user/1000/agent-session-slots/queue`; slot ownership is the `slot-N.lock` files in
that directory. The emergency seat is NOT an extra slot — its full owner contract is
`docs/specs/2026-08-15-seat-limiter-emergency-design.md` (always starts, runs a debt of 1,
next freed slot pays the debt; designation only via the Overdeck UI or /od-emergency;
journaled; debt visible). The buildbox runtime-seat plan
(`2026-08-05-agent-seats-on-buildboxes.md`) defines REMOTE seats under `~/seats/<seat-id>`
with `agent-seat-<id>.scope` units — those appear on this page as additional per-machine
pools when present, same row shape, host column differing.

## Architecture (Approach 1 — adapter over live slot state)

```
slots dir (/run/user/1000/agent-session-slots)     session ledger + board attribution
        │ poll (existing collector scheduler)              │
        ▼                                                  ▼
collector/src/adapters/seats.ts ──────────────► requests-store-adjacent seats snapshot
        │  GET /seats (authenticated, same pattern as existing pages)
        ▼
apps/web/src/pages/seats.astro + components/seats/SeatsApp.tsx
```

### Adapter contract — `collector/src/adapters/seats.ts`

`collectSeats(): SeatsSnapshot` on the existing adapter scheduler seam (same registration
as the github-checks adapter).

```ts
type SeatRow = {
  host: string;               // "laptop" | buildbox name
  slot: number;               // slot index; emergency rows use slot: -1
  state: "free" | "held" | "wedged";
  holder: null | {
    pid: number;
    sessionId: string | null;     // from the session ledger, if resolvable
    sessionName: string | null;   // friendly name via the attribution seam
    workKey: string | null;       // board row / plan slug via attribution
    adwId: string | null;         // factory run when the holder is a factory session
    heldForSec: number;
  };
};
type SeatsSnapshot = {
  hosts: { host: string; cap: number; queueDepth: number; emergencyDebt: number }[];
  rows: SeatRow[];
  observedAt: string;
};
```

Behavior (prose, not body — CORRECTED per sol review 2026-08-16 against
modules/workstation/claude/lib/buildslot.sh): the `slot-N.lock` FILE is persistent state;
OWNERSHIP is the kernel flock, released when the admitted process tree exits, leaving the
file and its stale stamp behind. File-existence or stamp-pid liveness is NEVER the
classifier. Four states:
- `free`: a nonblocking exclusive flock PROBE succeeds — regardless of stale stamp content.
- `held`: probe fails AND the stamp's pid + process start-ticks still match /proc.
- `wedged`: probe fails, the stamped process is dead/reused, and a bounded confirmation
  (the limiter's own ghost-check ordering: contention → stamp validation → lsof) confirms
  abnormal ownership. The `queue.lock.wedged-*` artifacts also render as wedged facts.
- `unknown`: probe fails but confirmation cannot run safely — rendered as unknown, never
  guessed into held or wedged.
The adapter is strictly READ-ONLY: it never calls the limiter's unlink/rotation path.

Holder resolution (the stamped pid identifies the admission/exec chain, NOT the ledger's
runtime pid — systemd-run forks the scoped command, so equality-join is wrong): read and
validate the stamp pid/start-ticks; enumerate actual holder processes of the lock inode
(bounded to the configured slots); correlate holder/descendant pids against ledger `pid`;
anything unresolved renders as an unresolved holder (stamp pid in diagnostics only) —
never fabricate a session. A future stronger seam (admission stamping the session id
directly once runtime attribution exists) is S2+ work, recorded here so nobody re-derives
the equality-join mistake. `emergencyDebt` reads the limiter's journal when the S2/S3
machinery exists; until then it is 0 with the field present (forward-compatible shape).

### Page contract — `/seats`

- One section per host: cap, free count, queue depth, emergency debt chip (owner language:
  "Emergency seat active — next free slot repays it").
- Seat rows: state, holder session NAME (not id), its task title (board row via workKey),
  held-for duration. Raw pids/ids live behind the diagnostics affordance, never on the row
  face (same doctrine as the activity story).
- Click-through: row → the run it serves — factory holders link to
  `/factory/[adwId]`; inside, agents link to `/factory/[adwId]/agents/[agentId]` (the
  rehomed activity story). Non-factory sessions link to the session's board row drawer.
- Queue: the waiting FIFO renders as "N waiting" expandable to waiting entries with age.
- Empty/degraded honesty: slots dir unreadable → the page says the limiter state is
  unavailable, never renders a fabricated pool.
- UI doctrine: new page → Astryx primitives; deck-ui composition only; tokens; both
  themes; gallery registration for any new deck-ui component; slopgate green.

### Config seam (S2)

`~/.config/overdeck/seats.toml` — `cap` per host + emergency designation state. The
admission script sources cap from this file when present (env var still wins for tests);
the page writes it through a collector endpoint that validates (positive integer, journal
who/when). Emergency designation implements the 2026-08-15 emergency spec exactly —
this page is the "selected by me in the overdeck UI" designation source the contract
names. Never a second limiter: the page configures, `_agent-session-admission` enforces.

### S3 — automatic seat dispatch (named next slice, design pinned, not built in S1/S2)

A dispatcher (systemd path/timer-free: triggered by the slot-release seam the admission
script already owns) that, on a freed slot with an empty emergency debt: takes the
highest-priority `asked` board row carrying a plan-backed work_key, launches it via
`factory ... --skip-plan docs/plans/<work_key>.md` under the freed slot, and flips the row
in_flight via the existing writers. Priority = board priority (FIRE > numbered > NORMAL),
FIFO within a class. Rows without a plan doc are never auto-dispatched (no synthetic
plans). Every dispatch journaled + visible on /seats as the slot's holder. Emergency debt
semantics per the emergency spec outrank any dispatch.

**S3's triggering mechanism is the push upgrade (Approach 2, owner-ratified 2026-08-16):**
at slot release, the admission script emits a fire-and-forget event (append to a spool
file, or POST with sub-second timeout and unconditional fail-open) that wakes the
dispatcher — and the SAME event feeds the seats adapter as a second source, demoting the
file scan to reconciliation. HARD ACCEPTANCE CRITERION: admission NEVER waits on the
collector — a session that cannot launch because the observability service is down is the
exact coupling class this fleet spent 2026-08-16 removing from deploys and gates; any
implementation where collector unavailability delays or fails an admission is rejected.
The page/route/components do not change: push lands inside the adapter seam, exactly once,
for a consumer (the dispatcher) that genuinely needs slot-release latency — as a pure
display upgrade it stays unbuilt.

## Error handling

Adapter failures degrade to "state unavailable" honestly. Config writes fail closed with
the validation error surfaced on the page. The dispatcher (S3) never retries a failed
launch silently — a failed dispatch releases the slot and marks the row with the failure
line via the receipt trail.

## Testing

Adapter: fixture slots dirs (held/free/wedged/dead-pid) → snapshot assertions; attribution
resolution with and without ledger entries. Page: component tests for row states, debt
chip, click-through hrefs, degraded state. S2: config write validation + journal. S3:
dispatcher unit tests with a fake board + fake admission seam (priority order, debt
blocking, no-plan refusal). All suites in their homes' existing styles.

## Slices

- S1 (owner-visible same day): adapter + read-only /seats with click-through into runs and
  agent transcripts. Evidence: the page shows the real current pool with this session
  holding a seat, wedged artifact visible.
- S2: cap + emergency designation configuration (implements the emergency spec's UI
  source; debt chip goes live).
- S3: automatic priority dispatch from the board queue.

## Architecture Decisions

- Adapter-over-files chosen for S1 over admission-push events: zero hot-path coupling and
  nothing visible ships sooner with push. Push is NOT rejected — it is S3's triggering
  mechanism (owner-ratified), landing inside the same adapter seam with fail-open as a
  hard acceptance criterion. The objection was to push as the STARTING point, not to the
  architecture. (Two-way door, and the door gets walked through exactly once, in S3.)
- Emergency semantics live in the LIMITER (S2 implements the existing spec); the page only
  designates and displays — deletion test: removing the page leaves the limiter whole.
- No new drawer/inspector primitive — reuse DetailDrawer for diagnostics (standing rule).
