Summary
summarize_with_agent_impl_timeout enforces summarization.agent_timeout_secs by killing the spawned agent when the deadline passes. It kills only the direct child, not the child's process group. A grandchild that outlives it inherits the stdout pipe and holds it open, so the reader thread's join() blocks until that grandchild exits — long past the configured timeout.
The result is that agent_timeout_secs is not a hard bound on how long summarization can block.
Line references are against 7a61dd57 (current main at time of filing).
Where
crates/core/src/summarize.rs, timeout branch of summarize_with_agent_impl_timeout (fn at 1682, branch at 1820):
if start.elapsed() > timeout {
child.kill().ok();
let _ = child.wait();
let _ = stdout_handle.join(); // <-- blocks while a grandchild holds the pipe
let _ = stderr_handle.join();
stdout_handle / stderr_handle are real reader threads spawned at 1760 / 1766. Killing the direct child does not close the pipe, because the surviving grandchild still holds the write end.
The same file already contains the correct pattern. run_speaker_mapping_via_agent puts its agent in its own process group precisely so a timeout can kill the whole tree:
2948 — comment: "Put the agent in its own process group so a timeout can kill the WHOLE tree"
2954 — command.process_group(0);
3002 — kill_process_group(child_pid); before child.kill()
3022 — kill_process_group helper (with a non-Unix no-op stub at 3037)
The summarize path does none of this.
Secondary, milder site
run_title_refinement_via_agent (fn at 2600, timeout branch at 2660) also kills only the direct child. It does not hang — the thread it spawns is a stdin writer and the timeout branch never joins it, so it returns on schedule — but it still leaks orphaned grandchildren. Worth fixing in the same pass, though it is not the blocking bug.
Reproduction
An "agent" that spawns a child surviving the shell being killed:
printf '#!/bin/sh\ncat >/dev/null 2>&1\nsleep 900\n' > /tmp/hang-engine.sh
chmod +x /tmp/hang-engine.sh
~/.config/minutes/config.toml:
[summarization]
engine = "agent"
agent_command = "/tmp/hang-engine.sh"
agent_timeout_secs = 30
Then trigger any summarization.
Observed: at 30s the shell is killed, but ps shows sleep 900 reparented to PID 1 and still running. The summarize call remained blocked 4+ minutes later. Killing the orphan by hand released it immediately.
Control: changing the script's last line to exec sleep 600, so no grandchild survives, makes the timeout behave exactly as documented — the call returns at the deadline. The only difference between the two runs is whether a grandchild outlives the direct child.
Why it matters
Real agent CLIs spawn child processes (MCP servers, tool subprocesses), so this is reachable in normal use rather than only with a synthetic script.
- CLI:
minutes process / minutes resummarize can block well past agent_timeout_secs.
- Desktop: any UI that serialises on summarization stays wedged for the duration. I hit this while building a desktop Resummarize button, where a single in-flight guard is held app-wide — every meeting's button stays disabled until the orphan exits, with no recovery short of quitting the app.
A timeout on the caller side is not a fix: whatever holds the guard must outlive any task that could still write to the artifact, or two runs race on the same file. The fix belongs here, in core.
Suggested fix
Implementation note: See the follow-up comment on this issue before implementing. Output collection must be bounded after both a timeout and a successful direct-child exit; the Unix process-group pattern is not sufficient by itself.
Mirror run_speaker_mapping_via_agent: spawn the agent as a process-group leader (command.process_group(0)) and, on timeout, call the existing kill_process_group(child_pid) before child.kill(). The helper and the non-Unix stub already exist, so this is mostly wiring.
Dropping the pipe read handles before join() — or joining with a deadline — would be a complementary guard, so a stuck reader can never outlive the timeout even if a process escapes the group.
Summary
summarize_with_agent_impl_timeoutenforcessummarization.agent_timeout_secsby killing the spawned agent when the deadline passes. It kills only the direct child, not the child's process group. A grandchild that outlives it inherits the stdout pipe and holds it open, so the reader thread'sjoin()blocks until that grandchild exits — long past the configured timeout.The result is that
agent_timeout_secsis not a hard bound on how long summarization can block.Line references are against
7a61dd57(currentmainat time of filing).Where
crates/core/src/summarize.rs, timeout branch ofsummarize_with_agent_impl_timeout(fn at1682, branch at1820):stdout_handle/stderr_handleare real reader threads spawned at1760/1766. Killing the direct child does not close the pipe, because the surviving grandchild still holds the write end.The same file already contains the correct pattern.
run_speaker_mapping_via_agentputs its agent in its own process group precisely so a timeout can kill the whole tree:2948— comment: "Put the agent in its own process group so a timeout can kill the WHOLE tree"2954—command.process_group(0);3002—kill_process_group(child_pid);beforechild.kill()3022—kill_process_grouphelper (with a non-Unix no-op stub at3037)The summarize path does none of this.
Secondary, milder site
run_title_refinement_via_agent(fn at2600, timeout branch at2660) also kills only the direct child. It does not hang — the thread it spawns is a stdin writer and the timeout branch never joins it, so it returns on schedule — but it still leaks orphaned grandchildren. Worth fixing in the same pass, though it is not the blocking bug.Reproduction
An "agent" that spawns a child surviving the shell being killed:
~/.config/minutes/config.toml:Then trigger any summarization.
Observed: at 30s the shell is killed, but
psshowssleep 900reparented to PID 1 and still running. The summarize call remained blocked 4+ minutes later. Killing the orphan by hand released it immediately.Control: changing the script's last line to
exec sleep 600, so no grandchild survives, makes the timeout behave exactly as documented — the call returns at the deadline. The only difference between the two runs is whether a grandchild outlives the direct child.Why it matters
Real agent CLIs spawn child processes (MCP servers, tool subprocesses), so this is reachable in normal use rather than only with a synthetic script.
minutes process/minutes resummarizecan block well pastagent_timeout_secs.A timeout on the caller side is not a fix: whatever holds the guard must outlive any task that could still write to the artifact, or two runs race on the same file. The fix belongs here, in core.
Suggested fix
Mirror
run_speaker_mapping_via_agent: spawn the agent as a process-group leader (command.process_group(0)) and, on timeout, call the existingkill_process_group(child_pid)beforechild.kill(). The helper and the non-Unix stub already exist, so this is mostly wiring.Dropping the pipe read handles before
join()— or joining with a deadline — would be a complementary guard, so a stuck reader can never outlive the timeout even if a process escapes the group.