From 6a80378e7324c3e14630ec0cc4d378664e312fe5 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 21 Jun 2026 18:16:48 -0500 Subject: [PATCH] =?UTF-8?q?fix(fleet):=20heartbeat=20consistency=20?= =?UTF-8?q?=E2=80=94=20honor=20MOSAIC=5FHOME=20+=20MOSAIC=5FHEARTBEAT=5FIN?= =?UTF-8?q?TERVAL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F3 milestone 1 — the two pre-existing HB bugs (from #584) flagged in the #590 review: - writer (start-agent-session.sh): MOSAIC_HEARTBEAT_RUN_DIR now defaults to ${MOSAIC_HOME:-$HOME/.config/mosaic}/fleet/run, matching the path `fleet ps` reads (heartbeatPath uses the resolved mosaicHome). Fixes false "stale/unknown" HB on custom MOSAIC_HOME deployments. - reader (fleet.ts): new heartbeatIntervalMs() honors MOSAIC_HEARTBEAT_INTERVAL (seconds); parseHeartbeat's freshness threshold now matches the writer's actual cadence instead of a hardcoded 15s. + vitest coverage. Validated: custom-home writer dir == reader path; interval 30 -> 30000ms, unset -> 15000ms; prettier clean. Refs #542 #588 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tools/fleet/start-agent-session.sh | 2 +- packages/mosaic/src/commands/fleet.spec.ts | 17 +++++++++++++++++ packages/mosaic/src/commands/fleet.ts | 12 +++++++++++- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/mosaic/framework/tools/fleet/start-agent-session.sh b/packages/mosaic/framework/tools/fleet/start-agent-session.sh index 53782ff..8c3b656 100755 --- a/packages/mosaic/framework/tools/fleet/start-agent-session.sh +++ b/packages/mosaic/framework/tools/fleet/start-agent-session.sh @@ -6,7 +6,7 @@ MOSAIC_TMUX_SOCKET=${MOSAIC_TMUX_SOCKET:-mosaic-factory} MOSAIC_AGENT_RUNTIME=${MOSAIC_AGENT_RUNTIME:-pi} MOSAIC_AGENT_WORKDIR=${MOSAIC_AGENT_WORKDIR:-$HOME} MOSAIC_AGENT_COMMAND=${MOSAIC_AGENT_COMMAND:-} -MOSAIC_HEARTBEAT_RUN_DIR=${MOSAIC_HEARTBEAT_RUN_DIR:-$HOME/.config/mosaic/fleet/run} +MOSAIC_HEARTBEAT_RUN_DIR=${MOSAIC_HEARTBEAT_RUN_DIR:-${MOSAIC_HOME:-$HOME/.config/mosaic}/fleet/run} MOSAIC_HEARTBEAT_INTERVAL=${MOSAIC_HEARTBEAT_INTERVAL:-15} if [ -z "$AGENT_NAME" ]; then diff --git a/packages/mosaic/src/commands/fleet.spec.ts b/packages/mosaic/src/commands/fleet.spec.ts index 0b641d5..e7fe766 100644 --- a/packages/mosaic/src/commands/fleet.spec.ts +++ b/packages/mosaic/src/commands/fleet.spec.ts @@ -851,6 +851,23 @@ describe('fleet ps — heartbeat parsing', () => { expect(hb.health).toBe('unknown'); expect(hb.ts).toBeNull(); }); + + it('honors MOSAIC_HEARTBEAT_INTERVAL for the freshness threshold', () => { + const prev = process.env.MOSAIC_HEARTBEAT_INTERVAL; + try { + // A 60s-old beat is STALE at the default 15s interval (3x15 = 45s)... + const ts = new Date(NOW - 60_000).toISOString(); + const content = `ts=${ts}\npid=1\nstatus=ok\n`; + delete process.env.MOSAIC_HEARTBEAT_INTERVAL; + expect(parseHeartbeat(content, NOW).health).toBe('stale'); + // ...but HEALTHY when the operator widened the interval to 30s (3x30 = 90s). + process.env.MOSAIC_HEARTBEAT_INTERVAL = '30'; + expect(parseHeartbeat(content, NOW).health).toBe('healthy'); + } finally { + if (prev === undefined) delete process.env.MOSAIC_HEARTBEAT_INTERVAL; + else process.env.MOSAIC_HEARTBEAT_INTERVAL = prev; + } + }); }); describe('fleet ps — systemd show parsing', () => { diff --git a/packages/mosaic/src/commands/fleet.ts b/packages/mosaic/src/commands/fleet.ts index 54ad1b9..4a166d2 100644 --- a/packages/mosaic/src/commands/fleet.ts +++ b/packages/mosaic/src/commands/fleet.ts @@ -368,6 +368,16 @@ export function buildAgentTailCommand( // --------------------------------------------------------------------------- export const HEARTBEAT_INTERVAL_MS = 15_000; + +/** + * Heartbeat interval in ms, honoring MOSAIC_HEARTBEAT_INTERVAL (seconds) so the + * `fleet ps` freshness threshold matches the writer sidecar's actual cadence + * (start-agent-session.sh). Falls back to HEARTBEAT_INTERVAL_MS (15s). + */ +export function heartbeatIntervalMs(): number { + const sec = Number.parseInt(process.env.MOSAIC_HEARTBEAT_INTERVAL ?? '', 10); + return Number.isFinite(sec) && sec > 0 ? sec * 1000 : HEARTBEAT_INTERVAL_MS; +} export const HEARTBEAT_HEALTHY_MULTIPLIER = 3; export interface HeartbeatInfo { @@ -496,7 +506,7 @@ export function parseHeartbeat(content: string | null, nowMs = Date.now()): Hear status = val; } } - const thresholdMs = HEARTBEAT_INTERVAL_MS * HEARTBEAT_HEALTHY_MULTIPLIER; + const thresholdMs = heartbeatIntervalMs() * HEARTBEAT_HEALTHY_MULTIPLIER; let health: 'healthy' | 'stale' | 'unknown' = 'unknown'; let ageMs: number | null = null; if (ts !== null) {