Skip to content

fix(session): resume the conversation when respawning a dead pane - #467

Merged
Ark0N merged 2 commits into
Ark0N:masterfrom
irisitymichaelgrundberg:fix/respawn-session-id-collision
Sep 23, 2026
Merged

Ark0N merged 2 commits into
Ark0N:masterfrom
irisitymichaelgrundberg:fix/respawn-session-id-collision

Conversation

@irisitymichaelgrundberg

Copy link
Copy Markdown
Contributor

Resume the conversation when respawning a dead pane

Recovering a session whose pane is dead relaunches the CLI with a bare
--session-id <id>. Claude refuses an id that is already in use, so the
relaunch dies on startup, the pane goes dead again immediately, and the user's
conversation is stranded behind a tab that looks merely idle.

Error: Session ID 2aa84afa-5469-4cf5-8608-a687bb4cc4bc is already in use.

How to reproduce

The dead pane is the precondition, and a crashed, OOM-killed or otherwise
terminated agent leaves one on its own — that is the state #446
is about. /exit is simply the quickest way to stage it deliberately.

  1. Start a claude session in Codeman and give it one prompt. The answer is not
    important; being prompted is what writes a transcript under the session id.
  2. Type /exit in that session's pane. remain-on-exit on keeps the pane, so
    tmux reports pane_dead=1 and Codeman's tab still reads idle.
  3. Restart the Codeman server. Restore-on-startup finds the session, takes the
    dead-pane branch and relaunches the CLI.
  4. Read the pane's scrollback:
    tmux -L codeman capture-pane -p -S - -t codeman-<id fragment>

On master step 4 prints the error above followed by
Pane is dead (status 1, ...). On this branch the pane is alive, claude is
running in it, and the conversation from step 1 is back in the scrollback.

Who it hits

Every session whose agent has actually been prompted, because being prompted is
what creates a transcript under the session id. A session nobody typed into
owns no transcript and recovers fine, which is what makes this look like an
edge case rather than the rule.

Why it has been quiet

The dead-pane branch of _setupOrAttachMuxSession() runs only for a pane whose
agent has exited. Until #446, Codeman could not report that state
at all: remain-on-exit on keeps the pane, the tmux attach-session process
that Codeman records as the pid keeps running, and the session reads as live
and idle. I found this while testing #446, whose branch makes the state
visible. The two changes are independent, and this one applies to master on
its own.

The fix

restartCli() has pinned a resume id against this since the custom-model work,
and its own comment states the assumption that made the other path look safe:

Unlike the dead-pane respawn, this one kills a WORKING pane whose
conversation already has a transcript

A pane whose agent exited has a transcript too. Both relaunch paths now build
their options through _buildRespawnPaneOptionsWithResumePin(), and so does
the create-path fallback after a failed respawn, which otherwise met the same
refusal that made it the fallback. The launch then renders
--resume <id> || --session-id <id>, the shape the docker and remote pane
commands already use, so the relaunch resumes the conversation and falls back
to a fresh one only when resuming is impossible.

Four conditions gate the pin. Each one stands for a way of resuming the wrong
conversation, or of making a working relaunch fail.

  1. The CLI must declare a fallback launch chain. The registry shape is
    the gate rather than the CLI's name, exactly as restartCli() had it. An
    entry whose resume id the CLI mints itself (codex, pi, omp, grok) declares
    no such chain and reads its resume field from its own <Mode>Config.
  2. A remote or docker session is never pinned. restartCli() is local-only
    because its routes refuse those two, not because the method checks anything;
    the dead-pane branch has no such route in front of it. Both buildRemoteLaunchCommand()
    and claudeDockerPaneCommand() flip from the self-healing
    --session-id || --resume to resume-first once the resume id differs from
    the session id. The conversation lives on the far side, so a local id
    resolves to nothing there and the --session-id fallback then collides with
    the transcript the far side does hold.
  3. The id comes from the conversation chain, not _claudeSessionId. That
    field also holds history-correlated guesses keyed on the working directory.
    _recordClaudeSessionInChain() refuses those so they cannot "write a foreign
    conversation into this pane's permanent record", and launching a CLI from a
    guess is worse than the display bug that rule prevents. The chain tail also
    outranks the launch seed, which is written once at construction and never
    moves off a /clear.
  4. A pin no transcript backs is dropped, and so is a synthetic
    restored-<fragment> id from socket discovery. The fallback branch keeps
    --session-id <this.id>, so a pin that cannot resume would collide there
    instead. The restored-* id fails claude's uuid token pattern, which means
    the renderer would emit the unpinned command while the caller believed
    otherwise; that case is logged rather than dropped silently.

reattachRemote() is the third respawnPane() caller and deliberately keeps
the unpinned options. It re-runs the remote session command, which attaches to
the durable remote tmux with the agent still running inside it, and renders no
local --session-id to collide.

Tests

test/respawn-session-id-collision.test.ts adds 14 tests. They drive
startInteractive() and assert the rendered launch command rather than reading
the source, so a refactor that preserves behaviour keeps passing and a gutted
pin does not. Four of them fail against the unfixed source, including the core
bug. The remote and docker tests were separately checked against a build with
only that guard removed, because they pass on master for the wrong reason.

npm run typecheck, npm run lint, npm run format:check,
npm run check:frontend-syntax and npm test all pass (415 test files, 7842
tests).

Manual verification

On tmux 3.2a, I ran the same fixture against both builds: one claude session in
a scratch directory, prompted once so a transcript exists, then /exit, leaving
a dead pane. The Codeman data directory was snapshotted before the first run and
restored before the second, so both builds saw identical state.

build result
master respawn ran, pane dead again with status 1, scrollback reads Error: Session ID … is already in use.
this branch pane alive, claude running, the earlier turn present in the scrollback and context at 4% — the conversation resumed rather than restarted

No already in use appears in the server log on this branch.

Two pre-existing problems this does not fix

Review turned up two issues that this change makes more prominent without
introducing. Each deserves its own change rather than riding along here.

A resumed transcript replays into the expensive parsers. claude --resume
reprints the conversation into the pane, and every chunk reaches
_processExpensiveParsers(), which feeds the Ralph tracker's completion-phrase
counter. The replayed transcript contains the completion phrase from the
previous run, so Ralph can report completion, or meet its exit gate, before the
agent has done anything. This already affects every remote and docker resume
today; this change makes local resume routine as well. A fix needs a
suppression window and a rule for when replay ends, which is a design decision
rather than a patch.

nice wraps only the first branch of the chain. wrapWithNice() at
src/utils/nice-wrapper.ts:14 prefixes the whole rendered string, so the
command becomes nice -n 10 claude --resume A || claude --session-id A and the
fallback branch inherits no priority. The two-branch shape is already normal for
docker and remote; this change makes it normal for local claude relaunches too.
The function is shared by every caller, so fixing it means either string surgery
on a rendered command or nesting another bash -c inside the one tmux already
uses.


🤖 Generated with Claude Code

A CLI that launches with `--session-id <id>` refuses an id that is already in
use (claude: `Error: Session ID ... is already in use.`), and every session
whose agent has been prompted owns a transcript under that id. The dead-pane
respawn in `_setupOrAttachMuxSession()` passed the bare launch line, so
recovering such a session relaunched a CLI that died on startup, the pane went
dead again at once, and the conversation was stranded behind a tab that looked
merely idle.

`restartCli()` has pinned a resume id against this since the custom-model work,
and its comment states the assumption that made the other path look safe:
"Unlike the dead-pane respawn, this one kills a WORKING pane whose conversation
already has a transcript". A pane whose agent exited has a transcript too.

Both relaunch paths now build options through
`_buildRespawnPaneOptionsWithResumePin()`, and so does the create-path fallback
after a failed respawn, which otherwise met the same refusal that made it the
fallback. Four gates guard the pin, each standing for a way of resuming the
WRONG conversation or of making a working relaunch fail.

A remote or docker session is never pinned. Unlike `restartCli()`, whose route
refuses both, the dead-pane respawn is reached by every session shape. Their
pane commands already render a self-healing `--session-id || --resume`, and
both flip to resume-first once the resume id differs; the conversation lives on
the far side, so a local id resolves to nothing there and the `--session-id`
fallback then collides with the transcript the far side does hold.

The id comes from the conversation CHAIN rather than `_claudeSessionId`, which
also holds history-correlated guesses keyed on the working directory.
`_recordClaudeSessionInChain()` refuses those so they cannot "write a foreign
conversation into this pane's permanent record", and launching from one is
worse than the display bug that rule prevents. The chain tail also outranks the
launch seed, which is written once at construction and never moves off a
`/clear`.

A pin no transcript backs is dropped, because the fallback branch keeps
`--session-id <this.id>` and would collide. A synthetic `restored-<fragment>`
id from socket discovery is dropped too, and logged: it fails claude's `uuid`
token pattern, so the renderer would emit the unpinned command while the caller
believed otherwise.

Tests cover each gate and the rendered command. Four of them fail against the
unfixed source; the remote and docker ones were separately checked against a
build with only that guard removed, since they pass on master for the wrong
reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Ark0N

Ark0N commented Sep 21, 2026

Copy link
Copy Markdown
Owner

Thanks for this, and for the reproduction and the before/after table: a dead-pane relaunch that collides with its own transcript is a real bug, and pinning the conversation so the relaunch renders --resume <id> || --session-id <id> is the right shape for it. The four gates are well chosen, and driving the tests through startInteractive() and the real registry renderer rather than reading the source is exactly right. I confirmed the 4-of-14 failures against master, and the full gate is green here too (415 files, 7842 tests).

One thing to fix before I merge, plus a few small ones that live in the same few lines.

1. A dropped pin falls back to the colliding command, not to the self-healing one (src/session.ts:2022, same shape at :2015).

return options hands back resumeSessionId = this._resumeSessionId, which for an ordinary session is undefined, so the renderer emits claude --dangerously-skip-permissions --session-id "<this.id>" alone. Any session prompted before its first /clear owns a transcript under this.id, so that is the refusal this PR removes, with no || branch to catch it. I checked it rather than reasoning about it: a chain tail with no transcript plus a transcript under session.id gives resumeSessionId: undefined and that bare command.

It is also a regression against master on the restartCli() path, which set this._claudeSessionId ?? this.id and, since the constructor seeds _claudeSessionId at src/session.ts:781, always pinned something. Under this branch a custom-model apply can now land unpinned and kill the pane, which is the failure that pin was added for.

Your own doc comment already has the answer two paragraphs up ("Pinning the session's own id renders the self-healing ... which needs no transcript to be correct"). Please degrade to this.id rather than to nothing. A candidate walk covers it:

const pattern = getCli(this.mode)?.launch.params?.resumeId;
const configDir = this._claudeConfigDir();
const candidates = [chainTail, options.resumeSessionId, this.id].filter((v): v is string => !!v);
for (const candidate of candidates) {
  if (pattern?.type === 'token' && !matchesPattern(pattern.pattern, candidate)) continue;
  if (!(await claudeTranscriptExists(candidate, configDir))) continue;
  options.resumeSessionId = candidate;
  return options;
}
return options; // nothing on disk to collide with, so the bare --session-id is correct

Two tests would pin it: a chain tail with no transcript while this.id has one, and no transcript anywhere leaving the command single-branch.

2. The transcript lookup ignores the server's own CLAUDE_CONFIG_DIR (src/utils/claude-transcript.ts:31).

claudeCredentialsPath() (src/claude-credentials.ts:85) and realClaudeConfigDir() (src/custom-model-injection-apply.ts:59) both honour the process env for the same directory, and a pane inherits the server env through tmux. On an install that exports it, every lookup is a false negative, which today means the colliding form from point 1. Please use configDir || process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude'). I confirmed the false negative with a throwaway test.

Related: the header at src/utils/claude-transcript.ts:37 says skipping the resume "is the safe direction". At this call site it is the unsafe one, so that sentence wants updating with the fix.

3. "The command shape is unchanged" is not quite true (src/session.ts:1775).

For a genuinely new session the pin resolves to this.id with no transcript check, so the shape does change, to --resume <id> || --session-id <id>. Your own test "renders a self-healing resume-or-new when the pin is the session id" asserts it. Two costs on a pane with no transcript: claude's "No conversation found" line lands in the scrollback of a session that is brand new, and wrapWithNice() (src/utils/nice-wrapper.ts:17) prefixes only the first branch, so the branch that actually runs loses its priority for the life of the session. The candidate walk in point 1 removes both, because nothing gets pinned when nothing is on disk.

4. The create-path pin leaves _claudeSessionId pointing elsewhere (src/session.ts:1778).

On that path isRestored is false, so line 2404 sets _claudeSessionId from this._resumeSessionId || ... || this.id. When the pin is the chain tail, claude resumes it while Codeman believes the conversation is this.id, and the response viewer, Read My Mind and the unified-list alias map all read that field until the next first-hand hook. Setting this._resumeSessionId = pinned alongside the createSessionOptions write keeps them in step.

Smaller things I am happy to take at merge time if you would rather not touch them:

  • src/session.ts:2008 re-implements resolveResumeConversationId() (src/reboot-restore.ts:103), and src/utils/claude-transcript.ts:39 duplicates the inline scan at src/web/routes/session-routes.ts:1042. One copy of each would be better.
  • The two recovery paths now disagree on whether a pin needs a transcript: reboot-restore pins with no gate at all (src/web/routes/reboot-restore-routes.ts:190). Worth settling one way, though not in this PR.
  • CLAUDE.md:232 still says the pin is a restartCli()-only thing sourced from the live conversation id. All three halves of that sentence moved here.
  • src/session.ts:2005 and :2014 both call getCli(this.mode).

On the two problems you listed as out of scope: I agree on both. The parser replay deserves its own issue rather than a patch, and the nice wrapping is a shared-function problem. Point 3 above does reduce how often the second one bites.

One more for your awareness rather than this PR: #446 proposes closing cleanly exited dead panes, and this one resumes them. They can coexist (close on status 0, resume otherwise), but it is worth deciding which lands first.

Fix point 1 and fold in 2 to 4 while you are in there, and I will merge. The rest I can take at merge time.

A single pin that failed its transcript gate returned the options untouched,
so `resumeSessionId` fell back to `_resumeSessionId` — undefined for an
ordinary session — and the renderer emitted the bare
`claude --dangerously-skip-permissions --session-id "<this.id>"`. Every
session prompted before its first `/clear` owns a transcript under that id,
so the dropped pin handed back exactly the refusal this branch removes, with
no `||` branch to catch it. It was also a regression against master on the
`restartCli()` path, which pinned `_claudeSessionId ?? this.id` and, since the
constructor seeds that field, could never land unpinned.

The pin now walks three candidates in priority order — the conversation
chain's tail, the launch seed, then the session's own id — and takes the first
one a transcript backs. A candidate that misses is passed over rather than
ending the walk.

Falling off the end pins nothing, which also settles the second half of the
problem: the old code skipped the transcript check whenever the pin was the
session's own id, so a genuinely new pane rendered the two-branch form after
all. That costs a brand-new session claude's "No conversation found" line in
its scrollback, and `wrapWithNice()` prefixes only the first branch of the
rendered `a || b`, so the branch that actually runs loses its priority for the
life of the session. With no transcript anywhere the bare `--session-id` is
the correct command, so the comment claiming an unchanged shape is now true.

The transcript lookup reads the server process's own `CLAUDE_CONFIG_DIR` when
a session declares none. A pane inherits the server environment through tmux,
so on an install that exports it the CLI writes its transcripts there and
every lookup under `~/.claude` was a false negative — which under the old code
meant the colliding command. `claudeCredentialsPath()` and
`realClaudeConfigDir()` resolve the same directory the same way. The header
sentence calling a skipped resume "the safe direction" described the opposite
of what happens at this call site, and says so now.

The create-path fallback writes `_resumeSessionId` alongside the create
options. That branch leaves `isRestored` false, so `_claudeSessionId` is
recomputed from the launch fields and settled on `this.id` while the CLI
resumed the chain tail; the response viewer, Read My Mind and the unified-list
alias map read that field until the next first-hand hook.

Four new tests: a chain tail with no transcript while the session id has one,
no transcript anywhere, the create path's alias, and the process-env lookup.
All four fail against the previous commit. Two existing tests move with the
gate — the guess-refusal test now backs the session's own id, and the
custom-model restart test gives its working pane the transcript that makes
`--session-id` collide in the first place, alongside a new one pinning the
no-transcript case.

CLAUDE.md described the pin as a `restartCli()`-only thing sourced from the
live conversation id. All three halves of that moved here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@irisitymichaelgrundberg

Copy link
Copy Markdown
Contributor Author

Claude:

Fixed points 1 to 4, and folded in the two smaller ones that sit inside the lines I was already rewriting. Pushed as 1cb0441.

1. The dropped pin. Confirmed, the restartCli() regression included: _buildRespawnPaneOptions() sets resumeSessionId: this._resumeSessionId, so every early return handed back the bare form. The pin is now the candidate walk you proposed — chain tail, launch seed, session id — taking the first one a transcript backs and passing over a candidate that has none rather than stopping there.

2. CLAUDE_CONFIG_DIR. claudeProjectsDir() resolves configDir || process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude'). The "safe direction" sentence is rewritten: skipping the resume is safe only where nothing can collide with the bare --session-id, so a lookup that misses the real config dir turns a recoverable pane into the collision the module exists to prevent.

3. The shape claim. You are right, and the walk removes both costs. A pane with no transcript now gets no pin, so it keeps the single-branch command, keeps "No conversation found" out of a brand-new scrollback, and keeps nice on the branch that actually runs. The comment at session.ts:1775 says that now instead of the old claim.

4. _claudeSessionId. The create path writes this._resumeSessionId = pinned alongside the createSessionOptions write, so the field and the CLI name the same conversation.

Also folded in: one getCli(this.mode) call instead of two, and CLAUDE.md's pin paragraph, which described all three halves of the old behaviour.

Left for you at merge time, as you offered: the resolveResumeConversationId() and transcript-scan deduplication, and the reboot-restore gate disagreement. One note on the first — the walk needs its candidates individually rather than one resolved id, so the shared piece is the ordered candidate list with resolveResumeConversationId() as its head, not that function as it stands today.

Tests: four new ones, all failing against 47e7935. A chain tail with no transcript while the session id has one, no transcript anywhere leaving the command single-branch, the create path's alias, and the process-env lookup. Two existing tests moved with the gate: the guess-refusal test now backs the session's own id, and the custom-model restart test gives its working pane the transcript that makes --session-id collide in the first place, with a new sibling covering the no-transcript case. Full gate green here, 415 files and 7849 tests.

@Ark0N
Ark0N merged commit bc04b64 into Ark0N:master Sep 23, 2026
2 checks passed
Ark0N pushed a commit that referenced this pull request Sep 23, 2026
- test/setup.ts strips CLAUDE_CONFIG_DIR (pinned in test-env-isolation), so
  transcript-fixture tests such as session-custom-model-restart no longer go
  red on a machine that exports it for a separate Claude account (#255).
- The vanished-tmux-session branch of _setupOrAttachMuxSession() relaunches
  the CLI through createSession() just like a failed respawn, so it now takes
  the same resume pin. A genuinely new session is unaffected.
- After a dead-pane respawn of a fallback-chain CLI, _claudeSessionId names
  the conversation the walk actually pinned instead of the chain tail, which
  the walk may have passed over for lack of a transcript.
- _claudeConfigDir() trims the override like claudeProjectsDir() does.
- The remote-reattach test is labelled as documentation, since the pin
  builder's own remote guard would make it pass either way.
- CLAUDE.md: the create-path pin persists through toState() as
  resumeSessionId, and the end of the walk adds no pin rather than clearing
  the launch seed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Ark0N

Ark0N commented Sep 23, 2026

Copy link
Copy Markdown
Owner

Merged, thanks @irisitymichaelgrundberg! This ships in 1.32.1.

The 1cb0441b round was exactly right: degrading the pin to the session id rather than to nothing keeps every relaunch on the self-healing --resume <id> || --session-id <id> shape, and the tests assert the rendered launch command, so they will survive refactors.

I finished the rest at merge:

  • The CLAUDE_CONFIG_DIR finding was real: test/session-custom-model-restart.test.ts went red for anyone exporting it. test/setup.ts now strips it and test/test-env-isolation.test.ts pins that.
  • The vanished-tmux-session branch (tmux lost the whole session, not just the pane) reached createSession() unpinned, so it still emitted the colliding command. It now takes the same pin as a failed respawn; a brand-new session is untouched. Two tests, both red on your head.
  • After a dead-pane respawn, _claudeSessionId follows the id the relaunch actually resumed instead of the chain tail, so the response viewer and the unified list read the conversation that is really running.
  • _claudeConfigDir() trims its value like claudeProjectsDir(), the remote-reattach test is honestly labelled as documentation, and the CLAUDE.md paragraph now says the create-path pin persists through toState() as resumeSessionId.

@github-actions github-actions Bot mentioned this pull request Sep 23, 2026
@irisitymichaelgrundberg
irisitymichaelgrundberg deleted the fix/respawn-session-id-collision branch September 23, 2026 11:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants