feat(cluster): 1 s liveness loop for telemetry nodes, with the fleet silence guard (Phase 3)

LivenessService::tick() runs every second in MAIN's signals daemon
(cron:cluster is the minute fallback) and judges the active nodes whose
TELEMETRY flow is on by their silence: ok, suspect after 10 s, offline
after cluster_offline_after_sec (30 s). The result, in
tmp/cluster/health.json, is read through Core\Cluster\ClusterHealth:
ServerRepository::getAll() takes server_online from it for those nodes
(instead of the 90 s last_check_ago rule) and getCapacity() doubles a
suspect node's weight. Each transition rewrites the servers cache and is
audited.

When over half of the judged nodes (at least two) fall silent together,
the fleet silence guard holds every node at its last state instead of
marking any offline, audits it and alerts on the Cluster Nodes page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DcNdxHieZuUh3EnMUKt7zf
This commit is contained in:
Claude
2026-09-25 12:54:40 +00:00
parent f162182a03
commit ad6d136fb7
18 changed files with 363 additions and 2 deletions
@@ -105,4 +105,26 @@ final class ServerRepositoryPhpPidsTest extends TestCase {
public function testGetByIdKeepsTheFullRow(): void {
$this->assertSame('[11,12]', ServerRepository::getById(7)['php_pids']);
}
public function testClusterHealthDecidesOnlineForAgentNodes(): void {
$rPath = sys_get_temp_dir() . '/health_' . bin2hex(random_bytes(4)) . '.json';
\XcVm\Core\Cluster\ClusterHealth::usePath($rPath);
try {
// Fresh last_check_ago, but MAIN's liveness loop says offline.
\XcVm\Core\Cluster\ClusterHealth::write([7 => 'offline'], false);
$this->assertFalse(ServerRepository::getAll(true)[7]['server_online']);
// A stale last_check_ago (legacy: offline after 90 s), but the loop says suspect.
$this->db->query('UPDATE servers SET last_check_ago = ? WHERE id = 7', time() - 600);
\XcVm\Core\Cluster\ClusterHealth::write([7 => 'suspect'], false);
$rServers = ServerRepository::getAll(true);
$this->assertTrue($rServers[7]['server_online']);
$this->assertSame('suspect', $rServers[7]['cluster_health']);
// Not judged by the loop: the legacy rule.
\XcVm\Core\Cluster\ClusterHealth::write([], false);
$this->assertFalse(ServerRepository::getAll(true)[7]['server_online']);
} finally {
@unlink($rPath);
\XcVm\Core\Cluster\ClusterHealth::usePath(null);
}
}
}