Files
stack/tools/matrix-presence-harness/validate.ts
mosaic-coder 2e7c124e4d
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
feat(comms): P1 presence — minimal Synapse + fleet presence room + mosaic.presence heartbeat + liveness
Implements RFC-001 P1 (the first shippable slice): deterministic Matrix
presence/liveness on a single-instance dev Synapse.

- infra/matrix/: DEV Synapse (RFC-002 Mode B, federation OFF, self-signed TLS,
  enable_registration:false, appservice registration wired). Rendered from
  parameterized templates — zero hardcoded topology. .data is gitignored.
- packages/comms/: minimal MACP presence SDK — set presence, run the
  mosaic.presence heartbeat (seq + interval per RFC-001 §4.5), and a
  deterministic liveness reader (online/away/offline from heartbeat age, NOT
  native-presence-timeout). Liveness core written RED-FIRST. 19 vitest tests.
- tools/matrix-presence-harness/: minimal provisioner (registers >=3 agent
  MXIDs, creates the fleet presence room, joins them — reuses the existing
  @mosaicstack/appservice intent lib) + an E2E validation harness proving
  A2/A3/A4 against a real Synapse.
- eslint.config.mjs: register packages/comms/vitest.config.ts with the
  type-aware project service (same as other packages' vitest configs).

DEV-compose validated only; production deploy is a separate coordinated step
(deploy-holds respected).

Part of the comms-evolution program (RFC-001 P1)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0158NZqN2n2ymKFeJAZ4GUCb
2026-07-24 20:18:18 -05:00

211 lines
7.5 KiB
TypeScript

/**
* tools/matrix-presence-harness/validate.ts
*
* *** DEV harness — LOCAL SANDBOX ONLY. Never point at live infra. ***
*
* End-to-end proof of RFC-001 P1 acceptance criteria against the local dev
* Synapse (infra/matrix):
*
* A2 provision >=3 agents, they heartbeat, all show ONLINE in the fleet
* presence room.
* A3 hard-kill (SIGKILL) one agent's process; assert it flips to OFFLINE
* within dark_threshold, DETERMINISTICALLY (heartbeat-age based, not
* native-presence-timeout), while the survivors stay ONLINE.
* A4 dump the human-readable fleet liveness board.
*
* Exits 0 on success, 1 on any failed assertion.
* ESM / NodeNext: .js import extensions.
*/
import { spawn, type ChildProcess } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import {
FleetLivenessReader,
MinimalMatrixClient,
type AgentLiveness,
type LivenessPolicy,
} from '@mosaicstack/comms';
import { provision } from './provision.js';
const HERE = dirname(fileURLToPath(import.meta.url));
const env = (k: string, fallback?: string): string => {
const v = process.env[k] ?? fallback;
if (v === undefined) throw new Error(`missing env ${k}`);
return v;
};
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
const board = (rows: AgentLiveness[]): Record<string, string> =>
Object.fromEntries(rows.map((r) => [r.slug, r.status]));
async function main(): Promise<void> {
const homeserverUrl = env('MATRIX_CS_URL');
const asToken = env('MOSAIC_AS_TOKEN');
const hsToken = env('MOSAIC_HS_TOKEN', 'unused-in-p1');
const serverName = env('MATRIX_SERVER_NAME');
const intervalMs = Number(env('HEARTBEAT_INTERVAL_MS', '1000'));
const missTolerance = Number(env('MISS_TOLERANCE', '2'));
const darkThresholdMs = Number(env('DARK_THRESHOLD_MS', '6000'));
const slugs = env('AGENT_SLUGS', 'alpha,bravo,charlie').split(',');
const victim = env('VICTIM_SLUG', 'charlie');
const policy: LivenessPolicy = {
heartbeatIntervalMs: intervalMs,
missTolerance,
darkThresholdMs,
};
const failures: string[] = [];
const assert = (ok: boolean, msg: string): void => {
console.log(` ${ok ? 'PASS' : 'FAIL'} ${msg}`);
if (!ok) failures.push(msg);
};
// ── Provision (register agents, create fleet room, join) ──────────────────
console.log(`\n[validate] provisioning ${slugs.length} agents + fleet presence room...`);
const { roomId, senderUserId } = await provision({
homeserverUrl,
serverName,
asToken,
hsToken,
agentSlugs: slugs,
});
console.log(`[validate] fleet room ${roomId} (sender ${senderUserId})`);
// Reader masquerades as the AS sender (a room member) to read heartbeats.
const reader = new FleetLivenessReader({
client: new MinimalMatrixClient({
homeserverUrl,
accessToken: asToken,
actAsUserId: senderUserId,
}),
roomId,
policy,
});
// ── Spawn each agent as its own OS process ────────────────────────────────
const procs = new Map<string, ChildProcess>();
for (const slug of slugs) {
// `--import tsx` runs agent-proc.ts IN-PROCESS (no tsx grandchild), so the
// spawned pid IS the agent — a SIGKILL to it is a true hard-kill (A3).
// cwd=HERE so the `tsx` specifier resolves against the harness node_modules.
const child = spawn('node', ['--import', 'tsx', resolve(HERE, 'agent-proc.ts')], {
cwd: HERE,
env: {
...process.env,
AGENT_SLUG: slug,
FLEET_ROOM_ID: roomId,
MATRIX_CS_URL: homeserverUrl,
MOSAIC_AS_TOKEN: asToken,
MATRIX_SERVER_NAME: serverName,
HEARTBEAT_INTERVAL_MS: String(intervalMs),
MISS_TOLERANCE: String(missTolerance),
DARK_THRESHOLD_MS: String(darkThresholdMs),
},
stdio: ['ignore', 'inherit', 'inherit'],
});
procs.set(slug, child);
}
const hardKill = (pid: number): void => {
try {
process.kill(pid, 'SIGKILL'); // pid is the in-process agent — a true kill
} catch {
/* already gone */
}
};
const cleanup = (): void => {
for (const [, c] of procs) {
if (c.pid) hardKill(c.pid);
}
};
process.on('exit', cleanup);
try {
// ── A2: let a few beats flow, assert all online ─────────────────────────
console.log(`\n[validate] A2 — waiting for heartbeats (${intervalMs}ms interval)...`);
await sleep(intervalMs * 4 + 1000);
const a2 = await reader.read();
console.log('[validate] board:', board(a2));
for (const slug of slugs) {
assert(a2.find((r) => r.slug === slug)?.status === 'online', `A2 ${slug} is online`);
}
assert(a2.length >= 3, 'A2 >=3 agents present in fleet room');
// ── A4: human-readable board ────────────────────────────────────────────
console.log('\n[validate] A4 — fleet liveness board (human view):');
console.log(await reader.formatBoard());
// ── A3: hard-kill the victim, measure flip-to-offline latency ───────────
const victimProc = procs.get(victim);
if (!victimProc?.pid) throw new Error(`no pid for victim ${victim}`);
console.log(
`\n[validate] A3 — SIGKILL ${victim} (pid ${victimProc.pid}); dark_threshold=${darkThresholdMs}ms`,
);
const killTs = Date.now();
hardKill(victimProc.pid);
let victimOffline: AgentLiveness | undefined;
let detectMs = 0;
const deadline = killTs + darkThresholdMs + 6000;
while (Date.now() < deadline) {
await sleep(400);
const rows = await reader.read();
const v = rows.find((r) => r.slug === victim);
const elapsed = Date.now() - killTs;
if (v) {
console.log(
` t+${String(elapsed).padStart(5)}ms ${victim}=${v.status} (age=${v.ageMs}ms) ` +
`survivors=${slugs
.filter((s) => s !== victim)
.map((s) => `${s}:${rows.find((r) => r.slug === s)?.status}`)
.join(',')}`,
);
if (v.status === 'offline') {
victimOffline = v;
detectMs = elapsed;
break;
}
}
}
assert(!!victimOffline, `A3 ${victim} flipped to offline after kill`);
if (victimOffline) {
// Deterministic: the flip is driven by heartbeat age reaching dark_threshold.
assert(
victimOffline.ageMs >= darkThresholdMs,
`A3 flip is heartbeat-deterministic (age ${victimOffline.ageMs}ms >= dark_threshold ${darkThresholdMs}ms)`,
);
assert(
detectMs <= darkThresholdMs + 2000,
`A3 detected within dark_threshold + poll slack (detected at t+${detectMs}ms)`,
);
}
// Survivors unaffected.
const finalRows = await reader.read();
for (const slug of slugs.filter((s) => s !== victim)) {
assert(
finalRows.find((r) => r.slug === slug)?.status === 'online',
`A3 survivor ${slug} still online`,
);
}
console.log('\n[validate] final board after kill:');
console.log(await reader.formatBoard());
} finally {
cleanup();
}
console.log(
`\n[validate] ${failures.length === 0 ? 'ALL PASSED ✅' : `FAILED ❌ (${failures.length})`}`,
);
process.exit(failures.length === 0 ? 0 : 1);
}
main().catch((err) => {
console.error('[validate] fatal:', err);
process.exit(1);
});