[BUG] FleetView new-context launch is correctly refused with `Session ID … is already in use`, then the retry silently resumes that unrelated conversation and drops the prompt
Summary
Launching a new context from FleetView does not allocate a fresh session. It creates a dispatch and claims a pre-warmed worker from the daemon's spare pool to serve it. Twice in one day on this host, a source: fleet dispatch was created carrying a session UUID that a previous dispatch had already used and settled, and whose transcript file still exists on disk. The first attempt crashed at init with Session ID <uuid> is already in use — this is the guard working correctly, since that check is a plain "does <uuid>.jsonl already exist" test. The daemon then retried, re-claimed the same UUID, and this time started successfully, opening on top of the old conversation. For the retry to pass a guard the first attempt failed, it cannot have taken the --session-id path; it took a resume/restore path instead. So the real defect is: a new-context launch that was correctly refused gets escalated by the retry into a silent resume of an unrelated conversation. The prompt typed into FleetView was delivered into that old context and then died with the worker seconds later.
From the user's side the symptom is: "I open a new context and it attaches to an old one, and I lose the prompt I started it with."
Environment
- Claude Code 2.1.232, native install (
~/.local/bin/claude→~/.local/share/claude/versions/2.1.232) - Linux
robin7.0.0-28-generic, Ubuntu 24.04, x86_64 - Heavy concurrent use: ~20 peer sessions, multiple daemons, high spare churn
- Not reproducible on demand. Two occurrences observed in a ~40 minute window; both captured in full.
What the user sees
A FleetView row opens showing a conversation that is not the one they just started — including its ai-title from the previous conversation. The prompt they typed never runs. Nothing reports an error to the user.
---
Measured evidence
Three independent traces on disk agree. Timestamps in the daemon log are UTC; local host is UTC-3.
Trace 1 — ~/.claude/daemon.log
Incident A — fe62646d-1f67-4ac5-bae8-c1c1cc694a7f. This UUID was first claimed as a spare at 15:57:24Z and its worker settled killed at 17:48:45Z. Its transcript on disk covers 15:57:25Z to 17:22:03Z. Seventeen minutes after the worker died, a FleetView launch claimed the same UUID:
[2026-08-14T18:05:54.421Z] [bg] bg claimed-spare fe62646d (fleet)
[2026-08-14T18:05:54.758Z] [bg] bg settled fe62646d (crashed): exit 1 before init — Error: Session ID fe62646d-1f67-4ac5-bae8-c1c1cc694a7f is already in use.
[2026-08-14T18:05:55.037Z] [bg] bg claimed-spare fe62646d (fleet)
[2026-08-14T18:06:44.753Z] [bg] bg settled fe62646d (killed)
Incident B — 97517ae6-def0-4d6a-b251-8cf4b60e69bc. Same shape. Claimed as spare at 18:06:46Z, settled killed at 18:22:43Z, then re-claimed by a FleetView launch 25 minutes later:
[2026-08-14T18:06:46.689Z] [bg] bg claimed-spare 97517ae6 (spare)
[2026-08-14T18:22:43.390Z] [bg] bg settled 97517ae6 (killed)
[2026-08-14T18:47:23.961Z] [bg] bg claimed-spare 97517ae6 (fleet)
[2026-08-14T18:47:24.344Z] [bg] bg settled 97517ae6 (crashed): exit 1 before init — Error: Session ID 97517ae6-def0-4d6a-b251-8cf4b60e69bc is already in use.
[2026-08-14T18:47:25.736Z] [bg] bg claimed-spare 97517ae6 (fleet)
[2026-08-14T18:47:41.938Z] [bg] bg settled 97517ae6 (killed)
Note that the retry re-claims the same short id rather than allocating a new one.
Trace 2 — the transcript contains two conversations
~/.claude/projects/<slug>/97517ae6-def0-4d6a-b251-8cf4b60e69bc.jsonl is a single file holding two unrelated conversations:
- lines 0–41: timestamps 18:06:48Z through 18:14:36Z (the original conversation,
ai-title: "linear-comment-watch setup") - line 42:
{"type":"last-prompt","leafUuid":"f61ebd6a-...","sessionId":"97517ae6-..."} - 32.9 minute gap
- lines 44–55: timestamps 18:47:27Z through 18:47:37Z (the new launch)
- line 51:
{"type":"last-prompt","leafUuid":"5a69f2a4-...","sessionId":"97517ae6-..."}— a different leaf, so a new prompt leaf was created on top of the old tree
The ai-title on line 52 is still "linear-comment-watch setup" — the title of the old conversation, which is what the user sees in the FleetView row.
Trace 3 — the prompt was delivered, into the wrong context
~/.claude/history.jsonl has exactly one entry for this session:
{"display": "/rc", "pastedContents": {}, "timestamp": 1786733256342, "project": "<cwd>", "sessionId": "97517ae6-def0-4d6a-b251-8cf4b60e69bc"}
1786733256342 is 18:47:36.342Z. The daemon settled that worker killed at 18:47:41.938Z — 5.6 seconds later. So the prompt reached the process, was recorded against the recycled session id, and never ran.
---
Reconstructed sequence
- A worker settles (
killed). Its session UUID is retired but its transcript file remains on disk. - Some time later (17 and 25 minutes in the two observed cases) a FleetView launch creates a dispatch carrying that same UUID, and claims a spare worker to serve it.
- Startup fails:
Session ID <uuid> is already in use→exit 1 before init. This is correct. The guard is a file-existence test and the transcript does exist. - The launch retries ~0.3–1.8s later and claims the same UUID. This time it starts. Since the transcript still exists, it cannot have passed the same guard — so the retry used a resume/restore path rather than
--session-id. - The session opens on the old transcript. The user gets the old conversation, with its old
ai-title. - The typed prompt is written against that session id and the worker is killed shortly after. The prompt never executes.
Steps 1–3 and 5–6 are measured. Step 4's mechanism is inferred from the guard's semantics — I can see that the retry succeeded where an identical-looking first attempt failed, but not which code path it took. Step 6's cause of the kill is not established either; I can see the kill, not who issued it.
What the code shows
The already in use guard is a file-existence test, not a liveness check
if (H) { if (!K) {
let Nt = AE(H);
if (!Nt) return ba("Error: Invalid session ID. Must be a valid UUID.");
if (!(t.forkSession && t.resume === Nt) && SEn(Nt))
return ba(`Error: Session ID ${Nt} is already in use.`)
}}
function SEn(e){
let t = Sz() ?? Zv(Hn()), // projects/<slug>
r = Xf.join(t, `${e}.jsonl`),
n = br();
try { n.statSync(r); return !0 } catch { return !1 }
}
Two consequences.
The message is misleading. "Already in use" actually means "a transcript with this name exists on disk". There is no lock, no pid check, no session-env involvement. A cleanup of stale metadata will not change this behavior, so the mitigations proposed in coder/registry#726 do not apply here.
The retry cannot have taken this path. The transcript did not disappear between 18:47:24 and 18:47:25, so an identical --session-id invocation would have failed identically. The only in-code bypass is t.forkSession && t.resume === Nt, and a fork would have produced a new id rather than appending to the existing file. Therefore the retry resolved to a resume/restore launch. That escalation — refused-as-new becomes silently-resumed — is the defect.
Dispatch and pool
From the 2.1.232 bundle, the spare-claim path is behind a remote gate and falls through to a cold spawn when the gate is off:
if (… && rt("tengu_bg_spare_enable", !0)) {
…
N("tengu_bg_spare_claim", { age_ms: Date.now() - ue.startedAt }),
e(`bg claimed-spare ${z.short} (${z.source})`),
…
return "claimed"
} catch (re) { … N("tengu_bg_spare_claim_fail", { reason: me(ce) }), ue.dispose() }
let oe = d0e.spawn(z, a, …) // cold spawn path
Note that the log line is bg claimed-spare ${z.short} (${z.source}) and the roster is keyed by z.short, whose values are the session shorts. So z is the dispatch, not the spare: the pool supplies a warm process, and the dispatch supplies the session UUID it will assume. That places the origin of the recycled UUID in whatever built the source: fleet dispatch, not in the pool's own allocation. The attach-journal entry for a FleetView gesture agrees — it records "surface": "fleet" with "via": "spare". Which component picks the stale id is not established from the outside; the observable fact is that a source: fleet dispatch was created with a UUID that a prior source: spare dispatch had already used and settled.
The dispatch record for a healthy fleet launch carries the prompt in two places — launch.args after --, and dispatch.seed.intent. A resume-mode launch carries neither (flagArgs with no --, and intent: ""). Healthy spare launches record restoresTranscript: false. I could not capture the poisoned launch's own restoresTranscript value because roster.json only retains live workers; that specific field is inferred, not measured.
Why this is not an existing issue
- #59653 (
exit 1 before init, macOS arm64) — closed as duplicate; there every daemon-spawned worker crashed, with a Bun stack trace. Here the failure is intermittent, on Linux, and the error is specificallySession ID … is already in use. - #73754 (attaching to a stopped background agent) — different error string (
is currently running as a background agent), and it is about an explicit re-attach, not a new launch. - #70373 (backgrounding with in-flight subagents forks the session) — that creates a new id seeded with a copy; here an old id is re-issued and written into.
Impact
- Silent data mixing: two unrelated conversations end up in one transcript file, which corrupts any tooling that treats a
.jsonlas one session. - Silent prompt loss, with no error surfaced to the user.
- The user cannot distinguish this from "Claude ignored me".
Suggested fixes
- Never build a dispatch for a new context with a session UUID whose transcript file already exists. Cheapest guard, catches both observed cases.
- Primary. A retry after
Session ID … is already in usemust allocate a fresh UUID. It must never resolve to resume/restore, because the first attempt was refused precisely to stop that. Today the retry converts a correctly-blocked new launch into a silent resume of someone else's conversation. - When a launch is
mode: promptbut the resolved session already has a transcript, refuse and re-dispatch instead of restoring. - Surface the collision to the user rather than failing silently.
Recovery note for affected users
The lost prompt is not gone. It is recorded in ~/.claude/history.jsonl, keyed by sessionId and timestamp, even when the worker was killed before running it.
No local workaround
There is nothing a user can configure to prevent this.
- The spare path is behind the remote gate
tengu_bg_spare_enable; a user cannot flip it. daemonColdStart(settings, orCLAUDE_CODE_DAEMON_COLD_START=transient|ask) governs how the daemon starts, not spare pre-warming or id selection.- The
already in useguard reads only the transcript file, so pruning~/.claude/session-env/(2661 stale entries on this host) changes nothing. - Deleting the colliding transcript would let the launch through, but that destroys the earlier conversation and is not a workaround.
The only user-side action is recovery after the fact, via history.jsonl.