Compare commits

...

2 Commits

Author SHA1 Message Date
Jarvis
ac79922c3f chore(release): bump mosaic cli to 0.0.32
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-20 15:56:43 -05:00
b5c1381e45 fix(fleet): harden operator sends for release (#565)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-20 20:41:11 +00:00
5 changed files with 237 additions and 12 deletions

View File

@@ -17,6 +17,9 @@ Product-owned defaults:
- `packages/mosaic/framework/tools/tmux/agent-send.sh`
- `packages/mosaic/framework/tools/tmux/send-message.sh`
These files are published through `packages/mosaic/package.json`, whose `files`
allowlist includes `framework` along with `dist`.
Site-owned local roster:
```text
@@ -66,6 +69,14 @@ These commands read the roster and target the configured tmux socket. The
generated systemd agent services use `start-agent-session.sh`; message delivery
uses the tmux send tools with `-L mosaic-factory`.
`mosaic agent send` is operator-origin traffic unless a caller explicitly says
otherwise. The CLI always passes a deterministic source label to
`agent-send.sh` with `-S`, defaulting to `<hostname>:operator`, so it does not
query the target tmux socket and accidentally identify as an active agent pane.
Use `--source-label <label>` or `--source <label>` only when deliberately
impersonating a known handoff lane. The lower-level inter-agent wrapper
`agent-send.sh -S <label>` remains the explicit source override for scripts.
## Verification
Use these checks before expanding the roster:
@@ -83,6 +94,27 @@ Expected results:
- `tmux ls` shows only the default tmux server sessions and is not changed by
fleet start/stop operations.
- `mosaic fleet verify` checks exact session targets on the isolated socket.
- `systemctl --user status ...` may show `active (exited)` for oneshot units;
that means the unit ran, not that an agent pane is live. Treat tmux
`has-session`, `list-panes`, process tree, and logs as the liveness evidence.
## Release Preflight
Run this checklist before cutting or dogfooding a fleet release:
- Real AI dogfood: send at least one task through `mosaic agent send`, then
confirm the agent accepted/responded using pane, process, or log evidence.
- Restart/stop/idempotency: run `mosaic fleet start`, `restart`, `stop`, and a
repeated `start` against the named socket; verify the default tmux server is
unchanged.
- Liveness verification: run `mosaic fleet verify` and confirm roster sessions
with `tmux -L mosaic-factory ls` or exact `has-session` checks.
- Package dry-run: run `npm pack --dry-run --json` from `packages/mosaic` and
confirm `framework/fleet`, `framework/systemd/user`,
`framework/tools/fleet`, and `framework/tools/tmux` assets are included.
- Mosaic update test: install or upgrade from the packed artifact in a temporary
Mosaic home and confirm `mosaic update` or the release upgrade path does not
remove local roster/config files.
## Rollback

View File

@@ -0,0 +1,35 @@
# Fleet release hardening
## Objective
Harden the Mosaic local fleet release path for operator sends, tmux/systemd verification, package contents, and dogfood release documentation.
## Constraints
- Do not edit `docs/TASKS.md`.
- Do not change production deployment refs.
- Keep fleet transport generic and named-socket safe.
- Preserve strict roster validation.
- Add tests first or alongside fixes.
## Plan
1. Add regression tests for deterministic `mosaic agent send` source labels.
2. Strengthen fleet status/verify/package/install-systemd coverage.
3. Implement focused CLI/source-label changes.
4. Update local canary documentation with dogfood preflight.
5. Run formatting, targeted tests, typecheck, lint, and package dry-run evidence.
## Evidence Log
- Started from existing `docs/PRD.md`; durable local fleet canary is in v0.1.0 scope.
- Loaded `mosaic-fleet-operations` skill; key constraints are isolated tmux sockets, no default tmux positive tests, and `active (exited)` is not liveness.
- TDD red: `pnpm --filter @mosaicstack/mosaic test -- src/commands/fleet.spec.ts` initially failed because `node_modules` was absent; after `pnpm install`, the new source-label tests failed on missing `-S`, missing helper, and unknown `--source-label`.
- Green implementation: `mosaic agent send` now passes `-S <hostname>:operator` by default and accepts `--source-label` / `--source` overrides.
- Test coverage added for tmux-based fleet verify liveness, package `files` allowlist containing `framework`, and explicit operator source-label command construction.
- Formatting: `pnpm exec prettier --write packages/mosaic/src/commands/fleet.ts packages/mosaic/src/commands/fleet.spec.ts docs/guides/fleet-local-canary.md docs/scratchpads/2026-06-20-fleet-release-hardening.md`.
- Targeted tests: `pnpm --filter @mosaicstack/mosaic test -- src/commands/fleet.spec.ts src/cli-smoke.spec.ts` passed with 49 tests.
- Typecheck: `pnpm typecheck` passed.
- Lint: `pnpm lint` passed.
- Package dry-run: `npm pack --dry-run --json` from `packages/mosaic` included `framework/fleet`, `framework/systemd/user`, `framework/tools/fleet/start-agent-session.sh`, and `framework/tools/tmux/{agent-send.sh,send-message.sh}`.
- Review: `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` approved the supplied diff with no findings; the review tool noted its read-only sandbox could not inspect files directly.

View File

@@ -1,6 +1,6 @@
{
"name": "@mosaicstack/mosaic",
"version": "0.0.31",
"version": "0.0.32",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",

View File

@@ -7,6 +7,7 @@ import {
buildAgentSendCommand,
buildFleetServiceCommand,
generateAgentEnv,
getDefaultOperatorSourceLabel,
getRosterAgent,
loadFleetRoster,
registerFleetCommand,
@@ -229,10 +230,14 @@ describe('fleet command construction', () => {
it('builds socket-scoped agent send commands', () => {
const paths = resolveFleetPaths('/home/test/.config/mosaic');
expect(buildAgentSendCommand(paths, 'coder0', 'hello', 'mosaic-factory')).toEqual([
expect(
buildAgentSendCommand(paths, 'coder0', 'hello', 'mosaic-factory', 'operator:mosaic-cli'),
).toEqual([
'/home/test/.config/mosaic/tools/tmux/agent-send.sh',
'-L',
'mosaic-factory',
'-S',
'operator:mosaic-cli',
'-s',
'coder0',
'-m',
@@ -255,6 +260,36 @@ describe('fleet command construction', () => {
expect(calls).toEqual([['systemctl', '--user', 'status', 'mosaic-tmux-holder.service']]);
});
it('verifies liveness with tmux has-session and does not trust systemd active exited', async () => {
const home = await tempDir();
const rosterPath = join(home, 'fleet', 'roster.yaml');
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
rosterPath,
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
'\n',
),
);
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
return { stdout: 'active (exited)\n', stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
try {
await program.parseAsync(['node', 'mosaic', 'fleet', 'verify']);
expect(calls).toEqual([
['tmux', '-L', 'mosaic-factory', 'has-session', '-t', '=_holder:0.0'],
['tmux', '-L', 'mosaic-factory', 'has-session', '-t', '=coder0:0.0'],
]);
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('writes init output to the explicit roster path', async () => {
const home = await tempDir();
const rosterPath = join(home, 'custom', 'roster.yaml');
@@ -536,6 +571,104 @@ describe('fleet command construction', () => {
}
});
it('passes a deterministic operator source label for agent sends', async () => {
const home = await tempDir();
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
join(home, 'fleet', 'roster.yaml'),
JSON.stringify({
version: 1,
transport: 'tmux',
agents: [{ name: 'json-agent', runtime: 'pi' }],
}),
);
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
return { stdout: '', stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
registerAgentCommand(program, { runner, mosaicHome: home });
try {
await program.parseAsync([
'node',
'mosaic',
'agent',
'send',
'json-agent',
'--message',
'status check',
]);
expect(calls).toEqual([
[
join(home, 'tools', 'tmux', 'agent-send.sh'),
'-L',
'mosaic-factory',
'-S',
getDefaultOperatorSourceLabel(),
'-s',
'json-agent',
'-m',
'status check',
],
]);
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('allows agent sends to override the source label explicitly', async () => {
const home = await tempDir();
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
join(home, 'fleet', 'roster.yaml'),
JSON.stringify({
version: 1,
transport: 'tmux',
agents: [{ name: 'coder0', runtime: 'codex' }],
}),
);
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
return { stdout: '', stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
registerAgentCommand(program, { runner, mosaicHome: home });
try {
await program.parseAsync([
'node',
'mosaic',
'agent',
'send',
'coder0',
'--message',
'handoff',
'--source-label',
'lead:manual',
]);
expect(calls).toEqual([
[
join(home, 'tools', 'tmux', 'agent-send.sh'),
'-L',
'mosaic-factory',
'-S',
'lead:manual',
'-s',
'coder0',
'-m',
'handoff',
],
]);
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('rejects agent status typos before invoking the runner', async () => {
const home = await tempDir();
const rosterPath = join(home, 'fleet', 'roster.yaml');
@@ -560,4 +693,14 @@ describe('fleet command construction', () => {
await rm(home, { recursive: true, force: true });
}
});
it('keeps fleet framework assets in the published package file list', async () => {
const packageJson = JSON.parse(
await readFile(resolve(process.cwd(), 'package.json'), 'utf8'),
) as {
files?: string[];
};
expect(packageJson.files).toEqual(expect.arrayContaining(['dist', 'framework']));
});
});

View File

@@ -1,6 +1,6 @@
import { constants } from 'node:fs';
import { access, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { homedir, hostname } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawn } from 'node:child_process';
@@ -158,11 +158,14 @@ export function buildAgentSendCommand(
agentName: string,
message: string,
socketName = DEFAULT_SOCKET_NAME,
sourceLabel = getDefaultOperatorSourceLabel(),
): string[] {
return [
join(paths.tmuxToolsDir, 'agent-send.sh'),
'-L',
socketName,
'-S',
sourceLabel,
'-s',
agentName,
'-m',
@@ -170,6 +173,11 @@ export function buildAgentSendCommand(
];
}
export function getDefaultOperatorSourceLabel(): string {
const shortHostname = hostname().split('.')[0] || 'localhost';
return `${shortHostname}:operator`;
}
export function buildAgentResetCommand(
paths: FleetPaths,
agentName: string,
@@ -384,15 +392,22 @@ export function registerFleetAgentCommands(
.command('send <agent>')
.description('Send a message to a local fleet agent')
.requiredOption('--message <text>', 'Message text')
.action(async (agent: string, opts: { message: string }) => {
.option('--source-label <label>', 'Source label for the message preamble')
.option('--source <label>', 'Alias for --source-label')
.action(
async (agent: string, opts: { message: string; sourceLabel?: string; source?: string }) => {
const roster = await loadRosterFromAgentCommand(agentCommand, deps.mosaicHome);
getRosterAgent(roster, agent);
const paths = resolveFleetPaths(resolveMosaicHomeFromCommand(agentCommand, deps.mosaicHome));
const paths = resolveFleetPaths(
resolveMosaicHomeFromCommand(agentCommand, deps.mosaicHome),
);
const sourceLabel = opts.sourceLabel ?? opts.source ?? getDefaultOperatorSourceLabel();
await runChecked(
runner,
buildAgentSendCommand(paths, agent, opts.message, roster.tmux.socketName),
buildAgentSendCommand(paths, agent, opts.message, roster.tmux.socketName, sourceLabel),
);
},
);
});
agentCommand
.command('reset <agent>')