From 812e2df1da4326786d5c363136b9494bd386668c Mon Sep 17 00:00:00 2001 From: veronica Date: Tue, 25 Aug 2026 01:17:15 +0000 Subject: [PATCH] fix(#1408): mosaic-agent@ condition arms on either home shape (#1410) Co-authored-by: veronica --- .../systemd/user/mosaic-agent@.service | 12 ++++- .../systemd/user/test-fleet-units.sh | 11 ++++ .../commands/fleet-roster-v2-dispatch.spec.ts | 54 ++++++++++++++++--- 3 files changed, 68 insertions(+), 9 deletions(-) diff --git a/packages/mosaic/framework/systemd/user/mosaic-agent@.service b/packages/mosaic/framework/systemd/user/mosaic-agent@.service index 81f76b54..36db469b 100644 --- a/packages/mosaic/framework/systemd/user/mosaic-agent@.service +++ b/packages/mosaic/framework/systemd/user/mosaic-agent@.service @@ -11,7 +11,17 @@ PartOf=mosaic-tmux-holder.service # launcher would fail the unit. A skipped unit is the honest state for "enabled # but not yet configured"; systemd re-evaluates the condition on every start, so # the seat comes up on the next start once the reconciler has written env. -ConditionPathExists=%h/.config/mosaic/fleet/agents/%i.env.generated +# +# #1408: the reconciler writes projections into the BRAIN home when one is +# active (~/.mosaic/fleet/agents, mirroring start-agent-session.sh's brain-home +# resolution), and into MOSAIC_HOME on a legacy single-tree host. A single +# config-home condition therefore skipped every seat on brain-home estates — +# measured on two estates: 27 projections vs 0, and 5 vs 0, gate never fired. +# Two TRIGGERING conditions (the `|` prefix ORs same-type conditions, which +# otherwise AND): either shape arms the unit; the launcher still resolves the +# authoritative copy itself. +ConditionPathExists=|%h/.config/mosaic/fleet/agents/%i.env.generated +ConditionPathExists=|%h/.mosaic/fleet/agents/%i.env.generated [Service] Type=oneshot diff --git a/packages/mosaic/framework/systemd/user/test-fleet-units.sh b/packages/mosaic/framework/systemd/user/test-fleet-units.sh index 4edc1989..4f7ff5ab 100755 --- a/packages/mosaic/framework/systemd/user/test-fleet-units.sh +++ b/packages/mosaic/framework/systemd/user/test-fleet-units.sh @@ -32,6 +32,17 @@ if grep -qF -- '/bin/bash -lc' "$HOLDER"; then fail "holder must not start tmux through a login shell" fi grep -qF 'Requires=mosaic-tmux-holder.service' "$AGENT" || fail "agent does not require holder" +# #1408: the projection condition must arm on EITHER home shape. Both lines must +# carry the `|` triggering prefix — same-type conditions without it AND together, +# which can never be true (one file cannot exist at two paths), so a bare-spelling +# regression would disable autostart everywhere while reading as "has a condition". +grep -qF 'ConditionPathExists=|%h/.config/mosaic/fleet/agents/%i.env.generated' "$AGENT" || \ + fail "agent lacks triggering condition for the config home projection" +grep -qF 'ConditionPathExists=|%h/.mosaic/fleet/agents/%i.env.generated' "$AGENT" || \ + fail "agent lacks triggering condition for the brain home projection (#1408)" +if grep -qE '^ConditionPathExists=[^|]' "$AGENT"; then + fail "agent has a non-triggering ConditionPathExists — same-type conditions AND, re-arming #1408" +fi grep -qF 'start-agent-session.sh' "$AGENT" || fail "agent unit does not call start-agent-session.sh" if grep -qE '^Environment(File)?=' "$AGENT" "$INTERACTION"; then fail "agent units must not accept ambient or projection environment before strict parsing" 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..2d073ec4 100644 --- a/packages/mosaic/src/commands/fleet-roster-v2-dispatch.spec.ts +++ b/packages/mosaic/src/commands/fleet-roster-v2-dispatch.spec.ts @@ -211,12 +211,23 @@ describe('mosaic fleet install — roster v2', (): void => { describe('mosaic-agent@.service', (): void => { const unitPath = resolve(process.cwd(), 'framework', 'systemd', 'user', 'mosaic-agent@.service'); - /** The single `ConditionPathExists=` value declared by the unit template. */ - async function conditionPath(): Promise { + /** + * Every `ConditionPathExists=` value declared by the unit template, in file + * order, `|` triggering prefix included. + * + * Until #1410 this helper pinned `toHaveLength(1)` — a count assertion, not + * a content assertion, and the count pin was itself the defect: when #1408 + * required a second triggering line (the `%h/.mosaic` brain-home shape), + * this spec was a second consumer of the unit template that the shell suite + * and the enumeration guard could not see, so the fix failed here first + * (CI 2651 — the installation-documentation.spec.ts lesson again). Assert + * content per line, never count. + */ + async function conditionPaths(): Promise { const unit = await readFile(unitPath, 'utf8'); const matches = unit.match(/^ConditionPathExists=(.+)$/gm) ?? []; - expect(matches).toHaveLength(1); - return matches[0]!.slice('ConditionPathExists='.length).trim(); + expect(matches.length).toBeGreaterThan(0); + return matches.map((line) => line.slice('ConditionPathExists='.length).trim()); } it('will not attempt a seat before the reconciler has written its env', async (): Promise => { @@ -224,7 +235,26 @@ describe('mosaic-agent@.service', (): void => { // unit (WantedBy=default.target) but does not start it, so without this // condition a reboot between `install` and the first `apply` would run // ExecStart against an absent env file and fail every seat unit. - expect(await conditionPath()).toBe('%h/.config/mosaic/fleet/agents/%i.env.generated'); + // + // Two lines since #1408: the env projection lives under %h/.config/mosaic + // on framework-home hosts and under %h/.mosaic on brain-home hosts. Both + // carry the `|` triggering prefix — systemd ANDs same-type conditions + // unless every line is triggering (then they OR), and a bare spelling + // would demand BOTH home shapes on one host, which is never true, so + // every seat would silently skip. + expect(await conditionPaths()).toEqual([ + '|%h/.config/mosaic/fleet/agents/%i.env.generated', + '|%h/.mosaic/fleet/agents/%i.env.generated', + ]); + }); + + it('refuses the bare ANDed spelling on every condition line', async (): Promise => { + // Invariant ported from test-fleet-units.sh, held separately from the + // literal pin above so it survives future edits to the path set: every + // ConditionPathExists line must stay triggering (`|`). + for (const value of await conditionPaths()) { + expect(value.startsWith('|')).toBe(true); + } }); /** @@ -246,10 +276,18 @@ describe('mosaic-agent@.service', (): void => { */ 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 rendered = (await conditionPaths()).map((value) => + value.replace(/^\|/, '').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')); + // Per home shape, the guard must render to exactly the file the + // reconciler writes there: mosaicHome (%h/.config/mosaic) on + // framework-home hosts, %h/.mosaic on brain-home hosts (#1408) — each + // line pinned to its file, not merely present. + expect(rendered).toEqual([ + join(mosaicHome, 'fleet', 'agents', 'coder0.env.generated'), + join(tempHome!, '.mosaic', 'fleet', 'agents', 'coder0.env.generated'), + ]); }); it('guards a real failure — the launcher rejects an absent generated env', async (): Promise => {