Skip to content

feat: shell restart loop + connection-aware disconnect handling - #304

Open
dcetlin wants to merge 1 commit into
mainfrom
feat/cc-self-exit-respawn
Open

feat: shell restart loop + connection-aware disconnect handling#304
dcetlin wants to merge 1 commit into
mainfrom
feat/cc-self-exit-respawn

Conversation

@dcetlin

@dcetlin dcetlin commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

Two changes that address CC session death during protocol runs.

Root cause finding: CC self-exits ([uds-messaging] Shutting down), but the cascading session replacements were caused by the daemon killing live tmux sessions on bridge disconnect — not by CC's process dying. The daemon's 2s disconnect timer fired before CC could reconnect, and resumeParticipant killed the live session to spawn a replacement. Each replacement entered the same cycle.

1. Shell-level restart loop

Non-fork spawns wrap the claude command in a while true loop inside the tmux shell. On any exit, the loop writes an exit marker (with self_exit detection via debug log grep), logs the restart with timestamp/exit code/debug path, sleeps 2s, and restarts CC with --resume. The tmux session stays alive through CC restarts. Debug logs rotate per restart (-r1.log, -r2.log, ...). Only tmux kill-session (SIGHUP) kills the shell.

2. Connection-aware disconnect handling

onRunDisconnect now checks tmuxHasSession before acting. If the tmux session is alive (CC or the restart loop is running), the daemon logs and waits (15s window) instead of killing the session. Only resumes if tmux is actually dead.

3. Keepalive disabled by default

keepaliveEnabled() returns process.env.HYDRA_KEEPALIVE === '1' (was !== '0'). Late-bound per repo convention. Keepalive was identified as an accelerant for bridge disconnects — 120 messages/hour gave the connection 120 chances to drop.

Other

  • _resumeAttempts keyed by role (survives session replacement), not reset on success
  • killSession:269execSyncexecFileSync
  • --disallowedTools/--tools carried in restart resume command
  • Cross-file invariant comment restored in session-health.ts
  • buildExitMarkerScript extracted for shared use

Test plan

  • 909 tests pass, all 3 compiles green
  • Run review, observe critic surviving bridge disconnect via tmux-alive check
  • Verify no cascade on bridge disconnect (daemon waits instead of killing)
  • Verify shell restart loop fires on actual CC self-exit (restart log written)

🤖 Generated with Claude Code

Comment thread daemon/session-lifecycle.ts Outdated

if (!debugLogPath) return false
try {
const fd = openSync(debugLogPath, 'r')

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 (flagged by both reviewers): FD leak — if fstatSync(fd) or readSync() throws after openSync() succeeds, the catch block returns false without calling closeSync(fd), leaking the file descriptor.

Wrap in try/finally:

const fd = openSync(debugLogPath, 'r')
try {
  const stat = fstatSync(fd)
  const readFrom = Math.max(0, stat.size - 8192)
  const buf = Buffer.alloc(Math.min(8192, stat.size))
  readSync(fd, buf, 0, buf.length, readFrom)
  return buf.toString().includes(CC_SHUTDOWN_MARKER)
} finally {
  closeSync(fd)
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 2a2e704 — try/finally wrapping, FD always closed.

Comment thread daemon/session-health.ts Outdated
}
process.stderr.write(`daemon: crash detected: ${info.tmuxName}\n`)
if (tmuxHasSession(info.tmuxName)) {
try { execSync(`tmux kill-session -t ${shq(info.tmuxName)}`, { stdio: 'pipe' }) } 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 (flagged by both reviewers): Three issues on this line:

  1. shq duplication — a second copy is added at line 9, identical to the one in session-lifecycle.ts. Will rot independently.
  2. Shell-string execSync — every other tmux call in the codebase uses execFileSync (array form, no shell). Using the shell form here is inconsistent and requires the shq import.
  3. Missing timeout — no timeout option. If the tmux server is hung, this blocks the health poll indefinitely (5-min setInterval callback never returns). Every other tmux call in this PR uses timeout: 2000.

All three are fixed by switching to:

execFileSync('tmux', ['kill-session', '-t', info.tmuxName], { stdio: 'pipe', timeout: 2000 })

Then drop the execSync import and the shq definition.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 2a2e704 — replaced execSync+shq with execFileSync array form, added 5s timeout.

Comment thread daemon/session-health.ts

// Orphan detection — tmux alive but bridge never connected past grace window.
// See also: daemon/resume-health.ts classifyResumeFailure, which checks
// the same condition at bridge-timeout time. Both paths must preserve.

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: Semantic shift worth a comment — with remain-on-exit on, a self-exited session now has tmuxHasSession=true but sessionProcessAlive=false. This branch requires sessionProcessAlive=true, so dead-pane sessions skip orphan detection entirely. They should be caught by the crash branch on the next poll (once bridge disconnects too), but there's a brief window where neither branch fires if the bridge hasn't disconnected yet. If this is intentional, a comment here would help future readers.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 2a2e704 — updated comment to explain remain-on-exit semantics.

@dcetlin dcetlin changed the title feat: CC self-exit respawn prerequisites (Phase 1) feat: CC self-exit in-place respawn Aug 14, 2026
@dcetlin
dcetlin force-pushed the feat/cc-self-exit-respawn branch 2 times, most recently from 0e6e6ba to ec849b8 Compare August 15, 2026 00:08
Comment thread daemon/protocol-runner.ts Outdated
if (respawned) {
if (run.timeout) { clearTimeout(run.timeout); run.timeout = undefined }
const ok = await waitForBridge(sessionId, 60_000)
respawnsInProgress.delete(currentInfo.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.

Blocker (flagged by both reviewers): respawnsInProgress is leaked when waitForBridge returns false or the run becomes terminal during the 60s wait.

respawnsInProgress.delete() only fires here on the success path (line 380). When the if (ok && !isTerminal(run)) check fails, execution falls through to the resume/grace fallback below without ever removing the session ID from respawnsInProgress. This permanently suppresses crash detection in session-health.ts for that session (the !respawnsInProgress.has(...) guard).

Move the delete above the if (ok ...) check, or add it to the fallthrough path as well:

respawnsInProgress.delete(currentInfo.sessionId)
if (ok && !isTerminal(run)) {
  // success path...
  return
}
// fallthrough to resume/grace

This is already correct in the diff — the delete just needs to be unconditional after waitForBridge returns.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The delete is at line 380, BEFORE the if(ok) check — it runs on both success and failure paths. Not leaked. But I added the other fixes in this push.

Comment thread daemon/session-lifecycle.ts Outdated

const model = info.capabilities?.model ?? spawnModel()
const stderrLog = info.stderrLogPath
const exitFile = info.exitFilePath

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: process.env.SPAWN_CWD is a process-global env var. Sessions spawned with --worktree have a different effective CWD (computed by resolveForkSpawnCwd in the original spawn path). Respawning a worktree session will cd into the wrong directory, breaking its repo context.

Consider persisting the resolved CWD on SessionInfo at spawn time and reading it here instead.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in b755d36 — uses capabilities.cwd with SPAWN_CWD fallback.

Comment thread daemon/session-lifecycle.ts Outdated
// Fresh debug log per respawn — CC appends, so the old log's shutdown
// marker would make every subsequent exit look like a self-exit (S2)
const n = (info.inPlaceRespawnCount ?? 0) + 1
const debugLog = join(SPAWN_LOGS_DIR, `debug-${info.tmuxName}-${info.sessionId}-r${n}.log`)

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: info.debugLogPath is mutated here before the execFileSync call at line 1131. If execFileSync throws, debugLogPath now points to a file that was never created. The next isCCSelfExit call will try to open a nonexistent debug log, fall through to false (safe), but the old debug log with the real shutdown marker is now unreachable — so the retry at line 374 in protocol-runner.ts will also return false, preventing a legitimate respawn on the second attempt.

Defer the mutation until after the exec succeeds (alongside the inPlaceRespawnCount assignment at line 1139).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in b755d36 — debugLogPath mutation deferred until after successful execFileSync.

`_HYDRA_EXIT_CODE=$?`,
`_HYDRA_EXIT_TS=$(date +%s)`,
`_HYDRA_SELF_EXIT=false`,
`if grep -q '\\[uds-messaging\\] Shutting down' ${shq(debugLog)} 2>/dev/null; then _HYDRA_SELF_EXIT=true; fi`,

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: The exit-trap grep runs immediately after CC exits, but the debug log may not be fully flushed to disk. If the OS buffer hasn't synced, grep misses the marker → self_exit=false in the exit file → isCCSelfExit short-circuits at self_exit=false and never falls back to the debug log. The window is small but real.

A sync before the grep, or just removing the grep from the trap and relying solely on the Node-side isCCSelfExit debug log fallback, would close it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in b755d36 — added sync before grep in EXIT trap.

Comment thread daemon/util.ts Outdated
try {
const result = execFileSync('tmux', ['display-message', '-t', name, '-p', '#{pane_dead}'], {
encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 2000,
}).toString().trim()

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: .toString() is redundant when encoding: 'utf8' is set — execFileSync already returns a string.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in b755d36 — removed redundant .toString().

@dcetlin
dcetlin force-pushed the feat/cc-self-exit-respawn branch 8 times, most recently from ecc1df2 to 56fc094 Compare August 15, 2026 02:11
Comment thread daemon/session-lifecycle.ts Outdated
if (!info.claudeSessionId) { process.stderr.write(`daemon: respawn-in-place ${name}: skipped — no claudeSessionId\n`); return false }
if ((info.inPlaceRespawnCount ?? 0) >= MAX_IN_PLACE_RESPAWNS) { process.stderr.write(`daemon: respawn-in-place ${name}: skipped — circuit breaker (${info.inPlaceRespawnCount}/${MAX_IN_PLACE_RESPAWNS})\n`); return false }
if (!isCCSelfExit(info.exitFilePath, info.debugLogPath)) { process.stderr.write(`daemon: respawn-in-place ${name}: skipped — not a CC self-exit\n`); return false }
const ctx = getContextPercent(name)

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: getContextPercent(name) calls tmux capture-pane on a dead pane (remain-on-exit keeps the pane, but CC's status bar is gone). In practice this returns '?', so parseInt('?') >= 90 evaluates to NaN >= 90false, and the guard always passes. The context-at-90 check is dead code in the respawn path — respawn will never be blocked by context usage. Harmless (fails open), but misleading if someone later expects it to work.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 33e5fc8 — removed dead context gate. getContextPercent returns '?' on dead panes, so the guard never fired.

Comment thread daemon/session-lifecycle.ts Outdated
const stderr = err?.stderr?.toString?.()?.trim() ?? ''
const msg = stderr || (err instanceof Error ? err.message : String(err))
process.stderr.write(`daemon: respawn-in-place ${name} FAILED: ${msg}\n`)
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: The exit file path is reused across in-place respawns (same exitFilePath on info), while the debug log is fresh per respawn. If a respawned CC is SIGKILLed (EXIT trap doesn't fire), the stale exit file from the previous self-exit still contains self_exit=true, causing the next isCCSelfExit to misclassify the kill as another self-exit and trigger another respawn. Bounded by the circuit breaker (MAX_IN_PLACE_RESPAWNS = 10), so low-impact. Could be closed by truncating the exit file at the start of respawnInPlace before respawn-pane.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 33e5fc8 — exit file truncated before respawn so a SIGKILL doesn't inherit the previous self_exit=true.

@dcetlin
dcetlin force-pushed the feat/cc-self-exit-respawn branch from 56fc094 to 33e5fc8 Compare August 15, 2026 03:19
Comment thread daemon/protocol-runner.ts Outdated
const model = currentInfo.capabilities?.model ?? ''
const bridgeTimeoutMs = /opus/.test(model) ? 180_000 : 60_000
const ok = await waitForBridge(sessionId, bridgeTimeoutMs)
if (run._disconnectGen.get(sessionId) !== gen) 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 (flagged by both reviewers): respawnsInProgress leaked on stale-gen early return.

When respawned is true and a newer disconnect fires (bumping the gen counter), this line returns before respawnsInProgress.delete() on line 390. The session ID stays in respawnsInProgress permanently, which suppresses crash detection in session-health.ts (the !respawnsInProgress.has(info.sessionId) guard). The session becomes invisible to the health poller.

Same leak applies if a second respawnInPlace at line 381 succeeds but this gen check bails.

Fix: Move respawnsInProgress.delete(currentInfo.sessionId) above the gen check, or add it to every early-return path after respawnInPlace returns true.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 958210f — moved respawnsInProgress.delete before the gen check so it always runs.

Comment thread daemon/protocol-runner.ts Outdated
let doSpawnSession = _doSpawnSession
let waitForBridge = _waitForBridge
let killSession = _killSession
let respawnInPlace = _respawnInPlace

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: respawnInPlace is wired as a late-bound let (matching doSpawnSession/waitForBridge/killSession), but __test.setLifecycle at line ~977 was not updated to include it. Tests exercising the new onRunDisconnect respawn path will call the real respawnInPlace (which shells out to tmux respawn-pane). Add it to the setLifecycle overrides + resetLifecycle.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 958210f — added respawnInPlace to setLifecycle/resetLifecycle.

Comment thread daemon/protocol-runner.ts Outdated

let respawned = await respawnInPlace(currentInfo)
if (!respawned && !isCCSelfExit(currentInfo.exitFilePath, currentInfo.debugLogPath)) {
await new Promise(r => setTimeout(r, 1000))

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: The 1-second fixed sleep before the retry respawnInPlace assumes the EXIT trap's grep on the debug log completes within 1s. Production debug logs can be much larger than the 16KB tested — grep on a 100MB+ log could exceed this. Consider polling the exit file for self_exit with a short backoff, or document the timing assumption.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Acknowledged. The EXIT trap writes the exit marker (which isCCSelfExit reads first), so the grep timing only affects the exit marker's self_exit field. 1s is generous for the grep; debug logs >100MB would be exceptional.

@dcetlin
dcetlin force-pushed the feat/cc-self-exit-respawn branch from 33e5fc8 to 958210f Compare August 15, 2026 15:03
Comment thread daemon/bridge-server.ts Outdated
const processAlive = sessionProcessAlive(info.tmuxName)

if (!tmuxAlive) {
if (!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: checkSessionDeath now uses sessionProcessAlive to detect death (correct), but doesn't kill the lingering tmux session afterward. With remain-on-exit on, dead-pane sessions persist as zombies until the 5-min health poll cleans them up.

session-health.ts (lines 39-41 in the diff) correctly adds tmux kill-session after crash detection — this path was missed. Add the same cleanup here:

if (tmuxHasSession(info.tmuxName)) {
  try { execFileSync('tmux', ['kill-session', '-t', info.tmuxName], { stdio: 'pipe', timeout: 5000 }) } catch {}
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 7e918a0 — added tmux kill-session cleanup in checkSessionDeath after crash detection.

Comment thread daemon/protocol-runner.ts Outdated
if (isTerminal(run)) return
if (run._disconnectGen.get(sessionId) !== gen) return
resetTimeout(run)
if (claudeSessionId && !isAlive(currentInfo) && (run._resumeAttempts ?? 0) < 5) {

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 5 — this was previously MAX_RESUME_ATTEMPTS from auto-resume.ts (which this PR removes the import for). Consider extracting to a named constant to preserve the intent.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 7e918a0 — extracted MAX_RESUME_ATTEMPTS = 5 constant.

Comment thread daemon/session-lifecycle.ts Outdated
process.stderr.write(`daemon: spawn ${tmuxName}: fork+worktree — using PM CWD ${spawnCwd} for fork (worktree ${effectiveCwd} in prompt)\n`)
}
const inner = [
`tmux set-option remain-on-exit on`,

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 (flagged by both reviewers): tmux set-option remain-on-exit on runs inside the pane's && chain. If it fails, the entire spawn silently aborts (CC never starts). Risk is low since tmux is definitely available, but consider setting this as a session option on creation (separate execFileSync after new-session) for robustness.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Intentional — the && chain was specifically to fix the TOCTOU race from earlier rounds. An external set-option after new-session has a window where CC can exit before remain-on-exit is set. Inside the pane, it runs before claude starts. tmux is guaranteed available (we just created the session with it).

@dcetlin
dcetlin force-pushed the feat/cc-self-exit-respawn branch 2 times, most recently from 7e918a0 to afa8369 Compare August 15, 2026 17:03
@dcetlin dcetlin changed the title feat: CC self-exit in-place respawn feat: in-place respawn for CC self-exit during protocol runs Aug 15, 2026
@dcetlin
dcetlin force-pushed the feat/cc-self-exit-respawn branch 2 times, most recently from cb21fed to 6d371be Compare August 15, 2026 20:59
Comment thread daemon/factory.ts Outdated
const builderSessionToTicket = new Map<string, string>() // builderSessionId → ticket
const builderThreadToTicket = new Map<string, string>() // builderThreadId → ticket

const pmReviewFailures = new Map<string, number>() // pmThreadId → consecutive review failure count

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 (flagged by both reviewers): pmReviewFailures is never cleared on build abandon or PM death.

cleanupState (line 1069) doesn't call pmReviewFailures.delete(state.pmThreadId), so the counter persists across builds in the same PM thread. If a PM abandons a build after review failures, then starts a new build, the elevated counter carries over and could trigger the "Factory degraded" warning prematurely.

Fix: add pmReviewFailures.delete(state.pmThreadId) in cleanupState.

@dcetlin
dcetlin force-pushed the feat/cc-self-exit-respawn branch 3 times, most recently from 1be258e to 1941e37 Compare August 15, 2026 21:53
Comment thread daemon/protocol-runner.ts
const claudeSessionId = info?.claudeSessionId
const existingTimer = run.disconnectTimers.get(sessionId)
if (existingTimer) clearTimeout(existingTimer)
const gen = (run._disconnectGen.get(sessionId) ?? 0) + 1

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 (flagged by ts-reviewer): _disconnectGen is bumped here on each new disconnect, and the gen check inside the callback guards against stale disconnect handlers. But onRunReconnect (not modified by this PR) only clearTimeouts the timer — it does not bump _disconnectGen. If the timer already fired before the reconnect, the in-flight async callback passes the gen check at line 373 because gen was never invalidated. The transport.has() check at line 372 catches most reconnects, but there's a TOCTOU window: if the session reconnects during the await respawnInPlace() or await waitForBridge() calls, subsequent transport.has checks haven't fired yet and respawnInPlace could stomp a live session.

Fix: bump _disconnectGen in onRunReconnect so in-flight callbacks bail out.

function buildSelfRestartLoop(claudeCmd: string, resumeCmd: string | undefined, exitFile: string, restartLog: string, debugLogBase: string, sessionId: string, tmuxName: string, stderrLog?: string): string {
const stderrPart = stderrLog ? ` 2>>${shq(stderrLog)}` : ''
const restartClaudeCmd = resumeCmd ?? claudeCmd
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 (flagged by both reviewers): while true with no iteration limit. respawnInPlace increments inPlaceRespawnCount but never checks it against a cap. A CC that consistently self-exits after a few seconds would loop forever at the tmux level, burning API credits and producing unbounded logs.

Fix: add a max iteration guard in the shell loop (e.g., if [ $_HYDRA_RESTART_N -ge 5 ]; then break; fi), or check inPlaceRespawnCount against MAX_RESUME_ATTEMPTS in respawnInPlace.

Comment thread daemon/protocol-runner.ts Outdated
const respawned = await respawnInPlace(info)
if (respawned) {
try {
const model = info.capabilities?.model ?? ''

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 (flagged by sp-reviewer): When bridge timeout occurs after resurrection, this logs the failure but falls through to the transport.sendOrQueue call below, which delivers a turn notification to a dead session. No fallback (cancel run, start grace timer, or attempt full resume) is taken. The resurrected tmux session sits orphaned.

Consider cancelling the run or falling back to resumeParticipant when resurrection fails.

].join('; ')
}

function buildSelfRestartLoop(claudeCmd: string, resumeCmd: string | undefined, exitFile: string, restartLog: string, debugLogBase: string, sessionId: string, tmuxName: string, stderrLog?: string): string {

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 (flagged by both reviewers): For fresh spawns where resumeCmd is undefined (no assignedClaudeSessionId, not a resume), restartClaudeCmd falls back to claudeCmd — the original spawn command. After CC self-exits, the shell loop restarts with the same command, starting a brand-new conversation instead of resuming.

The shell loop races the daemon's disconnect handler (~2s). If the loop restarts CC first, it boots fresh; the daemon sees the bridge connect and the disconnect callback returns early. The daemon's respawnInPlace correctly uses info.claudeSessionId (discovered after first boot), but the shell loop can't — the session ID wasn't known at bake time.

Consider writing the discovered claudeSessionId to a file that the shell loop reads, or disabling the shell-level loop for sessions without a known resume target.

Comment thread daemon/factory.ts Outdated
const builderSessionToTicket = new Map<string, string>() // builderSessionId → ticket
const builderThreadToTicket = new Map<string, string>() // builderThreadId → ticket

const pmReviewFailures = new Map<string, number>() // pmThreadId → consecutive review failure count

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 (flagged by both reviewers): pmReviewFailures is cleared on successful review but never on factory_abandon. Since it's keyed by pmThreadId and PM threads are long-lived, a stale count from an abandoned build could trigger the degradation warning on a future unrelated build in the same PM thread. Minor memory leak too.

Consider clearing the entry in the abandon path.

@dcetlin
dcetlin force-pushed the feat/cc-self-exit-respawn branch 2 times, most recently from ee81380 to a3d411b Compare August 16, 2026 02:58
@dcetlin
dcetlin force-pushed the feat/cc-self-exit-respawn branch 2 times, most recently from 24d276a to e287211 Compare August 16, 2026 03:06
`_HYDRA_KILLED=false`,
`trap '_HYDRA_KILLED=true' HUP INT TERM`,
`_HYDRA_RESTART_N=0`,
`while 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.

Blocker (flagged by both reviewers): Shell restart loop has no iteration cap — unbounded restarts possible.

The while true loop only breaks on _HYDRA_KILLED=true (signal receipt). If CC keeps self-exiting (e.g., a persistent error), the loop restarts indefinitely with only a 2-second sleep. The daemon-side MAX_RESUME_ATTEMPTS in protocol-runner.ts only governs the daemon's resumeParticipant path (which spawns a new tmux session) — it does not constrain this in-place shell loop. These are two independent restart mechanisms with no shared budget.

Add a MAX_RESTART guard in the shell loop itself (e.g., break after N iterations) so a crash loop doesn't burn cycles forever.

Comment thread daemon/protocol-runner.ts
// Shell restart loop handles CC self-exits inside the tmux session.
// If bridge reconnects within this 2s window, the gen check above catches it.
// If it doesn't, fall through to resumeParticipant (new session) or grace timer.
resetTimeout(run)

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: resetTimeout(run) called unconditionally before checking whether resume will succeed.

If claudeSessionId is falsy or roleAttempts >= MAX_RESUME_ATTEMPTS, execution falls through to startGraceTimer — but the phase timeout has already been reset. A participant that disconnects repeatedly without a valid claudeSessionId will keep resetting the phase timeout indefinitely, preventing the phase from ever timing out.

Move resetTimeout(run) inside the if branch that actually performs the resume.

try {
// Only kill if the tmux session isn't owned by a new session (name recycling)
const currentOwner = [...registry.values()].find(s => s.tmuxName === tmuxName)
const currentOwner = [...registry.values()].find(s => s.tmuxName === tmuxName && s.createdAt >= killedAt)

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: Deferred-kill guard can kill the restart loop's tmux session.

The new check s.createdAt >= killedAt only skips the kill if a newer registry entry owns the name. But the restart loop reuses the same tmux session name without creating a new registry entry — that's the whole point. So 3 seconds after killSession, the deferred kill finds no currentOwner with createdAt >= killedAt and kills the tmux session that now hosts the restarted CC process.

The guard needs to account for in-place restart loop sessions (e.g., check sessionProcessAlive or a sentinel).

Comment thread daemon/protocol-runner.ts
const KEEPALIVE_INTERVAL_MS = 30_000
const KEEPALIVE_ENABLED = process.env.HYDRA_KEEPALIVE !== '0'
const MAX_RESUME_ATTEMPTS = 5
function keepaliveEnabled(): boolean { return process.env.HYDRA_KEEPALIVE === '1' }

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: Keepalive default flipped from opt-out to opt-in without migration.

Changed from HYDRA_KEEPALIVE !== '0' (default ON) to HYDRA_KEEPALIVE === '1' (default OFF). This silently disables keepalive for all existing deployments that don't set this env var. The test update (beforeAll(() => { process.env.HYDRA_KEEPALIVE = '1' })) confirms the behavioral change. If this is intentional, document it; if not, use !== '0' to preserve the default.

Comment thread daemon/protocol-runner.ts
resetTimeout(run)
const roleAttempts = run._resumeAttempts.get(role) ?? 0
if (claudeSessionId && !isAlive(currentInfo) && roleAttempts < MAX_RESUME_ATTEMPTS) {
run._resumeAttempts.set(role, roleAttempts + 1)

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 (flagged by both reviewers): _resumeAttempts per-role counter never resets on successful resume.

If a role legitimately disconnects and resumes 3 times across different phases, it has consumed 3 of 5 attempts. A later real failure gets only 2 retries. The old flat counter had the same issue, but per-role keying makes it more visible. Consider resetting the role's counter on successful bridge reconnect (in onRunReconnect).

Comment thread daemon/factory.ts Outdated
const builderSessionToTicket = new Map<string, string>() // builderSessionId → ticket
const builderThreadToTicket = new Map<string, string>() // builderThreadId → ticket

const pmReviewFailures = new Map<string, number>() // pmThreadId → consecutive review failure count

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: pmReviewFailures is only cleared on successful review (onFactoryReviewComplete). If a build is abandoned (factory_abandon), the entry persists as a minor memory leak. Consider clearing in the abandon/cleanup path too.

@dcetlin
dcetlin force-pushed the feat/cc-self-exit-respawn branch 2 times, most recently from c33a087 to 1e62afb Compare August 16, 2026 03:42
@dcetlin dcetlin changed the title feat: in-place respawn for CC self-exit during protocol runs feat: shell restart loop + connection-aware disconnect handling Aug 16, 2026
@dcetlin
dcetlin force-pushed the feat/cc-self-exit-respawn branch 5 times, most recently from 2ba2c0a to 3f82f49 Compare August 16, 2026 06:14
`_HYDRA_RESTART_N=0`,
`while true`,
`do`,
`_HYDRA_DEBUG_LOG="${debugLogBase}$( [ $_HYDRA_RESTART_N -gt 0 ] && echo "-r$_HYDRA_RESTART_N" || echo "" ).log"`,

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 (flagged by both reviewers): The while true shell loop restarts CC indefinitely with only a 2s sleep. The daemon's MAX_RESUME_ATTEMPTS budget only applies to resumeParticipant (pane-dead path), but with remain-on-exit on the pane stays alive — so the daemon never triggers its budget, and the shell loop spins unbounded.

If CC enters a crash loop (bad config, corrupted state), this burns resources forever. Add a max iteration guard:

if [ $_HYDRA_RESTART_N -ge 10 ]; then break; fi

Comment thread daemon/protocol-runner.ts
const KEEPALIVE_ENABLED = process.env.HYDRA_KEEPALIVE !== '0'
const MAX_RESUME_ATTEMPTS = 5
const DISCONNECT_WAIT_MS = 15_000
function keepaliveEnabled(): boolean { return process.env.HYDRA_KEEPALIVE === '1' }

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: Default silently flipped from opt-out (!== '0') to opt-in (=== '1'). Any production deployment relying on keepalive being on by default will silently lose it. If intentional, call it out in release notes; if not, revert to !== '0'.





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: Four consecutive blank lines — one suffices.

Comment thread daemon/protocol-runner.ts
@@ -1,8 +1,8 @@
import { execFileSync } from 'child_process'

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: execFileSync imported into protocol-runner for a single tmux kill-session call in resumeParticipant. Shelling out to tmux belongs in session-lifecycle.ts (which owns all tmux interactions). Consider extracting a lightweight kill helper there and calling it through the existing late-bound seam.

Two changes that address CC session death during protocol runs:

1. Shell-level restart loop: non-fork spawns wrap the claude command
   in a while-true loop. On any exit, the loop writes an exit marker,
   logs the restart, sleeps 2s, and restarts CC with --resume. The
   tmux session stays alive through CC restarts. Debug logs rotate
   per restart. Only tmux kill-session (SIGHUP) kills the shell.

2. Connection-aware disconnect handling: onRunDisconnect now checks
   tmuxHasSession before killing a session. If the tmux session is
   alive (CC or the restart loop is running), the daemon waits
   (15s window) instead of immediately spawning a replacement.
   This prevents the daemon from killing live sessions on transient
   bridge disconnects — the observed cause of every cascade in this
   workstream.

Also:
- Keepalive disabled by default (HYDRA_KEEPALIVE must be '1' to
  enable), late-bound per repo convention. Keepalive accelerated
  bridge disconnects by sending 120 messages/hour.
- _resumeAttempts keyed by role (survives session replacement)
- killSession:269 execSync → execFileSync
- --disallowedTools/--tools carried in restart resume command
- Cross-file invariant comment restored in session-health.ts
- buildExitMarkerScript extracted for shared use

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@dcetlin
dcetlin force-pushed the feat/cc-self-exit-respawn branch from 3f82f49 to 9d733b7 Compare August 16, 2026 06:30
const restartClaudeCmd = resumeCmd ?? claudeCmd
return [
`_HYDRA_RESTART_N=0`,
`while 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 (flagged by both reviewers): Shell restart loop has no upper bound on restarts. _HYDRA_RESTART_N is tracked and logged but never checked against a max. If CC crash-loops (bad config, corrupted state), the shell restarts infinitely every 2s. The daemon-side MAX_RESUME_ATTEMPTS doesn't apply here because the bridge reconnects before the 15s disconnect timer fires.

Add a max restart guard:

if [ $_HYDRA_RESTART_N -ge 10 ]; then echo "shell-loop: ${tmuxName} restart budget exhausted" >&2; break; fi

Comment thread daemon/protocol-runner.ts
recordSessionDeath(info, `${role} exited (auto-resuming)`, getProtocolContext(deadSessionId))
// Kill old tmux session before spawning replacement
try { execFileSync('tmux', ['kill-session', '-t', info.tmuxName], { stdio: 'pipe', timeout: 5000 }) } 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 (flagged by both reviewers): execFileSync imported at line 1 and used here — layer violation. protocol-runner.ts is a coordination layer; shelling out to tmux belongs in session-lifecycle.ts (where killSession already lives). Add a targeted helper like killTmuxSession(name) in session-lifecycle and import it here.

Comment thread daemon/protocol-runner.ts
joinThread: run.threadId,
resumeFrom: claudeSessionId,
model: run.params.model as string | undefined,
...(info?.capabilities?.disallowedTools?.length ? { disallowedTools: info.capabilities.disallowedTools } : {}),

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: info.capabilities.disallowedTools does not exist on SessionCapabilities. The type has tools: string[] but no disallowedTools field. This optional chain silently produces {}, so disallowedTools restrictions are dropped when resumeParticipant fires (e.g., a factory PM session resuming after crash would regain write tools it was denied).

Either add disallowedTools?: string[] to SessionCapabilities and persist it at spawn time, or read from the original SpawnOpts.

}



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 blank lines added here, creating four consecutive blank lines total (813-816). Remove the extras.

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