fix(review,investigate): kill agent process groups so Ctrl+C can't hang · Entire
fix(review,investigate): kill agent process groups so Ctrl+C can't hang
0f184b3→main·
dipree·2w ago·6 files·+130 added/-0 removed
Agent CLIs (codex, claude, ...) spawn helper grandchildren (sandbox, MCP servers) that inherit the agent's stdout pipe. exec.CommandContext's default Cancel only SIGKILLs the agent itself, so on Ctrl+C / reviewer timeout the grandchildren stay alive holding the pipe open. The reviewer template drains Events() (the stdout pipe) to EOF before calling Wait, so the read blocks forever, the per-agent goroutine never finishes, the fan-in channel never closes, and RunMulti hangs — the dashboard sits on "cancelling" indefinitely. Investigate's cmd.Run has the same exposure.
Add procutil.TerminateOnCancel (process-group SIGKILL on cancel + a WaitDelay backstop) and apply it on the agent spawn path in both the review template and the investigate loop. Mirrors the existing checkpoint/remote git-transport fix.
Sessions
15b7eec39aa5View transcript
Changes
6
cmd/entire/cli
investigate
- Mloop.go+4
procutil
- Aprocutil.go+25
- Aprocutil_unix.go+28
- Aprocutil_unix_test.go+58
- Aprocutil_windows.go+9
review/types
- Mtemplate.go+6
42 unmodified lines
43
44
45
46
47
48
49
262 unmodified lines
312
313
314
315
316
317
318
319
320
42 unmodified lines
"github.com/entireio/cli/cmd/entire/cli/agent/spawn"
"github.com/entireio/cli/cmd/entire/cli/logging"
"github.com/entireio/cli/cmd/entire/cli/procutil"
// LoopDeps collects the runtime-injectable hooks RunInvestigateLoop needs.
262 unmodified lines
StartingSHA: in.StartingSHA,
})
cmd := spawner.BuildCmd(ctx, env, prompt)
// Kill the agent's whole process group on cancel so a grandchild holding the
// pipe can't make cmd.Run block forever (Ctrl+C hang).
procutil.TerminateOnCancel(cmd)
// Agent stdout/stderr are captured by the lifecycle hooks into the
// session transcript (full.jsonl) and condensed onto
// Package procutil holds helpers for terminating spawned subprocesses and
// their descendants when a context is cancelled.
package procutil
import (
"os/exec"
"time"
)
// terminateWaitDelay backstops the wait after ctx-cancel so Wait/Run returns
// even if a wedged descendant keeps an output pipe open after the group kill.
const terminateWaitDelay = 5 * time.Second
// TerminateOnCancel makes cmd and its descendants die when cmd's context is
// cancelled, and guarantees Wait/Run returns even if a descendant keeps an
// output pipe open. Call after building cmd, before Start/Run.
//
// Agent CLIs (codex, claude, ...) spawn helper grandchildren (sandbox, MCP
// servers) that inherit the stdout pipe. exec.CommandContext's default Cancel
// only SIGKILLs the agent itself, leaving grandchildren alive with the pipe
// open — so a reader draining stdout to EOF blocks forever and Ctrl+C hangs.
func TerminateOnCancel(cmd *exec.Cmd) {
cmd.WaitDelay = terminateWaitDelay
killProcessGroupOnCancel(cmd)
}
//go:build unix
package procutil
import (
"fmt"
"os/exec"
"syscall"
)
// killProcessGroupOnCancel SIGKILLs the whole process group on ctx-cancel, so
// grandchildren that inherited the output pipe die too.
func killProcessGroupOnCancel(cmd *exec.Cmd) {
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.Setpgid = true
cmd.Cancel = func() error {
if cmd.Process == nil {
return nil
}
// Negative PID = whole group (leader pid == pgid). ESRCH = already exited.
if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); err != nil && err != syscall.ESRCH {
return fmt.Errorf("kill process group: %w", err)
}
return nil
}
}
//go:build unix
package procutil
import (
"bufio"
"context"
"os/exec"
"testing"
"time"
)
// A grandchild that inherits stdout keeps the pipe open after the parent exits.
// Without TerminateOnCancel, draining stdout to EOF blocks until the grandchild
// dies on its own (here ~60s). The group-kill on cancel must unblock it fast.
func TestTerminateOnCancel_UnblocksGrandchildHoldingPipe(t *testing.T) {
if _, err := exec.LookPath("sh"); err != nil {
t.Skip("sh not available")
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Parent prints "ready", then exits, leaving a backgrounded sleep that
// inherited the stdout pipe.
cmd := exec.CommandContext(ctx, "sh", "-c", "sleep 60 & echo ready")
TerminateOnCancel(cmd)
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatalf("stdout pipe: %v", err)
}
if err := cmd.Start(); err != nil {
t.Fatalf("start: %v", err)
}
r := bufio.NewReader(stdout)
if _, err := r.ReadString('\n'); err != nil {
t.Fatalf("read ready line: %v", err)
}
// Drain to EOF in the background, mirroring how the reviewer reads Events.
drained := make(chan struct{})
go func() {
_, _ = r.ReadString('\n') // blocks on the open pipe until cancel closes it
close(drained)
}()
cancel()
select {
case <-drained:
case <-time.After(15 * time.Second):
t.Fatal("stdout drain did not unblock after cancel — Ctrl+C would hang")
}
_ = cmd.Wait()
}
//go:build windows
package procutil
import "os/exec"
// killProcessGroupOnCancel is a no-op on Windows: reliable tree-kill needs a Job
// Object. The WaitDelay backstop still bounds the wait on a hung subprocess.
func killProcessGroupOnCancel(_ *exec.Cmd) {}