diff --git a/plugins/telegram-codex/dispatcher.ts b/plugins/telegram-codex/dispatcher.ts index dbd78cd..dfbaafb 100644 --- a/plugins/telegram-codex/dispatcher.ts +++ b/plugins/telegram-codex/dispatcher.ts @@ -10,6 +10,10 @@ import { ChannelDispatcher, parseOutboundMessage, type DispatchMessage, type DispatchRoute, } from './dispatcher-core.ts' import { installLifecycle, recordLifecycle } from './lifecycle.ts' +import { + HEALTH_HEARTBEAT_MS, HEALTH_SCHEMA, writeHealth, + type ChannelHealth, type HealthFailure, +} from './health.ts' const STATE_DIR = process.env.CODEX_DISPATCHER_STATE_DIR ?? join(homedir(), '.codex', 'channels', 'dispatcher') @@ -25,6 +29,55 @@ for (const dir of [STATE_DIR, INBOX_DIR, OUTBOX_DIR, join(OUTBOX_DIR, 'telegram' mkdirSync(dir, { recursive: true, mode: 0o700 }) } +// ── the handshake (DIVE-3964) ─────────────────────────────────────────────── +// +// Everything a reader needs to tell a bound bridge from a deaf one, asserted by +// the bridge itself on an interval. `updatedAt` is the liveness signal: a +// record that stopped moving is positive evidence of a dead bridge, which is +// the reading the pane-banner probe could never produce. See health.ts. + +const BRIDGE_VERSION: string = (() => { + try { + return String(JSON.parse(readFileSync(join(import.meta.dir, 'package.json'), 'utf8')).version ?? 'unknown') + } catch { return 'unknown' } +})() + +const health: ChannelHealth = { + schema: HEALTH_SCHEMA, + bridge: 'codex-dispatcher', + bridgeVersion: BRIDGE_VERSION, + pid: process.pid, + startedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + heartbeatMs: HEALTH_HEARTBEAT_MS, + declared: [...CHANNELS], + listening: [], + bound: false, + queueDepth: 0, +} + +function publishHealth(): void { + health.updatedAt = new Date().toISOString() + // Read the queue and the active turn off the dispatcher rather than + // maintaining a second copy: a counter that drifts from the state it claims + // to describe is worse than no counter. + try { + const snap = dispatcher.snapshot() + health.threadId = snap.threadId + health.queueDepth = snap.pending.length + health.active = snap.active + ? { turnId: snap.active.turnId, source: snap.active.route.source, startedAt: health.active?.turnId === snap.active.turnId ? health.active.startedAt : new Date().toISOString() } + : undefined + } catch {} + writeHealth(STATE_DIR, health) +} + +function markFailure(channel: string, cause: string): void { + const failure: HealthFailure = { at: new Date().toISOString(), channel, cause } + health.failure = failure + publishHealth() +} + class JsonRpcProcess { private child: ChildProcessWithoutNullStreams private nextId = 1 @@ -134,6 +187,7 @@ function stateStore() { let outSeq = 0 async function publish(route: DispatchRoute, text: string, meta: Record): Promise { const outbound = parseOutboundMessage(text) + health.lastOutboundAt = new Date().toISOString() process.stdout.write(`${outbound.text}\n`) if (route.source === 'agent') return if (!outbound.text) throw new Error('dispatcher reply has no text after attachment directives') @@ -155,6 +209,11 @@ async function initialize(): Promise { }) rpc.notify('initialized', {}) await dispatcher.initialize() + // BOUND means a live Codex thread, not "the process started". Everything + // above can succeed and still leave the bridge unable to run a turn. + health.bound = Boolean(dispatcher.snapshot().threadId) + health.failure = undefined + publishHealth() process.stderr.write(`codex-dispatcher: ready thread=${dispatcher.snapshot().threadId} cwd=${WORKDIR}\n`) } @@ -179,12 +238,15 @@ function ingest(name: string): void { try { unlinkSync(full) } catch {} return } + health.lastInboundAt = new Date().toISOString() void dispatcher.submit(msg).then(outcome => { + publishHealth() try { unlinkSync(full) } catch {} process.stderr.write(`codex-dispatcher: ${outcome} ${msg.id} source=${msg.route.source}\n`) }).catch(err => { // Keep the file: a run-loop restart will retry it after app-server recovers. process.stderr.write(`codex-dispatcher: dispatch failed for ${msg.id}: ${err}\n`) + markFailure(msg.route.source, `dispatch failed for ${msg.id}: ${err}`) setTimeout(() => ingest(name), 1000).unref?.() }) } @@ -197,14 +259,23 @@ function startInbox(): void { } const children: ChildProcessWithoutNullStreams[] = [] -function startAdapter(file: string, extraEnv: Record): void { +function startAdapter(file: string, channel: string, extraEnv: Record): void { const child = spawn(BUN_BIN, [file], { cwd: WORKDIR, env: { ...process.env, CODEX_DISPATCHER_STATE_DIR: STATE_DIR, ...extraEnv }, stdio: ['ignore', 'inherit', 'inherit'], }) children.push(child) + // Listening is claimed at spawn and RETRACTED on exit. The retraction is the + // load-bearing half: a dead telegram adapter beside a live dispatcher is + // precisely the `mismatched` state, and without it the record would keep + // asserting a channel nobody is serving. + if (!health.listening.includes(channel)) health.listening.push(channel) + publishHealth() child.once('exit', (code, signal) => { + health.listening = health.listening.filter(c => c !== channel) + const why = `adapter exited code=${code ?? 'null'} signal=${signal ?? 'none'}` + markFailure(channel, why) if (!shuttingDown) fatal(`channel adapter ${file} exited code=${code ?? 'null'} signal=${signal ?? 'none'}`) }) } @@ -213,6 +284,8 @@ let shuttingDown = false function fatal(message: string): never { process.stderr.write(`codex-dispatcher: ${message}\n`) recordLifecycle(STATE_DIR, 'crash', 'codex-dispatcher', message) + health.bound = false + markFailure('bridge', message) shutdown(1) throw new Error(message) } @@ -234,13 +307,17 @@ installLifecycle({ cleanup: () => shutdown(0), }) +publishHealth() +const beat = setInterval(publishHealth, HEALTH_HEARTBEAT_MS) +beat.unref?.() + await initialize() startInbox() if (CHANNELS.has('telegram')) { - startAdapter(join(import.meta.dir, 'server.ts'), { CODEX_DISPATCHER_ADAPTER: 'telegram' }) + startAdapter(join(import.meta.dir, 'server.ts'), 'telegram', { CODEX_DISPATCHER_ADAPTER: 'telegram' }) } if (CHANNELS.has('dashboard')) { - startAdapter(join(import.meta.dir, '..', 'dashboard', 'server.ts'), { + startAdapter(join(import.meta.dir, '..', 'dashboard', 'server.ts'), 'dashboard', { CODEX_DISPATCHER_ADAPTER: 'dashboard', DASHBOARD_STATE_DIR: process.env.DASHBOARD_STATE_DIR // The control plane and shelld use this compatibility path for every diff --git a/plugins/telegram-codex/health.ts b/plugins/telegram-codex/health.ts new file mode 100644 index 0000000..b96410c --- /dev/null +++ b/plugins/telegram-codex/health.ts @@ -0,0 +1,261 @@ +// plugins/telegram-codex/health.ts — the Codex channel bridge's HANDSHAKE, and +// the verdict a reader derives from it (DIVE-3964, P05). +// +// WHY THIS EXISTS. +// Until now the only measurable thing about a Codex seat's channels was the +// REFUSAL banner in the session pane (`5dive-cli:agent_channels_binding`, +// DIVE-2766). That probe is honest but one-sided by construction: the binary +// prints no success banner, the refusal line rolls off the scrollback, and the +// whole reading is gone the moment the pane is unreadable. So the fleet had +// exactly two answers — `refused` and `unknown` — and `unknown` covered +// "bound and working" and "silently deaf for 2.2 days" (DIVE-4036) alike. +// +// A banner is a side effect of a session. A handshake is a fact the bridge +// itself asserts, on an interval, in a file a reader can stat. The difference +// that matters is AGE: an assertion that stopped being refreshed is positive +// evidence of a dead bridge, where a missing banner is evidence of nothing. +// +// WHY THE DECISIONS ARE PURE (same reason as lifecycle.ts): +// repo CI runs a bare `bun test` with no plugin dependencies installed, so +// anything importing grammy or the MCP SDK is unexecutable there. This file +// imports node builtins ONLY — `classifyHealth` is therefore actually executed +// by CI rather than grepped for, and 5dive-cli can re-implement the same +// verdict in shell against a format that has a test. + +import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +export const HEALTH_FILE = 'health.json' + +/** Bump when a field's MEANING changes. A reader that does not know the schema + * must report unknown rather than guess — an old CLI reading a new file is the + * ordinary case on a fleet where the plugin and the CLI ship separately. */ +export const HEALTH_SCHEMA = 1 + +/** Heartbeat cadence. Written INTO the record so a reader never hardcodes it: + * the bridge is the only thing that knows how often it promised to write. */ +export const HEALTH_HEARTBEAT_MS = 15_000 + +/** How many missed heartbeats before the record is stale. Three, because a + * single missed write under load must not restart a working bridge — the bias + * is false-negative, like every other threshold the supervisor acts on. */ +export const HEALTH_STALE_FACTOR = 3 + +/** Floor on the staleness window, so a bridge that declares an absurdly small + * cadence cannot make itself permanently stale. */ +export const HEALTH_STALE_FLOOR_MS = 60_000 + +export type HealthFailure = { + at: string + /** The channel that failed, or `bridge` for the dispatcher itself. */ + channel: string + /** Actionable cause, in the words the operator needs. Never just "error". */ + cause: string +} + +export type ChannelHealth = { + schema: number + bridge: string + bridgeVersion: string + pid: number + startedAt: string + /** Refreshed every HEALTH_HEARTBEAT_MS. This field IS the liveness signal. */ + updatedAt: string + heartbeatMs: number + /** Channels this bridge was told to run. */ + declared: string[] + /** Channels whose adapter is up RIGHT NOW. Disagreement with `declared` is + * the mismatch the supervisor acts on. */ + listening: string[] + /** The app-server handshake completed and a thread is live. */ + bound: boolean + threadId?: string + lastInboundAt?: string + lastOutboundAt?: string + /** Messages accepted and not yet started. */ + queueDepth: number + active?: { turnId: string; source: string; startedAt: string } + failure?: HealthFailure +} + +export type HealthState = + | 'healthy' + | 'absent' + | 'stale' + | 'mismatched' + | 'unbound' + | 'failed' + +/** What a supervisor should DO. `restart` is only ever proposed for a cause a + * restart can plausibly fix; a named failure cause (a dead token, a refused + * account) survives every restart, so it is reported, not retried. */ +export type HealthRepair = 'none' | 'restart' | 'report' + +export type HealthVerdict = { + state: HealthState + detail: string + repair: HealthRepair +} + +export type ClassifyInput = { + /** Parsed health record, or null when the file is absent/unparseable. */ + health: ChannelHealth | null + /** What the REGISTRY says this agent should be running. The handshake alone + * cannot detect a channel that was declared and never even attempted. */ + declared: string[] + /** Whether the agent's service unit is up. An absent handshake under a dead + * unit is expected, not a defect. */ + serviceActive: boolean + now: Date + /** Restarts already spent on this condition without healing it. */ + repairAttempts?: number + /** Ceiling on those restarts. Past it the answer is a report, not another + * restart — a restart loop is how a broken bridge becomes a broken box. */ + maxRepairs?: number +} + +function sameSet(a: string[], b: string[]): boolean { + const x = [...new Set(a)].sort() + const y = [...new Set(b)].sort() + return x.length === y.length && x.every((v, i) => v === y[i]) +} + +export function staleAfterMs(health: ChannelHealth): number { + const cadence = Number.isFinite(health.heartbeatMs) && health.heartbeatMs > 0 + ? health.heartbeatMs + : HEALTH_HEARTBEAT_MS + return Math.max(HEALTH_STALE_FLOOR_MS, cadence * HEALTH_STALE_FACTOR) +} + +/** + * The whole verdict, as one pure function. Order is deliberate: a reading we + * cannot trust (absent, wrong schema, stale) is settled BEFORE any field inside + * the record is believed, because a stale record's `bound: true` is exactly the + * lie this ticket exists to stop reporting. + */ +export function classifyHealth(input: ClassifyInput): HealthVerdict { + const { health, declared, serviceActive, now } = input + const attempts = input.repairAttempts ?? 0 + const max = input.maxRepairs ?? 2 + // A restart is only offered while restarts are still plausibly useful. + const restartOrReport: HealthRepair = attempts >= max ? 'report' : 'restart' + const exhausted = (detail: string): string => + attempts >= max + ? `${detail}; ${attempts} restart(s) did not heal it — this needs a person, not another restart` + : detail + + if (declared.length === 0) { + return { state: 'healthy', detail: 'no channels declared, nothing to bind', repair: 'none' } + } + + if (!health) { + if (!serviceActive) { + return { + state: 'absent', + detail: 'no handshake and the agent service is not running — start the agent first', + repair: 'none', + } + } + const d = 'the agent is running but its Codex channel bridge has never written a handshake — the bridge did not start' + return { state: 'absent', detail: exhausted(d), repair: restartOrReport } + } + + if (health.schema !== HEALTH_SCHEMA) { + return { + state: 'absent', + detail: `handshake schema ${health.schema} is not readable by this build (expected ${HEALTH_SCHEMA}) — upgrade the CLI or the plugin`, + repair: 'report', + } + } + + const updated = Date.parse(health.updatedAt) + if (!Number.isFinite(updated)) { + return { + state: 'stale', + detail: `handshake has an unreadable updatedAt (${health.updatedAt})`, + repair: 'report', + } + } + const ageMs = now.getTime() - updated + const window = staleAfterMs(health) + if (ageMs > window) { + const d = `handshake last refreshed ${Math.round(ageMs / 1000)}s ago, past its ${Math.round(window / 1000)}s window — the bridge is wedged or gone (pid ${health.pid})` + return { state: 'stale', detail: exhausted(d), repair: restartOrReport } + } + + // Fresh from here down, so the record's own fields are believable. + + if (health.failure && !health.bound) { + return { + state: 'failed', + detail: `${health.failure.channel}: ${health.failure.cause} (at ${health.failure.at})`, + repair: 'report', + } + } + + if (!health.bound) { + const d = 'the bridge is running but has no live Codex thread — the app-server handshake has not completed' + return { state: 'unbound', detail: exhausted(d), repair: restartOrReport } + } + + if (!sameSet(declared, health.listening)) { + const missing = declared.filter(c => !health.listening.includes(c)) + const extra = health.listening.filter(c => !declared.includes(c)) + const parts: string[] = [] + if (missing.length) parts.push(`declared but not listening: ${missing.join(',')}`) + if (extra.length) parts.push(`listening but not declared: ${extra.join(',')}`) + const cause = health.failure ? ` (last failure — ${health.failure.channel}: ${health.failure.cause})` : '' + const d = `${parts.join('; ')}${cause}` + // A channel that is declared and not listening is exactly what a restart + // re-attempts; a named failure cause is not, and says so in `detail`. + return { state: 'mismatched', detail: exhausted(d), repair: health.failure ? 'report' : restartOrReport } + } + + return { + state: 'healthy', + detail: `bound, listening on ${health.listening.join(',')}${health.active ? `, turn ${health.active.turnId} from ${health.active.source}` : ''}${health.queueDepth ? `, ${health.queueDepth} queued` : ''}`, + repair: 'none', + } +} + +/** One line for a human, from the verdict plus the record it came from. */ +export function renderHealth(v: HealthVerdict, health: ChannelHealth | null): string { + if (!health) return `${v.state} — ${v.detail}` + const bits = [ + `bridge ${health.bridgeVersion}`, + `queue ${health.queueDepth}`, + `in ${health.lastInboundAt ?? 'never'}`, + `out ${health.lastOutboundAt ?? 'never'}`, + ] + return `${v.state} — ${v.detail} [${bits.join(' · ')}]` +} + +// ── the writer ────────────────────────────────────────────────────────────── + +function atomicWrite(path: string, body: string): void { + const tmp = `${path}.${process.pid}.tmp` + writeFileSync(tmp, body, { mode: 0o600 }) + renameSync(tmp, path) +} + +/** + * Write the handshake. Never throws: a bridge must not die because its own + * health file is unwritable — an unwritable file already reads as `absent`, + * which is the correct answer and reaches the operator through the reader. + */ +export function writeHealth(stateDir: string, health: ChannelHealth): void { + try { + mkdirSync(stateDir, { recursive: true, mode: 0o700 }) + atomicWrite(join(stateDir, HEALTH_FILE), JSON.stringify(health) + '\n') + } catch {} +} + +/** Read + parse, with every failure collapsing to null (= `absent`). */ +export function readHealth(stateDir: string): ChannelHealth | null { + try { + const parsed = JSON.parse(readFileSync(join(stateDir, HEALTH_FILE), 'utf8')) + return parsed && typeof parsed === 'object' ? parsed as ChannelHealth : null + } catch { + return null + } +} diff --git a/plugins/telegram-codex/package.json b/plugins/telegram-codex/package.json index e9e7fab..604b6a8 100644 --- a/plugins/telegram-codex/package.json +++ b/plugins/telegram-codex/package.json @@ -26,6 +26,7 @@ "server.ts", "dispatcher.ts", "dispatcher-core.ts", + "health.ts", "lifecycle.ts", "tna.ts", "README.md" diff --git a/test/codex-bridge-health.test.ts b/test/codex-bridge-health.test.ts new file mode 100644 index 0000000..b0a9c0b --- /dev/null +++ b/test/codex-bridge-health.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, test } from 'bun:test' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + HEALTH_FILE, + HEALTH_HEARTBEAT_MS, + HEALTH_SCHEMA, + classifyHealth, + readHealth, + renderHealth, + staleAfterMs, + writeHealth, + type ChannelHealth, +} from '../plugins/telegram-codex/health.ts' + +const NOW = new Date('2026-09-13T12:00:00.000Z') +const ago = (ms: number) => new Date(NOW.getTime() - ms).toISOString() + +function record(over: Partial = {}): ChannelHealth { + return { + schema: HEALTH_SCHEMA, + bridge: 'codex-dispatcher', + bridgeVersion: '0.5.18', + pid: 4242, + startedAt: ago(600_000), + updatedAt: ago(5_000), + heartbeatMs: HEALTH_HEARTBEAT_MS, + declared: ['telegram', 'dashboard'], + listening: ['telegram', 'dashboard'], + bound: true, + threadId: 'thread-1', + lastInboundAt: ago(30_000), + lastOutboundAt: ago(20_000), + queueDepth: 0, + ...over, + } +} + +const classify = (health: ChannelHealth | null, over: Partial[0]> = {}) => + classifyHealth({ health, declared: ['telegram', 'dashboard'], serviceActive: true, now: NOW, ...over }) + +describe('healthy', () => { + test('a fresh, bound handshake whose listening set matches the registry', () => { + const v = classify(record()) + expect(v.state).toBe('healthy') + expect(v.repair).toBe('none') + expect(v.detail).toContain('telegram,dashboard') + }) + + test('an active turn and a queue are reported, not treated as unhealthy', () => { + const v = classify(record({ queueDepth: 3, active: { turnId: 'turn-9', source: 'telegram', startedAt: ago(1_000) } })) + expect(v.state).toBe('healthy') + expect(v.detail).toContain('turn-9') + expect(v.detail).toContain('3 queued') + }) + + test('nothing declared is nothing to bind — never a defect', () => { + const v = classify(null, { declared: [] }) + expect(v.state).toBe('healthy') + expect(v.repair).toBe('none') + }) + + test('the rendered line carries the fields an operator has to act on', () => { + const h = record({ queueDepth: 2 }) + const line = renderHealth(classify(h), h) + expect(line).toContain('bridge 0.5.18') + expect(line).toContain('queue 2') + expect(line).toContain(h.lastInboundAt!) + expect(line).toContain(h.lastOutboundAt!) + }) +}) + +describe('stale', () => { + test('a handshake past its window is a dead bridge, not a bound one', () => { + const v = classify(record({ updatedAt: ago(20 * 60_000) })) + expect(v.state).toBe('stale') + expect(v.repair).toBe('restart') + expect(v.detail).toContain('1200s ago') + expect(v.detail).toContain('pid 4242') + }) + + test("a stale record's `bound: true` is never believed", () => { + // The whole point of the ticket: the pane probe could not tell these apart. + const fresh = classify(record()) + const stale = classify(record({ updatedAt: ago(10 * 60_000) })) + expect(fresh.state).toBe('healthy') + expect(stale.state).not.toBe('healthy') + }) + + test('one missed heartbeat is not stale — the bias stays false-negative', () => { + expect(classify(record({ updatedAt: ago(HEALTH_HEARTBEAT_MS + 1_000) })).state).toBe('healthy') + expect(staleAfterMs(record())).toBe(60_000) + }) + + test('a bridge cannot make itself permanently stale with a tiny cadence', () => { + expect(staleAfterMs(record({ heartbeatMs: 10 }))).toBe(60_000) + expect(staleAfterMs(record({ heartbeatMs: 120_000 }))).toBe(360_000) + }) + + test('an unreadable updatedAt is reported, never restarted blind', () => { + const v = classify(record({ updatedAt: 'not-a-date' })) + expect(v.state).toBe('stale') + expect(v.repair).toBe('report') + }) +}) + +describe('mismatched', () => { + test('declared-but-not-listening names the channel and offers a restart', () => { + const v = classify(record({ listening: ['telegram'] })) + expect(v.state).toBe('mismatched') + expect(v.repair).toBe('restart') + expect(v.detail).toContain('declared but not listening: dashboard') + }) + + test('listening-but-not-declared is a disagreement too', () => { + const v = classify(record(), { declared: ['telegram'] }) + expect(v.state).toBe('mismatched') + expect(v.detail).toContain('listening but not declared: dashboard') + }) + + test('a mismatch WITH a named cause is reported — a restart cannot fix a dead token', () => { + const v = classify(record({ + listening: ['telegram'], + failure: { at: ago(30_000), channel: 'dashboard', cause: 'adapter exited code=1 signal=none' }, + })) + expect(v.state).toBe('mismatched') + expect(v.repair).toBe('report') + expect(v.detail).toContain('adapter exited code=1') + }) + + test('set comparison ignores order and duplicates', () => { + expect(classify(record({ listening: ['dashboard', 'telegram', 'telegram'] })).state).toBe('healthy') + }) +}) + +describe('unbound and failed', () => { + test('running with no live thread is unbound, and restartable', () => { + const v = classify(record({ bound: false, threadId: undefined })) + expect(v.state).toBe('unbound') + expect(v.repair).toBe('restart') + }) + + test('an unbound bridge WITH a cause reports the cause verbatim', () => { + const v = classify(record({ + bound: false, + failure: { at: ago(10_000), channel: 'bridge', cause: 'app-server exited code=127 signal=none' }, + })) + expect(v.state).toBe('failed') + expect(v.repair).toBe('report') + expect(v.detail).toContain('code=127') + expect(v.detail).toContain('bridge') + }) +}) + +describe('absent', () => { + test('no handshake under a live service is a bridge that never started', () => { + const v = classify(null) + expect(v.state).toBe('absent') + expect(v.repair).toBe('restart') + }) + + test('no handshake under a dead service is expected, and never repaired', () => { + const v = classify(null, { serviceActive: false }) + expect(v.state).toBe('absent') + expect(v.repair).toBe('none') + expect(v.detail).toContain('start the agent first') + }) + + test('a schema this build cannot read is reported, never guessed at', () => { + const v = classify(record({ schema: HEALTH_SCHEMA + 1 })) + expect(v.repair).toBe('report') + expect(v.detail).toContain('upgrade') + }) +}) + +describe('failed repair', () => { + test('a restart is withdrawn once the attempts are spent', () => { + const stale = record({ updatedAt: ago(20 * 60_000) }) + expect(classify(stale, { repairAttempts: 1, maxRepairs: 2 }).repair).toBe('restart') + const spent = classify(stale, { repairAttempts: 2, maxRepairs: 2 }) + expect(spent.repair).toBe('report') + expect(spent.state).toBe('stale') + expect(spent.detail).toContain('2 restart(s) did not heal it') + }) + + test('every restartable state honours the ceiling', () => { + const spent = { repairAttempts: 3, maxRepairs: 2 } + expect(classify(null, spent).repair).toBe('report') + expect(classify(record({ bound: false }), spent).repair).toBe('report') + expect(classify(record({ listening: [] }), spent).repair).toBe('report') + }) + + test('a healthy bridge is never restarted, whatever the attempt count', () => { + expect(classify(record(), { repairAttempts: 9, maxRepairs: 2 }).repair).toBe('none') + }) +}) + +describe('the file on disk', () => { + test('write then read round-trips, and a corrupt file reads as absent', () => { + const dir = mkdtempSync(join(tmpdir(), 'codex-health-')) + try { + expect(readHealth(dir)).toBeNull() + const h = record() + writeHealth(dir, h) + expect(readHealth(dir)).toEqual(h) + writeFileSync(join(dir, HEALTH_FILE), '{ not json') + expect(readHealth(dir)).toBeNull() + // An absent/corrupt file must be a REPORTED state, not a thrown reader. + expect(classify(readHealth(dir)).state).toBe('absent') + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + test('writing to an unwritable path never throws', () => { + expect(() => writeHealth('/proc/nope/nowhere', record())).not.toThrow() + }) +})