diff --git a/packages/daemon/src/daemon.test.ts b/packages/daemon/src/daemon.test.ts index 384d9687b..8519bcaf7 100644 --- a/packages/daemon/src/daemon.test.ts +++ b/packages/daemon/src/daemon.test.ts @@ -417,7 +417,110 @@ describe('Daemon', () => { }); }); -// ---------- CLI integration ---------- +// ---------- Health heartbeat ---------- + +describe('Health heartbeat', () => { + it('logs health entry with expected fields after interval tick', async () => { + const dir = tmpDir(); + cleanupDirs.push(dir); + const logPath = join(dir, 'health.log'); + + const config: DaemonConfig = { + discord: undefined, + projects: { scan_roots: [] }, + log: { file: logPath, level: 'info', max_size_mb: 50 }, + }; + + const logger = new Logger({ filePath: logPath, level: 'info' }); + // Use 50ms interval for fast test + const daemon = new Daemon(config, logger, 50); + + await daemon.start(); + + // Wait for at least one health tick + await new Promise((r) => setTimeout(r, 120)); + + const origExit = process.exit; + // @ts-expect-error — overriding process.exit for test + process.exit = () => {}; + try { + await daemon.shutdown(); + } finally { + process.exit = origExit; + } + + const content = readFileSync(logPath, 'utf-8'); + const lines = content.trim().split('\n'); + const healthLines = lines.filter((l) => { + const e: LogEntry = JSON.parse(l); + return e.msg === 'health'; + }); + + assert.ok(healthLines.length >= 1, 'should have at least one health log entry'); + + const entry: LogEntry = JSON.parse(healthLines[0]!); + assert.equal(entry.msg, 'health'); + assert.equal(typeof entry.data?.uptime_s, 'number'); + assert.equal(typeof entry.data?.active_sessions, 'number'); + assert.equal(typeof entry.data?.discord_connected, 'boolean'); + assert.equal(typeof entry.data?.memory_rss_mb, 'number'); + assert.equal(entry.data?.discord_connected, false); // no discord configured + assert.equal(entry.data?.active_sessions, 0); // no sessions + }); + + it('health timer is cleared on shutdown — no lingering intervals', async () => { + const dir = tmpDir(); + cleanupDirs.push(dir); + const logPath = join(dir, 'health-cleanup.log'); + + const config: DaemonConfig = { + discord: undefined, + projects: { scan_roots: [] }, + log: { file: logPath, level: 'info', max_size_mb: 50 }, + }; + + const logger = new Logger({ filePath: logPath, level: 'info' }); + // Use 50ms interval + const daemon = new Daemon(config, logger, 50); + + await daemon.start(); + + // Wait for one tick + await new Promise((r) => setTimeout(r, 80)); + + const origExit = process.exit; + // @ts-expect-error — overriding process.exit for test + process.exit = () => {}; + try { + await daemon.shutdown(); + } finally { + process.exit = origExit; + } + + // Count health entries at shutdown + const contentAtShutdown = readFileSync(logPath, 'utf-8'); + const healthCountAtShutdown = contentAtShutdown + .trim() + .split('\n') + .filter((l) => JSON.parse(l).msg === 'health').length; + + // Wait another interval — no new health entries should appear + await new Promise((r) => setTimeout(r, 120)); + + // Re-read (logger is closed, so file shouldn't change) + const contentAfterWait = readFileSync(logPath, 'utf-8'); + const healthCountAfterWait = contentAfterWait + .trim() + .split('\n') + .filter((l) => JSON.parse(l).msg === 'health').length; + + assert.equal( + healthCountAfterWait, + healthCountAtShutdown, + 'no new health entries should appear after shutdown', + ); + }); +}); describe('CLI integration', () => { it('--help prints usage and exits 0', () => { diff --git a/packages/daemon/src/daemon.ts b/packages/daemon/src/daemon.ts index f5bedfacb..7eb2c1670 100644 --- a/packages/daemon/src/daemon.ts +++ b/packages/daemon/src/daemon.ts @@ -13,6 +13,7 @@ import { Orchestrator } from './orchestrator.js'; export class Daemon { private shuttingDown = false; private keepaliveTimer: ReturnType | undefined; + private healthTimer: ReturnType | undefined; private readonly onSigterm: () => void; private readonly onSigint: () => void; private sessionManager: SessionManager | undefined; @@ -23,6 +24,7 @@ export class Daemon { constructor( private readonly config: DaemonConfig, private readonly logger: Logger, + private readonly healthIntervalMs: number = 300_000, ) { this.onSigterm = () => void this.shutdown(); this.onSigint = () => void this.shutdown(); @@ -105,6 +107,21 @@ export class Daemon { this.discordBot = undefined; } } + + // Health heartbeat — logs uptime, session count, Discord status, memory + const startTime = Date.now(); + this.healthTimer = setInterval(() => { + const sessions = this.sessionManager?.getAllSessions() ?? []; + const activeSessions = sessions.filter( + (s) => s.status === 'running' || s.status === 'blocked', + ).length; + this.logger.info('health', { + uptime_s: Math.floor((Date.now() - startTime) / 1000), + active_sessions: activeSessions, + discord_connected: !!this.discordBot?.getClient()?.isReady(), + memory_rss_mb: Math.round(process.memoryUsage().rss / 1024 / 1024), + }); + }, this.healthIntervalMs); } /** Scan configured project roots for project directories. */ @@ -141,6 +158,12 @@ export class Daemon { process.removeListener('SIGTERM', this.onSigterm); process.removeListener('SIGINT', this.onSigint); + // Clear health heartbeat timer + if (this.healthTimer) { + clearInterval(this.healthTimer); + this.healthTimer = undefined; + } + // Clear keepalive so the event loop can drain if (this.keepaliveTimer) { clearInterval(this.keepaliveTimer); diff --git a/packages/daemon/src/discord-bot.ts b/packages/daemon/src/discord-bot.ts index 3edde49b5..94e6aeae7 100644 --- a/packages/daemon/src/discord-bot.ts +++ b/packages/daemon/src/discord-bot.ts @@ -129,6 +129,26 @@ export class DiscordBot { this.handleInteraction(interaction); }); + // Reconnection observability — structured logging for all shard lifecycle events (R027) + client.on('shardError', (error) => { + this.logger.error('discord shard error', { error: error.message }); + }); + client.on('shardDisconnect', (event, shardId) => { + this.logger.warn('discord shard disconnected', { shardId, code: event.code }); + }); + client.on('shardReconnecting', (shardId) => { + this.logger.info('discord shard reconnecting', { shardId }); + }); + client.on('shardResume', (shardId, replayedEvents) => { + this.logger.info('discord shard resumed', { shardId, replayedEvents }); + }); + client.on('warn', (message) => { + this.logger.warn('discord warning', { message }); + }); + client.on('error', (error) => { + this.logger.error('discord error', { error: error.message }); + }); + await client.login(this.config.token); this.client = client; this.destroyed = false;