feat(comms): P1 presence — minimal Synapse + fleet presence room + mosaic.presence heartbeat + liveness (#888)
Some checks failed
ci/woodpecker/push/ci-image Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed

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:
2026-07-25 21:03:18 +00:00
committed by Mos
parent fabde1c834
commit 2698ddb7b5
33 changed files with 2140 additions and 0 deletions

View File

@@ -0,0 +1,92 @@
/**
* High-level presence agent (RFC-001 §4.1 steps 1011, §4.5).
*
* Ties the pieces together for one agent: join the fleet presence room, set
* native Matrix presence online (for Element's dot), and run the authoritative
* `mosaic.presence` heartbeat loop. This is the P1 slice of what a harness does
* on spin — no enrollment/token-minting/introductions (those are P2).
*/
import {
HeartbeatEmitter,
startHeartbeatLoop,
type HeartbeatAgentIdentity,
type HeartbeatLoopHandle,
} from './heartbeat.js';
import type { MinimalMatrixClient } from './matrix-client.js';
import { DEFAULT_LIVENESS_POLICY, type LivenessPolicy, type PresenceStatus } from './types.js';
export interface PresenceAgentOptions {
client: MinimalMatrixClient;
agent: HeartbeatAgentIdentity;
/** Fleet presence room id (or alias) to heartbeat into. */
roomId: string;
/** Heartbeat cadence; defaults to the policy interval. */
intervalMs?: number;
policy?: LivenessPolicy;
missionId?: string;
onError?: (err: unknown) => void;
}
export class PresenceAgent {
private readonly intervalMs: number;
private readonly emitter: HeartbeatEmitter;
private loop: HeartbeatLoopHandle | undefined;
private resolvedRoomId: string | undefined;
constructor(private readonly opts: PresenceAgentOptions) {
const policy = opts.policy ?? DEFAULT_LIVENESS_POLICY;
this.intervalMs = opts.intervalMs ?? policy.heartbeatIntervalMs;
this.emitter = new HeartbeatEmitter({
agent: opts.agent,
intervalMs: this.intervalMs,
missionId: opts.missionId,
});
}
/** Join the fleet room and go present. Returns the resolved room id. */
async connect(): Promise<string> {
this.resolvedRoomId = await this.opts.client.joinRoom(this.opts.roomId);
await this.opts.client.setPresence(this.opts.agent.mxid, 'online', 'mosaic.presence heartbeat');
return this.resolvedRoomId;
}
/** Start the heartbeat loop (emits immediately, then every intervalMs). */
start(status: () => PresenceStatus = () => 'online'): void {
const roomId = this.resolvedRoomId ?? this.opts.roomId;
this.loop = startHeartbeatLoop({
emitter: this.emitter,
intervalMs: this.intervalMs,
status,
onError: this.opts.onError,
send: async (content) => {
await this.opts.client.sendHeartbeat(roomId, content);
},
});
}
get currentSeq(): number {
return this.emitter.currentSeq;
}
/**
* Stop only the heartbeat loop, sending NO graceful signal. This models a
* hard crash/kill: the authoritative liveness path must detect it purely from
* the absence of heartbeats (RFC-001 §4.5, A3), not from any native presence
* change. Idempotent.
*/
pauseHeartbeat(): void {
this.loop?.stop();
this.loop = undefined;
}
/** Graceful stop: stop heartbeating and drop native presence to offline. */
async stop(): Promise<void> {
this.pauseHeartbeat();
try {
await this.opts.client.setPresence(this.opts.agent.mxid, 'offline');
} catch (err) {
this.opts.onError?.(err);
}
}
}