feat(fleet): onboarding-injection — comms cheat-sheet + peer roster per agent (#620)
Root cause of a spawned agent's failed first send: fleet agents are born not knowing how to reach their peers. Inject a comms cheat-sheet + peer roster into each fleet agent's system prompt via composeContract (the runtime-agnostic path every `mosaic yolo <runtime>` agent hits), so it can reach the orchestrator and peers from its first turn. - src/fleet/comms-onboarding.ts (standalone, no fleet.ts coupling): parseRosterAgents (name/class/host/ssh/socket), renderPeerReach (data-driven agent-send command), buildFleetCommsBlock (own [host:session] identity + agent-send path + peer table + FLIP-to-reply + `agent send --verify`=ACCEPTED), readFleetCommsBlock (reads roster.yaml; '' when not a member). - launch.ts composeContract appends it only when MOSAIC_AGENT_NAME is set + the agent is in the roster (no-op for non-fleet launches). - roster.schema.json: optional per-agent host/ssh/socket (cross-host + socket addressing; manual cross-host listing is a pre-federation stopgap). Address rendering is fully data-driven per Mos's acceptance criteria: - cross-host: local → `-s <session>` (no -H); remote → `-H <ssh> -s <session>`. - socket: named → `-L <socket>`; unset → default socket (no -L) — matches the LIVE socket, never blindly inherits the roster's tmux.socket_name. Verified: 14 onboarding + 9 composeContract tests (parse, render local/remote/ fallback/equal-host/socket-none/named/combined, build, situational read + composeContract injection w/ correct cross-host+socket addrs, no-op when MOSAIC_AGENT_NAME unset). tsc/eslint/prettier/sanitize clean. Refs #620 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EsgTQzV5YUGk1JtCLP4B83
This commit is contained in:
@@ -70,3 +70,7 @@ Active workstream is **W1 — Federation v1**. Workers should:
|
|||||||
## F4 — Orchestrator chat connector + Matrix (#616) — feat/f4-matrix-connector
|
## 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.
|
- 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.
|
||||||
|
|||||||
31
docs/scratchpads/fleet-comms-onboarding.md
Normal file
31
docs/scratchpads/fleet-comms-onboarding.md
Normal file
@@ -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 <runtime>` 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 <ssh> -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 <ssh> -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.
|
||||||
@@ -81,12 +81,27 @@
|
|||||||
"class": {
|
"class": {
|
||||||
"type": "string"
|
"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 <user@host>` 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 <socket>` 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": {
|
"working_directory": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"workingDirectory": {
|
"workingDirectory": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"workingDirectory": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"model_hint": {
|
"model_hint": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -56,6 +56,55 @@ describe('composeContract — overlay composer', () => {
|
|||||||
rmSync(cwdDir, { recursive: true, force: true });
|
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', () => {
|
it('includes the per-tier anchors and the selected harness runtime', () => {
|
||||||
const out = composeContract('claude', fixture.home);
|
const out = composeContract('claude', fixture.home);
|
||||||
expect(out).toContain('GATE-1: the non-negotiable law.'); // L0
|
expect(out).toContain('GATE-1: the non-negotiable law.'); // L0
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { createRequire } from 'node:module';
|
|||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { join, dirname } from 'node:path';
|
import { join, dirname } from 'node:path';
|
||||||
import type { Command } from 'commander';
|
import type { Command } from 'commander';
|
||||||
|
import { readFleetCommsBlock } from '../fleet/comms-onboarding.js';
|
||||||
|
|
||||||
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
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
|
// Runtime-specific contract
|
||||||
parts.push('\n\n# Runtime-Specific Contract\n\n' + readFileSync(runtimeFile, 'utf-8'));
|
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');
|
return parts.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
187
packages/mosaic/src/fleet/comms-onboarding.spec.ts
Normal file
187
packages/mosaic/src/fleet/comms-onboarding.spec.ts
Normal file
@@ -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 <socket> 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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
183
packages/mosaic/src/fleet/comms-onboarding.ts
Normal file
183
packages/mosaic/src/fleet/comms-onboarding.ts
Normal file
@@ -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
|
||||||
|
* <runtime>` (→ 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 <socket>`; an unset socket → the
|
||||||
|
* default tmux socket (no `-L`). A cross-host peer adds `-H <ssh|host>`.
|
||||||
|
*/
|
||||||
|
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 \`<src>\` 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 <user@host>\`.
|
||||||
|
|
||||||
|
## Peers
|
||||||
|
|
||||||
|
| Agent | Role | Reach (session = agent name) |
|
||||||
|
| ----- | ---- | ---------------------------- |
|
||||||
|
${rows}
|
||||||
|
|
||||||
|
${orchLine}
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- Every message carries a self-identifying preamble \`[<src_host>:<src_session> -> <dst_host>:<dst_session>]\` — \`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');
|
||||||
Reference in New Issue
Block a user