# Row 190: several Mosaic Stack instances on one host Brief: `docs/plans/2026-10-20_multi-instance.md`, blob `4e8f1e052894` (commit 48ebee0ff, rev 2259). Gitea #1688. Owner Darkwing, reviewer Filbert, gate owner Sage. Design: `DESIGN.md` in this directory, revision 3 (sha256 75b21f7561cb...), approved by Filbert. Row 191 (Rocko) merge-chains on this candidate. Round 1 is the record below up to "Follow-ups". Filbert's round 1 verdict was CHANGES (Gitea comment 29634, queue rev 2362), and "Round 2" at the end replaces round 1's Candidate and gate sections. ## Files | File | What | |---|---| | `DESIGN.md` | the approved design, revision 3 | | `build.patch` | the change, `git diff da230ca48` over the 24 paths below, the two new files included | | `candidate-manifest.sha256` | one line per path | | `ti8/` | the T-I8 one-shot: the test file (not committed), its runner, `record.txt` and the run logs | Base: da230ca48. Changed (22) and new (2): - Scripts: `scripts/instance.sh` (new), `scripts/mosaic-config.mjs`, `scripts/bootstrap.sh`, `scripts/bus-service.sh`, `scripts/webui-service.sh`, `scripts/discord-service.sh`, `scripts/stack-copy.sh`, `scripts/stack-release.sh`. - Templates: `packages/cli/systemd/mosaic-bus.service.in`, `packages/webui/systemd/mosaic-webui.service.in`, `packages/discord/systemd/mosaic-discord@.service.in`. Each gains one `@CONFIG_ENV@` line, and the bus template's `EnvironmentFile` becomes `@SESSION_ENV@`. - Guard: `packages/conversation/src/guard.mjs`. - Tests: `packages/cli/tests/stack-instances.test.mjs` (new), `packages/cli/tests/stack-release.test.mjs` (T55 and the stand-in), and fixture changes in `packages/cli/tests/host.test.mjs`, `packages/webui/tests/service.test.mjs`, `packages/webui/tests/pinned-copy.test.mjs`, `packages/discord/tests/copy-service.test.mjs` and `scripts/test-discord.sh`. - Docs: `AGENTS.md` (the two lines in Mos's wording, sage-1335), `docs/TOOLS.md` (the `--instance` section, which I wrote, per sage-1335), and the three package READMEs. The worktree's `node_modules` symlink is not part of the candidate. ## The tests | Design test | Where | Subtests | |---|---|---| | T-I1 bootstrap | `stack-instances.test.mjs` | 7: alpha and beta created with modes; a second run changes no byte, mode or mtime; a non-empty unmarked root refuses, `worktrees` included; an empty root is claimed and a lost marker refuses; a dangling symlink at the config path is never written through, named or default; bad names exit 4; `--instance dev` writes what no flag writes | | T-I2 selection | `stack-instances.test.mjs` | 6: the four D1 rows, a named instance with no config, and the flag through an entry point | | T-I3 render | T55 in `stack-release.test.mjs` | each instance's own `MOSAIC_CONFIG`, session env and Description, nothing of the other's | | T-I4 ports | T55 | port-claimed for the Console port and the tester port, the default on a named port, the reserved ports, the stack-release run and `--plan` | | T-I5 side by side | `stack-instances.test.mjs` | two real bus hosts at once, each answering from its own socket; stopping alpha leaves beta up | | T-I6 no cross-reach | T55 | alpha's activate and rollback reach nothing of beta's, then swapped | | T-I7 claims | `stack-instances.test.mjs` (4) and T55 (1) | the mismatch in every op, D1 row 2, a default config on alpha's root, and each bad marker shape; T55 runs D1 row 2 through `stack-release.sh activate` | | T-I8 default unchanged | `ti8/` one-shot, plus the existing suites | see below | | T-I9 busy scoping | T55 | another instance's bus refuses nothing; this one's does | | T-I10 guard | `stack-instances.test.mjs` | every instance's config dir and roots, a corrupt config, `mosaic.bak` | ### T-I8 The one-shot copies the fixture repository, puts the da230ca48 blob of each of the 11 changed scripts, templates and guard into the copy, removes `scripts/instance.sh` from it, and asserts each of the 11 now differs from the candidate's. It then runs the same 15 commands from both trees on one seeded fixture: - `bus-service.sh render` with and without `--pm`, `webui-service.sh render` with and without `--tester-port`, `discord-service.sh render`, and `stack-copy.sh check` with and without `--business`; - `stack-release.sh status`, four `activate ... --plan` forms (C1, C1 with `--tester-port`, C with `--migrate`, and B, which refuses) and `rollback --plan` (which refuses); - after a real `activate C1`, `status` and `rollback --plan` again. Exit code, stdout and stderr match on all 15 (`ti8/record.txt` holds each sha256). The one normalization replaces the base tree's path with the candidate's in the output, since each script names its own repository. `discord-service.sh render` runs without `--copy`: a copy render needs `packages/discord/src/cli.mjs`, which the fixture copy lacks (run 2 failed on that, `ti8/run-2.log`; run 3 passed, `ti8/run-3.log`). The checkout render goes through the same template fill. The test file is `ti8/zz-ti8-oneshot.test.mjs.txt` (sha256 e888e55e140a...). It is not in the candidate, because it reads da230ca48 from git and stops meaning anything once that base is old. The default renders are also pinned by the existing fixtures: `service.test.mjs` and `copy-service.test.mjs` compare a full render with the template, now with the `@CONFIG_ENV@` line deleted. ## Deviations and readings 1. Names alpha and beta, not the design's `a` and `b`. The D1 rule `^[a-z][a-z0-9]{1,15}$` needs two characters or more, so `a` is invalid. 2. The new test file is `stack-instances.test.mjs`, not `instances.test.mjs`. It names what it tests the way `stack-release.test.mjs` does. 3. Existing tests changed only to keep the instance selection off `~/.config/mosaic-dev`: - `host.test.mjs`: the two bus-service tests pass a scratch `MOSAIC_CONFIG` that does not exist, so the default names are selected as before. - `scripts/test-discord.sh`: the service-unit block exports the same kind of scratch `MOSAIC_CONFIG` and puts the caller's value back after it. - `pinned-copy.test.mjs`: "copy writes nothing when an archived path is missing" removed `scripts/mosaic-config.mjs`. The entry point now runs that file to select the instance before it copies, so the test removes `packages/webui/README.md` instead, a path only the copy reads. - `service.test.mjs` and `copy-service.test.mjs`: the expected render deletes the `@CONFIG_ENV@` line. 4. T-I4: the design says a named instance with no `--port`, and one on 7332, refuse `port-claimed`. They exit 4, with `needs --port` and `port-reserved` (7330, 7332 and 7333). Both are usage errors that need no unit on disk, so I read them as exit 4 like the name errors. A port another installed Console holds is `port-claimed`: exit 1 from `webui-service.sh`, exit 3 from `stack-release.sh`. 5. `stack-release.sh --plan` now runs `ports_unclaimed`, so the plan lists a `port-claimed` refusal with the others. With no claimed port the plan text is unchanged (T-I8). 6. T-I5 checks the writer-lock pid against the broker process under the host, not the host itself. The host spawns the broker, and the broker holds `bus/writer.lock`. 7. The `systemctl` stand-in now reads drop-ins in systemd's full order: `service.d`, then the unit name cut after each hyphen (`mosaic-.service.d`, `mosaic-bus-.service.d`), then the template's and the unit's own `.d`. Before, it read only the last two. T-I6 needs the prefix directories to exist and be read. 8. The webui template still says "The unit sets no MOSAIC_*" (`mosaic-webui.service.in:15`). A named unit now sets `MOSAIC_CONFIG`. I left the comment as it is so the default render stays byte-identical; correcting it changes every default unit's bytes. Follow-up for Sage to rule on. Round 2: Sage ruled (sage-1474), see round 2's N3. 9. M11 had no killer in the first mutant list. I added the T-I1 subtest "a dangling symlink at the config path is never written through, named or default": with `w` instead of `wx`, bootstrap creates the link's target. ## R3: bare default literals left in code `grep -nE 'mosaic-dev|mosaic-bus@|mosaic-webui\.service|mosaic-discord@|\$BIN/mosaic\b'` over the eight changed scripts, comment lines dropped. Every hit, with the reason it stays: | Line | Literal | Why | |---|---|---| | `scripts/webui-service.sh:77` | `mosaic-webui.service.in` | the template file's path, which every instance renders from | | `scripts/discord-service.sh:24` | `mosaic-discord@.service.in` | same, for Discord | | `scripts/discord-service.sh:162` | `.../mosaic-dev/config.json` | `status` reads the default's config only when no suffix is set; the next line takes `MOSAIC_INSTANCE_CONFIG` for a named instance. The base's line, kept so the default's status is unchanged | | `scripts/instance.sh:87` | the default bus drop-in globs | the default instance's own scope, set in the one place every script reads | | `scripts/instance.sh:88` | the default `SESSION_ENV` | the default's `EnvironmentFile` path, the base template's literal (T-I8 pins it) | | `scripts/instance.sh:119` | the port holder's Console scan | it scans every Console unit, default and named, to find a port another instance holds | | `scripts/stack-copy.sh:72` | `file="$BIN/mosaic"` | the default wrapper's path only; a named instance returns earlier through `wrapper_instance` | | `scripts/stack-copy.sh:106` | `mosaic-bus@*\|mosaic-bus-*@*` | the `unit_root` case matches both the default's and a named instance's bus units | | `scripts/stack-release.sh:1446` | `LIVE_CFG` default | the rehearsal reads `MOSAIC_CONFIG` first, and `instance_select` exports it for a named instance (`instance.sh:99`), so the default path is reached only by the default instance (F9) | | `scripts/mosaic-config.mjs:307` | `DEFAULT_CONFIG` root `~/.mosaic-dev` | the default instance's config; a named bootstrap writes `~/.mosaic-NAME` | The unit names themselves come from `scripts/instance.sh:80-85`, built from `$MOSAIC_UNIT_SUFFIX`, so they don't match the grep. ## Run environment - Every run is in the git worktree `/mnt/storage/scratch/darkwing-r190` (da230ca48 plus the candidate). - TMPDIR is `/mnt/storage/scratch/tmp/dw190`, 30 bytes, within the cli suite's limit (row 177). - T-I5's longest socket path is TMPDIR + `/si-XXXXXX/h/.mosaic-alpha/bus/broker.sock`, 72 bytes, under the kernel's 107. - No test reads or writes `~/.mosaic-dev`, `~/.config/mosaic-dev`, a live unit or a slot file. `HOME` is a scratch directory in every new test. ## Results ### Builder gate `~/darkwing-scratch/r190/gate.sh`, 2026-10-20T20:07:11Z-20:17:52Z, after busy.sh was clear on two checks 20 s apart. 17 steps, each passing, counts summed by awk over the gate log: | Step | Pass | Fail | |---|---|---| | `packages/cli` `node --test tests/*.test.mjs` | 501 | 0 | | `packages/conversation` | 182 | 0 | | `packages/control-board` | 124 | 0 | | `packages/discord` | 183 | 0 | | `packages/plugins` `harness.test.mjs` | 9 | 0 | | `packages/webui` `service.test.mjs` | 31 | 0 | | `packages/webui` `pinned-copy.test.mjs` | 10 | 0 | | `scripts/test-auth.sh` | 15 | 0 | | `scripts/test-conductor.sh` | 17 | 0 | | `scripts/test-config.sh` | 24 | 0 | | `scripts/test-discord.sh` | 78 | 0 | | `scripts/test-extension-package.sh` | 18 | 0 | | `scripts/test-foundation.sh` | 44 | 0 | | `scripts/test-land-work.sh` | 86 | 0 | | `scripts/test-queue.sh` | 156 node tests, 28 suite checks | 0 | | `scripts/test-release.sh` | 14 | 0 | | `scripts/test-task.sh` | 98 | 0 | The node tests total 1196 and the selftests 422. test-task's compose ran as project `darkwing-r190`, and the EXIT trap removed its network with no containers on it. An earlier gate run (20:01:24Z) overlapped Launchpad's row 200 mutants, because busy.sh didn't match their directory. I killed it and threw away its results. busy.sh now matches every `scratch/-r/` directory. ### Mutants `~/darkwing-scratch/r190/mutants.sh`, 2026-10-20T20:17:53Z-20:27:16Z, after the gate in the same slot. First three controls with no change, each passing: SI (`stack-instances.test.mjs`), T55 (`--test-name-pattern='^T55 '` on `stack-release.test.mjs`) and FIX (`webui service.test.mjs`, then `discord copy-service.test.mjs`). Each mutant does the following, and the script stops at the first step that fails: - back up the file and check the backup's sha256; - make one exact replacement (count 1); - run a syntax check; - run its killers; - put the file back and check its sha256. Every restore matched. | # | File | Replacement | Killers run | Fail | Failing tests | |---|---|---|---|---|---| | M1 | `instance.sh` | `local s=""` | T55 | 7 | T-I3, T-I4, both T-I6, T-I7, T-I9 | | M2 | `instance.sh` | the port holder's match line becomes `:` | T55 | 2 | T-I4 | | M3 | `mosaic-config.mjs` | `return;` at the top of `checkClaim` | SI, T55 | 7 + 2 | five T-I1 and T-I7 subtests in SI; T-I7 in T55 | | M4 | `bus-service.sh` | adds `-e '/Environment=MOSAIC_CONFIG=/d'` to the render | T55 | 5 | T-I3, both T-I6, T-I9 | | M5 | `instance.sh` | a named `SESSION_ENV` set to the default's path | T55 | 2 | T-I3 | | M6 | `instance.sh` | a named `BUS_GLOB='mosaic-bus*@*.service'` | T55 | 6 | T-I9, and T-I3, T-I4 and both T-I6 through their seeds | | M7 | `instance.sh` | `/^@CONFIG_ENV@$/d` becomes `s/^@CONFIG_ENV@$//` | FIX | 6 | six webui `service.test.mjs` tests that compare the default render byte for byte | | M8 | `stack-release.sh` | capture copies `$BIN/mosaic` | T55 | 3 | both T-I6 | | M9 | `mosaic-config.mjs` | `if (false) {` for the mismatch | SI | 3 | T-I2 row 4 and the entry-point subtest | | M10 | `mosaic-config.mjs` | the name pattern allows `-` | SI | 4 | T-I1 bad names, T-I2 entry point | | M11 | `mosaic-config.mjs` | `"w"` instead of `"wx"` | SI | 2 | T-I1 dangling symlink | | M12 | `guard.mjs` | `for (const name of [])` | SI | 1 | T-I10 | | M13 | `mosaic-config.mjs` | the no-flag branch's `checkClaim` dropped | SI, T55 | 3 + 2 | T-I7's two D1 row 2 subtests and the default-on-alpha's-marker one; T-I7 in T55 | | M14 | `stack-release.sh` | the capture loop adds `mosaic-bus-.service.d` | T55 | 3 | both T-I6 | | M15 | `guard.mjs` | the unconditional `~/.mosaic-NAME` push dropped | SI | 1 | T-I10 (`.mosaic-alpha/x` under a corrupt config) | The fail counts are the runner's `ℹ fail` lines and include each failing test's parent. Under M4, each T-I6 subtest runs about 122 s before it fails, because without `MOSAIC_CONFIG` the stand-in bus waits for a host that never comes up. FIX stops at the webui file under M7, so the discord file didn't run there. ## Candidate - `build.patch`: sha256 `6389af4fe8fb7a49ac561adb586474e8328cc401d95ae985c385ca73484bd5e4`, 2611 lines. It is `git diff da230ca48` over the 24 paths, with the two new files added as intent-to-add for the diff only. - `candidate-manifest.sha256`: sha256 `068828bcb5c75033aa441037bf3e9b84de0ac0f8a624aa0d0bc5a32fb9198901`, 24 lines. - The patch applies on da230ca48 in a fresh detached worktree, and `sha256sum -c` of the manifest passes there. I built both after the mutant run had ended and its restores had matched. A first build during the run held M4's `bus-service.sh`; I deleted it unread. - The review candidate is commit `8862feccc9c6c96088ce934d984e00731da0b425` on local branch `darkwing/row190`, parent da230ca48. It is not pushed. The queue refused the manifest as a candidate, because `PATH_RE` (`packages/queue/src/queue.mjs:37`) has no `@` and `packages/discord/systemd/mosaic-discord@.service.in` is one of the 24 paths. Each path's blob in the commit hashes to its manifest line, with zero diff lines. ## For Sage's gate - Run the cli suite and every `scripts/test-*.sh` from a git worktree of 8862feccc, with TMPDIR at 30 bytes or less. - No test pins a changed file by sha256 (grep of `packages/*/tests` and `scripts/test-*.sh` for each changed path and a 64-hex string found none). ## Follow-ups - The Console and Discord uninstall and status scans read only their own instance's drop-ins; a scan of the shared prefix directories is out of this row. - The webui template comment (deviation 8). ## Round 2 Filbert's round 1 (comment 29634): B1 blocking, N1-N7 not blocking. Sage's sage-1474 ruled N3 and left the rest to me. ### B1: rebase Row 189 (ace27dc5e) landed after da230ca48 on `scripts/stack-release.sh`, `stack-release.test.mjs` and `docs/TOOLS.md`. I rebased onto refactor 088087ac9 on a new local branch, `darkwing/row190-r2`, because #426(a) refuses a force-push to `darkwing/row190`. The rebased commit is ceaf93837. - `scripts/stack-release.sh`: one conflict in `plan()`. Row 189 added `echo "$SEND_PLAN"` before the Console line, and row 190 added `$flags` to the Console and CLI lines. The resolution keeps both. `$flags` is set four lines above, from `INSTANCE_FLAGS`. - `stack-release.test.mjs` merged with no conflict, but both rows had named a test T55. Row 190's is now T56 (`T56 row 190: named instances side by side ...`). The comment in `stack-instances.test.mjs` and the mutant script's killer pattern now say T56. Round 1's tables above keep the name T55, the name it had when they ran. - `docs/TOOLS.md` merged with no conflict. After the rebase, T55 (row 189, 7 subtests) and T56 (row 190, 6 subtests) passed together, 15 tests and 0 failures, before any round 2 edit. ### Notes - N1, TOOLS.md. The bus-service block and the discord-service line now show `[--instance NAME]`. `webui-service.sh --instance NAME copy` used to drop the flag without a word, while `stack-copy.sh copy --instance` exits 4. Now both refuse it with exit 4, because every instance shares the copies. The webui usage puts `[--instance NAME]` on the verbs that take it, and TOOLS.md says both copy verbs refuse it. New T-I2 subtest in `stack-instances.test.mjs`: "copy takes no --instance in either script". Mutant M17 below. - N2, T-I8. Deviation 10 below. - N3, Sage's ruling (sage-1474): the named-only sed rewrites the Console comment "The unit sets no MOSAIC_*, CLAUDE*, CODEX* or PI_* variable." to "The unit sets MOSAIC_CONFIG and no other MOSAIC_*, CLAUDE*, CODEX* or PI_* variable." The default keeps the line byte for byte. T56's T-I3 pins both. It checks the named Console render for the new line and for no "sets no MOSAIC_", and the default render for the template's line and for no `MOSAIC_CONFIG`. Mutants M16 and M19. - N4, `instance_port_holder` reads only the unit file's `ExecStart=`, so a drop-in that overrides `ExecStart` is missed. Moot on this host (no Console drop-in sets one), so it's a follow-up. - N5, TOOLS.md said the config directory is 0700. Bootstrap creates either directory 0700, and one that already exists keeps its mode. TOOLS.md now says that. The code is unchanged: changing the mode of a directory the user already made isn't bootstrap's job. - N6, `stack-copy.sh check --instance NAME` now also needs the wrapper's line 4 to be `export MOSAIC_CONFIG=''`, as `wrapper_instance` writes it. If it isn't, the CLI line reads ` (MOSAIC_CONFIG mismatch)`, the same form as `unit_root`'s `(ExecStart mismatch)`, and check exits 1. The new test in `packages/cli/tests/stack-copy.test.mjs`, "row 190 N6", covers a good wrapper, one with another instance's config and one with no config line. With the check line disabled, the test failed (exit 1); restored, it passed. Mutant M18. - N7, `discord-service.sh status` for the default reads `~/.config/mosaic-dev/config.json` and not `MOSAIC_CONFIG`. That's base behavior (R3 table, `discord-service.sh:162`), and D1 row 2 says `MOSAIC_CONFIG` alone selects that config. Follow-up for Sage, not changed here, so the default's status stays as it is. ### Deviations, round 2 10. T-I8 is a one-shot, not a committed test (Filbert N2). It ran once against da230ca48 (`ti8/`), and no committed test compares the full default bus render with the base's. Committed tests pin the default Console and Discord renders byte for byte against their templates (`service.test.mjs`, `copy-service.test.mjs`), plus the default bus render's lines that `host.test.mjs` checks. I didn't rerun T-I8 on 088087ac9. Row 189 changed none of the 11 files it compares except `stack-release.sh`, and that change is the `send:` line, which shows on both sides. 11. `packages/cli/tests/stack-copy.test.mjs` joins the manifest for N6, so there are 25 paths. 12. Round 2 adds four mutants, M16-M19, on top of the design's M1-M15. Each one guards one of the round 2 changes. ### Gate and mutants, round 2 Sage's slot sage-grant-190-r2 (sage-1478). `~/darkwing-scratch/r190/run-all.sh` waited for busy.sh to be clear on two checks 20 s apart, then ran the gate and the mutants on 537a02392 in the r190 worktree. Gate, 2026-10-20T20:56:42Z-21:07:41Z. 17 steps, each rc=0. Counts are awk sums over the gate logs: | Step | Pass | Fail | |---|---|---| | `packages/cli` `node --test tests/*.test.mjs` | 511 | 0 | | `packages/conversation` | 182 | 0 | | `packages/control-board` | 124 | 0 | | `packages/discord` | 183 | 0 | | `packages/plugins` `harness.test.mjs` | 9 | 0 | | `packages/webui` `service.test.mjs` | 31 | 0 | | `packages/webui` `pinned-copy.test.mjs` | 10 | 0 | | `scripts/test-auth.sh` | 15 | 0 | | `scripts/test-conductor.sh` | 17 | 0 | | `scripts/test-config.sh` | 24 | 0 | | `scripts/test-discord.sh` | 78 | 0 | | `scripts/test-extension-package.sh` | 18 | 0 | | `scripts/test-foundation.sh` | 44 | 0 | | `scripts/test-land-work.sh` | 86 | 0 | | `scripts/test-queue.sh` | 156 node tests, 28 suite checks | 0 | | `scripts/test-release.sh` | 14 | 0 | | `scripts/test-task.sh` | 98 | 0 | The node tests total 1206 and the selftests 422. pkg-cli has 10 more than round 1: row 189's T55 (7) and round 2's N1 and N6 subtests. test-task's compose ran as project `darkwing-r190`, and the EXIT trap removed its network. Mutants, 2026-10-20T21:07:42Z-21:18:02Z, with the same steps as round 1. Four controls with no change, each exit 0: SI, T56 (`--test-name-pattern='^T56 '` on `stack-release.test.mjs`), SC (`--test-name-pattern='^row 190 N6'` on `stack-copy.test.mjs`) and FIX. Every restore matched. 19 run, 19 killed, 0 survived: | # | Killers run | Fail | Failing tests | |---|---|---|---| | M1 | T56 | 7 | T-I3, T-I4, both T-I6, T-I7, T-I9 | | M2 | T56 | 2 | T-I4 | | M3 | SI, T56 | 9 | T-I1 and T-I7 subtests in SI, T-I7 in T56 | | M4 | T56 | 5 | T-I3, both T-I6, T-I9 | | M5 | T56 | 2 | T-I3 | | M6 | T56 | 6 | T-I3, T-I4, both T-I6, T-I9 | | M7 | FIX | 6 | the webui `service.test.mjs` tests that compare the default render byte for byte | | M8 | T56 | 3 | both T-I6 | | M9 | SI | 3 | T-I2 row 4 and the entry-point subtest | | M10 | SI | 4 | T-I1 bad names, T-I2 entry point | | M11 | SI | 2 | T-I1 dangling symlink | | M12 | SI | 1 | T-I10 | | M13 | SI, T56 | 5 | T-I7's D1 row 2 subtests, T-I7 in T56 | | M14 | T56 | 3 | both T-I6 | | M15 | SI | 1 | T-I10 | | M16 | T56 | 2 | T-I3 (the named Console comment kept) | | M17 | SI | 2 | T-I2 "copy takes no --instance in either script" | | M18 | SC | 1 | row 190 N6 | | M19 | T56 | 2 | T-I3 (the default Console comment rewritten) | M16 drops the named sed line for the comment, M17 makes `webui-service.sh copy` accept `--instance`, M18 skips the wrapper config check in `stack-copy.sh check`, and M19 adds the comment rewrite to the default's sed. Fail counts are the runner's `ℹ fail` lines, parents included. The M3 count is 9 here against 7 + 2 in round 1 because this table sums both killers. ### Candidate, round 2 - The review candidate is commit `537a02392bf5eeecf3f55dbfec5162565b786456` on `darkwing/row190-r2` (pushed), parent ceaf93837, which is round 1's change rebased onto refactor 088087ac9. It's a commit, not the manifest, because `PATH_RE` still refuses the `@` path. - `build-r2.patch`: sha256 `0ba1aa3ec6b466055754893c33c8934eb1323627cb4ef32b163d18b489421f8b`, 2707 lines, `git diff 088087ac9 537a02392` over the 25 paths. - `candidate-manifest-r2.sha256`: sha256 `2296e9cb130eafdd79515aec364393e1493dad81e10a2e57e4ac4ba96f024e0f`, 25 lines. - The patch applies with `git apply --index` on 088087ac9 in a fresh detached worktree, `sha256sum -c` of the manifest passes there, and each path's blob there equals its blob in 537a02392. I built both after the mutant run ended and its restores matched. - Round 1's `build.patch` and `candidate-manifest.sha256` stay as round 1's record. - No test pins a changed file by sha256. The only test with a 64-hex string that names a changed file is `packages/webui/tests/service.test.mjs`, and its string is a fake UID (`:21`). For Sage's gate: run the cli suite and every `scripts/test-*.sh` from a git worktree of 537a02392, with TMPDIR at 30 bytes or less. ### Follow-ups, round 2 - N4: `instance_port_holder` and a drop-in `ExecStart` override. - N7: the default `discord-service.sh status` and `MOSAIC_CONFIG` (D1 row 2). - `PATH_RE` and `@` paths (reported to Sage in round 1). - Round 1's first follow-up (drop-in scans) stands. The webui template comment is settled by N3. ## Round 3 Filbert's round 2 (comment 29685) had one blocking item, B1. Row 192 (4429a104f, Q175 phase 1 SSO) landed at 2026-10-20T21:21:29Z, after round 2's candidate and gate. Row 192 touches four of the 25 paths: `scripts/mosaic-config.mjs`, `scripts/webui-service.sh`, `packages/webui/tests/service.test.mjs` and `packages/webui/README.md`. Round 2's own changes needed nothing more. ### B1: rebase I rebased onto refactor 176a6bf4b on a new local branch, `darkwing/row190-r3`, because #426(a) refuses a force-push. The commits: - ab1d2ee9e: round 1's change, rebased. - cdac7ba92: round 2's change, rebased. - df65a3d4c: round 3's only new code, the T-I3 assertion below. I didn't run `npm ci` in the worktree. Its `node_modules` is a symlink to the canonical checkout's, which already has row 192's oauth4webapi 3.8.8. The webui suite's SSO tests passed on it in the gate. Filbert's merge hazards, each checked on df65a3d4c: - Row 192 makes `validate` and `load` async. Every call to `load` in `mosaic-config.mjs` is awaited: in `bootstrapDefault` (:350, :356, :359), `bootstrapNamed` (:368, :394, :398), `selected` (:415, :420) and `loadSelected` (:442). Those four functions are async now, and the command switch awaits each one (:457-483). `checkClaim` stays sync, and every caller passes it a `dataRoot` from an awaited `load`, so it never gets a Promise. - The top-level `try { switch ... } catch` (:487-490) awaits everything inside it, so a Failure thrown from row 192's `validateConsole` still exits with its own code. `scripts/test-config.sh` proves it for the default: five bad `console` bodies through `validate` each exit 2 and name the key path. That suite has 31 checks now, up from 24 in round 2, and the 7 new ones are row 192's. No test sends a bad `console` through a named instance. That path is the same `loadSelected` to `load` to `validate` chain with `instance` set. - `webui-service.sh` keeps row 192's `--sso` comment (:52), flag (:143), refusals (:221-227) and unit flag (:260), plus row 190's instance block (:86-91). USAGE has one FLAGS line: `[--sso [--send] | --public-host NAME --auth-header HEADER --allow-user ID [--send]]`. In `service.test.mjs`, row 192's tests merged with round 1's `@CONFIG_ENV@` line removal in the expected render, with no conflict. - Filbert note 4. A named unit's `Environment=MOSAIC_CONFIG=` gives each instance its own `console.sso` (read through `loadSystem`) and its own slots (`business.mjs` `configDir()`). T56's T-I3 now renders alpha's Console with `--sso` and checks that the unit carries both `--sso` on `ExecStart` and alpha's `MOSAIC_CONFIG` line. That's 6 lines in `stack-release.test.mjs`. Mutant M20 below drops `--sso` from the named render. ### Deviations, round 3 13. Filbert asked for the 19 mutants on the new base. I ran 20: M1-M19 unchanged, plus M20 for note 4's assertion. ### Gate and mutants, round 3 Sage's slot (sage-1512). `~/darkwing-scratch/r190/run-all.sh` waited for busy.sh to be clear on two checks 20 s apart, then ran the gate and the mutants on df65a3d4c in the r190 worktree. Gate, 2026-10-20T21:47:27Z-21:59:13Z. 17 steps, each rc=0. Counts are awk sums over `gate-r3.txt`: | Step | Pass | Fail | |---|---|---| | `packages/cli` `node --test tests/*.test.mjs` | 511 | 0 | | `packages/conversation` | 182 | 0 | | `packages/control-board` | 124 | 0 | | `packages/discord` | 183 | 0 | | `packages/business` | 149 | 0 | | `packages/webui` (whole suite) | 299, 1 skipped | 0 | | `packages/plugins` `harness.test.mjs` | 9 | 0 | | `scripts/test-auth.sh` | 15 | 0 | | `scripts/test-conductor.sh` | 17 | 0 | | `scripts/test-config.sh` | 31 | 0 | | `scripts/test-discord.sh` | 78 | 0 | | `scripts/test-extension-package.sh` | 18 | 0 | | `scripts/test-foundation.sh` | 44 | 0 | | `scripts/test-land-work.sh` | 86 | 0 | | `scripts/test-queue.sh` | 156 node tests, 28 suite checks | 0 | | `scripts/test-release.sh` | 14 | 0 | | `scripts/test-task.sh` | 98 | 0 | That's 2042 passed: 1613 node tests and 429 selftests. Round 2 had 1206 and 422. This gate adds `packages/business` (149) and runs the whole webui suite (299) instead of its two service files (41). Those two changes account for the 407 extra node tests. The 7 extra selftests are row 192's `test-config.sh` checks. pkg-cli is still 511, and that includes T56's new T-I3 assertion, which sits inside an existing subtest. test-task's compose ran as project `darkwing-r190`, and the EXIT trap removed its network. Row 192's re-pin activate (W5) ran at 22:02:09Z, after the gate and during the mutants. Nothing in the gate or the mutants uses the live units. M4's two T-I6 cases ran across 22:02. Each one timed out at about 122 s, against 4 s in the control. Rounds 1 and 2 show the same 122 s timeouts under M4 (`mut-r1/`, `mut-r2/`), so the mutant causes them, not the activate. Mutants, 2026-10-20T21:59:14Z-22:09:37Z, with the same steps as round 2. The four controls (SI, T56, SC, FIX) each exited 0. Every restore matched, and the worktree ended at df65a3d4c with no changes. 20 run, 20 killed, 0 survived: | # | Killers run | Fail | Failing tests | |---|---|---|---| | M1 | T56 | 7 | T-I3, T-I4, both T-I6, T-I7, T-I9 | | M2 | T56 | 2 | T-I4 | | M3 | SI, T56 | 9 | T-I1 and T-I7 subtests in SI, T-I7 in T56 | | M4 | T56 | 5 | T-I3, both T-I6, T-I9 | | M5 | T56 | 2 | T-I3 | | M6 | T56 | 6 | T-I3, T-I4, both T-I6, T-I9 | | M7 | FIX | 6 | the webui `service.test.mjs` tests that compare the default render byte for byte | | M8 | T56 | 3 | both T-I6 | | M9 | SI | 3 | T-I2 row 4 and the entry-point subtest | | M10 | SI | 4 | T-I1 bad names, T-I2 entry point | | M11 | SI | 2 | T-I1 dangling symlink | | M12 | SI | 1 | T-I10 | | M13 | SI, T56 | 5 | T-I7's D1 row 2 subtests, T-I7 in T56 | | M14 | T56 | 3 | both T-I6 | | M15 | SI | 1 | T-I10 | | M16 | T56 | 2 | T-I3 (the named Console comment kept) | | M17 | SI | 2 | T-I2 "copy takes no --instance in either script" | | M18 | SC | 1 | row 190 N6 | | M19 | T56 | 2 | T-I3 (the default Console comment rewritten) | | M20 | T56 | 2 | T-I3 (the named `--sso` render) | M1-M19 fail the same tests, with the same counts, as round 2. M20 replaces `[ "$SSO" = 0 ] || flags="$flags --sso"` in `webui-service.sh` with `:`, so a named `--sso` render loses `--sso`. Fail counts are the runner's `ℹ fail` lines, parents included. ### Candidate, round 3 - The review candidate is commit `df65a3d4c8fec64ba3aba20a481c244f9d73a84d` on `darkwing/row190-r3` (pushed), base refactor 176a6bf4b. It's a commit, not the manifest, because `PATH_RE` still refuses the `@` path. - `build-r3.patch`: sha256 `efe898c2ce2de5c98cf0354746aa3b7ba8c61467499ccf67d62a9fa33a59ad04`, 2713 lines, `git diff 176a6bf4b df65a3d4c` over the 25 paths, the same path set as round 2. - `candidate-manifest-r3.sha256`: sha256 `cc0ea9e8f352a4805f8cda6dae8c2401880bed32fc10f25fee4ae7091c7970af`, 25 lines. - I built both from git objects, not from the worktree the mutants edit. The patch applies with `git apply --index` on 176a6bf4b in a fresh detached worktree, `sha256sum -c` of the manifest passes there, and that tree equals df65a3d4c's. - No test pins a changed file by sha256. Two test files with a 64-hex string name a changed path. In `packages/webui/tests/service.test.mjs` the string is the fake UID (`:21`), and in `packages/queue/tests/fixtures/queue-marked.md` it's an old manifest digest in history text (`:119`). For Sage's gate: run the cli suite and every `scripts/test-*.sh` from a git worktree of df65a3d4c, with `node_modules` linked and TMPDIR at 30 bytes or less. ### Follow-ups, round 3 Unchanged from round 2: N4 (`instance_port_holder` and a drop-in `ExecStart`), N7 (the default `discord-service.sh status` and `MOSAIC_CONFIG`), and `PATH_RE` with `@` paths. Row 230 (C2-13a) leaves out row 190's `@CONFIG_ENV@` and `@SESSION_ENV@` tokens (gosalyn-0058 item 4). Whichever of the two rows lands second adds them. Filbert approved round 3 (Gitea 29713, queue rev 2504, 2026-10-20T22:15:59Z). One more follow-up came from that review, not blocking: no test sends a bad `console` body through a named instance's `validate`.