0010 — A finished session leaves, by its own exit path, never by a signal
- Status: Accepted ⚠️ VOCABULARY: the role this ADR calls the Operator is the Driver since 2026-09-10 —
operatoris the product's word for a PM company. Decision text below is unchanged; ids, binaries, trigger phrases and theOperatorTaskWorkflowtype that still sayoperatorare live invocations and deliberately lag. See ADR-0021. (Appended toStatuson purpose: this adds NO line, so every line-number citation into this file stays true.) - Date: 2026-09-02
- Deciders: the ccswitch session, on Gera's instruction — "how can we have sessions close when done? should we let them expire, with a 48 hr no activity kill switch?" and then "yes, build both." Planning input from Fable 5.1.
- Amends: decision b88294643, answered by Gera earlier the same day. That answer was "cap only… never killing a running one", and it explicitly declined a reaper. This ADR exists because ADR-0004 says a decision that changes how systems relate is not done until its ADR is written — and changing a decision Gera made is exactly that.
What b88294643 actually said, and why it is being amended within a day
After the 2026-09-01 kernel panic (88 sessions, 114.8 GB on a 64 GB box) Gera was offered a cap, a reaper, or both. He chose the cap:
"Cap only: refuse to start new sessions beyond a concurrency ceiling (~35 on this hardware), never killing a running one"
and added, in his own note: "cap but also return a log saying we have hit the limit; let's not create more until they're done; and make sure we clean them when they are done."
session-cap (local-bin#64) shipped those four clauses. The fourth — CLEAN —
was implemented as session-cap reap, whose predicate is tmux's pane_dead.
That clause is a no-op for memory, and the same day proved it. pane_dead
means the process has ALREADY EXITED, so its resident pages are already gone;
removing the tmux session returns a slot in the tmux sense only. Measured
2026-09-02: the cap REFUSED a start at 36/35 within hours of shipping, 14
finished sessions were holding 6.4 GB between them, and reap found nothing
to remove — because every one of those processes was still running. Closing
them by hand took free memory from 0.8 GB to 15.5 GB.
So the cap without a working fourth clause is the paralysis its own docstring warns about: a fleet that cannot start anything, on a box full of sessions that have nothing left to do. Something has to end a process. A dead pane is the aftermath, not the act.
The decision
- A finished session leaves through its own exit path.
claude stop <id>for a background session;/exittyped into the pane for an interactive one. Both run the session's Stop hooks, flush its transcript, and leave the conversation resumable. There is no SIGTERM and no SIGKILL anywhere on this path, and none may be added. - The Supervisor asks before it acts, and gives up before it escalates.
exits.tsruns afterreleases.tson the same terminal task: it sends one message, waits, requests the exit at most three times, and then raises it for a person. It never reaches for a stronger instrument. - A schedule expires the sessions no task can reach.
session-cap expire(every 30 min) covers the untasked majority, on a 48-hour window. - A task that is not terminal protects its session, always — including
awaiting-human. Those lanes are held open for Gera; ending one is a decision he has not been asked to make.
Why this is not the reaper he declined
The reaper was declined for a reason with a date. On 2026-08-08 this fleet's idleness detection misread a transient probe failure and retired 7 of 10 loops whose tasks were still active. That failure had three properties, and this design breaks all three:
| 2026-08-08 | here |
|---|---|
| the trigger was a status word from a probe | the triggers are timestamps; a status may only ever VETO an expiry, never cause one |
| a transient failure produced a plausible wrong reading ("gone") | a failed read produces no reading; every unknown holds |
| it acted per row, in a burst, within seconds | at most 3 per run, and if >25% of the fleet qualifies at once it acts on none and alarms |
And the act itself is different in kind. Gera's objection was that a killed
session's context is not recoverable. That is true of a signal against a
mid-turn session; it is not true of a graceful exit. Verified live on
2026-09-02: a 355 MB background session was stopped through this path, its
transcript grew on the flush (50,910 → 52,436 bytes), and claude --resume
came back with full context. A wrongly-exited session costs one resume.
The asymmetry Gera weighed — "a refused start is reversible, a kill is not" — therefore lands differently once the act is reversible and the subject is a session that is terminal, released, idle, silent for fifteen minutes, unattached, with a drained inbox, and asked first. What is not reversible is the other side: the panic took all 88 sessions at once with nothing flushed.
releases.ts rule 3 is narrowed, not repealed
Release states:
IT RELEASES A LOOP, NEVER A SESSION.
nudge --stop --sidkills the watcher process; it does not kill the Operator, which may legitimately still be writing its close-out.
That rule is right at the moment it applies. Release fires the instant a task turns terminal, and at that instant there is no evidence either way about whether the Operator is mid-close-out — so refusing to touch the session is the only honest move, and release keeps doing exactly that.
The exit phase runs later, on the same task, with evidence release never had:
fifteen minutes of transcript silence, roster idle rather than busy or
waiting, no attached client, a drained inbox, and a grace window since the
release. Rule 3's objection is about the moment, not about the act — so the
answer is a later rung, not a change to release.
What was measured, and what was not
Measured on the mini, 2026-09-02:
- 96 roster rows, of which only 28 carry a pid. The other 68 are finished
claude --bgrecords the roster keeps soattachcan resume them. Treating a roster row as a session would have handed all 68 to the idleness test. - 208 task files: 163
done, 28abandoned, 7parked, 10awaiting-human. All 198 terminal tasks already carryreleasedAt— release is working; it is the sessions that stay. - No live session is anywhere near 48 hours idle. The longest transcript
silence across all 28 is 4.5 h, so
session-cap expirefires on nothing today. It is a backstop, and it is honest to say so. - The box had been up 27 hours at the time of measurement, so this cannot settle whether 48-hour idleness happens in steady state. The panic came at 81 hours of uptime; the sampler that would answer the question only started the day before. This is an open measurement, not a settled one.
What is durable, and is not a post-reboot artifact: the 10 awaiting-human
tasks have been waiting 46 to 392 hours, and their Operators are respawned
as they die. Measured by process-tree RSS rather than extrapolated: 5.4 GB
across the ten, 389–790 MB each — about 35% of the fleet's entire 15.3 GB,
held by lanes waiting on an answer. The oldest, sess-agent-smith-d5, has
waited 269 h (11 days).
That is the largest identified block of memory held by sessions with nothing to do, and this ADR deliberately does not touch it. Bounding a lane that is waiting on Gera is a decision for Gera — it belongs on the Decisions page, not in a daemon.
Consequences
TaskFilegains five presence-latched fields (exitAskedAt,exitRequestedAt,exitRequests,exitedAt,exitGaveUpAt), all carried in thereadTasksallowlist.exitedAtandexitGaveUpAtare distinct on purpose: the second means the session is still running and we stopped asking.A new alarm kind,
session-will-not-exit, raised once per task.session-exitis the single implementation of "how a session leaves"; the Supervisor andsession-capboth call it rather than each learning the kind split, which fails silently in both directions.Smith's
daemon-herd-prune(agent-smith/src/agent_smith/machine_hygiene.py) is a second reaper with a different doctrine — itsis_recently_workedsays "unknown idle (None — transcript gone) is NOT protected". Measured against the live roster on 2026-09-02 by calling its ownselect_prunable: it would retire 0 of 76 background rows today, so this is a design hazard rather than a live one, and it is stated at that strength deliberately.The specific seam, for whoever picks it up:
classify_agentdecides onkind, the ROSTER'sstate,idle_hoursand the pin list. It never reads~/.claude/jobs/tasks/. Soawaiting-human— the state that exists precisely to keep a lane supervised and revivable — is invisible to it, and a session can carry rosterstate: donewhile its agentflow task is open (c5a0985cdoes today). Two mechanisms deciding the same sessions' fate from non-overlapping evidence is the thing to fix, not the doctrine gap alone.