Files
jason.woltje e65a3ab4d2 stack-release: --units external for root-owned units, with confirm, revert and gate --since; stack-copy copy --bundle and wrapper --create (row 232, #1734, installer I1)
Blobs of Darkwing's round 2 da2da2f6c on origin/darkwing/row232-r2 (base
8fede5fc6), the 9 manifest paths, manifest 7af3c943. Round 2 is a
rebase-only round over row 230 (c8bc5cf4c): packages/cli/README.md is a
clean 3-way merge with 232's hunk unchanged, and the other 8 paths keep the
round 1 hashes (manifest 7a3b4ea1). Filbert approve round 1 (comment 29829)
and round 2 (comment 29865).

Under --units user every command line and every byte of output is the
base's. Under external the script reads root's system units, starts and
stops none, stages a copy behind AWAITING-UNITS, and confirm gates root's
start on the bus unit's ActiveEnterTimestamp and launches list.

Mutants 17 of 17 killed (BUILD.md). Darkwing's landing gate on da2da2f6c,
03:07:15Z to 03:17:48Z: packages/cli 593 pass 0 fail, 10 scripts/test-*.sh
suites 429 pass 0 fail (plus 156 node tests in the queue suite). Refactor
moved to 0444acd08 since, three commits touching no manifest path. No live
change: the stack runs from copy 4429a104f, and --units user stays the
default.
2026-10-20 22:19:47 -05:00

19 KiB

Row 232 (I1): stack-release external units and bundle copies

Brief: docs/plans/2026-10-20_installer-rows.md section I1, blob f35e7e57daee (commit ad6f0be7a). Gitea #1734, Vikunja P19 #432. Owner Darkwing, reviewer Filbert, gate owner Sage. Design input: row 195 DESIGN r6, agents/darkwing/work/queue-195/DESIGN.md at b7239bb66 (approved in duckworth-0055). Where the brief and r6 differ, the brief wins. sage-1561 ruled where the stack user's stack-release.sh runs: from its own clone of the release bundle, so check_target, the plan's commit list and status drift are unchanged and nothing adds safe.directory.

Files

Base: cb5fdb064 (row 199's landing, on top of row 200's 9e1085816). Branch darkwing/row232-r1. The first build, 0af0c73c6 on darkwing/row232, sat on 00d4cc1f6 (row 190); row 200 landed first, so this one is rebased onto it (see D9 and D10).

  • scripts/stack-release.sh: --units user|external, the sc and jctl backend, confirm, revert, gate --since, rollback --to, --plan --expect-from, the external gate (start floor, PM check from launches list), external-pm, and the awaiting units: status line.
  • scripts/stack-copy.sh: check --units external, copy --bundle FILE SHA, wrapper ID --create.
  • packages/cli/tests/stack-release-external.test.mjs (new, 25 tests).
  • packages/cli/tests/stack-release-fixture.mjs (new): the helpers stack-release.test.mjs defined, moved so both files share them. See deviation D1.
  • packages/cli/tests/stack-release.test.mjs: the helpers leave and one import line comes in. The 74 tests have the same names and the same bodies as on the base.
  • packages/cli/tests/stack-copy.test.mjs: three new row 232: tests and a wider node:fs import.
  • docs/TOOLS.md: the stack-copy usage block and three paragraphs, the stack-release usage block, and a new subsection "Root-owned units (--units external, row 232)".
  • packages/cli/README.md: one paragraph.
  • This file.

The worktree's node_modules symlink is not part of the candidate.

User mode is unchanged

Every systemctl call goes through sc and every journalctl call through jctl. Under user they are systemctl --user "$@" and journalctl --user "$@". Under external, sc allows only show, is-active and list-units, and jctl returns 1, so an external run can't start, stop, install or read the journal even through a path the tests miss.

Two tests hold the brief's "every command line and every byte of output":

  • user mode: every systemctl call carries --user, and activate, rollback and check make the base's calls. A fixture repo gets the base's two scripts from git show cb5fdb064:. The same activate, rollback and stack-copy.sh check run from both trees, and the recorded argv (with the fixture root and port replaced by placeholders) must match line for line. Every call carries --user.
  • user mode: a default activate --plan prints the base's bytes, for four plans.

The base output is produced live from the base blobs, not stored as a fixture file. See deviation D3.

r6 section 7, test by test

All in stack-release-external.test.mjs unless named.

r6 :742-812 item Test
units-running (N4), store, log and TAKEN left units-running (N4) refuses while a unit is active...; also checks that failed counts as down and that the Console is checked first
activate leaves the pointer, writes AWAITING-UNITS and a backup activate stages: ..., which also checks the set's file list, UNITS.sha256, no --user, no journal call and status
open-activation with zero, two, the wrong copy, one not open confirm and revert refuse open-activation ...
confirm polls: 3 s fails at --timeout 1, passes at --timeout 10 confirm polls: bus status good after 3 s fails ... and confirm polls to a pass ...
stale-start floor with started at .900 stale-start: @1792540799 fails ... and stale-start is a gate code ...
confirm pass writes pointer, ok, removes marker; timeout writes FAILED and exits 1 the two polling tests
activation-failed when FAILED exists confirm polls: ... FAILED then refuses activation-failed
lines carry the started op, from, to, activation and backup sameAsStarted in every confirm test, and sequence 7
gate (N2) with and without --since gate (N2, A2f): ... and the usage test
--plan --expect-from (N5), activate and rollback --to sequence 7
A3 order: capture-failed, marker-failed, wrapper failure after migrate A3 order: ... and sequence 2
refusals log no activation; interrupted unchanged open-activation test and sequence 5
PM check: one, none, two, early, not live, other business, other instance; no journal call the external PM check reads launches list ... (modes '', coder, none, twolive, early, dead, other) and journalCalls in every external test
external-pm pm-on and activate --pm-after-harness refuse external-pm
revert restores TAKEN only after a migration revert puts the wrapper back and leaves the store ... and sequences 2 and 8
confirm --rollback per cause, unrecorded, rollback-failed, mismatch sequences 1, 2, 3, 5 and 8, and confirm --rollback refuses rollback-mismatch, and logs rollback-failed ...
sequences 1-8 sequence 1: to sequence 8:, each ending with the closed check (one started and one result line, empty interrupted, no marker or FAILED, a following --plan that doesn't refuse interrupted-run)
rollback --to refuses not-previous; rollback --restore finds an external backup sequence 7, and confirm --rollback after a --restore swap ...
gate writes nothing the gate (N2, A2f) test and sequence 4 compare the log, the pointer and the state tree
confirm, revert, gate, --to refuse without --units external confirm, revert, gate, --to and --expect-from need --units external ...
default path: every call carries --user; default --plan bytes the two user-mode tests above

The brief's additions, in stack-copy.test.mjs:

  • row 232: copy --bundle copies a SHA the bundle's refactor reaches, byte-identical to copy SHA, and refuses the rest: a short SHA, a missing file, a symlink, a bundle that fails git bundle verify, a bundle with no refs/heads/refactor, a SHA not in the bundle, and a SHA the bundle carries on another branch that refactor doesn't reach. The bundle copy's tree matches copy SHA's byte for byte, and the release: and bundle sha256: lines are checked.
  • row 232: wrapper --create writes the first wrapper only, the same bytes wrapper ID writes, and refuses over any file, a symlink included. Without --create a missing wrapper still refuses.
  • row 232: check --units external reads the system units, the default reads the user units.

Mutants

mutants.sh in my scratch (not committed, it edits the worktree), 17 mutants: r6's re-pin list (:817-838) minus the three case1-repin.sh ones, plus the brief's two. Each one applies exact replacements after a checked backup, runs bash -n, runs its named killers, restores the file and checks its sha256. Two controls run first with no change: the whole external file, and stack-copy's row 232: tests.

Mutant Killers
m01 confirm writes the pointer on a gate failure confirm polls: bus status
m02 gate_once in place of gate confirm polls to a pass, stale-start is a gate code
m03 PM check counts any instance external PM check
m04 PM check drops the startedAt bound external PM check
m05 confirm takes the newest AWAITING-UNITS open-activation
m06 rollback --to skips the comparison sequence 7:
m07 the default path drops --user from one call (is-active) user mode: every systemctl
m08 the timeout writes r4's interim rollback pending line confirm polls: bus status, sequence 1:, sequence 5:
m09 AWAITING-UNITS last, after the wrapper sequence 2:
m10 a refusal names the open activation sequence 5:, open-activation
m11 stale-start is a refusal stale-start is a gate code
m12 revert restores without MIGRATING revert puts the wrapper back
m13 confirm logs op confirm sequence 7:, confirm polls to a pass
m14 MIGRATING after the migration sequence 8:
m15 gate takes its floor from the log without --since gate (N2
m16 copy --bundle skips the reachability check stack-copy row 232:
m17 wrapper --create overwrites stack-copy row 232:

Deviations and choices

D1. The shared helpers moved to a new file, packages/cli/tests/stack-release-fixture.mjs. The brief's files list names stack-release.test.mjs "where a shared helper moves" but not a destination. Importing from the other test file would run its 74 tests twice, so the helpers need a module of their own.

D2. The fixture's T17 check (what a run may never remove) now exempts a rollback set's AWAITING-UNITS and FAILED. confirm removes both when it closes the activation (r6 A5 and R4). User mode writes neither, so user-mode runs check exactly what they did.

D3. The user-mode argv and --plan tests read the base's scripts with git show cb5fdb064: rather than storing their output as a fixture file. A later row that changes user-mode output on purpose moves the BASE constant in the test to its own base, or the test fails. I chose this over a stored fixture because a stored copy can drift from the base without anyone noticing.

D4. marker-failed is reached by making state/stack-rollback unwritable. r6 says "an existing $RS", but next_activation never picks a taken name, so no test can make the run meet an existing $RS. The exclusive mkdir is still what refuses.

D5. The stale-start polling case turns the enter time to @1792540800, not r6's @1792540805, with the started line at 1792540800.900. That also checks that the floor is the whole second, so .900 doesn't push it to 1792540801.

D6. No fake sudo or case2-unit.sh. r6's sequences ran under them because r6 had case1-repin.sh drive the steps, and that script is I3's. Here the test plays root: it stops and starts the fake system units and installs with the existing bus-service.sh and webui-service.sh stand-ins, between the real stack-release.sh calls, on one log.

D7. The fake marker in the open-activation test is named 20000101T000000Z-<C1 prefix> so it sorts before the real one. A marker that sorted after it would leave m05 (newest marker) alive.

D8. m10 sets ACT inside open_activation, where r6's wording ("ACT set before the checks") lands in this code. ACT is set only after every check in confirm.

D9. Row 200's --sso is a usage error under --units external, with the proof flags, --no-proof and --no-send. It is a Console flag, and root's Console unit carries the Console flags; the script under external renders no Console line and never calls console_for_target, so sso_checks doesn't run there either. The usage text, the TOOLS.md bullet and the external usage test name it.

D10. Row 200's config_checks runs under --units external too, after plugin_checks, in both the --plan and run paths of activate and rollback --to. It checks the target's loader against the stack user's config.json, which the bus on a VM still reads, so a console key the target refuses would break root's bus start the same way. The cost is one more refusal (config-console-key) an external run can print; I took it over a bus that fails after root's start. The rebase changed no user-mode line: the conflicts were the SSO=0 globals line, the plan body (row 200's SSO_PLAN line sits in the user branch) and the two --plan lines that gained config_checks. In the tests, row 200's fixture lines (the loaders, sso-config.mjs and the --sso cli.mjs line for C1 and C) moved into stack-release-fixture.mjs with the rest of the copy setup, and the ALGS import stays in stack-release.test.mjs, which T57 uses.

duckworth-0055's notes on r6 (brief line 169)

Duckworth's r6 approval (REVIEW-r5.md at 23f5520b5) carried two notes.

  • N1, A2's stop timeout under gate --since. A unit that never stopped keeps its old ActiveEnterTimestamp, so gate --since fails it with stale-start, and the run ends at exit 2 with root stopping the units. That is intended: a unit that won't stop is a fair reason to stop. In I1 this is the gate code and its polling (stale-start: @1792540799 fails ..., gate (N2, A2f), m11). The run sheet's A2f line and the re-pin test that points "exits 1 with OLD's gate passing" at an A3 exit 3 belong to I3, which owns scripts/stack-upgrade.sh and docs/guides/host-run-sheet.md. I'll name both in the I3 build.
  • N2, the claude path under /opt/mosaic-stack (gosalyn-0058, sage-1522). I1 doesn't touch it. Under --units external the script checks no claude pin of its own (no-claude-pin applies only to user mode), because root's unit carries it. The --claude value, the managed-settings reasoning, F6 and the run sheet's claude step go to I2 and I3.

Run environment

Worktree /mnt/storage/scratch/darkwing-r232, TMPDIR=/mnt/storage/scratch/tmp/r232, node from PATH. One external file run takes about 94 s.

Results

Singles on the rebased candidate, before the gate:

File Tests Pass Fail
packages/cli/tests/stack-release-external.test.mjs 25 25 0
packages/cli/tests/stack-copy.test.mjs 16 16 0
packages/cli/tests/stack-release.test.mjs (row 200's T57 included) 192 192 0
packages/cli/tests/stack-instances.test.mjs 23 23 0
packages/webui/tests/pinned-copy.test.mjs 10 10 0
packages/cli/tests/channel-recovery.test.mjs (imports channel-fixture.mjs, which names the scripts) 1 1 0
packages/cli/tests/host.test.mjs (reads packages/cli/README.md) 42 42 0

These are every test file that reads stack-release.sh or stack-copy.sh (a grep of packages/*/tests and scripts/). None of them pins a sha256 of a changed file. packages/cli/tests/host.test.mjs reads packages/cli/README.md for a unit file name; this row only adds a paragraph there. The grep and the runs were repeated on the rebased tree on 2026-10-21; the reader set is the same. A dry run of mutants.sh matches each of the 17 replacements once on the rebased scripts.

Gate, 2026-10-21, Sage's slot (sage-1618)

Candidate a0ffabc3f on darkwing/row232-r1, run by ~/darkwing-scratch/r232/gate.sh: the cli suite, then every scripts/test-*.sh, one after another, stopping at the first failure. busy.sh passed at 01:07:49Z after Launchpad's single stack-release.test.mjs run ended. The gate stopped at the first one, so the chain waited for that PID to exit and checked again. The EXIT trap removed the compose network; no container was left.

Step Time (UTC) Result
packages/cli, node --test tests/*.test.mjs 01:07:50 to 01:17:53 561 pass, 0 fail, 0 skipped
scripts/test-auth.sh 01:17:53 to 01:17:54 15 passed, 0 failed
scripts/test-conductor.sh 01:17:54 to 01:18:00 17 passed, 0 failed
scripts/test-config.sh 01:18:00 to 01:18:02 31 passed, 0 failed
scripts/test-discord.sh 01:18:02 to 01:18:12 78 passed, 0 failed
scripts/test-extension-package.sh 01:18:12 to 01:18:13 18 passed, 0 failed
scripts/test-foundation.sh 01:18:13 to 01:19:01 44 passed, 0 failed
scripts/test-land-work.sh 01:19:01 to 01:19:14 86 passed, 0 failed
scripts/test-queue.sh 01:19:14 to 01:19:37 156 node tests pass, queue suite 28 passed, 0 failed
scripts/test-release.sh 01:19:37 to 01:19:51 14 passed, 0 failed
scripts/test-task.sh 01:19:51 to 01:20:44 98 passed, 0 failed

Mutants, 2026-10-21, same slot

~/darkwing-scratch/r232/mutants.sh, right after the gate. Both controls (the external file whole, and stack-copy's ^row 232: tests) exited 0 on the unmutated tree. Each mutant was applied after a checked backup and a syntax check, ran its killers from the table above, and was put back with its sha256 checked. The script stops on any restore mismatch, and it ran to its end.

Mutant Killer exit Result
m01 pointer on gate failure 1 killed
m02 gate once 1 killed
m03 PM from any instance 1 killed
m04 no startedAt check 1 killed
m05 newest marker 1 killed
m06 --to not compared 1 killed
m07 one call without --user 1 killed
m08 interim pending line 1 killed
m09 marker written last 1 killed
m10 refusal names the open activation 1 killed
m11 stale-start refuses 1 killed
m12 revert always restores 1 killed
m13 confirm op 1 killed
m14 MIGRATING after migrate 1 killed
m15 gate floor from the log 1 killed
m16 bundle reachability unchecked 1 killed
m17 --create overwrites 1 killed

17 of 17 killed, 01:22:16 to 01:24:32Z. I read the logs for m01 and m09, two of the quick kills: each failed on its named test's assertion (m09 on AWAITING-UNITS in sequence 2), not on a load error.

Round 2: rebase onto 8fede5fc6 (sage-1673)

Rebase only. Before the landing gate, a check of BASE..HEAD on the manifest paths found that row 230 (c8bc5cf4c) had changed one of them after the round 1 base, cb5fdb064. Sage ruled a re-round with a scoped review, the same shape as row 210 round 3.

Branch darkwing/row232-r2: the two round 1 commits, a0ffabc3f and 84f1a91e6, cherry-picked onto 8fede5fc6 with no conflict, then this section. No code or test changed.

  • git merge-tree --write-tree origin/refactor darkwing/row232-r1 (refactor at 8fede5fc6) gives tree 837bde7923 with no conflict. The cherry-picked tip before this section has the same tree.
  • packages/cli/README.md is a clean 3-way merge. Row 230's hunk is at lines 710-717 (the session credential paragraph); this row's is a new paragraph at 773. With git patch-id --stable, the README diff from r1 to r2 is 02b679e2, the same as row 230's README diff cb5fdb064..8fede5fc6. This row's README diff is c395ecd2 on both bases (cb5fdb064..r1 and 8fede5fc6..r2).
  • The seven code, test and doc paths other than the README hash the same as in manifest 7a3b4ea1, which Filbert approved in round 1: docs/TOOLS.md, packages/cli/tests/stack-copy.test.mjs, packages/cli/tests/stack-release-external.test.mjs, packages/cli/tests/stack-release-fixture.mjs, packages/cli/tests/stack-release.test.mjs, scripts/stack-copy.sh, scripts/stack-release.sh. This file also matched until this section was appended. Its other lines are unchanged.
  • The one test that reads the README, host.test.mjs:855, looks for the unit's EnvironmentFile= line. That line is unchanged context in row 230's hunk.
  • No row 230 test reads stack-release.sh or stack-copy.sh, and no test of this row reads a row 230 file.

Nothing was run for this round. The landing gate runs on the r2 tip on Sage's slot.

Follow-up, doc-only (sage-1673): scripts/case2-unit.sh:108 and scripts/case2-precheck.sh:157 cite scripts/stack-copy.sh:8 and :22 for COPIES and copy_root. With this row those lines are :10 and :26. Only comments are affected and no test reads them. Row 233 doesn't touch those scripts, so the fix goes on the #1732 carry list.