DIVE-3752: install the orphan watchdog in every plugin, and fix the clause that could not fire - #45
Merged
Merged
Conversation
…lause that could not fire The watchdog DIVE-3486 compiled reached exactly one plugin. buzz ended in a bare setInterval with no process.on, no stdin handler and no exit, and leaked one live poller per restart for six days; dashboard had the same gap, and telegram-pi and telegram-opencode never call process.exit at all. All eight plugins that end in a long-lived timer now install the same lifecycle.ts. Porting it surfaced a second defect in the original: Bun caches process.ppid at boot and never refreshes it, so `process.ppid !== bootPpid` — the clause that exists precisely because stdin EOF is unreliable when the parent chain is severed — cannot ever fire. Measured: an orphan reported its dead parent's pid for six seconds while ps showed ppid=1. Read PPid: from /proc/self/status instead, with boot-parent liveness as the fallback. Also de-silence the channel start: `bun install && bun server.ts` exits 1 and never starts the poller when the install fails, which is how three seats went deaf for 2h33m with nine gates pending. `&&` becomes `;` (kept, not dropped — node_modules is gitignored, so a fresh box still needs it), and a new start.ts records start/exit/crash to <state-dir>/lifecycle.log, including the case where server.ts throws on import and nothing else could speak.
The five telegram forks are generated, and `bun generator/generate.ts --check` is the parity workflow's second step. It reds the first push: a new shared module that is not in COPY_FILES is "only-in-committed" for telegram-agy and telegram-grok. lifecycle.ts joins tna.ts and banner.ts in the byte-exact copy set. Byte-exact rather than name-swept for two reasons: the sweep would rewrite `telegram` inside the module's own comments and break the byte-identity the parity arm asserts, and those comments cite plugins/telegram/server.ts by path as the place the dead ppid clause came from — a swept copy would claim that history happened in a fork it never happened in. bun test 933 pass / 0 fail; generate.ts --check byte-exact on both forks.
quinn's iteration-1 grade: plugins/telegram/server.ts did not parse — the new installLifecycle import had been inserted INSIDE the msglog import's brace list. Every gate on the PR was green: bun test never imports a server.ts (CI has no plugin deps), the generator is a text transform that never parses what it copies, and parity is the repo's only workflow. Shipped, the flagship plugin — bumped to 0.5.49 and distributed via marketplace.json — would have deafened every telegram seat on the next channel start. 1. Move the import out of the brace list. All 8 plugin entry points now transpile clean. 2. Add the gate that was missing. test/entrypoint-parse.test.ts sweeps every .ts under plugins/ with `bun build --no-bundle`, which resolves no import specifier and so needs no node_modules — verified on a worktree with none in any of the eight plugin dirs. Plus a named `entry points parse` step in parity.yml so the failure has a name in the checks list. The gate sweeps EVERY file rather than the declared entry point, and that is load-bearing: `start` now names start.ts, which imports only node builtins and lifecycle.ts and parses fine while the server.ts it imports does not. Mutation control: with the defect restored, exactly 1 of the 100 tests fires, and it is the sweep arm — the "the file each plugin actually launches parses" arm stays green throughout. Positive control inside the test: it constructs this exact defect in a temp file and requires the parser to reject it, alongside the well-formed twin, so a green sweep grades the tree and not a broken harness. Checked on the merged tree with unpiped exit codes: bun test 1033 pass / 0 fail / 3386 assertions across 36 files, exit 0; bun generator/generate.ts --check byte-exact on both committed forks, exit 0 (the forks are unchanged — the moved line lands where the generator's block deletion already left it). No further version bump: 0.5.49 is unreleased, introduced by this same branch. Wiki: community/wiki/a-green-suite-that-never-imports-the-entry-point-cannot-fail.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
DIVE-3752 — the orphan watchdog, installed everywhere, with its dead clause replaced
Items 1–3 of the row. Item 4 (rung-4
poller-deadrestart) is in5dive-cliand is filedseparately — see the bottom.
1. Installed in one plugin, needed in eight
DIVE-3486 compiled "
bun rundoes not forward SIGTERM, so MCP servers orphan" on 2026-08-16 andplugins/telegram/server.tsreceived the remedy. Nothing else did. Audited all eight plugins whoseserver.tsends in a long-lived timer:shutdown()stops the bot and returnsbuzzis the measured leak: 22 reparented pollers on one seat, oldest six days old, all reaped by aplain
kill -TERM. Nothing was wedged — they were healthy processes nobody had asked to stop, whichis the signature of a missing handler rather than a hung poll.
agent-donis a within-seat control:same host, same launcher shape, ~22 restarts, both plugins spawned by the same parent — telegram 0
orphans, buzz 22. The variable is the plugin's own shutdown code.
All eight now install the same
lifecycle.ts.Why the file is duplicated per plugin directory:
.claude-plugin/marketplace.jsonpublishes./plugins/<name>as the unit and Claude Code caches it at<cache>/5dive-plugins/<plugin>/<version>/, so an import reaching outside the plugin directoryresolves in this repo and not on a customer box. Duplication is forced; drift between the copies
is not — a test asserts all eight are byte-identical.
2. The clause the watchdog existed for could not fire
Porting the snippet surfaced a defect in the original. Its ppid comparison is there on the stated
grounds that "stdin events don't reliably fire when the parent chain is severed" — so it is the
clause that covers the severed-parent case specifically. Under Bun it cannot fire. Measured on
this host: a grandchild whose parent was
SIGKILLed, sampled every 500ms:process.ppidis captured at boot and never refreshed, so an orphan compares a dead pid againstitself forever.
psshowed the real ppid was 1 the whole time. Telegram's zero-orphan record camefrom its stdin handlers, not from that comparison — which also means the wiki page crediting the
ppid clause for it is wrong on the mechanism, and that correction is compiled alongside this.
The module reads
PPid:from/proc/self/status, with "is the boot parent still a process at all"(
kill(pid, 0), treatingEPERMas alive) as the fallback for an unreadable/proc. Both readingsflipped at the instant of severance above.
telegram's inline block is replaced rather than kept:a broken implementation is not a proven one.
3. The
&&deafener, and giving the launcher a voice"start": "bun install --no-summary && bun server.ts"in telegram, buzz and dashboard. Measuredunder the launcher's own shell (
bun run --shell=bun):So a network-dependent step in front of a channel start is a deafener, and on 2026-08-26 three seats
including the coordinator were deaf for 2h33m with 9 human gates pending while the only signal
available to a human or to the DIVE-1434 canary was an absent heartbeat — which cannot say which
of three failures produced it.
The install is kept, not dropped. The row suggested dropping it on the grounds that
node_modulesis vendored in every seat's plugin cache. It is vendored in the cache (92–99entries on this seat) but it is gitignored in this repo, so deleting the install risks breaking
the first channel start on a cold box.
&&becomes;: the install can still populate a coldcache, and can no longer take the poller with it.
The remaining silence was
server.tsthrowing on import. A newstart.tsis the entry point; itimports node builtins and
lifecycle.tsonly, so it still loads — and can still write a record — inexactly the case
server.tscannot. Start / exit / crash records with reasons land in<state-dir>/lifecycle.log.Honest limit: if Claude Code never spawns the launcher at all, nothing of ours runs and nothing
of ours can record it. That state is un-recordable from inside a plugin and stays outside this diff.
What is new is that "the poller is absent" now separates into a record exists with a reason versus
no record at all.
Checks
bun test— 933 pass, 0 fail, 3268 assertions across 35 files on the merged tree, including thepre-existing
test/parity.test.tsfork-drift suite.test/lifecycle.test.tsadds 35 tests in three arms, because a single arm would have been vacuousin a way this repo has been bitten by before:
bun test(no plugin deps installed) can actually run them.installLifecycle({...})call, not merely the import: apure module nobody calls is not installed. Comments are stripped before every assertion,
because a bare "does it have a
process.on" grep is satisfiable by a comment. Includes aregression arm that no plugin compares
process.ppidto a boot snapshot again, and that no startscript puts
&&in front of the server.ignored stdin would hit the EOF clause instantly and pass for the wrong reason), proves it isalive and still heartbeating a second later, then
SIGKILLs the parent and requires the childto be gone.
Mutation control, because "it exited" is a claim that passes trivially: restoring the old
process.ppidclause makes the end-to-end arm sit alive for its full 15-second window and fail —6 assertions pass first, so the positive control is what holds and the failure is the child's
survival. With the
/procread it exits in under a second.The forks are generated, and the first push was red
bun generator/generate.ts --checkis the parity workflow's second step, and I had run onlybun testlocally. It reds the first push:telegram-agyandtelegram-grokare generated from thetelegrambase, so a new shared modulethat is not in the generator's
COPY_FILESexists in the committed fork and not in the generatedone. Fixed at the generator:
lifecycle.tsjoinstna.tsandbanner.tsin the byte-exactcopy set — not name-swept, because the sweep would rewrite
telegraminside the module's owncomments and break the byte-identity the parity arm asserts, and because those comments cite
plugins/telegram/server.tsby path as where the dead clause came from. A swept copy would claimthat history happened in a fork it never happened in.
The two gates now cover each other: the drift gate catches a fork the generator forgot, and the
parity arm's
existsSync+ byte-identity assertions catch a generator that drops the module.Re-checked on the merged tree, unpiped exit codes:
bun test933 pass / 0 fail, exit 0;generate.ts --checkbyte-exact on both forks, exit 0.Versions
telegram
0.5.48 → 0.5.49, buzz0.1.1 → 0.1.2, dashboard0.4.0 → 0.4.1. A plugin change thatdoes not bump reaches no seat. The five telegram forks are not in
marketplace.jsonand are notversion-distributed, so they are not bumped.
Not in this PR
Item 4 — let rung-4
poller-deadrestart the seat — lives in5dive-cli's recovery ladder(
src/cmd_supervisor.sh), whose verb set isnudge|resume|rotate|escalate|deferwith norestartverb to switch on. Adding one is a new fleet-wide remedy that restarts seats, whichwants its own row, its own rate limit and its own smoke rather than riding a plugins PR. Filed
separately with olivia's recommendation and grounds carried forward intact.
🤖 Generated with Claude Code