import { chmod, mkdir, mkdtemp, rm, 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 CommandRunner } from '../commands/fleet.js'; import { executeFleetReconcile, type FleetReconcileCommandResult, type FleetReconcileDeps, type FleetReconcileResult, } from './fleet-reconciler.js'; import { parseRosterV2, renderRosterV2Yaml, type FleetRosterV2, type FleetRosterV2Agent, } from './roster-v2.js'; import { FleetTmuxRuntimeTransport } from './tmux-runtime-transport.js'; const holderIdentity = '11111111-1111-4111-8111-111111111111'; const stoppedAgent: FleetRosterV2Agent = { name: 'coder0', alias: 'Coder 0', className: 'code', runtime: 'pi', provider: 'openai', model: 'gpt-5.6-sol', reasoning: 'high', toolPolicy: 'code', workingDirectory: '/srv/mosaic', persistentPersona: false, resetBetweenTasks: true, lifecycle: { enabled: true, desiredState: 'stopped' }, launch: { yolo: true }, }; const baseRoster: FleetRosterV2 = { version: 2, generation: 7, transport: 'tmux', tmux: { socketName: 'mosaic-fleet', holderSession: '_holder' }, defaults: { workingDirectory: '/srv/mosaic', runtime: 'pi' }, runtimes: { pi: { resetCommand: '/new' } }, agents: [stoppedAgent], }; interface InjectedLifecycleFailure { readonly action: 'start' | 'stop' | 'restart'; readonly service: string; readonly diagnostic: string; } class FakeLifecycleHost { readonly calls: string[][] = []; readonly sessions = new Set(); readonly activeServices = new Set(); private failure: InjectedLifecycleFailure | undefined; constructor(readonly roster: FleetRosterV2) { this.sessions.add(roster.tmux.holderSession); } injectFailure(failure: InjectedLifecycleFailure): void { this.failure = failure; } readonly run = async ( command: string, args: readonly string[], ): Promise => { this.calls.push([command, ...args]); if (command === 'tmux') return this.runTmux(args); if (command === 'systemctl') return this.runSystemctl(args); return { stdout: '', stderr: 'unsupported fake command', exitCode: 127 }; }; private runTmux(args: readonly string[]): FleetReconcileCommandResult { if (args.includes('list-sessions')) { return { stdout: `${[...this.sessions].join('\n')}\n`, stderr: '', exitCode: 0 }; } if (args.includes('has-session')) { const targetArgument = args[args.indexOf('-t') + 1]; const sessionName = targetArgument?.replace(/^=/, '').split(':')[0]; return { stdout: '', stderr: '', exitCode: sessionName !== undefined && this.sessions.has(sessionName) ? 0 : 1, }; } if (args.includes('show-environment')) { return { stdout: [ 'HOME=/home/mosaic', `MOSAIC_FLEET_OWNER=${holderIdentity}`, `MOSAIC_TMUX_HOLDER=${this.roster.tmux.holderSession}`, `MOSAIC_TMUX_SOCKET=${this.roster.tmux.socketName}`, 'PATH=/usr/bin:/bin', 'PWD=/home/mosaic', '', ].join('\n'), stderr: '', exitCode: 0, }; } return { stdout: '', stderr: 'destructive tmux action rejected by fake', exitCode: 125 }; } private runSystemctl(args: readonly string[]): FleetReconcileCommandResult { const action = args[1]; const service = args[2]; if (action === 'show' && service !== undefined) { return { stdout: `ActiveState=${this.activeServices.has(service) ? 'active' : 'inactive'}\n`, stderr: '', exitCode: 0, }; } if ( (action === 'start' || action === 'stop' || action === 'restart') && service !== undefined ) { this.applyLifecycleEffect(action, service); if (this.failure?.action === action && this.failure.service === service) { const diagnostic = this.failure.diagnostic; this.failure = undefined; return { stdout: '', stderr: diagnostic, exitCode: 1 }; } return { stdout: '', stderr: '', exitCode: 0 }; } return { stdout: '', stderr: 'unsupported fake systemctl action', exitCode: 125 }; } private applyLifecycleEffect(action: 'start' | 'stop' | 'restart', service: string): void { if (service === 'mosaic-tmux-holder.service') return; const match = /^mosaic-agent@(.+)\.service$/.exec(service); if (!match) return; const agentName = match[1]; if (agentName === undefined) return; if (action === 'stop') { this.activeServices.delete(service); this.sessions.delete(agentName); return; } this.activeServices.add(service); this.sessions.add(agentName); } } const cleanupDirectories: string[] = []; afterEach(async (): Promise => { vi.restoreAllMocks(); process.exitCode = undefined; await Promise.all( cleanupDirectories.splice(0).map(async (directory: string): Promise => { await rm(directory, { recursive: true, force: true }); }), ); }); function reconcileDeps(host: FakeLifecycleHost): FleetReconcileDeps { return { runner: host.run, homeDirectory: '/home/mosaic', readHolderIdentity: async () => holderIdentity, validateRoster: async () => undefined, prepareProjections: async () => [{ agentName: 'coder0' }], applyProjection: async () => undefined, readRoster: async () => host.roster, acquireMutationLock: async () => async () => undefined, }; } async function execute( host: FakeLifecycleHost, command: 'apply' | 'reconcile' | 'restart' | 'status' | 'stop', agentName?: string, ): Promise { return executeFleetReconcile({ roster: host.roster, command, ...(agentName === undefined ? {} : { agentName }), ...(command === 'status' ? {} : { expectedGeneration: host.roster.generation }), deps: reconcileDeps(host), }); } async function fixtureHome(roster: FleetRosterV2): Promise { const home = await mkdtemp(join(tmpdir(), 'mosaic-reconciler-acceptance-')); cleanupDirectories.push(home); const fleetDirectory = join(home, 'fleet'); await mkdir(fleetDirectory, { mode: 0o700 }); await chmod(home, 0o700); await chmod(fleetDirectory, 0o700); await writeFile(join(fleetDirectory, 'roster.yaml'), renderRosterV2Yaml(roster), { mode: 0o600 }); return home; } async function fixtureLegacyRoster(): Promise { const home = await mkdtemp(join(tmpdir(), 'mosaic-reconciler-acceptance-v1-')); cleanupDirectories.push(home); const fleetDirectory = join(home, 'fleet'); await mkdir(fleetDirectory, { mode: 0o700 }); await chmod(home, 0o700); await chmod(fleetDirectory, 0o700); const rosterPath = join(fleetDirectory, 'roster.yaml'); await writeFile( rosterPath, [ 'version: 1', 'transport: tmux', 'tmux:', ' holder_session: _holder', 'agents:', ' - name: coder0', ' runtime: pi', ' class: code', '', ].join('\n'), { mode: 0o600 }, ); return rosterPath; } function cliProgram(home: string, host: FakeLifecycleHost): Command { const program = new Command(); program.exitOverride(); registerFleetCommand(program, { mosaicHome: home, runner: async (command: string, args: string[]): Promise => host.run(command, args), reconcileDeps: reconcileDeps(host), }); return program; } function captureJson(): string[] { const output: string[] = []; vi.spyOn(console, 'log').mockImplementation((line: string): void => { output.push(line); }); return output; } describe('FCM-M3-002 reconciler lifecycle acceptance', (): void => { it('observes named-socket drift through canonical roster-v2 parsing without runtime mutation', async (): Promise => { const roster: FleetRosterV2 = { ...baseRoster, agents: [ stoppedAgent, { ...stoppedAgent, name: 'reviewer0', alias: 'Reviewer 0', lifecycle: { enabled: true, desiredState: 'running' }, }, { ...stoppedAgent, name: 'validator0', alias: 'Validator 0', lifecycle: { enabled: false, desiredState: 'stopped' }, }, ], }; const home = await fixtureHome(roster); const host = new FakeLifecycleHost(roster); host.sessions.add('coder0'); host.sessions.add('validator0'); host.sessions.add('coder0-shadow'); const output = captureJson(); await cliProgram(home, host).parseAsync(['node', 'mosaic', 'fleet', 'status']); expect(output).toHaveLength(1); expect(JSON.parse(output[0] ?? '{}')).toMatchObject({ plan: { agents: [ { name: 'coder0', drift: ['unexpected-session'] }, { name: 'reviewer0', drift: ['missing-session'] }, { name: 'validator0', drift: ['unexpected-session', 'disabled-running'] }, ], unmanagedSessions: ['coder0-shadow'], }, }); expect(host.calls[0]).toEqual([ 'tmux', '-L', 'mosaic-fleet', 'list-sessions', '-F', '#{session_name}', ]); expect( host.calls.every( (call: string[]): boolean => call[0] !== 'tmux' || (call[1] === '-L' && call[2] === 'mosaic-fleet'), ), ).toBe(true); expect( host.calls.every((call: string[]): boolean => call[0] !== 'systemctl' || call[2] === 'show'), ).toBe(true); expect(process.exitCode).toBe(0); }); it('rejects a missing canonical roster-v2 tmux socket through parseRosterV2', (): void => { const source = renderRosterV2Yaml(baseRoster).replace(/^ socket_name:.*\n/m, ''); expect(() => parseRosterV2(source, 'yaml')).toThrow( 'Roster v2 tmux socket_name is required and must be a string.', ); }); it('accepts an explicit empty canonical roster-v2 socket as the literal default server', (): void => { const source = renderRosterV2Yaml(baseRoster).replace( /^ socket_name:.*$/m, ' socket_name: ""', ); expect(parseRosterV2(source, 'yaml').tmux.socketName).toBe(''); }); it('omits -L for the literal default tmux server at the runtime transport boundary', async (): Promise => { const rosterPath = await fixtureLegacyRoster(); const runner = vi.fn( async (): Promise => ({ stdout: '111 pi 0 0 0 0\n', stderr: '', exitCode: 0, }), ); const transport = new FleetTmuxRuntimeTransport({ mosaicHome: '/unused', rosterPath, runner, }); await expect(transport.verifySession('coder0')).resolves.toEqual({ id: 'coder0', runtimeId: 'pi', socketName: '', }); expect(runner).toHaveBeenCalledTimes(1); expect(runner).toHaveBeenCalledWith('tmux', [ 'list-panes', '-t', '=coder0:0.0', '-F', '#{pane_pid} #{pane_current_command} #{pane_dead} #{pane_activity} #{window_activity} #{session_activity}', ]); expect(runner.mock.calls[0]?.[1]).not.toContain('-L'); }); it('classifies unmanaged near-collisions and stops only the exact roster-owned service', async (): Promise => { const host = new FakeLifecycleHost(baseRoster); host.sessions.add('coder0'); host.sessions.add('coder0-shadow'); host.sessions.add('unmanaged'); host.activeServices.add('mosaic-agent@coder0.service'); host.activeServices.add('mosaic-agent@coder0-shadow.service'); const observed = await execute(host, 'status'); const stopped = await execute(host, 'stop', 'coder0'); expect(observed.plan.unmanagedSessions).toEqual(['coder0-shadow', 'unmanaged']); expect(stopped).toMatchObject({ applied: true, lifecycle: 'complete' }); expect(host.activeServices.has('mosaic-agent@coder0.service')).toBe(false); expect(host.activeServices.has('mosaic-agent@coder0-shadow.service')).toBe(true); expect(host.sessions.has('coder0-shadow')).toBe(true); expect(host.sessions.has('unmanaged')).toBe(true); expect(host.calls).toContainEqual([ 'systemctl', '--user', 'stop', 'mosaic-agent@coder0.service', ]); expect( host.calls.some( (call: string[]): boolean => call.includes('kill-session') || call.includes('coder0-shadow.service') || call.includes('unmanaged.service'), ), ).toBe(false); }); it('preserves persisted stopped state through apply, reconcile, restart failure, and recovery reconcile', async (): Promise => { const host = new FakeLifecycleHost(baseRoster); host.sessions.add('coder0'); host.activeServices.add('mosaic-agent@coder0.service'); const applied = await execute(host, 'apply'); const reconciled = await execute(host, 'reconcile'); host.injectFailure({ action: 'restart', service: 'mosaic-agent@coder0.service', diagnostic: 'crash after effect: TOKEN=acceptance-secret', }); const partialRestart = await execute(host, 'restart', 'coder0'); expect(applied).toMatchObject({ applied: true, lifecycle: 'complete' }); expect(reconciled).toMatchObject({ applied: true, lifecycle: 'complete' }); expect(host.roster.agents[0]?.lifecycle.desiredState).toBe('stopped'); expect(partialRestart).toMatchObject({ applied: false, authoritativeRoster: 'unchanged', projections: 'not-applied', lifecycle: 'incomplete', recovery: { code: 'lifecycle-apply-failed', action: 'rerun-after-inspecting-owned-resources', }, }); expect(host.activeServices.has('mosaic-agent@coder0.service')).toBe(true); const recovered = await execute(host, 'reconcile'); expect(recovered).toMatchObject({ applied: true, lifecycle: 'complete' }); expect(host.roster.agents[0]?.lifecycle.desiredState).toBe('stopped'); expect(host.activeServices.has('mosaic-agent@coder0.service')).toBe(false); expect(host.sessions.has('coder0')).toBe(false); const destructiveCalls = host.calls.filter( (call: string[]): boolean => call[0] === 'systemctl' && ['start', 'stop', 'restart'].includes(call[2] ?? ''), ); expect( destructiveCalls.every( (call: string[]): boolean => call[3] === 'mosaic-agent@coder0.service', ), ).toBe(true); expect(destructiveCalls.some((call: string[]): boolean => call[2] === 'start')).toBe(false); }); it('emits stable non-zero redacted JSON for a partial lifecycle effect', async (): Promise => { const home = await fixtureHome(baseRoster); const host = new FakeLifecycleHost(baseRoster); host.injectFailure({ action: 'restart', service: 'mosaic-agent@coder0.service', diagnostic: 'simulated runner stderr with PASSWORD=acceptance-secret', }); const output = captureJson(); await cliProgram(home, host).parseAsync([ 'node', 'mosaic', 'fleet', 'restart', 'coder0', '--expected-generation', '7', ]); const line = output.at(-1) ?? ''; expect(output).toHaveLength(1); expect(JSON.parse(line)).toEqual({ applied: false, authoritativeRoster: 'unchanged', projections: 'not-applied', lifecycle: 'incomplete', plan: { generation: 7, holder: 'owned', agents: [ { name: 'coder0', desiredState: 'stopped', enabled: true, systemd: 'inactive', tmux: 'missing', drift: [], }, ], unmanagedSessions: [], }, recovery: { code: 'lifecycle-apply-failed', action: 'rerun-after-inspecting-owned-resources', }, }); expect(process.exitCode).toBe(1); expect(line).not.toContain('PASSWORD'); expect(line).not.toContain('acceptance-secret'); expect(line).not.toContain('simulated runner stderr'); }); });