From 65ab83515007394677000a5732a0063c0cb006fa Mon Sep 17 00:00:00 2001 From: Jarvis Date: Wed, 24 Jun 2026 11:46:27 -0500 Subject: [PATCH] feat(fleet): export MOSAIC_AGENT_CLASS into agent pane env (A3a) generateAgentEnv now emits MOSAIC_AGENT_CLASS (shell-escaped, right after MOSAIC_AGENT_NAME) so a launching agent knows its class and the companion A3b goal can inject the matching persona contract. Defaults to `worker`. Co-Authored-By: Claude Opus 4.8 --- packages/mosaic/src/commands/fleet.spec.ts | 58 ++++++++++++++++++++++ packages/mosaic/src/commands/fleet.ts | 3 ++ 2 files changed, 61 insertions(+) diff --git a/packages/mosaic/src/commands/fleet.spec.ts b/packages/mosaic/src/commands/fleet.spec.ts index 731a729..e6d6fdf 100644 --- a/packages/mosaic/src/commands/fleet.spec.ts +++ b/packages/mosaic/src/commands/fleet.spec.ts @@ -246,6 +246,8 @@ describe('fleet roster parsing', () => { expect(generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toBe( [ 'MOSAIC_AGENT_NAME=coder0', + // Reflects the roster's non-default `class: implementer` (A3a). + 'MOSAIC_AGENT_CLASS=implementer', 'MOSAIC_AGENT_RUNTIME=codex', 'MOSAIC_AGENT_MODEL=', 'MOSAIC_AGENT_WORKDIR=/srv/mosaic', @@ -255,6 +257,40 @@ describe('fleet roster parsing', () => { ); }); + it('emits MOSAIC_AGENT_CLASS=worker for an agent that declares no class', async () => { + cleanup = await tempDir(); + const rosterPath = join(cleanup, 'roster.json'); + await writeFile( + rosterPath, + JSON.stringify({ + version: 1, + transport: 'tmux', + agents: [{ name: 'coder0', runtime: 'codex' }], + }), + ); + const roster = await loadFleetRoster(rosterPath); + expect(generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toContain( + 'MOSAIC_AGENT_CLASS=worker\n', + ); + }); + + it('shell-escapes MOSAIC_AGENT_CLASS so a launcher reads it verbatim', async () => { + cleanup = await tempDir(); + const rosterPath = join(cleanup, 'roster.json'); + await writeFile( + rosterPath, + JSON.stringify({ + version: 1, + transport: 'tmux', + agents: [{ name: 'coder0', runtime: 'codex', class: 'orchestrator' }], + }), + ); + const roster = await loadFleetRoster(rosterPath); + expect(generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toContain( + 'MOSAIC_AGENT_CLASS=orchestrator\n', + ); + }); + it('preserves site-owned agent EnvironmentFile overrides while refreshing roster keys', () => { const generated = [ 'MOSAIC_AGENT_NAME=coder0', @@ -286,6 +322,28 @@ describe('fleet roster parsing', () => { ); }); + it('updates (does not duplicate) MOSAIC_AGENT_CLASS on re-launch', () => { + const generated = [ + 'MOSAIC_AGENT_NAME=coder0', + 'MOSAIC_AGENT_CLASS=orchestrator', + 'MOSAIC_AGENT_RUNTIME=codex', + '', + ].join('\n'); + const existing = [ + 'MOSAIC_AGENT_NAME=coder0', + 'MOSAIC_AGENT_CLASS=worker', + 'MOSAIC_AGENT_RUNTIME=codex', + '', + ].join('\n'); + + const merged = mergeAgentEnv(generated, existing); + // mergeAgentEnv keys by VAR name, so the regenerated CLASS wins and there is + // exactly one MOSAIC_AGENT_CLASS line (no stale worker value left behind). + expect(merged).toContain('MOSAIC_AGENT_CLASS=orchestrator'); + expect(merged).not.toContain('MOSAIC_AGENT_CLASS=worker'); + expect(merged.match(/^MOSAIC_AGENT_CLASS=/gm)).toHaveLength(1); + }); + it('rejects unknown roster fields instead of silently defaulting', async () => { cleanup = await tempDir(); const rosterPath = join(cleanup, 'roster.yaml'); diff --git a/packages/mosaic/src/commands/fleet.ts b/packages/mosaic/src/commands/fleet.ts index b6c8763..8aeb492 100644 --- a/packages/mosaic/src/commands/fleet.ts +++ b/packages/mosaic/src/commands/fleet.ts @@ -490,6 +490,9 @@ export function generateAgentEnv(roster: FleetRoster, agent: FleetAgent): string const workingDirectory = agent.workingDirectory ?? roster.defaults.workingDirectory; return [ `MOSAIC_AGENT_NAME=${shellEnvValue(agent.name)}`, + // Per-agent class → start-agent-session.sh / launcher reads this to inject the + // matching persona contract for the agent's class (default `worker`). + `MOSAIC_AGENT_CLASS=${shellEnvValue(agent.className)}`, `MOSAIC_AGENT_RUNTIME=${shellEnvValue(agent.runtime)}`, // Per-agent model hint → start-agent-session.sh appends `--model ` to // the `mosaic yolo` launch so workers run on the roster's model (e.g. pi on