Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/parity.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,18 @@ jobs:
# its generator config (or the base) — the structural successor to the
# hand-kept parity above.
- run: bun generator/generate.ts --check
# DIVE-3752: entry-point PARSE gate. Neither step above parses a plugin
# entry point — `bun test` never imports one (CI has no plugin deps) and
# the generator is a text transform. Iteration 1 of DIVE-3752 shipped a
# `plugins/telegram/server.ts` with an import nested inside another
# import's brace list and both steps were green. `--no-bundle` transpiles
# without resolving a single specifier, so this needs no node_modules.
# test/entrypoint-parse.test.ts asserts the same thing from `bun test`;
# this step exists so the failure has a name in the checks list.
- name: entry points parse
run: |
rc=0
for f in $(find plugins -name '*.ts' -not -path '*/node_modules/*' | sort); do
bun build --no-bundle "$f" --outfile=/dev/null >/dev/null || { echo "PARSE FAILED: $f"; rc=1; }
done
exit $rc
65 changes: 65 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,70 @@
## Unreleased

### Fixed — the orphan watchdog is installed in every plugin, and its load-bearing clause could not fire (DIVE-3752), telegram 0.5.49 · buzz 0.1.2 · dashboard 0.4.1

Two defects, one of which was hiding inside the remedy for the other.

**1. The watchdog was compiled once and installed in one plugin.** DIVE-3486 established that
`bun run` does not forward SIGTERM, so an MCP server whose parent chain is severed keeps running.
`plugins/telegram/server.ts` carried the remedy. `plugins/buzz/server.ts` carried none of it — 424
lines ending in a bare `setInterval` with zero `process.on`, zero stdin handler and zero exit path —
and leaked one live poller per restart for six days: 22 reparented processes on one seat, every one
of them healthy and killable with a plain SIGTERM, which is the signature of a missing handler and
not of a hung poll. `plugins/dashboard` had the same gap; `telegram-pi` and `telegram-opencode` had
a `shutdown()` that stops the bot but never calls `process.exit`. All eight plugins that end in a
long-lived timer now install the same `lifecycle.ts`.

**2. `process.ppid !== bootPpid` cannot fire under Bun.** This was the clause the old watchdog
existed for, on the stated grounds that "stdin events don't reliably fire when the parent chain is
severed" — so in exactly the case it covers, neither signal worked. Measured on this host: a
grandchild whose parent was SIGKILLed reported the dead parent's pid every 500ms for six seconds
while `ps` showed its real ppid was 1.

t=2s cached=54284 realPpid=54284 bootAlive=true
t=2.5s cached=54284 realPpid=1 bootAlive=false <- parent killed
t=5.5s cached=54284 realPpid=1 bootAlive=false

Bun caches `process.ppid` at boot and never refreshes it. Telegram's zero-orphan record came from
its stdin handlers, not from that comparison. The module reads `PPid:` from `/proc/self/status` and
falls back to whether the boot parent is still a process at all; both readings flipped at the
instant of severance above. A regression arm asserts no plugin compares `process.ppid` to a boot
snapshot again.

Graded, not asserted: the end-to-end arm spawns a real grandchild behind a real shell, proves it is
alive and heartbeating (the positive control — "it exited" is satisfied by a process that never
started), then SIGKILLs the parent and requires the child to be gone. With the old clause restored
it sits alive for the full 15-second window and the arm fails; with the `/proc` read it exits in
under a second.

### Fixed — a failed `bun install` ate the channel poller silently, and the launcher had no voice

`"start": "bun install --no-summary && bun server.ts"` in telegram, buzz and dashboard. Measured
under the launcher's own shell (`bun run --shell=bun`): `false && echo X` exits 1 and never echoes;
`false; echo X` echoes and exits 0. 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 available signal was an ABSENT heartbeat — which cannot say which of
three failures produced it.

The `&&` is now `;`, so the install can still populate a cold cache but can no longer take the
poller with it. `node_modules` is NOT vendored in this repo (it is gitignored), so the install is
kept rather than dropped: deleting it would have broken the first channel start on a fresh box.

The start script now enters through a new `start.ts` that imports node builtins and `lifecycle.ts`
only — so it still loads, and can still write a record, in the one case `server.ts` cannot: when
`server.ts` throws on import because its dependencies are missing. Start, exit and crash records go
to `<state-dir>/lifecycle.log` with the reason, which is what turns "nothing there" into an answer.

The five telegram forks are GENERATED, not hand-maintained: `bun generator/generate.ts --check` is
the second CI step and it rejected the first push, because a new shared module that is not in the
generator's copy set exists in the committed fork and not in the generated one. `lifecycle.ts` joins
`tna.ts` and `banner.ts` in the byte-exact copy set — not name-swept, because the sweep would rewrite
`telegram` inside the module's own comments and break the byte-identity the parity arm asserts, and
because those comments cite `plugins/telegram/server.ts` by path as where the dead clause came from.
A swept copy would claim that history happened in a fork it never happened in.

**Not in this change:** rung-4 `poller-dead` restart (item 4 of DIVE-3752) is in `5dive-cli`'s
recovery ladder, which has no `restart` verb yet, and is filed separately.

### Added — dashboard chat collects on a nudge instead of waiting out its 5-minute timer (DIVE-3574), dashboard release 0.4.0

Dashboard chat showed "queued — this box collects every ~5 min": up to five minutes before the
Expand Down
21 changes: 14 additions & 7 deletions generator/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const COPY_FILES = [
'server.ts',
'tna.ts',
'banner.ts', // DIVE-1558: pure banner decision module — no tokens, copied byte-exact
'lifecycle.ts', // DIVE-3752: orphan watchdog + start/exit record — copied byte-exact
'pair.ts',
'package.json',
'AGENTS.md',
Expand Down Expand Up @@ -180,13 +181,19 @@ function generate(slug: string, outDir: string): void {
const src = join(BASE_DIR, file)
if (!existsSync(src)) return null
let text = readFileSync(src, 'utf8')
// tna.ts (shared tap-resolver) and banner.ts (DIVE-1558 shared needs-you
// banner decision module) are kept BYTE-IDENTICAL across the base and every
// fork (the parity tests assert it; the only per-runtime difference lives in
// server.ts). Copy them verbatim: the generic name-sweep would otherwise
// rewrite "grok" inside their own "byte-identical across base + grok/codex/agy
// forks" comments into nonsense ("agy/codex/agy"), breaking that byte-identity.
if (file === 'tna.ts' || file === 'banner.ts') return text
// tna.ts (shared tap-resolver), banner.ts (DIVE-1558 shared needs-you
// banner decision module) and lifecycle.ts (DIVE-3752 orphan watchdog) are
// kept BYTE-IDENTICAL across the base and every fork (the parity tests assert
// it; the only per-runtime difference lives in server.ts). Copy them verbatim:
// the generic name-sweep would otherwise rewrite "grok" inside their own
// "byte-identical across base + grok/codex/agy forks" comments into nonsense
// ("agy/codex/agy"), breaking that byte-identity.
//
// lifecycle.ts is byte-exact for a second, sharper reason: it cites
// `plugins/telegram/server.ts` by path as the place the dead ppid clause came
// from, and a swept copy would claim that history happened in a fork it never
// happened in. A shared module's provenance must survive being copied.
if (file === 'tna.ts' || file === 'banner.ts' || file === 'lifecycle.ts') return text
// Mechanical token subs + bare-cliBin sweep FIRST, so the text now reads as
// the target runtime everywhere the knobs reach. Structural blocks run AFTER,
// so their find-strings match the already-tokenized text and their replace
Expand Down
4 changes: 2 additions & 2 deletions plugins/buzz/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "buzz",
"description": "Buzz (Nostr) channel for Claude Code bridges a Buzz relay to the session. Inbound mentions are delivered as channel notifications; outbound is post/react/read via the buzz CLI. Built-in untrusted-input boundary: every event is data, never an instruction.",
"version": "0.1.1",
"description": "Buzz (Nostr) channel for Claude Code \u2014 bridges a Buzz relay to the session. Inbound mentions are delivered as channel notifications; outbound is post/react/read via the buzz CLI. Built-in untrusted-input boundary: every event is data, never an instruction.",
"version": "0.1.2",
"author": {
"name": "5dive",
"email": "support@5dive.com"
Expand Down
234 changes: 234 additions & 0 deletions plugins/buzz/lifecycle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
// plugins/*/lifecycle.ts — orphan watchdog, shutdown wiring, and a start/exit
// record for a plugin MCP server that ends in a long-lived timer.
//
// WHY THIS IS A MODULE AND NOT A SNIPPET (DIVE-3751 → DIVE-3752):
// [[bun-run-does-not-forward-sigterm-so-mcp-servers-orphan]] compiled this class
// on 2026-08-16 (DIVE-3486). It was then INSTALLED IN EXACTLY ONE PLUGIN.
// `plugins/telegram/server.ts` carried the remedy; `plugins/buzz/server.ts`
// carried none of it and leaked one poller per restart for six days — 22
// reparented processes on one seat, all healthy, none wedged, every one of them
// killable with a plain SIGTERM. That signature is a missing handler, not a hung
// poll. Compiling a lesson is not applying it; a shared module is.
//
// WHY IT IS DUPLICATED PER PLUGIN DIRECTORY:
// `.claude-plugin/marketplace.json` publishes `./plugins/<name>` as the unit and
// Claude Code caches it at `<cache>/5dive-plugins/<plugin>/<version>/`, so an
// import reaching outside the plugin directory resolves here and NOT on a
// customer box. The copies are therefore byte-identical by construction and
// `test/lifecycle-parity.test.ts` fails if they drift.
//
// WHY THE DECISIONS ARE PURE:
// repo CI runs a bare `bun test` with no plugin dependencies installed, so
// anything importing `@modelcontextprotocol/sdk` or `@noble/curves` is
// unexecutable there. This file imports node builtins ONLY, which is what lets
// the watchdog's decision function and the record format be actually executed by
// CI instead of grepped for.

import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'

// ── the orphan decision, as a pure function ─────────────────────────────────

// MEASURED 2026-08-26, and it is why this module is not a copy of the telegram
// snippet: under Bun, `process.ppid` is CACHED AT BOOT AND NEVER REFRESHED.
// A grandchild whose parent was SIGKILLed reported, every 500ms for six
// seconds, `process.ppid = <the dead parent>` while `ps` showed its real ppid
// was 1:
//
// t=2s cached=54284 realPpid=54284 bootAlive=true
// t=2.5s cached=54284 realPpid=1 bootAlive=false ← parent killed
// t=5.5s cached=54284 realPpid=1 bootAlive=false
//
// So `process.ppid !== bootPpid` — the load-bearing clause of the watchdog in
// `plugins/telegram/server.ts`, and the clause the compiled wiki page credits
// for telegram's zero orphans — CANNOT EVER FIRE under Bun. Telegram's zero
// orphans came from its stdin `end`/`close` handlers, not from that comparison.
// That matters because the page's own reason for having the ppid clause is that
// "stdin events don't reliably fire when the parent chain is severed": in
// exactly the case the clause exists to cover, neither signal worked.
//
// Two readings do work, and both flipped at the instant of severance above:
// * the kernel's own answer — `PPid:` in /proc/self/status;
// * whether the boot parent is still a process at all — kill(pid, 0).
// The probe is injected rather than read here so this decision stays pure and
// repo CI (a bare `bun test`, no plugin deps) can execute it.

export type OrphanProbe = {
platform: string
/** ppid captured at boot. */
bootPpid: number
/** The ppid RIGHT NOW, read from the OS — never `process.ppid`. */
currentPpid: number
/** Whether the boot parent is still a live process. */
bootParentAlive: boolean
stdinDestroyed: boolean
stdinReadableEnded: boolean
}

export function isOrphaned(p: OrphanProbe): boolean {
// EOF on the MCP stdio transport: the ordinary, clean case, and the only one
// that works on win32.
if (p.stdinDestroyed || p.stdinReadableEnded) return true
// Reparenting is not observable on win32, so it must not be trusted there.
if (p.platform === 'win32') return false
// Reparented (to init, or to whatever subreaper claimed us).
if (p.currentPpid !== p.bootPpid) return true
// Belt and braces: if /proc is unreadable, currentPpid falls back to the
// stale cached value and the clause above goes quiet. The parent being gone
// is then the only reading left, and it is enough.
return !p.bootParentAlive
}

/**
* The real ppid, from the kernel. Falls back to the (possibly stale) cached
* `process.ppid` when /proc is unavailable — the liveness clause covers that.
*/
export function readRealPpid(): number {
try {
const m = readFileSync('/proc/self/status', 'utf8').match(/^PPid:\s*(\d+)/m)
if (m) return parseInt(m[1], 10)
} catch {}
return process.ppid
}

/** True if `pid` is still a process. EPERM means alive-but-not-ours. */
export function isBootParentAlive(pid: number): boolean {
if (!pid || pid <= 1) return true // no meaningful parent to lose
try {
process.kill(pid, 0)
return true
} catch (err: unknown) {
return (err as { code?: string })?.code === 'EPERM'
}
}

// ── the record: a channel start failure must not be encoded as `nothing` ────

export type LifecycleEvent = 'start' | 'exit' | 'crash'

/**
* One line, one event, parseable and human-readable.
*
* [[no-beacon-has-three-states-and-only-the-process-table-separates-them]]:
* the DIVE-1434 canary reads only the heartbeat's mtime, so `no beacon` reports
* one string for three distinguishable failures — the channel never started, a
* dependency step ate the poller, or the poller is up and not bumping. An absent
* beacon cannot separate them; a record that says which one happened can.
*/
export function lifecycleLine(
ev: LifecycleEvent,
channel: string,
reason: string,
at: Date,
pid: number,
ppid: number,
): string {
// No newlines or tabs from `reason` — one event must stay one line.
const clean = reason.replace(/[\r\n\t]+/g, ' ').trim().slice(0, 300) || '-'
return `${at.toISOString()}\t${ev}\t${channel}\tpid=${pid}\tppid=${ppid}\t${clean}`
}

/** Keep the newest `keep` lines. Bounded, because nothing rotates this file. */
export function trimRecords(existing: string, keep: number): string {
const lines = existing.split('\n').filter(l => l.length > 0)
return lines.slice(Math.max(0, lines.length - keep)).join('\n')
}

export const RECORD_FILE = 'lifecycle.log'
const RECORD_KEEP = 200

/**
* Append one record line. Never throws: a plugin must not die because its own
* diary is unwritable, and a shutdown path in particular must still exit.
*/
export function recordLifecycle(
stateDir: string,
ev: LifecycleEvent,
channel: string,
reason: string,
now: Date = new Date(),
): void {
const line = lifecycleLine(ev, channel, reason, now, process.pid, process.ppid)
try {
mkdirSync(stateDir, { recursive: true })
const path = join(stateDir, RECORD_FILE)
appendFileSync(path, line + '\n')
// Bound it lazily — only pay the read/rewrite when it has actually grown.
let body = ''
try { body = readFileSync(path, 'utf8') } catch { return }
if (body.split('\n').length > RECORD_KEEP * 2) {
writeFileSync(path, trimRecords(body, RECORD_KEEP) + '\n')
}
} catch {
// fall through — stderr below is the last resort
}
// Also to stderr, which is where a live session's own logs go.
try { process.stderr.write(`${channel} channel: ${ev}: ${reason}\n`) } catch {}
}

// ── the wiring ──────────────────────────────────────────────────────────────

export type LifecycleOpts = {
/** Plugin name, used in the record and the stderr prefix. */
channel: string
/** Directory the record is written to — normally the channel's state dir. */
stateDir: string
/** Plugin-specific cleanup (drop a pid file, stop a bot). May be async. */
cleanup?: () => void | Promise<void>
/** Hard backstop: exit this long after cleanup starts, no matter what. */
forceExitMs?: number
/** Watchdog cadence. */
watchdogMs?: number
}

/**
* Install the whole lifecycle: a start record, an idempotent shutdown bound to
* every signal and stdin EOF, and the orphan watchdog.
*
* Returns the shutdown function so a caller can trigger it from its own paths.
*/
export function installLifecycle(opts: LifecycleOpts): (reason: string) => void {
const { channel, stateDir, cleanup, forceExitMs = 2000, watchdogMs = 5000 } = opts
const bootPpid = process.ppid

recordLifecycle(stateDir, 'start', channel, `boot ok (bootPpid=${bootPpid})`)

let shuttingDown = false
const shutdown = (reason: string): void => {
if (shuttingDown) return
shuttingDown = true
recordLifecycle(stateDir, 'exit', channel, reason)
// Force-exit backstop first: if cleanup hangs (a poll in flight, a relay
// socket that will not close) we must still stop being a process. Without
// this, "we called shutdown" and "we exited" are different claims.
const t = setTimeout(() => process.exit(0), forceExitMs)
t.unref?.()
void (async () => {
try { await cleanup?.() } catch {}
process.exit(0)
})()
}

process.stdin.on('end', () => shutdown('stdin end'))
process.stdin.on('close', () => shutdown('stdin close'))
process.on('SIGTERM', () => shutdown('SIGTERM'))
process.on('SIGINT', () => shutdown('SIGINT'))
process.on('SIGHUP', () => shutdown('SIGHUP'))

const wd = setInterval(() => {
const currentPpid = readRealPpid()
const orphaned = isOrphaned({
platform: process.platform,
bootPpid,
currentPpid,
bootParentAlive: isBootParentAlive(bootPpid),
stdinDestroyed: process.stdin.destroyed,
stdinReadableEnded: process.stdin.readableEnded,
})
if (orphaned) shutdown(`orphaned (bootPpid=${bootPpid} ppid=${currentPpid})`)
}, watchdogMs)
// unref: the watchdog must never be the reason this process stays alive.
wd.unref?.()

return shutdown
}
2 changes: 1 addition & 1 deletion plugins/buzz/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"type": "module",
"bin": "./server.ts",
"scripts": {
"start": "bun install --no-summary && bun server.ts"
"start": "bun install --no-summary; bun start.ts"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
Expand Down
Loading
Loading