355 lines
18 KiB
Markdown
355 lines
18 KiB
Markdown
# Stack release, rollback and rehearsal (D1, D2), 2026-10-18
|
|
|
|
Status: written by Sage, lead, for decision 96's D track (Q97). Decision 96
|
|
orders S7, then M1-M2, then D1-D2 beside I1, then I2-I6. I1 is written
|
|
(`docs/plans/2026-10-18_containerized-install-design.md`), so D1 and D2
|
|
come before row 157 (I2). It follows `docs/plans/BRIEF-TEMPLATE.md`. Two
|
|
rows. Line numbers are at 7dfda5182.
|
|
|
|
## Background
|
|
|
|
Every live change since row 85 goes out as a new copy and a re-pin
|
|
(`docs/plans/2026-10-12_one-copy-stack.md`). The bus, the Console and the
|
|
human CLI all run from one read-only copy:
|
|
- `scripts/stack-copy.sh copy` (:34-60) makes the copy;
|
|
- `bus-service.sh install --copy` (:132-170) and
|
|
`webui-service.sh install --copy` (:242-281) write the units;
|
|
- `stack-copy.sh wrapper` (:61-74) switches the CLI;
|
|
- `stack-copy.sh check` (:87-98) confirms all three point at the same copy.
|
|
|
|
What happens between those steps is manual. `docs/TOOLS.md:343-381`
|
|
("Pinned stack update") lists seven operator steps. Rollback means
|
|
repeating steps 2-6 with the earlier id, and nothing records which copy
|
|
is live or which copy came before it. The bus has no health endpoint. The
|
|
Console's `/healthz` returns a fixed 200 (`packages/webui/src/serve.mjs:355`).
|
|
A schema change has no backup in product code: `packages/bus/README.md:678`
|
|
says only "after a consistent backup". The store migrates forward only
|
|
(`packages/bus/src/store.mjs:21-50`), and there is no down-migration.
|
|
|
|
The real windows filled these gaps with plan-local scripts. The de352dfaf
|
|
window (`agents/sage/work/window-plan/repin-de352dfaf/`) had these:
|
|
- `capture-rollback.sh`: the old wrapper and unit files, with sums;
|
|
- `stopped-check.sh`: no writer.lock holder and no bus process;
|
|
- `backup.sh`: an owner-only copy of the stopped store, with sums;
|
|
- `rehearse.sh`: the migration run on disposable copies of the backup;
|
|
- `restore.sh`: the backup put back, with the post-attempt store moved
|
|
aside and nothing deleted;
|
|
- `verify-live.sh` and `verify-new.sh`: the effective paths.
|
|
|
|
`scripts/release.sh` already has the shape decision 96 names for the
|
|
worker image:
|
|
- a health gate before the switch (`release.sh:41-57`);
|
|
- an atomic pointer at `state/active.json`, written to a temp file and
|
|
renamed (:74-80);
|
|
- an append-only `state/activation-log.jsonl`;
|
|
- `rollback` to the last different tag through the same gate (:133-143).
|
|
|
|
D1 gives the stack that shape. D2 makes the rehearsal a product step.
|
|
|
|
## Stack activate and rollback, health-gated (D1)
|
|
|
|
### Problem
|
|
|
|
A re-pin is a hand-run sequence of stops, installs, a wrapper switch and
|
|
starts, checked by eye. The window plans carry the backup, restore and
|
|
path checks as one-off scripts. The live stack has no pointer and no log,
|
|
so "what is live and what was before it" lives in a plan file. A bad
|
|
start has no gate that puts the old copy back.
|
|
|
|
### Owner and reviewer
|
|
|
|
- Owner: darkwing (Sage, 2026-10-18).
|
|
- Reviewer: filbert.
|
|
|
|
### Files owned
|
|
|
|
- `scripts/stack-release.sh` (new): verbs `adopt`, `activate`,
|
|
`rollback` and `status`
|
|
- `scripts/stack-copy.sh`: only to share the copy-id and copies-directory
|
|
helpers, if the owner factors them out; `copy`, `wrapper` and `check`
|
|
keep their behavior
|
|
- `packages/cli/tests/stack-release.test.mjs` (new)
|
|
- `docs/TOOLS.md`, the "Pinned stack update" section, and
|
|
`packages/cli/README.md`, the paragraph on the pinned stack
|
|
- the packet `agents/<owner>/work/queue-<row>/`
|
|
|
|
### What ships
|
|
|
|
1. A pointer and a log, the same shape as `release.sh`:
|
|
- `<dataRoot>/state/stack-active.json` holds `{ copy, business, pm,
|
|
claude, console, schema, activation, at }`. It is written to a temp
|
|
file and renamed. `claude` is the bus unit's `--claude` pin, and
|
|
`console` is the Console unit's flag list (`--port`, `--business`,
|
|
`--board`, `--send` and the public-host, auth-header and allow-user
|
|
set). `webui-service.sh` saves its flags nowhere else, so the
|
|
pointer is their record (Darkwing's survey, 2026-10-18).
|
|
- `<dataRoot>/state/stack-activation-log.jsonl` is append-only. Each
|
|
line has `at`, `op`, `from`, `to`, `backup`, `result` and, on a
|
|
refusal or a failed gate, `code`. `op` is one of `adopt`,
|
|
`activate` or `rollback`.
|
|
- Both are files under `state/`, which AGENTS.md's data map already
|
|
gives to the release pointer and its logs.
|
|
2. `adopt BUSINESS` records the stack that is running now as the first
|
|
pointer, and changes nothing. It refuses unless `stack-copy.sh check`
|
|
passes and the bus unit's copy is the Console's and the wrapper's.
|
|
The live stack is already on one copy (52f35e7a0), so no mixed-setup
|
|
path is needed. A pointer that already exists refuses
|
|
`already-adopted`. `adopt` reads `claude` and `console` from the
|
|
effective units with `systemctl --user show -p ExecStart -p
|
|
Environment`, the way `bus-service.sh status` reads `--pm`
|
|
(:200-209).
|
|
3. `activate ID BUSINESS [--pm]` moves the stack from the pointer's copy
|
|
to copy `ID`. It refuses, with a code, a log line and no change, when
|
|
any of these holds:
|
|
- there's no pointer, `no-pointer`;
|
|
- the copy is missing or isn't reachable from `refactor` and
|
|
`origin/refactor`, using the rule `stack-copy.sh` already enforces;
|
|
- any instance is busy, `instance-busy`, using the check
|
|
`bus-service.sh install_guard` uses (:100-130);
|
|
- a bus drop-in overrides `ExecStart` or `WorkingDirectory`,
|
|
`drop-in-override`. It is named, and the operator removes it as in
|
|
TOOLS.md step 3;
|
|
- the target is already the pointer's copy, `already-active`;
|
|
- the effective units' `--pm`, `--claude` or Console flags differ from
|
|
the pointer's, `unit-flags-drift`. The difference is printed. A hand
|
|
edit between runs is never carried forward or reverted without
|
|
anyone seeing it; the operator settles it first.
|
|
4. The order inside `activate` is as follows. Each step is logged, and a
|
|
failure at any step goes to item 6.
|
|
1. Capture the rollback set under
|
|
`<dataRoot>/state/stack-rollback/<activation>/`, mode 0700: the
|
|
installed bus and Console unit files, their drop-in directories,
|
|
and the wrapper's bytes and mode, with SHA256SUMS. This is
|
|
`capture-rollback.sh` as product code.
|
|
2. Stop the Console, then the bus. Wait for both to be inactive and
|
|
for the stopped check to pass: no `writer.lock` holder and no bus
|
|
process for the data root (`stopped-check.sh`).
|
|
3. Read the stopped store's schema version without writing. The
|
|
owner picks the read, for example a read-only SQLite open of the
|
|
version the store records, and the packet names it. If the version
|
|
is the new copy's `SCHEMA_VERSION`, there's no backup. If it's
|
|
older and the run has no `--migrate`, refuse `migration-needs-flag`
|
|
through item 6. With `--migrate`, back up the stopped store to
|
|
`<dataRoot>/backups/stack/<activation>/` (`backup.sh`: 0700, sums,
|
|
a source and copy comparison, the stopped check again after the
|
|
copy), then run the new copy's `migrate-schema.mjs`. If it's newer
|
|
than the new copy's, or not in the copy's chain, refuse
|
|
`store-newer`. The stack is stopped by then, so the refusal goes
|
|
through item 6 and starts the old stack again.
|
|
4. Install the bus unit with `bus-service.sh install --copy ID`,
|
|
with exactly the `--pm` and `--claude` pin the pointer recorded.
|
|
Then install the Console with `webui-service.sh install --copy ID`
|
|
with exactly the pointer's `console` flags. Then switch the wrapper
|
|
with
|
|
`stack-copy.sh wrapper ID`.
|
|
5. Start the bus, then the Console.
|
|
5. The health gate runs after the start and passes only when all of
|
|
these hold within a timeout (default 60 s, `--timeout`):
|
|
- `systemctl --user is-active` reports both units active;
|
|
- the copy's `scripts/mosaic bus status --json` exits 0 and shows a
|
|
live host for BUSINESS whose pid is the bus unit's MainPID, with the
|
|
boot status ok (`packages/cli/src/cli.mjs:499-514`). The store opens
|
|
only at its own schema, so a running host means the schema matches;
|
|
- the Console's `/healthz` answers 200 on its configured port;
|
|
- `stack-copy.sh check` passes;
|
|
- with `--pm`, `scripts/mosaic launches list` shows the PM.
|
|
|
|
On a pass, the pointer is renamed into place and the log says `ok`.
|
|
The pointer changes only on a pass.
|
|
6. A failed gate, or a failure after step 2, rolls back inside the same
|
|
run. It stops the Console and the bus, then puts back the rollback
|
|
set's unit files, drop-ins and wrapper bytes and runs `daemon-reload`.
|
|
If step 3 migrated, it restores the backup the way `restore.sh` does:
|
|
the current `bus/` is renamed to `bus.post-attempt-<stamp>`, nothing
|
|
is deleted, and the restore is checked against the backup's sums.
|
|
Then it starts the old stack and runs the same gate against the
|
|
pointer's copy. The log records `gate-failed` with the failing check
|
|
and the outcome of the rollback. If the rollback's own gate fails,
|
|
the script stops, prints both results and leaves the stack stopped,
|
|
with a `rollback-failed` line. It never retries.
|
|
7. `rollback BUSINESS` moves to the copy the log names as the one
|
|
before the pointer's, the same way `release.sh rollback` finds it. It
|
|
runs steps 4.1-4.5 and the gate with that copy as the target. If the
|
|
store's schema is newer than that copy's, it refuses
|
|
`schema-differs` unless `--restore ACTIVATION` names the backup taken
|
|
when the store was migrated. With `--restore`, it prints the
|
|
post-attempt path it keeps and how many bus events were written after
|
|
the backup's stamp, then restores. If that count is above zero, it
|
|
refuses `post-backup-events` unless `--events N` repeats the printed
|
|
count.
|
|
8. `activate ID BUSINESS --plan` and `rollback BUSINESS --plan` print
|
|
the target, whether a migration or a restore is expected and the
|
|
flags that would be installed. The store schema comes from a
|
|
read-only read of the store the running bus holds, or of the stopped
|
|
store when the bus is down. With no store file it falls back to the
|
|
pointer's `schema` and says so (sage-0745, mos-0741; this replaced
|
|
the first draft's "pointer only, no store read"). The target side is the
|
|
copy's `SCHEMA_VERSION`. They change nothing and write no log line,
|
|
so the window knows beforehand whether Jason must be present.
|
|
9. `status BUSINESS` prints the pointer, the last five log lines, the
|
|
live units' copies, the result of `stack-copy.sh check`, `bus status`
|
|
and the drift from `refactor`. It only reads.
|
|
10. Who runs it. Decision 96 covers the script stopping and starting
|
|
the live units under four conditions (Mos, mos-0716), and the
|
|
header comment states all four:
|
|
- every run of `adopt`, `activate` or `rollback` happens on Mos's
|
|
go, and Jason or Mos runs it;
|
|
- a run that migrates the store, an `activate --migrate`, happens
|
|
with Jason present;
|
|
- a `rollback --restore` with events written after the backup waits
|
|
for Jason, which is why it needs `--events N`;
|
|
- the script never prunes backups, rollback sets, post-attempt
|
|
directories or copies.
|
|
|
|
Decisions 94 and 102 still decide who starts the Console.
|
|
`webui-service.sh` keeps having no start or stop verb (:50). The
|
|
release script is the one path that stops and starts. `status` and
|
|
`--plan` are open to every seat.
|
|
|
|
Tests run with a stand-in `systemctl` and `systemd-analyze` on `PATH`
|
|
(`install_guard` also calls `systemd-analyze --user unit-paths`), temporary
|
|
`XDG_CONFIG_HOME` and `XDG_DATA_HOME`, a temporary data root, and fake
|
|
copies whose `scripts/mosaic` and Console are small stubs, as
|
|
`packages/cli/tests/stack-copy.test.mjs` and
|
|
`packages/webui/tests/service.test.mjs` do. The cases:
|
|
|
|
- `adopt` writes the pointer and one log line only when the check passes,
|
|
with `claude` and `console` read from the stand-in's `show` output.
|
|
A second `adopt` refuses.
|
|
- `activate` installs exactly the pointer's `--pm`, `--claude` and
|
|
Console flags, and a stand-in unit whose flags differ refuses
|
|
`unit-flags-drift` with no change.
|
|
- Each refusal in item 3 leaves the units, the wrapper, the pointer and
|
|
the store unchanged, and it writes one log line with its code.
|
|
- An `activate` with no schema change: the stand-in sees the stops, the
|
|
installs and the starts in that order. The pointer names the new
|
|
copy, and no backup directory exists.
|
|
- An `activate` with a schema change and no `--migrate` refuses
|
|
`migration-needs-flag` and starts the old stack. With `--migrate` it
|
|
makes the backup before the migration. A fixture migration that fails restores the backup and
|
|
starts the old stack. The post-attempt directory exists, and the
|
|
restored files match the backup's sums.
|
|
- Each check in item 5, failing on its own, rolls back. The pointer is
|
|
unchanged, the old unit files and wrapper bytes come back
|
|
byte-for-byte, and the log names that check.
|
|
- A failing rollback gate stops with `rollback-failed` and starts
|
|
nothing more.
|
|
- `rollback` finds the previous copy from the log, refuses
|
|
`schema-differs` without `--restore`, and restores with it. With
|
|
post-backup events, `--restore` without a matching `--events N`
|
|
refuses `post-backup-events`.
|
|
- `--plan` reports the expected migration or restore and the flags, and
|
|
leaves the units, pointer, log and store unchanged.
|
|
- The store is never opened for writing outside `migrate-schema.mjs` and
|
|
the restore, and nothing under `backups/`, `state/stack-rollback/`,
|
|
`bus.post-attempt-*` or the copies directory is removed.
|
|
|
|
Mutants: one per behavior above, with the killing test in the packet.
|
|
|
|
### Out of scope
|
|
|
|
- Any live run of `adopt`, `activate` or `rollback`. The first `adopt` and
|
|
the first `activate` happen in a Mos window, with Mos's go.
|
|
- The Discord unit (`docs/plans/2026-10-14_discord-pinned-copy.md`). It
|
|
joins in a follow-up once D1 lands.
|
|
- Pruning copies, rollback sets or backups.
|
|
- A down-migration, any change to the store's schema chain, and any change
|
|
to `migrate-schema.mjs`.
|
|
- A health endpoint in the bus. The gate uses `bus status`.
|
|
- Any change to the human proof (`packages/bus/src/human.mjs`) or to
|
|
`packages/queue`.
|
|
- CI, which is D4.
|
|
|
|
### Gate
|
|
|
|
Filbert approves on the row's issue, naming the candidate manifest. The
|
|
cli, bus and webui node suites and every `scripts/test-*.sh` pass on
|
|
Sage's gate rerun. The landing changes nothing live. Sage sends Mos the
|
|
commit and `stack-release.sh status` output from a scratch root. The
|
|
first live `adopt` waits for Mos's go.
|
|
|
|
## Stack rehearsal on a scratch root (D2)
|
|
|
|
### Problem
|
|
|
|
Decision 96's D2 is staging: trying a candidate before live takes it.
|
|
Today the trial is `rehearse.sh`, a plan-local script that checks only the
|
|
migration on copies of the backup. Nothing starts the new copy's bus host
|
|
and Console against real data before the live window, so a failure that
|
|
shows only at start is found with the live stack stopped.
|
|
|
|
### Owner and reviewer
|
|
|
|
- Owner: sage (row 163, briefed 2026-10-20).
|
|
- Reviewer: filbert.
|
|
|
|
### Files owned
|
|
|
|
- `scripts/stack-release.sh`: the `rehearse` verb
|
|
- `packages/cli/tests/stack-release.test.mjs`: the rehearsal cases
|
|
- `docs/TOOLS.md`, the same section as D1
|
|
- the packet `agents/<owner>/work/queue-<row>/`
|
|
|
|
### What ships
|
|
|
|
1. `rehearse ID BUSINESS --backup ACTIVATION|PATH` builds a scratch stack
|
|
under a new 0700 directory, `<dataRoot>/rehearsals/<stamp>/`:
|
|
- a data root holding copies of the backup's store files;
|
|
- the business file, roles and organization file copied from the live
|
|
config directory, read only;
|
|
- a scratch config that names the scratch data root.
|
|
|
|
The live store, live config and the backup are never written. The
|
|
backup's sums are checked before and after.
|
|
2. On the scratch root it runs the de352dfaf rehearsal checks with the
|
|
new copy: the new code refuses the unmigrated store at a normal open,
|
|
`migrate-schema.mjs` succeeds, a second run is a no-op, and the old
|
|
copy refuses the migrated store. Those checks apply only when the
|
|
schema differs.
|
|
3. It starts the new copy's bus host in the foreground on the scratch
|
|
root with launches disabled, so no role session starts and no model
|
|
is called. It starts the new copy's Console on a free loopback port
|
|
with no send. Then it runs D1's gate checks against both, except the
|
|
PM check. It stops both and prints one PASS or FAIL line per check,
|
|
then `REHEARSAL_PASS` or `REHEARSAL_FAIL`.
|
|
4. "Launches disabled" is a host flag the owner adds if the host lacks
|
|
one. Its default is off, and the live units never pass it. If the
|
|
host can't start without launching, the row stops and Sage takes the
|
|
design back to Mos. No rehearsal calls a model.
|
|
5. The rehearsal directory stays for reading. Nothing prunes it, and it
|
|
is 0700 because it holds bus data.
|
|
6. Who runs it. `rehearse` reads a backup of the live store, so only
|
|
Jason or Mos run it, as the window's pre-step (the old P-steps). No
|
|
development seat runs it against live data. Tests use fixture stores.
|
|
|
|
Tests:
|
|
- The rehearsal passes on a fixture store and copy, and the scratch root
|
|
holds the expected files.
|
|
- The live store, the live config files and the backup are byte-identical
|
|
before and after.
|
|
- A migration that fails, a new copy whose host doesn't start, and a
|
|
Console that doesn't answer each give `REHEARSAL_FAIL` naming the check.
|
|
- No role session starts: the fixture's launcher stub records no start.
|
|
- A scratch port that is in use refuses and picks no other port that
|
|
could be the live Console's.
|
|
|
|
Mutants: one per behavior above.
|
|
|
|
### Out of scope
|
|
|
|
- A standing staging stack with its own units, Console, business and
|
|
model turns. That needs a second system config beside
|
|
`~/.config/mosaic-dev/config.json` (AGENTS.md invariant 2) and model
|
|
spend, so it's Jason's call. It is not briefed here.
|
|
- Everything D1's out-of-scope list names.
|
|
|
|
### Gate
|
|
|
|
As D1. D2 lands after D1, because both edit `stack-release.sh` and its
|
|
test file.
|
|
|
|
D2 also lands after the Part B window (P19 385) has run. The Part B window
|
|
file names the `stack-release.sh` blob, and any change to that blob means
|
|
Mos checks the window again (mos-0958, mos-0959). The build and review can
|
|
go ahead before then.
|