[BUG] Remote Control: ending a session from the app exits the child 1, so the bridge reports a crash and permanently leaks the worktree

Status Open
Reported on v2.1.235
Maintainer reply None cached
Activity 3 comments · opened Aug 19, 2026

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

  1. On a machine with a git repo, run claude remote-control --spawn=worktree
  2. Start a session from the app and let it run
  3. Delete or archive that session from the app
  4. Observe Session failed: Process exited with error and kept worktree … · session crashed, despite the child having shut down cleanly
  5. Confirm the worktree is still on disk, and that no later claude remote-control run 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>

View original on GitHub ↗

3 Comments

qtec-technology · 1 month ago

Could not reproduce the archive path on 2.1.251 / Ubuntu 26.04 (WSL2) — but a different keep path did occur, with a stated reason. Posting both because they look like separate mechanisms.

1. Archiving from the iOS app: clean (n = 1)

claude remote-control --spawn worktree --capacity 1, session spawned from the app with + New session, then Archive from the session menu. Server output:

[13:58:08] Session completed (2m 57s) cse_01Hjgj…
    Capacity: 1/1 -> 0/1

State returned to baseline on all three counters watched separately: git worktree list 9 → 10 → 9, git branch --list 'worktree-*' 0 → 1 → 0, directories in .claude/worktrees/ 0 → 1 → 0.

The log line matters more than the counts: this issue's failure mode prints session crashed + kept worktree <path>, i.e. the child exited nonzero. Ours printed Session completed ⇒ exit 0 ⇒ the normal cleanup branch.

⚠️ n = 1, and "cannot reproduce" is not "fixed." The changelog carries no entry that would explain a fix, so this may just be a different code path rather than a repaired one.

2. A keep that looks deliberate, not a crash

Later the same day, the server was terminated with SIGTERM (a timeout wrapper expiring) while a spawned session held uncommitted changes. Server output:

kept worktree /…/.claude/worktrees/bridge-cse_019Ar… · uncommitted changes

The worktree and its branch survived; the git worktree lock was released. This reads as intended data protection, not the defect in this issue — the reason is stated and the work is preserved. Worth distinguishing in triage: a keep with · uncommitted changes is a different event from a keep after session crashed.

Consequence downstream, though, regardless of intent: the retained worktree still counts against any per-repo worktree budget, and it is invisible to tooling that scans sibling directories rather than .claude/worktrees/.

3. iOS has no Delete

The session menu on iOS offers Copy branch / Background tasks / Share / Rename / Archive only. So the deleted half of this report is not reachable from the iOS app at all, and remains untested here.

4. Orphan session cards after the server is killed

Possibly unrelated, possibly useful: after killing two servers in succession (each restart issues a new environment id), the device sheet in the app still listed "2 of 2 active sessions" for that device, with both cards showing a directory, while the live server on the same machine reported Ready · Capacity: 0/2 and had no child processes. A + New session tap in that state produced a session that sat on "Allocating sandbox" indefinitely — nothing on the host side ever picked it up.

⇒ Restarting claude remote-control while the mobile app is open appears to leave the app targeting the previous environment, with no visible error. Force-quitting the app was the suggested recovery; whether it clears is not confirmed here.

Also observed while testing (not a defect, but surprising)

The app's Rename renames the git branch, not just the session title:

Branch: renamed refs/heads/worktree-bridge-cse_019Ar… -> refs/heads/feature/…

No server-side log line accompanied it, and the server header kept showing the old auto-title. Git was the only local surface where the rename was visible.

Environment: Claude Code 2.1.251, Ubuntu 26.04 under WSL2, iOS app, --spawn worktree.

fracampit · 1 month ago

Corroborating this on a different platform and with a different error variant — plus one correction to the CCR-v2 note above.

Environment: Claude Code 2.1.261, Windows 11, claude rc with the default spawnMode=same-dir (so no worktree involved — this confirms the exit-1 defect is separable from the worktree leak, which is downstream of it).

The variant here is a 409, not a 404

Same shape as your report — end_session handled correctly, every MCP server shut down cleanly, then CCRClient kills the process — but the trip is a single 409 session_not_active rather than the 404 counter reaching 3, and it lands ~130ms after end_session rather than ~0.9s:

13:31:53.015 [DEBUG] [print.ts] end_session received, reason=archived
13:31:53.021 [DEBUG] MCP server "chrome-devtools": Terminating MCP server process tree
13:31:53.052 [DEBUG] MCP server "stitch": HTTP connection closed after 268s (cleanly)
13:31:53.063 [DEBUG] MCP server "Gmail": CLAUDEAI-PROXY connection closed after 268s (cleanly)
13:31:53.117 [DEBUG] MCP server "playwright": UNKNOWN connection closed after 266s (cleanly)
13:31:53.120 [DEBUG] MCP server "chrome-devtools": UNKNOWN connection closed after 266s (cleanly)
13:31:53.171 [ERROR] CCRClient: Epoch mismatch (409, reason=session_not_active), shutting down

Bridge side:

13:31:53.013 [bridge:ws] <<< {"request":{"reason":"archived","subtype":"end_session"},"type":"control_request"}
13:31:53.228 [bridge:session] failed exit_code=1 pid=...
13:31:53.229 [bridge:session] workId=cse_... exited status=failed duration=4m 31s
13:31:53.239 [ERROR] Bridge session failed: Process exited with error

No Sending SIGTERM anywhere, so the bridge never terminates the child — it exits under its own power, as you describe. Worth noting the killing [ERROR] goes to the log sink and never reaches stderr, which is why the bridge falls back to its lastStderr.length > 0 ? ... : "Process exited with error" string and the terminal line carries no diagnosis at all.

Possibly two distinct paths into the same onEpochMismatch default (404-counter vs single 409), which may matter if the fix targets only one.

Correction: CCR v2 is active in the local bridge here

Re: "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."

On 2.1.261 the local bridge logs, at spawn time:

[bridge:session] CCR v2: registered worker sessionId=... epoch=1 attempt=1

So v2 is in play here and the child still exits 1. Whatever gates the typed-close path, being on CCR v2 isn't sufficient to avoid the hard exit — worth checking before a fix is scoped to that flag.

It isn't always exit 1 — it's a race, and the loser is the MCP teardown

Your report says the child always exits 1. Over 7 consecutive archives on one host I got 6 failures and 1 clean exit 0. The 409's arrival is near-constant (126–156ms, mean ~137ms); what varies is whether the child finishes its entire teardown first. Five MCP servers configured (chrome-devtools, playwright, an HTTP one, a proxy one, and one local stdio server):

| # | MCP servers confirmed closed before the 409 | 409 at | outcome |
|---|---|---|---|
| 1 | 4/5 | 156ms | failed (1) |
| 2 | 5/5 | — | completed (0) |
| 3 | 4/5 | 138ms | failed (1) |
| 4 | 4/5 | 134ms | failed (1) |
| 5 | 4/5 | 126ms | failed (1) |
| 6 | 4/5 | 137ms | failed (1) |
| 7 | 3/5 | 129ms | failed (1) |

The one success is the only run where all five had confirmed closed (by 126ms); it then reached [uds-messaging] Shutting down, the SSE stream abort and LSP server manager shut down successfully — three teardown steps the failing runs never get to — and exited 0 at ~137ms.

Run 7 is the interesting one: it closed the slowest server from runs 1–6 in 98ms, and still failed because chrome-devtools and playwright hadn't confirmed. So the variable is the whole set completing, not any single server.

Testable consequence: configurations with few or no MCP servers should win this race routinely and rarely see the bug, while heavy MCP setups should see it almost every time. That would explain patchy reporting. I haven't tested a lean config, so that's a prediction rather than a measurement.

This also reinforces your suggested fix: close()ing the CCRClient as part of end_session teardown removes the race entirely, rather than making the child merely usually fast enough.

Related

#83940 and #89183 report the same 409 session_not_active / epoch-mismatch shutdown, though from different triggers (epoch supersession on re-attach, and archived web sessions failing to reconnect). If they share the onEpochMismatch default with this one, a single fix may cover all three.

luckury · 16 hours ago

Confirming the "related but separate" 403 at the bottom of this report, on Claude Code 2.1.236, Linux, Team plan with Remote Control enabled by the org — so there is nothing for an admin to fix, which is what makes the wording misleading.

Two different paths produce it identically on a claude remote-control server (--spawn same-dir, --no-create-session-in-dir):

  1. Archiving the conversation from the app — exactly the case this issue describes.
  2. SIGTERM to the session child from outside. We run a small sweep on a shared multi-user host that ends sessions nobody has touched for two hours, because a session is a ~420 MB process that nothing else reclaims.

Both print the same pair, within a second:

[14:56:50] Session failed: Process exited with error cse_…
[14:56:51] Error: Failed to stop work cse_…: StopWork: Access denied (403): Missing permissions. Please check with one of your organization admins if you think this is in error.. Check your organization permissions.

That the deliberate end is booked as Session failed: Process exited with error matches the main report. For the 403, the suppression predicate keying on message substrings is what surfaces it: this server message names no scope, so neither substring matches. The doubled period before "Check your organization permissions" also suggests two messages being concatenated, which may be why no single substring fits.

Nothing breaks for us — the process is gone, the memory comes back, and claude remote-control --session-id <cse_…> brings the conversation back with its history, tested 2.5 hours later. But on a shared account the error reads like a misconfiguration somebody has to chase, so +1 for splitting it out and keying on HTTP status plus a structured error type rather than message substrings.