Co-authored-by: jason.woltje <jason@diversecanvas.com> Co-committed-by: jason.woltje <jason@diversecanvas.com>
152 lines
4.9 KiB
TypeScript
152 lines
4.9 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { FleetLivenessReader } from '../liveness-reader.js';
|
|
import type { MinimalMatrixClient } from '../matrix-client.js';
|
|
import { PresenceAgent } from '../presence-agent.js';
|
|
import type { HeartbeatObservation, LivenessPolicy, PresenceStatus } from '../types.js';
|
|
|
|
/**
|
|
* An in-memory fake homeserver room: records heartbeats with a controllable
|
|
* server clock and reduces them exactly like the real readHeartbeats. Lets us
|
|
* prove the PresenceAgent -> room -> FleetLivenessReader flow (including the A3
|
|
* hard-kill -> offline transition) deterministically, with no network.
|
|
*/
|
|
class FakeRoomClient {
|
|
readonly beats: Array<{
|
|
slug: string;
|
|
mxid: string;
|
|
seq: number;
|
|
ts: number;
|
|
status: PresenceStatus;
|
|
}> = [];
|
|
presence: Record<string, PresenceStatus> = {};
|
|
|
|
constructor(private readonly clock: () => number) {}
|
|
|
|
async joinRoom(roomId: string): Promise<string> {
|
|
return roomId;
|
|
}
|
|
async setPresence(userId: string, status: PresenceStatus): Promise<void> {
|
|
this.presence[userId] = status;
|
|
}
|
|
async sendHeartbeat(
|
|
_roomId: string,
|
|
content: { agent: { slug: string; mxid: string }; seq: number; status: PresenceStatus },
|
|
): Promise<string> {
|
|
this.beats.push({
|
|
slug: content.agent.slug,
|
|
mxid: content.agent.mxid,
|
|
seq: content.seq,
|
|
ts: this.clock(), // server receive time
|
|
status: content.status,
|
|
});
|
|
return `$evt${this.beats.length}`;
|
|
}
|
|
async readHeartbeats(): Promise<HeartbeatObservation[]> {
|
|
const bySlug = new Map<string, HeartbeatObservation>();
|
|
for (const b of this.beats) {
|
|
const prev = bySlug.get(b.slug);
|
|
if (!prev || b.seq > prev.lastSeq) {
|
|
bySlug.set(b.slug, {
|
|
slug: b.slug,
|
|
mxid: b.mxid,
|
|
lastSeenTs: b.ts,
|
|
lastSeq: b.seq,
|
|
assertedStatus: b.status,
|
|
});
|
|
}
|
|
}
|
|
return [...bySlug.values()];
|
|
}
|
|
}
|
|
|
|
const policy: LivenessPolicy = {
|
|
heartbeatIntervalMs: 1000,
|
|
missTolerance: 2,
|
|
darkThresholdMs: 5000,
|
|
};
|
|
|
|
describe('presence flow (A2 + A3 at unit level)', () => {
|
|
afterEach(() => vi.useRealTimers());
|
|
|
|
it('shows agents online while beating, then A3: a hard-killed agent goes offline within dark_threshold', async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(0);
|
|
|
|
const fake = new FakeRoomClient(() => Date.now());
|
|
const client = fake as unknown as MinimalMatrixClient;
|
|
const reader = new FleetLivenessReader({
|
|
client,
|
|
roomId: '!fleet',
|
|
policy,
|
|
now: () => Date.now(),
|
|
});
|
|
|
|
const mk = (slug: string) =>
|
|
new PresenceAgent({
|
|
client,
|
|
agent: { mxid: `@agent-${slug}:matrix.localhost`, slug, harness: 'claude-code' },
|
|
roomId: '!fleet',
|
|
intervalMs: 1000,
|
|
policy,
|
|
});
|
|
|
|
const alpha = mk('alpha');
|
|
const bravo = mk('bravo');
|
|
const charlie = mk('charlie');
|
|
|
|
for (const a of [alpha, bravo, charlie]) {
|
|
await a.connect();
|
|
a.start();
|
|
}
|
|
// native presence set online for all three (Element dot)
|
|
expect(fake.presence['@agent-alpha:matrix.localhost']).toBe('online');
|
|
|
|
// let a couple of beats flow — all three fresh => online (A2)
|
|
await vi.advanceTimersByTimeAsync(1500);
|
|
const board1 = Object.fromEntries((await reader.read()).map((r) => [r.slug, r.status]));
|
|
expect(board1).toEqual({ alpha: 'online', bravo: 'online', charlie: 'online' });
|
|
|
|
// HARD-KILL charlie: stop its loop, no more beats. alpha/bravo keep beating.
|
|
charlie.pauseHeartbeat(); // hard-kill: no graceful presence signal
|
|
|
|
// advance to just before dark threshold from charlie's last beat...
|
|
await vi.advanceTimersByTimeAsync(3000);
|
|
const mid = Object.fromEntries((await reader.read()).map((r) => [r.slug, r.status]));
|
|
expect(mid.alpha).toBe('online');
|
|
expect(mid.charlie).not.toBe('online'); // already stale (away)
|
|
|
|
// ...advance past dark_threshold: charlie is deterministically offline.
|
|
await vi.advanceTimersByTimeAsync(4000);
|
|
const final = await reader.read();
|
|
const byslug = Object.fromEntries(final.map((r) => [r.slug, r]));
|
|
expect(byslug.charlie!.status).toBe('offline');
|
|
expect(byslug.alpha!.status).toBe('online');
|
|
expect(byslug.bravo!.status).toBe('online');
|
|
|
|
for (const a of [alpha, bravo]) await a.stop();
|
|
});
|
|
|
|
it('formatBoard renders a human-readable liveness board (A4)', async () => {
|
|
const fake = new FakeRoomClient(() => 10_000);
|
|
fake.beats.push({
|
|
slug: 'alpha',
|
|
mxid: '@agent-alpha:matrix.localhost',
|
|
seq: 3,
|
|
ts: 9_500,
|
|
status: 'online',
|
|
});
|
|
const reader = new FleetLivenessReader({
|
|
client: fake as unknown as MinimalMatrixClient,
|
|
roomId: '!fleet',
|
|
policy,
|
|
now: () => 10_000,
|
|
});
|
|
const board = await reader.formatBoard();
|
|
expect(board).toContain('Fleet presence');
|
|
expect(board).toContain('alpha');
|
|
expect(board).toContain('online');
|
|
expect(board).toContain('online=1');
|
|
});
|
|
});
|