[BUG] Remote Control: ending a session from the app exits the child 1, so the bridge reports a crash and permanently leaks the worktree
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet (nearest neighbours are #79529, #77372, #80311 and #61832 — all concern sessions dying unexpectedly or 404ing server-side; this is a deliberate session end being misclassified as a crash)
- [x] This is a single bug report (a related-but-separate 403 log-severity defect is noted at the bottom rather than mixed in — happy to file it separately)
- [x] I am using the latest version of Claude Code
What's Wrong?
When a Remote Control session is ended from the server side — deleted or archived from the app — the child claude -p process always exits with code 1, even though its own shutdown completed cleanly. The bridge maps any nonzero exit to failed, so it prints session crashed and skips worktree cleanup. Under --spawn=worktree the orphaned worktree is then never reclaimed on any later run.
The child handles end_session correctly — it breaks its control loop, aborts the turn, and shuts down every MCP server, all finishing at .186. But CCRClient's polling loops keep issuing requests against a session record the server has already removed, and consecutiveNotFound (shared across both loops) trips at 3:
this.onEpochMismatch = r?.onEpochMismatch ?? (() => { process.exit(1) })
That default fires at .921, ~0.9s after the graceful teardown had already finished, so the hard exit wins the race. The CCR-v2 handler that closes with a typed code instead of exiting is only wired up when CLAUDE_CODE_USE_CCR_V2=1, which the self-hosted runner sets and the local bridge does not.
The bridge then classifies purely on exit code:
if (signal === "SIGTERM" || signal === "SIGINT") → "interrupted"
else if (code === 0) → "completed"
else → "failed"
and on failed takes the crash branch:
let crashed = status === "failed" && !aborted && !F
if (crashed) { logStatus(`kept worktree ${w.worktreePath} · session crashed`) }
else W(cleanupWorktree(w, ui))
Only the transport closed sub-case is queued for deferred removal, so every other crash keeps the worktree forever. The crash branch also skips the dirty / commits-ahead check that normally justifies keeping one, so clean worktrees are kept too — one per ended session.
archived is not a workaround. The only special-casing of it requires a resumed worker:
function shouldIgnoreStaleEndSession(reason, epoch) {
return parseInt(epoch ?? "1", 10) > 1 && reason === "archived"
}
On a fresh session (CLAUDE_CODE_WORKER_EPOCH unset → 1) archived takes the identical branch to deleted. Confirmed empirically — archiving reproduces this exactly.
What Should Happen?
The child should exit 0. An end_session was received and honored; the subsequent 404s are the expected consequence of that, not a failure. The bridge should then treat the session as completed and run its normal worktree cleanup (which keeps the worktree only when it's dirty or ahead).
Suggested fix: the guard immediately above the 404 counter already short-circuits everything —
if (this.closed) return { ok: false, status: c.status, reason: `http_${c.status}` }
— so close()ing the CCRClient as part of end_session teardown would fix it at the source. Alternatively, suppress the session_not_found exit (or make it exit 0) once an end_session has been seen.
Independently worth hardening: the bridge's crash branch could still reclaim provably-clean worktrees, so a false crash classification doesn't leak disk.
Error Messages/Logs
Child process debug log:
13:14:12.034 [DEBUG] [print.ts] end_session received, reason=deleted
13:14:12.035 [DEBUG] MCP server "mongodb-prod": Sending SIGINT to MCP server process
13:14:12.035 [DEBUG] MCP server "playwright": Sending SIGINT to MCP server process
13:14:12.135 [DEBUG] MCP server "mongodb-prod": SIGINT failed, sending SIGTERM to MCP server process
13:14:12.164 [WARN] CCRClient: delivery batch returned 404
13:14:12.170 [WARN] CCRClient: client events returned 404
13:14:12.186 [DEBUG] MCP server "playwright": MCP server process exited cleanly
13:14:12.921 [ERROR] CCRClient: 3 consecutive 404s — session gone, exiting
Note every MCP server had already exited cleanly at .186, 735ms before the hard exit.
Bridge (parent) log:
13:14:12.033 [DEBUG] [bridge:ws] sessionId=[REDACTED] <<< {"request":{"reason":"deleted","subtype":"end_session"},"request_id":"req_…","type":"control_request"}
13:14:12.034 [DEBUG] [bridge:ws] sessionId=[REDACTED] <<< {"type":"control_response","response":{"subtype":"success","request_id":"req_…"}}
13:14:12.941 [DEBUG] [bridge:session] sessionId=[REDACTED] failed exit_code=1 pid=3296294
13:14:12.942 [DEBUG] [bridge:session] sessionId=[REDACTED] workId=cse_<REDACTED> exited status=failed duration=35s
13:14:12.947 [ERROR] Bridge session failed: Process exited with error
13:14:12.947 [DEBUG] [bridge:session] Session failed, returning to idle (multi-session mode)
Console output:
[13:14:12] Session failed: Process exited with error cse_<REDACTED>
[13:14:12] kept worktree <repo>/.claude/worktrees/bridge-cse_<REDACTED> · session crashed
Steps to Reproduce
- On a machine with a git repo, run
claude remote-control --spawn=worktree - Start a session from the app and let it run
- Delete or archive that session from the app
- Observe
Session failed: Process exited with errorandkept worktree … · session crashed, despite the child having shut down cleanly - Confirm the worktree is still on disk, and that no later
claude remote-controlrun ever reclaims it
Deterministic — reproduces on every server-side session end, with either reason.
Claude Model
Not sure / Multiple models
Is this a regression?
I don't know
Claude Code Version
2.1.235 (Claude Code) — BUILD_TIME 2026-08-18T01:27:41Z, GIT_SHA ba01fa45e3d1d888c1c23caed775ddd06192448b.
Caveat: the code excerpts above were verified against this build, and the affected bridge emitted these exact log strings, but the remote box's build was not separately recorded — treat the version as approximate.
Platform
Anthropic API
Operating System
Other Linux
Terminal/Shell
Other
Additional Information
Current workaround: end the session by Ctrl-C'ing the bridge rather than ending it in the app. The child then dies on SIGTERM → interrupted, the shutdown path runs worktree cleanup unconditionally, and the session is archived server-side anyway. It takes down the whole bridge, so it doesn't help with --capacity > 1.
Related but separate defect (not filed as part of this report — say the word and I'll open it separately): after the nonzero exit, the bridge fires stopWork, which 403s with a user-facing error. The suppression predicate matches on server message substrings:
function isExpected403(e) {
if (e.status !== 403) return false
return e.message.includes("external_poll_sessions") || e.message.includes("environments:manage")
}
The server response here names no scope (Missing permissions. Please check with one of your organization admins…), so it misses both and surfaces via logError. Two further notes: the shutdown path logs the same failure at logVerbose, so the two paths disagree on severity for one event; and the adjacent env-gone check only accepts 404 || 410, so if the server answers 403 for work under a deleted session it can never be classified as "environment gone". Keying on HTTP status plus a structured error type rather than message substrings would fix it.
Session/work IDs are redacted here; happy to supply them privately for triage.
<sub>Investigated and drafted with Claude Code; the log excerpts are from my own session and the quoted code was read out of the installed 2.1.235 bundle.</sub>
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗