Fix #1686: reconcile must confirm death before deleting/SIGTERMing a shellper row - #1693
Merged
Merged
Conversation
…shellper row Tower reconcile treated any failed shellper reconnect as death. reconnectSession() returns null not only for a dead process but also when client.connect() is refused because another Tower client already owns the shellper (one-client-per-shellper) or on a transient boot-time socket/fd hiccup. Phase 2 then SIGTERMed the still-live pid and deleted the row — destroying 53 live sessions in the #1629 incident. Require positive evidence of death before touching the process or row: the shellper pid must be gone AND/OR its socket file absent. A live pid with a present socket is "could not confirm" — the row is left in place (WARN) for a later pass, never signaled. Applied at both delete sites that read reconnect failure as death: - reconcile Phase 2 sweep (the SIGTERM + DELETE path from the incident), and - getTerminalsForWorkspace() on-the-fly reconnect (DELETE-only), which otherwise would delete the very row Phase 2 preserved on the first /api/state read. Adds an `unconfirmed` counter so a preserve-only reconcile no longer logs "No terminal sessions to reconcile". Regression tests cover both sites: a live-pid + present-socket row whose reconnect fails survives untouched and unsignaled (verified to fail without each guard).
CMAP (codex, HIGH) flagged that the earlier `shellperAlive && socketPresent` guard still SIGTERMed + deleted a LIVE shellper whose socket file was transiently absent — violating #1686's explicit "a row whose pid is alive is left in place ... never signaled". Architect ruling: adopt it. The issue's "AND/OR socket absent" clause was loose drafting; the invariant is "destroy only what is proven dead; proof = pid down". Both guards now gate on `shellperAlive` alone (socket-file state no longer contributes to the kill decision) at reconcile Phase 2 and the getTerminalsForWorkspace on-the-fly site. A pid-dead row still deletes regardless of socket state. Adds regression coverage for the (live-pid, socket-absent) case at both sites (verified to fail under the old socket-present guard). Accepted residual grows accordingly: a live but permanently-unreconnectable socketless shellper keeps its row until husk-sweep (#1227) / stop reaps it — leak a row, not a session.
Non-blocking review follow-ups (2× APPROVE, 1× COMMENT on the PR): - Reword two comments that still described the superseded "live pid + present socket" guard; the invariant is pid-only. - Add a fifth regression test asserting a pid-DEAD shellper row is still swept, bounding the guard against future over-preservation. (PR body separately corrected: the retained live socketless row is reaped by the next boot's killOrphanedShellpers, not the #1227 husk sweep, which exempts the still-"registered" pid.)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Tower's terminal reconcile treated any failed shellper reconnect as death: it SIGTERMed the (still-live) pid and deleted the DB row. In the #1629 incident this destroyed 53/56 live sessions. The fix makes death provable — a live pid is never signaled.
Fixes #1686
Root Cause
SessionManager.reconnectSession()(session-manager.ts:535-577) returnsnullfor five distinct reasons, only some of which mean the process is dead:client.connect()refused because another Tower client already owns the shellper (one-client-per-shellper), or a transient boot-time socket/fd hiccup (see tower startup can hang unbounded and unlogged in killOrphanedShellpers, tripping the 30s launcher timeout (field report on 3.3.3) #1685's environment).Reconcile Phase 1 collapsed all five into
"stale (PID/socket dead)", and Phase 2's sweep then SIGTERMedsession.pid(alive) and deleted the row — signalling exactly the live processes it should have preserved. The same delete-on-null defect lived behind a second door:getTerminalsForWorkspace()'s on-the-fly reconnect deleted the row (no SIGTERM) on any null, so the first/api/state//api/overviewread would delete a row Phase 2 had just preserved.Fix
Destroy only what is proven dead, and the sole proof of death is the pid being down. A live shellper pid is never signaled and its row is never deleted, regardless of socket-file state — a transiently absent socket is the same transient-failure class and must not kill a live agent. A pid-dead row still deletes as before. The row is left in place (WARN) and retried on a later reconcile/adoption pass.
Applied at both sites that read reconnect-failure as death:
getTerminalsForWorkspace()on-the-fly reconnect — the DELETE-only sibling path (folded in per review: same root cause; without it Phase 2's "retried next pass" promise is false).Also: the Phase 1 null-reconnect log no longer asserts "is stale (PID/socket dead)" (it defers the verdict to the Phase 2 sweep), and a new
unconfirmedcounter keeps a preserve-only reconcile from logging "No terminal sessions to reconcile".Note on the issue text: #1686 contained an internal tension — "positive evidence of death: pid dead AND/OR socket absent" vs. "a row whose pid is alive is left in place … never signaled". This PR resolves it (per review + owner ruling) to the invariant above: proof of death = pid down; socket-file state does not contribute to the kill decision.
Relationship to #1629: that issue's owner-lock guard stops the second-Tower amplifier; this removes the destructive mechanism, which a single-Tower transient can also trigger.
Known residuals / decisions
killOrphanedShellpers,tower-server.ts:725), which skips shellpers with a responsive socket (the incident case, correctly preserved) and SIGTERMs the socket-dead ones; the following reconcile //api/stateread then findsshellperAlive === falseand deletes the row. (Note: the Codev process fleet drives macOS memory-pressure kills: stranded shellper husks accumulate per restart + no idle-fleet memory policy #1227 husk sweep does not reap it — the retained row makes that pid count as "registered" and thus husk-exempt.) Deliberate: leak a row rather than kill a session.processExists(shellper_pid)can't distinguish a recycled pid. Kept conservative (a live-looking pid preserves the row); tightening viagetProcessStartTime()would re-open the false-death path this fix exists to close.shellper_socketset butshellper_pid === nullyieldsshellperAlive=falseand falls through to SIGTERM ofsession.pid— ~unreachable givensaveTerminalSession's call sites.Test Plan
&& socketPresentguard). Deterministic (no timers); theprocess.killspy passes signal 0 through to real liveness but swallows SIGTERM so a regression can't kill the test runner;onTestFinishedrestores the mock.pnpm build, exit 0).tower-terminals.test.ts65/65;tsc --noEmitclean; fullagent-farmsuite green (176 files, 3683 tests, 0 failures) afterpnpm build. Note: running vitest beforepnpm buildsurfaces 5getRolesDir→ "Roles directory not found" failures inconsolidate.test.ts/spawn-retirement.test.ts— those tests depend on the skeleton copied bybundle-assets, so they only fail when tests run ahead of the build (a pre-existing build-prerequisite artifact, reproduced identically on the clean base, unrelated to this change; CI builds before testing).