diff --git a/packages/mosaic/src/commands/fleet-roster-v2-dispatch.spec.ts b/packages/mosaic/src/commands/fleet-roster-v2-dispatch.spec.ts index 12d039a0..d34938a9 100644 --- a/packages/mosaic/src/commands/fleet-roster-v2-dispatch.spec.ts +++ b/packages/mosaic/src/commands/fleet-roster-v2-dispatch.spec.ts @@ -244,12 +244,115 @@ describe('mosaic-agent@.service', (): void => { * absent env — either of which turns the condition into decoration while the * literal-string assertion above still passes. */ + /** + * The condition an installed unit actually presents to systemd: the shipped + * unit's values with drop-ins applied in lexical order, honouring the list + * reset an empty assignment performs. + * + * Reading the *installed* tree rather than the template is the point. The + * template alone cannot show what `install` produced, and #1310 lived in that + * gap: the template was correct for the install it was written for, and wrong + * for the one the operator had. + */ + async function effectiveConditionPaths(systemdUserDir: string): Promise { + const dropInDir = join(systemdUserDir, 'mosaic-agent@.service.d'); + const dropIns = ( + await readdir(dropInDir).catch((error: NodeJS.ErrnoException): string[] => { + if (error.code === 'ENOENT') return []; + throw error; + }) + ) + .filter((name): boolean => name.endsWith('.conf')) + .sort(); + const sources = [ + join(systemdUserDir, 'mosaic-agent@.service'), + ...dropIns.map((name): string => join(dropInDir, name)), + ]; + const conditions: string[] = []; + for (const source of sources) { + for (const line of (await readFile(source, 'utf8')).split('\n')) { + const match = line.match(/^ConditionPathExists=(.*)$/); + if (!match) continue; + const value = match[1]!.trim(); + if (value === '') conditions.length = 0; + else conditions.push(value); + } + } + return conditions; + } + + /** Makes `~/.mosaic` adoptable — the shape that puts a real box in split mode. */ + async function adoptableBrainHome(): Promise { + const brainHome = join(tempHome!, '.mosaic'); + await mkdir(join(brainHome, 'fleet', 'agents'), { recursive: true, mode: 0o700 }); + return brainHome; + } + it('guards exactly the file the fleet writes, so the two cannot drift apart', async (): Promise => { const mosaicHome = await v2Home(); - const rendered = (await conditionPath()).replace('%h', tempHome!).replace('%i', 'coder0'); + const systemdUserDir = join(tempHome!, '.config', 'systemd', 'user'); + + await program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']); + + const rendered = (await effectiveConditionPaths(systemdUserDir)).map((value): string => + value.replace('%h', tempHome!).replace('%i', 'coder0'), + ); // The path an installed fleet actually places for this agent. - expect(rendered).toBe(join(mosaicHome, 'fleet', 'agents', 'coder0.env.generated')); + expect(rendered).toEqual([join(mosaicHome, 'fleet', 'agents', 'coder0.env.generated')]); + }); + + /** + * #1310. The test above passed throughout the bug, because its fixture never + * creates `~/.mosaic/fleet/agents` and so can only ever reach legacy mode, + * where brain home and config home are the same directory and the drift it + * names is unobservable. A guard that cannot reach the state it guards is + * decoration; this is the arm that reaches it. + * + * Measured consequence on a real box (mosaic-sbx-dev 1124, 0.0.50-next.2507): + * the config-home condition can never be met, systemd reports every seat + * `ConditionResult=no` with `Result=success` and `ExecMainStatus=0`, and + * `fleet start` exits 0 having started nothing. + */ + it('follows the brain home when one is active, not the config home', async (): Promise => { + const mosaicHome = await v2Home(); + const brainHome = await adoptableBrainHome(); + const systemdUserDir = join(tempHome!, '.config', 'systemd', 'user'); + + await program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']); + + const rendered = (await effectiveConditionPaths(systemdUserDir)).map((value): string => + value.replace('%h', tempHome!).replace('%i', 'coder0'), + ); + + // Exactly one condition: the drop-in must reset the shipped value, not join + // it. Two conditions would both have to pass and the seat would stay skipped. + expect(rendered).toEqual([join(brainHome, 'fleet', 'agents', 'coder0.env.generated')]); + expect(rendered[0]).not.toContain(mosaicHome); + }); + + /** + * The other direction, which fails the same silent way: a box that had a + * brain home and no longer does would keep a condition pointing at a path the + * reconciler has stopped writing. + */ + it('drops a stale brain-home override when the brain home goes away', async (): Promise => { + const mosaicHome = await v2Home(); + const systemdUserDir = join(tempHome!, '.config', 'systemd', 'user'); + await adoptableBrainHome(); + await program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']); + // Control: the override is present before the brain home is removed, so a + // passing assertion below cannot come from it never having been written. + expect(await effectiveConditionPaths(systemdUserDir)).toHaveLength(1); + expect((await effectiveConditionPaths(systemdUserDir))[0]).toContain('.mosaic/fleet/agents'); + + await rm(join(tempHome!, '.mosaic'), { recursive: true, force: true }); + await program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']); + + const rendered = (await effectiveConditionPaths(systemdUserDir)).map((value): string => + value.replace('%h', tempHome!).replace('%i', 'coder0'), + ); + expect(rendered).toEqual([join(mosaicHome, 'fleet', 'agents', 'coder0.env.generated')]); }); it('guards a real failure — the launcher rejects an absent generated env', async (): Promise => { diff --git a/packages/mosaic/src/commands/fleet.ts b/packages/mosaic/src/commands/fleet.ts index 2bf0bab8..5e1309f4 100644 --- a/packages/mosaic/src/commands/fleet.ts +++ b/packages/mosaic/src/commands/fleet.ts @@ -13,7 +13,7 @@ import { import { randomUUID } from 'node:crypto'; import { homedir, hostname, userInfo } from 'node:os'; import { dirname, join, resolve } from 'node:path'; -import { fleetAgentEnvDir } from '../fleet/brain-home.js'; +import { brainHomeIsActive, fleetAgentEnvDir } from '../fleet/brain-home.js'; import { fileURLToPath } from 'node:url'; import { spawn } from 'node:child_process'; import * as readline from 'node:readline'; @@ -2350,6 +2350,80 @@ export function registerFleetAgentCommands( }); } +/** Directory holding the install-managed drop-ins for `mosaic-agent@.service`. */ +export function agentUnitDropInDir(systemdUserDir: string): string { + return join(systemdUserDir, 'mosaic-agent@.service.d'); +} + +/** The one drop-in `mosaic fleet install` owns: the brain-home env condition. */ +export function agentUnitBrainHomeDropInPath(systemdUserDir: string): string { + return join(agentUnitDropInDir(systemdUserDir), '10-brain-home.conf'); +} + +/** + * Drop-in re-pointing the agent unit's env condition at the resolved brain home. + * + * The shipped unit gates on `%h/.config/mosaic/fleet/agents/%i.env.generated`, + * which is right for a legacy single-tree install. When a brain home is active + * (canon `docs/STRUCTURE-CANON.md` §2) the reconciler writes seat env under the + * brain home instead, so that condition can never be met — and an unmet + * condition is reported as success (`ConditionResult=no`, `Result=success`, + * `ExecMainStatus=0`). `fleet start` therefore returns 0, prints nothing, and + * starts no seat, with every status surface agreeing the fleet is healthy + * (#1310, measured on mosaic-sbx-dev 1124 against 0.0.50-next.2507). + * + * An empty `ConditionPathExists=` resets the inherited list before the correct + * path is added, so the shipped condition is replaced rather than joined — two + * conditions would both have to pass and the seat would stay skipped. + * + * The boot-window behaviour the shipped unit documents is deliberately kept: + * this still gates on the generated env existing, only at the path that + * actually receives it. The launcher already resolves the brain home itself, + * so nothing downstream of the condition needs to change. + */ +export function renderAgentUnitBrainHomeDropIn(agentEnvDir: string): string { + return [ + '# Managed by `mosaic fleet install`. Do not edit; re-run install to update.', + '#', + '# Re-points the seat env condition at the active brain home. Without this the', + '# shipped config-home condition can never be met on a split install and every', + '# seat is silently skipped while `fleet start` reports success (#1310).', + '[Unit]', + 'ConditionPathExists=', + `ConditionPathExists=${join(agentEnvDir, '%i.env.generated')}`, + '', + ].join('\n'); +} + +/** + * Write the brain-home drop-in when a brain is active, and remove a stale one + * when it is not. + * + * The removal arm is load-bearing, not tidiness: a host that had a brain home + * and no longer does would otherwise keep a condition pointing at a path the + * reconciler has stopped writing, which fails in exactly the same silent + * direction as the bug this fixes. + */ +export async function syncAgentUnitBrainHomeDropIn( + systemdUserDir: string, + mosaicHome: string, +): Promise { + const dropInPath = agentUnitBrainHomeDropInPath(systemdUserDir); + if (!brainHomeIsActive(mosaicHome)) { + try { + await unlink(dropInPath); + } catch (error) { + // Absent is the expected state on a legacy install; anything else is real. + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + return; + } + await mkdir(agentUnitDropInDir(systemdUserDir), { recursive: true }); + await writeFile(dropInPath, renderAgentUnitBrainHomeDropIn(fleetAgentEnvDir(mosaicHome)), 'utf8'); +} + async function installFleet(cmd: Command, frameworkRoot: string): Promise { const activePaths = resolveFleetPaths(cmd.opts<{ mosaicHome: string }>().mosaicHome); assertDefaultMosaicHomeForSystemd(activePaths.mosaicHome); @@ -2410,6 +2484,9 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise join(frameworkRoot, 'systemd', 'user', 'mosaic-agent@.service'), join(activePaths.systemdUserDir, 'mosaic-agent@.service'), ); + // Must follow the copy: the drop-in overrides the unit just written, and on a + // split install the unit alone gates every seat on a path nothing writes. + await syncAgentUnitBrainHomeDropIn(activePaths.systemdUserDir, activePaths.mosaicHome); await copyFile( join(frameworkRoot, 'systemd', 'user', 'mosaic-interaction-agent@.service'), join(activePaths.systemdUserDir, 'mosaic-interaction-agent@.service'),