Background agent wedged at `opening…` forever when its pty host becomes a zombie
Summary
A background agent can become permanently unopenable. Selecting it in the agent list shows opening… forever, with no timeout and no error. The cause is that the worker's pty host process died and became a zombie (<defunct>) that the daemon never reaped. A zombie still satisfies kill -0 and still has a readable /proc/<pid>/stat, so the daemon's liveness check treats the dead worker as healthy and keeps handing the UI a pty socket that no process is accepting on.
The job is unrecoverable through the UI. esc cancels the attach, reopening reproduces it, and there is no timeout that would ever surface an error.
Environment
- Claude Code 2.1.241 (native install)
- Linux 6.8.0-138-generic, x86_64
- Daemon-backed background agents (
claude daemon run --origin transient) - Job was created via fleet dispatch,
--permission-mode auto,--model opus[1m]
Evidence
The daemon roster (~/.claude/daemon/roster.json) recorded the worker as live:
"b7a0ec85": {
"pid": 9658,
"procStart": "82693",
"ptySock": "/tmp/cc-daemon-1000/<id>/spare/fff834d5.pty.sock",
...
}
But that pid was a zombie, and its own child (the session process) had been orphaned under it:
PID PPID STAT COMMAND
9658 9625 ZNsl [2.1.241] <defunct> <-- roster pid for the worker, parented to the daemon
9709 9658 ZN [2.1.241] <defunct>
The zombie passes the checks that the roster fields imply are used for liveness:
kill -0 9658 -> succeeds
awk '{print $22}' /proc/9658/stat -> 82693 (matches roster "procStart": "82693")
The recorded pty socket was still present on disk and still in LISTEN, but with no owning process and a connection stuck unaccepted in its backlog. Compare the wedged worker against two healthy ones:
u_str LISTEN 0 512 .../spare/94c19990.pty.sock 55422 * 0 users:(("2.1.241",pid=10380,fd=13))
u_str LISTEN 0 512 .../spare/5784f798.pty.sock 231867 * 0 users:(("2.1.241",pid=100452,fd=13))
u_str LISTEN 1 512 .../spare/fff834d5.pty.sock 49783 * 0
^ ^
| no users: nothing owns this socket
one connection queued, never accepted
Scanning every /proc/*/fd for the socket's inode (49783) returned no holder. So connect() from the UI succeeds at the kernel level, which is why there is no connection error, and then blocks forever because nothing ever calls accept().
Inferred mechanism
I did not read the daemon source, so the following is inference from the observed state, but it accounts for every symptom:
- The pty host process for a background worker exits.
- The daemon (its parent) does not
wait()on it, so it stays a zombie rather than disappearing. - Liveness is checked by pid existence plus a start-time match. A zombie satisfies both, since
/proc/<pid>persists until reaping. - The daemon therefore keeps the worker in the roster as healthy and, on attach, gives the UI the recorded
ptySock. - The UI connects successfully but hangs, because the accept loop died with the host.
The load-bearing part is step 3: a pid check cannot distinguish a live process from an unreaped zombie. /proc/<pid>/stat field 3 (state Z) does distinguish them.
Impact
The background job is permanently wedged, with no in-product path to recover it or even to see that anything is wrong. The conversation is not lost, but recovering it requires knowing to resume the session directly from the transcript, which is not discoverable from the UI.
Suggested fixes
- Treat state
Zin/proc/<pid>/statas dead in the worker liveness check, not just pid existence plus start time. Reaping is the real fix, but this check is cheap and correct even when reaping is missed. - Reap child pty hosts (
SIGCHLDhandler or an explicitwaitpidloop), so zombies do not accumulate under the daemon in the first place. - Add a timeout to the pty attach. An attach that cannot complete should surface an error and offer respawn rather than displaying
opening…indefinitely. - Optionally, verify a recorded
ptySockactually has an owning listener before handing it out.
Workaround
Kill the orphaned session process, remove the stale socket, and resume the session directly:
claude -r <session-uuid>
The transcript in ~/.claude/projects/<project>/<session-uuid>.jsonl is intact and resumes with full history.
Related, likely a separate bug
The same worker's roster entry had launch.transcriptPath pointing into a project directory derived from a git worktree the session had entered mid-run:
~/.claude/projects/<project>--claude-worktrees-<branch>/<session-uuid>.jsonl
That worktree was later removed, taking the directory with it, while the session's actual transcript continued in the main project directory. The recorded resume path was left dangling with "restoresTranscript": true, so a respawn would have tried to restore a transcript that no longer exists. Happy to file this separately if preferred.