[BUG] Entering a background agent thread hangs when the session is parked in AskUserQuestion (attach guard checks only `state`, ignores `tempo`/`block`)
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
- Start a background agent (
claude --bg …, or dispatch one from claude.ai/code onto the machine). - Have it call
AskUserQuestionand leave the question unanswered. - 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 ablockobject) the same asstate === "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'))
EOFThis issue has 2 comments on GitHub. Read the full discussion on GitHub ↗