mirror of
https://github.com/ruvnet/ruflo.git
synced 2026-09-14 14:01:28 +08:00
40878cca35
* dream(swarm): #3242 fix AgentPool health checks that could never detect a dead agent performHealthChecks() stamped lastHeartbeat = now on every "still healthy" tick, and updateAgentHeartbeat() (the only other legitimate way to advance that field) has zero callers anywhere in v3/ — so the check's own tick was the sole thing keeping every pooled agent's heartbeat fresh, meaning timeSinceLastActivity could never exceed one health-check interval and replaceUnhealthyAgent() could never fire for any agent, in any pool, ever. Fixes the self-stamp; matches the correct pattern already implemented in UnifiedCoordinator.checkHeartbeats() in the same package. New deterministic test file (fake timers): baseline fails 2/3 tests, candidate passes 3/3, 223/223 full package suite, tsc clean. Co-Authored-By: RuFlo <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_01TAzfKzdv9uRVjKt5TeBgkv * dream(swarm): fold in adversarial-critique caveats, re-verify, ACCEPT-scoped Independent critic (separate context) confirmed the fix and re-derived everything itself (zero-callers grep, full suite, tsc, stash-isolated baseline/candidate). Two disclosed, non-blocking caveats folded into the gist: (1) @claude-flow/swarm is not currently a dependency of the shipped @claude-flow/cli, so tonight's fix is real within the swarm package's own UnifiedSwarmCoordinator/tests but has no reachable path through the actual CLI today; (2) health still optimistically recovers +0.1/tick inside the grace window even with zero real heartbeats (pre-existing, out-of-scope, mirrors UnifiedSwarmCoordinator's own no-tick-recovery design). Also fixes a cosmetic class-name typo in the source comment (UnifiedCoordinator -> UnifiedSwarmCoordinator). Re-ran full suite (223/223) and tsc (clean) after the comment edit; recomputed the witness stamp over final content. Co-Authored-By: RuFlo <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_01TAzfKzdv9uRVjKt5TeBgkv * dream(swarm): update LEDGER.md for #3242/#3243 Records tonight's row plus verified GitHub state for the trailing nights (MCP tools, not inferred) and the 2026-09-08 gap note (same ledger-rows-only-reach-main-via-merge structural issue tracked since 2026-08-19). Co-Authored-By: RuFlo <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_01TAzfKzdv9uRVjKt5TeBgkv --------- Co-authored-by: Claude <noreply@anthropic.com>
104 lines
3.4 KiB
TypeScript
104 lines
3.4 KiB
TypeScript
/**
|
|
* AgentPool health-check tests
|
|
*
|
|
* Regression coverage for a dead-agent-detection bug: performHealthChecks()
|
|
* used to stamp `lastHeartbeat = now` on every tick for any agent that had
|
|
* not yet crossed the unhealthy threshold. Since updateAgentHeartbeat() has
|
|
* no callers anywhere in this repo, that self-stamping was the *only* thing
|
|
* ever touching lastHeartbeat after agent creation — so timeSinceLastActivity
|
|
* could never grow past one health-check interval, unhealthyThresholdMs
|
|
* (3x the interval) could never be exceeded, and a truly hung/silent agent
|
|
* was never flagged unhealthy or replaced, no matter how long it stayed
|
|
* silent.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import { AgentPool, createAgentPool } from '../src/agent-pool.js';
|
|
|
|
describe('AgentPool health checks', () => {
|
|
let pool: AgentPool;
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await pool?.shutdown();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('flags a silent agent unhealthy once it exceeds healthCheckIntervalMs * 3', async () => {
|
|
pool = createAgentPool({
|
|
name: 'silent-pool',
|
|
minSize: 1,
|
|
maxSize: 1,
|
|
healthCheckIntervalMs: 1000,
|
|
});
|
|
await pool.initialize();
|
|
|
|
const [agent] = pool.getAllAgents();
|
|
expect(agent.status).toBe('idle');
|
|
expect(agent.health).toBe(1.0);
|
|
|
|
// Advance well past the unhealthy threshold (3 * interval = 3000ms)
|
|
// without ever calling updateAgentHeartbeat() — simulating a hung agent
|
|
// that has genuinely stopped reporting liveness.
|
|
for (let i = 0; i < 5; i++) {
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
}
|
|
|
|
const after = pool.getAgent(agent.id.id);
|
|
expect(after?.status).toBe('error');
|
|
expect(after!.health).toBeLessThan(1.0);
|
|
});
|
|
|
|
it('replaces a silent agent once its health is fully depleted', async () => {
|
|
pool = createAgentPool({
|
|
name: 'replace-pool',
|
|
minSize: 1,
|
|
maxSize: 1,
|
|
healthCheckIntervalMs: 1000,
|
|
});
|
|
await pool.initialize();
|
|
|
|
const [original] = pool.getAllAgents();
|
|
const replacedEvents: Array<{ oldAgentId: string }> = [];
|
|
pool.on('agent.replaced', (evt: { oldAgentId: string }) => replacedEvents.push(evt));
|
|
|
|
// health starts at 1.0 and decrements by 0.2 per unhealthy tick once the
|
|
// threshold (3 intervals) is first exceeded, so it takes threshold + 5
|
|
// more ticks (1.0 -> 0.0 in steps of 0.2) to fully deplete and replace.
|
|
for (let i = 0; i < 9; i++) {
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
}
|
|
|
|
expect(replacedEvents).toEqual([{ oldAgentId: original.id.id }]);
|
|
// A fresh replacement agent exists and is not the original.
|
|
const ids = pool.getAllAgents().map(a => a.id.id);
|
|
expect(ids).not.toContain(original.id.id);
|
|
});
|
|
|
|
it('keeps a genuinely alive agent healthy across many health-check ticks', async () => {
|
|
pool = createAgentPool({
|
|
name: 'alive-pool',
|
|
minSize: 1,
|
|
maxSize: 1,
|
|
healthCheckIntervalMs: 1000,
|
|
});
|
|
await pool.initialize();
|
|
|
|
const [agent] = pool.getAllAgents();
|
|
|
|
// Real liveness signal, sent on roughly every other tick — well inside
|
|
// the 3-interval grace window each time.
|
|
for (let i = 0; i < 9; i++) {
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
pool.updateAgentHeartbeat(agent.id.id);
|
|
}
|
|
|
|
const after = pool.getAgent(agent.id.id);
|
|
expect(after?.status).toBe('idle');
|
|
expect(after?.health).toBe(1.0);
|
|
});
|
|
});
|