diff --git a/docs/TASKS.md b/docs/TASKS.md index bc4abf6..0950127 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -70,3 +70,7 @@ Active workstream is **W1 — Federation v1**. Workers should: ## F4 — Orchestrator chat connector + Matrix (#616) — feat/f4-matrix-connector - Status: Phase 1 MERGED (#617: connector interface send/subscribe/health + registry + roster schema + design). Phase 2a (#618): Matrix CS-API client + factory. 20 connector tests green; no fleet.ts changes. Remaining Phase 2: init/configure connector-selection UX + roster wiring, systemd launch wiring, Conduit deploy guide. Detail: scratchpads/f4-matrix-connector.md. + +## Fleet onboarding-injection — comms cheat-sheet + peer roster (#620) — feat/fleet-comms-onboarding + +- Status: implemented + tested. Injects # Fleet Comms (peer roster + cross-host agent-send commands + FLIP-reply + --verify) into each spawned fleet agent via composeContract; optional per-agent host/ssh/socket roster fields (socket: named → -L, unset → default socket no -L). 10 + 2 tests green. Detail: scratchpads/fleet-comms-onboarding.md. diff --git a/docs/scratchpads/fleet-comms-onboarding.md b/docs/scratchpads/fleet-comms-onboarding.md new file mode 100644 index 0000000..974b012 --- /dev/null +++ b/docs/scratchpads/fleet-comms-onboarding.md @@ -0,0 +1,31 @@ +# Fleet onboarding-injection — comms cheat-sheet + peer roster (#620) + +- **Issue:** #620 · **Branch:** `feat/fleet-comms-onboarding` (off main). Root cause of Mos's failed first send. + +## What + +Inject a `# Fleet Comms` block into each spawned fleet agent's system prompt (via composeContract — the +runtime-agnostic path every `mosaic yolo ` agent hits), so it boots knowing how to reach peers. + +- `src/fleet/comms-onboarding.ts` (standalone, no fleet.ts coupling): + - `parseRosterAgents` (name/class/host/ssh, lenient), `renderPeerReach` (same-host `-s` vs cross-host + `-H -s`), `buildFleetCommsBlock` (self [host:session] identity + agent-send path + peer table + + FLIP-to-reply + `agent send --verify`=ACCEPTED), `readFleetCommsBlock` (reads roster.yaml; '' if not a member). + - `composeContract` appends it only when MOSAIC_AGENT_NAME is set + the agent is in the roster. +- `roster.schema.json`: optional per-agent `host` + `ssh` (cross-host addresses; manual = pre-federation + stopgap, federation/W1 auto-discovers later). + +## Acceptance criteria (Mos) — all covered + +1. own [host:session] + agent-send path + peer roster ✓ +2. cross-host correctness: local→`-s` (no -H); remote→`-H -s` ✓ (concrete coder0-0@dragon-lin) +3. FLIP-the-preamble reply rule ✓ +4. `agent send --verify` = ACCEPTED ✓ +5. no `-L` (default socket); matches live tooling ✓ + +## Verification + +- 10 onboarding unit tests (parse, render local/remote/fallback/equal-host, build, situational read) + + 2 composeContract situational tests (injects for fleet agent w/ correct cross-host addr; no-op when + MOSAIC_AGENT_NAME unset). tsc/eslint/prettier/sanitize clean. +- Post-merge validation: Mos spawns a real w-jarvis agent → first-try reach to coder0-0@dragon-lin + a local peer. diff --git a/packages/mosaic/framework/fleet/roster.schema.json b/packages/mosaic/framework/fleet/roster.schema.json index f23d4e0..5d96048 100644 --- a/packages/mosaic/framework/fleet/roster.schema.json +++ b/packages/mosaic/framework/fleet/roster.schema.json @@ -81,6 +81,18 @@ "class": { "type": "string" }, + "host": { + "description": "Host the agent runs on (hostname or IP). Absent = the fleet host. Used by onboarding-injection to render cross-host comms addresses. Manual cross-host listing is a pre-federation stopgap; federation (W1) auto-discovers later.", + "type": "string" + }, + "ssh": { + "description": "SSH target (user@host) for a cross-host peer, so onboarding renders the `agent-send.sh -H ` form. Optional; only needed for agents on a different host than the fleet.", + "type": "string" + }, + "socket": { + "description": "tmux socket the agent's session runs on. Onboarding renders `-L ` when set; absent = the default socket (no `-L`). Must match the LIVE socket, not blindly inherit the roster's tmux.socket_name.", + "type": "string" + }, "working_directory": { "type": "string" }, diff --git a/packages/mosaic/src/commands/compose-contract.spec.ts b/packages/mosaic/src/commands/compose-contract.spec.ts index f60f5a8..6abecce 100644 --- a/packages/mosaic/src/commands/compose-contract.spec.ts +++ b/packages/mosaic/src/commands/compose-contract.spec.ts @@ -56,6 +56,55 @@ describe('composeContract — overlay composer', () => { rmSync(cwdDir, { recursive: true, force: true }); }); + it('injects the fleet comms cheat-sheet for a spawned fleet agent (situational)', () => { + // A spawned agent has MOSAIC_AGENT_NAME set + is a member of the roster. + mkdirSync(join(fixture.home, 'fleet'), { recursive: true }); + writeFileSync( + join(fixture.home, 'fleet', 'roster.yaml'), + [ + 'version: 1', + 'transport: tmux', + 'agents:', + ' - name: orchestrator', + ' runtime: claude', + ' class: orchestrator', + ' - name: enhancer', + ' runtime: claude', + ' class: enhancer', + ' - name: coder0-0', + ' runtime: claude', + ' class: implementer', + ' host: 10.1.10.37', + ' ssh: jwoltje@10.1.10.37', + '', + ].join('\n'), + ); + const prev = process.env['MOSAIC_AGENT_NAME']; + try { + process.env['MOSAIC_AGENT_NAME'] = 'enhancer'; + const out = composeContract('claude', fixture.home); + expect(out).toContain('# Fleet Comms'); + expect(out).toMatch(/`\[[^\]]+:enhancer\]`/); // own [host:session] identity (host machine-dependent) + // local peer → no -H; cross-host peer → -H ssh + expect(out).toContain('-s orchestrator -m "…"'); + expect(out).toContain('-H jwoltje@10.1.10.37 -s coder0-0 -m "…"'); + expect(out).not.toContain('-H jwoltje@10.1.10.37 -s orchestrator'); // local stays local + } finally { + if (prev === undefined) delete process.env['MOSAIC_AGENT_NAME']; + else process.env['MOSAIC_AGENT_NAME'] = prev; + } + }); + + it('does NOT inject fleet comms when MOSAIC_AGENT_NAME is unset (non-fleet launch)', () => { + const prev = process.env['MOSAIC_AGENT_NAME']; + try { + delete process.env['MOSAIC_AGENT_NAME']; + expect(composeContract('claude', fixture.home)).not.toContain('# Fleet Comms'); + } finally { + if (prev !== undefined) process.env['MOSAIC_AGENT_NAME'] = prev; + } + }); + it('includes the per-tier anchors and the selected harness runtime', () => { const out = composeContract('claude', fixture.home); expect(out).toContain('GATE-1: the non-negotiable law.'); // L0 diff --git a/packages/mosaic/src/commands/launch.ts b/packages/mosaic/src/commands/launch.ts index f47cb13..471fcd3 100644 --- a/packages/mosaic/src/commands/launch.ts +++ b/packages/mosaic/src/commands/launch.ts @@ -19,6 +19,7 @@ import { createRequire } from 'node:module'; import { homedir } from 'node:os'; import { join, dirname } from 'node:path'; import type { Command } from 'commander'; +import { readFleetCommsBlock } from '../fleet/comms-onboarding.js'; const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic'); @@ -383,6 +384,12 @@ For required push/merge/issue-close/release actions, execute without routine con // Runtime-specific contract parts.push('\n\n# Runtime-Specific Contract\n\n' + readFileSync(runtimeFile, 'utf-8')); + // Fleet onboarding: when this is a spawned fleet agent (MOSAIC_AGENT_NAME set + // and present in the roster), inject a comms cheat-sheet + peer roster so it + // knows how to reach the orchestrator and its peers from its first turn. + const fleetComms = readFleetCommsBlock(mosaicHome, process.env['MOSAIC_AGENT_NAME']); + if (fleetComms) parts.push('\n\n' + fleetComms); + return parts.join('\n'); } diff --git a/packages/mosaic/src/fleet/comms-onboarding.spec.ts b/packages/mosaic/src/fleet/comms-onboarding.spec.ts new file mode 100644 index 0000000..40539f5 --- /dev/null +++ b/packages/mosaic/src/fleet/comms-onboarding.spec.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + parseRosterAgents, + buildFleetCommsBlock, + renderPeerReach, + readFleetCommsBlock, + type CommsPeer, +} from './comms-onboarding.js'; + +const ROSTER = [ + 'version: 1', + 'transport: tmux', + 'agents:', + ' - name: orchestrator', + ' runtime: claude', + ' class: orchestrator', + ' - name: enhancer', + ' runtime: claude', + ' class: enhancer', + ' - name: coder0', + ' runtime: pi', + ' class: implementer', + ' # a manually-listed cross-host peer (pre-federation stopgap)', + ' - name: coder0-0', + ' runtime: claude', + ' class: implementer', + ' host: 10.1.10.37', + ' ssh: jwoltje@10.1.10.37', + '', +].join('\n'); + +describe('parseRosterAgents', () => { + it('parses name + class + optional host/ssh', () => { + const peers = parseRosterAgents(ROSTER); + expect(peers.map((p) => p.name)).toEqual(['orchestrator', 'enhancer', 'coder0', 'coder0-0']); + expect(peers.find((p) => p.name === 'coder0')).toMatchObject({ className: 'implementer' }); + expect(peers.find((p) => p.name === 'coder0-0')).toMatchObject({ + className: 'implementer', + host: '10.1.10.37', + ssh: 'jwoltje@10.1.10.37', + }); + // local agents have no host/ssh + expect(peers.find((p) => p.name === 'orchestrator')!.host).toBeUndefined(); + }); + + it('parses an optional per-agent socket', () => { + const peers = parseRosterAgents( + ['agents:', ' - name: a', ' class: worker', ' socket: mosaic-factory'].join('\n'), + ); + expect(peers[0]).toMatchObject({ name: 'a', socket: 'mosaic-factory' }); + }); + + it('stops at the next top-level key', () => { + const peers = parseRosterAgents( + ['agents:', ' - name: a', ' class: worker', 'defaults:', ' working_directory: ~'].join( + '\n', + ), + ); + expect(peers.map((p) => p.name)).toEqual(['a']); + }); +}); + +describe('renderPeerReach — same-host vs cross-host', () => { + const send = '/home/u/.config/mosaic/tools/tmux/agent-send.sh'; + + it('renders the short form for a same-host peer', () => { + const peer: CommsPeer = { name: 'enhancer', className: 'enhancer' }; + expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(`${send} -s enhancer -m "…"`); + }); + + it('renders the -H form for a cross-host peer using ssh', () => { + const peer: CommsPeer = { + name: 'coder0-0', + className: 'implementer', + host: '10.1.10.37', + ssh: 'jwoltje@10.1.10.37', + }; + expect(renderPeerReach(peer, 'w-jarvis', send)).toBe( + `${send} -H jwoltje@10.1.10.37 -s coder0-0 -m "…"`, + ); + }); + + it('falls back to host when a cross-host peer has no ssh', () => { + const peer: CommsPeer = { name: 'x', className: 'worker', host: '10.0.0.9' }; + expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(`${send} -H 10.0.0.9 -s x -m "…"`); + }); + + it('treats a peer whose host equals the fleet host as same-host', () => { + const peer: CommsPeer = { name: 'y', className: 'worker', host: 'w-jarvis' }; + expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(`${send} -s y -m "…"`); + }); + + it('emits NO -L for an unset/default socket', () => { + const peer: CommsPeer = { name: 'lead', className: 'orchestrator' }; + expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(`${send} -s lead -m "…"`); + }); + + it('emits -L for a named socket', () => { + const peer: CommsPeer = { name: 'coder0', className: 'implementer', socket: 'mosaic-factory' }; + expect(renderPeerReach(peer, 'w-jarvis', send)).toBe( + `${send} -L mosaic-factory -s coder0 -m "…"`, + ); + }); + + it('combines -L (named socket) and -H (cross-host) in order', () => { + const peer: CommsPeer = { + name: 'coder0-0', + className: 'implementer', + host: '10.1.10.37', + ssh: 'jwoltje@10.1.10.37', + socket: 'mosaic-factory', + }; + expect(renderPeerReach(peer, 'w-jarvis', send)).toBe( + `${send} -L mosaic-factory -H jwoltje@10.1.10.37 -s coder0-0 -m "…"`, + ); + }); +}); + +describe('buildFleetCommsBlock', () => { + const send = '/h/.config/mosaic/tools/tmux/agent-send.sh'; + const agents = parseRosterAgents(ROSTER); + + it('excludes self, lists peers, flags the orchestrator, and emits both address forms', () => { + const block = buildFleetCommsBlock({ + selfName: 'enhancer', + agents, + fleetHost: 'w-jarvis', + agentSendPath: send, + }); + expect(block).toContain('# Fleet Comms'); + expect(block).toContain('You are **enhancer**'); + // criterion 1: agent's own [host:session] identity + expect(block).toContain('`[w-jarvis:enhancer]`'); + // self excluded + expect(block).not.toMatch(/\|\s*enhancer\s*\|/); + // peers present + expect(block).toContain('| orchestrator |'); + expect(block).toContain('point of contact'); + // same-host peer short form + expect(block).toContain(`${send} -s coder0 -m "…"`); + // cross-host peer -H form + host annotation + expect(block).toContain(`${send} -H jwoltje@10.1.10.37 -s coder0-0 -m "…"`); + expect(block).toContain('host `10.1.10.37`'); + // conventions + expect(block).toContain('FLIP the preamble'); + expect(block).toContain('ACCEPTED'); + }); + + it('returns empty when the agent has no peers', () => { + expect( + buildFleetCommsBlock({ + selfName: 'solo', + agents: [{ name: 'solo', className: 'orchestrator' }], + fleetHost: 'h', + agentSendPath: send, + }), + ).toBe(''); + }); +}); + +describe('readFleetCommsBlock — situational (the context a spawned agent gets)', () => { + let home: string; + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'mosaic-comms-')); + mkdirSync(join(home, 'fleet'), { recursive: true }); + writeFileSync(join(home, 'fleet', 'roster.yaml'), ROSTER); + }); + afterEach(() => rmSync(home, { recursive: true, force: true })); + + it('builds the cheat-sheet with correct peer addresses for a fleet member', () => { + const block = readFleetCommsBlock(home, 'orchestrator', 'w-jarvis'); + expect(block).toContain('# Fleet Comms'); + expect(block).toContain('| enhancer |'); + expect(block).toContain(`${join(home, 'tools', 'tmux', 'agent-send.sh')} -s coder0 -m "…"`); + expect(block).toContain('-H jwoltje@10.1.10.37 -s coder0-0'); + expect(block).not.toMatch(/\|\s*orchestrator\s*\|/); // self excluded + }); + + it('returns empty when MOSAIC_AGENT_NAME is unset, no roster, or agent not a member', () => { + expect(readFleetCommsBlock(home, undefined, 'w-jarvis')).toBe(''); + expect(readFleetCommsBlock(home, 'stranger', 'w-jarvis')).toBe(''); + expect(readFleetCommsBlock(mkdtempSync(join(tmpdir(), 'noroster-')), 'orchestrator')).toBe(''); + }); +}); diff --git a/packages/mosaic/src/fleet/comms-onboarding.ts b/packages/mosaic/src/fleet/comms-onboarding.ts new file mode 100644 index 0000000..65fc825 --- /dev/null +++ b/packages/mosaic/src/fleet/comms-onboarding.ts @@ -0,0 +1,183 @@ +/** + * Fleet onboarding-injection (#620). + * + * Fleet agents are born not knowing how to reach their peers — the root cause of + * a spawned agent's failed first send. When an agent boots via `mosaic yolo + * ` (→ composeContract → system prompt), we append a comms cheat-sheet + * + peer roster so it can talk to the orchestrator and other agents immediately. + * + * Cross-host aware: a peer may carry `host`/`ssh` (a deliberate pre-federation + * stopgap — manual cross-host listing; federation/W1 auto-discovers later), so a + * w-jarvis agent is born knowing the exact `-H` command to reach a dragon-lin + * peer. Same-host peers render the short form. + * + * Standalone (no fleet.ts import) to keep launch.ts's prompt path free of the + * heavy fleet command module. The roster is parsed leniently — the cheat-sheet + * is best-effort onboarding, never a hard dependency. + */ + +import { existsSync, readFileSync } from 'node:fs'; +import { homedir, hostname } from 'node:os'; +import { join } from 'node:path'; + +export interface CommsPeer { + name: string; + /** Roster `class` (orchestrator | enhancer | implementer | worker | …). */ + className: string; + /** Host the peer runs on; absent ⇒ the fleet host (same host). */ + host?: string; + /** SSH target (user@host) for a cross-host peer; renders the `-H` form. */ + ssh?: string; + /** tmux socket the peer's session lives on; absent ⇒ default socket (no `-L`). */ + socket?: string; +} + +/** + * Lenient parse of a fleet `roster.yaml` for agent name/class/host/ssh. Avoids a + * dependency on the full fleet roster parser; the format is `- name:` list items + * with `class:`/`host:`/`ssh:` siblings under `agents:`. + */ +export function parseRosterAgents(yamlText: string): CommsPeer[] { + const peers: CommsPeer[] = []; + let current: CommsPeer | null = null; + let inAgents = false; + const scalar = (line: string, key: string): string | null => { + const m = line.match(new RegExp(`^\\s*${key}:\\s*["']?([^"'#]+?)["']?\\s*$`)); + return m ? (m[1] as string).trim() : null; + }; + for (const rawLine of yamlText.split('\n')) { + const line = rawLine.replace(/\s+$/, ''); + if (/^agents:\s*$/.test(line)) { + inAgents = true; + continue; + } + if (!inAgents) continue; + // A new top-level key (no leading space) ends the agents block. + if (/^\S/.test(line)) break; + + const nameMatch = line.match(/^\s*-\s*name:\s*["']?([A-Za-z0-9._-]+)["']?\s*$/); + if (nameMatch) { + if (current) peers.push(current); + current = { name: nameMatch[1] as string, className: 'worker' }; + continue; + } + if (!current) continue; + const cls = scalar(line, 'class'); + if (cls) current.className = cls; + const host = scalar(line, 'host'); + if (host) current.host = host; + const ssh = scalar(line, 'ssh'); + if (ssh) current.ssh = ssh; + const socket = scalar(line, 'socket'); + if (socket) current.socket = socket; + } + if (current) peers.push(current); + return peers; +} + +export interface FleetCommsOptions { + /** This agent's name (it is excluded from its own peer list). */ + selfName: string; + /** All roster agents (including self; filtered out internally). */ + agents: CommsPeer[]; + /** Host the fleet runs on (short hostname) — the same-host baseline. */ + fleetHost: string; + /** Absolute path to agent-send.sh in this install. */ + agentSendPath: string; +} + +/** Is this peer on a different host than the fleet baseline? */ +function isRemote(peer: CommsPeer, fleetHost: string): boolean { + return peer.host !== undefined && peer.host !== fleetHost; +} + +/** + * Render the exact agent-send command to reach a peer (session = agent name). + * Data-driven per peer: a named `socket` → `-L `; an unset socket → the + * default tmux socket (no `-L`). A cross-host peer adds `-H `. + */ +export function renderPeerReach(peer: CommsPeer, fleetHost: string, agentSendPath: string): string { + const parts = [agentSendPath]; + if (peer.socket) parts.push('-L', peer.socket); // unset ⇒ default socket, no -L + if (isRemote(peer, fleetHost)) parts.push('-H', peer.ssh ?? (peer.host as string)); + parts.push('-s', peer.name, '-m', '"…"'); + return parts.join(' '); +} + +/** + * Build the `# Fleet Comms` onboarding block (pure markdown). Returns '' when + * the agent has no peers (a single-agent roster has no one to talk to). + */ +export function buildFleetCommsBlock(opts: FleetCommsOptions): string { + const peers = opts.agents.filter((a) => a.name !== opts.selfName); + if (peers.length === 0) return ''; + + const orchestrator = peers.find((p) => p.className === 'orchestrator'); + const rows = peers + .map((p) => { + const where = isRemote(p, opts.fleetHost) + ? `${p.className} · host \`${p.host}\`` + : p.className; + const role = p.className === 'orchestrator' ? `${where} ← point of contact` : where; + return `| ${p.name} | ${role} | \`${renderPeerReach(p, opts.fleetHost, opts.agentSendPath)}\` |`; + }) + .join('\n'); + + const orchLine = orchestrator + ? `Your point of contact is **${orchestrator.name}** (the orchestrator) — route questions, ` + + `status, and decisions there.` + : `This fleet has no orchestrator in its roster; coordinate with your peers directly.`; + + return `# Fleet Comms — reach your peers + +You are **${opts.selfName}** in this fleet. Your comms identity is \`[${opts.fleetHost}:${opts.selfName}]\` — +that is the \`\` other agents see and reply to. Reach other agents (durable tmux sessions) with the +Mosaic comms tool at \`${opts.agentSendPath}\`. The **Reach** column below is the exact command per peer: +same-host peers use the short form (no \`-H\`); cross-host peers include \`-H \`. + +## Peers + +| Agent | Role | Reach (session = agent name) | +| ----- | ---- | ---------------------------- | +${rows} + +${orchLine} + +## Conventions + +- Every message carries a self-identifying preamble \`[: -> :]\` — \`agent-send.sh\` adds it automatically. +- **To reply, FLIP the preamble:** address your reply to the sender's \`src\` (their host:session becomes your \`-s\`/\`-H\`). +- \`agent-send.sh\` (a.k.a. \`agent send --verify\`) confirms the message was **ACCEPTED** at the destination prompt — not merely injected. Prefer it for anything that matters.`; +} + +/** + * Read the fleet roster from `mosaicHome` and build the comms block for + * `selfName`. Returns '' when there is no roster, the agent is not in it, or + * there are no peers — onboarding is best-effort and never throws. + */ +export function readFleetCommsBlock( + mosaicHome: string, + selfName: string | undefined, + fleetHost: string = hostname().split('.')[0] || 'localhost', +): string { + if (!selfName) return ''; + const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml'); + if (!existsSync(rosterPath)) return ''; + let text: string; + try { + text = readFileSync(rosterPath, 'utf-8'); + } catch { + return ''; + } + const agents = parseRosterAgents(text); + if (!agents.some((a) => a.name === selfName)) return ''; // not a member of this fleet + return buildFleetCommsBlock({ + selfName, + agents, + fleetHost, + agentSendPath: join(mosaicHome, 'tools', 'tmux', 'agent-send.sh'), + }); +} + +/** Default mosaic home (mirrors launch.ts), for callers that don't pass one. */ +export const DEFAULT_MOSAIC_HOME_FOR_COMMS = join(homedir(), '.config', 'mosaic');