From 7ce58f596693115d33c7017be91bcb16dfccb275 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Mon, 16 Feb 2026 15:24:12 -0800 Subject: [PATCH] fix(heartbeat): fallback on invalid notify_cooldown values --- docs/plans/state.json | 4 ++-- src/automation/heartbeat.test.ts | 24 ++++++++++++++++++++++++ src/automation/heartbeat.ts | 14 ++++++++++++-- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/docs/plans/state.json b/docs/plans/state.json index ce9c2ec..ed3c561 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -193,7 +193,7 @@ "status": "completed", "date": "2026-02-16", "updated": "2026-02-16", - "summary": "Implemented operator-focused hardening and onboarding polish: added setup Operator Pack flows in both Automation menu and first-run wizard to preconfigure scheduled backups, heartbeat alerts, daily briefing, and default MinIO sync; added operator-pack prompts for output channel/peer routing; added heartbeat notification throttling via `automation.heartbeat.notify_cooldown`; and added `flynn doctor --strict` to treat warnings as failures. Updated docs/default config examples and onboarding tests accordingly.", + "summary": "Implemented operator-focused hardening and onboarding polish: added setup Operator Pack flows in both Automation menu and first-run wizard to preconfigure scheduled backups, heartbeat alerts, daily briefing, and default MinIO sync; added operator-pack prompts for output channel/peer routing; added heartbeat notification throttling via `automation.heartbeat.notify_cooldown` (with invalid-value fallback handling); and added `flynn doctor --strict` to treat warnings as failures. Updated docs/default config examples and onboarding tests accordingly.", "files_modified": [ "src/cli/setup/config.ts", "src/cli/setup/config.test.ts", @@ -3499,7 +3499,7 @@ } }, "overall_progress": { - "total_test_count": 1869, + "total_test_count": 1870, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", diff --git a/src/automation/heartbeat.test.ts b/src/automation/heartbeat.test.ts index 5c8bf86..86bd10f 100644 --- a/src/automation/heartbeat.test.ts +++ b/src/automation/heartbeat.test.ts @@ -307,6 +307,30 @@ describe('HeartbeatMonitor', () => { })); }); + it('falls back to default cooldown when notify_cooldown is invalid', async () => { + const mockSend = vi.fn().mockResolvedValue(undefined); + const mockGet = vi.fn().mockReturnValue({ send: mockSend }); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const deps = makeDeps({ + config: makeConfig({ + checks: ['model'], + failure_threshold: 1, + notify_cooldown: 'not-a-duration', + notify: { channel: 'telegram', peer: '123' }, + }), + modelRouter: undefined, + channelLookup: { get: mockGet }, + }); + monitor = new HeartbeatMonitor(deps); + + await monitor.runChecks(); + expect(mockSend).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('invalid notify_cooldown')); + + warnSpy.mockRestore(); + }); + it('recovery notification sent when checks pass after failures', async () => { const mockSend = vi.fn().mockResolvedValue(undefined); const mockGet = vi.fn().mockReturnValue({ send: mockSend }); diff --git a/src/automation/heartbeat.ts b/src/automation/heartbeat.ts index 5afb3cf..f040da1 100644 --- a/src/automation/heartbeat.ts +++ b/src/automation/heartbeat.ts @@ -485,8 +485,18 @@ export class HeartbeatMonitor { return Date.now() - lastAt >= cooldownMs; } + private getNotifyCooldownMs(): number { + const raw = this.deps.config.notify_cooldown ?? '30m'; + try { + return parseInterval(raw); + } catch { + console.warn(`HeartbeatMonitor: invalid notify_cooldown '${raw}', falling back to 30m`); + return parseInterval('30m'); + } + } + private async notifyFailureWithCooldown(text: string, signature: string): Promise { - const cooldownMs = parseInterval(this.deps.config.notify_cooldown ?? '30m'); + const cooldownMs = this.getNotifyCooldownMs(); const signatureChanged = signature !== this.lastFailureSignature; const cooldownPassed = this.shouldNotifyByCooldown(this.lastFailureNotificationAt, cooldownMs); if (!signatureChanged && !cooldownPassed) { @@ -500,7 +510,7 @@ export class HeartbeatMonitor { } private async notifyRecoveryWithCooldown(text: string): Promise { - const cooldownMs = parseInterval(this.deps.config.notify_cooldown ?? '30m'); + const cooldownMs = this.getNotifyCooldownMs(); const cooldownPassed = this.shouldNotifyByCooldown(this.lastRecoveryNotificationAt, cooldownMs); if (!cooldownPassed) { return false;