[BUG] Entering a background agent thread hangs when the session is parked in AskUserQuestion (attach guard checks only `state`, ignores `tempo`/`block`)

Status Open
Reported on v2.1.221
Maintainer reply None cached
Activity 2 comments · opened Aug 4, 2026

Summary

Opening a background agent thread from the claude agents view hangs indefinitely (held screen, gated stdin, no way out but killing the terminal) when that job is parked mid-turn in an AskUserQuestion.

Root cause is a two-axis state mismatch: the job record tracks state and tempo independently, and the attach guard reads only state. AskUserQuestion is a mid-turn tool call, so the dialog writer sets tempo: "blocked" + needs + block: {questions} and never touches state, which stays at the "working" value stamped when the job record was created. The guard therefore passes and the client attaches to a session that cannot repaint or accept input from that surface.

A question asked in plain text at a turn boundary is classified correctly to state: "blocked", the guard fires, and you get the intended "That session is blocked — back to the list" bounce. Only the tool path breaks. On my machine five other blocked jobs behave correctly; the single job blocked via AskUserQuestion is the one that hangs, every time.

Version

  • Claude Code 2.1.221
  • macOS 26.5.2 (arm64)
  • Job launched as a daemon-hosted background agent (--agent claude --permission-mode bypassPermissions)

Steps to reproduce

  1. Start a background agent (claude --bg …, or dispatch one from claude.ai/code onto the machine).
  2. Have it call AskUserQuestion and leave the question unanswered.
  3. Open claude agents, select that thread, press enter.

Expected

Either the question renders and is answerable in the attached view, or the attach is refused with the existing "That session is blocked — back to the list" message so the question can be answered from the list.

Actual

The client attaches and hangs. Nothing renders, stdin is gated, the thread is unusable. Repeats on every attempt.

Evidence

1. The job record disagrees with itself. From ~/.claude/jobs/<short>/state.json on the stuck job:

{
  "state": "working",
  "tempo": "blocked",
  "needs": "answer: …",
  "block": { "questions": [ … ] },
  "inFlight": { "tasks": 1, "queued": 0, "kinds": ["monitor"] }
}

Every other blocked job on the same box reads "state": "blocked", "tempo": "blocked", no block object, and attaches (bounces) correctly.

2. The attach guard only tests state. From the 2.1.221 bundle:

async function F5f(e, t) {
  let r = await gNo(Ec(e));                                  // read state.json
  if (r === null) return { … "That session was removed — back to the list" };
  if (r.state === "done" || r.state === "stopped" || r.state === "blocked")
    return { … "That session is blocked — back to the list" };
  if (r.state === "failed") { … }
  return;                                    // falls through → G5f() attaches
}

G5f() then attaches with holdScreenOnDisconnect: true and gateStdinUntilFirstFrame, which is what turns "no frame arrives" into an unrecoverable-looking hang rather than an error.

3. Compounding symptom — the session also stops draining its queue. Queued input is gated on the modal:

function MLm({ executeQueuedInput: e, hasActiveLocalJsxUI: t, queryGuard: r }) {
  …
  if (n || r.isActive) return;
  if (t) return;              // active JSX UI (the question modal) blocks queued input
  if (o.length === 0) return;
  DLm({ executeInput: e });
}

So background-task notifications pile up undelivered. In my case a monitor task completed two minutes after the question was asked; fifteen minutes later the transcript still ended with two unconsumed type: "queue-operation" entries, state.json had not been written since before the question, and it still advertised inFlight: { tasks: 1 } for a task that had finished. The recap.trigger file the client drops on attach was also still sitting there nine minutes later, despite the session's own 500 ms poll — a peer job's trigger was consumed normally in the same window.

The session process itself is healthy throughout: alive, owning its rv/<short>.sock, main thread idle in kevent64, stack indistinguishable from a working session. Nothing has crashed — it is simply waiting on an answer it can never be given.

Suggested fix

Either:

  • have the attach guard treat tempo === "blocked" (or the presence of a block object) the same as state === "blocked", so the user is bounced to the list where the question is answerable; or
  • have the dialog writer set both axes when it parks a job on an interactive prompt.

The first is the smaller change and restores an already-working path — the list view renders block.questions and routes the answer over the daemon op: "reply" socket.

Workaround

Edit ~/.claude/jobs/<short>/state.json and set "state": "blocked". The guard then fires, the list view comes back, and the question can be answered normally. Live jobs run fine with state: "blocked", so this does not disturb the session.

Detection sweep, since the thread gives no warning before it hangs:

python3 - <<'EOF'
import json, glob, os
for p in glob.glob(os.path.expanduser('~/.claude/jobs/*/state.json')):
    s = json.load(open(p))
    if s.get('tempo') == 'blocked' and s.get('state') != 'blocked':
        print('STUCK', p.split('/')[-2], s.get('name'))
EOF

View original on GitHub ↗

This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗