Files
stack/docs/plans/2026-10-12_one-copy-stack.md
T

10 KiB

Bus, Console and mosaic CLI on one pinned copy (2026-10-12)

Status: written by Sage, lead. It follows docs/plans/BRIEF-TEMPLATE.md. It is the fix plan in my comment 52 on Vikunja #225, which Mos asked for in sage-0094 ("Tell me the plan before anything touches the wrapper or the unit") and sage-0095 ("Keep the one-copy row brief going"). It follows queue row 77 (#1571), whose round 2 makes webui-service.sh install --copy refuse unless the bus runs from the same copy. Gitea #1579.

Bus, Console and mosaic CLI on one pinned copy (#225)

Problem

On 2026-10-12 Jason ran mosaic talk pm "Can you hear me now?" and got mosaic: human-required (#225). The bus accepts a human proof only from its own packages/bus/src/human-cli.mjs: verifyHuman compares the proof process's argv[1] with resolve(cliPath) (packages/bus/src/human.mjs:52-102), and cliPath comes from the bus's own tree (packages/bus/src/runtime.mjs:68). The CLI spawns the human-cli of its own tree (packages/cli/src/transport.mjs:12), and so does the Console (packages/webui/src/bus.mjs). Today three things run from three trees:

  • the bus unit, ExecStart=/mnt/storage/src/mosaic-stack/scripts/mosaic bus start %i, rendered by scripts/bus-service.sh from packages/cli/systemd/mosaic-bus.service.in with REPO set to the script's own checkout;
  • the mosaic wrapper (Q81, ~/.npm-global/bin/mosaic), which runs a linked worktree at ~/.local/share/mosaic-cli/stack, pinned at d3ca2c22, six bus and CLI commits behind;
  • the Console, which runs from the checkout today and from a row 77 copy under ~/.local/share/mosaic-console/<id>/ once row 77 lands and is installed.

So the proof works only from one tree at a time. The stopgap is the full path, /mnt/storage/src/mosaic-stack/scripts/mosaic talk pm ..., which Jason confirmed works (6b5b7aef). The bus also runs code from the moving checkout, so every landing in packages/bus or packages/cli is live at its next restart, the same problem row 77 fixes for the Console (lead decision 109). And the unit template has no --pm (packages/cli/src/cli.mjs:265-279), so no PM session ran until Mos added a temporary drop-in that sets it (sage-0096; the S7 run log, 2026-10-12T18:49Z).

Accepting a second path in the proof (#225 option B) is rejected: it adds a trusted path and keeps the three trees on different code.

Owner and reviewer

Owner: Rocko. Reviewers: Darkwing and Filbert. Rocko has no Gitea token; Sage runs the review request as jarvis. This row starts when row 77 is done, and it goes ahead of row 84, since S7 comes before M1 (Q97).

Files owned

  • scripts/stack-copy.sh, new
  • scripts/webui-service.sh, only the copy verb, the copies directory and the same-copy check row 77 round 2 adds
  • scripts/bus-service.sh
  • packages/cli/systemd/mosaic-bus.service.in
  • tests for the above, under packages/cli/tests/ and packages/webui/tests/, or a new scripts/test-stack-copy.sh
  • packages/cli/README.md, the "Bus host" section
  • packages/webui/README.md, the service section
  • docs/TOOLS.md, the entries for the three scripts only
  • agents/rocko/work/queue-<row>/

What ships

One copy for all three:

  • scripts/stack-copy.sh copy COMMIT [--copies DIR] makes the copy. It takes over row 77's copy code and rules: canonical checkout only (the --allow-worktree test override stays), the full 40-character id, a temp directory beside the target and a rename, write permission removed, and an existing copy left as it is (exists:). COMMIT must be reachable from both refs/heads/refactor and refs/remotes/origin/refactor, since the wrapper runs pushed code only (Q81).
  • The default directory is ${XDG_DATA_HOME:-$HOME/.local/share}/mosaic-stack/copies/<full id>/. webui-service.sh copy stays as a call into stack-copy.sh copy, so row 77's documented steps still work, and both scripts read the same directory.
  • The path list grows to what the bus host, the CLI and the Console load: row 77's list, plus scripts/mosaic, adapters/, packages/queue/src if any copied path imports it, package.json and package-lock.json. The script runs npm ci in the temp directory before the rename, so the copy carries the pi pin's node_modules and pi-pin.mjs's lock check passes there (packages/conversation/src/pi-pin.mjs:52-57). Row 61 adds packages/tasks/src and packages/tasks/fields.json to what the Console loads (Darkwing, 2026-10-12), so the list carries them. The script refuses if package.json is missing from the temp directory, so npm ci can never walk up. Rocko derives the final list from the imports and records how in the packet.
  • Tests build a copy from a fixture repository, then from that copy: bus start reaches bus host up with a stand-in config, cli.mjs serve answers /healthz 200, and scripts/mosaic talk gets past the human proof against a broker started from the same copy. A path missing from the list fails the suite.

The proof across copies, as tests (with the agent-environment check stubbed as packages/bus/tests/human.test.mjs already does, never bypassed in the code):

  • a human-cli from the same copy as the broker is accepted;
  • a human-cli from another copy, or from the checkout, is refused with human-required.

The bus unit:

  • bus-service.sh render and install take --copy ID and --pm. --copy ID names an existing copy by its full id, and the template's @REPO@ becomes the copy's path, so WorkingDirectory, ExecStart and Documentation point at it. Without --copy the unit renders as today, so a fresh install from the checkout still works, and status says the unit runs the checkout.
  • --pm renders ExecStart=<copy>/scripts/mosaic bus start %i --pm. A PM refusal at start exits 3, which the unit doesn't retry (RestartPreventExitStatus=2 3 4), so the host stays down and its journal names the refusal. The README says so.
  • install refuses while any mosaic-bus@ instance is active, activating or deactivating, with row 77's N1 wording (Jason or Mos stops it first), and with row 77's N4 rule for a failed systemctl read.
  • status prints the copy id the unit runs, read from its WorkingDirectory, whether --pm is in its ExecStart, and whether refactor points at another commit. It changes nothing.
  • The --claude PATH pin (lead decision 91) is unchanged and is rendered next to --copy and --pm.
  • Mos's temporary drop-in, ~/.config/systemd/user/[email protected]/pm.conf (sage-0096, 2026-10-12T18:53:34Z), resets ExecStart to add --pm until this row lands. A drop-in overrides the installed unit, so status and check read the effective ExecStart and WorkingDirectory from systemctl --user show, not from the unit file, and status lists any drop-in files. install refuses while a drop-in for mosaic-bus@ sets ExecStart or WorkingDirectory, naming the file. The update step removes pm.conf before the install.

The check and the wrapper:

  • stack-copy.sh check prints the tree each of the three runs: the bus unit's WorkingDirectory, the Console unit's WorkingDirectory, and the copy the mosaic wrapper execs. It exits 0 only when all three name the same copy, and 1 otherwise, naming each path. It changes nothing. A missing unit or wrapper counts as a mismatch, named as absent.
  • stack-copy.sh wrapper ID rewrites ~/.npm-global/bin/mosaic to exec <copy>/scripts/mosaic. It keeps the Q81 lease line that sends __lease-capability to mosaic-old, and keeps the Q81 marker on line 2. It refuses unless the current file carries that marker and isn't a symlink, and it writes a temp file, then renames it. --bin DIR points it at a test directory. Tests cover the refusal on a foreign file and the lease line.

The update step, documented once in docs/TOOLS.md and pointed to from both READMEs: stack-copy.sh copy COMMIT; stop the Console, then the bus; remove any mosaic-bus@ drop-in that sets ExecStart (the first time, Mos's pm.conf) and run systemctl --user daemon-reload; bus-service.sh install --copy ID --pm with its flags; webui-service.sh install --copy ID with its flags; stack-copy.sh wrapper ID; start the bus, then the Console; stack-copy.sh check, webui-service.sh status, /healthz and scripts/mosaic launches list showing the PM. Rollback is the same steps with the earlier id. A bus restart ends the running sessions (packages/cli/src/launcher.mjs:304-320), so the docs say to do it when no PM turn is in flight. The stops, starts, installs and the wrapper rewrite are Jason's or Mos's (lead decisions 94, 102 and 103, and Mos's sage-0094: nothing touches the wrapper or the units before the row lands and Mos says go).

Suites: the cli, bus and webui node suites, and every scripts/test-*.sh.

Out of scope

Running any step of the update on the live system: stopping, starting, installing or enabling either unit, and rewriting the real wrapper. Removing the Q81 worktree at ~/.local/share/mosaic-cli/stack or mosaic-old; that's Mos's or Jason's after the first update. Pruning old copies. A health-gated activate and rollback like scripts/release.sh, which is D1. Any change to the human proof itself (packages/bus/src/human.mjs). Any change to packages/queue; mosaic queue from a copy refuses as it does from the Q81 worktree today, and queue work stays on the checkout's scripts/mosaic. Relaunching a PM whose session ended while the host runs.

Gate

Darkwing and Filbert approve on the row's issue, each naming the candidate manifest. The cli, bus and webui node suites and every scripts/test-*.sh pass on Sage's gate rerun. Before the landing Sage notifies Mos (decision 109); after it, Sage sends Mos the commit and scripts/webui-service.sh status, scripts/bus-service.sh status mosaic-stack and /healthz. #225 closes when Mos's first update step at the landed commit ends with stack-copy.sh check exiting 0, and Jason's plain mosaic talk pm "..." printing sent and then a reply.