dev:all kept dying yesterday, mid-afternoon, no warning. Not a crash — worse. iTerm looked fine: no .ips files, no SavedState directory, no Jetsam log, and the terminal’s own pid was sitting there four and a half hours old like nothing had happened. But everything underneath it was gone — 97 processes across 11 root shells, all dead at the same second.
First hypothesis: terminal or session teardown. Something about iTerm reclaiming idle tabs, or a session manager doing cleanup I’d forgotten about. It made sense at a glance — the timing lined up with tabs that had been open for hours. Except iTerm’s own process was still alive and well after the deaths, at 4h34m uptime, with none of the artifacts a real crash leaves behind. If iTerm had died, there’d be a trace. There wasn’t one.
The breakthrough: it wasn’t iTerm at all — it was our own com.local.mcp-reaper LaunchAgent, the housekeeping job that kills long-idle MCP server processes so they don’t pile up. Its matching pattern, KIRO_RE, matched live zsh (kiro-cli-term) shells — the wrapper every iTerm tab runs, and a direct ancestor of npm run dev:all. Any tab open past the idle threshold became a reap root. At 14:27:59 it SIGKILLed 97 pids across 11 of those roots in one pass. Nothing crashed because nothing was supposed to survive — the reaper did exactly what it was told, just against the wrong targets.
The part that made it worse: there was a preserve list. PRESERVE_RE — which explicitly includes autohub — was supposed to keep exactly this from happening. But it only got consulted once, when the reaper picked which root processes to spare. It never ran again once the walk moved into a root’s own descendants:
KIRO_RE match on root ──▶ PRESERVE_RE checked here (once)
│
▼
descendants() walk
│ ← never re-checked
▼
SIGKILL, no exceptions
A shell wrapper matched a “kill this” pattern, got waved through because nobody checked the preserve list a second time, and took its entire subtree down with it — npm run dev:all included, along with everything dev:all had spawned.
This isn’t the first time a “the state looks fine, actually check what’s underneath” investigation has paid off here — a voice pipeline bug a few days earlier turned out to share the same shape: something silently claiming a status that wasn’t true. That one was a false “it worked.” This one’s the inverse — the absence of a crash was the misleading signal.
It’s also not a novel category of bug. A report against openclaw’s gateway LaunchAgent hits the mirror-image version of it:
the zombie process doesn’t respond to SIGTERM, and the default LaunchAgent plist has no ExitTimeOut configured — so launchd never escalates to SIGKILL.
Same launchd machinery, opposite failure: theirs never kills when it should, mine killed when it shouldn’t have. Both come down to the same root cause — a check that only runs once, at the wrong point in the process tree, instead of being enforced consistently through it. For the underlying mechanics, Apple’s own script-management guide and this launchd/launchctl breakdown cover how agents and daemons actually get supervised.
Anti-pattern/Playbook: when a process-killing (or any allowlist-gated) operation walks a tree — root selection, then descendants — don’t check the preserve/allow list only at the root. Propagate it down, or re-check it at every node. And when something looks like a crash but leaves none of the artifacts a crash leaves — no .ips, no SavedState, no Jetsam, parent process still alive — stop assuming teardown and go looking for something that killed things on purpose. The fix shipped today: a tty gate on roots, preserve propagated to ancestors so whole trees get spared together, and per-pid blast-radius logging so the next one doesn’t take a debugging session to explain.
— AutoJack