Symptom
After a Tower restart, a persistent (shellper-backed) terminal can become a silent zombie: viewers (VS Code adapter AND web dashboard) render a blank screen, keystrokes and afx send messages go nowhere, yet Tower reports the session as status: "running", persistent: true and the message router logs "Message sent". No error or warning appears anywhere. The underlying shellper and its Claude child are healthy the whole time, with the full conversation intact.
Observed 2026-07-18 on the shannon workspace's app architect (terminal dd7f81ad, shellper pid 4509, claude child 4640 alive since Jul 13). At least three messages were dropped into the void between the 06:25Z restart and diagnosis (two from builder pir-2579, one cross-workspace test), each logged as successfully sent.
Evidence trail
- Tower startup log 06:25:18.470Z:
Session dd7f81ad… reconnected: pid=4509 — claims success.
GET /api/terminals/dd7f81ad…/output → {"lines":[],"total":0}. Every healthy architect terminal returns total=1 (the reconnect replay frame).
POST /api/terminals/dd7f81ad…/resize succeeds and updates cols/rows in the DB, but the output buffer stays empty: no SIGWINCH ever reaches the PTY, so the resize is a DB-only mutation.
- fd asymmetry: every healthy shellper socket shows 3 fds (
lsof on the socket path); app's shows 2. The missing fd is the live connection from the current Tower.
- No
shellper disconnected unexpectedly line for this session anywhere in the log. The failure left zero trace.
- Context: TWO Tower starts that morning, 06:18:41Z and 06:25:18Z. The zombie was minted during the second start's adoption pass.
Root cause: an error-path close is swallowed, and everything downstream hangs off it
packages/codev/src/terminal/shellper-client.ts:
-
On any post-handshake socket or parser error, onError runs safeEmitError(err) then cleanup().
-
cleanup() (line ~266) sets this._connected = false before destroying the socket.
-
The socket's subsequent 'close' event hits the guard (lines ~153-163):
socket.on('close', () => {
const wasConnected = this._connected; // already false — cleanup ran first
this.cleanup();
if (wasConnected) { this.emit('close'); } // skipped on every error-path close
So the client suppresses its own 'close' emission exactly when it matters most.
-
session-manager.ts's client.on('close') handler (reconnect path, ~406-413) is the ONLY code that logs shellper disconnected unexpectedly and calls removeDeadSession. It never runs. The session stays in the map forever.
-
The 'error' the client DID emit is re-emitted by session-manager as 'session-error', which has no consumer in the Tower server layer — not logged, not acted on.
-
From then on, write() and resize() are silent no-ops by design: if (!this._connected || !this.socket) return; (lines ~274-282). The message router cannot distinguish delivered from dropped, so it logs "Message sent" for messages that were dropped.
Net: a transient one-shot socket error is converted into a permanent, invisible outage of one terminal, with success-shaped logging on top. There is no retry path in the running process; only a full Tower restart (which discards the poisoned in-memory client and re-runs adoption) clears it — and each restart is a fresh roll of the same dice for every persistent terminal.
Contributing race: afx tower stop does not wait
packages/codev/src/agent-farm/commands/tower.ts (~346-358): towerStop sends SIGTERM and returns immediately; there is no poll for process exit. towerStart tolerates ~1s of port-in-use. So afx tower stop && afx tower start guarantees an overlap window in which the new Tower's adoption pass (all 20 shellper connects within ~50ms of boot) runs while the old Tower is still tearing down its 20 connections. Each shellper briefly services a dying client and a new handshake concurrently — a plausible source of the transient post-handshake error. This is the likely trigger for today's instance (timing fits; exact errno not recoverable because nothing was logged).
Proposed fix (in priority order)
- Fix the swallowed close. Emit
'close' based on a "was ever connected" flag that cleanup() does not clear, or drive the dead-session path from cleanup() itself. An error-path close must produce the same removal + logging as a clean disconnect.
- Consume and log
'session-error' in the Tower layer. A reconnect that dies must leave a trace.
- Make
towerStop wait: SIGTERM, poll until the PID exits, escalate to SIGKILL after a timeout. stop && start should serialize, not overlap.
- Make
write() failure observable (return boolean or throw) so the message router stops logging "Message sent" for drops.
- Optional hardening: the protocol already has PING/PONG; Tower could detect zombie sessions via missed heartbeats and re-run the reconnect for that session instead of waiting for a full restart.
Why this matters beyond one blank tab
Gate notifications, builder wake-ups, afx send, and all sibling-architect coordination ride this same PTY-injection path. The current failure mode is "silently degrade one terminal until the next full restart, which may silently degrade a different one", with the router attesting success throughout. #1194 (gate-event notifications) will increase reliance on this path.
Remediation for a currently-stuck terminal
Tower restart (discards the zombie client; adoption re-connects). Verify afterwards, do not trust appearances: every persistent terminal's GET /api/terminals/:id/output should be non-empty; any total=0 terminal is a fresh zombie (confirm via fd count on its shellper socket).
Symptom
After a Tower restart, a persistent (shellper-backed) terminal can become a silent zombie: viewers (VS Code adapter AND web dashboard) render a blank screen, keystrokes and
afx sendmessages go nowhere, yet Tower reports the session asstatus: "running", persistent: trueand the message router logs "Message sent". No error or warning appears anywhere. The underlying shellper and its Claude child are healthy the whole time, with the full conversation intact.Observed 2026-07-18 on the shannon workspace's
apparchitect (terminaldd7f81ad, shellper pid 4509, claude child 4640 alive since Jul 13). At least three messages were dropped into the void between the 06:25Z restart and diagnosis (two from builder pir-2579, one cross-workspace test), each logged as successfully sent.Evidence trail
Session dd7f81ad… reconnected: pid=4509— claims success.GET /api/terminals/dd7f81ad…/output→{"lines":[],"total":0}. Every healthy architect terminal returnstotal=1(the reconnect replay frame).POST /api/terminals/dd7f81ad…/resizesucceeds and updates cols/rows in the DB, but the output buffer stays empty: no SIGWINCH ever reaches the PTY, so the resize is a DB-only mutation.lsofon the socket path);app's shows 2. The missing fd is the live connection from the current Tower.shellper disconnected unexpectedlyline for this session anywhere in the log. The failure left zero trace.Root cause: an error-path close is swallowed, and everything downstream hangs off it
packages/codev/src/terminal/shellper-client.ts:On any post-handshake socket or parser error,
onErrorrunssafeEmitError(err)thencleanup().cleanup()(line ~266) setsthis._connected = falsebefore destroying the socket.The socket's subsequent
'close'event hits the guard (lines ~153-163):So the client suppresses its own
'close'emission exactly when it matters most.session-manager.ts'sclient.on('close')handler (reconnect path, ~406-413) is the ONLY code that logsshellper disconnected unexpectedlyand callsremoveDeadSession. It never runs. The session stays in the map forever.The
'error'the client DID emit is re-emitted by session-manager as'session-error', which has no consumer in the Tower server layer — not logged, not acted on.From then on,
write()andresize()are silent no-ops by design:if (!this._connected || !this.socket) return;(lines ~274-282). The message router cannot distinguish delivered from dropped, so it logs "Message sent" for messages that were dropped.Net: a transient one-shot socket error is converted into a permanent, invisible outage of one terminal, with success-shaped logging on top. There is no retry path in the running process; only a full Tower restart (which discards the poisoned in-memory client and re-runs adoption) clears it — and each restart is a fresh roll of the same dice for every persistent terminal.
Contributing race:
afx tower stopdoes not waitpackages/codev/src/agent-farm/commands/tower.ts(~346-358):towerStopsends SIGTERM and returns immediately; there is no poll for process exit.towerStarttolerates ~1s of port-in-use. Soafx tower stop && afx tower startguarantees an overlap window in which the new Tower's adoption pass (all 20 shellper connects within ~50ms of boot) runs while the old Tower is still tearing down its 20 connections. Each shellper briefly services a dying client and a new handshake concurrently — a plausible source of the transient post-handshake error. This is the likely trigger for today's instance (timing fits; exact errno not recoverable because nothing was logged).Proposed fix (in priority order)
'close'based on a "was ever connected" flag thatcleanup()does not clear, or drive the dead-session path fromcleanup()itself. An error-path close must produce the same removal + logging as a clean disconnect.'session-error'in the Tower layer. A reconnect that dies must leave a trace.towerStopwait: SIGTERM, poll until the PID exits, escalate to SIGKILL after a timeout.stop && startshould serialize, not overlap.write()failure observable (return boolean or throw) so the message router stops logging "Message sent" for drops.Why this matters beyond one blank tab
Gate notifications, builder wake-ups,
afx send, and all sibling-architect coordination ride this same PTY-injection path. The current failure mode is "silently degrade one terminal until the next full restart, which may silently degrade a different one", with the router attesting success throughout. #1194 (gate-event notifications) will increase reliance on this path.Remediation for a currently-stuck terminal
Tower restart (discards the zombie client; adoption re-connects). Verify afterwards, do not trust appearances: every persistent terminal's
GET /api/terminals/:id/outputshould be non-empty; anytotal=0terminal is a fresh zombie (confirm via fd count on its shellper socket).