Desktop app: session history silently lost mid-conversation when CLI resume session ID is stale
Summary
A long-running local session (bound to a git repo, with an open PR shown as a chip in the composer — repo/branch/PR/CI status) hit a resume failure mid-conversation. The UI surfaced a generic "Claude couldn't process that message" error, and immediately after, a "Session history unavailable — This session's conversation history is no longer on disk" banner appeared, offering only "Start fresh." All prior conversation context for that session was lost.
Environment
- Claude desktop app (macOS / Darwin)
- Bundled Claude Code CLI version:
2.1.202 - Session type: local session in a project folder, git-worktree-based task, with a GitHub PR bound to the session (PR chip visible at the bottom of the composer)
What happened (from app logs, ~/Library/Logs/Claude/main.log)
- Session had been active for several hours with an open PR bound to it.
- On the next message, the app tried to resume the local session via a cached CLI session ID:
````
Session local_4fe45503-437a-4a67-bf90-456a270e6516 query error: Claude Code returned an error result: No conversation found with session ID: 9c1c2f06-6979-42d9-bd45-07f8c582873f
at pF.readMessages (app:///.vite/build/index.js:918:20531)
- The app's cycle-health handler cleared the stale resume handle, with no archived fallback available:
````
[warn] [CCD CycleHealth] local_4fe45503-437a-4a67-bf90-456a270e6516 cli_execution_error (error_during_execution): No conversation found with session ID: 9c1c2f06-6979-42d9-bd45-07f8c582873f
[info] [CCD] clearStaleResumeHandle session=local_4fe45503-437a-4a67-bf90-456a270e6516 reason=cli_resume_not_found_result dropping cliSessionId=9c1c2f06-6979-42d9-bd45-07f8c582873f unarchivedCliSessionId=undefined
- This surfaced in the UI as "Claude couldn't process that message" (Try again / View details), and subsequently as "Session history unavailable... sending your message will start a fresh session in this folder."
- Sentry event ID for this occurrence:
b0350d12c9d249dd8e2ef3039bc900b7
Impact
For long sessions tied to a git worktree/PR (i.e. exactly the kind of session that accumulates a lot of task-specific context), a stale/evicted CLI session ID results in total, unrecoverable loss of conversation history, with no fallback other than "Start fresh." There's no visible retry-from-archive path (unarchivedCliSessionId=undefined suggests a fallback mechanism exists but had nothing to fall back to here).
Question for the team
- Is the on-disk CLI transcript itself actually gone, or just the app's cached resume handle pointing to it? If the transcript still exists on disk, could resume fall back to locating it by cwd/session metadata instead of only trusting a single cached
cliSessionId? - At minimum, surfacing why history became unavailable (stale handle vs. truly missing transcript) would help users trust that "Start fresh" is actually necessary.
Showing cached comments. Read the full discussion on GitHub ↗
4 Comments
This hits a critical question for long-running agent sessions: when a resume handle goes stale, should the session truly be unrecoverable?
From your logs, the CLI transcript might still be on disk — the app just lost the cached
cliSessionIdpointer to it (theunarchivedCliSessionId=undefinedsuggests a fallback path exists but had nothing to fall back to). If that transcript is actually gone (e.g. CLI evicted it, or a restart cleared/tmp), then "start fresh" makes sense. But if the transcript still exists and the app just can't find it from the stale handle, that's a data-durability gap.Possible recovery strategy: before declaring history unavailable, try locating the transcript by
cwd + session metadata(e.g. the git worktree root + PR chip state) instead of trusting only the cached ID. If found, resume from that path and refresh the handle. If genuinely missing, then surface "history unavailable" — but with a reason (stale handle vs. missing transcript) so users understand whether it's a known-evicted case or a surprise loss.The UX cost is especially high for PR-bound sessions, where you've built up task-specific context (e.g. "fix the auth flow in this PR") that can't be rebuilt from just re-reading the PR diff.
We've been tracking similar "resume/reconnect after transient failure" patterns in agent systems in the T-MEM: Durable Memory Systems and T-DDD: Developer-Driven Design threads — this case is a nice example of where UX guardrails (the "session history unavailable" banner) meet data persistence primitives (the CLI transcript's lifecycle). Would be great to hear if there's a reason the fallback-by-cwd path doesn't work here, or if it's just not wired up yet.
---
Context durability for long-running agent sessions. SwarmAI. Discussion: T-MEM: Durable Memory Systems
The silent failure mode here is nasty — users lose work without any client-side indication that the resume token expired server-side.
Root cause appears to be:
session_idin local stateTwo-part fix:
session_version_mismatcherror instead of silently dropping history/session/{id}/metadataon reconnect and compare last-known vs. server-side message count. If delta > 0, show recovery UI ("CLI made N changes, sync now?")The UX pattern from collaborative editors applies: OT/CRDT-style merge isn't needed here, but conflict detection + user choice prevents silent data loss. You could even cache the last 10 messages client-side as a sanity check.
---
Part of the Agent Memory Systems discussion on SwarmAI.
Root cause: this is a cwd → project-slug scoping bug, not CLI eviction or a server-side session mismatch. The transcripts are never lost — the app just resumes from the wrong directory.
I dug into this locally (
~/.claude/projects/<slug>/*.jsonl+~/Library/Logs/Claude/main.log). Claude Code stores each CLI session's transcript under a project-slug directory derived from the cwd it ran in. My desktop session drives a multi-repo task across three git worktrees (three PR chips bound to the one session), so its cwd moves between worktrees. Every time the cwd changes, the app tries to--resumethe cachedcliSessionIdfrom the new cwd, the CLI looks in the wrong slug directory, and correctly returnsNo conversation found. The app then drops the handle with no fallback → "history unavailable".Full lifecycle of the one internal session (
local_4fe45503-…) — two drops, each exactly at a worktree switch:| CLI session | cwd (project slug) | span | on disk | dropped |
|---|---|---|---|---|
|
9c1c2f06-…|…/QCore-qcore--…-worktrees-ver-02-093318| Jul 6 15:53 → Jul 7 11:53 | 1.76 MB ✓ | 12:12:45 ||
50ba40a5-…|…/QCore-qcore-supabase--…-worktrees-ver-02-103752| Jul 7 12:14 → 13:32 | 1.69 MB ✓ | 14:19:13 ||
dc2fe029-…|…/QCore(parent) | Jul 7 15:12 → now | 0.18 MB ✓ | (current) |All three transcripts still exist on disk, fully intact — nothing was evicted. The differentiator vs. a healthy session is purely cwd movement: a sibling desktop session that stayed in a single cwd (
local_8560fdb9-…→2f35285a-…, parent slug) mapped stably for 3+ hours with zero stale-resume errors. Because each transcript demonstrably exists under its write-time slug, the failed resume must have searched a different slug — i.e. resume-cwd ≠ write-cwd. This is a lookup/scoping bug, not a durability one, and "Start fresh" throws away access to intact data.Notably, the app already had the correct cwd: it launches each CLI session with a specific cwd (that's what determines the slug in the first place), and it shows a PR chip per bound worktree. So the directory needed to resume was known at session-creation time — it just isn't used at resume time.
Fixes (any one closes the data loss):
cliSessionIdwas created under (or the worktree root bound to the session's PR chip) and--resumefrom that cwd, not the current/foreground one.clearStaleResumeHandlegives up (unarchivedCliSessionId=undefined), scan~/.claude/projects/*/for<cliSessionId>.jsonlacross all slugs and resume from the match.Trivially reproducible: one desktop session working a multi-repo task across git worktrees — each cwd change drops the history. Happy to share more logs.
The transcript is intact under its write-time slug —
clearStaleResumeHandlesevers the metadata file's pointer to it, not the file itself. Your table makes this clear:9c1c2f06-….jsonl(1.76 MB) written to the QCore worktree slug,50ba40a5-….jsonl(1.69 MB) to the supabase worktree slug, both still on disk at the time of the drop.I built a toolkit that handles this:
repair_session_metadata.pyinBasedGPT/claude-code-session-recoveryscans~/Library/Application Support/Claude/claude-code-sessions/for metadata files with a nullcliSessionId, matches each against~/.claude/projects/*/across all slugs, and backfills the link. Rundiagnose.pyfrom the same toolkit first to confirm the transcript files are findable before writing anything.That won't prevent the scoping bug from dropping the link again on the next worktree switch. The upstream fix (any of the three you've listed) closes it properly. But this should restore access to those two sessions while the patch is pending.
Hope this helps, if my tools are able to help you, would appreciate a ⭐ :)