[Bugfix #1224] Skip architect session resume when a live process holds the id#1225
Conversation
…ct session id resolveArchitectLaunch resumed a stored architect session id whenever its jsonl existed on disk (#1145), never checking whether a live process already held it. After a workspace restart a stale pre-restart shellper's claude child (or an unrelated foreground claude) still holds the id, so 'claude --resume <id>' dies with 'Session ID is already in use' and the shellper crash-loops forever. Add sessionHasLiveHolder(), a flag-anchored scan of the process table for '--session-id <id>' / '--resume <id>', and gate the resume branch on it: a held id mints a fresh session (with a WARN) instead of baking a colliding resume. Observation-only — it never touches the holder (case 2 is the user's own terminal). resolveArchitectRestart threads the logger so the restart-bake path inherits the guard.
Held owned id → mint fresh (with WARN) instead of --resume; no-holder → still resumes; ownership short-circuits the scan for stale ids. Plus sessionHasLiveHolder unit cases: session-id/resume holders match, unrelated id doesn't, scan failure → false.
CMAP review results
Both completing reviewers approved with HIGH confidence and no requested changes. Claude specifically flagged the TOCTOU windows (holder dies/appears between check and launch) as safe — either mints fresh unnecessarily or falls back to the existing #1149 crash-loop backstop — and confirmed the guard is purely additive (scan failure → |
|
Excellent root-cause work — the #1145-guard analysis ("a held session's jsonl exists precisely because the holder is writing it") is exactly right, and the fail-open, observation-only design is well judged. I'd like to see three changes before merge; the third is an approved scope expansion, not creep. 1. Detect shellper parents too. A remnant shellper's own argv carries the session id in JSON form — 2. Mint-or-reclaim, not mint-only. When the live holder is verifiably our own superseded shellper for the same architect identity, abandoning the conversation is a worse outcome than necessary. In that case: kill the stale instance (whole process group, SIGTERM with SIGKILL escalation — the 3. Fix symptom B in this PR as well. Two concrete legs:
For the wedge-after-free (incident 3's key datapoint: children kept dying <8s with the session demonstrably free), please capture and log the dying child's stderr/exit reason as part of this work — if the actual cause surfaces and is separable, document it in the PR rather than chasing it blind. Practical pointers: the full forensic timeline (PIDs, lstart times, session ids, the manual recovery that worked, plus today's live |
…fecycle reconciliation
Addresses the architect review's three asks on top of the initial live-holder guard.
1. Detect shellper PARENTS, not just claude children. A crash-looping remnant's
claude child is dead most of the time (<8s/respawn); the durable evidence is
the shellper parent's JSON config argv, which carries the id as
'"--session-id","<id>"'. sessionIdNeedles now includes the JSON forms, so
detection is reliable instead of timing-dependent.
2. Mint-or-RECLAIM (new servers/architect-session-holder.ts). classify a live
holder as our-own-superseded-shellper (shellper-main.js + matching session id
+ cwd + CODEV_ARCHITECT_NAME) vs foreign. Reclaim = reap the process group
(SIGTERM→SIGKILL, poll-for-death) then RESUME, preserving the conversation.
A foreign holder (e.g. the user's own tty claude) is NEVER touched → mint
fresh. Wired into addArchitect and launchInstance's main path (main recovers
via 'workspace start', so that is main's reclaim site).
3. Symptom B lifecycle:
- remove-architect now reaps a live-process-without-a-registry-row zombie
(findOwnArchitectShellpers by identity) instead of failing 'not found'.
- crash-loop give-up (SessionManager maxRestarts) reaps the shellper husk
(process-group SIGTERM) so it leaves no row-gone/process-alive zombie, and
captures the dying child's stderr/exit reason for the wedge-after-free.
…give-up husk reap architect-session-holder.test.ts: needle forms (child shell + shellper JSON), identity gate, classifier (own→reclaimable, foreign→never-reclaimable incl. foreign-alongside-own), findOwnArchitectShellpers, reapShellpers SIGTERM→SIGKILL escalation, and reconcile (foreign→no reap / own→reap / none→resume). Explicit never-kill-foreign coverage per review. session-manager: give-up SIGTERMs the shellper process group.
Iteration 2 — addressed the three review asksThanks for the detailed review. All three landed on this branch (commits 1. Detect shellper parents (JSON-argv needles)
2. Mint-or-reclaim (never touch foreign)New
The never-kill-foreign branch is covered explicitly ( 3. Symptom B
On the wedge-after-free (incident 3: children dying <8s with the session demonstrably free): claude's own error surfaces through the PTY data / terminal ring buffer, not the shellper's stderr, so give-up now logs the exit code/signal + shellper stderr for diagnosis and reaps the husk so the loop can't persist. The definitive cause of that specific datapoint is captured-for-diagnosis rather than claimed-fixed — flagging honestly per your "document rather than chase blind." The reporter's offer to run live diagnostics on the next occurrence would confirm it. ValidationFull build ✓ · full unit suite 3582 passed / 48 skipped / 0 failed · Re-running CMAP now and will re-request the |
…holder-check on restart-bake Two review-driven correctness fixes: - reapShellpers now polls for death AFTER the SIGKILL escalation too (codex review). Returning right after sending SIGKILL let the caller resume 'claude --resume <id>' while the holder was still in the kernel exit path and had not released the session lock — re-opening the collision race the reap exists to close. Added a killGraceMs post-SIGKILL confirmation poll + a test. - resolveArchitectRestart passes hasLiveHolder:()=>false. The restart-bake computes future-restart args for a shellper Tower already owns; at bake time the session's holder is THIS shellper's own child (its argv carries --resume <id>), so a self-detection would bake fresh args on every healthy reconnect and drop conversation continuity on the next child crash. Collision-avoidance lives at the add/launch reconcile layer + the #1149 runtime fallback, not the self-managed restart bake.
CMAP iteration 2 + responses
Codex #1 —
|
Architect Integration ReviewHigh-risk band (Tower core, ~600 added lines) — reviewed the full production diff against source, on top of the PR's own two CMAP iterations (codex REQUEST_CHANGES → SIGKILL-confirmation race fixed with the What I verified independently:
Two advisory notes, neither blocking:
Architect integration review |
Summary
After a workspace restart, re-adding architects via
afx workspace add-architectcould put an architect terminal into a permanent crash loop:The shellper restarted its
claudechild forever, every child dying instantly. This fixes the root cause: an architect launch would resume a persisted conversation session id without checking whether a live process already held it.Fixes #1224.
Root Cause
resolveArchitectLaunch(packages/codev/src/agent-farm/servers/tower-utils.ts) decides whether to resume a persisted architect conversation. Since #1145 it resumes a stored session id whenever the session jsonl exists on disk (sessionIsOwned→verifySessionOwnership).That existence check answers "does the transcript exist?" but not "is someone using it right now?" — and a held session's jsonl exists precisely because the holder is writing to it, so the #1145 guard is guaranteed to pass in exactly the collision case. Two live holders were reported:
claudechild for the same architect name (shellpers survive Tower restarts by design).claudethe user started by hand.In both cases the launch bakes
claude --resume <id>, claude sees the id already locked and exits with "Session ID is already in use", and the shellper auto-restarts into the same collision forever. (This is the reporter's suggested fix (a): never reuse a persisted session id without verifying no live holder.)Fix
sessionHasLiveHolder(sessionId, {list?})— scansps -A -o args=for a live process launched with--session-id <id>or--resume <id>(both space- and=-joined forms). The match is flag-anchored, not a bare substring: a session id is short in tests and a raw substring coincided with unrelated paths in the process table; the flags are exactly how a holder is spawned. On any scan failure (psmissing/timeout) it returnsfalse— the guard is purely additive, diverting to a fresh session only on positive evidence of a holder, and never making an un-held resume worse than before.resolveArchitectLaunchresume branch now requiressessionIsOwned(...) && !hasLiveHolder(...). A held id mints a fresh session (with aWARNlog) instead of baking a colliding--resume. Ownership is still checked first, so thepsscan is skipped for stale ids.hasLiveHolderandlogare injectable seams.resolveArchitectRestartthreads the logger, so the shellper restart-bake path inherits the guard for free.tower-instances.tspasseslog: _deps.logat theaddArchitectandlaunchInstancemain-path call sites, so the divert-to-fresh decision is diagnosable in Tower logs.This is observation-only — it never touches the holder. That is deliberate: holder case 2 is the user's own foreground terminal, so killing-to-reclaim would be wrong. Minting fresh is universally safe for both cases, and the architect ends up with a working conversation instead of a crash loop.
Scope note (what this PR intentionally does not do)
The issue also lists suggested fixes (b) and (c):
maybeApplyCrashLoopFallback(3 failing exits in 30s → swap to a fresh launch). This PR removes the collision at its source so that backstop isn't reached in the reported path.Test Plan
New cases in
tower-utils.test.ts:--resume(asserts no collision baked + aWARNlog). Fails without the fix.sessionHasLiveHolderunits:--session-id/--resumeholders match, an unrelated id doesn't, and a scan failure returnsfalse(never throws).Validation:
porch check— build ✓, tests ✓ (full suite).tsc --noEmitclean.