diff --git a/docs/TASKS.md b/docs/TASKS.md index 1ccf253..8d85fa0 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -86,3 +86,7 @@ Active workstream is **W1 — Federation v1**. Workers should: ## #631 — re-seed preserves user fleet data (CRITICAL) — fix/631-reseed-preserves-fleet-data - Status: implemented + tested. PRIMARY: install.sh PRESERVE_PATHS += fleet/\*.yaml + fleet/agents + fleet/run (glob-aware cp-fallback); TS parity. SECONDARY: refreshActiveFleetUnits propagates unit fixes to ~/.config/systemd/user on mosaic update. bash F6 + TS + unit tests green. Detail: scratchpads/631-reseed-preserves-fleet.md. + +## #633 — comms-block emitter + FLEET-LAUNCH runbook — feat/633-comms-block-runbook + +- Status: implemented + tested (TDD). `mosaic fleet comms-block [--host]` wraps resolveCommsBlock → readFleetCommsBlock; fails loud (stderr + exit 1) on unknown role / missing roster instead of silent empty. docs/fleet/FLEET-LAUNCH.md runbook: worker path + orchestrator .env fold (MOSAIC_AGENT_COMMAND; line-41 [-z] short-circuits line-44 yolo hardcode) + 3 launch gotchas + #632 preserve note + North-Star 4-field arc (harness ✅/model ✅ roster-native today; yolo + command/channels = PATH B #636). 177 fleet+comms tests green (6 new resolveCommsBlock cases). PATH A of the A→B→webUI arc. Detail: scratchpads/633-comms-block-runbook.md. diff --git a/docs/fleet/FLEET-LAUNCH.md b/docs/fleet/FLEET-LAUNCH.md new file mode 100644 index 0000000..515aabe --- /dev/null +++ b/docs/fleet/FLEET-LAUNCH.md @@ -0,0 +1,114 @@ +# Fleet Launch Runbook + +How every Mosaic fleet agent — workers **and** the orchestrator — is launched, and how to +configure each one. The guiding principle: **one roster-driven launcher**. There is no bespoke +per-agent launch script; the roster plus per-agent `.env` files are the single source of launch +config. + +## The launch chain + +| Layer | File | Responsibility | +| ---------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| systemd unit | `mosaic-agent@.service` | One templated unit per role; `ExecStart` runs the session launcher with the instance name `%i`. Defaults `MOSAIC_AGENT_RUNTIME=pi`, `MOSAIC_AGENT_NAME=%i`. | +| session launcher | `tools/fleet/start-agent-session.sh ` | Builds the launch command, opens the tmux pane, wires the heartbeat. | +| launch command | `mosaic yolo ` (or a per-agent override) | Replaces the pane's foreground process with the runtime, fully seeded. | +| seeding | `mosaic`'s `composeContract()` | Injects the Constitution/USER/TOOLS/runtime contract, `*.local` overlays, **and** the Fleet-Comms cheat-sheet — all via `--append-system-prompt`. | + +Per-agent overrides live in `fleet/agents/.env`, generated from `roster.yaml` by +`generateAgentEnv` (`packages/mosaic/src/commands/fleet.ts`) and consumed by the launcher. + +## Worker launch path (default) + +1. `roster.yaml` carries each agent's `runtime` and optional `model_hint`. +2. `generateAgentEnv` emits `fleet/agents/.env` with `MOSAIC_AGENT_NAME`, + `MOSAIC_AGENT_RUNTIME`, and `MOSAIC_AGENT_MODEL`. +3. `start-agent-session.sh` has no `MOSAIC_AGENT_COMMAND` set, so it falls through to the default + (line ~44): + ```sh + MOSAIC_AGENT_COMMAND="mosaic yolo $MOSAIC_AGENT_RUNTIME${MOSAIC_AGENT_MODEL:+ --model $MOSAIC_AGENT_MODEL}" + ``` +4. The launcher bakes `MOSAIC_AGENT_NAME` into the pane command (line ~118), so `composeContract` + can inject the Fleet-Comms cheat-sheet for that role. + +That is the whole worker path: roster → `.env` → `mosaic yolo ` → seeded pane. + +## Orchestrator fold (PATH A — ships today) + +The orchestrator is **just another roster agent** launched through the canonical path — not a +snowflake script. + +| Piece | Value | +| ------------------ | ----------------------------------- | +| host-side launcher | `orchestrator-launch.sh` | +| systemd unit | `mosaic-fleet-orchestrator.service` | +| tmux session | `orchestrator` (role-named) | + +Set its launch command via `fleet/agents/orchestrator.env`: + +```sh +MOSAIC_AGENT_COMMAND='mosaic yolo claude --channels plugin:discord@' +``` + +When `MOSAIC_AGENT_COMMAND` is set, `start-agent-session.sh`'s `if [ -z "$MOSAIC_AGENT_COMMAND" ]` +guard (line ~41) is false, so the line-44 default — **including its hardcoded `yolo`** — is skipped +entirely. The override fully controls the runtime and flags. Routing through `mosaic yolo claude` +(rather than a raw `claude` invocation) is what gives the orchestrator the same full +`composeContract` seeding + Fleet-Comms cheat-sheet as every worker, with `--channels` and any +other flags passed straight through to the `claude` binary. + +## Launch gotchas + +1. **Flag conflict.** `mosaic yolo claude` already injects `--dangerously-skip-permissions`. Do + **not** also pass `--permission-mode bypassPermissions` — the `claude` binary would receive both. + Use `mosaic yolo claude …` alone (yolo covers the unattended posture), **or** non-yolo + `mosaic claude --permission-mode bypassPermissions …`. Never mix the two. +2. **`MOSAIC_AGENT_NAME` must reach the pane.** The launcher bakes it from the instance name, and + `composeContract` gates the Fleet-Comms block on it (`launch.ts`, in `composeContract`) — **and** + the role must be a member of `roster.yaml`, or the block resolves empty. +3. **`launchRuntime` guards.** `mosaic yolo claude` runs `checkSoul` / `checkRuntime` / + `checkSequentialThinking`. The host needs `SOUL.md` and the sequential-thinking MCP, or the + launch aborts (a raw `claude` invocation skipped these checks). Dry-run the composed command in a + throwaway tmux session before swapping a live launcher. + +## Why per-agent `.env` survives upgrades (#632) + +`install.sh` `PRESERVE_PATHS` includes `fleet/*.yaml`, `fleet/agents`, and `fleet/run`, so +`mosaic update`'s framework re-seed **preserves** your roster and per-agent `.env` overrides +(glob-aware `cp` fallback; matching TS parity in `file-adapter.ts`). Before #632, an auto re-seed +could wipe them — which is exactly why PATH A's `.env` override is safe to rely on now. + +## Inspecting the comms wiring + +- `mosaic fleet comms-block ` prints the Fleet-Comms cheat-sheet a given role receives at + launch — its `[host:session]` identity, the exact `agent-send.sh` command for each peer, and the + FLIP / `--verify` conventions. `--host ` previews a cross-host view. An unknown role or missing + roster **fails loud** (stderr + non-zero exit), so a typo is never a silent no-op. +- Versus `mosaic compose-contract `: that emits the **whole** system prompt and reads the + role from `MOSAIC_AGENT_NAME` (a full-prompt smoke test). `comms-block` is the targeted, + explicit-arg, comms-only view — e.g. `mosaic fleet comms-block coder0-0` to preview a peer. + +## North Star / future direction + +**Vision:** a webUI lets the user edit each agent's launch config — switch **harness** +(claude / pi / codex / opencode), toggle **yolo**, pick a **model**, set a **command/channels** +override — with no terminal. + +**Continuity — this is not a new launch path.** It is a data-model + UI-binding layer over the +existing roster-driven launcher. Field-by-field status today: + +| Launch-config field | Roster-native today? | Mechanism / gap | +| ------------------------ | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **harness** (`runtime`) | ✅ end-to-end | `roster.runtime` → `generateAgentEnv` emits `MOSAIC_AGENT_RUNTIME` → launcher line 44. UI just writes the field. | +| **model** (`model_hint`) | ✅ end-to-end | `roster.model_hint` → `MOSAIC_AGENT_MODEL` → launcher line 44 `--model`. UI just writes the field. | +| **yolo** | ❌ new | Launcher line 44 **hardcodes** `mosaic yolo`. A non-yolo toggle needs a roster `yolo` field → emit `MOSAIC_AGENT_YOLO` → make line 44 conditional. | +| **command / channels** | ❌ new | `MOSAIC_AGENT_COMMAND` is **consumed** (launcher line ~12) but `generateAgentEnv` does not emit it. Needs a roster `command`/`channels` field → emitted. | + +**The arc:** + +- **A** — `.env` `MOSAIC_AGENT_COMMAND` hatch: manual, ships now, kept safe across upgrades by #632. +- **B** — roster-native launch-config: harness + model are already there; add the **yolo** toggle + (line-44 conditional) and **command/channels** emission to complete the data model. +- **webUI** — binds dropdowns/toggles directly to those four roster fields. + +PATH A's `.env` override is the **manual form** of exactly what PATH B makes roster-native and the +webUI edits — one continuous arc, not three separate features. PATH B is tracked as #636. diff --git a/docs/scratchpads/633-comms-block-runbook.md b/docs/scratchpads/633-comms-block-runbook.md new file mode 100644 index 0000000..2b91ae2 --- /dev/null +++ b/docs/scratchpads/633-comms-block-runbook.md @@ -0,0 +1,54 @@ +# #633 — comms-block emitter + FLEET-LAUNCH runbook + +Branch: `feat/633-comms-block-runbook` (off `bf2a6745`, post-#632 merge) +Issue: #633 · Follow-up filed: #636 (PATH B) + +## Goal + +PATH A of the orchestrator-launch fix: give every launch path the Fleet-Comms onboarding, and +document the canonical roster-driven launcher so the orchestrator stops being a bespoke snowflake. + +## Deliverables + +1. **`mosaic fleet comms-block [--host ]`** — explicit-arg, comms-block-only emitter. + - Backed by new `resolveCommsBlock(mosaicHome, role, fleetHost?)` in `fleet/comms-onboarding.ts` + returning `{ ok, output, error }`. + - Unlike `readFleetCommsBlock` (returns `''` on any miss so `composeContract` can no-op silently + during launch), the emitter **fails loud**: unknown role / missing roster → `ok:false` → CLI + prints to stderr + sets `process.exitCode = 1`. A typo is never a silent no-op. + - Distinct from `mosaic compose-contract ` (whole prompt, env-coupled via + `MOSAIC_AGENT_NAME`); comms-block is the targeted, explicit-arg, comms-only view. +2. **`docs/fleet/FLEET-LAUNCH.md`** — worker path + orchestrator `.env` fold + 3 launch gotchas + + #632 preserve note + North-Star 4-field arc. + +## Key findings (drove the design) + +- `mosaic yolo claude` **already** forwards `--channels`/`--permission-mode` to the binary + (`launch.ts` claude case `cliArgs.push(...args)`) AND injects the comms block via + `composeContract` → `readFleetCommsBlock(home, env.MOSAIC_AGENT_NAME)`. So no `launch.ts` change + was needed — PATH A is `.env` + doc only. +- `start-agent-session.sh` line ~41 `[ -z "$MOSAIC_AGENT_COMMAND" ]` short-circuits the line-44 + default, so an `.env` `MOSAIC_AGENT_COMMAND` override bypasses the hardcoded `yolo` entirely — the + yolo-conditional is therefore a PATH B (default-path) concern, not PATH A. +- `generateAgentEnv` (`fleet.ts` ~202-207) emits NAME/RUNTIME/MODEL but **not** `MOSAIC_AGENT_COMMAND` + — the seam PATH B (#636) closes. + +## A → B → webUI arc (North Star) + +- A = `.env` `MOSAIC_AGENT_COMMAND` hatch (manual, ships now, #632-safe). +- B (#636) = roster-native launch-config: harness ✅ + model ✅ already there; add **yolo** (line-44 + conditional `MOSAIC_AGENT_YOLO`) + **command/channels** (`generateAgentEnv` emission). +- webUI binds dropdowns/toggles to those four roster fields. One launcher, no new launch path. + +## Results + +- TDD: spec first (`comms-onboarding.spec.ts`, 6 new `resolveCommsBlock` cases) → red → implement → green. +- `fleet.spec.ts` subcommand-list assertion extended with `comms-block`. +- 177 fleet+comms tests green; typecheck clean; eslint clean; prettier clean. + +## Risks / notes + +- Pre-existing local-only failure `uninstall.spec.ts > removeFramework > handles missing mosaicHome +gracefully` (EACCES on `/nonexistent` as non-root) — unrelated to #633, passes in CI as root. +- Did NOT run `mosaic update` / anything auto-reseed: installed CLI still 0.0.40 (roster-wipe live + until mos-claude-0 ships 0.0.41). All work is in-repo + vitest, never touches the live mosaic home. diff --git a/packages/mosaic/src/commands/fleet.spec.ts b/packages/mosaic/src/commands/fleet.spec.ts index ac57919..a5be83b 100644 --- a/packages/mosaic/src/commands/fleet.spec.ts +++ b/packages/mosaic/src/commands/fleet.spec.ts @@ -95,6 +95,7 @@ describe('registerFleetCommand', () => { expect(agent).toBeDefined(); expect(agent!.options.map((option) => option.long)).toContain('--list'); expect(agent!.commands.map((command) => command.name()).sort()).toEqual([ + 'comms-block', 'reset', 'roster', 'send', diff --git a/packages/mosaic/src/commands/fleet.ts b/packages/mosaic/src/commands/fleet.ts index 96e5621..a37d680 100644 --- a/packages/mosaic/src/commands/fleet.ts +++ b/packages/mosaic/src/commands/fleet.ts @@ -7,6 +7,7 @@ import { spawn } from 'node:child_process'; import * as readline from 'node:readline'; import type { Command } from 'commander'; import YAML from 'yaml'; +import { resolveCommsBlock } from '../fleet/comms-onboarding.js'; /** * A function that spawns a command with inherited stdio (TTY passthrough). @@ -1359,6 +1360,23 @@ export function registerFleetAgentCommands( } }); + agentCommand + .command('comms-block ') + .description( + "Print the Fleet Comms cheat-sheet for a roster role (preview a peer's peer-reach view)", + ) + .option('--host ', 'Override the fleet host (preview a cross-host peer view)') + .action((role: string, opts: { host?: string }) => { + const mosaicHome = resolveMosaicHomeFromCommand(agentCommand, deps.mosaicHome); + const res = resolveCommsBlock(mosaicHome, role, opts.host); + if (!res.ok) { + console.error(`[mosaic] comms-block: ${res.error}`); + process.exitCode = 1; + return; + } + console.log(res.output); + }); + agentCommand .command('status [agent]') .description('Show tmux status for the local fleet or one agent') diff --git a/packages/mosaic/src/fleet/comms-onboarding.spec.ts b/packages/mosaic/src/fleet/comms-onboarding.spec.ts index 3ea6ba5..85afd8d 100644 --- a/packages/mosaic/src/fleet/comms-onboarding.spec.ts +++ b/packages/mosaic/src/fleet/comms-onboarding.spec.ts @@ -7,6 +7,7 @@ import { buildFleetCommsBlock, renderPeerReach, readFleetCommsBlock, + resolveCommsBlock, type CommsPeer, } from './comms-onboarding.js'; @@ -185,3 +186,53 @@ describe('readFleetCommsBlock — situational (the context a spawned agent gets) expect(readFleetCommsBlock(mkdtempSync(join(tmpdir(), 'noroster-')), 'orchestrator')).toBe(''); }); }); + +describe('resolveCommsBlock — `mosaic fleet comms-block ` emitter semantics', () => { + // The emitter wraps readFleetCommsBlock but must NEVER print an empty string silently: + // an unknown role / missing roster has to fail loud (caller maps !ok → stderr + exit 1) + // so `mosaic fleet comms-block bogus` is a visible error, not a confusing no-op. The + // success path returns the block verbatim for `mosaic fleet comms-block ` previews. + let home: string; + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'mosaic-commsblk-')); + mkdirSync(join(home, 'fleet'), { recursive: true }); + writeFileSync(join(home, 'fleet', 'roster.yaml'), ROSTER); + }); + afterEach(() => rmSync(home, { recursive: true, force: true })); + + it('returns ok + the cheat-sheet for a roster member', () => { + const res = resolveCommsBlock(home, 'orchestrator', 'w-jarvis'); + expect(res.ok).toBe(true); + expect(res.output).toContain('# Fleet Comms'); + expect(res.output).toContain('| enhancer |'); + expect(res.error).toBeUndefined(); + }); + + it('fails loud (not ok + error naming the role) for a non-member — never silently empty', () => { + const res = resolveCommsBlock(home, 'stranger', 'w-jarvis'); + expect(res.ok).toBe(false); + expect(res.output).toBe(''); + expect(res.error).toContain('stranger'); + }); + + it('fails loud when no roster exists at the mosaic home', () => { + const noRoster = mkdtempSync(join(tmpdir(), 'mosaic-noroster-')); + const res = resolveCommsBlock(noRoster, 'orchestrator', 'w-jarvis'); + expect(res.ok).toBe(false); + expect(res.error).toBeTruthy(); + rmSync(noRoster, { recursive: true, force: true }); + }); + + it('fails loud for a missing role argument', () => { + const res = resolveCommsBlock(home, undefined, 'w-jarvis'); + expect(res.ok).toBe(false); + expect(res.error).toBeTruthy(); + }); + + it('honors a host override so a peer can preview its own cross-host view', () => { + // coder0-0 viewing with its own host → its self-identity line uses that host. + const res = resolveCommsBlock(home, 'coder0-0', '10.1.10.37'); + expect(res.ok).toBe(true); + expect(res.output).toContain('`[10.1.10.37:coder0-0]`'); + }); +}); diff --git a/packages/mosaic/src/fleet/comms-onboarding.ts b/packages/mosaic/src/fleet/comms-onboarding.ts index 65fc825..6a93992 100644 --- a/packages/mosaic/src/fleet/comms-onboarding.ts +++ b/packages/mosaic/src/fleet/comms-onboarding.ts @@ -179,5 +179,48 @@ export function readFleetCommsBlock( }); } +/** Result of resolving a comms-block emit request — see `mosaic fleet comms-block`. */ +export interface CommsBlockResult { + /** True when a cheat-sheet was produced; false maps to stderr + non-zero exit. */ + ok: boolean; + /** The Fleet-Comms cheat-sheet (empty unless ok). */ + output: string; + /** Operator-facing reason when !ok. */ + error?: string; +} + +/** + * Resolve the Fleet-Comms cheat-sheet for an explicit , backing the + * `mosaic fleet comms-block ` command. Unlike readFleetCommsBlock — which + * returns '' on any miss so composeContract can no-op silently during a launch — + * this NEVER silently emits empty: an unknown role or missing roster yields + * ok:false + an operator-facing reason, so the CLI surfaces it (stderr + exit 1) + * rather than printing nothing. That makes it safe to preview any peer's view, + * e.g. `mosaic fleet comms-block coder0-0`. + */ +export function resolveCommsBlock( + mosaicHome: string, + role: string | undefined, + fleetHost?: string, +): CommsBlockResult { + if (!role) { + return { ok: false, output: '', error: 'comms-block requires a argument' }; + } + const block = fleetHost + ? readFleetCommsBlock(mosaicHome, role, fleetHost) + : readFleetCommsBlock(mosaicHome, role); + if (!block) { + const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml'); + return { + ok: false, + output: '', + error: existsSync(rosterPath) + ? `role "${role}" is not a member of the fleet roster at ${rosterPath}` + : `no fleet roster at ${rosterPath}`, + }; + } + return { ok: true, output: block }; +} + /** Default mosaic home (mirrors launch.ts), for callers that don't pass one. */ export const DEFAULT_MOSAIC_HOME_FOR_COMMS = join(homedir(), '.config', 'mosaic');