anthropics / anthropics/claude-code
Remote Control: session worktrees are deleted before the session is archived, and activeSessionIds is almost never persisted
- Lingua principale
- Python
- Stelle
- 145k
- Fork
- 23.1k
- Metriche di merge delle PR
- Metriche PR in attesa
Descrizione
## Summary
Two behaviours in the Remote Control bridge combine to make sessions unresumable after the bridge process restarts:
1. A session's isolated worktree is deleted as soon as that session's child process exits — long before the session is archived or deleted server-side. On shutdown the same happens to every still-active session, **including on the code path that deliberately preserves the environment so those sessions can be reconnected**.
2. `activeSessionIds` is only written to `bridge-pointer.json` by a daemon-hosted bridge shutting down with cause `upgrade`, `reload` or `yield`. A standalone `claude remote-control` never writes it at all, and no signal-driven shutdown (SIGINT / SIGTERM / SIGHUP, or a machine restart) qualifies.
Net effect for sessions started from the desktop app or claude.ai: after a restart at most one session comes back — the single `sessionId` in the pointer — and the directory it was working in is gone.
Read out of the shipped binary for **2.1.267** on Linux. Identifiers below are the minified ones, quoted verbatim so the sites are findable.
## 1. Worktree lifetime is tied to the child process, not to the session
In `spawnMode: "worktree"` the bridge creates `.claude/worktrees/bridge-` per session and tracks it in a map. On session exit:
```js
let Me=he.get(y);
if(Me)if(he.delete(y),B){
if(n.logStatus(`kept worktree ${Me.worktreePath} · session crashed`),Je?.includes("transport closed"))Ve.add(Me.worktreePath)
}else Ie(or(Me,n,{storageV5:e.storageV5}));
```
`B` is `O==="failed"&&!D.aborted&&!dt`. So a session that ends **cleanly** loses its worktree immediately, while a session that **crashes** keeps it. That is inverted: the clean session is the one still listed and still reopenable from claude.ai, and it is the one whose working tree is destroyed.
At shutdown every remaining worktree goes:
```js
if(Xe?.cancelAll(),he.size>0){
let O=[...he.values()];he.clear(),
t(`[bridge:shutdown] Cleaning up ${O.length} worktree(s)`),
await Promise.allSettled(O.map((W)=>or(W,n,{storageV5:e.storageV5})))
}
```
and only **after** that does the preserve branch run:
```js
if(e.preserveOnShutdown&&!dt){
n.logStatus(... "Environment preserved. Restart `claude remote-control` to reconnect existing sessions."),
t(`[bridge:shutdown] Skipping archive+deregister to allow resume (env ${r}, spawnMode ${e.spawnMode})`);
return;
}
```
This is the contradiction. The bridge skips `archiveSession` and `deregisterEnvironment` precisely so the sessions survive and can be reconnected — and it has already deleted the directories those sessions were working in. Restarting the bridge reconnects a session to a `cwd` that no longer exists.
### What I think it should do
A session's worktree should live as long as the session does. Remove it when the session is archived or deleted server-side, not when its child process exits and not on bridge shutdown. Orphans can be reaped at the **next** bridge start by reconciling `.claude/worktrees/bridge-*` against the server's session list, which handles the crashed-and-never-resumed case without having to guess at shutdown time — and does it with information the shutdown path does not have.
Worth noting the removal is already conservative in one direction: `removeAgentWorktree` refuses when the tree is dirty ("changed file(s) would be lost"), and `git worktree remove --force` is a single `--force`, so a `git worktree lock --reason "..."` also survives. Today that makes the effective rule "you keep your work only if you left it uncommitted or locked it by hand", which is a strange thing for users to have to rely on.
## 2. `activeSessionIds` is written on one path only
`persistActiveSessionsOnShutdown` occurs at exactly five sites. The only site that **supplies** it is the daemon worker entry:
```js
...,onBusyChange:L,persistActiveSessionsOnShutdown:X,awaitShutdownCause:i},r)
```
The standalone `claude remote-control` builds its bridge config without either field (no `persistActiveSessionsOnShutdown`, no `awaitShutdownCause`, no `onBusyChange`), so both of the sites that read it short-circuit. The pointer written on this machine records `"source":"standalone"`.
The shutdown guard:
```js
let y=e.persistActiveSessionsOnShutdown&&e.preserveOnShutdown&&!dt&&e.ownsPointer?await e.awaitShutdownCause?.():null;
if(y!=="upgrade"&&y!=="reload"&&y!=="yield")return;
```
and the cause decoder, which admits nothing else:
```js
function oe(e){
if(!z(e)||typeof e!=="object"||e===null||!("cause"in e))return;
return e.cause==="upgrade"||e.cause==="reload"||e.cause==="yield"?e.cause:void 0
}
```
The signal handlers carry no cause at all — they just abort:
```js
let ft=new AbortController,
Mt=()=>{t("[bridge:shutdown] SIGINT received, shutting down"),ft.abort()},
Bt=()=>{t("[bridge:shutdown] SIGTERM received, shutting down"),ft.abort()},
Ut=()=>{t("[bridge:shutdown] SIGHUP received, shutting down"),...,ft.abort()};
```
So on a reboot, a `systemctl stop`, or a plain Ctrl-C, the multi-session list is never persisted. The standalone bridge can only ever *drain* a list it inherited (`onSessionServiced` removes entries one by one) — it never produces one.
### What I think it should do
When `preserveOnShutdown && ownsPointer` holds, persist `activeSessionIds` on **any** clean shutdown, not only the three internal causes; and supply `persistActiveSessionsOnShutdown` on the standalone path too. Preserving the environment for reconnect while discarding the list of what to reconnect leaves the feature half-wired.
## Reproduction
1. Enable Remote Control on a git project, spawn mode `worktree`.
2. Start two or more sessions from claude.ai or the desktop app; let them do some work and commit nothing.
3. Reboot the machine (or `SIGTERM` the bridge).
4. Start the bridge again in the same directory.
**Expected:** both sessions reconnect, each in the worktree it was using.
**Actual:** at most one session reconnects (the pointer's `sessionId`); the worktrees of any session whose child had already exited cleanly are gone, and so are the worktrees of the sessions that were active at shutdown.
## Environment
- Claude Code 2.1.267, native installer, Linux
- Remote Control started per project, pointer `"source":"standalone"`
- `spawnMode: "worktree"`
## Workaround in the meantime
`git worktree lock --reason "keep" ` holds a worktree: `git worktree remove --force` is a single `--force` and git refuses a locked tree, and the stale sweeper skips any lock whose reason does not match its own `^claude (?:agent|session) .{1,255} \(pid (\d{1,10})(?: start (.{1,255}))?\)$`. It has to be unlocked by hand afterwards, since the automatic cleanup will not touch it either.
Local transcripts are unaffected and still sit under `~/.claude/projects//*.jsonl`, so the conversations themselves are recoverable even when the worktree is not.
Guida per i contributori
Nessuna guida per i contributori indicizzata per questo repository
Direzione di ricerca
Start by locating the Remote Control bridge session-exit and shutdown paths corresponding to the quoted minified identifiers in the shipped 2.1.267 binary. Compare the standalone bridge configuration with the daemon worker path, and trace writes to bridge-pointer.json. Done means preserved sessions retain their worktrees and all active session IDs survive clean and signal-driven shutdowns so the reproduction reconnects both sessions.
Scritto dal modello di indicizzazione a partire dal testo della issue.
Valutazione
- Stack tecnologico
- git, linux
- Ambito
- backend, cli
- Tipo di issue
- Bug
- Difficoltà
- 5/5
- Tempo stimato
- Più di una settimana
- Stato di attività
- Attiva
- Chiarezza
- Abbastanza chiara
- Idoneità per principianti
- 35/100