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.
- Count the levels in a real run, not a diagram.
pgrep -flduring 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. - 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

Four variants against the identical tree. In each case the timeout fires three seconds in, and the log is read four seconds after that.
| Cleanup | Reached | Deepest process |
|---|---|---|
kill <pid> | level 1 | still ticking |
pkill -P <pid> then kill <pid> on a two-level tree | levels 1–2 | stopped |
pkill -P <pid> then kill <pid> on a three-level tree | levels 1–2 | still ticking |
setsid at launch, then killpg(<pgid>) | whole group | stopped |
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