Repository navigation
fix: stop spawning sessions against state that is gone - #123
Merged
Merged
Conversation
Reopening a session that had run Claude Code always raised the resume prompt, because the decision read the session row alone: a stored provider session id was taken as proof the conversation still existed. Claude Code prunes its own transcripts, so an old enough id outlived what it pointed at, and accepting the prompt spawned `claude --resume` against nothing — the user got Claude's own error inside the PTY in place of a session. The check the prompt was missing already existed. claudeTranscriptPath locates a conversation's transcript by id for the context-window readout; ResumeAvailable asks it for existence alone and exposes that over the RPC, so the prompt is raised only for a conversation still on disk. When it is gone the session spawns fresh and a toast says why. A failed check still asks. Losing a live conversation to an answer nobody gave costs more than the error this removes, and a transcript pruned between the check and the spawn keeps the old behaviour as its last resort.
…at fail A worktree removed outside lich leaves its session row behind, pointing at a directory that no longer exists. Opening that session called Start, which failed to chdir, and nothing caught the rejection: the terminal mounted empty and the user was told nothing. Nothing recovers a session whose checkout is gone, so the gate that already decided whether to raise the resume prompt now settles the directory first. A missing one closes the session and says why — closes, not deletes: an absent path is not proof of a permanent one (a mount that was not up yet answers the same way), and the row still carries the provider conversation a later resume would need. Re-creating the worktree picks both back up through ReopenWorktreeSession, which already keys on the path. Every other spawn failure is worth retrying — a provider binary that is not installed, a wrong path in Settings — and startPTY reports them all as one opaque string, so the frontend cannot tell them apart. They now surface as a toast with the session left in place, rather than as the same blank terminal. The decision moved to lib/session/spawn-gate.ts with its two checks injected. The host's effect was at the size limit, and the frontend gate cannot render, so this is the only way the four outcomes are covered by a test.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two failures with the same shape: a session row outlives what it points at, and
the spawn goes ahead anyway. Both end with the user looking at a terminal that
explains nothing.
The resume that cannot happen
Reopening a session that had run Claude Code always raised the resume prompt.
The decision read the row alone (
resumableSession,sessions.ts):kind === "claude"plus a non-emptyproviderSessionIdwas taken as proof theconversation still existed. It is not — Claude Code prunes its own transcripts,
so an old enough id outlives what it points at. Accepting the prompt spawned
claude --resume <id>against nothing, and Claude's own error landed in the PTYin place of a session.
It was a documented shortcut, taken back when a resume only ever followed a
restart (
terminal.go,Start):Parked worktree sessions age, so it stopped being rare.
The check was already written:
claudeTranscriptPathlocates a conversation'stranscript by id for the context-window readout, honouring
CLAUDE_CONFIG_DIR.ResumeAvailableasks it for existence alone. The prompt is now raised only fora conversation still on disk; when it is gone the session starts fresh and says
so. Offering a choice that cannot be honoured was the bug.
The checkout that is gone
A worktree removed outside lich leaves its session behind, pointing at a
directory that no longer exists.
Terminal.Startrejected, and nothing caughtit (
TerminalView.tsx) — the terminal mounted empty, in silence.Nothing recovers that session, so the gate settles the directory first and
closes it. Closes, not deletes. An absent path is not proof of a permanent
one: a mount that was not up yet answers exactly the same, and a deleted row
takes the provider conversation with it. Parking costs the same and loses
nothing — re-creating the worktree picks the session back up through
ReopenWorktreeSession, which already keys on the path.WorkdirMissingis deliberately asymmetric: only a directory provably absent(or a path that is not a directory) answers true. An empty cwd, a permission
error, a mount answering badly all fall through to the spawn, because closing a
session on a check that never answered is the worse trade.
The floor under both
Every other spawn failure — a provider binary that is not installed, a wrong
path in Settings › Providers, an exhausted fd table — reaches the frontend as
one opaque string from
startPTY, indistinguishable from each other. They allused to fail into the same blank terminal. They now surface as a toast, with the
session left in place: all of them are worth another try once the cause is
fixed, which is exactly why none of them may close a card.
Shape
The decision moved to
lib/session/spawn-gate.ts, with its two backend checksinjected. The host's effect was already at the size limit, and the frontend gate
cannot render — injection is what puts the four outcomes, and both fail-safe
directions, under a test.
Test plan
gofmt -l .,go vet ./...,go test ./...(529 pass)biome check(12 warnings, all the standing a11y backlog),vitest run(501 pass),tsc --noEmit+vite buildTestResumeAvailable: transcript on disk, pruned transcript, empty id, shell session, another providerTestWorkdirMissing: existing directory, empty cwd, deleted checkout, path that is a filespawn-gate.test.ts: all four verdicts, plus each check failing (workdir spawns anyway, resume still asks)task dev— the frontend gate is node-only, so the render path is unverified:~/.claude/projects/→ no prompt, a toast, a working fresh sessionrm -rfa worktree behind an open session → toast, card closes, re-creating the worktree brings it back