[BUG] Agents view: reopening a stopped background agent dispatches a blank `mode: prompt` worker — same session ID, intact transcript never loaded, original prompt re-injected

Status Fixed / completed
Reported on v2.1.207
Maintainer reply None cached
Activity 3 comments · opened Jul 12, 2026 · closed Jul 16, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet (closest: #75929, which covers the worktree-relocation trigger of the same blank-resume family; the 2.1.207 fix for that variant does not cover this one)
  • [x] This is a single bug report
  • [x] I am using the latest version of Claude Code

What's Wrong?

Summary

When a background (daemon) agent settles as killed (user stops it in the agents view, or the initial handoff worker dies) and the user then reopens/resumes it from the agents view, the reclaim dispatches a fresh mode: "prompt" worker with no resume target instead of resuming the session. The worker comes up with an empty conversation reusing the same session ID, re-injects the job's original intent as a new user prompt, and appends onto (or collides with, producing .orphaned-* stubs next to) the fully intact transcript. To the user, a long-running conversation "clears out" with no error.

The transcript is intact on disk the whole time — claude --resume <sessionId> from a terminal restores the full conversation. This is a dispatch bug, not data loss.

Environment

  • macOS arm64 (Darwin 25.5.0)
  • Daemon backend; reproduced on daemon v2.1.206 (2026-07-10) and v2.1.207 (2026-07-12) — so the 2.1.207 changelog fix ("background sessions that entered a git worktree resuming blank after a cold reopen") does not cover this path. No worktree involved; transcript never relocated.

Observed timeline (daemon.log, v2.1.207, 2026-07-12)

[2026-07-12T14:30:37.558Z] [bg] bg spawned 8984c5ed (slash)        <- background handoff from an interactive session
[2026-07-12T14:31:16.510Z] [bg] bg settled 8984c5ed (killed)       <- user stops it in the agents view; moves to Completed
[2026-07-12T14:31:26.309Z] [bg] bg claimed-spare 8984c5ed (fleet)  <- user reopens it from the agents view
[2026-07-12T14:31:35.507Z] [bg] bg settled 8984c5ed (killed)       <- comes up blank; killed/interrupted again

Identical sequence on v2.1.206 two days earlier (job 45cc2ff8): spawned (slash) 14:22:04 -> settled (killed) 14:22:08 -> claimed-spare (fleet) 14:22:09 -> blank.

Dispatch evidence (daemon/roster.json)

The initial handoff spawn is dispatched correctly and forks the parent conversation:

launch: { mode: "resume", sessionId: "<parent>.jsonl", fork: true }   source: slash

The fleet reclaim of the same job after it settled is dispatched with no resume at all:

launch: { mode: "prompt", fork: null }   source: fleet    <- no session/transcript target

state.json's sessionId/resumeSessionId are correct and the child .jsonl exists with full forked history (in the 45cc2ff8 case: 291KB, 9 user / 11 assistant messages, same project dir as the job cwd) — the reclaim simply never consults them.

Effect on the transcript

  • The blank worker adopts the same session ID and appends fresh metadata (ai-title, agent-name, mode, permission-mode) plus a re-injected copy of the job's original intent as a new user turn onto the intact transcript.
  • In some occurrences the collision instead produces a <sessionId>.orphaned-<ts>-<hash>.jsonl stub containing only ai-title/agent-name lines.
  • A related earlier variant (observed 2026-07-06 on v2.1.199/201, and 2026-07-08): the initial handoff worker itself is settled (killed) seconds after spawn, before the fork ever writes assistant turns — the subsequent fleet reclaim then shows the same amnesia.

What Should Happen?

Any of:

  1. A fleet reclaim of a job whose session .jsonl exists resumes it (mode: resume), exactly like the initial slash dispatch does; or
  2. the reclaim honors resumeSessionId from the job's state.json; or
  3. at minimum, reopening fails loudly instead of silently starting an empty conversation under the same session ID and re-running the original prompt.

Steps to Reproduce

  1. In an interactive session with some history, background it (agents-view handoff) so a daemon bg job is created; verify the child session .jsonl contains the forked history.
  2. Stop the agent in the claude agents view (it settles killed and moves to Completed).
  3. Reopen it from the agents view.
  4. The conversation is blank; daemon/roster.json shows the new worker dispatched mode: "prompt" with no resume target; the original intent is re-injected as a fresh prompt; the historied .jsonl is intact on disk.

Claude Model

Opus

Is this a regression?

Unknown — observed on 2.1.199 through 2.1.207.

Claude Code Version

2.1.207 (Claude Code)

Platform

Anthropic API

Operating System

macOS

Additional Information

Recovery that works today (may help other users hitting "my background agent cleared out"): find the job's sessionId in ~/.claude/jobs/<short>/state.json and run claude --resume <sessionId> in a terminal — full history loads. If the child transcript is empty (the initial worker died before forking), the history is still in the parent session's .jsonl; it can be located by grepping ~/.claude/projects/ for the job's intent string.

🤖 Generated with Claude Code

View original on GitHub ↗

3 Comments

MySethPorter · 3 months ago

Update: I read the reopen path in the 2.1.207 binary and my original framing above ("the reclaim never consults the transcript") is wrong — I want to correct it, because the real mechanism is more specific and probably easier for you to fix.

The resume logic is already there and looks right. The view-side respawn helper (the one emitting tengu_bg_respawn) does:

let b = await Uke(d, c.cwd, c.linkScanPath);   // resolve transcript for session d
let w = b.hasMessages;
...
let H = w && !x;                                // x = exec-intent case
let $ = x ? void 0 : t?.initialPrompt ?? c.queuedPrompt ?? ((w || M) ? void 0 : n.intent);
let D = [...(H ? ["--resume", d] : []), ...P, ...($ ? ["--", $] : [])];

So --resume <sessionId> is prepended whenever the resolved transcript reports hasMessages, and the intent re-injection is explicitly the fallback for when it does not. Uke searches linkScanPath, every project dir, the computed path, and cross-worktree project dirs before returning hasMessages: false.

So the defect is that hasMessages returns false for a transcript that demonstrably has messages. Every occurrence I captured shows the reopen happening 1–2 seconds after the worker was killed:

[2026-07-12T14:31:16.510Z] [bg] bg settled 8984c5ed (killed)
[2026-07-12T14:31:26.309Z] [bg] bg claimed-spare 8984c5ed (fleet)   <- reopen

and in the 2.1.206 case the gap between the initial worker's death and the reclaim was ~1s (settled (killed) 14:22:08 → claimed-spare (fleet) 14:22:09). The transcript on disk afterwards is intact and non-empty (291KB, 9 user / 11 assistant turns) — but at the instant the reopen ran, hasMessages evidently evaluated false, so the code took the mode:"prompt" + re-inject-intent branch, which is exactly the observed blank session.

That points at a race between the respawn's transcript probe and the dying worker's flush/close of the .jsonl (or Tel() reading a transcript whose messages are not yet durable), rather than at a missing resume argument. Note that the no-transcript branch also calls oyt(b.path) (quarantine) — consistent with the .orphaned-* stubs appearing next to the real transcripts in the failures above.

Suggested angles, in case they're useful: await/confirm the killed worker's transcript flush before probing (the code already waits up to 3s for the process to disappear via yCp, but the probe path taken when _ / "ceremony skipped" is true appears to bypass that wait), and/or treat "state.json has a resumeSessionId and the transcript file exists but reads as empty" as retry-worthy rather than as "no conversation, start fresh with the original intent."

Environment unchanged from the report above (macOS arm64, daemon backend, no worktree involved).

rpelevin · 3 months ago

The corrected mechanism points to a useful fail-closed rule: transcript state that is not yet durable is not evidence that the conversation is empty.

I would make the reopen path distinguish at least three outcomes:

  • resumable: the transcript has a durable message boundary and can be claimed with --resume;
  • empty-confirmed: the transcript is durably empty, so prompt fallback may start a fresh session;
  • resume-state-uncertain: the prior worker is killed but transcript durability is unresolved, so retry the probe or fail visibly.

The uncertain branch should never re-inject the original intent and should never start mode: prompt under the prior session id. If prompt fallback is eventually valid, it should receive a fresh session id so one identity cannot describe two dispatch histories.

Regression coverage I would keep:

  1. Kill and reopen at 0s, 1s, and 2s with a delayed transcript flush; no path re-injects intent while durability is uncertain.
  2. A first empty probe followed by a non-empty durable read retries into resume rather than prompt fallback.
  3. A durably empty transcript may start a prompt worker only under a fresh session id.
  4. Two concurrent reopen attempts produce one winning resume claim and one no-effect/duplicate result.
  5. The resume receipt binds job id, prior session id, transcript ref or committed offset, dispatch mode, and new worker id.

That turns the race from silent amnesia into an explicit recoverable state.

Boundary: architecture and regression-test feedback only. I did not reproduce this report or validate Claude Code, daemon behavior, transcript durability, implementation correctness, security, production readiness, official alignment, adoption, partnership, integration, customer interest, or Neura usage.

github-actions[bot] · 1 month ago

This issue has been automatically locked since it was closed and has not had any activity for 7 days. If you're experiencing a similar issue, please file a new issue and reference this one if it's relevant.