Unclaimed files left in the canonical checkout. Inventory: canonical-cleanup-inventory.md (commit-as-evidence groups). detect-secrets scan clean on 2026-11-02: hits are sha256 pins, git object ids, test sentinels already in tracked tests and unit-file lines.
18 KiB
Row 169 (P19 382, #1666): activate past c216f47db with the PM after the harness kind
Owner: Darkwing. Reviewer: Filbert. Gate owner: Sage.
Brief: docs/plans/2026-10-19_pm-after-harness.md (blob bfc5fa7ba3), from mos-0848 (a) and (b).
Launch: sage-1014. Claimed at rev 1961.
Base: 4b738e2da, row 166's landing (sage-1070, mos-0852 point 5). Built first on row 166's
approved r1 candidate (manifest 4fc0613f...aced) applied on 56534526b; see "Rebase onto 4b738e2da".
Worktree: ~/darkwing-scratch/r169/wt at 4b738e2da. The 56534526b worktree moved to
~/darkwing-scratch/r169/wt-56534526b.
Files
| File | Change |
|---|---|
scripts/stack-release.sh |
items 1 to 4 and the --plan lines |
packages/cli/tests/stack-release.test.mjs |
T32 to T36; copies carry kinds.mjs, the business loader and roles/; every root gets a business file |
docs/TOOLS.md |
the "Pinned stack update" section only |
| this packet |
Items
1. Phase 1, activate ID BUSINESS --pm-after-harness. parse_opts takes the flag only for
activate, and --pm with it is a usage error (exit 4) in either order. plugin_checks runs
after precheck and before anything stops. In --plan it runs after check_target. It refuses:
pm-after-harness-targetwhen the target'sKINDShas noharness;pm-after-harness-setwhen the target's loader reads aplugins.harness,[]included. The message names--pm.
flags_drift needs a pointer with pm true for --pm-after-harness, as it compares --pm
with the pointer. run_pm sets the run's pm to 0 and pending to 1, so install_stack installs
the bus without --pm and the gate skips pm_launched. The pointer gets pm: false and
pmPending: true. The log's last line is result ok with pm pending, and the success line
names the next step.
2. The pending state elsewhere. read_pointer takes pmPending missing, or true beside
pm: false. Any other value, and true beside pm: true, is an unreadable pointer (exit 1), for
every verb. A plain activate, with or without --pm and with --pm-after-harness, refuses
pm-pending before anything stops. adopt keeps already-adopted. status prints
pm: pending (activate --pm-after-harness); next: Jason's plugins set of harness, then stack-release.sh pm-on BUSINESS.
3. pm-on BUSINESS [--timeout S]. In order, before anything stops:
not-pendingunless the pointer is pending;harness-unsetwhen the pointer copy's loader refuses the file, when it reads noharness, or whenharnessisn't a non-empty list;instance_busy,drop_in_override,read_units,no-claude-pinandflags_drift.
Then it takes a new activation id, logs started, captures the rollback set, stops the bus
alone, installs it on the pointer's copy with --pm and the pointer's claude, starts it and runs
the full gate. A pass writes pm: true with no pmPending and logs result ok pm on. The
Console unit, the copy and the store are never touched.
A failure goes to pm_on_fail: stop the bus, put_back the captured phase-1 unit, start it and
rerun the gate without pm_launched. A pass there logs pm-on-failed with the code and why, keeps
the pointer pending and exits 1. A failure there stops both units, logs pm-on-down with both
codes and exits 2.
4. Rollback and plug-in kinds. run_pm gives a rollback from a pending pointer pm 1, so OLD
goes back with --pm and the gate checks pm_launched. plugin_checks runs for activate and
rollback and refuses unknown-kind-for-target when the business file's plugins has a key
the target's KINDS lacks. It names every such kind. On a rollback the message adds the fix:
set each to null with plugins set through the live bus.
5. --plan. In plan mode check_refuse collects refusals instead of exiting. plan prints
the pending pointer line, the pending-start line for --pm-after-harness, then
refuses CODE: message for each refusal, and exits 3 when there is any.
Design notes
pmPendingis a separate key becauseread_pointerrefuses a non-booleanpm. True is only valid besidepm: false, so a pointer can't claim both a live and a pending PM.- One node helper (
PLUGINS_JS) imports the copy's ownkinds.mjs, and forharnessitsbusiness.mjsloader with the copy'sroles/. The unknown-kind check is a plain JSON read ofplugins, because OLD's loader refuses the first unknown kind and the brief wants every kind named. A copy withoutkinds.mjsfails the import and refusesbusiness-unreadable. No fallback. pm-after-harness-settreats[]as set: the file already has the kind, so the operator should see that rather than a phase 1 thatpm-onthen refuses.pm-onneeds a non-empty list, since[]switches no harness on.pm-onhas its own activation id.from_logreads onlyadopt,activateandrollbacklines, so apm-online is never a rollback target or a backup source. It reusescaptureandput_back, so the phase-1 unit is restored byte for byte.flags_driftneeds no pending clause. A pending pointer haspmfalse and its unit has no--pm, so they match.activaterefusespm-pendingbeforeflags_driftandadoptrefusesalready-adopted, so onlypm-onandrollbackreach it with a pending pointer.- A plain
activate --planfrom pending prints the--pmit would install (the run pm is 1) and thenrefuses pm-pending. - Tests use the real loader and
kinds.mjs. Copies older thanc216f47dbgetkinds.mjswithharnessrenamed, soAandBlack the kind andC1andChave it.
Tests
T32 to T36 in packages/cli/tests/stack-release.test.mjs. Each refusal test checks exit 3, the
message, an unchanged fingerprint, no stand-in stop or start, and one log line.
| Test | Brief item | Covers |
|---|---|---|
| T32 | 1 | usage errors, pm-after-harness-target, pm-after-harness-set (["claude-code"] and []), pm-less pointer drift, phase 1 pass |
| T33 | 2 | read_pointer bad pmPending values, pm-pending for three activate forms, already-adopted, the status line |
| T34 | 3 | not-pending, harness-unset (none, []), pass, pm-on-failed (exit 1), pm-on-down (exit 2) |
| T35 | 4 | rollback from pending with --pm, unknown-kind-for-target on rollback then pass after the reset, on activate naming each kind |
| T36 | 5 | --plan pending lines and refusals |
Mutants
mutants.py (copy in this packet, run log mutants-run.log), run with python3 -I. Each
mutant edits one check and must fail the named test.
| Id | Edit | Fails (node's fail count) |
|---|---|---|
| 1a | pm-after-harness-target removed |
T32 (2 failed) |
| 1b | pm-after-harness-set removed |
T32 (2 failed) |
| 1c | --pm with --pm-after-harness allowed |
T32 (2 failed) |
| 1d | --pm-after-harness accepts a pointer without pm |
T32 (2 failed) |
| 1e | phase 1 installs and gates --pm |
T32 (2 failed) |
| 1f | phase 1 writes no pmPending |
T32 (2 failed) |
| 2a | read_pointer takes any pmPending |
T33 (2 failed) |
| 2b | read_pointer takes pmPending beside pm true |
T33 (2 failed) |
| 2c | pm-pending removed |
T33 (2 failed) |
| 2d | status prints no pending line |
T33 (2 failed) |
| 3a | not-pending removed |
T34 (2 failed) |
| 3b | harness-unset for a missing harness removed |
T34 (2 failed) |
| 3c | harness-unset for an empty list removed |
T34 (2 failed) |
| 3d | pm-on installs without --pm |
T34 (2 failed) |
| 3e | pm-on gates without pm_launched |
T34 (3 failed) |
| 3f | a failed pm-on keeps the --pm unit |
T34 (3 failed) |
| 3g | a failed pm-on skips the phase-1 gate |
T34 (2 failed) |
| 4a | rollback from pending installs without --pm |
T35 (2 failed) |
| 4b | unknown-kind-for-target removed |
T35 (3 failed) |
| 4c | unknown-kind-for-target names only the first kind |
T35 (2 failed) |
| 5a | --plan prints no pending pointer line |
T36 (1 failed) |
| 5b | --plan lists no refusal |
T36 (1 failed) |
| 5c | --plan skips the plug-in checks |
T36 (1 failed) |
Rebase onto 4b738e2da (sage-1070)
Row 166 landed as 4b738e2da. At 4b738e2da, scripts/stack-release.sh (blob ed5b41d467) and
packages/cli/tests/stack-release.test.mjs are byte for byte the files in row 166's approved
manifest, so the base for those two did not move. docs/TOOLS.md changed once between 56534526b
and 4b738e2da: row 134 (3a600cf5b) rewrote two lines near line 685, outside the "Pinned stack
update" section.
git apply of the old build.patch on a clean 4b738e2da worktree: every hunk applied, no fuzz,
no offset. The one thing that didn't apply as written is the scripts/stack-release.sh header
old mode 100644 / new mode 100755. The old patch was a git diff --no-index against a file
copy of 166's tree, where the file was 0644 on disk. At 4b738e2da the file is already 100755, so
git warned "has type 100755, expected 100644" and changed no mode. The new patch has no mode lines.
After the apply, scripts/stack-release.sh (blob 0b501ffbc2) and the test file are identical to
the 56534526b candidate. docs/TOOLS.md carries the same added and removed lines at the same hunk
line numbers, on a base that includes row 134's change, so its sha256 is new.
The patch-ids (git patch-id --stable): old 5dca3351b279, new 17a90c4ad795. With the two mode
lines removed, the old patch also gives 17a90c4ad795, so the content of the change didn't move.
Results on 4b738e2da
On Sage's go (sage-1070), behind busy.sh, in the 4b738e2da worktree,
TMPDIR=~/darkwing-scratch/r169/tmp, spec reporter, sums from awk over the logs:
| File | Tests | Pass | Fail |
|---|---|---|---|
packages/cli/tests/stack-release.test.mjs |
93 | 93 | 0 |
packages/cli/tests/stack-copy.test.mjs |
12 | 12 | 0 |
| total | 105 | 105 | 0 |
T32 to T36 are among the 93 and all passed on the first run.
Mutants, python3 -I mutants.py right after (unchanged file; DRY=1 found every anchor first):
CONTROL passed (T32 6, T33 3, T34 6, T35 4, T36 1 subtests) and all 23 mutants were killed, each
failing the test in the table, the same tests and fail counts as on 56534526b. The run restored
scripts/stack-release.sh to blob 0b501ffbc2, and the sweep after found 0 processes. Log:
mutants-run.log.
Results on 56534526b (superseded)
On Sage's slot go (sage-1028), in the worktree, TMPDIR=~/darkwing-scratch/r169/tmp, spec
reporter, sums from awk over the logs:
| File | Tests | Pass | Fail |
|---|---|---|---|
packages/cli/tests/stack-release.test.mjs |
93 | 93 | 0 |
packages/cli/tests/stack-copy.test.mjs |
12 | 12 | 0 |
| total | 105 | 105 | 0 |
T32 to T36 are among the 93 and all passed on the first run.
Mutants, run with python3 -I mutants.py right after: CONTROL passed (T32 6, T33 3, T34 6, T35 4,
T36 1 subtests) and all 23 mutants were killed, each failing the test in the table. The run
restored scripts/stack-release.sh to its pristine blob, 0b501ffbc247, and the sweep after
found 0 processes.
Only stack-release.test.mjs reads scripts/stack-release.sh or docs/TOOLS.md among
packages/*/tests and scripts/test-*.sh, and it pins no hash.
Candidate
build.patch:git diff 4b738e2daof the three files in the worktree. Applied to a clean4b738e2datree it gives the manifest's three sha256 (checked withsha256sum -c). patch-id 17a90c4ad79550af4f2910199338dcdd9bb20dc5.candidate-manifest.sha256: the three files' sha256, digest c6aaac1ffa80a5c82158700113ab92f103630df5043ddd5c5164ab936e8f54af.- Superseded, kept:
build-56534526b.patch(sha256 89c2bd5863de),candidate-manifest-56534526b.sha256(digest 0b304705d58a),mutants-run-56534526b.log.
While renaming the old files I overwrote them with the new ones (git mv -k skips untracked
files and exits 0). I rebuilt the old patch and manifest from the 56534526b worktree with the
same commands, and both match the sha256 recorded when they were first written. The old mutants
log is the run's own copy from ~/darkwing-scratch/r169/out-56534526b/.
Rehearsal
Run 2026-10-19T22:52:43Z to 22:53:27Z (attempt 5), scratch only, on Sage's go (sage-1075) with
Mos's conditions (mos-0878, sage-1077). Files are in rehearsal/. Every file's sha256 is in
rehearsal/SHA256SUMS, whose own sha256 is
a36c89eb1ec3cfe43ceabba13e19739611d670a31e3d0019bb8ec6261ee9b031.
- Root
~/darkwing-scratch/r169/reh, outside~/.mosaic-dev. It has agit clone --sharedof the canonical checkout at4b738e2dawith this candidate applied, sosha256sum -c candidate-manifest.sha256passes in the clone.refactorin the clone isd195d171c. OLD isca15d73e9and NEW isd195d171c, both made fresh withstack-copy.sh copy. - The business file is the committed example (blob
3a81ca0fae) with{"usage-source":["transcript"],"channel":["cli","webui"]}and noharness(scratch-business-B.json, sha256 629931c5...).scratch-business-H.jsonadds"harness":["claude-code"]for step 1. - Every call runs under
env -iwith HOME, PATH, TMPDIR, LANG, MOSAIC_CONFIG, XDG_* and SD_* only. There is no credential and no pi variable, and the scratch HOME is empty. - The units are stand-ins (
harness/systemctl). No live bus or Console unit was touched.
Live store
stat -c '%n %Y' before step 1 and after step 6, from rehearsal-169-live-store-stat.txt:
# before prep, 2026-10-19T22:52:43Z
/home/jwoltje/.mosaic-dev/bus/bus.sqlite 1792446324
/home/jwoltje/.mosaic-dev/bus 1792446325
# after teardown, 2026-10-19T22:53:27Z
/home/jwoltje/.mosaic-dev/bus/bus.sqlite 1792446324
/home/jwoltje/.mosaic-dev/bus 1792446325
They match. They also match before and after each of attempts 1 to 4.
Result
The transcript (rehearsal-169.txt) has 29 parts: 23 exit 0, 2 exit 1 and 4 exit 3. Each
nonzero exit is the refusal its part expects:
| Brief step | Parts | Outcome |
|---|---|---|
| 1 | 1a, 1b, 1c | OLD on H refuses unknown kind "harness", exit 1. NEW on H and OLD on B pass |
| 2 | 2a, 2b, 2c | activate --pm-after-harness --plan, then phase 1. The pointer has pmPending: true and the bus unit has no --pm |
| 6, from pending | 6a-1 to 6a-3 | rollback from pending installs OLD with --pm and passes the gate |
| 4, before 3 | 2d, 4-pre | a fresh phase 1, then pm-on refuses harness-unset, exit 3 |
| 3 | 3 | harness ["claude-code"] set (see the stand-in below) |
| 4 | 4, 4a, 4b | pm-on passes. The pointer has pm: true and the unit has --pm. The PM launch is emulated |
| 6, after the set | 6b-1 to 6b-4 | rollback --plan and rollback refuse unknown-kind-for-target by name before any stop (exit 3). After harness is set to null, rollback passes |
| 5 | 5a, 5b, 5-route, 5c to 5e | a fresh phase 1 with harness ["pi"], then a real pm-on. The real launchPm refuses no-route and the gate fails units-inactive. pm_on_fail puts the phase-1 unit back and the phase-1 gate passes. The run exits 1 with the pointer still pending, and launches list is [] |
| 6, from step 5 | 6c-1 to 6c-4 | rollback refuses unknown-kind-for-target. After harness is set to null, OLD is back with --pm |
Step 5 started no pi process. Part 5c lists the processes under the root: the bus and the
Console stand-in, and 0 named pi. The 5-route guard ran before the real pm-on. It passes only a
chief route that check() refuses before the loader: no-route, or a claude-code primary with
no fallback, which harness-off refuses with ["pi"]. It found no-route.
Teardown stopped both units, port 47393 closed, and 0 processes were left. The Docker network
list (docker-networks-before.txt, -after.txt) has the same 15 names before and after. My
sweep afterwards found 0 processes.
Stand-ins and findings
- Step 3 is a stand-in. The bus refuses the real
mosaic pluginsread and sethuman-required.verifyHuman(packages/bus/src/human.mjs) walks the caller's parents and refuses at this agent's claude process. That is the agent check working, and I didn't try to get past it.harness/hcli.shruns the real CLI first and records the refusal (exit 3). Thenharness/hset.mjscalls the writer the NEW host builds forplugins.set(packages/bus/src/runtime.mjs:93:pluginSettingswith the same dir, rolesDir andhasModule) with actorstandin.darkwing. So the file write, the revision check and the module check are the host's own. Only the bus's human proof is skipped. Steps 6b-3, 5b and 6c-2 use the same stand-in. In the window, the set is Jason's, in his own shell. scripts/mosaicatd195d171cdoesn't dispatchplugins.scripts/mosaic pluginsprints the seat usage text, so the stand-in callspackages/cli/src/cli.mjs plugins. This matters for any window step that saysmosaic plugins set. TOOLS.md and the window plan should name the command that works on the pinned copy.- The example's chief has no
harnessvar. Since row 65,check()takes the route's harness from the instance vars only (routeOf,packages/cli/src/launcher.mjs). With the briefed file, every real PM launch at NEW refusesno-routeatrouteOf("primary"). That happens before readSkills, the harness-off check and the loader, whateverplugins.harnessholds. So step 5's refusal isno-route, notharness-off. pm_on_fail's restore path doesn't depend on the code, and the transcript shows it. Step 4 is emulated (a real claude-code launch needs a session credential in preflight), and a real one on this file would also refuseno-route. Whether Jason's live file sets the chief'sharnessvar is for the window plan's checks. I didn't read that file. - The scratch human id is the example's
"jason". It appears in the refused launch event in part 5c ("by":"jason"). It is a name in a scratch business, and nothing ran as Jason or in a human shell.
Attempts
Attempts 1 to 4 stopped on harness bugs, not on the candidate. Each transcript is kept as
attemptN-rehearsal-169.txt, and each attempt's teardown left 0 processes, the live stat
matched, and the networks were unchanged.
- Step 3:
hcli.shread.host.pathfrombus status --json, which has no such field. - Step 3:
scripts/mosaic pluginsisn't dispatched (finding 2). - Step 3: the real
mosaic pluginsis refusedhuman-required(finding 1). - 5-route: my probe called
resolveInstancewithsystem: {}instead of thesystemVars(config)thatcheck()uses. It threw, so the guard stopped the run before 5c. That probe found finding 3.
The candidate files didn't change, so the digest stays c6aaac1ffa80a5c82158700113ab92f103630df5043ddd5c5164ab936e8f54af.