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:
parent
14297845e9
commit
0de87955d3
3 changed files with 147 additions and 1 deletions
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue