From ca5e415e4db28fa6668c057331f434ecc68bfbad Mon Sep 17 00:00:00 2001 From: lodar Date: Wed, 26 Aug 2026 07:37:13 +0000 Subject: [PATCH 1/3] DIVE-3752: install the orphan watchdog in every plugin, and fix the clause that could not fire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 /lifecycle.log, including the case where server.ts throws on import and nothing else could speak. --- CHANGES.md | 57 ++++ plugins/buzz/.claude-plugin/plugin.json | 4 +- plugins/buzz/lifecycle.ts | 234 ++++++++++++++ plugins/buzz/package.json | 2 +- plugins/buzz/server.ts | 9 + plugins/buzz/start.ts | 38 +++ plugins/dashboard/.claude-plugin/plugin.json | 4 +- plugins/dashboard/lifecycle.ts | 234 ++++++++++++++ plugins/dashboard/package.json | 2 +- plugins/dashboard/server.ts | 7 + plugins/dashboard/start.ts | 38 +++ plugins/telegram-agy/lifecycle.ts | 234 ++++++++++++++ plugins/telegram-agy/server.ts | 7 + plugins/telegram-codex/lifecycle.ts | 234 ++++++++++++++ plugins/telegram-codex/server.ts | 7 + plugins/telegram-grok/lifecycle.ts | 234 ++++++++++++++ plugins/telegram-grok/server.ts | 7 + plugins/telegram-opencode/lifecycle.ts | 234 ++++++++++++++ plugins/telegram-opencode/server.ts | 9 + plugins/telegram-pi/lifecycle.ts | 234 ++++++++++++++ plugins/telegram-pi/server.ts | 9 + plugins/telegram/.claude-plugin/plugin.json | 4 +- plugins/telegram/lifecycle.ts | 234 ++++++++++++++ plugins/telegram/package.json | 2 +- plugins/telegram/server.ts | 27 +- plugins/telegram/start.ts | 38 +++ test/lifecycle.test.ts | 301 +++++++++++++++++++ 27 files changed, 2424 insertions(+), 20 deletions(-) create mode 100644 plugins/buzz/lifecycle.ts create mode 100644 plugins/buzz/start.ts create mode 100644 plugins/dashboard/lifecycle.ts create mode 100644 plugins/dashboard/start.ts create mode 100644 plugins/telegram-agy/lifecycle.ts create mode 100644 plugins/telegram-codex/lifecycle.ts create mode 100644 plugins/telegram-grok/lifecycle.ts create mode 100644 plugins/telegram-opencode/lifecycle.ts create mode 100644 plugins/telegram-pi/lifecycle.ts create mode 100644 plugins/telegram/lifecycle.ts create mode 100644 plugins/telegram/start.ts create mode 100644 test/lifecycle.test.ts diff --git a/CHANGES.md b/CHANGES.md index 53f46c1..dcecb26 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,62 @@ ## 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 `/lifecycle.log` with the reason, which is what turns "nothing there" into an answer. + +**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 diff --git a/plugins/buzz/.claude-plugin/plugin.json b/plugins/buzz/.claude-plugin/plugin.json index 062d105..b79520b 100644 --- a/plugins/buzz/.claude-plugin/plugin.json +++ b/plugins/buzz/.claude-plugin/plugin.json @@ -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" diff --git a/plugins/buzz/lifecycle.ts b/plugins/buzz/lifecycle.ts new file mode 100644 index 0000000..4fa3dd3 --- /dev/null +++ b/plugins/buzz/lifecycle.ts @@ -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/` as the unit and +// Claude Code caches it at `/5dive-plugins///`, 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 = ` 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 + /** 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 +} diff --git a/plugins/buzz/package.json b/plugins/buzz/package.json index 8033374..da2450b 100644 --- a/plugins/buzz/package.json +++ b/plugins/buzz/package.json @@ -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", diff --git a/plugins/buzz/server.ts b/plugins/buzz/server.ts index bc8237d..3d1bda6 100644 --- a/plugins/buzz/server.ts +++ b/plugins/buzz/server.ts @@ -27,6 +27,7 @@ import { schnorr } from '@noble/curves/secp256k1' import { npubEncode, encoderIsSane, shouldDeliver, parseDmList, type BuzzEvent } from './mention.ts' import { makeGuardedTick, mergeTargets, type PollTarget } from './poller.ts' import { readVerdict, hostAlreadyDelivered, trustLabel, type Verdict } from './bridge.ts' +import { installLifecycle } from './lifecycle.ts' const exec = promisify(execFile) const STATE_DIR = join(homedir(), '.claude', 'channels', 'buzz') @@ -420,5 +421,13 @@ function startPoller() { setInterval(fire, interval) } +// DIVE-3752: install the orphan watchdog that DIVE-3486 compiled and only +// `plugins/telegram` ever received. Without it this file ended in a bare +// `setInterval` with zero `process.on`, zero stdin handler and zero `exit`, and +// leaked one live poller per restart — 22 of them on one seat over six days. +// It must be installed BEFORE the poller starts: the window between the first +// `fire()` and the watchdog arming is a window in which an orphan is created. +installLifecycle({ channel: 'buzz', stateDir: STATE_DIR }) + startPoller() await mcp.connect(new StdioServerTransport()) diff --git a/plugins/buzz/start.ts b/plugins/buzz/start.ts new file mode 100644 index 0000000..92e5b1c --- /dev/null +++ b/plugins/buzz/start.ts @@ -0,0 +1,38 @@ +// plugins/buzz/start.ts — the launcher's VOICE (DIVE-3752). +// +// [[no-beacon-has-three-states-and-only-the-process-table-separates-them]] +// measured the shape of the 2026-08-26 outage: three seats, INCLUDING the +// coordinator, deaf for 2h33m with 9 human gates pending, and the only signal +// available to a human or to the DIVE-1434 canary was an ABSENT heartbeat. An +// absence cannot say which of three failures produced it — the channel never +// started, a dependency step ate the poller, or the poller is up and not +// bumping — and the launcher's stderr goes nowhere either of them reads. A +// failure encoded as *nothing there* is the same defect as +// [[absence-encoded-as-a-value-is-read-as-presence]]. +// +// This file is the smallest thing that can speak. It is the `start` script's +// entry point instead of `server.ts`, and it imports NOTHING but node builtins +// and ./lifecycle.ts — so it still loads, and can still write a record, in +// exactly the case `server.ts` cannot: when `server.ts` throws on import +// because its dependencies are missing. That case used to be silent. +// +// Read the records with: tail ~/.claude/channels/buzz/lifecycle.log + +import { homedir } from 'node:os' +import { join } from 'node:path' +import { recordLifecycle } from './lifecycle.ts' + +const CHANNEL = 'buzz' +const STATE_DIR = join(homedir(), '.claude', 'channels', 'buzz') + +recordLifecycle(STATE_DIR, 'start', CHANNEL, 'launcher: loading server.ts') + +try { + // Never resolves while the server is healthy — `server.ts` ends in a + // top-level `await mcp.connect(...)`. It rejects when the module fails to + // load, which is the whole point of catching it here. + await import('./server.ts') +} catch (err) { + recordLifecycle(STATE_DIR, 'crash', CHANNEL, `launcher: server.ts failed to load: ${err}`) + process.exit(1) +} diff --git a/plugins/dashboard/.claude-plugin/plugin.json b/plugins/dashboard/.claude-plugin/plugin.json index 91028d3..07a4c0a 100644 --- a/plugins/dashboard/.claude-plugin/plugin.json +++ b/plugins/dashboard/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "dashboard", - "description": "5dive dashboard chat channel — talk to this agent from the web dashboard (and mobile app). Native-push inbound via the box agent-inbox drop-dir, outbound replies via the control-plane messages API.", - "version": "0.4.0", + "description": "5dive dashboard chat channel \u2014 talk to this agent from the web dashboard (and mobile app). Native-push inbound via the box agent-inbox drop-dir, outbound replies via the control-plane messages API.", + "version": "0.4.1", "author": { "name": "5dive", "email": "support@5dive.com" diff --git a/plugins/dashboard/lifecycle.ts b/plugins/dashboard/lifecycle.ts new file mode 100644 index 0000000..4fa3dd3 --- /dev/null +++ b/plugins/dashboard/lifecycle.ts @@ -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/` as the unit and +// Claude Code caches it at `/5dive-plugins///`, 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 = ` 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 + /** 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 +} diff --git a/plugins/dashboard/package.json b/plugins/dashboard/package.json index 0534316..458413f 100644 --- a/plugins/dashboard/package.json +++ b/plugins/dashboard/package.json @@ -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" diff --git a/plugins/dashboard/server.ts b/plugins/dashboard/server.ts index d49f035..6f25c43 100644 --- a/plugins/dashboard/server.ts +++ b/plugins/dashboard/server.ts @@ -30,6 +30,7 @@ import { import { readFileSync, mkdirSync, readdirSync, unlinkSync, watch, chmodSync, copyFileSync } from 'fs' import { homedir } from 'os' import { join } from 'path' +import { installLifecycle } from './lifecycle.ts' let PLUGIN_VERSION = '?' try { @@ -353,6 +354,12 @@ mcp.setRequestHandler(CallToolRequestSchema, async req => { return { content: [{ type: 'text', text: `sent (id: ${j?.id ?? '?'})` }] } }) +// DIVE-3752: same gap as buzz — this server ends in long-lived timers with no +// signal handler, no stdin handler and no exit path, so a severed parent chain +// leaves it running. The interval below is `.unref()`d, which does NOT save it: +// the MCP stdio transport holds stdin open and keeps the loop alive. +installLifecycle({ channel: 'dashboard', stateDir: STATE_DIR }) + await mcp.connect(new StdioServerTransport()) // Claude Code registers channel-notification handling shortly AFTER the MCP // connection comes up ("Channel notifications registered", ~20-50ms later) — diff --git a/plugins/dashboard/start.ts b/plugins/dashboard/start.ts new file mode 100644 index 0000000..dd1b462 --- /dev/null +++ b/plugins/dashboard/start.ts @@ -0,0 +1,38 @@ +// plugins/dashboard/start.ts — the launcher's VOICE (DIVE-3752). +// +// [[no-beacon-has-three-states-and-only-the-process-table-separates-them]] +// measured the shape of the 2026-08-26 outage: three seats, INCLUDING the +// coordinator, deaf for 2h33m with 9 human gates pending, and the only signal +// available to a human or to the DIVE-1434 canary was an ABSENT heartbeat. An +// absence cannot say which of three failures produced it — the channel never +// started, a dependency step ate the poller, or the poller is up and not +// bumping — and the launcher's stderr goes nowhere either of them reads. A +// failure encoded as *nothing there* is the same defect as +// [[absence-encoded-as-a-value-is-read-as-presence]]. +// +// This file is the smallest thing that can speak. It is the `start` script's +// entry point instead of `server.ts`, and it imports NOTHING but node builtins +// and ./lifecycle.ts — so it still loads, and can still write a record, in +// exactly the case `server.ts` cannot: when `server.ts` throws on import +// because its dependencies are missing. That case used to be silent. +// +// Read the records with: tail ~/.claude/channels/dashboard/lifecycle.log + +import { homedir } from 'node:os' +import { join } from 'node:path' +import { recordLifecycle } from './lifecycle.ts' + +const CHANNEL = 'dashboard' +const STATE_DIR = process.env.DASHBOARD_STATE_DIR ?? join(homedir(), '.claude', 'channels', 'dashboard') + +recordLifecycle(STATE_DIR, 'start', CHANNEL, 'launcher: loading server.ts') + +try { + // Never resolves while the server is healthy — `server.ts` ends in a + // top-level `await mcp.connect(...)`. It rejects when the module fails to + // load, which is the whole point of catching it here. + await import('./server.ts') +} catch (err) { + recordLifecycle(STATE_DIR, 'crash', CHANNEL, `launcher: server.ts failed to load: ${err}`) + process.exit(1) +} diff --git a/plugins/telegram-agy/lifecycle.ts b/plugins/telegram-agy/lifecycle.ts new file mode 100644 index 0000000..4fa3dd3 --- /dev/null +++ b/plugins/telegram-agy/lifecycle.ts @@ -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/` as the unit and +// Claude Code caches it at `/5dive-plugins///`, 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 = ` 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 + /** 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 +} diff --git a/plugins/telegram-agy/server.ts b/plugins/telegram-agy/server.ts index 0ef7190..c6c5404 100644 --- a/plugins/telegram-agy/server.ts +++ b/plugins/telegram-agy/server.ts @@ -36,6 +36,7 @@ import { TNA_RE, resolveTnaAnswer, OPT_RE, optionChoices, parseOptions, tapEvide // (or collision with) whatever each plugin already imports from 'fs'. import { appendFileSync as tapAppendFileSync, mkdirSync as tapMkdirSync, statSync as tapStatSync, renameSync as tapRenameSync } from 'fs' import { summarizeNeeds, reconcileBanner, type BannerState, type NeedSummary } from './banner' +import { installLifecycle } from './lifecycle.ts' const PLUGIN_VERSION = (() => { try { @@ -2856,6 +2857,12 @@ function shutdown() { process.on('SIGTERM', shutdown) process.on('SIGINT', shutdown) +// DIVE-3752: signals and stdin EOF are not sufficient. When the parent chain +// (`claude` → `bun run` → us) is severed, neither fires reliably — but POSIX +// reparents us, so the ppid clause in ./lifecycle.ts catches it. No fork had +// that clause; only `plugins/telegram` did. +installLifecycle({ channel: 'telegram-agy', stateDir: STATE_DIR, cleanup: shutdown }) + // DIVE-1251: exit when our MCP parent (the agy/TUI session) disconnects. On // /clear, the TUI RE-INITS its MCP servers — it disconnects this server.ts and // spawns a fresh one — so our stdin hits EOF. The MCP SDK's StdioServerTransport diff --git a/plugins/telegram-codex/lifecycle.ts b/plugins/telegram-codex/lifecycle.ts new file mode 100644 index 0000000..4fa3dd3 --- /dev/null +++ b/plugins/telegram-codex/lifecycle.ts @@ -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/` as the unit and +// Claude Code caches it at `/5dive-plugins///`, 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 = ` 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 + /** 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 +} diff --git a/plugins/telegram-codex/server.ts b/plugins/telegram-codex/server.ts index 3131732..d82c5eb 100644 --- a/plugins/telegram-codex/server.ts +++ b/plugins/telegram-codex/server.ts @@ -36,6 +36,7 @@ import { TNA_RE, resolveTnaAnswer, OPT_RE, optionChoices, parseOptions, tapEvide // (or collision with) whatever each plugin already imports from 'fs'. import { appendFileSync as tapAppendFileSync, mkdirSync as tapMkdirSync, statSync as tapStatSync, renameSync as tapRenameSync } from 'fs' import { summarizeNeeds, reconcileBanner, type BannerState, type NeedSummary } from './banner' +import { installLifecycle } from './lifecycle.ts' const PLUGIN_VERSION = (() => { try { @@ -3102,6 +3103,12 @@ function shutdown() { process.on('SIGTERM', shutdown) process.on('SIGINT', shutdown) +// DIVE-3752: signals and stdin EOF are not sufficient. When the parent chain +// (`claude` → `bun run` → us) is severed, neither fires reliably — but POSIX +// reparents us, so the ppid clause in ./lifecycle.ts catches it. No fork had +// that clause; only `plugins/telegram` did. +installLifecycle({ channel: 'telegram-codex', stateDir: STATE_DIR, cleanup: shutdown }) + // DIVE-1251: exit when our MCP parent (the codex/TUI session) disconnects. On // /clear, codex RE-INITS its MCP servers — it disconnects this server.ts and // spawns a fresh one — so our stdin hits EOF. The MCP SDK's StdioServerTransport diff --git a/plugins/telegram-grok/lifecycle.ts b/plugins/telegram-grok/lifecycle.ts new file mode 100644 index 0000000..4fa3dd3 --- /dev/null +++ b/plugins/telegram-grok/lifecycle.ts @@ -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/` as the unit and +// Claude Code caches it at `/5dive-plugins///`, 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 = ` 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 + /** 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 +} diff --git a/plugins/telegram-grok/server.ts b/plugins/telegram-grok/server.ts index 5ec95bf..24247be 100755 --- a/plugins/telegram-grok/server.ts +++ b/plugins/telegram-grok/server.ts @@ -36,6 +36,7 @@ import { TNA_RE, resolveTnaAnswer, OPT_RE, optionChoices, parseOptions, tapEvide // (or collision with) whatever each plugin already imports from 'fs'. import { appendFileSync as tapAppendFileSync, mkdirSync as tapMkdirSync, statSync as tapStatSync, renameSync as tapRenameSync } from 'fs' import { summarizeNeeds, reconcileBanner, type BannerState, type NeedSummary } from './banner' +import { installLifecycle } from './lifecycle.ts' const PLUGIN_VERSION = (() => { try { @@ -2930,6 +2931,12 @@ function shutdown() { process.on('SIGTERM', shutdown) process.on('SIGINT', shutdown) +// DIVE-3752: signals and stdin EOF are not sufficient. When the parent chain +// (`claude` → `bun run` → us) is severed, neither fires reliably — but POSIX +// reparents us, so the ppid clause in ./lifecycle.ts catches it. No fork had +// that clause; only `plugins/telegram` did. +installLifecycle({ channel: 'telegram-grok', stateDir: STATE_DIR, cleanup: shutdown }) + // DIVE-1251: exit when our MCP parent (the grok/TUI session) disconnects. On // /clear, the TUI RE-INITS its MCP servers — it disconnects this server.ts and // spawns a fresh one — so our stdin hits EOF. The MCP SDK's StdioServerTransport diff --git a/plugins/telegram-opencode/lifecycle.ts b/plugins/telegram-opencode/lifecycle.ts new file mode 100644 index 0000000..4fa3dd3 --- /dev/null +++ b/plugins/telegram-opencode/lifecycle.ts @@ -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/` as the unit and +// Claude Code caches it at `/5dive-plugins///`, 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 = ` 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 + /** 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 +} diff --git a/plugins/telegram-opencode/server.ts b/plugins/telegram-opencode/server.ts index 6766fe4..5fc3621 100644 --- a/plugins/telegram-opencode/server.ts +++ b/plugins/telegram-opencode/server.ts @@ -36,6 +36,7 @@ import { import { randomBytes } from 'crypto' import { homedir } from 'os' import { join, sep } from 'path' +import { installLifecycle } from './lifecycle.ts' const PLUGIN_VERSION = (() => { try { @@ -1898,6 +1899,14 @@ function shutdown() { process.on('SIGTERM', shutdown) process.on('SIGINT', shutdown) +// DIVE-3752: signals and stdin EOF are not sufficient. When the parent chain +// (`claude` → `bun run` → us) is severed, neither fires reliably — but POSIX +// reparents us, so the ppid clause in ./lifecycle.ts catches it. No fork had +// that clause; only `plugins/telegram` did. +// This fork's own shutdown() stops the bot but never calls process.exit, so +// installLifecycle's force-exit backstop is what actually ends the process. +installLifecycle({ channel: 'telegram-opencode', stateDir: STATE_DIR, cleanup: shutdown }) + loadSessions() await ensureServer() void startEventRelay() diff --git a/plugins/telegram-pi/lifecycle.ts b/plugins/telegram-pi/lifecycle.ts new file mode 100644 index 0000000..4fa3dd3 --- /dev/null +++ b/plugins/telegram-pi/lifecycle.ts @@ -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/` as the unit and +// Claude Code caches it at `/5dive-plugins///`, 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 = ` 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 + /** 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 +} diff --git a/plugins/telegram-pi/server.ts b/plugins/telegram-pi/server.ts index 2c55fb8..ad5e742 100644 --- a/plugins/telegram-pi/server.ts +++ b/plugins/telegram-pi/server.ts @@ -51,6 +51,7 @@ import { import { randomBytes } from 'crypto' import { homedir } from 'os' import { join, sep } from 'path' +import { installLifecycle } from './lifecycle.ts' const PLUGIN_VERSION = (() => { try { @@ -1971,6 +1972,14 @@ function shutdown() { process.on('SIGTERM', shutdown) process.on('SIGINT', shutdown) +// DIVE-3752: signals and stdin EOF are not sufficient. When the parent chain +// (`claude` → `bun run` → us) is severed, neither fires reliably — but POSIX +// reparents us, so the ppid clause in ./lifecycle.ts catches it. No fork had +// that clause; only `plugins/telegram` did. +// This fork's own shutdown() stops the bot but never calls process.exit, so +// installLifecycle's force-exit backstop is what actually ends the process. +installLifecycle({ channel: 'telegram-pi', stateDir: STATE_DIR, cleanup: shutdown }) + // DIVE-1503/1558 pinned-banner store I/O. Heuristic state: a lost read/write only // costs one redundant banner send, never worth failing anything over. function readBannerStore(): Record { diff --git a/plugins/telegram/.claude-plugin/plugin.json b/plugins/telegram/.claude-plugin/plugin.json index c911d31..8776e31 100644 --- a/plugins/telegram/.claude-plugin/plugin.json +++ b/plugins/telegram/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "telegram", - "description": "Telegram channel for Claude Code — messaging bridge with built-in access control, bundled lifecycle hooks (AskUserQuestion intercept, Stop-reply safety net), and a notify-user comms-playbook skill. Fork of Anthropic's telegram plugin, maintained by 5dive.", - "version": "0.5.48", + "description": "Telegram channel for Claude Code \u2014 messaging bridge with built-in access control, bundled lifecycle hooks (AskUserQuestion intercept, Stop-reply safety net), and a notify-user comms-playbook skill. Fork of Anthropic's telegram plugin, maintained by 5dive.", + "version": "0.5.49", "author": { "name": "5dive", "email": "support@5dive.com" diff --git a/plugins/telegram/lifecycle.ts b/plugins/telegram/lifecycle.ts new file mode 100644 index 0000000..4fa3dd3 --- /dev/null +++ b/plugins/telegram/lifecycle.ts @@ -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/` as the unit and +// Claude Code caches it at `/5dive-plugins///`, 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 = ` 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 + /** 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 +} diff --git a/plugins/telegram/package.json b/plugins/telegram/package.json index 0fb36cc..294992c 100644 --- a/plugins/telegram/package.json +++ b/plugins/telegram/package.json @@ -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", diff --git a/plugins/telegram/server.ts b/plugins/telegram/server.ts index 4dc0223..6148aaf 100644 --- a/plugins/telegram/server.ts +++ b/plugins/telegram/server.ts @@ -36,6 +36,7 @@ import { resolveQuestionTap } from './hooks/lib/question-bridge' import { sweepStaleRelayIn } from './hooks/lib/relay-quarantine' import { summarizeNeeds, reconcileBanner, type BannerState, type NeedSummary } from './banner' import { +import { installLifecycle } from './lifecycle.ts' appendMessage as msglogAppend, readMessages as msglogRead, formatRecent as msglogFormat, @@ -1621,17 +1622,21 @@ process.on('SIGTERM', shutdown) process.on('SIGINT', shutdown) process.on('SIGHUP', shutdown) -// Orphan watchdog: stdin events above don't reliably fire when the parent -// chain (`bun run` wrapper → shell → us) is severed by a crash. Poll for -// reparenting (POSIX) or a dead stdin pipe and self-terminate. -const bootPpid = process.ppid -setInterval(() => { - const orphaned = - (process.platform !== 'win32' && process.ppid !== bootPpid) || - process.stdin.destroyed || - process.stdin.readableEnded - if (orphaned) shutdown() -}, 5000).unref() +// DIVE-3752 replaced the inline orphan watchdog that used to live here. +// +// It read, in full: +// const bootPpid = process.ppid +// setInterval(() => { +// const orphaned = +// (process.platform !== 'win32' && process.ppid !== bootPpid) || ... +// +// and its ppid clause COULD NOT FIRE: measured 2026-08-26, Bun caches +// `process.ppid` at boot and never refreshes it, so an orphan compares its +// dead parent's pid against itself forever. This plugin's zero-orphan record +// came from the stdin handlers above, not from that comparison — which is the +// one case the comparison existed to cover. ./lifecycle.ts reads the kernel's +// answer instead, and is the same module every other plugin now installs. +installLifecycle({ channel: 'telegram', stateDir: STATE_DIR, cleanup: shutdown }) // Find the most-recently-updated claude session file. Each running claude // process writes ~/.claude/sessions/.json with status/uptime metadata. diff --git a/plugins/telegram/start.ts b/plugins/telegram/start.ts new file mode 100644 index 0000000..c284601 --- /dev/null +++ b/plugins/telegram/start.ts @@ -0,0 +1,38 @@ +// plugins/telegram/start.ts — the launcher's VOICE (DIVE-3752). +// +// [[no-beacon-has-three-states-and-only-the-process-table-separates-them]] +// measured the shape of the 2026-08-26 outage: three seats, INCLUDING the +// coordinator, deaf for 2h33m with 9 human gates pending, and the only signal +// available to a human or to the DIVE-1434 canary was an ABSENT heartbeat. An +// absence cannot say which of three failures produced it — the channel never +// started, a dependency step ate the poller, or the poller is up and not +// bumping — and the launcher's stderr goes nowhere either of them reads. A +// failure encoded as *nothing there* is the same defect as +// [[absence-encoded-as-a-value-is-read-as-presence]]. +// +// This file is the smallest thing that can speak. It is the `start` script's +// entry point instead of `server.ts`, and it imports NOTHING but node builtins +// and ./lifecycle.ts — so it still loads, and can still write a record, in +// exactly the case `server.ts` cannot: when `server.ts` throws on import +// because its dependencies are missing. That case used to be silent. +// +// Read the records with: tail ~/.claude/channels/telegram/lifecycle.log + +import { homedir } from 'node:os' +import { join } from 'node:path' +import { recordLifecycle } from './lifecycle.ts' + +const CHANNEL = 'telegram' +const STATE_DIR = process.env.TELEGRAM_STATE_DIR ?? join(homedir(), '.claude', 'channels', 'telegram') + +recordLifecycle(STATE_DIR, 'start', CHANNEL, 'launcher: loading server.ts') + +try { + // Never resolves while the server is healthy — `server.ts` ends in a + // top-level `await mcp.connect(...)`. It rejects when the module fails to + // load, which is the whole point of catching it here. + await import('./server.ts') +} catch (err) { + recordLifecycle(STATE_DIR, 'crash', CHANNEL, `launcher: server.ts failed to load: ${err}`) + process.exit(1) +} diff --git a/test/lifecycle.test.ts b/test/lifecycle.test.ts new file mode 100644 index 0000000..2065cfd --- /dev/null +++ b/test/lifecycle.test.ts @@ -0,0 +1,301 @@ +// DIVE-3752 — the orphan watchdog, the launcher's voice, and the de-silenced +// channel start. +// +// Three arms, because the row's suggested acceptance names two traps and the +// repo's own history names a third: +// +// 1. UNIT — the decision function and the record format, executed. These are +// pure, so a bare `bun test` with no plugin dependencies installed can +// actually run them. +// 2. STATIC — every plugin that ends in a long-lived timer has watchdog +// coverage, and all copies of lifecycle.ts are byte-identical. Comments +// are STRIPPED before the assertion: "a bare `does it have a process.on` +// grep is satisfiable by a comment" (DIVE-3752 body), and +// [[extracting-a-rule-to-test-it-does-not-arm-the-caller]] — a pure module +// that no server calls is not installed. +// 3. END-TO-END — spawn a real process behind a real parent, sever the parent, +// assert the child exits. Armed with a POSITIVE CONTROL: a test asserting +// "it exits" passes trivially if the process never started, so we first +// prove the child was ALIVE and heartbeating with the parent up. + +import { describe, test, expect } from 'bun:test' +import { readFileSync, existsSync, mkdtempSync, rmSync, writeFileSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { + isOrphaned, lifecycleLine, trimRecords, recordLifecycle, RECORD_FILE, + readRealPpid, isBootParentAlive, +} from '../plugins/buzz/lifecycle.ts' + +const PLUGINS = join(import.meta.dir, '..', 'plugins') + +// Every plugin whose server.ts ends in a long-lived timer, i.e. every plugin +// that can be left running after its parent dies. Enumerated, not globbed: a +// new plugin should FAIL this list until someone decides which column it is in. +const TIMER_PLUGINS = [ + 'telegram', 'buzz', 'dashboard', + 'telegram-agy', 'telegram-codex', 'telegram-grok', 'telegram-pi', 'telegram-opencode', +] as const +// Nothing is exempt. `telegram` was, until its inline watchdog turned out to +// carry a clause that cannot fire under Bun (see ./lifecycle.ts) — a broken +// implementation is not a proven one, so it installs the module too. + +function src(plugin: string, file = 'server.ts'): string { + return readFileSync(join(PLUGINS, plugin, file), 'utf8') +} + +// Strip // line comments and /* */ blocks so a rule can never be satisfied by +// prose about the rule. Deliberately crude: it may eat a `//` inside a string +// literal, which can only make an assertion STRICTER, never falsely green. +function stripComments(s: string): string { + return s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1') +} + +// ── 1. unit: the orphan decision ──────────────────────────────────────────── + +describe('isOrphaned', () => { + const live = { + platform: 'linux', bootPpid: 100, currentPpid: 100, bootParentAlive: true, + stdinDestroyed: false, stdinReadableEnded: false, + } + + test('a healthy child of a live boot parent is not orphaned', () => { + expect(isOrphaned(live)).toBe(false) + }) + + test('reparenting is the clause stdin cannot provide', () => { + // Both stdin clauses are false here — the severed-parent case the whole + // module exists for — so the verdict must come from the ppid reading. + expect(isOrphaned({ ...live, currentPpid: 1, bootParentAlive: false })).toBe(true) + }) + + test('a dead boot parent is enough on its own', () => { + // The fallback that covers an unreadable /proc: `readRealPpid` then returns + // the STALE cached ppid, so the reparenting clause goes quiet and this is + // the only reading left. + expect(isOrphaned({ ...live, currentPpid: 100, bootParentAlive: false })).toBe(true) + }) + + test('stdin EOF still counts, on any platform', () => { + expect(isOrphaned({ ...live, stdinDestroyed: true })).toBe(true) + expect(isOrphaned({ ...live, stdinReadableEnded: true })).toBe(true) + expect(isOrphaned({ ...live, platform: 'win32', stdinDestroyed: true })).toBe(true) + }) + + test('on win32 reparenting is not observable, so it must not be trusted', () => { + expect(isOrphaned({ ...live, platform: 'win32', currentPpid: 1, bootParentAlive: false })).toBe(false) + }) +}) + +describe('the ppid reading is the KERNEL\'s, not Bun\'s cached one', () => { + // MEASURED: under Bun `process.ppid` is captured at boot and never updated. + // An orphan therefore compares a dead pid against itself and stays alive. + // These two assertions are what stop that clause coming back. + test('readRealPpid agrees with /proc for a live process', () => { + expect(readRealPpid()).toBe(parseInt( + readFileSync('/proc/self/status', 'utf8').match(/^PPid:\s*(\d+)/m)![1], 10)) + }) + + test('isBootParentAlive is true for a live pid and false for a reaped one', () => { + expect(isBootParentAlive(process.pid)).toBe(true) + // pid 1 and 0 are "no meaningful parent to lose" — never report orphaned. + expect(isBootParentAlive(1)).toBe(true) + expect(isBootParentAlive(0)).toBe(true) + // A pid that cannot exist. + expect(isBootParentAlive(0x7ffffff0)).toBe(false) + }) + + test('no plugin compares process.ppid to a boot snapshot', () => { + // The dead clause, as a regression guard across every plugin. + for (const p of TIMER_PLUGINS) { + const code = stripComments(src(p)) + expect(code).not.toMatch(/process\.ppid\s*!==/) + expect(code).not.toMatch(/const\s+bootPpid\s*=\s*process\.ppid/) + } + }) +}) + +// ── 1b. unit: the record ──────────────────────────────────────────────────── + +describe('the lifecycle record', () => { + test('one event is one line, even when the reason contains newlines', () => { + const l = lifecycleLine('crash', 'buzz', 'boom\nand\ttabs\r\n', new Date(0), 7, 8) + expect(l.includes('\n')).toBe(false) + expect(l).toBe('1970-01-01T00:00:00.000Z\tcrash\tbuzz\tpid=7\tppid=8\tboom and tabs') + }) + + test('an empty reason still produces a parseable line', () => { + expect(lifecycleLine('exit', 'buzz', ' ', new Date(0), 1, 1).endsWith('\t-')).toBe(true) + }) + + test('a long reason is bounded, so one bad error cannot be the whole file', () => { + const l = lifecycleLine('crash', 'buzz', 'x'.repeat(5000), new Date(0), 1, 1) + expect(l.length).toBeLessThan(420) + }) + + test('trimRecords keeps the NEWEST lines', () => { + const body = Array.from({ length: 10 }, (_, i) => `line${i}`).join('\n') + expect(trimRecords(body, 3).split('\n')).toEqual(['line7', 'line8', 'line9']) + expect(trimRecords('', 3)).toBe('') + expect(trimRecords('a\n', 3)).toBe('a') + }) + + test('recordLifecycle creates the state dir and appends', () => { + const dir = mkdtempSync(join(tmpdir(), 'dive3752-')) + try { + const nested = join(dir, 'channels', 'buzz') + recordLifecycle(nested, 'start', 'buzz', 'boot ok') + recordLifecycle(nested, 'exit', 'buzz', 'SIGTERM') + const body = readFileSync(join(nested, RECORD_FILE), 'utf8') + const lines = body.split('\n').filter(Boolean) + expect(lines.length).toBe(2) + expect(lines[0]).toContain('\tstart\tbuzz\t') + expect(lines[1]).toContain('\tSIGTERM') + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + test('an unwritable state dir does not throw — a diary must not kill a poller', () => { + // A shutdown path that throws is a shutdown path that does not shut down. + expect(() => recordLifecycle('/proc/definitely/not/writable', 'exit', 'buzz', 'x')).not.toThrow() + }) +}) + +// ── 2. static: it is INSTALLED, not merely available ──────────────────────── + +describe('the watchdog is installed in every plugin that can orphan', () => { + test('all copies of lifecycle.ts are byte-identical', () => { + // The marketplace publishes ./plugins/ as the unit and Claude Code + // caches it per-plugin, so an import reaching outside the plugin directory + // resolves in this repo and NOT on a customer box. Duplication is forced; + // silent drift between the duplicates is not. + const bodies = new Map() + for (const p of TIMER_PLUGINS) { + const f = join(PLUGINS, p, 'lifecycle.ts') + expect(existsSync(f)).toBe(true) + bodies.set(p, readFileSync(f, 'utf8')) + } + expect(new Set(bodies.values()).size).toBe(1) + }) + + for (const p of TIMER_PLUGINS) { + test(`${p}: has the ppid clause and a full handler set (comments stripped)`, () => { + const code = stripComments(src(p)) + // The CALL, not the import: importing a module arms nobody + // ([[extracting-a-rule-to-test-it-does-not-arm-the-caller]]). + expect(code).toMatch(/installLifecycle\(\{/) + expect(code).toContain(`channel: '${p}'`) + expect(code).toContain('stateDir: STATE_DIR') + // …and the module it calls is the one that binds the signals. + const lc = stripComments(src(p, 'lifecycle.ts')) + expect(lc).toContain("process.on('SIGTERM'") + expect(lc).toContain("process.on('SIGHUP'") + expect(lc).toMatch(/process\.stdin\.on\('end'/) + }) + } +}) + +// ── 2b. static: the deafener cannot come back ─────────────────────────────── + +describe('the channel start is not gated on a network step', () => { + const WITH_INSTALL = ['telegram', 'buzz', 'dashboard'] as const + + for (const p of TIMER_PLUGINS) { + test(`${p}: the start script never guards the server behind &&`, () => { + const pkg = JSON.parse(src(p, 'package.json')) as { scripts?: Record } + const start = pkg.scripts?.start ?? '' + expect(start).toBeTruthy() + // MEASURED (bun run --shell=bun): `false && echo X` exits 1 and never + // echoes; `false; echo X` echoes and exits 0. So `&&` in front of the + // server is exactly the mechanism by which a failed install becomes a + // deaf seat with no error anyone reads. + expect(start).not.toContain('&&') + // …and the server is still actually started. + expect(start).toMatch(/bun (start|server)\.ts/) + }) + } + + for (const p of WITH_INSTALL) { + test(`${p}: goes through start.ts, which can speak when server.ts cannot`, () => { + const pkg = JSON.parse(src(p, 'package.json')) as { scripts?: Record } + expect(pkg.scripts?.start).toContain('bun start.ts') + const boot = stripComments(src(p, 'start.ts')) + expect(boot).toContain('recordLifecycle') + expect(boot).toContain("await import('./server.ts')") + // start.ts must import node builtins + lifecycle.ts ONLY: it has to load + // in the one case server.ts cannot — deps missing. + const imports = [...boot.matchAll(/from\s+'([^']+)'/g)].map(m => m[1]) + for (const i of imports) { + expect(i === './lifecycle.ts' || i.startsWith('node:')).toBe(true) + } + }) + } +}) + +// ── 3. end-to-end: sever the parent, with a positive control ──────────────── + +describe('a severed parent actually kills the poller', () => { + test('the child heartbeats while the parent lives, then exits when orphaned', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dive3752-e2e-')) + try { + const beat = join(dir, 'beat') + const harness = join(dir, 'harness.ts') + // A faithful miniature of plugins/buzz/server.ts before this change: a + // long-lived, NON-unref'd interval, plus the lifecycle we just installed. + writeFileSync(harness, ` +import { writeFileSync } from 'node:fs' +import { installLifecycle } from ${JSON.stringify(join(PLUGINS, 'buzz', 'lifecycle.ts'))} +installLifecycle({ channel: 'harness', stateDir: ${JSON.stringify(dir)}, watchdogMs: 500, forceExitMs: 200 }) +// This is what keeps the process alive — the poller's own timer, unref'd by +// nobody. The watchdog is unref'd and must never be the thing holding it open. +setInterval(() => writeFileSync(${JSON.stringify(beat)}, String(Date.now())), 100) +`) + // The grandchild shape that actually reproduces this: claude → bun run → us. + // `bun harness.ts & wait` (not `exec`) so killing the shell leaves the bun + // process reparented, which is the ONLY thing that changes its ppid. + const parent = Bun.spawn(['bash', '-c', `bun ${harness} & echo $! > ${dir}/child.pid; wait`], { + // A pipe we hold open: the real MCP stdio transport keeps stdin open, and + // an 'ignore'd stdin would hit the EOF clause instantly and pass this + // test for the wrong reason. + stdin: 'pipe', stdout: 'ignore', stderr: 'ignore', + }) + + const wait = async (p: () => boolean, ms: number) => { + const end = Date.now() + ms + while (Date.now() < end) { if (p()) return true; await Bun.sleep(50) } + return false + } + + // ── POSITIVE CONTROL ── + // Without this, "the child exited" is satisfied by a child that never ran. + expect(await wait(() => existsSync(beat), 15_000)).toBe(true) + const pid = parseInt(readFileSync(join(dir, 'child.pid'), 'utf8').trim(), 10) + expect(pid).toBeGreaterThan(0) + const alive = () => { try { process.kill(pid, 0); return true } catch { return false } } + expect(alive()).toBe(true) + // …and it is STILL beating a second later, i.e. the watchdog is not just + // firing on boot for some unrelated reason. + const first = statSync(beat).mtimeMs + await Bun.sleep(1200) + expect(statSync(beat).mtimeMs).toBeGreaterThan(first) + expect(alive()).toBe(true) + + // ── sever the parent ── + parent.kill('SIGKILL') + await parent.exited + + // watchdogMs is 500 here (5000 in production); 15s is generous slack. + // Before the /proc read replaced `process.ppid`, this line was the one + // that failed — the child sat alive for the full 15s. + expect(await wait(() => !alive(), 15_000)).toBe(true) + + // And it said why, rather than just vanishing. + const rec = readFileSync(join(dir, RECORD_FILE), 'utf8') + expect(rec).toContain('\tstart\tharness\t') + expect(rec).toMatch(/\texit\tharness\t.*orphaned/) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }, 60_000) +}) From 22f9063aca1889f7eb3981c5c396092475542ef2 Mon Sep 17 00:00:00 2001 From: lodar Date: Wed, 26 Aug 2026 07:43:36 +0000 Subject: [PATCH 2/3] DIVE-3752: teach the fork generator about lifecycle.ts (CI drift gate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGES.md | 8 ++++++++ generator/generate.ts | 21 ++++++++++++++------- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index dcecb26..a2d5c51 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -54,6 +54,14 @@ only — so it still loads, and can still write a record, in the one case `serve `server.ts` throws on import because its dependencies are missing. Start, exit and crash records go to `/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. diff --git a/generator/generate.ts b/generator/generate.ts index 38c33fe..d3e98a3 100644 --- a/generator/generate.ts +++ b/generator/generate.ts @@ -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', @@ -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 From 8afc60f4d0f428b872b957fb4f51645fea98d574 Mon Sep 17 00:00:00 2001 From: lodar Date: Wed, 26 Aug 2026 09:14:13 +0000 Subject: [PATCH 3/3] DIVE-3752 it2: fix the unparseable telegram entry point, and gate it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/parity.yml | 15 ++++ plugins/telegram/server.ts | 2 +- test/entrypoint-parse.test.ts | 147 ++++++++++++++++++++++++++++++++++ 3 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 test/entrypoint-parse.test.ts diff --git a/.github/workflows/parity.yml b/.github/workflows/parity.yml index 6f4cb75..807bcc4 100644 --- a/.github/workflows/parity.yml +++ b/.github/workflows/parity.yml @@ -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 diff --git a/plugins/telegram/server.ts b/plugins/telegram/server.ts index 6148aaf..68d9142 100644 --- a/plugins/telegram/server.ts +++ b/plugins/telegram/server.ts @@ -35,8 +35,8 @@ import { renderRoster, renderLog, renderLineage, renderVerify, COUNCIL_BUTTONS, import { resolveQuestionTap } from './hooks/lib/question-bridge' import { sweepStaleRelayIn } from './hooks/lib/relay-quarantine' import { summarizeNeeds, reconcileBanner, type BannerState, type NeedSummary } from './banner' -import { import { installLifecycle } from './lifecycle.ts' +import { appendMessage as msglogAppend, readMessages as msglogRead, formatRecent as msglogFormat, diff --git a/test/entrypoint-parse.test.ts b/test/entrypoint-parse.test.ts new file mode 100644 index 0000000..2a00cfe --- /dev/null +++ b/test/entrypoint-parse.test.ts @@ -0,0 +1,147 @@ +// DIVE-3752 iteration 2 — NOTHING IN THIS REPO PARSES AN ENTRY POINT. +// +// Iteration 1 shipped a `plugins/telegram/server.ts` whose new import had been +// inserted INSIDE another import's brace list. Every existing gate was green: +// +// * `bun test` — 933/0, but no test imports any `plugins/*/server.ts` +// (repo CI runs with no plugin deps, so a server import would explode on +// `grammy` long before it ever reached a syntax error). +// * `bun generator/generate.ts --check` — byte-exact, because the generator +// is a TEXT TRANSFORM. It never parses what it copies. +// * parity — the only workflow, and it is those two steps. +// +// And the defect hid from a differential the way only this repo's shape allows: +// the five generated forks parsed FINE at the same line, because the generator +// deletes the whole msglog/council/gatereply block for forks and swallowed the +// orphaned `import {` opener on the way through. 7 clean / 1 broken, and the +// broken one is the BASE the other five are generated from. +// +// So the gate has to be a real parse, it has to cover the base and not just its +// derivatives, and it has to be runnable with ZERO plugin dependencies +// installed — which `bun build --no-bundle` is: it transpiles without resolving +// a single import specifier. Measured on a worktree with no node_modules in any +// of the eight plugin dirs. + +import { describe, test, expect } from 'bun:test' +import { readdirSync, readFileSync, existsSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' + +const PLUGINS = join(import.meta.dir, '..', 'plugins') + +// Transpile-only. Returns null on success, the compiler's stderr on failure. +// No bundling, so no import is resolved and no dependency needs to exist. +function parseError(file: string): string | null { + const r = Bun.spawnSync(['bun', 'build', '--no-bundle', file, '--outfile=/dev/null'], { + stdout: 'pipe', stderr: 'pipe', + }) + if (r.exitCode === 0) return null + return (r.stderr.toString() + r.stdout.toString()).trim() || `exit ${r.exitCode}` +} + +function tsFiles(): string[] { + const out: string[] = [] + const walk = (dir: string) => { + for (const e of readdirSync(dir, { withFileTypes: true })) { + if (e.name === 'node_modules' || e.name.startsWith('.')) continue + const p = join(dir, e.name) + if (e.isDirectory()) walk(p) + else if (e.name.endsWith('.ts')) out.push(p) + } + } + walk(PLUGINS) + return out.sort() +} + +const PLUGIN_DIRS = readdirSync(PLUGINS, { withFileTypes: true }) + .filter(e => e.isDirectory() && e.name !== 'node_modules') + .map(e => e.name) + .sort() + +// ── the gate itself ───────────────────────────────────────────────────────── + +describe('every plugin TypeScript file parses', () => { + const files = tsFiles() + + test('the sweep is not vacuous — it found the servers it is supposed to grade', () => { + expect(files.length).toBeGreaterThan(20) + for (const d of PLUGIN_DIRS) { + expect(files).toContain(join(PLUGINS, d, 'server.ts')) + } + }) + + for (const f of tsFiles()) { + test(f.slice(f.indexOf('plugins/')), () => { + expect(parseError(f)).toBeNull() + }) + } +}) + +// ── the entry points specifically, derived from what actually launches ────── +// +// Not a hardcoded list: the launcher runs `bun run start`, so the file named by +// each package.json's `start` script IS the entry point. A plugin that renames +// its entry point must not be able to fall out of this gate silently. + +describe('the file each plugin actually launches parses', () => { + for (const d of PLUGIN_DIRS) { + const pkgPath = join(PLUGINS, d, 'package.json') + if (!existsSync(pkgPath)) continue + const start = String(JSON.parse(readFileSync(pkgPath, 'utf8'))?.scripts?.start ?? '') + const named = [...start.matchAll(/bun\s+([A-Za-z0-9_.\-/]+\.ts)/g)].map(m => m[1]) + + test(`${d}: start script names a .ts entry point`, () => { + expect(named.length).toBeGreaterThan(0) + }) + + for (const rel of named) { + test(`${d}: ${rel} parses`, () => { + const abs = join(PLUGINS, d, rel) + expect(existsSync(abs)).toBe(true) + expect(parseError(abs)).toBeNull() + }) + } + } +}) + +// ── positive control: prove the gate can FAIL ─────────────────────────────── +// +// A sweep that returns "all clean" is worth nothing until the same command has +// been shown to reject the exact defect it exists for. This reproduces +// iteration 1's shape — an `import` statement inside another import's brace +// list — and requires the gate to reject it. + +describe('the gate can fire', () => { + test('an import nested inside another import\'s brace list is REJECTED', () => { + const dir = mkdtempSync(join(tmpdir(), 'dive3752-parse-')) + try { + const bad = join(dir, 'bad.ts') + writeFileSync(bad, [ + `import { summarizeNeeds } from './banner'`, + `import {`, + `import { installLifecycle } from './lifecycle.ts'`, + ` appendMessage as msglogAppend,`, + `} from './msglog'`, + ``, + ].join('\n')) + const err = parseError(bad) + expect(err).not.toBeNull() + expect(err).toContain('error') + + // ...and the well-formed version of the same file passes, so the control + // is grading the DEFECT and not merely the temp directory. + const good = join(dir, 'good.ts') + writeFileSync(good, [ + `import { summarizeNeeds } from './banner'`, + `import { installLifecycle } from './lifecycle.ts'`, + `import {`, + ` appendMessage as msglogAppend,`, + `} from './msglog'`, + ``, + ].join('\n')) + expect(parseError(good)).toBeNull() + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +})