feat: Added 6 discord.js shard/error/warn event listeners for reconnect…

- "packages/daemon/src/discord-bot.ts"
- "packages/daemon/src/daemon.ts"
- "packages/daemon/src/daemon.test.ts"

GSD-Task: S06/T02
This commit is contained in:
Lex Christopherson 2026-03-27 16:20:10 -06:00
parent 14297845e9
commit 0de87955d3
3 changed files with 147 additions and 1 deletions

View file

@ -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', () => {

View file

@ -13,6 +13,7 @@ import { Orchestrator } from './orchestrator.js';
export class Daemon {
private shuttingDown = false;
private keepaliveTimer: ReturnType<typeof setInterval> | undefined;
private healthTimer: ReturnType<typeof setInterval> | 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);

View file

@ -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;