9.8 KiB
Row 163 (D2): stack-release.sh rehearse and bus start --no-launch
Gitea #1660. Brief: docs/plans/2026-10-18_stack-release.md, "Stack rehearsal on a scratch root
(D2)", blob b1500d96ad. Reviewer: Filbert. Rulings: mos-0961, mos-0962, mos-0963.
Base: 4f02486d5 (origin/refactor). D2 lands after the Part B window (P19 385), because Part B's
window file names the stack-release.sh blob (mos-0958, mos-0959).
What it does
rehearse ID BUSINESS --backup ACTIVATION|PATH --port N [--timeout S] builds
<dataRoot>/rehearsals/<UTC second>-<copy 12>/ (0700) from a backup, runs a guard on the scratch
config with the target copy's code, runs the migration checks when the schema differs, starts the
target's bus start BUSINESS --no-launch and Console on the scratch root, gates them with D1's
checks minus the PM's, checks that no session started, stops both, and compares the backup and the
live config and business files with sums taken first. One PASS or FAIL line per check, then
REHEARSAL_PASS (0) or REHEARSAL_FAIL (1). Usage exits 4. docs/TOOLS.md has the operator text.
bus start --no-launch (brief item 4): start() refuses launches-disabled, so every launch path
spawns nothing; a session's launch request is refused and logged; listen() starts no thread
ticker; recover() signals and ends nothing and logs what it left; the Registry is read-only, so
sessions.json stays as an earlier host left it and add() refuses. The host-up line ends
, launches disabled. --pm with it is a usage error. No unit passes it.
Rulings carried
- mos-0961:
data-root-liverefuses a scratch dataRoot that resolves to the live data root;sessions-liverefuses when a sessions.json in the scratch data root names a running pid. The run never deletes the file or retries. recover() under --no-launch is covered by the launcher test "bus start --no-launch leaves an earlier host's live session alone: no signal, no end, sessions.json as it was". - mos-0962: every tracker.* variable is stripped, business and role level;
business.diffkeeps the change;tracker-configuredfails if bootConfig still sets trackers; notify.json is asserted to be exactly {"notifyVersion":1,"binding":null} at 0600. - mos-0963: every role credential reference and @sync's is moved to its own 0600 file under
<rehearsal>/placeholders/holdingrehearsal-placeholder-token;credential-ref-livefails for an env reference or a file whose real directory is not the placeholders directory. Test R9 leaves one role ref on a file outside placeholders and gets the FAIL; mutant K1 drops the check and R9 fails.
Outbound calls
The rehearsal reaches nothing off the host:
- reads: the live config.json, the live business file, the pointer, the backup, the copies
directory, the target copy's roles/, git in the canonical checkout (
copy_ok's merge-base); - writes: only
<dataRoot>/rehearsals/<stamp>/; - processes: the target's bus host (
--no-launch) and Console (serve --port N --business B, no--send), both withenv -i HOME PATH MOSAIC_CONFIG=<scratch config>; - network: 127.0.0.1:N for /healthz, and the host's sockets under the scratch data root.
No model call: launches are disabled and no session starts (no-session). No Vikunja: tracker.*
is stripped and tracker-configured checks bootConfig. No Discord: notify binding null. No Gitea:
every credential ref is a placeholder file, and the git socket serves only bound sessions, of
which there are none.
projects.stack.root stays at the live checkout, /mnt/storage/src/mosaic-stack. The business
file's projects are copied unchanged. Under --no-launch nothing makes a checkout from it, because
checkouts are made only in start(), which refuses.
Deviations from the brief
- Item 1 says to copy "the business file, roles and organization file from the live config
directory". Roles come from the target copy's
roles/, as the live bus reads them (rolesDir is<REPO>/rolesunless MOSAIC_ROLES_DIR is set, andenv -ileaves it unset). The live config directory has no organization file; bootConfig's organization entry is the config dir itself, which here is the scratch config dir. - The business file is rewritten, not copied (mos-0962, mos-0963), and
business.diffrecords the change. - The
trackerkey stays andtracker.sync.credentialsbecomes a placeholder: the business schema requires both, so stripping the key (mos-0962's wording) makes the loader refuse. bootConfig sets trackers only with tracker.baseUrl and a project's tracker.project, and both are stripped;tracker-configuredchecks the result. Authority: mos-0964 (2026-10-20T14:35:30Z), which accepts this and replaces the "@sync still present" clause of mos-0963 item 2: business.mjs:197 calls checkTracker on every load, and checkTracker requires tracker and tracker.sync (:57, :59) and checks sync.credentials (:63). @sync stays with its ref on a placeholder, andcredential-ref-livecovers it with the role refs. Evidence: R10 (a tracker still set failstracker-configured), K2 (the check dropped, R10 fails), K10 (the tracker.* strip dropped, R1 fails ontracker-configured), and K9 (the @sync swap dropped, R1 fails oncredential-ref-live). - mosaic-config already refuses a scratch config that names the live data root lexically, because
the rehearsal directory sits inside the live data root and a dataRoot may not contain the config
dir. The guard's
data-root-livecatches the form that reaches the live root through a link (R11). - Files owned: the brief lists stack-release.sh, its test, TOOLS.md and the packet. Item 4's flag adds packages/cli/src/cli.mjs, packages/cli/src/launcher.mjs, packages/seat/src/session.mjs and their tests (host.test.mjs, launcher.test.mjs, session.test.mjs).
- The brief's "free loopback port" is an operator-given
--port, refused when reserved (7332, 7333, the pointer's --port and --tester-port) or in use, never replaced by another port. - Brief item 3's "in the foreground": the host runs as a child of the script for the length of the gate, then gets SIGTERM; it is not a unit and not detached.
Tests
packages/cli/tests/stack-release.test.mjs, rehearse section (stand-in host, Console, migrate and Store in four fixture copies; real scripts/mosaic, mosaic-config, cli config and the business loader):
- R1 passes on the same schema: files, modes, placeholders, business.diff, the host's meta row in the scratch store and not in the backup, args logs, steps.log equal to the output, live files byte-identical.
- R2 migration path 3g to 3h; R3 migrate fails.
- R4 host fails, status fails, status names another pid, host launches, host keeps writer.lock.
- R5 SIGTERM ignored: SIGKILL and FAIL host-stopped. R6 healthz. R7 copy-match.
- R8 port in use, and 7332, 7333, the pointer's --port and --tester-port reserved.
- R9 credential-ref-live (mos-0963). R10 tracker-configured (mos-0962). R11 data-root-live through a link and sessions-live on a running pid (mos-0961). R12 notify-off.
- R13 old copy, backup missing, backup sums, no pointer, usage.
packages/cli/tests/launcher.test.mjs: recover() under --no-launch leaves an earlier host's live session alone; every launch is refused and logged; no thread tick (and the same host with launches runs it). packages/cli/tests/host.test.mjs: the host-up line, no sessions.json, --pm with --no-launch is usage, and no unit renders --no-launch. packages/seat/tests/session.test.mjs: a read-only Registry never writes and refuses add().
Mutants
33, one per behaviour, each killed (mutants.py, mutants-run.log and mutants-rerun.log beside this file). The first run left K11 (bus-status pid), K21 (scratch store linked to the backup), K27 (ticker under --no-launch) and K31 (cli drops launches); each got a test and the rerun killed all four. K21 now dies on FAIL backup-unchanged, the check it exists for.
Round 2 (Filbert r1, comment 29445: F1-F3 changes, N1)
Filbert's six mutants M1-M6 survived the R tests. Each named a check that worked but that no test
drove to FAIL. Round 2 adds those tests. stack-release.sh is unchanged (blob 0053f35d13).
- F1 (M1, M2): R14. A new fixture fault, host-touches, has the host append to a file it lists. Three cases (the live config, the live business file, the backup's bus.sqlite) each give exactly one FAIL, live-config-unchanged or backup-unchanged, after the gate passed and the host stopped.
- F2 (M3): R9 gains a pm gitea reference left on env REHEARSAL_GITEA_TOKEN, which fails credential-ref-live with "pm gitea is env REHEARSAL_GITEA_TOKEN" before the host starts.
- F2 (M4): R11 gains a scratch config whose dataRoot is another existing directory ("resolves to X, not R/data") and one whose dataRoot is not there ("X does not exist"). Both fail data-root-scratch before the host starts.
- F2 (M5): R4 gains host-launches-dir, a host that makes data/launches, which fails no-session with "found launches under".
- F3 (M6): R15 sends TERM, INT and HUP to a rehearsal whose gate is still waiting on /healthz. Each gives FAIL signal, then console-stopped, host-stopped, host-released-store, backup-unchanged and live-config-unchanged all PASS, REHEARSAL_FAIL, exit 1, and the host's pid is gone. rehearse() gained an e.during hook that gets the child process.
- N1: Registry.remove() wrote sessions.json under readOnly. #write() now returns at once when the registry is read-only, so the constructor, add() and remove() share one rule. The session test calls remove() on a read-only registry and checks that the file is unchanged.
Mutants K34-K41, one per finding and N1, are all killed (r2/mutants-run.log beside this file): K34 backup-unchanged always passes, K35 live-config-unchanged always passes, K36 env ref accepted, K37 data-root-scratch mismatch dropped, K38 missing root dropped, K39 launches dir ignored, K40 no INT/TERM/HUP trap, K41 read-only Registry writes.