Skip to content

summarize: agent timeout kills only the direct child, so a surviving grandchild blocks the call indefinitely #592

Description

@rymalia

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"
  • 2954command.process_group(0);
  • 3002kill_process_group(child_pid); before child.kill()
  • 3022kill_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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions