This commit was merged in pull request #813.
This commit is contained in:
885
packages/mosaic/src/commands/fleet-regen-command.spec.ts
Normal file
885
packages/mosaic/src/commands/fleet-regen-command.spec.ts
Normal file
@@ -0,0 +1,885 @@
|
||||
import { chmod, mkdir, mkdtemp, open, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { registerFleetCommand, type CommandResult, type FleetCommandDeps } from './fleet.js';
|
||||
import { executeFleetRegen, formatFleetRegenReport } from './fleet-regen-command.js';
|
||||
import {
|
||||
acquirePrivateRosterMutationLock,
|
||||
projectRosterV2AgentGeneratedEnv,
|
||||
} from '../fleet/fleet-reconciler.js';
|
||||
import { applyPreparedGeneratedAgentEnvironmentProjection } from '../fleet/generated-env-boundary.js';
|
||||
import { parseRosterV2 } from '../fleet/roster-v2.js';
|
||||
|
||||
// A two-agent roster-v2 SSOT. `mosaic fleet regen` must rebuild each agent's
|
||||
// `fleet/agents/<name>.env.generated` projection from exactly this source and
|
||||
// nothing else — deterministically, without ever touching agent lifecycle.
|
||||
const rosterYaml = `
|
||||
version: 2
|
||||
generation: 7
|
||||
transport: tmux
|
||||
tmux:
|
||||
socket_name: mosaic-fleet
|
||||
holder_session: _holder
|
||||
defaults:
|
||||
working_directory: /srv/mosaic
|
||||
runtime: pi
|
||||
runtimes:
|
||||
pi:
|
||||
reset_command: /new
|
||||
agents:
|
||||
- name: coder0
|
||||
alias: Coder 0
|
||||
class: code
|
||||
runtime: pi
|
||||
provider: openai
|
||||
model: gpt-5.6-sol
|
||||
reasoning: high
|
||||
tool_policy: code
|
||||
working_directory: /srv/mosaic
|
||||
persistent_persona: false
|
||||
reset_between_tasks: true
|
||||
lifecycle:
|
||||
enabled: true
|
||||
desired_state: stopped
|
||||
launch:
|
||||
yolo: true
|
||||
- name: coder1
|
||||
alias: Coder 1
|
||||
class: code
|
||||
runtime: pi
|
||||
provider: openai
|
||||
model: gpt-5.6-sol
|
||||
reasoning: medium
|
||||
tool_policy: code
|
||||
working_directory: /srv/other
|
||||
persistent_persona: false
|
||||
reset_between_tasks: true
|
||||
lifecycle:
|
||||
enabled: true
|
||||
desired_state: stopped
|
||||
launch:
|
||||
yolo: true
|
||||
`;
|
||||
|
||||
let cleanup: string | undefined;
|
||||
|
||||
afterEach(async (): Promise<void> => {
|
||||
vi.restoreAllMocks();
|
||||
process.exitCode = undefined;
|
||||
if (cleanup) await rm(cleanup, { recursive: true, force: true });
|
||||
cleanup = undefined;
|
||||
});
|
||||
|
||||
async function fleetHome(withRoster = rosterYaml): Promise<string> {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-fleet-regen-command-'));
|
||||
for (const directory of ['fleet', 'fleet/agents', 'fleet/roles']) {
|
||||
await mkdir(join(cleanup, directory), { recursive: true, mode: 0o700 });
|
||||
await chmod(join(cleanup, directory), 0o700);
|
||||
}
|
||||
await chmod(cleanup, 0o700);
|
||||
await writeFile(join(cleanup, 'fleet', 'roster.yaml'), withRoster, { mode: 0o600 });
|
||||
// Semantic roster validation (matching `fleet reconcile`) resolves each agent
|
||||
// class to a persona; seed the classes the fixtures reference.
|
||||
for (const klass of ['code', 'merge-gate']) {
|
||||
await writeFile(
|
||||
join(cleanup, 'fleet', 'roles', `${klass}.md`),
|
||||
`\`class: ${klass}\`\n\n# ${klass} persona\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
}
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
/**
|
||||
* A runner spy that records EVERY invocation. `regen` is projection-only and
|
||||
* must never issue a lifecycle/restart call, so a non-empty call log is a
|
||||
* hard failure — this is the load-bearing "never restarts" gate.
|
||||
*/
|
||||
function recordingRunner(
|
||||
calls: string[][],
|
||||
): (command: string, args: string[]) => Promise<CommandResult> {
|
||||
return async (command: string, args: string[]): Promise<CommandResult> => {
|
||||
calls.push([command, ...args]);
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
};
|
||||
}
|
||||
|
||||
function program(mosaicHome: string, runner: FleetCommandDeps['runner']): Command {
|
||||
const result = new Command();
|
||||
result.exitOverride();
|
||||
registerFleetCommand(result, { mosaicHome, runner });
|
||||
return result;
|
||||
}
|
||||
|
||||
function capture(): string[] {
|
||||
const lines: string[] = [];
|
||||
vi.spyOn(console, 'log').mockImplementation((value: string): void => {
|
||||
lines.push(value);
|
||||
});
|
||||
return lines;
|
||||
}
|
||||
|
||||
async function exists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
describe('projectRosterV2AgentGeneratedEnv', (): void => {
|
||||
it('maps a roster-v2 agent to exactly the eight generated projection keys', (): void => {
|
||||
const roster = parseRosterV2(rosterYaml, 'yaml');
|
||||
const agent = roster.agents.find((candidate) => candidate.name === 'coder0');
|
||||
expect(agent).toBeDefined();
|
||||
const values = projectRosterV2AgentGeneratedEnv(roster, agent!);
|
||||
expect(values).toEqual({
|
||||
MOSAIC_AGENT_NAME: 'coder0',
|
||||
MOSAIC_AGENT_CLASS: 'code',
|
||||
MOSAIC_AGENT_RUNTIME: 'pi',
|
||||
MOSAIC_AGENT_MODEL: 'gpt-5.6-sol',
|
||||
MOSAIC_AGENT_REASONING: 'high',
|
||||
MOSAIC_AGENT_TOOL_POLICY: 'code',
|
||||
MOSAIC_AGENT_WORKDIR: '/srv/mosaic',
|
||||
MOSAIC_TMUX_SOCKET: 'mosaic-fleet',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('mosaic fleet regen', (): void => {
|
||||
it('is dry-run by default: reports the plan and writes nothing', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const calls: string[][] = [];
|
||||
const lines = capture();
|
||||
|
||||
await program(home, recordingRunner(calls)).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'regen',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
const result = JSON.parse(lines.pop() ?? '{}');
|
||||
expect(result).toMatchObject({
|
||||
mode: 'dry-run',
|
||||
generation: 7,
|
||||
agentCount: 2,
|
||||
written: 0,
|
||||
});
|
||||
expect(result.agents.map((agent: { name: string }) => agent.name)).toEqual([
|
||||
'coder0',
|
||||
'coder1',
|
||||
]);
|
||||
expect(
|
||||
result.agents.every((agent: { disposition: string }) => agent.disposition === 'create'),
|
||||
).toBe(true);
|
||||
// Nothing on disk.
|
||||
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(false);
|
||||
expect(await exists(join(home, 'fleet', 'agents', 'coder1.env.generated'))).toBe(false);
|
||||
// No lifecycle/restart call — ever.
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('forwards configured persona roots (rolesDir/overrideDir) from reconcileDeps into regen', async (): Promise<void> => {
|
||||
// Codex r6: regen must resolve personas the SAME way reconcile does. If the
|
||||
// top-level registration drops reconcileDeps.rolesDir/overrideDir, a deployment
|
||||
// with custom persona roots gets reconcile ACCEPTING a roster while regen
|
||||
// REJECTS it (validating against the wrong default `<home>/fleet/roles`),
|
||||
// blocking the recovery command. Pin the wiring: seed personas ONLY under the
|
||||
// custom roots, leave the default roles dir empty, require regen to succeed.
|
||||
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-regen-wiring-'));
|
||||
cleanup = home;
|
||||
for (const directory of ['fleet', 'fleet/agents', 'fleet/roles']) {
|
||||
await mkdir(join(home, directory), { recursive: true, mode: 0o700 });
|
||||
await chmod(join(home, directory), 0o700);
|
||||
}
|
||||
await chmod(home, 0o700);
|
||||
await writeFile(join(home, 'fleet', 'roster.yaml'), rosterYaml, { mode: 0o600 });
|
||||
// Personas live ONLY under the configured roots — the default `fleet/roles`
|
||||
// stays empty, so broken wiring fails persona resolution.
|
||||
const customRoles = join(home, 'custom-roles');
|
||||
const customOverride = join(home, 'custom-roles.local');
|
||||
await mkdir(customRoles, { recursive: true, mode: 0o700 });
|
||||
await mkdir(customOverride, { recursive: true, mode: 0o700 });
|
||||
for (const klass of ['code', 'merge-gate']) {
|
||||
await writeFile(join(customRoles, `${klass}.md`), `\`class: ${klass}\`\n\n# ${klass}\n`, {
|
||||
mode: 0o600,
|
||||
});
|
||||
}
|
||||
|
||||
const command = new Command();
|
||||
command.exitOverride();
|
||||
registerFleetCommand(command, {
|
||||
mosaicHome: home,
|
||||
runner: recordingRunner([]),
|
||||
reconcileDeps: { rolesDir: customRoles, overrideDir: customOverride },
|
||||
});
|
||||
const lines = capture();
|
||||
|
||||
await command.parseAsync(['node', 'mosaic', 'fleet', 'regen', '--json']);
|
||||
|
||||
// Regen validated against the CONFIGURED roots → success. Broken wiring
|
||||
// resolves against the empty default and fails (exitCode 1, no JSON result).
|
||||
expect(process.exitCode).not.toBe(1);
|
||||
const result = JSON.parse(lines.pop() ?? '{}');
|
||||
expect(result).toMatchObject({ mode: 'dry-run', agentCount: 2 });
|
||||
});
|
||||
|
||||
it('--write rebuilds each generated projection from the roster SSOT', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const calls: string[][] = [];
|
||||
const lines = capture();
|
||||
|
||||
await program(home, recordingRunner(calls)).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'regen',
|
||||
'--write',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
const result = JSON.parse(lines.pop() ?? '{}');
|
||||
expect(result).toMatchObject({ mode: 'write', written: 2, agentCount: 2 });
|
||||
|
||||
const coder0 = await readFile(join(home, 'fleet', 'agents', 'coder0.env.generated'), 'utf8');
|
||||
expect(coder0).toContain('MOSAIC_AGENT_NAME=coder0');
|
||||
expect(coder0).toContain('MOSAIC_AGENT_WORKDIR=/srv/mosaic');
|
||||
expect(coder0).toContain('MOSAIC_TMUX_SOCKET=mosaic-fleet');
|
||||
const coder1 = await readFile(join(home, 'fleet', 'agents', 'coder1.env.generated'), 'utf8');
|
||||
expect(coder1).toContain('MOSAIC_AGENT_NAME=coder1');
|
||||
expect(coder1).toContain('MOSAIC_AGENT_WORKDIR=/srv/other');
|
||||
// No lifecycle/restart call — ever.
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('is deterministic and idempotent across repeated --write runs', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const calls: string[][] = [];
|
||||
const path = join(home, 'fleet', 'agents', 'coder0.env.generated');
|
||||
|
||||
const cli = program(home, recordingRunner(calls));
|
||||
capture();
|
||||
await cli.parseAsync(['node', 'mosaic', 'fleet', 'regen', '--write', '--json']);
|
||||
const first = await readFile(path, 'utf8');
|
||||
await cli.parseAsync(['node', 'mosaic', 'fleet', 'regen', '--write', '--json']);
|
||||
const second = await readFile(path, 'utf8');
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports rebuild disposition once the generated projection already exists', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const calls: string[][] = [];
|
||||
const cli = program(home, recordingRunner(calls));
|
||||
const lines = capture();
|
||||
|
||||
await cli.parseAsync(['node', 'mosaic', 'fleet', 'regen', '--write', '--json']);
|
||||
lines.length = 0;
|
||||
await cli.parseAsync(['node', 'mosaic', 'fleet', 'regen', '--json']);
|
||||
|
||||
const result = JSON.parse(lines.pop() ?? '{}');
|
||||
expect(
|
||||
result.agents.every((agent: { disposition: string }) => agent.disposition === 'rebuild'),
|
||||
).toBe(true);
|
||||
expect(result.written).toBe(0);
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('never issues a lifecycle/restart call in either mode', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const calls: string[][] = [];
|
||||
const cli = program(home, recordingRunner(calls));
|
||||
capture();
|
||||
|
||||
await cli.parseAsync(['node', 'mosaic', 'fleet', 'regen', '--json']);
|
||||
await cli.parseAsync(['node', 'mosaic', 'fleet', 'regen', '--write', '--json']);
|
||||
|
||||
// Structural guarantee: regen has no path to systemctl/tmux at all.
|
||||
expect(calls).toEqual([]);
|
||||
const systemctlCalls = calls.filter(([command]) => command === 'systemctl');
|
||||
expect(systemctlCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it('human-readable --write output prints the do-not-restart recovery runbook', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const lines = capture();
|
||||
|
||||
await program(home, recordingRunner([])).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'regen',
|
||||
'--write',
|
||||
]);
|
||||
|
||||
const output = lines.join('\n');
|
||||
expect(output).toMatch(/do not restart|before.*restart/i);
|
||||
expect(output).toMatch(/env\.generated/);
|
||||
// The unit has no EnvironmentFile= directive, so the runbook must NOT tell the
|
||||
// operator to verify one — verify the launcher/generated file instead.
|
||||
expect(output).not.toContain('EnvironmentFile');
|
||||
expect(output).toMatch(/systemctl --user cat mosaic-agent@<name>/);
|
||||
expect(output).toMatch(/restart/i);
|
||||
});
|
||||
|
||||
it('emits paths and counts only — never the projected env body (secrev)', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const lines = capture();
|
||||
|
||||
await program(home, recordingRunner([])).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'regen',
|
||||
'--write',
|
||||
]);
|
||||
|
||||
const output = lines.join('\n');
|
||||
// Relative paths + counts are fine; the rendered projection body (KEY=value
|
||||
// lines) must never be echoed to stdout.
|
||||
expect(output).toContain('coder0.env.generated');
|
||||
expect(output).not.toMatch(/^MOSAIC_AGENT_\w+=/m);
|
||||
expect(output).not.toContain('MOSAIC_TMUX_SOCKET=mosaic-fleet');
|
||||
});
|
||||
|
||||
it('is projection-only: leaves legacy .env untouched and writes no .env.local/.env.quarantine', async (): Promise<void> => {
|
||||
// Recovery contract: regen rebuilds ONLY <name>.env.generated. It must never
|
||||
// relocate, quarantine, or unlink the operator-owned legacy .env surface — the
|
||||
// full reconciler apply path does, so regen must NOT use it.
|
||||
const home = await fleetHome();
|
||||
const legacyPath = join(home, 'fleet', 'agents', 'coder0.env');
|
||||
await writeFile(legacyPath, 'MOSAIC_RUNTIME_BIN=/usr/bin/pi\n', { mode: 0o600 });
|
||||
capture();
|
||||
|
||||
await program(home, recordingRunner([])).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'regen',
|
||||
'--write',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
// Generated projection rebuilt...
|
||||
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(true);
|
||||
// ...but the operator's legacy .env is preserved verbatim, and no local/quarantine
|
||||
// files were fabricated from it.
|
||||
expect(await readFile(legacyPath, 'utf8')).toBe('MOSAIC_RUNTIME_BIN=/usr/bin/pi\n');
|
||||
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.local'))).toBe(false);
|
||||
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.quarantine'))).toBe(false);
|
||||
});
|
||||
|
||||
it('prepares every agent before writing any: a later prepare failure leaves nothing written', async (): Promise<void> => {
|
||||
// Fail-closed across agents. Pre-seed coder1's projection with world-readable
|
||||
// perms so its prepare rejects; coder0 (valid) must NOT be written because
|
||||
// preparation is fully completed before the first apply.
|
||||
const home = await fleetHome();
|
||||
const coder1Path = join(home, 'fleet', 'agents', 'coder1.env.generated');
|
||||
await writeFile(coder1Path, 'MOSAIC_AGENT_NAME=coder1\n', { mode: 0o644 });
|
||||
const calls: string[][] = [];
|
||||
capture();
|
||||
const errors: string[] = [];
|
||||
vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array): boolean => {
|
||||
errors.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
|
||||
await program(home, recordingRunner(calls)).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'regen',
|
||||
'--write',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(errors.join('')).toMatch(/regen failed/i);
|
||||
// coder0 is valid but must remain unwritten — no partial rebuild.
|
||||
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(false);
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('fails closed on a semantically invalid roster (protected-class tool-policy mismatch)', async (): Promise<void> => {
|
||||
// A hand-edited roster giving a protected class a weaker tool_policy is rejected
|
||||
// by reconcile/plan/verify; regen must enforce the SAME gate, not silently
|
||||
// project the downgraded policy into <name>.env.generated.
|
||||
const home = await fleetHome(`
|
||||
version: 2
|
||||
generation: 3
|
||||
transport: tmux
|
||||
tmux:
|
||||
socket_name: mosaic-fleet
|
||||
holder_session: _holder
|
||||
defaults:
|
||||
working_directory: /srv/mosaic
|
||||
runtime: pi
|
||||
runtimes:
|
||||
pi:
|
||||
reset_command: /new
|
||||
agents:
|
||||
- name: gate0
|
||||
alias: Gate 0
|
||||
class: merge-gate
|
||||
runtime: pi
|
||||
provider: openai
|
||||
model: gpt-5.6-sol
|
||||
reasoning: high
|
||||
tool_policy: code
|
||||
working_directory: /srv/mosaic
|
||||
persistent_persona: false
|
||||
reset_between_tasks: true
|
||||
lifecycle:
|
||||
enabled: true
|
||||
desired_state: stopped
|
||||
launch:
|
||||
yolo: true
|
||||
`);
|
||||
const calls: string[][] = [];
|
||||
capture();
|
||||
const errors: string[] = [];
|
||||
vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array): boolean => {
|
||||
errors.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
|
||||
await program(home, recordingRunner(calls)).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'regen',
|
||||
'--write',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(errors.join('')).toMatch(/regen failed/i);
|
||||
expect(await exists(join(home, 'fleet', 'agents', 'gate0.env.generated'))).toBe(false);
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses to write while a concurrent reconcile holds the mutation lock', async (): Promise<void> => {
|
||||
// regen --write mutates the same projections as reconcile; it must take the
|
||||
// shared reconcile lock so a concurrent reconcile cannot race a stale write.
|
||||
const home = await fleetHome();
|
||||
await writeFile(join(home, 'fleet', 'roster.yaml.reconcile.lock'), 'held\n', { mode: 0o600 });
|
||||
const calls: string[][] = [];
|
||||
capture();
|
||||
const errors: string[] = [];
|
||||
vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array): boolean => {
|
||||
errors.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
|
||||
await program(home, recordingRunner(calls)).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'regen',
|
||||
'--write',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(errors.join('')).toMatch(/regen failed/i);
|
||||
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(false);
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('fails closed (non-zero, no writes) on an invalid roster', async (): Promise<void> => {
|
||||
// roster-v2 requires >= 1 agent; a malformed roster must abort regen without
|
||||
// writing any projection and without touching lifecycle.
|
||||
const home = await fleetHome('version: 2\ngeneration: 1\n');
|
||||
const calls: string[][] = [];
|
||||
capture();
|
||||
const errors: string[] = [];
|
||||
vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array): boolean => {
|
||||
errors.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
|
||||
await program(home, recordingRunner(calls)).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'regen',
|
||||
'--write',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(errors.join('')).toMatch(/regen failed/i);
|
||||
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(false);
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects a non-canonical --roster path (canonical roster only)', async (): Promise<void> => {
|
||||
// `fleet` exposes a global `--roster`; reconcile rejects a non-canonical value
|
||||
// rather than silently reading the canonical roster. regen must enforce the
|
||||
// SAME guard so `--roster /elsewhere regen --write` cannot mislead the operator.
|
||||
const home = await fleetHome();
|
||||
const calls: string[][] = [];
|
||||
capture();
|
||||
const errors: string[] = [];
|
||||
vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array): boolean => {
|
||||
errors.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
|
||||
await program(home, recordingRunner(calls)).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'--roster',
|
||||
join(home, 'other', 'roster.yaml'),
|
||||
'regen',
|
||||
'--write',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(errors.join('')).toMatch(/regen failed/i);
|
||||
expect(errors.join('')).toMatch(/canonical roster/i);
|
||||
// No projection written against a misdirected roster path.
|
||||
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(false);
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports an incomplete rebuild when a later projection write fails mid-loop', async (): Promise<void> => {
|
||||
// Fault injected on the SECOND apply: coder0 is already replaced on disk, so a
|
||||
// bare throw would hide the partial state. regen must surface which agents were
|
||||
// rebuilt and which failed, with a verify-before-restart recovery instruction.
|
||||
const home = await fleetHome();
|
||||
const realApply = applyPreparedGeneratedAgentEnvironmentProjection;
|
||||
const result = await executeFleetRegen(
|
||||
{
|
||||
runner: recordingRunner([]),
|
||||
mosaicHome: home,
|
||||
applyProjection: async (prepared): Promise<string> => {
|
||||
if (prepared.generatedPath.endsWith('coder1.env.generated')) {
|
||||
throw new Error('simulated ENOSPC on coder1');
|
||||
}
|
||||
return realApply(prepared);
|
||||
},
|
||||
},
|
||||
{ write: true },
|
||||
);
|
||||
|
||||
// coder0 written before the fault; coder1 not.
|
||||
expect(result.written).toBe(1);
|
||||
expect(result.incomplete).toMatchObject({
|
||||
code: 'projection-apply-failed',
|
||||
failedAgent: 'coder1',
|
||||
writtenAgents: ['coder0'],
|
||||
});
|
||||
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(true);
|
||||
expect(await exists(join(home, 'fleet', 'agents', 'coder1.env.generated'))).toBe(false);
|
||||
|
||||
// The human report names the failed agent and directs a verify-before-restart
|
||||
// recovery — not a bare success runbook.
|
||||
const report = formatFleetRegenReport(result).join('\n');
|
||||
expect(report).toMatch(/incomplete/i);
|
||||
expect(report).toContain('coder1');
|
||||
expect(report).toMatch(/verify|re-run|retry/i);
|
||||
// Structural: still no lifecycle call anywhere on the failure path.
|
||||
expect(report).not.toMatch(/systemctl --user restart mosaic-agent@<name>\n.*\n/);
|
||||
});
|
||||
|
||||
it('refuses to write while agent CRUD holds the roster mutation lock', async (): Promise<void> => {
|
||||
// regen --write overwrites the SAME generated projections that `fleet agent
|
||||
// create/update/delete` writes under roster.yaml.mutation.lock. If regen only
|
||||
// took the reconcile lock it could read a stale roster and overwrite a just-
|
||||
// committed projection. It must also take the mutation lock and fail closed.
|
||||
const home = await fleetHome();
|
||||
await writeFile(join(home, 'fleet', 'roster.yaml.mutation.lock'), 'crud\n', { mode: 0o600 });
|
||||
const calls: string[][] = [];
|
||||
capture();
|
||||
const errors: string[] = [];
|
||||
vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array): boolean => {
|
||||
errors.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
|
||||
await program(home, recordingRunner(calls)).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'regen',
|
||||
'--write',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(errors.join('')).toMatch(/regen failed/i);
|
||||
// No projection written against a roster a concurrent CRUD is mutating.
|
||||
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(false);
|
||||
expect(await exists(join(home, 'fleet', 'agents', 'coder1.env.generated'))).toBe(false);
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('surfaces BOTH the roster failure and a stale-lock warning on a double fault', async (): Promise<void> => {
|
||||
// Worst case with nothing written: run() fails (invalid roster, fail-closed) AND
|
||||
// a lock release faults. The operator must be told the lock may be stale, not
|
||||
// just the roster error — otherwise the next regen/reconcile is silently blocked.
|
||||
const home = await fleetHome('version: 2\ngeneration: 1\n');
|
||||
const throwingRelease =
|
||||
() => async (): Promise<() => Promise<void>> => async (): Promise<void> => {
|
||||
throw new Error('simulated lock unlink fault');
|
||||
};
|
||||
await expect(
|
||||
executeFleetRegen(
|
||||
{
|
||||
runner: recordingRunner([]),
|
||||
mosaicHome: home,
|
||||
acquireReconcileLock: throwingRelease,
|
||||
acquireRosterMutationLock: throwingRelease,
|
||||
},
|
||||
{ write: true },
|
||||
),
|
||||
).rejects.toThrow(/stale|inspect|lock/i);
|
||||
// Nothing written on the fail-closed path.
|
||||
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(false);
|
||||
});
|
||||
|
||||
it('propagates a roster-mutation-lock release failure instead of silently keeping a stale lock', async (): Promise<void> => {
|
||||
// Finding L: the real `acquirePrivateRosterMutationLock` release must SURFACE an
|
||||
// unlink failure, not swallow it. If it swallowed (like the CRUD-internal
|
||||
// acquireMutationLock does), a stale roster.yaml.mutation.lock left after a
|
||||
// successful regen would block later regen/agent CRUD while the command reported
|
||||
// success — and the finding-J stale-lock warning would never fire for this lock.
|
||||
const home = await fleetHome();
|
||||
const release = await acquirePrivateRosterMutationLock(home)();
|
||||
// Simulate the lock vanishing (or being unremovable) before release runs: the
|
||||
// underlying unlink then faults. A swallowing release would resolve and hide it.
|
||||
await rm(join(home, 'fleet', 'roster.yaml.mutation.lock'));
|
||||
await expect(release()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('preserves a complete rebuild result and flags lock-cleanup when release faults', async (): Promise<void> => {
|
||||
// A lock-release fault after a successful write must NOT discard the fact that
|
||||
// projections are now live — the operator needs to know the write happened and
|
||||
// that the shared lock may be stale, not just a bare "regen failed".
|
||||
const home = await fleetHome();
|
||||
const result = await executeFleetRegen(
|
||||
{
|
||||
runner: recordingRunner([]),
|
||||
mosaicHome: home,
|
||||
acquireReconcileLock:
|
||||
() => async (): Promise<() => Promise<void>> => async (): Promise<void> => {
|
||||
throw new Error('simulated lock unlink fault');
|
||||
},
|
||||
},
|
||||
{ write: true },
|
||||
);
|
||||
|
||||
expect(result.written).toBe(2);
|
||||
expect(result.incomplete).toBeUndefined();
|
||||
expect(result.cleanup).toMatchObject({
|
||||
code: 'lock-cleanup-failed',
|
||||
action: 'inspect-lock-before-retry',
|
||||
});
|
||||
// Projections are genuinely on disk despite the release fault.
|
||||
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(true);
|
||||
expect(await exists(join(home, 'fleet', 'agents', 'coder1.env.generated'))).toBe(true);
|
||||
|
||||
const report = formatFleetRegenReport(result).join('\n');
|
||||
expect(report).toMatch(/lock/i);
|
||||
expect(report).toMatch(/stale|inspect|clear/i);
|
||||
});
|
||||
|
||||
it('preserves a partial rebuild AND flags lock-cleanup when both apply and release fault', async (): Promise<void> => {
|
||||
// Worst case: a mid-loop apply fault leaves a partial rebuild, then the release
|
||||
// also faults. Both recovery states must survive so the operator sees exactly
|
||||
// which projections are live and that the lock may be stale.
|
||||
const home = await fleetHome();
|
||||
const realApply = applyPreparedGeneratedAgentEnvironmentProjection;
|
||||
const result = await executeFleetRegen(
|
||||
{
|
||||
runner: recordingRunner([]),
|
||||
mosaicHome: home,
|
||||
applyProjection: async (prepared): Promise<string> => {
|
||||
if (prepared.generatedPath.endsWith('coder1.env.generated')) {
|
||||
throw new Error('simulated ENOSPC on coder1');
|
||||
}
|
||||
return realApply(prepared);
|
||||
},
|
||||
acquireReconcileLock:
|
||||
() => async (): Promise<() => Promise<void>> => async (): Promise<void> => {
|
||||
throw new Error('simulated lock unlink fault');
|
||||
},
|
||||
},
|
||||
{ write: true },
|
||||
);
|
||||
|
||||
expect(result.written).toBe(1);
|
||||
expect(result.incomplete).toMatchObject({
|
||||
code: 'projection-apply-failed',
|
||||
failedAgent: 'coder1',
|
||||
writtenAgents: ['coder0'],
|
||||
});
|
||||
expect(result.cleanup).toMatchObject({ code: 'lock-cleanup-failed' });
|
||||
|
||||
const report = formatFleetRegenReport(result).join('\n');
|
||||
expect(report).toMatch(/incomplete/i);
|
||||
expect(report).toMatch(/lock/i);
|
||||
});
|
||||
|
||||
it('fails closed without deleting a REPLACEMENT roster-mutation lock it no longer owns', async (): Promise<void> => {
|
||||
// Finding M1 (replacement-lock race): after this invocation created its lock, the
|
||||
// lock is cleared and re-created by ANOTHER writer (new inode + foreign token)
|
||||
// BEFORE release runs. The release must PROVE ownership (device/inode + token) and
|
||||
// refuse to unlink the stranger's live lock — otherwise it deletes it, a third
|
||||
// writer enters concurrently, and mutual exclusion over the projections is broken.
|
||||
// This pins the REACHABLE window (replaced-before-release); the residual sub-window
|
||||
// between the final check and the path `unlink` is unreachable within the `wx`
|
||||
// writer protocol and matches the merged reconcile lock — see the acquirer doc.
|
||||
const home = await fleetHome();
|
||||
const lockPath = join(home, 'fleet', 'roster.yaml.mutation.lock');
|
||||
const release = await acquirePrivateRosterMutationLock(home)();
|
||||
// A different writer replaces the lock: same path, NEW inode, foreign content.
|
||||
await rm(lockPath);
|
||||
await writeFile(lockPath, 'a-different-writer\n', { mode: 0o600 });
|
||||
const replacement = await stat(lockPath);
|
||||
// Release must reject (it no longer owns this file) AND the diagnosis must name
|
||||
// the MUTATION lock specifically — a fault on this shared helper must not be
|
||||
// mislabeled as the reconcile lock (that would defeat the M3 accurate-diagnosis
|
||||
// goal, since regen serializes on both) ...
|
||||
await expect(release()).rejects.toThrow(/roster\.yaml\.mutation\.lock/);
|
||||
// ... and MUST NOT have unlinked the stranger's live lock.
|
||||
expect(await exists(lockPath)).toBe(true);
|
||||
expect((await stat(lockPath)).ino).toBe(replacement.ino);
|
||||
});
|
||||
|
||||
it('does not strand the lock file when initialization fails after creating it', async (): Promise<void> => {
|
||||
// Codex r4: if writeFile/stat/close/ownership-check throws AFTER the `wx` create
|
||||
// succeeds, the just-created lock must NOT be left behind — a stranded lock would
|
||||
// permanently block future regen AND agent CRUD (both contend on this path). The
|
||||
// cleanup is dev/ino-guarded (never deletes a replacement) and best-effort (never
|
||||
// masks the initialization error).
|
||||
const home = await fleetHome();
|
||||
const lockPath = join(home, 'fleet', 'roster.yaml.mutation.lock');
|
||||
// Inject an opener that performs the REAL `wx` create (so the lock file really
|
||||
// lands on disk) but whose handle faults on the token write — a post-create
|
||||
// initialization failure.
|
||||
const faultingOpen = (async (
|
||||
path: Parameters<typeof open>[0],
|
||||
flags: Parameters<typeof open>[1],
|
||||
mode: Parameters<typeof open>[2],
|
||||
) => {
|
||||
const handle = await open(path, flags, mode);
|
||||
return new Proxy(handle, {
|
||||
get(target, prop, receiver): unknown {
|
||||
if (prop === 'writeFile') {
|
||||
return async (): Promise<never> => {
|
||||
throw new Error('simulated ENOSPC on token write');
|
||||
};
|
||||
}
|
||||
const value = Reflect.get(target, prop, receiver);
|
||||
return typeof value === 'function' ? value.bind(target) : value;
|
||||
},
|
||||
});
|
||||
}) as typeof open;
|
||||
|
||||
await expect(acquirePrivateRosterMutationLock(home, faultingOpen)()).rejects.toThrow(
|
||||
/mutation\.lock/,
|
||||
);
|
||||
// The lock we created must have been cleaned up — not stranded on disk.
|
||||
expect(await exists(lockPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not strand the lock file when the post-create stat itself fails', async (): Promise<void> => {
|
||||
// Codex r5: the narrower sub-path where `handle.stat()` ITSELF rejects right after
|
||||
// the `wx` create succeeds (transient EIO/EBADF). device/inode identity is then
|
||||
// unavailable, so the init-failure cleanup cannot prove ownership by dev/ino — yet
|
||||
// the lock must STILL not be stranded, because a stranded lock permanently blocks
|
||||
// future regen AND agent CRUD. The token we persisted before the failure uniquely
|
||||
// identifies OUR lock, so cleanup falls back to matching it.
|
||||
const home = await fleetHome();
|
||||
const lockPath = join(home, 'fleet', 'roster.yaml.mutation.lock');
|
||||
// Real `wx` create (lock really lands on disk); the token write SUCCEEDS so our
|
||||
// random token is persisted, then `stat()` faults — a post-create init failure.
|
||||
const faultingStatOpen = (async (
|
||||
path: Parameters<typeof open>[0],
|
||||
flags: Parameters<typeof open>[1],
|
||||
mode: Parameters<typeof open>[2],
|
||||
) => {
|
||||
const handle = await open(path, flags, mode);
|
||||
return new Proxy(handle, {
|
||||
get(target, prop, receiver): unknown {
|
||||
if (prop === 'stat') {
|
||||
return async (): Promise<never> => {
|
||||
throw new Error('simulated EIO on post-create stat');
|
||||
};
|
||||
}
|
||||
const value = Reflect.get(target, prop, receiver);
|
||||
return typeof value === 'function' ? value.bind(target) : value;
|
||||
},
|
||||
});
|
||||
}) as typeof open;
|
||||
|
||||
await expect(acquirePrivateRosterMutationLock(home, faultingStatOpen)()).rejects.toThrow(
|
||||
/mutation\.lock/,
|
||||
);
|
||||
// Even without dev/ino, the token-based fallback must have removed our lock.
|
||||
expect(await exists(lockPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('surfaces a lock-cleanup fault when unwinding after a later lock acquire fails', async (): Promise<void> => {
|
||||
// Finding M2: the FIRST lock is taken, the SECOND lock's acquire throws, and
|
||||
// unwinding the first lock's release ALSO faults. The acquire-unwind path must
|
||||
// surface both — the already-taken lock may now be stale and silently block the
|
||||
// next regen/reconcile — not just re-throw the acquire error and drop the fault.
|
||||
const home = await fleetHome();
|
||||
await expect(
|
||||
executeFleetRegen(
|
||||
{
|
||||
runner: recordingRunner([]),
|
||||
mosaicHome: home,
|
||||
// Mutation lock (acquired first) succeeds, but its RELEASE faults on unwind.
|
||||
acquireRosterMutationLock:
|
||||
() => async (): Promise<() => Promise<void>> => async (): Promise<void> => {
|
||||
throw new Error('simulated mutation-lock unlink fault');
|
||||
},
|
||||
// Reconcile lock (acquired second) ACQUIRE fails, triggering the unwind.
|
||||
acquireReconcileLock: () => async (): Promise<() => Promise<void>> => {
|
||||
throw new Error('simulated reconcile acquire failure');
|
||||
},
|
||||
},
|
||||
{ write: true },
|
||||
),
|
||||
).rejects.toThrow(/stale|inspect|lock/i);
|
||||
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(false);
|
||||
});
|
||||
|
||||
it('names BOTH fleet locks in the stale-lock warning (cleanup can come from either)', async (): Promise<void> => {
|
||||
// Finding M3: finding L made the roster MUTATION lock's release fault reachable, so
|
||||
// the cleanup marker can now originate from either lock. The human report must not
|
||||
// claim only the reconcile lock is stale — it must name both candidate lock files
|
||||
// so the operator inspects the right one.
|
||||
const home = await fleetHome();
|
||||
const result = await executeFleetRegen(
|
||||
{
|
||||
runner: recordingRunner([]),
|
||||
mosaicHome: home,
|
||||
// Fault the MUTATION lock's release specifically (reconcile lock is real).
|
||||
acquireRosterMutationLock:
|
||||
() => async (): Promise<() => Promise<void>> => async (): Promise<void> => {
|
||||
throw new Error('simulated mutation-lock unlink fault');
|
||||
},
|
||||
},
|
||||
{ write: true },
|
||||
);
|
||||
|
||||
expect(result.written).toBe(2);
|
||||
expect(result.cleanup).toMatchObject({ code: 'lock-cleanup-failed' });
|
||||
const report = formatFleetRegenReport(result).join('\n');
|
||||
expect(report).toContain('roster.yaml.mutation.lock');
|
||||
expect(report).toContain('roster.yaml.reconcile.lock');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user