Summary
When Claude Code exits, any child process it spawned that still holds a copy of the stdout pipe prevents Trinity's reader thread from seeing EOF. The reader thread waits, times out after 30s, force-closes, and loses the final result block. Trinity reports the execution as failed with "0 tool calls" even though tools were actually called. This has been causing persistent false failures across multiple agents.
Component
Agent Runtime / agent-server subprocess management
Priority
P1
Error
WARNING:agent_server.utils.subprocess_pgroup:[Subprocess] Reader thread(s) still busy after process exit (pid=XXXXX, stuck_count=1) — killing process group, then waiting 30s for natural drain
ERROR:agent_server.utils.subprocess_pgroup:[Subprocess] Reader thread(s) still stuck after 30s post-kill grace — force-closing pipes; some buffered data may be lost (pid=XXXXX, stuck_count=1)
ERROR:agent_server.utils.subprocess_pgroup:[Subprocess] 1 reader thread(s) leaked for pid=XXXXX after force-close; continuing anyway
ERROR:agent_server.services.claude_code:[Headless Task] Execution completed without a result message after 0 tool calls / N turns (raw_messages=M). Likely cause: a tool or child subprocess inherited stdout and prevented the claude reader thread from capturing the final result block.
ERROR:agent_server.services.claude_code:[Headless Task] Error reading stdout: I/O operation on closed file.
Root Cause
When the agent-server spawns claude --print --output-format stream-json, it creates a stdout pipe to read the streaming result. Two categories of child processes can inherit this pipe fd and keep it alive after Claude exits:
1. Async Stop hooks ("async": true in ~/.claude/settings.json)
Claude Code spawns Stop hook scripts as background child processes before exiting. If these inherit fd 1 (the Trinity pipe), they hold the pipe open for their entire run. Example: a git-sync hook that runs git push in the background after the session ends keeps the pipe open until the push completes (or forever if it hangs).
2. Process-based MCP servers (entries with "command" in .mcp.json)
Claude Code spawns MCP servers as child processes (npx, uv tool run, etc.). Even though their stdout is redirected to a dedicated JSON-RPC pipe, they may inherit the parent Claude process's original stdout fd (the Trinity pipe) if FD_CLOEXEC is not set on it before the exec. These processes run for the duration of the session and may not release inherited fds promptly on shutdown.
In both cases, the fix is to ensure the Trinity stdout pipe fd has FD_CLOEXEC set before Claude Code spawns any children, so inherited file descriptors are automatically closed on exec.
Reproduction Steps
Via async Stop hook (most reproducible):
- Configure an agent with
~/.claude/settings.json:
{ "hooks": { "Stop": [{ "hooks": [{ "type": "command", "command": "sleep 60", "async": true }] }] } }
- Trigger a scheduled execution on that agent
- Observe:
Reader thread(s) still busy after process exit in agent container logs
- Observe: execution recorded as failed with
0 tool calls in schedule_executions table, despite tools being used
Via process-based MCP server:
- Configure an agent with
.mcp.json:
{ "mcpServers": { "my-server": { "command": "npx", "args": ["-y", "some-mcp-server"] } } }
- Trigger an execution that uses MCP tools
- Observe intermittent
Reader thread stuck failures after execution completes
Suggested Fix
In agent_server/utils/subprocess_pgroup.py (or wherever claude --print is spawned):
Before spawning the Claude subprocess, mark the write end of the stdout pipe with FD_CLOEXEC so it is automatically closed in any child Claude spawns:
import fcntl, os
# When creating the pipe for Claude's stdout:
read_fd, write_fd = os.pipe()
# Mark write end close-on-exec so Claude's children don't inherit it
flags = fcntl.fcntl(write_fd, fcntl.F_GETFD)
fcntl.fcntl(write_fd, fcntl.F_SETFD, flags | fcntl.FD_CLOEXEC)
# Then spawn claude with stdout=write_fd
Or equivalently, if using Python's subprocess.Popen:
proc = subprocess.Popen(
["claude", "--print", "--output-format", "stream-json", ...],
stdout=subprocess.PIPE,
# pass_fds=() ensures no extra fds are inherited beyond 0/1/2
# close_fds=True (default on POSIX) handles fds >= 3
# The issue is that fd 1 of the *parent* may be inherited if not handled
)
The key invariant: the Trinity reader pipe fd must not be inheritable by anything Claude Code launches.
Agent-level workaround (for async Stop hooks):
Add exec >/dev/null at the start of the hook script body (after reading stdin). This immediately releases the inherited pipe fd in the background process.
Impact
- Executions recorded as
failed in schedule_executions with tool_calls = 0 even when tools ran
message column shows the error text; no execution log captured
- Agents with frequent changes (triggering git-sync hooks on every session) or process-based MCP servers fail the majority of their scheduled runs
- Retry logic re-runs the full task, doubling API cost and execution time
Environment
- Trinity version:
f199383
- Docker: 24.x
- OS: Ubuntu 22.04
Related
agent_server/utils/subprocess_pgroup.py — reader thread + process group management
agent_server/services/claude_code.py — headless task execution, result parsing
- The error message in
claude_code.py already correctly diagnoses the cause; this issue is to fix it at the source
Summary
When Claude Code exits, any child process it spawned that still holds a copy of the stdout pipe prevents Trinity's reader thread from seeing EOF. The reader thread waits, times out after 30s, force-closes, and loses the final result block. Trinity reports the execution as failed with "0 tool calls" even though tools were actually called. This has been causing persistent false failures across multiple agents.
Component
Agent Runtime / agent-server subprocess management
Priority
P1
Error
Root Cause
When the agent-server spawns
claude --print --output-format stream-json, it creates a stdout pipe to read the streaming result. Two categories of child processes can inherit this pipe fd and keep it alive after Claude exits:1. Async Stop hooks (
"async": truein~/.claude/settings.json)Claude Code spawns Stop hook scripts as background child processes before exiting. If these inherit fd 1 (the Trinity pipe), they hold the pipe open for their entire run. Example: a git-sync hook that runs
git pushin the background after the session ends keeps the pipe open until the push completes (or forever if it hangs).2. Process-based MCP servers (entries with
"command"in.mcp.json)Claude Code spawns MCP servers as child processes (
npx,uv tool run, etc.). Even though their stdout is redirected to a dedicated JSON-RPC pipe, they may inherit the parent Claude process's original stdout fd (the Trinity pipe) ifFD_CLOEXECis not set on it before the exec. These processes run for the duration of the session and may not release inherited fds promptly on shutdown.In both cases, the fix is to ensure the Trinity stdout pipe fd has
FD_CLOEXECset before Claude Code spawns any children, so inherited file descriptors are automatically closed on exec.Reproduction Steps
Via async Stop hook (most reproducible):
~/.claude/settings.json:{ "hooks": { "Stop": [{ "hooks": [{ "type": "command", "command": "sleep 60", "async": true }] }] } }Reader thread(s) still busy after process exitin agent container logs0 tool callsinschedule_executionstable, despite tools being usedVia process-based MCP server:
.mcp.json:{ "mcpServers": { "my-server": { "command": "npx", "args": ["-y", "some-mcp-server"] } } }Reader thread stuckfailures after execution completesSuggested Fix
In
agent_server/utils/subprocess_pgroup.py(or whereverclaude --printis spawned):Before spawning the Claude subprocess, mark the write end of the stdout pipe with
FD_CLOEXECso it is automatically closed in any child Claude spawns:Or equivalently, if using Python's
subprocess.Popen:The key invariant: the Trinity reader pipe fd must not be inheritable by anything Claude Code launches.
Agent-level workaround (for async Stop hooks):
Add
exec >/dev/nullat the start of the hook script body (after reading stdin). This immediately releases the inherited pipe fd in the background process.Impact
failedinschedule_executionswithtool_calls = 0even when tools ranmessagecolumn shows the error text; no execution log capturedEnvironment
f199383Related
agent_server/utils/subprocess_pgroup.py— reader thread + process group managementagent_server/services/claude_code.py— headless task execution, result parsingclaude_code.pyalready correctly diagnoses the cause; this issue is to fix it at the source