[glm-grade=B] fix(monitoring): DC-088 removeService generation tombstones + incident closure

- serviceGenerations no longer leaks entries: removeService deletes the live
  entry and records a TTL'd (10min) tombstone swept by cleanupHistory
- monotonic instance-wide generationSeq prevents generation reuse across
  remove->re-add cycles (ABA) and supersedes tombstones on re-configure
- _isStaleCapture(): presence-aware stale check — live entry must match
  exactly; no entry is stale only under a higher-generation tombstone
  (preserves correct behavior for disk-loaded never-configured services)
- catch path increments consecutiveFailures only after the stale check, so
  a late-rejected probe cannot resurrect state for a removed service
- open incidents for a removed service close via the standard resolve path
  (resolvedBy=service-removed, WS/SSE incident-resolved broadcast)
- 6 regression tests; full suite 2608/2608 green

Judge: GLM-5.3 cold read (Codex stand-in), verdict B/ship, zero blockers
This commit is contained in:
Hermes
2026-08-22 15:55:26 -07:00
parent c71b794ccc
commit 3ccf66754a
2 changed files with 183 additions and 7 deletions
@@ -591,6 +591,113 @@ describe('HealthChecker', () => {
});
});
describe('DC-088: removeService generation tombstones + incident closure', () => {
it('does not leak a serviceGenerations entry and records a tombstone', () => {
healthChecker.configureService('svc1', { url: 'http://test.local' });
expect(healthChecker.serviceGenerations.has('svc1')).toBe(true);
healthChecker.removeService('svc1');
expect(healthChecker.serviceGenerations.has('svc1')).toBe(false);
const tomb = healthChecker.removedGenerations.get('svc1');
expect(tomb).toBeDefined();
expect(tomb.generation).toBeGreaterThan(0);
expect(tomb.removedAt).toBeGreaterThan(0);
});
it('re-added service gets a strictly higher generation (no ABA)', () => {
healthChecker.configureService('svc1', { url: 'http://test.local' });
const gen1 = healthChecker.serviceGenerations.get('svc1');
healthChecker.removeService('svc1');
healthChecker.configureService('svc1', { url: 'http://test.local/v2' });
const gen2 = healthChecker.serviceGenerations.get('svc1');
expect(gen2).toBeGreaterThan(gen1);
expect(healthChecker.removedGenerations.has('svc1')).toBe(false);
});
it('closes open incidents for the removed service as resolved', () => {
healthChecker.saveConfig = jest.fn();
healthChecker.incidents.push({
id: 'incident-test-1',
serviceId: 'svc1',
type: 'outage',
status: 'open',
createdAt: new Date(Date.now() - 60_000).toISOString()
});
healthChecker.incidents.push({
id: 'incident-other',
serviceId: 'svc2',
type: 'outage',
status: 'open',
createdAt: new Date(Date.now() - 60_000).toISOString()
});
const resolvedSpy = jest.fn();
healthChecker.on('incident-resolved', resolvedSpy);
healthChecker.removeService('svc1');
const closed = healthChecker.incidents.find(i => i.id === 'incident-test-1');
expect(closed.status).toBe('resolved');
expect(closed.resolvedBy).toBe('service-removed');
expect(closed.resolvedAt).toBeDefined();
expect(closed.duration).toBeGreaterThan(0);
expect(healthChecker.incidents.find(i => i.id === 'incident-other').status).toBe('open');
expect(resolvedSpy).toHaveBeenCalledTimes(1);
});
it('in-flight probe captured before removal is discarded via tombstone', async () => {
let resolveProbe;
healthChecker.config.services.svc1 = { url: 'http://test.local' };
healthChecker._doRequest = jest.fn(() => new Promise(resolve => {
resolveProbe = resolve;
}));
const pending = healthChecker.checkService('svc1', healthChecker.config.services.svc1);
healthChecker.saveConfig = jest.fn();
healthChecker.removeService('svc1');
resolveProbe({ healthy: true, statusCode: 200, message: 'late', details: {} });
await pending;
expect(healthChecker.currentStatus.has('svc1')).toBe(false);
expect(healthChecker.consecutiveFailures.has('svc1')).toBe(false);
});
it('a rejected in-flight probe after removal does not re-create failure state', async () => {
let rejectProbe;
healthChecker.config.services.svc1 = { url: 'http://test.local' };
healthChecker._doRequest = jest.fn(() => new Promise((resolve, reject) => {
rejectProbe = reject;
}));
const pending = healthChecker.checkService('svc1', healthChecker.config.services.svc1);
healthChecker.saveConfig = jest.fn();
healthChecker.removeService('svc1');
rejectProbe(new Error('late failure'));
await pending;
expect(healthChecker.consecutiveFailures.has('svc1')).toBe(false);
expect(healthChecker.currentStatus.has('svc1')).toBe(false);
});
it('sweeps expired tombstones in cleanupHistory', () => {
healthChecker.removedGenerations.set('svc1', {
generation: 1,
removedAt: Date.now() - 60 * 60 * 1000 // 1h ago, TTL default 10m
});
healthChecker.removedGenerations.set('svc2', {
generation: 2,
removedAt: Date.now() // fresh
});
healthChecker.cleanupHistory();
expect(healthChecker.removedGenerations.has('svc1')).toBe(false);
expect(healthChecker.removedGenerations.has('svc2')).toBe(true);
});
});
describe('cleanupHistory', () => {
it('removes entries older than retention period', () => {
const old = new Date(Date.now() - 35 * 24 * 60 * 60 * 1000).toISOString(); // 35 days ago