Skip to content

feat: remain-on-exit — retain dead panes for reliable crash detection - #307

Open
dcetlin wants to merge 6 commits into
mainfrom
feat/remain-on-exit
Open

feat: remain-on-exit — retain dead panes for reliable crash detection#307
dcetlin wants to merge 6 commits into
mainfrom
feat/remain-on-exit

Conversation

@dcetlin

@dcetlin dcetlin commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • CC's EIO teardown during REPL unmount destroys the tmux pane. With remain-on-exit on, the pane is retained after death and #{pane_dead} distinguishes alive from dead-but-retained.
  • Adds sessionProcessAlive() — checks #{pane_dead} via tmux display-message. Returns false for dead-but-retained panes and missing sessions.
  • Switches 12 tmuxHasSession call sites to sessionProcessAlive where the question is "is CC running?" (crash detection, orphan detection, thread occupancy guards, fork/resume/respawn guards, codex bootstrap, isAlive()).
  • Keeps tmuxHasSession at 3 sites where "does the tmux session exist?" is correct (peek, snapshot — capture-pane works on retained dead panes).
  • Autopsy reports both tmuxExists and processAlive; cleans up retained panes on resume failure.

Evidence

Experimentally verified on a live test session:

  • Without remain-on-exit: CC exits → has-session: FALSE (session destroyed)
  • With remain-on-exit: CC exits → has-session: TRUE, pane_dead=1

Analysis of 485 exit logs: EIO fires in 98.4% of CC exits (477/485). Shell survives the EIO in 98.5% of those (470/477). The 7 pane-death cases are the exact scenario remain-on-exit addresses.

Test plan

  • All 3 entry points compile clean
  • 943 tests pass, 0 failures
  • Restart daemon on this branch, kick off a review, observe crash detection with retained panes
  • Verify peek and snapshot work on dead-but-retained panes
  • Verify resume and respawn commands work when pane is dead-but-retained (should not block with "already running")

🤖 Generated with Claude Code

Comment thread daemon/bridge-server.ts Outdated
encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 2000,
}).trim()
processAlive = pd === '0'
} catch {}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should-fix: This inlines the same execFileSync('tmux', ['display-message', ...]) logic that sessionProcessAlive() in util.ts already encapsulates. Same duplication exists in sessions.ts:421-426. Call sessionProcessAlive(info.tmuxName) instead — if the parsing semantics ever change (e.g. tmux version differences), these copies will drift.

(flagged by both reviewers)

Comment thread daemon/session-health.ts
// by the bridge-server disconnect handler (3s delay + tmux check). Skip sessions in spawn
// grace period (bridge needs time to connect).
if (!crashAlerted.has(info.sessionId) && info.sessionType !== 'thread_guest' && !info.deadAt && (now - info.createdAt > SPAWN_GRACE_MS) && !tmuxHasSession(info.tmuxName) && !transport.has(info.sessionId)) {
if (!crashAlerted.has(info.sessionId) && info.sessionType !== 'thread_guest' && !info.deadAt && (now - info.createdAt > SPAWN_GRACE_MS) && !sessionProcessAlive(info.tmuxName) && !transport.has(info.sessionId)) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should-fix: When crash detection fires here, deadAt is set and persisted, but the retained tmux session (kept alive by remain-on-exit) is never killed. Compare with sessions.ts:432 and session-lifecycle.ts:991-993 which both clean up the corpse with kill-session. Without cleanup here, a crashed session that is never resumed and survives daemon restarts will leave a dead tmux session lingering indefinitely.

if (!info) return null

const tmuxExists = tmuxHasSession(info.tmuxName)
const processAlive = sessionProcessAlive(info.tmuxName)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: classifyResumeFailure receives processAlive as the tmuxAlive parameter. Previously tmuxAlive meant "tmux session exists"; now it means "process inside the pane is alive" — a different semantic. With remain-on-exit, these can diverge (tmux session exists but process is dead). The classifier still produces correct results because both old-false and new-false lead to 'kill', but the parameter name is now misleading. Consider renaming the parameter to processAlive in classifyResumeFailure to match.

Comment thread daemon/sessions.ts
if (processAlive) {
delete info.deadAt
restored++
} else {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: This kill-session call has no timeout — a hung tmux server would block the event loop. The new sessionProcessAlive() correctly sets timeout: 2000; add the same here and at session-lifecycle.ts:992 for consistency.

@dcetlin
dcetlin force-pushed the feat/remain-on-exit branch from 2dac548 to 5b0275e Compare August 16, 2026 21:16
@dcetlin

dcetlin commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author

All 4 review items addressed in 5b0275e:

  1. Inline probe deduplicationbridge-server.ts and sessions.ts now call sessionProcessAlive() instead of inlining the display-message logic.
  2. Corpse cleanup in crash detectionsession-health.ts now kills the retained tmux session when setting deadAt, matching the behavior in sessions.ts (restart) and session-lifecycle.ts (resume).
  3. Parameter renameclassifyResumeFailure parameter renamed tmuxAliveprocessAlive. Test updated.
  4. Timeout on kill-session — Added timeout: 2000 to kill-session calls in sessions.ts and session-lifecycle.ts.

943 tests pass (including the updated resume-health.test.ts).

Comment thread daemon/protocol-runner.ts Outdated
await fireTransition(run, 'timeout', '', 'backstop timed out')
}, ms)
return true
return false

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker: backstop return value changed from true to false with no test or comment. When backstopTimer is the only onEnter behavior (e.g. spike's reporting phase), returning false means !handled fires, calling resetTimeout() — which sets up a second timeout timer alongside the one backstopTimer just created. Both target the same phase and both call fireTransition(run, 'timeout', ...), causing a double-fire race. If this change is intentional, it needs either a comment explaining why or resetTimeout suppression in the !handled path.

(flagged by both reviewers)

Comment thread daemon/protocol-runner.ts Outdated
if (info && await tryRespawnPane(info)) {
run.disconnectTimers.delete(sessionId)
return
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker: When tryRespawnPane fails (returns false), execution falls through to resumeParticipant() which spawns a completely new session. But respawn-pane -k already killed the dead pane and started a new process inside it. If that new process is still booting when resumeParticipant fires, you have two processes racing for the same tmux session name. On failure, the respawned pane needs to be cleaned up before falling through.

Comment thread daemon/session-lifecycle.ts Outdated
if (ok) {
process.stderr.write(`daemon: respawn-pane ${info.tmuxName}: bridge reconnected — recovery successful\n`)
return true
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should-fix: After successful respawn-pane recovery, info.deadAt is never cleared and registry.persist() is never called. If deadAt was set by crash detection before the respawn attempt, the session will appear dead in the dashboard and health polls despite being alive. Neither caller (checkSessionDeath, onRunDisconnect) clears it on a true return either.

Comment thread daemon/protocol-runner.ts Outdated
if (ownerIsActor) {
notifyParticipant(run, run.ownerSessionId, run.protocol.ownerKickoff(run.params))
return
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should-fix: The new return exits notifyKickoff after sending ownerKickoff to the owner-as-active-actor, but skips the notification loop for non-owner participants. Previously, execution continued and all non-active participants received the onKickoff/fallback notification. Now (e.g. in build protocol where owner=builder=active actor), the critic gets no kickoff notification at all.

Comment thread daemon/dashboard.ts Outdated
import { registry, threadRegistry, sessionEmoji } from './sessions.js'
import { transport } from './bridge-transport.js'
import { formatDuration, tmuxHasSession } from './util.js'
import { formatDuration, tmuxHasSession, sessionProcessAlive } from './util.js'

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: sessionProcessAlive is imported but never used in this file — the dashboard still uses tmuxHasSession at the filter. Either switch the dashboard filter to sessionProcessAlive for consistency or remove the unused import.

Comment thread daemon/session-lifecycle.ts Outdated

export async function tryRespawnPane(info: SessionInfo): Promise<boolean> {
if (!info.claudeSessionId) return false
if (sessionProcessAlive(info.tmuxName)) return false

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Hardcoded model fallback 'claude-opus-4-6[1m]' — use spawnModel() (already imported from shared/constants.ts) for late-bound defaults per project conventions.

Comment thread daemon/session-lifecycle.ts Outdated
try {
execSync(`tmux respawn-pane -k -t ${shq(info.tmuxName)} ${shq(cmd)}`, { stdio: 'pipe', timeout: 5000 })
} catch (err) {
process.stderr.write(`daemon: respawn-pane ${info.tmuxName}: failed: ${err instanceof Error ? err.message : String(err)}\n`)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: execSync with string interpolation through shq() — every other tmux call in this file uses execFileSync with an args array, which avoids the shell entirely. respawn-pane takes a shell command as its final arg so the shell may be needed here, but if so add a brief comment explaining why.

(flagged by both reviewers)

@dcetlin

dcetlin commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

All 7 review items addressed in 00c59fc:

  1. Blocker — backstopTimer double timeout: Restored return true so resetTimeout in the !handled path doesn't create a duplicate timer.
  2. Blocker — respawn-pane race on failure: tryRespawnPane now kills the tmux session on failure before falling through to resumeParticipant.
  3. Should-fix — deadAt not cleared: tryRespawnPane clears deadAt and persists on successful recovery.
  4. Should-fix — notifyKickoff early return: Removed the return so non-owner participants still get kickoff notifications.
  5. Nit — unused import: Removed sessionProcessAlive from dashboard.ts.
  6. Nit — hardcoded model: Now uses spawnModel() for late-bound defaults.
  7. Nit — execSync comment: Added comment explaining why execSync is needed (respawn-pane takes a shell command string).

943 tests pass.

Dan Cetlin and others added 5 commits August 16, 2026 17:40
CC's EIO teardown during REPL unmount destroys the tmux pane, making
tmuxHasSession flip to false before the daemon can react. With
remain-on-exit on, the pane is retained after death and #{pane_dead}
distinguishes alive (0) from dead-but-retained (1).

- Add sessionProcessAlive() — checks #{pane_dead} via tmux display-message
- Set remain-on-exit on (window option, -w) at spawn time
- Switch 12 tmuxHasSession sites to sessionProcessAlive where the
  question is "is CC running?" (crash detect, orphan detect, thread
  guards, fork/resume/respawn guards, codex bootstrap, isAlive)
- Keep tmuxHasSession at 3 sites where "does tmux session exist?" is
  correct (peek, snapshot — capture-pane works on retained dead panes)
- Autopsy reports both tmuxExists and processAlive; cleans up retained
  panes on resume failure

Experimentally verified: CC exits → pane retained → has-session true,
pane_dead=1. EIO fires in 98.4% of exits (477/485); shell survives in
98.5% of those (470/477). The 7 pane-death cases are the exact scenario
remain-on-exit addresses.

943 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The initial remain-on-exit audit grepped for tmuxHasSession but missed
three inline execFileSync('tmux', ['has-session'...]) probes that bypass
the helper entirely:

- bridge-server.ts:422 (checkSessionDeath) — the primary death detection
  path. With a retained pane, has-session succeeds and the entire autopsy
  block is skipped. Now checks #{pane_dead} via display-message.

- sessions.ts:423 (daemon restart) — resurrects dead sessions when the
  retained pane makes has-session succeed. Now checks pane_dead, and
  reaps retained corpses with kill-session so names return to the pool.

- session-lifecycle.ts:499 (spawn cleanup) — reap path lived in a catch
  block that never ran with retained panes. Now uses sessionProcessAlive.

Found by adversarial review critic (round 1, §2/§4/§5).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ameter

- Replace inline display-message probes in bridge-server.ts and
  sessions.ts with sessionProcessAlive() calls (review item 1)
- Add kill-session cleanup in session-health.ts crash detection so
  retained corpses don't linger (review item 2 — real bug)
- Rename classifyResumeFailure parameter tmuxAlive → processAlive
  to reflect changed semantics (review item 3)
- Add timeout: 2000 to kill-session calls in sessions.ts and
  session-lifecycle.ts (review item 4)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When CC self-exits and the pane is retained (remain-on-exit), try
tmux respawn-pane to restart CC in the same pane before falling
through to Layer 2 (resumeParticipant, which creates a new session).

tryRespawnPane: checks pane is dead but tmux session exists, then
runs respawn-pane -k with claude --resume. Waits 10s for bridge
reconnection. If the bridge reconnects, recovery is silent — no
new session, no name churn, no spawn announcements. If not, falls
through to the existing resumeParticipant path.

Wired into both paths:
- Protocol critics: protocol-runner.ts, before resumeParticipant
- Non-protocol sessions: bridge-server.ts checkSessionDeath

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- tryRespawnPane: kill pane on failure before falling through to
  resumeParticipant (prevents two processes racing for same name)
- tryRespawnPane: clear deadAt on successful recovery
- tryRespawnPane: use spawnModel() instead of hardcoded model fallback
- tryRespawnPane: add comment explaining execSync usage
- backstopTimer: restore return true (false caused duplicate timeout
  alongside the backstop's own timer)
- notifyKickoff: remove early return so non-owner participants still
  get kickoff notifications when ownerKickoff fires
- dashboard.ts: remove unused sessionProcessAlive import

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@dcetlin
dcetlin force-pushed the feat/remain-on-exit branch from 00c59fc to fdc4111 Compare August 17, 2026 00:41
…ry path

tryRespawnPane inserted a 10-second broken detour on every critic death:
wrong --channels flag meant CC never connected its bridge, waitForBridge
burned the full timeout, then killed the pane and fell through to Layer 2.
Meanwhile the protocol runner's disconnect timer logic was confused by the
delay. The review's own critic dying proved this live.

Also adds kill-session to checkSessionDeath's death branch so retained
corpses (remain-on-exit) are reaped on the primary death path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Comment thread daemon/sessions.ts
delete info.deadAt
restored++
} else {
try { execFileSync('tmux', ['kill-session', '-t', info.tmuxName], { stdio: 'pipe', timeout: 2000 }) } catch {}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should-fix: sessionProcessAlive() returns false both when the pane is dead (remain-on-exit retained it) AND when the tmux session doesn't exist at all. This kill-session fires unconditionally, spraying failed calls for sessions whose tmux is already gone on every daemon restart. Worse, if a tmux session name was reused between daemon shutdown and restart, this kills the wrong session. Guard with tmuxHasSession() first, matching the pattern in session-health.ts:27-29.

(flagged by both reviewers)

Comment thread daemon/codex-bootstrap.ts
const info = registry.get(sessionId)
if (info && !info.deadAt && !tmuxHasSession(info.tmuxName)) {
if (info && !info.deadAt && !sessionProcessAlive(info.tmuxName)) {
info.deadAt = Date.now()

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should-fix: Both death-detection paths in codex-bootstrap.ts (here and line 85) set deadAt but don't kill the now-retained tmux session. With remain-on-exit on, these zombie panes will accumulate until daemon restart or manual cleanup. Every other death-detection site in this PR (bridge-server, session-health, sessions.ts, session-lifecycle resume) performs the cleanup — codex-bootstrap is the odd one out.

hasExitMarker: !!(info.exitFilePath && existsSync(info.exitFilePath)),
hasExitFilePath: !!info.exitFilePath,
})
if (tmuxExists && !processAlive) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should-fix: This inline kill-session fires unconditionally when tmuxExists && !processAlive, regardless of verdict. Then on kill verdict, killSession() also runs — which likely tries its own tmux cleanup on a now-gone session. If killSession does any tmux-dependent operations (reading final pane state, etc.), that data is already unavailable. Consider either: (a) only doing inline kill when verdict is 'orphan', or (b) moving the cleanup into killSession itself.

Comment thread daemon/sessions.ts
import { join } from 'path'
import { execSync, execFileSync } from 'child_process'
import { STATE_DIR } from './config.js'
import { sessionProcessAlive } from './util.js'

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Two separate import statements from './util.js' — merge into one.

// the pane and tmuxHasSession flips to false before the daemon can react.
if (tmuxConfirmedAlive) {
try {
execFileSync('tmux', ['set-option', '-w', '-t', tmuxName, 'remain-on-exit', 'on'], { stdio: 'pipe' })

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: -w scopes remain-on-exit to the window level. If the session ever gets a second window (manual split, plugin), only the original window gets the option. Using -s or omitting the flag (defaults to session when -t targets a session name) would be more robust. Minor because the daemon doesn't create extra windows.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants