anthropics / anthropics/claude-code

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

Đang mở
#93,345 1 bình luận 0 reaction 0 người được giao Xem trên GitHub
area:core bug has repro platform:linux
Ngôn ngữ chính
Python
Star
145k
Fork
23.1k
Chỉ số merge pull request
Chỉ số pull request đang chờ

Mô tả

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

Hướng dẫn đóng góp

Chưa lập chỉ mục được hướng dẫn đóng góp cho kho mã nguồn này

Hướng nghiên cứu

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.

Do mô hình lập chỉ mục viết ra từ nội dung của issue.

Đánh giá

Công nghệ
git, linux
Lĩnh vực
backend, cli
Loại issue
Lỗi
Độ khó
5/5
Thời gian dự kiến
Hơn một tuần
Mức độ hoạt động
Sôi nổi
Độ rõ ràng
Khá rõ ràng
Mức phù hợp với người mới
35/100

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.