Blobs of Darkwing's round 2da2da2f6con origin/darkwing/row232-r2 (base8fede5fc6), 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 onda2da2f6c, 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 to0444acd08since, three commits touching no manifest path. No live change: the stack runs from copy4429a104f, and --units user stays the default.
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, thescandjctlbackend,confirm,revert,gate --since,rollback --to,--plan --expect-from, the external gate (start floor, PM check fromlaunches list),external-pm, and theawaiting 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 helpersstack-release.test.mjsdefined, 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 newrow 232:tests and a widernode:fsimport.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 fromgit show cb5fdb064:. The same activate, rollback andstack-copy.sh checkrun 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 failsgit bundle verify, a bundle with norefs/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 matchescopy SHA's byte for byte, and therelease:andbundle 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--createa 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 oldActiveEnterTimestamp, sogate --sincefails it withstale-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 ownsscripts/stack-upgrade.shanddocs/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 externalthe script checks no claude pin of its own (no-claude-pinapplies only to user mode), because root's unit carries it. The--claudevalue, 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 at8fede5fc6) gives tree837bde7923with no conflict. The cherry-picked tip before this section has the same tree.packages/cli/README.mdis 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. Withgit patch-id --stable, the README diff from r1 to r2 is 02b679e2, the same as row 230's README diffcb5fdb064..8fede5fc6. This row's README diff is c395ecd2 on both bases (cb5fdb064..r1and8fede5fc6..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'sEnvironmentFile=line. That line is unchanged context in row 230's hunk. - No row 230 test reads
stack-release.shorstack-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.