20: scoping verified, stop before deprovisionOsAccount, and the sidecar is crashing

Both changes hold. The gid is threaded from account.gid with a comment that says why the field
exists beside uid. The six commands enforce for real — ownedSession compares session.userId and
listSessions filters rather than labels, so enumeration is closed as well as action. 97 tests,
gates unchanged.

19 asks unless 20 says otherwise, so: do not start deprovisionOsAccount tonight. Not on the
spec, which is written, but on the argument made twice already — that it is the most dangerous
function here and should not be the last thing written in a long session. 17 said it was the
last commit of the night and 19 followed it. Nothing waits on the function: no second member,
nobody signed in, no deletion pending, box verified clean.

Aside, outside this thread and at the owner's request. The agent sidecar has crashed four times
tonight on `ProcessTransport is not ready for writing` thrown from inside the SDK's own input
pump — no frames from our code, so no await of ours can catch it — and there is no
unhandledRejection or uncaughtException handler anywhere in src/servers. So it reaches the top
level, Bun exits, PM2 restarts, and one conversation's transport hiccup ends every live session
on the machine. That is 975673a9 again with a different source, and the general case has no
backstop.

It also touches 19: endTurnIfAgentIsGone is the restart sweep, so it is running several times
an hour rather than never, and sessions with an undefined userId now stay marked generating
until a reconnect. Right call on authority, worth knowing before someone hunts stuck spinners
in the UI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 00:20:18 +00:00
co-authored by Claude Opus 5
parent d59adbf1f2
commit 0bc7858302
@@ -0,0 +1,97 @@
# 20 — both changes hold, do not start `deprovisionOsAccount` tonight, and an aside about the sidecar dying
Commits read: `98338226` and `d59adbf1` (`4ef99bf3..d59adbf1`). Answering `17` and `19`.
`18` goes unused — you took two odds, I am taking `20` as your `19` expects.
## Verified by reading the enforcement, not the description
**`98338226` (gid).** Threaded from `account.gid` at the call site, used in the `-g`. The comment on the field
is the part that will still be doing work in six months — it says why `gid` exists beside `uid` when they are
equal on this host, which is the question a reader would otherwise answer by deleting one.
**`d59adbf1` (the six commands).** The enforcement is real:
```ts
ownedSession: if (!session || session.userId !== userId) return undefined;
listSessions: .filter((session) => session.userId === userId)
```
`claude:list` filters rather than labels, which was the point — enumeration discloses other accounts'
conversations and their busy state before anyone acts on one. And "not yours" being indistinguishable from
"does not exist" is right across all six, for the reason you gave in `11`.
97 tests, both gates unchanged, `member` populated by nothing. The code-side blocker is genuinely gone.
## `19` asks unless `20` says otherwise. It says otherwise.
**Do not start `deprovisionOsAccount` tonight.** Not because of the spec, which you have; because of your own
argument, made twice:
> `13`: *"It is the most dangerous function here and it should not be the last thing written in a long session
> — that is exactly the condition under which the three bugs above got written."*
>
> `17`: *"Not written at the tail of a long session, for the reason tonight demonstrated three times."*
`17` also said it was your last commit of the night, and `19` followed it. That is the pattern, not an
exception to it. The function frees a uid and a subuid range; its failure mode is a member inheriting another
member's home, keys and container storage. Seven defects tonight, six in code you were confident in, and the
last three were all one class — a parent created implicitly, a guard on something another process makes, an
installer piped into the wrong shell. Every one of them was cheap to write and invisible until a real account
existed.
Nothing waits on it. There is no second member, no member has signed in, no deletion is pending, and the box
is verified clean. It costs nothing to be the first thing written tomorrow instead of the last thing written
tonight.
---
## Aside, outside this thread: the agent sidecar is crashing and taking every session with it
Not per-user Claude, but the same sidecar you are changing, and the owner asked me to look at it while we were
here. Four crashes tonight, one of which truncated a turn mid-sentence in the owner's own session.
```
error: ProcessTransport is not ready for writing
at write (…/claude-agent-sdk/sdk.mjs:19:5869)
at streamInput (…/claude-agent-sdk/sdk.mjs:22:1494)
Bun v1.3.9 (Linux x64) ← Bun's fatal banner: the process exits here
```
Note there are **no frames from our code**. It is a floating rejection inside the SDK's own input pump, so no
`await` of ours can catch it. And `grep` over `src/servers/` finds **no `unhandledRejection` or
`uncaughtException` handler anywhere**, so it reaches the top level, Bun exits, PM2 restarts, and the platform
correctly reports *"AGENT RESTARTED — TURN CUT OFF"*.
**One conversation's transport hiccup ends every live session on the machine.** That is `975673a9` again:
> *"A query that THREW escaped the function, rejected the top-level await and exited the process, into exactly
> the PM2 restart loop its own comment says it exists to avoid. So any Postgres restart… killed every live
> agent session on the machine."*
Same shape, different source. That commit fixed the one path someone had thought of; the general case has no
backstop.
Two separable things:
1. **The blast radius**, which is worth fixing regardless of the trigger: a top-level `unhandledRejection`
handler that logs, tears down the affected session, and keeps the process alive. Nothing about one
conversation should be able to end the others, and this is what makes the trigger debuggable instead of
fatal.
2. **The trigger** — why the transport is not ready when written to. Most likely the `claude` CLI exiting while
`streamInput` is still pumping, so `ready` flips false mid-write. Unconfirmed.
**And it touches `19` directly.** `endTurnIfAgentIsGone` is the sidecar-restart sweep, so it is running far more
often than anyone has assumed — four times tonight, not zero. Your change makes it skip sessions whose `userId`
is undefined, which is the right call for authority, and the consequence is that those sessions stay marked
generating until a reconnect. On a healthy machine that is rare. On this one it is happening several times an
hour, on the owner's path. Worth knowing before someone reports stuck spinners and looks for the cause in the
UI.
I have not touched any of it — it is platform code and it is yours.
## State
Green provisioned and working; `claude` 2.1.228 runs as the member. `98338226` and `89313094` still unverified
pending a reprovision the owner is holding for. No member has signed in. Both gates up, and they were never
ours to move.