anthropics / anthropics/claude-code

Remote Control: session worktrees are deleted before the session is archived, and activeSessionIds is almost never persisted

オープン
#93,345 コメント 1 件 リアクション 0 件 担当者 0 名 GitHub で見る
area:core bug has repro platform:linux
主要言語
Python
スター
145k
フォーク
23.1k
PR マージ指標
PR 指標を取得中

説明

## 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.

コントリビューションガイド

このリポジトリのコントリビューションガイドは索引されていません

調査の方向性

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.

索引モデルが issue の本文から書いたものです。

評価

技術スタック
git, linux
領域
backend, cli
issue の種類
バグ
難易度
5/5
見積もり時間
1週間以上
活発さ
活発
明瞭さ
おおむね明確
初心者へのやさしさ
35/100

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。