From 58af3deb475b54cc4d606bd6b49a8e300b7b3206 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Wed, 15 Jul 2026 11:34:54 -0500 Subject: [PATCH] test(fleet): correct socket boundary coverage Co-Authored-By: Claude Opus 4.8 --- docs/TASKS.md | 2 +- ...8-fcm-m3-002-reconciler-lifecycle-gates.md | 15 +- .../fleet/fleet-reconciler.acceptance.spec.ts | 202 +++++++++++++----- 3 files changed, 154 insertions(+), 65 deletions(-) diff --git a/docs/TASKS.md b/docs/TASKS.md index 31343fe..5d8ebcc 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -61,7 +61,7 @@ Active workstream is **W1 — Federation v1**. Workers should: | FCM-M2-001 | done | Migrate generic launch chain to deterministic `.env.generated` plus strict data-only `.env.local`; quarantine forbidden legacy keys | #758 | codex | mosaicstack/stack | `feat/758-generated-env-boundary` | FCM-M1-001, FCM-M1-002 | 30K | #772 squash `191efae`; generated/local boundary and private quarantine delivered | | FCM-M2-002 | done | Add generation-guarded local fleet agent create/get/update/delete mutations with plan/dry-run, atomic roster writes, and recovery output | #758 | codex | mosaicstack/stack | `feat/758-fleet-agent-crud` | FCM-M1-001, FCM-M2-001 | 30K | #773 squash `bc5e736`; generation-guarded atomic CRUD and recovery contracts delivered | | FCM-M3-001 | done | Implement local roster-owned reconcile/apply plus lifecycle/status/verify/doctor contracts and stable JSON/exit codes | #758 | codex | mosaicstack/stack | `feat/758-local-reconciler` | FCM-M2-001, FCM-M2-002 | 35K | #785 squash `4990905`; exact roster-owned systemd/tmux reconcile and lifecycle contracts delivered | -| FCM-M3-002 | in-progress | Add isolated systemd/tmux lifecycle, drift, socket, unmanaged-session, crash, and rollback acceptance coverage | #758 | sonnet | mosaicstack/stack | `test/758-reconciler-lifecycle-gates` | FCM-M3-001 | 25K | Started from `4990905`; fake adapters/temp fixtures only; no live lifecycle actions | +| FCM-M3-002 | in-progress | Add isolated systemd/tmux lifecycle, drift, socket, unmanaged-session, crash, and rollback acceptance coverage | #758 | sonnet | mosaicstack/stack | `test/758-reconciler-lifecycle-gates` | FCM-M3-001 | 25K | Canonical v2 named-socket + legacy-v1 default-server boundaries; fake adapters/temp fixtures only | | FCM-M4-001 | not-started | Implement field-complete v1-to-v2 inventory/preview/migrator with alias, lifecycle, env-quarantine, and remote/connector disposition evidence | #758 | codex | mosaicstack/stack | `feat/758-v1-v2-migrator` | FCM-M1-003, FCM-M3-001 | 35K | Preview first; no unreviewed lifecycle inference | | FCM-M4-002 | not-started | Add reversible canary migration, rollback, stale-projection/orphan classification, and current-host 9-managed/3-unmanaged fixture coverage | #758 | sonnet | mosaicstack/stack | `test/758-migration-rollback-gates` | FCM-M4-001, FCM-M3-002 | 25K | Never starts a previously stopped agent or kills an unproven unmanaged session | | FCM-M5-001 | not-started | Deliver the accepted fleet documentation IA, how-to/operations/migration references, and link/example validation | #758 | haiku | mosaicstack/stack | `docs/758-fleet-config-operator-docs` | FCM-M1-003, FCM-M2-002, FCM-M3-001, FCM-M4-001 | 24K | Must close every checklist item or record an approved deferral | diff --git a/docs/scratchpads/758-fcm-m3-002-reconciler-lifecycle-gates.md b/docs/scratchpads/758-fcm-m3-002-reconciler-lifecycle-gates.md index 972c6c7..51d0bc1 100644 --- a/docs/scratchpads/758-fcm-m3-002-reconciler-lifecycle-gates.md +++ b/docs/scratchpads/758-fcm-m3-002-reconciler-lifecycle-gates.md @@ -24,8 +24,8 @@ Add broad, behavior-oriented acceptance evidence around the shipped local reconc | FCM-M3-002 acceptance concern | Delivered isolated evidence | | --- | --- | | Systemd/tmux lifecycle | `fleet-reconciler.acceptance.spec.ts` drives apply, reconcile, stop, restart, status, and recovery reconcile through one stateful injected fake host. The fake models exact systemd effects and tmux session observations; no host commands run. | -| Drift | Named/default socket matrix classifies `missing-session`, `unexpected-session`, and `disabled-running`, including combined drift, while asserting observation emits no lifecycle mutation. | -| Exact default/named socket targeting | Matrix asserts named calls use exactly `-L mosaic-fleet` and default-socket calls contain no `-L`; recorded calls cannot target an adjacent socket. | +| Drift | Canonical roster-v2 YAML drives the Commander `fleet status` boundary and classifies `missing-session`, `unexpected-session`, and `disabled-running`, including combined drift, while asserting observation emits no lifecycle mutation. | +| Exact default/named socket targeting | Canonical v2 requires an explicit non-empty named socket; the parser rejects missing/empty values and the Commander acceptance path asserts exact `-L mosaic-fleet` targeting. A separate canonical legacy-v1 roster loader plus runtime-transport path proves a socket-less compatibility roster targets the literal tmux default server with no `-L`. No unreachable empty-socket v2 fixture is used. | | Unmanaged-session classification | Stateful fixtures report sorted `coder0-shadow`/`unmanaged` sessions, then prove an exact roster stop leaves both sessions and the near-collision service intact. | | Crash/partial failure | Injected restart failure is applied after the fake effect to model crash/partial truth: result is `lifecycle: incomplete`, the roster is unchanged, and observed runtime may be active. | | Rollback/recovery semantics | M3 has no rollback command and explicitly does not claim automatic rollback. The acceptance workflow proves the bounded recovery contract: inspect, then exact reconcile restores the persisted stopped target without a start or fuzzy effect. M4 migration/canary rollback remains outside this card. | @@ -51,7 +51,7 @@ This card adds acceptance coverage to already-delivered behavior. Test-first app - Intake and immutable baseline verification complete. - Existing coverage inventory confirmed strong unit coverage but no stateful cross-command lifecycle acceptance harness. -- Added `packages/mosaic/src/fleet/fleet-reconciler.acceptance.spec.ts`: one injected stateful fake systemd/tmux host, temporary canonical roster fixture, five acceptance tests (six matrix cases). +- Added `packages/mosaic/src/fleet/fleet-reconciler.acceptance.spec.ts`: one injected stateful fake systemd/tmux host, temporary canonical v2 and legacy-v1 roster fixtures, and seven acceptance tests. - Production source is unchanged; no product defect requiring an FCM-M3-002 fix was found. - `docs/TASKS.md` reconciles only merged M1/M2/M3-001 truth and marks FCM-M3-002 in progress. @@ -59,17 +59,18 @@ This card adds acceptance coverage to already-delivered behavior. Test-first app All commands ran from `/home/jarvis/src/mosaic-stack-local-reconciler` and passed unless explicitly noted. -- `pnpm --filter @mosaicstack/mosaic exec vitest run src/fleet/fleet-reconciler.acceptance.spec.ts` — 1 file, 5 tests passed (the socket matrix executes six acceptance cases). -- `pnpm --filter @mosaicstack/mosaic exec vitest run src/fleet/fleet-reconciler.acceptance.spec.ts src/fleet/fleet-reconciler.spec.ts src/commands/fleet-reconciler-command.spec.ts src/commands/fleet-agent-crud-command.spec.ts src/fleet/fleet-agent-crud.spec.ts src/commands/fleet.spec.ts` — 6 files, 256 tests passed. -- `pnpm --filter @mosaicstack/mosaic test` — final run: 57 files, 825 tests passed. +- `pnpm --filter @mosaicstack/mosaic exec vitest run src/fleet/fleet-reconciler.acceptance.spec.ts` — final remediation run: 1 file, 7 tests passed; canonical v2 named-socket parsing/Commander status, missing/empty v2 rejection, and canonical legacy-v1 default-server runtime targeting are distinct reachable cases. +- Focused reconciler/roster/transport command covering acceptance, reconciler, command, CRUD, v2 parser, and runtime transport specs — 8 files, 304 tests passed. +- `pnpm --filter @mosaicstack/mosaic test` — final remediation run: 57 files, 827 tests passed. - `pnpm --filter @mosaicstack/mosaic typecheck` — passed. - `pnpm --filter @mosaicstack/mosaic lint` — passed. - `pnpm typecheck` — 42/42 Turbo tasks successful. - `pnpm lint` — 23/23 Turbo tasks successful. +- `pnpm exec prettier --check docs/TASKS.md docs/scratchpads/758-fcm-m3-002-reconciler-lifecycle-gates.md packages/mosaic/src/fleet/fleet-reconciler.acceptance.spec.ts` — passed. - `pnpm format:check` — all matched files use Prettier style. - `git diff --check` — passed with no output. - Initial scoped Prettier check found style drift in the new spec and tracking table; `pnpm exec prettier --write ...` remediated it before all final gates above. -- No live fleet, systemctl, tmux, process, site, migration, canary, deploy, runtime, connector, or remote command was invoked. The sole tmux action after documentation/final evidence is the user-required completion `wait-for` signal. +- No live fleet, systemctl, tmux, process, site, migration, canary, deploy, runtime, connector, or remote command was invoked. ## Review boundary diff --git a/packages/mosaic/src/fleet/fleet-reconciler.acceptance.spec.ts b/packages/mosaic/src/fleet/fleet-reconciler.acceptance.spec.ts index c046583..998f599 100644 --- a/packages/mosaic/src/fleet/fleet-reconciler.acceptance.spec.ts +++ b/packages/mosaic/src/fleet/fleet-reconciler.acceptance.spec.ts @@ -3,14 +3,20 @@ 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 } from '../commands/fleet.js'; +import { registerFleetCommand, type CommandResult, type CommandRunner } from '../commands/fleet.js'; import { executeFleetReconcile, type FleetReconcileCommandResult, type FleetReconcileDeps, type FleetReconcileResult, } from './fleet-reconciler.js'; -import { renderRosterV2Yaml, type FleetRosterV2, type FleetRosterV2Agent } from './roster-v2.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 = { @@ -73,6 +79,15 @@ class FakeLifecycleHost { 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: [ @@ -182,6 +197,32 @@ async function fixtureHome(roster: FleetRosterV2): Promise { 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(); @@ -203,67 +244,114 @@ function captureJson(): string[] { } describe('FCM-M3-002 reconciler lifecycle acceptance', (): void => { - it.each([ - { socketName: 'mosaic-fleet', socketArgs: ['-L', 'mosaic-fleet'] }, - { socketName: '', socketArgs: [] }, - ])( - 'observes drift on the exact "$socketName" socket without runtime mutation', - async ({ socketName, socketArgs }): Promise => { - const roster: FleetRosterV2 = { - ...baseRoster, - tmux: { ...baseRoster.tmux, socketName }, + 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: [ - stoppedAgent, - { - ...stoppedAgent, - name: 'reviewer0', - alias: 'Reviewer 0', - lifecycle: { enabled: true, desiredState: 'running' }, - }, - { - ...stoppedAgent, - name: 'validator0', - alias: 'Validator 0', - lifecycle: { enabled: false, desiredState: 'stopped' }, - }, + { name: 'coder0', drift: ['unexpected-session'] }, + { name: 'reviewer0', drift: ['missing-session'] }, + { name: 'validator0', drift: ['unexpected-session', 'disabled-running'] }, ], - }; - const host = new FakeLifecycleHost(roster); - host.sessions.add('coder0'); - host.sessions.add('validator0'); - host.sessions.add('coder0-shadow'); + 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); + }); - const result = await execute(host, 'status'); - - expect(result.plan.agents).toMatchObject([ - { name: 'coder0', drift: ['unexpected-session'] }, - { name: 'reviewer0', drift: ['missing-session'] }, - { name: 'validator0', drift: ['unexpected-session', 'disabled-running'] }, - ]); - expect(result.plan.unmanagedSessions).toEqual(['coder0-shadow']); - expect(host.calls[0]).toEqual([ - 'tmux', - ...socketArgs, - 'list-sessions', - '-F', - '#{session_name}', - ]); - expect( - host.calls.every((call: string[]): boolean => { - if (call[0] !== 'tmux') return true; - return socketArgs.length === 0 - ? !call.includes('-L') - : call[1] === '-L' && call[2] === socketName; - }), - ).toBe(true); - expect( - host.calls.every( - (call: string[]): boolean => call[0] !== 'systemctl' || call[2] === 'show', - ), - ).toBe(true); + it.each([ + { + label: 'missing', + source: renderRosterV2Yaml(baseRoster).replace(/^ socket_name:.*\n/m, ''), + }, + { + label: 'empty', + source: renderRosterV2Yaml(baseRoster).replace(/^ socket_name:.*$/m, ' socket_name: ""'), + }, + ])( + 'rejects a $label canonical roster-v2 tmux socket through parseRosterV2', + ({ source }): void => { + expect(() => parseRosterV2(source, 'yaml')).toThrow( + 'Roster v2 tmux socket_name is required and must be a non-empty string.', + ); }, ); + it('uses the literal default tmux server only through the legacy-v1 loader and 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');