fix(fleet): consume model_hint + fix socket-default trap (stand-up fixes) (#627)
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #627.
This commit is contained in:
@@ -74,3 +74,7 @@ Active workstream is **W1 — Federation v1**. Workers should:
|
|||||||
## Fleet onboarding-injection — comms cheat-sheet + peer roster (#620) — feat/fleet-comms-onboarding
|
## 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.
|
- 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.
|
||||||
|
|
||||||
|
## Fleet stand-up fixes — model_hint→--model + socket-default trap (#626) — feat/fleet-standup-fixes
|
||||||
|
|
||||||
|
- Status: implemented + tested. FIX1 model_hint→MOSAIC_AGENT_MODEL→--model. FIX2 absent socket = default tmux socket (no -L) across parse/spawn/systemd-unit/observe (socketArgs helper, bare-empty shellEnvValue, conditional -L). 158 fleet tests green; shipped presets unaffected (explicit socket_name). Detail: scratchpads/fleet-standup-fixes.md.
|
||||||
|
|||||||
28
docs/scratchpads/fleet-standup-fixes.md
Normal file
28
docs/scratchpads/fleet-standup-fixes.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Fleet stand-up fixes — model_hint→--model + socket-default trap (#626)
|
||||||
|
|
||||||
|
- **Issue:** #626 · **Branch:** `feat/fleet-standup-fixes` (off main). PoC-blocking, before doctrine doc.
|
||||||
|
|
||||||
|
## FIX 1 — model_hint consumed
|
||||||
|
|
||||||
|
- generateAgentEnv emits `MOSAIC_AGENT_MODEL=<modelHint>` (bare empty when unset).
|
||||||
|
- start-agent-session.sh default command → `mosaic yolo $RUNTIME ${MOSAIC_AGENT_MODEL:+--model $MOSAIC_AGENT_MODEL}`.
|
||||||
|
→ pi workers launch with `--model openai-codex/gpt-5.5:high`.
|
||||||
|
|
||||||
|
## FIX 2 — socket default trap (absent ⇒ literal default socket, no -L everywhere)
|
||||||
|
|
||||||
|
- THE TRAP (3 sites): parseRosterText fallback was DEFAULT_SOCKET_NAME; systemd unit had
|
||||||
|
`Environment=MOSAIC_TMUX_SOCKET=mosaic-factory` + `ExecStop ${…:-mosaic-factory}`; start-agent-session
|
||||||
|
defaulted `:-mosaic-factory`. All fixed → absent socket = '' = default tmux socket (no -L).
|
||||||
|
- `socketArgs(name)` helper → `name ? ['-L', name] : []`; replaced all ~15 -L render sites in fleet.ts.
|
||||||
|
- shellEnvValue('') now emits a **bare** `VAR=` (not `''`) — unambiguous empty in systemd EnvironmentFile
|
||||||
|
(a quoted '' could become a literal socket named "''").
|
||||||
|
- start-agent-session.sh: `_tmux` wrapper passes -L only when socket set; mosaic-agent@.service: dropped the
|
||||||
|
socket default + conditional ExecStop. So spawn == observe == onboarding cheat-sheet.
|
||||||
|
- CONTAINMENT: all 6 shipped presets set socket_name: mosaic-factory explicitly → unaffected; only
|
||||||
|
socket-less rosters (the PoC) get default-socket behavior. DEFAULT_SOCKET_NAME exported for explicit use.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- 158 fleet + 201 fleet-adjacent tests green; new: socketArgs none/named, model_hint→env, explicit-socket
|
||||||
|
renders -L, socket-less env bare. tsc/eslint/prettier/sanitize clean. Shell bash -n + end-to-end sim
|
||||||
|
(socket-less→no -L, model→--model).
|
||||||
@@ -8,13 +8,15 @@ PartOf=mosaic-tmux-holder.service
|
|||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
RemainAfterExit=yes
|
RemainAfterExit=yes
|
||||||
Environment=MOSAIC_TMUX_SOCKET=mosaic-factory
|
# No default MOSAIC_TMUX_SOCKET: an absent roster socket means the literal
|
||||||
|
# default tmux socket (no -L). The per-agent .env sets it when the roster names
|
||||||
|
# one; otherwise it stays unset and start-agent-session.sh uses the default socket.
|
||||||
Environment=MOSAIC_AGENT_NAME=%i
|
Environment=MOSAIC_AGENT_NAME=%i
|
||||||
Environment=MOSAIC_AGENT_RUNTIME=pi
|
Environment=MOSAIC_AGENT_RUNTIME=pi
|
||||||
Environment=MOSAIC_AGENT_WORKDIR=%h
|
Environment=MOSAIC_AGENT_WORKDIR=%h
|
||||||
EnvironmentFile=-%h/.config/mosaic/fleet/agents/%i.env
|
EnvironmentFile=-%h/.config/mosaic/fleet/agents/%i.env
|
||||||
ExecStart=/bin/bash %h/.config/mosaic/tools/fleet/start-agent-session.sh %i
|
ExecStart=/bin/bash %h/.config/mosaic/tools/fleet/start-agent-session.sh %i
|
||||||
ExecStop=-/bin/bash -lc 'tmux -L "${MOSAIC_TMUX_SOCKET:-mosaic-factory}" kill-session -t "=%i"'
|
ExecStop=-/bin/bash -lc 'if [ -n "${MOSAIC_TMUX_SOCKET:-}" ]; then tmux -L "$MOSAIC_TMUX_SOCKET" kill-session -t "=%i"; else tmux kill-session -t "=%i"; fi'
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=default.target
|
WantedBy=default.target
|
||||||
|
|||||||
@@ -2,8 +2,12 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
AGENT_NAME=${1:-${MOSAIC_AGENT_NAME:-}}
|
AGENT_NAME=${1:-${MOSAIC_AGENT_NAME:-}}
|
||||||
MOSAIC_TMUX_SOCKET=${MOSAIC_TMUX_SOCKET:-mosaic-factory}
|
# Absent socket ⇒ the LITERAL default tmux socket (no -L). The roster's
|
||||||
|
# socket_name is honored when set; absent never silently becomes mosaic-factory
|
||||||
|
# (spawn stays consistent with the onboarding cheat-sheet + fleet ps observe).
|
||||||
|
MOSAIC_TMUX_SOCKET=${MOSAIC_TMUX_SOCKET:-}
|
||||||
MOSAIC_AGENT_RUNTIME=${MOSAIC_AGENT_RUNTIME:-pi}
|
MOSAIC_AGENT_RUNTIME=${MOSAIC_AGENT_RUNTIME:-pi}
|
||||||
|
MOSAIC_AGENT_MODEL=${MOSAIC_AGENT_MODEL:-}
|
||||||
MOSAIC_AGENT_WORKDIR=${MOSAIC_AGENT_WORKDIR:-$HOME}
|
MOSAIC_AGENT_WORKDIR=${MOSAIC_AGENT_WORKDIR:-$HOME}
|
||||||
MOSAIC_AGENT_COMMAND=${MOSAIC_AGENT_COMMAND:-}
|
MOSAIC_AGENT_COMMAND=${MOSAIC_AGENT_COMMAND:-}
|
||||||
MOSAIC_HEARTBEAT_RUN_DIR=${MOSAIC_HEARTBEAT_RUN_DIR:-${MOSAIC_HOME:-$HOME/.config/mosaic}/fleet/run}
|
MOSAIC_HEARTBEAT_RUN_DIR=${MOSAIC_HEARTBEAT_RUN_DIR:-${MOSAIC_HOME:-$HOME/.config/mosaic}/fleet/run}
|
||||||
@@ -19,13 +23,25 @@ if ! command -v tmux >/dev/null 2>&1; then
|
|||||||
exit 69
|
exit 69
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if tmux -L "$MOSAIC_TMUX_SOCKET" has-session -t "=${AGENT_NAME}:0.0" 2>/dev/null; then
|
# tmux wrapper: pass -L only when a socket is configured. An absent/empty socket
|
||||||
echo "Mosaic agent session already running: $AGENT_NAME on socket $MOSAIC_TMUX_SOCKET"
|
# means the default tmux socket (no -L), keeping spawn == observe == cheat-sheet.
|
||||||
|
_tmux() {
|
||||||
|
if [ -n "$MOSAIC_TMUX_SOCKET" ]; then
|
||||||
|
tmux -L "$MOSAIC_TMUX_SOCKET" "$@"
|
||||||
|
else
|
||||||
|
tmux "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if _tmux has-session -t "=${AGENT_NAME}:0.0" 2>/dev/null; then
|
||||||
|
echo "Mosaic agent session already running: $AGENT_NAME on socket ${MOSAIC_TMUX_SOCKET:-(default)}"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -z "$MOSAIC_AGENT_COMMAND" ]; then
|
if [ -z "$MOSAIC_AGENT_COMMAND" ]; then
|
||||||
MOSAIC_AGENT_COMMAND="mosaic yolo $MOSAIC_AGENT_RUNTIME"
|
# Map the roster's per-agent model_hint to `--model` so workers launch on the
|
||||||
|
# configured model (e.g. pi on openai-codex/gpt-5.5:high). Omitted when unset.
|
||||||
|
MOSAIC_AGENT_COMMAND="mosaic yolo $MOSAIC_AGENT_RUNTIME${MOSAIC_AGENT_MODEL:+ --model $MOSAIC_AGENT_MODEL}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Derive a runtime-bin PATH prefix ─────────────────────────────────────────
|
# ── Derive a runtime-bin PATH prefix ─────────────────────────────────────────
|
||||||
@@ -107,13 +123,13 @@ fi
|
|||||||
mkdir -p "$MOSAIC_AGENT_WORKDIR"
|
mkdir -p "$MOSAIC_AGENT_WORKDIR"
|
||||||
|
|
||||||
# ── Launch the tmux session (no exec — we continue to wire the heartbeat) ────
|
# ── Launch the tmux session (no exec — we continue to wire the heartbeat) ────
|
||||||
tmux -L "$MOSAIC_TMUX_SOCKET" new-session -d -s "$AGENT_NAME" -c "$MOSAIC_AGENT_WORKDIR" \
|
_tmux new-session -d -s "$AGENT_NAME" -c "$MOSAIC_AGENT_WORKDIR" \
|
||||||
bash -c "$PANE_SHELL_SNIPPET"
|
bash -c "$PANE_SHELL_SNIPPET"
|
||||||
|
|
||||||
# ── Resolve the pane PID (retry briefly to let the session initialise) ────────
|
# ── Resolve the pane PID (retry briefly to let the session initialise) ────────
|
||||||
PANE_PID=""
|
PANE_PID=""
|
||||||
for _retry in 1 2 3 4 5; do
|
for _retry in 1 2 3 4 5; do
|
||||||
PANE_PID=$(tmux -L "$MOSAIC_TMUX_SOCKET" list-panes \
|
PANE_PID=$(_tmux list-panes \
|
||||||
-t "=${AGENT_NAME}:0.0" -F '#{pane_pid}' 2>/dev/null || true)
|
-t "=${AGENT_NAME}:0.0" -F '#{pane_pid}' 2>/dev/null || true)
|
||||||
[ -n "$PANE_PID" ] && break
|
[ -n "$PANE_PID" ] && break
|
||||||
sleep 0.2
|
sleep 0.2
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
buildFleetServiceCommand,
|
buildFleetServiceCommand,
|
||||||
buildSystemdEnableCommand,
|
buildSystemdEnableCommand,
|
||||||
buildSystemdDisableCommand,
|
buildSystemdDisableCommand,
|
||||||
|
socketArgs,
|
||||||
buildSystemdShowCommand,
|
buildSystemdShowCommand,
|
||||||
buildTmuxListPanesCommand,
|
buildTmuxListPanesCommand,
|
||||||
buildTmuxListSessionsCommand,
|
buildTmuxListSessionsCommand,
|
||||||
@@ -114,7 +115,7 @@ describe('fleet roster parsing', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('defaults local canary rosters to the isolated mosaic-factory socket', async () => {
|
it('defaults a socket-less roster to the literal default tmux socket (empty, no -L)', async () => {
|
||||||
cleanup = await tempDir();
|
cleanup = await tempDir();
|
||||||
const rosterPath = join(cleanup, 'roster.yaml');
|
const rosterPath = join(cleanup, 'roster.yaml');
|
||||||
await writeFile(
|
await writeFile(
|
||||||
@@ -131,12 +132,55 @@ describe('fleet roster parsing', () => {
|
|||||||
|
|
||||||
const roster = await loadFleetRoster(rosterPath);
|
const roster = await loadFleetRoster(rosterPath);
|
||||||
|
|
||||||
expect(roster.tmux.socketName).toBe('mosaic-factory');
|
expect(roster.tmux.socketName).toBe(''); // absent ⇒ default socket (no -L), not mosaic-factory
|
||||||
expect(roster.tmux.holderSession).toBe('_holder');
|
expect(roster.tmux.holderSession).toBe('_holder');
|
||||||
expect(roster.agents).toHaveLength(1);
|
expect(roster.agents).toHaveLength(1);
|
||||||
expect(getRosterAgent(roster, 'canary-pi').runtime).toBe('pi');
|
expect(getRosterAgent(roster, 'canary-pi').runtime).toBe('pi');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('socketArgs: named socket → -L <name>; empty → no -L (default socket)', () => {
|
||||||
|
expect(socketArgs('mosaic-factory')).toEqual(['-L', 'mosaic-factory']);
|
||||||
|
expect(socketArgs('')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors an explicit socket_name (renders -L) — containment for shipped presets', async () => {
|
||||||
|
cleanup = await tempDir();
|
||||||
|
const rosterPath = join(cleanup, 'roster.yaml');
|
||||||
|
await writeFile(
|
||||||
|
rosterPath,
|
||||||
|
[
|
||||||
|
'version: 1',
|
||||||
|
'transport: tmux',
|
||||||
|
'tmux:',
|
||||||
|
' socket_name: mosaic-factory',
|
||||||
|
'agents:',
|
||||||
|
' - name: canary-pi',
|
||||||
|
' runtime: pi',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
const roster = await loadFleetRoster(rosterPath);
|
||||||
|
expect(roster.tmux.socketName).toBe('mosaic-factory');
|
||||||
|
expect(buildTmuxListSessionsCommand(roster.tmux.socketName)).toContain('-L');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps a per-agent model_hint into MOSAIC_AGENT_MODEL', async () => {
|
||||||
|
cleanup = await tempDir();
|
||||||
|
const rosterPath = join(cleanup, 'roster.json');
|
||||||
|
await writeFile(
|
||||||
|
rosterPath,
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
transport: 'tmux',
|
||||||
|
agents: [{ name: 'coder0', runtime: 'pi', model_hint: 'openai-codex/gpt-5.5:high' }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const roster = await loadFleetRoster(rosterPath);
|
||||||
|
const env = generateAgentEnv(roster, getRosterAgent(roster, 'coder0'));
|
||||||
|
expect(env).toContain('MOSAIC_AGENT_MODEL=openai-codex/gpt-5.5:high');
|
||||||
|
// socket-less roster ⇒ a bare empty socket (no quotes), so spawn uses no -L
|
||||||
|
expect(env).toContain('MOSAIC_TMUX_SOCKET=\n');
|
||||||
|
});
|
||||||
|
|
||||||
it('generates deterministic per-agent EnvironmentFile content', async () => {
|
it('generates deterministic per-agent EnvironmentFile content', async () => {
|
||||||
cleanup = await tempDir();
|
cleanup = await tempDir();
|
||||||
const rosterPath = join(cleanup, 'roster.json');
|
const rosterPath = join(cleanup, 'roster.json');
|
||||||
@@ -156,6 +200,7 @@ describe('fleet roster parsing', () => {
|
|||||||
[
|
[
|
||||||
'MOSAIC_AGENT_NAME=coder0',
|
'MOSAIC_AGENT_NAME=coder0',
|
||||||
'MOSAIC_AGENT_RUNTIME=codex',
|
'MOSAIC_AGENT_RUNTIME=codex',
|
||||||
|
'MOSAIC_AGENT_MODEL=',
|
||||||
'MOSAIC_AGENT_WORKDIR=/srv/mosaic',
|
'MOSAIC_AGENT_WORKDIR=/srv/mosaic',
|
||||||
'MOSAIC_TMUX_SOCKET=mosaic-factory',
|
'MOSAIC_TMUX_SOCKET=mosaic-factory',
|
||||||
'',
|
'',
|
||||||
@@ -355,8 +400,9 @@ describe('fleet command construction', () => {
|
|||||||
try {
|
try {
|
||||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'verify']);
|
await program.parseAsync(['node', 'mosaic', 'fleet', 'verify']);
|
||||||
expect(calls).toEqual([
|
expect(calls).toEqual([
|
||||||
['tmux', '-L', 'mosaic-factory', 'has-session', '-t', '=_holder:0.0'],
|
// socket-less roster ⇒ default tmux socket (no -L)
|
||||||
['tmux', '-L', 'mosaic-factory', 'has-session', '-t', '=coder0:0.0'],
|
['tmux', 'has-session', '-t', '=_holder:0.0'],
|
||||||
|
['tmux', 'has-session', '-t', '=coder0:0.0'],
|
||||||
]);
|
]);
|
||||||
} finally {
|
} finally {
|
||||||
await rm(home, { recursive: true, force: true });
|
await rm(home, { recursive: true, force: true });
|
||||||
@@ -637,7 +683,7 @@ describe('fleet command construction', () => {
|
|||||||
try {
|
try {
|
||||||
await program.parseAsync(['node', 'mosaic', 'agent', 'status', 'json-agent']);
|
await program.parseAsync(['node', 'mosaic', 'agent', 'status', 'json-agent']);
|
||||||
expect(calls).toEqual([
|
expect(calls).toEqual([
|
||||||
['tmux', '-L', 'mosaic-factory', 'has-session', '-t', '=json-agent:0.0'],
|
['tmux', 'has-session', '-t', '=json-agent:0.0'], // socket-less ⇒ no -L
|
||||||
]);
|
]);
|
||||||
} finally {
|
} finally {
|
||||||
await rm(home, { recursive: true, force: true });
|
await rm(home, { recursive: true, force: true });
|
||||||
@@ -677,8 +723,6 @@ describe('fleet command construction', () => {
|
|||||||
expect(calls).toEqual([
|
expect(calls).toEqual([
|
||||||
[
|
[
|
||||||
join(home, 'tools', 'tmux', 'agent-send.sh'),
|
join(home, 'tools', 'tmux', 'agent-send.sh'),
|
||||||
'-L',
|
|
||||||
'mosaic-factory',
|
|
||||||
'-S',
|
'-S',
|
||||||
getDefaultOperatorSourceLabel(),
|
getDefaultOperatorSourceLabel(),
|
||||||
'-s',
|
'-s',
|
||||||
@@ -727,8 +771,6 @@ describe('fleet command construction', () => {
|
|||||||
expect(calls).toEqual([
|
expect(calls).toEqual([
|
||||||
[
|
[
|
||||||
join(home, 'tools', 'tmux', 'agent-send.sh'),
|
join(home, 'tools', 'tmux', 'agent-send.sh'),
|
||||||
'-L',
|
|
||||||
'mosaic-factory',
|
|
||||||
'-S',
|
'-S',
|
||||||
'lead:manual',
|
'lead:manual',
|
||||||
'-s',
|
'-s',
|
||||||
@@ -811,9 +853,11 @@ describe('fleet ps — command construction', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses DEFAULT_SOCKET_NAME when socket is omitted from list-panes', () => {
|
it('uses the default tmux socket (no -L) when socket is omitted from list-panes', () => {
|
||||||
const cmd = buildTmuxListPanesCommand('canary-pi');
|
const cmd = buildTmuxListPanesCommand('canary-pi');
|
||||||
expect(cmd[2]).toBe('mosaic-factory');
|
expect(cmd).not.toContain('-L'); // omitted socket ⇒ default socket
|
||||||
|
expect(cmd[0]).toBe('tmux');
|
||||||
|
expect(cmd[1]).toBe('list-panes');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('derives heartbeat path under ~/.config/mosaic/fleet/run/', () => {
|
it('derives heartbeat path under ~/.config/mosaic/fleet/run/', () => {
|
||||||
@@ -1331,8 +1375,9 @@ describe('fleet ps — command sequences issued', () => {
|
|||||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'ps']);
|
await program.parseAsync(['node', 'mosaic', 'fleet', 'ps']);
|
||||||
expect(calls).toEqual([
|
expect(calls).toEqual([
|
||||||
buildSystemdShowCommand('coder0'),
|
buildSystemdShowCommand('coder0'),
|
||||||
buildTmuxListPanesCommand('coder0', 'mosaic-factory'),
|
// socket-less roster ⇒ default socket (no -L)
|
||||||
buildTmuxListSessionsCommand('mosaic-factory'),
|
buildTmuxListPanesCommand('coder0'),
|
||||||
|
buildTmuxListSessionsCommand(),
|
||||||
]);
|
]);
|
||||||
} finally {
|
} finally {
|
||||||
console.log = origLog;
|
console.log = origLog;
|
||||||
@@ -1353,9 +1398,10 @@ describe('buildTmuxListSessionsCommand', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses DEFAULT_SOCKET_NAME when socket is omitted', () => {
|
it('uses the default tmux socket (no -L) when socket is omitted', () => {
|
||||||
const cmd = buildTmuxListSessionsCommand();
|
const cmd = buildTmuxListSessionsCommand();
|
||||||
expect(cmd[2]).toBe('mosaic-factory');
|
expect(cmd).not.toContain('-L');
|
||||||
|
expect(cmd).toEqual(['tmux', 'list-sessions', '-F', '#{session_name}']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1633,9 +1679,10 @@ describe('agent watch', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('buildAgentWatchCommand (deprecated) still uses DEFAULT_SOCKET_NAME when socket is omitted', () => {
|
it('buildAgentWatchCommand (deprecated) uses the default tmux socket (no -L) when socket is omitted', () => {
|
||||||
const cmd = buildAgentWatchCommand('canary-pi');
|
const cmd = buildAgentWatchCommand('canary-pi');
|
||||||
expect(cmd[2]).toBe('mosaic-factory');
|
expect(cmd).not.toContain('-L'); // omitted socket ⇒ default socket
|
||||||
|
expect(cmd[0]).toBe('tmux');
|
||||||
expect(cmd).toContain('-r');
|
expect(cmd).toContain('-r');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1829,9 +1876,10 @@ describe('agent send --verify', () => {
|
|||||||
|
|
||||||
// 3 calls: BEFORE-capture, send, AFTER-capture (pane changed on first poll → accepted immediately)
|
// 3 calls: BEFORE-capture, send, AFTER-capture (pane changed on first poll → accepted immediately)
|
||||||
expect(calls).toHaveLength(3);
|
expect(calls).toHaveLength(3);
|
||||||
expect(calls[0]).toEqual(buildAgentVerifyAcceptedCommand('coder0', 'mosaic-factory', 5));
|
// socket-less roster ⇒ default socket (no -L)
|
||||||
|
expect(calls[0]).toEqual(buildAgentVerifyAcceptedCommand('coder0', '', 5));
|
||||||
expect(calls[1]![0]).toContain('agent-send.sh');
|
expect(calls[1]![0]).toContain('agent-send.sh');
|
||||||
expect(calls[2]).toEqual(buildAgentVerifyAcceptedCommand('coder0', 'mosaic-factory', 5));
|
expect(calls[2]).toEqual(buildAgentVerifyAcceptedCommand('coder0', '', 5));
|
||||||
} finally {
|
} finally {
|
||||||
await rm(home, { recursive: true, force: true });
|
await rm(home, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,10 +117,26 @@ export interface FleetPaths {
|
|||||||
|
|
||||||
type FleetServiceAction = 'start' | 'stop' | 'restart' | 'status';
|
type FleetServiceAction = 'start' | 'stop' | 'restart' | 'status';
|
||||||
|
|
||||||
const DEFAULT_SOCKET_NAME = 'mosaic-factory';
|
/**
|
||||||
|
* The named tmux socket the canonical fleet uses. Kept as a public constant for
|
||||||
|
* rosters/callers that explicitly want isolation; it is NO LONGER the silent
|
||||||
|
* fallback for a socket-less roster (that now resolves to the default socket).
|
||||||
|
*/
|
||||||
|
export const DEFAULT_SOCKET_NAME = 'mosaic-factory';
|
||||||
const DEFAULT_HOLDER_SESSION = '_holder';
|
const DEFAULT_HOLDER_SESSION = '_holder';
|
||||||
const DEFAULT_WORKING_DIRECTORY = '~/src';
|
const DEFAULT_WORKING_DIRECTORY = '~/src';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* tmux `-L` args for a socket name. An empty/absent socket ⇒ the LITERAL default
|
||||||
|
* tmux socket (no `-L`), so spawn, observe (`fleet ps`/watch), and the onboarding
|
||||||
|
* cheat-sheet all agree. A named socket ⇒ `-L <name>`. `DEFAULT_SOCKET_NAME`
|
||||||
|
* remains a constant for callers that explicitly want mosaic-factory; it is no
|
||||||
|
* longer the silent fallback for a socket-less roster.
|
||||||
|
*/
|
||||||
|
export function socketArgs(socketName: string): string[] {
|
||||||
|
return socketName ? ['-L', socketName] : [];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default poll interval (ms) between capture-pane checks in `send --verify`.
|
* Default poll interval (ms) between capture-pane checks in `send --verify`.
|
||||||
* Kept short enough to react quickly while not hammering tmux on busy hosts.
|
* Kept short enough to react quickly while not hammering tmux on busy hosts.
|
||||||
@@ -185,6 +201,10 @@ export function generateAgentEnv(roster: FleetRoster, agent: FleetAgent): string
|
|||||||
return [
|
return [
|
||||||
`MOSAIC_AGENT_NAME=${shellEnvValue(agent.name)}`,
|
`MOSAIC_AGENT_NAME=${shellEnvValue(agent.name)}`,
|
||||||
`MOSAIC_AGENT_RUNTIME=${shellEnvValue(agent.runtime)}`,
|
`MOSAIC_AGENT_RUNTIME=${shellEnvValue(agent.runtime)}`,
|
||||||
|
// Per-agent model hint → start-agent-session.sh appends `--model <hint>` to
|
||||||
|
// the `mosaic yolo` launch so workers run on the roster's model (e.g. pi on
|
||||||
|
// openai-codex/gpt-5.5:high). Empty when the agent declares no model_hint.
|
||||||
|
`MOSAIC_AGENT_MODEL=${shellEnvValue(agent.modelHint ?? '')}`,
|
||||||
`MOSAIC_AGENT_WORKDIR=${shellEnvValue(expandHome(workingDirectory))}`,
|
`MOSAIC_AGENT_WORKDIR=${shellEnvValue(expandHome(workingDirectory))}`,
|
||||||
`MOSAIC_TMUX_SOCKET=${shellEnvValue(roster.tmux.socketName)}`,
|
`MOSAIC_TMUX_SOCKET=${shellEnvValue(roster.tmux.socketName)}`,
|
||||||
'',
|
'',
|
||||||
@@ -319,13 +339,12 @@ export function buildAgentSendCommand(
|
|||||||
paths: FleetPaths,
|
paths: FleetPaths,
|
||||||
agentName: string,
|
agentName: string,
|
||||||
message: string,
|
message: string,
|
||||||
socketName = DEFAULT_SOCKET_NAME,
|
socketName = '',
|
||||||
sourceLabel = getDefaultOperatorSourceLabel(),
|
sourceLabel = getDefaultOperatorSourceLabel(),
|
||||||
): string[] {
|
): string[] {
|
||||||
return [
|
return [
|
||||||
join(paths.tmuxToolsDir, 'agent-send.sh'),
|
join(paths.tmuxToolsDir, 'agent-send.sh'),
|
||||||
'-L',
|
...socketArgs(socketName),
|
||||||
socketName,
|
|
||||||
'-S',
|
'-S',
|
||||||
sourceLabel,
|
sourceLabel,
|
||||||
'-s',
|
'-s',
|
||||||
@@ -344,12 +363,11 @@ export function buildAgentResetCommand(
|
|||||||
paths: FleetPaths,
|
paths: FleetPaths,
|
||||||
agentName: string,
|
agentName: string,
|
||||||
resetCommand: string,
|
resetCommand: string,
|
||||||
socketName = DEFAULT_SOCKET_NAME,
|
socketName = '',
|
||||||
): string[] {
|
): string[] {
|
||||||
return [
|
return [
|
||||||
join(paths.tmuxToolsDir, 'send-message.sh'),
|
join(paths.tmuxToolsDir, 'send-message.sh'),
|
||||||
'-L',
|
...socketArgs(socketName),
|
||||||
socketName,
|
|
||||||
'-t',
|
'-t',
|
||||||
`=${agentName}`,
|
`=${agentName}`,
|
||||||
'-m',
|
'-m',
|
||||||
@@ -357,15 +375,10 @@ export function buildAgentResetCommand(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildAgentTailCommand(
|
export function buildAgentTailCommand(agentName: string, lines: number, socketName = ''): string[] {
|
||||||
agentName: string,
|
|
||||||
lines: number,
|
|
||||||
socketName = DEFAULT_SOCKET_NAME,
|
|
||||||
): string[] {
|
|
||||||
return [
|
return [
|
||||||
'tmux',
|
'tmux',
|
||||||
'-L',
|
...socketArgs(socketName),
|
||||||
socketName,
|
|
||||||
'capture-pane',
|
'capture-pane',
|
||||||
'-t',
|
'-t',
|
||||||
`=${agentName}:0.0`,
|
`=${agentName}:0.0`,
|
||||||
@@ -449,14 +462,10 @@ export function buildSystemdShowCommand(agentName: string): string[] {
|
|||||||
* Returns the tmux list-panes command for an agent pane.
|
* Returns the tmux list-panes command for an agent pane.
|
||||||
* Format: `#{pane_pid} #{pane_current_command} #{pane_dead} #{pane_activity}`
|
* Format: `#{pane_pid} #{pane_current_command} #{pane_dead} #{pane_activity}`
|
||||||
*/
|
*/
|
||||||
export function buildTmuxListPanesCommand(
|
export function buildTmuxListPanesCommand(agentName: string, socketName = ''): string[] {
|
||||||
agentName: string,
|
|
||||||
socketName = DEFAULT_SOCKET_NAME,
|
|
||||||
): string[] {
|
|
||||||
return [
|
return [
|
||||||
'tmux',
|
'tmux',
|
||||||
'-L',
|
...socketArgs(socketName),
|
||||||
socketName,
|
|
||||||
'list-panes',
|
'list-panes',
|
||||||
'-t',
|
'-t',
|
||||||
`=${agentName}:0.0`,
|
`=${agentName}:0.0`,
|
||||||
@@ -470,8 +479,8 @@ export function buildTmuxListPanesCommand(
|
|||||||
* Format: `tmux -L <socket> list-sessions -F '#{session_name}'`
|
* Format: `tmux -L <socket> list-sessions -F '#{session_name}'`
|
||||||
* Used to discover ad-hoc sessions that are not in the roster.
|
* Used to discover ad-hoc sessions that are not in the roster.
|
||||||
*/
|
*/
|
||||||
export function buildTmuxListSessionsCommand(socketName = DEFAULT_SOCKET_NAME): string[] {
|
export function buildTmuxListSessionsCommand(socketName = ''): string[] {
|
||||||
return ['tmux', '-L', socketName, 'list-sessions', '-F', '#{session_name}'];
|
return ['tmux', ...socketArgs(socketName), 'list-sessions', '-F', '#{session_name}'];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -653,12 +662,11 @@ export function getDefaultTenantAndHost(): { tenant_id: string; host: string } {
|
|||||||
export function buildAgentWatchCreateViewerCommand(
|
export function buildAgentWatchCreateViewerCommand(
|
||||||
agentName: string,
|
agentName: string,
|
||||||
viewerSessionName: string,
|
viewerSessionName: string,
|
||||||
socketName = DEFAULT_SOCKET_NAME,
|
socketName = '',
|
||||||
): string[] {
|
): string[] {
|
||||||
return [
|
return [
|
||||||
'tmux',
|
'tmux',
|
||||||
'-L',
|
...socketArgs(socketName),
|
||||||
socketName,
|
|
||||||
'new-session',
|
'new-session',
|
||||||
'-d',
|
'-d',
|
||||||
'-t',
|
'-t',
|
||||||
@@ -672,11 +680,8 @@ export function buildAgentWatchCreateViewerCommand(
|
|||||||
* Builds the interactive attach command for a viewer session (read-only).
|
* Builds the interactive attach command for a viewer session (read-only).
|
||||||
* Must be run via interactiveRunner (stdio: 'inherit').
|
* Must be run via interactiveRunner (stdio: 'inherit').
|
||||||
*/
|
*/
|
||||||
export function buildAgentWatchAttachCommand(
|
export function buildAgentWatchAttachCommand(viewerSessionName: string, socketName = ''): string[] {
|
||||||
viewerSessionName: string,
|
return ['tmux', ...socketArgs(socketName), 'attach', '-r', '-t', viewerSessionName];
|
||||||
socketName = DEFAULT_SOCKET_NAME,
|
|
||||||
): string[] {
|
|
||||||
return ['tmux', '-L', socketName, 'attach', '-r', '-t', viewerSessionName];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -685,9 +690,9 @@ export function buildAgentWatchAttachCommand(
|
|||||||
*/
|
*/
|
||||||
export function buildAgentWatchKillViewerCommand(
|
export function buildAgentWatchKillViewerCommand(
|
||||||
viewerSessionName: string,
|
viewerSessionName: string,
|
||||||
socketName = DEFAULT_SOCKET_NAME,
|
socketName = '',
|
||||||
): string[] {
|
): string[] {
|
||||||
return ['tmux', '-L', socketName, 'kill-session', '-t', viewerSessionName];
|
return ['tmux', ...socketArgs(socketName), 'kill-session', '-t', viewerSessionName];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -705,11 +710,8 @@ export function buildViewerSessionName(agentName: string): string {
|
|||||||
*
|
*
|
||||||
* Kept for backward compatibility only.
|
* Kept for backward compatibility only.
|
||||||
*/
|
*/
|
||||||
export function buildAgentWatchCommand(
|
export function buildAgentWatchCommand(agentName: string, socketName = ''): string[] {
|
||||||
agentName: string,
|
return ['tmux', ...socketArgs(socketName), 'attach', '-r', '-t', `=${agentName}`];
|
||||||
socketName = DEFAULT_SOCKET_NAME,
|
|
||||||
): string[] {
|
|
||||||
return ['tmux', '-L', socketName, 'attach', '-r', '-t', `=${agentName}`];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -719,13 +721,12 @@ export function buildAgentWatchCommand(
|
|||||||
*/
|
*/
|
||||||
export function buildAgentVerifyAcceptedCommand(
|
export function buildAgentVerifyAcceptedCommand(
|
||||||
agentName: string,
|
agentName: string,
|
||||||
socketName = DEFAULT_SOCKET_NAME,
|
socketName = '',
|
||||||
lines = 5,
|
lines = 5,
|
||||||
): string[] {
|
): string[] {
|
||||||
return [
|
return [
|
||||||
'tmux',
|
'tmux',
|
||||||
'-L',
|
...socketArgs(socketName),
|
||||||
socketName,
|
|
||||||
'capture-pane',
|
'capture-pane',
|
||||||
'-t',
|
'-t',
|
||||||
`=${agentName}:0.0`,
|
`=${agentName}:0.0`,
|
||||||
@@ -989,8 +990,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
const socketName = roster.tmux.socketName;
|
const socketName = roster.tmux.socketName;
|
||||||
await runChecked(runner, [
|
await runChecked(runner, [
|
||||||
'tmux',
|
'tmux',
|
||||||
'-L',
|
...socketArgs(socketName),
|
||||||
socketName,
|
|
||||||
'has-session',
|
'has-session',
|
||||||
'-t',
|
'-t',
|
||||||
`=${roster.tmux.holderSession}:0.0`,
|
`=${roster.tmux.holderSession}:0.0`,
|
||||||
@@ -998,8 +998,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
for (const agent of roster.agents) {
|
for (const agent of roster.agents) {
|
||||||
await runChecked(runner, [
|
await runChecked(runner, [
|
||||||
'tmux',
|
'tmux',
|
||||||
'-L',
|
...socketArgs(socketName),
|
||||||
socketName,
|
|
||||||
'has-session',
|
'has-session',
|
||||||
'-t',
|
'-t',
|
||||||
`=${agent.name}:0.0`,
|
`=${agent.name}:0.0`,
|
||||||
@@ -1370,8 +1369,8 @@ export function registerFleetAgentCommands(
|
|||||||
getRosterAgent(roster, agent);
|
getRosterAgent(roster, agent);
|
||||||
}
|
}
|
||||||
const command = agent
|
const command = agent
|
||||||
? ['tmux', '-L', roster.tmux.socketName, 'has-session', '-t', `=${agent}:0.0`]
|
? ['tmux', ...socketArgs(roster.tmux.socketName), 'has-session', '-t', `=${agent}:0.0`]
|
||||||
: ['tmux', '-L', roster.tmux.socketName, 'ls'];
|
: ['tmux', ...socketArgs(roster.tmux.socketName), 'ls'];
|
||||||
const result = await runner(...splitCommand(command));
|
const result = await runner(...splitCommand(command));
|
||||||
if (opts.json) {
|
if (opts.json) {
|
||||||
console.log(
|
console.log(
|
||||||
@@ -1689,9 +1688,12 @@ function normalizeRoster(raw: RawFleetRoster): FleetRoster {
|
|||||||
version: 1,
|
version: 1,
|
||||||
transport: 'tmux',
|
transport: 'tmux',
|
||||||
tmux: {
|
tmux: {
|
||||||
|
// Absent socket_name ⇒ '' (the literal default tmux socket, no -L) — NOT
|
||||||
|
// mosaic-factory. Shipped presets set socket_name explicitly, so they are
|
||||||
|
// unaffected; only socket-less rosters get default-socket behavior.
|
||||||
socketName: stringValue(
|
socketName: stringValue(
|
||||||
raw.tmux?.socket_name ?? raw.tmux?.socketName,
|
raw.tmux?.socket_name ?? raw.tmux?.socketName,
|
||||||
DEFAULT_SOCKET_NAME,
|
'',
|
||||||
'Fleet roster tmux socket_name',
|
'Fleet roster tmux socket_name',
|
||||||
),
|
),
|
||||||
holderSession: stringValue(
|
holderSession: stringValue(
|
||||||
@@ -1857,6 +1859,12 @@ function expandHome(path: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function shellEnvValue(value: string): string {
|
function shellEnvValue(value: string): string {
|
||||||
|
// Empty ⇒ a bare `VAR=` (unambiguous empty in a systemd EnvironmentFile and
|
||||||
|
// when shell-sourced). Quoting it as '' risks a literal two-char value (e.g.
|
||||||
|
// a tmux socket named "''"), which would defeat the default-socket behavior.
|
||||||
|
if (value === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
if (/^[A-Za-z0-9_./:=@+-]+$/.test(value)) {
|
if (/^[A-Za-z0-9_./:=@+-]+$/.test(value)) {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user