SikiT

Field notes on running AI agents without a human in the loop.

Field notes on unattended AI agents.

5–7 minutes

Your agent timeout kills the agent and leaves its grandchildren

A timeout that kills the agent PID, or the PID plus its direct children, leaves anything one level deeper still running. Four measured variants on the same three-level process tree.

A table of four cleanup strategies against the same process tree. Killing the PID, and killing the PID plus its direct children on a three-level tree, both left the deepest process running.

Sources are linked in this article. Found an error? Report a correction.

An agent CLI rarely runs alone. It starts a shell, the shell starts an interpreter, and the interpreter starts something else. When a timeout fires and the wrapper kills the agent, the depth at which the cleanup stops decides how much of that tree keeps running. I measured four cleanup strategies against the same three-level tree. Two of them left the deepest process alive and reported nothing.

On this page

Confirm the symptom: processes older than their run

The tell is a process whose start time predates the run record that should have owned it. It is still holding a file handle, still appending to a log, still counted against whatever quota it consumes, and no scheduler thinks it exists.

Reproducing it takes three scripts, each starting the next in the background and then waiting:

lvl1.sh  →  lvl2.sh  →  lvl3.sh

Only lvl3.sh writes a heartbeat line every second. That way the log answers one question directly: did the cleanup reach the bottom?

Safest checks before changing the cleanup

Measure the depth you actually have before assuming it is the problem.

  1. Count the levels in a real run, not a diagram. pgrep -fl during a live agent run shows what the tool actually spawns. An agent that shells out to a language runtime is already three levels deep, and that is the common case rather than the exotic one.
  2. Check the process group, not just the parent chain. A cleanup keyed on parent-child relationships and a cleanup keyed on the process group behave differently the moment anything re-parents, and re-parenting is what happens when an intermediate process exits first.

Both checks take one command during a run you are already doing. Guessing at the shape of the tree is how a cleanup ends up one level short.

Find the cause: cleanup depth, measured

Terminal output from a re-run of four timeout cleanups. Killing only the agent PID leaves the deepest process ticking; pkill -P then kill is clean on a two-level tree but leaks on a three-level tree; setsid at launch followed by killpg leaves no heartbeat after the cleanup.
Re-run on October 5, 2026 in Terminal on macOS 27.0.1. The heartbeat line is the deepest process; a heartbeat after the cleanup line means the cleanup did not reach it.

Four variants against the identical tree. In each case the timeout fires three seconds in, and the log is read four seconds after that.

CleanupReachedDeepest process
kill <pid>level 1still ticking
pkill -P <pid> then kill <pid> on a two-level treelevels 1–2stopped
pkill -P <pid> then kill <pid> on a three-level treelevels 1–2still ticking
setsid at launch, then killpg(<pgid>)whole groupstopped

The second and third rows are the same cleanup code. The only thing that changed is how deep the tree was, and that is enough to flip the outcome.

pkill -P matches processes whose parent is the given PID. One level. It is a real improvement over killing the PID alone, which is why it feels like the fix, and it stops exactly one level short of a tree that has an interpreter in the middle.

In the failing run the log kept going after the cleanup:

02:58:44 L3 great-grandchild started pid=39759 ppid=39754

02:58:45 L3 still alive tick 1
...
02:58:51 L3 still alive tick 6

Six seconds after the wrapper considered the run terminated, the bottom of the tree was still writing.

Why depth is the wrong thing to reason about

Every parent-walking cleanup has to answer "how deep?", and the honest answer is that you do not control it. The agent decides what to spawn. A tool upgrade can add a level. A prompt that asks for a different kind of work can add a level for that run only.

A process group does not have that parameter. Every descendant inherits the group unless something deliberately leaves it, so one signal covers the tree whatever shape it took.

Fix the cause: give the run its own process group

Two steps, and the first is the one people skip.

Start the agent in a new process group. Signalling a group you did not create means signalling yourself, so the group has to be established at launch:

import os, signal, subprocess

proc = subprocess.Popen(argv, preexec_fn=os.setsid)
...
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)

Then escalate on a deadline. SIGTERM to the group, wait a grace period, SIGKILL to the group if anything remains. Send both to the group, not to the PID: a process that ignored the first signal is exactly the one whose children you still need to reach.

In the measured run this collapsed the whole tree. The heartbeat stops at tick 2, which is when the signal landed, and pgrep finds nothing afterward.

For scheduled work, a supervisor removes the question entirely. A launchd job owns its process group and tears it down on its own terms, which is why the scheduled jobs behind this site do not implement any of this themselves.

Check again: assert the tree is gone

A timeout that reports success without checking is the same class of problem as the one it is trying to fix.

After the grace period, have the wrapper confirm the group is empty and treat a survivor as a failed cleanup with its own exit code. "Timed out and cleaned up" and "timed out and leaked" need different follow-up, and only the second one requires a human.

Run the three-level reproduction against your own wrapper rather than trusting the code review. It takes a minute and it answers the only question that matters, which is how deep your cleanup actually reaches today.

Limits and evidence: four runs on macOS, untested on Linux

macOS, 2026-08-28, four runs. pkill -P semantics are the same on Linux, so I expect the depth result to carry, but I did not verify it there and process-group behavior around orphaned groups differs enough between platforms that I would re-run rather than assume.

The three-level tree here is synthetic. It reproduces the shape that agent CLIs produce, not a specific tool’s behavior, and I did not instrument a real agent run to count its levels. If you want that number for your own setup, pgrep -fl during a run gives it in one command.

I also did not test a process that ignores SIGTERM, or one that re-parents itself deliberately. Both defeat parts of what is described here, and both are rare enough that I have not needed to handle them.

Sources

  • kill — The Open Group Base Specifications, on signalling a process group with a negative PID
  • setsid — The Open Group Base Specifications

Related articles

Stay in the loop

Get new agent guides with their test conditions and limitations. Unsubscribe anytime.

Comments

Questions, corrections, and useful counterpoints are welcome. Keep comments specific and on topic.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.