Skip to main content
Insights•11 min read

The Test That Killed the Team: One awk Line, 12 AI Agents

October 14, 2026 by Asif Waliuddin

AI AgentsAI governanceSecurityThe Honor SystemAgent Control Plane
A glowing orange net on a dark charcoal background lifts a dense mound of spheres while the one small sphere it was cast for sits alone beneath it, untouched.

On October 14, 2026, an AI reviewer agent ran a cleanup test that contained one wrong awk line. Its kill list grew to almost every process on my machine, and kill -KILL took down 12 of my AI agents' sessions in the middle of their work. Nobody noticed for about two hours.

At 21:20:58 UTC on October 14, a review subagent on my agent server was re-checking a pull request for a plugin I build. Its job was narrow: confirm that the plugin cleans up its own processes. The harness it ran built the launcher's process tree with awk, then cleaned up with kill -KILL $tree. One line in the tree builder read a table entry where it should have tested membership. The list that reached kill held almost every process ID on the machine.

12 tmux sessions died, each one an AI agent whose Claude process was the pane's command. Two more agents lost their process but kept their session: my code-review agent, and the program manager agent that had commissioned the review. The test killed its own boss. Chromium went down with them, and so did the archiver that ships my memory database's write-ahead log. The kill stopped only when it reached the shell that was running the test.

What happened: an AI agent cleanup test with a computed kill list

The 12 sessions read like a roll call of everything I had running that evening. My product oracle agent. A product team agent about nine minutes into a tenancy rework it had started at roughly 21:12 UTC. Another product team agent in the middle of a pull request's test run. Three agents on my Forge products, one of them preparing the plugin's 3.14.0 release. My website team's agent. The content engine agent that drafts articles like this one. The podcast pipeline agent. An engagement agent. A program agent for my memory platform. And my own counterpart agent.

Grid of the 12 tmux sessions that died at 21:20:58 UTC on October 14, named by role. 01 Product oracle. 02 Product team, tenancy rework. 03 Product team, pull request test run. 04 Forge products, 3.14.0 release prep. 05 Forge products. 06 Forge products. 07 Website team. 08 Content engine. 09 Podcast pipeline. 10 Engagement. 11 Memory platform program. 12 My own counterpart. Beside the grid, lost their process but kept their session: the program manager, which commissioned the review, highlighted, and the code-review agent. Also killed: Chromium and the write-ahead-log archiver. Line: The kill stopped only when it reached the shell that was running the test. Headline: The test killed its own boss.
12 tmux sessions died, each one an AI agent whose Claude process was the pane's command.

The quieter damage took longer to see. The write-ahead-log archiver restarts on its own but cannot attach, because the database still marks its replication slot as active for a stale sender process. WAL archiving for my memory database went down at 21:21 UTC, and when this was written it was still down, waiting for that sender to be cleared. The restore I describe below brought back each agent's conversation. It did not bring back work in flight, so the tenancy rework, the test run, the release prep and the program manager's open reviews all stopped where they were.

The awk line that turned a process tree into the whole machine

The harness tested t[pp[i]] and t[pi[i]] inside a condition, where t was meant to be the set of processes in the launcher's tree. In awk, reading an array element that does not exist creates it, with an empty value. The GNU Awk manual warns about this pattern and gives the fix: test membership with (indx in array), which creates nothing. In the harness, every process ID the condition looked up became a key in t, so the loop that printed the tree, for (k in t), printed almost every PID on the box.

I reproduced the mechanism with GNU Awk 5.1.0. You can paste both commands into any shell with awk installed. The first starts with one key, reads two entries inside a condition, and counts again. The second does the same check with the membership operator:

awk 'BEGIN{ t["root"]=1; pp["a"]="parent-of-a"; pp["b"]="parent-of-b";
  n=0; for(k in t) n++; print "before:", n;
  if (t[pp["a"]] || t[pp["b"]]) {};
  n=0; for(k in t) n++; print "after reading two parents:", n }'
# before: 1
# after reading two parents: 3
 
awk 'BEGIN{ t["root"]=1; if (("x" in t)) {}; n=0; for(k in t) n++; print "with (x in t):", n }'
# with (x in t): 1

Reading two parents inside an if grew the table from 1 key to 3. The membership test left it at 1. Scale that up to every process on a busy agent server and the "tree" becomes the machine. kill -KILL then worked through the list in awk's hash order, killing whatever came next, until it reached the shell that had launched it.

Side-by-side awk reproduction on GNU Awk 5.1.0, on a table t that starts with one key, root. Left panel, read inside an if: if (t[pp[a]] || t[pp[b]]) {}. Keys before: root, count 1. Keys after: root, parent-of-a, parent-of-b, count 3. Right panel, membership test: if ((x in t)) {}. Keys before: root, count 1. Keys after: root, count 1. Line: Reading two parents inside an if grew the table from 1 key to 3. The membership test left it at 1. Headline: A read inside a condition was the entire bug.
Reading two parents inside an if grew the table from 1 key to 3. The membership test left it at 1.

How I found it

I did not get an alert. Around 23:15 UTC I went looking for my product oracle agent's pane, and it was gone. So was a second product team's. Two missing sessions on a machine with a 15-minute self-heal should not happen quietly, so before anyone guessed at a cause I wanted the record of what had killed them.

Three records settled it. The reviewer's own transcript held the exact command, timestamped 21:20:58.989 UTC. The systemd journal at 21:21:00.566 UTC showed the WAL archiver and the desktop accessibility bus ending with code=killed, status=9/KILL, and the Chromium scope ending in the same millisecond. There was no kernel OOM entry, so memory pressure was ruled out: a process had sent SIGKILL. A diff of the 21:10 UTC tmux-resurrect save against a live tmux ls produced the list of 12 missing sessions. The incident record put the mechanism in one line: "kill -KILL on that list killed processes in hash order until it reached its own shell."

The self-heal was the part that hurt. The 15-minute self-heal and a guardian script exist to recreate sessions that disappear, and neither did so in two hours. Worse, at 21:30 UTC, nine minutes after the kill, the 10-minute tmux-resurrect save overwrote the last-known layout with the post-kill one. From that point on, my own backup described the damaged fleet as normal, and I found the problem by hand.

Timeline of October 14, 2026, in UTC, with the axis broken between 21:30 and 23:15. 21:10: tmux-resurrect save, the last good layout. 21:20:58: kill -KILL on the computed list, 12 sessions die. 21:21: journal shows killed, status 9, and WAL archiving goes down. 21:30: the 10-minute save overwrites the last good layout with the post-kill one. About 23:15: I notice a missing pane, no alert, found by hand. About 23:25: sessions restored, each with its exact conversation. A hatched band runs from 21:21 to 23:15, labelled about two hours, the self-heal recreated nothing. Headline: My own backup described the damaged fleet as normal.
From that point on, my own backup described the damaged fleet as normal, and I found the problem by hand.

What I changed, and what is still owed

The restore finished at about 23:25 UTC, roughly ten minutes after I noticed. The controls are ordered, each with an owner.

  1. Done: restored all 12 sessions with each agent's exact conversation. Each session came back in its own project directory with claude --resume <session id>, using the newest transcript in that project. A fresh session would have cost every agent its context; resume returned it. The program manager resumed its own conversation, and the code-review agent restarted.

  2. Ordered: fix the harness to test membership. The condition becomes (k in t). A read inside a condition was the entire bug.

  3. Ordered: kill test processes by their own process group. Start them under setsid, then kill -- -PGID, or run them in a systemd-run --user --scope unit. A negative PID sends the signal to every process in that group (kill(2)), so the test never has to rebuild its tree from a table. Never a computed PID list.

  4. Ordered: add a refusal to the shell-command guard. The guard already runs in block mode on that machine. The new rule refuses kill or pkill with -9 or -KILL when the targets come from a variable or a command substitution, unless the target is a negative process group ID that the same command created.

  5. Ordered: make the self-heal remember. It should recreate any session that existed in the previous save and raise an alert. The layout save must keep the pre-drop copy when sessions vanish, instead of overwriting the last good layout.

  6. Ordered: clear the stale sender on the database. Confirm WAL streaming resumes, then report the exact gap window.

The restore had its own near miss. A coordination message from another agent landed in the program manager's pane while that pane was still a bare shell. Bash ran each line of the message as a command. Every line failed with a syntax error or "command not found", and nothing executed. As I write this, step 1 is done and steps 2 through 6 are owed. I am not counting any of them as a control until it has refused something in a test of its own.

Six-step list of what I changed and what is still owed. 01 Done: restored all 12 sessions with each agent's exact conversation. 02 Ordered: fix the harness to test membership, (k in t). 03 Ordered: kill by process group, setsid, kill -- -PGID, or systemd-run --user --scope. 04 Ordered: guard refuses kill -9 or -KILL on targets from a variable or command substitution. 05 Ordered: self-heal remembers, keep the last good layout. 06 Ordered: clear the stale sender on the database. Status: step 1 is done, steps 2 through 6 are owed. Ordered means owed, not shipped. Quote: I am not counting any of them as a control until it has refused something in a test of its own. Headline: A test is evidence, a refusal is a control.
As I write this, step 1 is done and steps 2 through 6 are owed.

The principle: a test is evidence, a refusal is a control

In Episode 5 of AI Unveiled, Policy Is Not a Control, I wrote that "A policy that cannot stop anything is a memo." The cleanup test existed to produce evidence of safety, proof that the plugin leaves nothing behind. The instrument producing that evidence was the hazard. Every agent in the chain would have agreed with a written rule like "tests only kill their own processes," and the awk line would still have run, because nothing between the agent and the kernel could say no.

Episode 5 splits governance into four loops, and this incident broke two of them. Control closure needs something that changes behavior before the action lands. The guard refusal does that: it reads the command before it executes and declines a SIGKILL aimed at a list computed at run time. Process groups make the honest target cheap to name, so the refusal costs a correct test nothing. Correction closure needs the last good state to survive the incident, and mine was overwritten nine minutes after the kill by the backup that was meant to protect it.

The same shape shows up anywhere an agent computes a target and then acts on it with no undo: a file list fed to rm -rf, a set of rows fed to a delete, a recipient list fed to a send. When the target is computed, the computation belongs to the blast radius. Name the target by construction. Refuse the computed one. Keep the last good state.


This is part of the Operator Series, field research from running AI-native workflows at scale. The governance model behind this fix is in Policy Is Not a Control: What Europe Just Taught America About AI Governance.

Asif Waliuddin is the founder of NXTG.AI. He writes about AI operations at nxtgai.substack.com.

Frequently asked questions

Why did one awk line put almost every process on the kill list?
The harness read t[x] inside a condition. In awk, reading an element that does not exist creates it, so every process ID the condition looked up became a key, and the printed tree held almost every PID on the machine.
How do you test array membership in awk without creating the key?
Use the membership operator, (x in t). In a GNU Awk 5.1.0 reproduction, reading two entries inside a condition grew a one-key table to three keys, while (x in t) left it at one.
How should a test kill only the processes it started?
Start them in their own process group with setsid and kill the group with kill -- -PGID, or run them in a systemd-run --user --scope unit. Never hand kill a PID list computed at run time.
How long did the outage go unnoticed?
About two hours. The kill ran at 21:20:58 UTC, I noticed around 23:15 UTC, and the 12 sessions were restored with their conversations at about 23:25 UTC.
Why did the self-heal not bring the sessions back?
The 15-minute self-heal did not recreate the sessions within two hours. Nine minutes after the kill, the 10-minute tmux-resurrect save overwrote the last good layout with the post-kill one.

Ready to build?

Ship AI you can trust

Forge gives you agents, governance, and verification — so your AI ships with confidence, not hope.

LinkedIn newsletter

Enjoyed this article?

Get the next one through the NXTG.ai newsletter on LinkedIn.

You subscribe on LinkedIn. This site does not collect your email.

Subscribe on LinkedIn