feat(comms): P1 presence — minimal Synapse + fleet presence room + mosaic.presence heartbeat + liveness (#888)
Co-authored-by: jason.woltje <jason@diversecanvas.com> Co-committed-by: jason.woltje <jason@diversecanvas.com>
This commit was merged in pull request #888.
This commit is contained in:
151
packages/comms/src/__tests__/presence-flow.test.ts
Normal file
151
packages/comms/src/__tests__/presence-flow.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user