Compare commits
3 Commits
feat/fleet
...
release/mo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac79922c3f | ||
| b5c1381e45 | |||
| 6dfd78f643 |
@@ -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
|
||||
|
||||
|
||||
35
docs/scratchpads/2026-06-20-fleet-release-hardening.md
Normal file
35
docs/scratchpads/2026-06-20-fleet-release-hardening.md
Normal 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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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']));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>')
|
||||
|
||||
Reference in New Issue
Block a user