[Bug] Session state inconsistency after daemon upgrade: resume refuses completed background session
Bug Description
Title: --resume refuses a completed background session after a daemon self-upgrade restart (self-cleared after ~30 min)
---
Summary
After a /fork, a background session became unresumable: claude --resume <uuid> reported it was "currently running as a background agent (bg)" while claude agents listed the same session under Completed. The daemon log shows the session was killed and then respawned under the same id several minutes later, following a self-restart triggered by an auto-upgrade. The state cleared on its own roughly half an hour later and resume worked normally after that. Theoretically no data was lost, but the sessions history / data is a bit of a mess.
Filing this as a transient rather than a 100% repro — the upgrade timing may well be incidental, and I can't reliably reproduce it.
Environment
- Claude Code 2.1.222 (upgraded from 2.1.215 mid-session), npm global install
- Windows 10 Pro 19045
- Background session created via
/forkfrom an interactive session
What happened
- Ran
/forkin an interactive session; a background session started and ran to completion.
- Closed the interactive session with CTRL+X.
claude --resume <fork-parent-uuid>returned:
``claude agents
Session <uuid> is currently running as a background agent (bg).
Use to find and attach to it, or add --fork-session to branch off a copy.``
claude agents(TUI) listed that same session under Completed, not under running.
- Selecting it in the TUI showed
opening… · esc to canceland never completed (waited >1 min, repeatedly).
- Selecting the sibling background session showed
That session ended — back to the list.
- Roughly 30 minutes later,
--resumeon all sessions worked with no intervention.
Daemon log around the event
16:17:27 [bg] bg spawned <session-a> (slash)
16:54:32 [supervisor] binary at ...\claude.exe changed (mtime changed) — self-restarting for upgrade
16:54:32 [supervisor] shutting down (cause=upgrade, uptime=2460s, leases=2, live_workers=3)
16:54:39 [supervisor] ─── daemon start ─── version=2.1.222 pid=<X> origin=transient
16:54:39 [supervisor] another daemon won the lock race (pid=<Y>) — exiting
16:54:39 [bg] bg adopt: adopted=3 respawned=0 dead=0
16:59:47 [bg] bg settled <session-a> (killed)
17:02:49 [bg] bg spawned <spare-1> (spare)
17:06:23 [bg] bg spawned <session-a> (fleet)
17:08:11 [bg] bg settled <spare-1> (killed)
The line I found surprising is the last spawn of <session-a>: the same session id is spawned again after it was settled/killed, this time tagged (fleet) rather than (slash).
Corroborating state
At the time of the refusal:
~/.claude/jobs/<short-id>/state.jsoncontained"state": "running"alongside"firstTerminalAt": "…T16:59:45.806Z"— i.e. a terminal timestamp was recorded, but the state field readrunning.updatedAtwas17:06:28, matching the respawn.- A sibling background session that behaved normally had a self-consistent record:
"state": "done"withfirstTerminalAt == updatedAt. - A live process existed for the refused session (
--bg-pty-host …-pty-<short-id>with a childclaude --resume …<uuid>.jsonl --model opus[1m]), started at the respawn time. It was still running hours later, idle, having accumulated CPU time. daemon.status.jsonread"workers": {}while the daemon process had two live pty-host children.- The named pipes
cc-daemon-<id>-pty-<short-id>and-rv-<short-id>both existed.
Expected vs actual
Expected: a session that claude agents shows as Completed can be resumed with --resume, or the TUI can attach to it.
Actual: --resume refused, TUI attach hung indefinitely, and the only suggested alternatives were claude agents (which cannot release a session) and --fork-session (which works, but abandons the original session id).
Notes and leads (inferred — treat with caution)
I looked at the installed binary's strings to understand the refusal, so the following is inference from minified code, not verified by execution, and may be wrong in detail:
- The refusal appears to consult a per-process registry under
~/.claude/sessions/<pid>.jsonrather than thejobs/*/state.jsonrecord that the TUI displays — which would explain the two views disagreeing. - The liveness check appears to treat an indeterminate result as alive: if the recorded process start time is absent, or the re-query of the process's creation time returns nothing, the comparison seems to return a match. On Windows that re-query is a
Get-CimInstance Win32_Processcall, which legitimately returns nothing when the PID no longer exists. - The - cleanup that unlinks stale registry entries appears to run only when the cheap PID-existence probe fails, which would be the opposite case from the one above.
If any of that is off-base, the observed timeline above stands on its own.
Reproduction
Not reliably reproducible. It required an auto-upgrade to fire while a /fork-created background session was active, followed by the daemon respawning that session after settling it. I have not attempted to force this.
Minor, possibly related
claude agents --helplists only options for dispatching sessions; the commands that manage existing ones (claude attach,logs,stop,rm,respawn) are top-level and are not mentioned in the refusal message, which points users atclaude agents.claude rmin particular documents itself as working on already-exited sessions, which would have been the obvious remedy had I known it existed.- Resuming by session name is ambiguous when several forks share a name — after a fork, three of my sessions shared one name, and the exit hint printed on session close suggests
claude --resume "<name>".
Impact
Seemingly no data loss — all transcripts seem to be intact and appended-only throughout. The practical cost was ~30 minutes of a session appearing unreachable, plus a background process holding a model session open indefinitely.