0028 — The session ceiling counts what is actually running, and 70 is the number

Why this was not a tuning change

session-cap refuses a start past a ceiling. It counted with host_headroom.claude_processes(), which cannot see a daemon-hosted session at all. A background session runs as one process carrying both the wrapper and the real binary on a single command line:

claude bg-pty-host --bg-pty-host /tmp/cc-daemon-501/<d>/spare/<id>.pty.sock \
    200 50 -- .../share/claude/versions/2.1.276 --session-id <sid> ...

Its comm is the version path, so the claude test matched. Its cmd contained bg-pty-host, so the helper test dropped it. The second won, and the session was counted as scaffolding. Measured 2026-09-19: seven of eight version-named processes carried a helper marker.

That is why the ceiling did not hold on 2026-09-17, when a spawn bug (nudge:1568, local-bin #231) took the fleet from 16 to 134 sessions in 2h30m and the machine kernel-panicked on VM compressor segment exhaustion. The cap was not lagging. It was structurally blind to the only population that was growing, and its counted number sat pinned at 35-36 throughout.

The measurement the decision rests on

254 paired readings, 2026-09-20, 00:21 → 21:29:

                min  median  p90  MAX
GATED (old)      29    31     35   39
ARGV  (true)     38    40     44   51     ratio 1.29

The true count exceeded the old ceiling of 35 in 254 of 254 samples, on a box that was healthy all day: swap 0.0G on every reading, compressor flat at 9-15% and not rising with the count, state OK on 252 of 254.

The decision

  1. The ceiling counts by argv. claude_processes_argv() classifies on argv position: the effective program is whatever follows a standalone --, and a process is a helper only when that program's own first argument is a helper subcommand. Substring matching against the whole command line is what caused the blindness and is not used.
  2. The ceiling is 70. It clears the observed peak of 51 with about a day of headroom, and still stops a 09-17-shaped burst far below 134. It is NOT "35 adjusted" — the two numbers count different populations and are not comparable.
  3. The count ceiling and the pressure verdict both stay, because they answer different questions. pressure_verdict reads swap, compressor and free — "the box is dying now" — and already covers memory, including the ~2.8 GB of idle spares no session count can see. The ceiling answers "too many things are starting". They are not redundant: on 09-17 pressure did not reach DANGER until 05:45, by which time the fleet was already ~100. Pressure is the backstop; the count is the only control that can fire early.
  4. A count is an acknowledged proxy, not the hazard. Sessions measured 427 MB median on 2026-09-20; at the panic they averaged ~1.6 GB. The hazard is count × size. SYSTEMS.md already says "session age is the variable, not session count". The ceiling is kept because a count is what a spawn site can be refused against — and for no stronger reason than that.

⚠️ What this decision does NOT fix, and Gera named it when he took it

The same 254 readings show the fleet growing +0.62/hour, +15/day, with the hourly minimum rising too — accumulation, not churn. Of the 47 sessions live at 21:30, 34 were over a day old and 14 over two days.

A ceiling alone converts "crash in N days" into "wedge in N days." At this drift, any fixed number is reached and then refuses everything. The decision taken was explicitly "70, plus work the drift". The ceiling buys about a day; it is not the fix. Sessions have to leave.

The lead: tests/session-expire.py is red on local-bin main at 82 passed / 15 failed (measured 2026-09-20, identical before and after this change). The failing cases cover status holds, the per-run cap, and whether a run writes its log line at all — the behaviour of the one tool whose job is to remove sessions.

The failure mode is a wedge, so the rollback is not "remove the brake"

SESSION_CAP_COUNT=legacy returns the gate to the old number while keeping refusal, rather than SESSION_CAP_OFF=1 which removes it. An unreadable count still yields ALLOW-UNKNOWN: a gate that fails closed on its own bug stops every spawn path on the machine, including the session someone would open to fix it. Controls live in tests/session-cap-gates-on-the-true-count.py, where the load-bearing cases are the ALLOWs — a guard that refuses everything hides behind looking careful.

Consequences