Compare commits

...
25 Commits
Author SHA1 Message Date
jason.woltje d6116771fd row 189 (#1687): stack-release.sh activate --no-send drops the Console's --send (Sage; candidate r1) 2026-10-20 13:36:34 -05:00
jason.woltje def2e79327 plans: brief for stack-release activate --no-send (Q175 interim, mos-0972/0973, #1687, P19 413) 2026-10-20 13:21:30 -05:00
jason.woltje c30b36fffb sessions: Filbert Q175 SSO design review r1, changes 2026-10-20 13:19:06 -05:00
jason.woltje 9ae8c73acb queue: revs 2235-2236: row 163 Sage landing note (8ecc7a239, patch-id b8d5f8a37c24, gate passed on eaa0f2666) and done 2026-10-20 13:18:46 -05:00
jason.woltje 8ecc7a239d row 163 (#1660): stack-release.sh rehearse, the D2 rehearsal on a scratch root from a backup, and bus start --no-launch (Sage; Filbert approve 29447; Sage gate passed) 2026-10-20 13:17:07 -05:00
jason.woltje 7f57419b38 sage: Q175 SSO design for #412, OIDC relying party on oauth4webapi 3.8.8, for Filbert review (mos-0970, mos-0971) 2026-10-20 13:13:38 -05:00
jason.woltje 4ca8d6e949 sage: row 42 run log, S7 reachability pass (51cebff7 answered by cff1e9ad after a chief launch, mos-0969) 2026-10-20 12:54:16 -05:00
jason.woltje 22e5e825b0 sage: row 42 run log, plain mosaic on a pinned host supersedes the 10-12 full-path line (mos-0968) 2026-10-20 12:48:53 -05:00
jason.woltje e6dea74b22 sessions: Sage row 163 r2 approval intake; P19 337 comment 368 script and record 2026-10-20 10:42:00 -05:00
jason.woltje cd2b377bbf queue: rev 2234: row 163 r2 approve from Filbert (op filbert-163-r2, comment 29447, candidate c6f4f4c12596) 2026-10-20 10:41:33 -05:00
jason.woltje ceccb0ef26 sessions: Filbert row 163 r2 review, approve 2026-10-20 10:40:36 -05:00
jason.woltje 10ab8ea624 sessions: Sage row 163 r2 in-review; P19 337 comment 367 script and record 2026-10-20 10:26:33 -05:00
jason.woltje c539995cf4 queue: revs 2231-2233: row 163 r2 in-review from Sage (ops sage-163-r2-start, sage-163-inreview-2, .outcome; request comment 29446, reviewer Filbert, manifest c6f4f4c12596) 2026-10-20 10:26:02 -05:00
jason.woltje 92cc6011c8 queue: rev 2230: row 163 r1 changes from Filbert (op filbert-163-r1, comment 29445, candidate 430212299beb) 2026-10-20 10:23:12 -05:00
jason.woltje 7af1665748 sage: row 163 r2 packet (candidate 2c090eefd on code 2a5f3a3a0, base 4f02486d5: diff, manifest c6f4f4c12596, gate passed, mutants K34-K41 killed) 2026-10-20 10:22:02 -05:00
jason.woltje 2244e8bafc sessions: Filbert row 163 r1 review, changes 2026-10-20 10:06:03 -05:00
jason.woltje bde0fdec9f sessions: Sage row 163 r1 in-review; P19 337 comment 366 script and record 2026-10-20 09:49:45 -05:00
jason.woltje def677bc76 queue: revs 2228-2229: row 163 r1 in-review from Sage (ops sage-163-inreview-1, .outcome; request comment 29444, reviewer Filbert, manifest 430212299beb) 2026-10-20 09:49:08 -05:00
jason.woltje 51f67f1f87 sage: row 163 packet (candidate 4be3f6aec on 580500699: diff, manifest 430212299beb, gate passed, mutants K1-K33 killed after four survivors got tests) 2026-10-20 09:44:55 -05:00
jason.woltje 5b298bd00b sessions: Dewey row 66 r1 (changes) and r2 (approve) reviews 2026-10-20 09:12:15 -05:00
jason.woltje 565bc32c2f queue: revs 2223-2227: row 66 r2 round from Sage (2223-2225, ops sage-r66-r2-start, sage-r66-inreview-2); r2 verdicts from Filbert (2226, op filbert-66-r2, approve) and Dewey (2227, op dewey-66-r2, approve) 2026-10-20 09:12:10 -05:00
jason.woltje 5140aa9e88 sessions: Filbert row 66 r2 review, approve 2026-10-20 08:56:35 -05:00
jason.woltje 973cb5a872 queue: revs 2220-2222: row 163 Sage start (2220); row 66 r1 verdicts from Filbert (2221, op filbert-66-r1) and Dewey (2222, op dewey-66-r1) 2026-10-20 08:50:44 -05:00
jason.woltje b82ec93536 sage: row 66 r2 packet (candidate 0f4a57956: diff, manifest cee63e06fdea, gate, mutants K1-K4 killed, singles rerun) 2026-10-20 08:49:55 -05:00
jason.woltje 3560c450a6 sessions: Filbert row 66 r1 review, changes 2026-10-20 08:18:38 -05:00
48 changed files with 5796 additions and 90 deletions

No files matched your search

+315
View File
@@ -0,0 +1,315 @@
# Q175 design: the Console verifies SSO itself (Vikunja #412)
Author Sage, 2026-10-20. Reviewer Filbert, and the review comes before any code. Line numbers are at refactor
4ca8d6e94. This replaces Part B (row 178 proxy proof, #385 comments 345 and 353). #385 stays open until the
Console stops trusting `X-authentik-username`.
## The ruling this implements
Jason, DECISIONS Q175, verbatim: "Q1: yes. This should be a requirement for SSO usage. Whether we are using
Pocket ID, Authentik, WorkOS, or another provider (we should support multiple), the system should use best
practices for jwt or other methods of validation that do not rely on hacks to pass the auth." He added "Zero
trust with verification" and confirmed five points (mos-0970):
1. The stack verifies every SSO login itself. It never takes identity from a proxy header or a shared secret.
2. The Console is an OIDC relying party: discovery from the issuer URL, authorization code with PKCE, the ID
token checked against the provider JWKS (signature, iss, aud, exp, nonce), then its own session cookie.
3. Several providers can be configured side by side.
4. Only asymmetric signing counts. HS256 is refused.
5. A proxy-forwarded token such as `X-authentik-jwt` counts only through the same checks. It is optional and
never the only way in.
## What exists today
The live Console runs from copies/490e2771d3d0 with `--public-host mosaic.woltje.com --auth-header
X-authentik-username --allow-user jason.woltje --send --tester-port 7333`.
- `previewOptions` (packages/webui/src/serve.mjs:205) builds `{ host, header, user }`.
- A request is `named` when its Host equals the public name (:493). A named request passes sign-in when it
carries exactly one auth header value equal to the user (:517-521). That header value is the whole identity.
- Requests with a loopback Host on 7332 get the unauthenticated read path (:509-512). Writes need `named` (:526-546).
- `proofOptions` (:217) is the row 178 shared-secret header. Q175 point 1 rules it out, so it retires with the
auth header.
- The tester listener (`--tester-port`, row 176) binds 127.0.0.1. Its writer is fixed as
`{ tester: true, user: 'tester' }` (:522), it allows only the instance, route, name and chat writes (:523),
and it refuses the auth and proof headers (:486-487). 7332 refuses `x-mosaic-tester` (:485).
- The public path is Traefik, then the Authentik proxy outpost (provider mosaic-preview, HS256, empty JWKS),
then chisel `R:0.0.0.0:7332:127.0.0.1:7332`, then the Console. Every public request reaches the Console from
127.0.0.1 with whatever Host the outpost forwards.
## Design
### 1. The Console becomes an OIDC relying party
New module `packages/webui/src/sso.mjs`, used only by the human listener (7332). Routes:
| Route | Method | What it does |
|---|---|---|
| `/auth/login` | GET | One provider configured: start its flow. Several: a plain page listing them, each a link to `/auth/login?provider=ID`. |
| `/auth/callback` | GET | Finish the flow, create the session, 303 to the saved return path. |
| `/auth/logout` | POST | Same-origin checks, delete the session, 303 to the provider's `end_session_endpoint`. |
| `/auth/signed-out` | GET | Static "signed out" page with a sign-in link. |
The flow per provider:
1. Start. Generate a state, a nonce and a PKCE verifier (`generateRandomState`, `generateRandomNonce`,
`generateRandomCodeVerifier`), and a challenge with `calculatePKCECodeChallenge` (S256 only). Put them, the
provider id and the return path in a server-side login record keyed by a random id. Set
`__Host-mosaic_login` to that id. 303 to `authorization_endpoint` with `response_type=code`,
`scope=openid profile email`, `code_challenge_method=S256`, `redirect_uri`, `state` and `nonce`.
2. Callback. Read exactly one `__Host-mosaic_login` cookie, then take and delete its record (single use, so a
replayed callback finds nothing). Expire the cookie in the response. The provider comes from the record,
never from the query, which stops a mix-up between providers.
3. `validateAuthResponse(as, client, url, record.state)` checks state, refuses an error response, and checks
the `iss` parameter when the provider advertises RFC 9207.
4. `authorizationCodeGrantRequest(as, client, clientAuth, params, redirectUri, record.verifier)`, then
`processAuthorizationCodeResponse(as, client, response, { expectedNonce: record.nonce, requireIdToken: true })`.
That checks iss, aud (and azp when aud has several values), exp, iat and nonce, and checks `alg` against
`client.id_token_signed_response_alg`.
5. Signature. oauth4webapi does not verify the signature of an ID token that came straight from the token
endpoint over TLS (OIDC Core 3.1.3.7 allows that, and the library docs for
`validateApplicationLevelSignature` say so). Point 2 asks for the JWKS check, so the callback always calls
`validateApplicationLevelSignature(as, response)` after step 4. It fetches the JWKS from the discovered
`jwks_uri`, picks the key by `kid` and `alg`, and refuses HS* outright.
6. Our own checks on top of the library: `header.alg` is in the provider's configured `algs`, the token's
`iss` equals the configured issuer exactly, and `(iss, sub)` is in the provider's `subjects` list. Anything
else is a 403 that logs `refused sign-in iss=... sub=...` (never a token, code or cookie).
7. Create the session (section 3) and 303 to the return path. The return path must start with one `/` and
is otherwise replaced with `/`.
Every refusal in steps 2-6 ends at a 403 page saying sign-in failed, with a link back to `/auth/login`. No
step falls back to a header.
### 2. Library: oauth4webapi 3.8.8, pinned exactly
Mos's steer (mos-0971) was to prefer oauth4webapi over hand-rolled verification on `node:crypto` unless I
could name a defect or a dependency cost that outweighs it. I found neither.
- MIT, no dependencies, one ESM file (build/index.js, 99,856 bytes). Tarball sha256 `bac088aecc7c...`,
integrity `sha512-8N28E+a/oxfXW...` (the full values go in package-lock.json).
- `supported()` covers PS/ES/RS 256/384/512, Ed25519/EdDSA and ML-DSA. No HS*. RSA keys under 2048 bits are
refused.
- It does discovery with an issuer equality check (`processDiscoveryResponse(expectedIssuer, ...)`), PKCE,
state, the RFC 9207 `iss` parameter, nonce, and JWKS caching.
- The one gap is the default skip of the token-endpoint signature check (section 1 step 5). Calling one
exported function closes it, so it isn't a reason to hand-roll.
Hand-rolling would mean writing JOSE parsing, JWK import, key selection by `kid`, the claim checks and the
discovery checks ourselves, with the same tests and more places to get it wrong.
Pin: `"oauth4webapi": "3.8.8"` in the root package.json `dependencies`, next to the pi pin, with the lockfile
entry. Root is right because `scripts/stack-copy.sh:23-31` copies only the root package.json and lock and runs
`npm ci` there (:55-56). packages/webui/src resolves the import from the copy's root node_modules. This is the
webui's first runtime dependency. The root dependency rule (exact pins, never unversioned) applies to it.
Upgrades are reviewed commits.
### 3. Sessions, cookies and logout
- Session id: 32 random bytes, base64url. The store keys on its sha256, so a heap dump doesn't hold usable ids.
- Cookie `__Host-mosaic_session=<id>; Secure; HttpOnly; SameSite=Lax; Path=/`, no Domain, no Max-Age (a
browser-session cookie). The `__Host-` prefix makes the browser refuse it unless it was set over https on
this exact host.
- Login cookie `__Host-mosaic_login`, same attributes plus `Max-Age=600`. The login record also expires at
10 minutes server-side.
- Lax, not Strict, because the callback is a top-level navigation from the provider's site, and a Strict
cookie set there would be missing on the redirect to `/`. Lax still keeps the session off cross-site POSTs.
The write routes keep their exact `Origin` and `Sec-Fetch-Site: same-origin` checks (:171, :250, :297, :340,
:387, :425), so cross-site writes are refused twice.
- Lifetime: 8 h absolute, 1 h idle, both server-side. The store is in memory with a cap of 64 sessions
(oldest goes first), so a Console restart signs everyone out. That's acceptable for one human and keeps
sessions off disk.
- The session holds `{ provider, iss, sub, name, idToken, created, seen }`. `name` is the subject entry's
label. It goes where `preview.user` goes today (send logs, settings, seat routes), so the downstream writer
shape stays as it is. `idToken` is kept only as the logout hint and never leaves the server.
- Every request on 7332 except `/healthz` and `/auth/*` needs a valid session. Without one, `/api/*` gets
401 JSON and other GETs get a 303 to `/auth/login?return=<path>`.
- Logout: `POST /auth/logout` with the same-origin checks. It deletes the session, expires the cookie, and
303s to `end_session_endpoint?id_token_hint=...&post_logout_redirect_uri=https://mosaic.woltje.com/auth/signed-out&client_id=...`.
If the provider has no `end_session_endpoint`, it goes straight to `/auth/signed-out`.
- Limit: signing out at the provider does not end a Console session until idle or absolute expiry. Back-channel
logout is a follow-up, not phase 1.
### 4. Provider config: shape and location
No secret value goes in this file. The client secret is a slot reference.
```json
"console": {
"sso": {
"publicOrigin": "https://mosaic.woltje.com",
"providers": [
{
"id": "authentik",
"issuer": "https://auth.diversecanvas.com/application/o/mosaic-stack/",
"clientId": "<from Jason>",
"clientSecret": { "slot": "console-sso-authentik" },
"algs": ["RS256"],
"subjects": [ { "sub": "<from Jason>", "name": "jason.woltje" } ]
}
]
}
}
```
Location: a new optional `console` key in the business file (`~/.config/mosaic-dev/businesses/<id>.json`). The
Console already serves one business (`--business`), the identities map to that business's human, and Q173
lets agents edit the test business with a backup first. Adding the key means adding `console` to `TOP_KEYS`
(packages/business/src/business.mjs:21) with a strict validator. It's additive, and existing files without
it still load. The alternative is `~/.config/mosaic-dev/config.json`, which is user-authored and never
auto-written. That fits a deployment setting well, but it puts per-business identity in system config.
**Filbert, rule on this one.**
The slot is `~/.config/mosaic-dev/secrets/mosaic-stack/<slot>`, read at start. The Console refuses to start if
the file isn't a regular file, isn't owned by the Console's uid, has any group or other mode bits, or
resolves under the repo or a stack copy. The value is never logged or put in a refusal message.
`stack-release.sh rehearse` copies a backup onto a scratch root. A rehearsed Console must not use the live
redirect URI, so rehearse strips `console.sso` from the scratch business file and the rehearsed Console runs
without SSO (tester listener only). That's a row 163 follow-up, named in the build brief.
Startup checks for each provider, all fail-closed (the Console doesn't start, and the message names the
provider and the check):
- `issuer` is https with no query or fragment. Discovery returns that issuer exactly.
- `id_token_signing_alg_values_supported` shares at least one alg with `algs`, and `algs` holds only
asymmetric algs from the library's list. mosaic-preview advertises HS256 only, so the Console refuses it at
start.
- `code_challenge_methods_supported` includes `S256`.
- `jwks_uri` returns at least one key usable for one of `algs`. The empty mosaic-preview JWKS fails here too.
- `subjects` is non-empty, and each `sub` is 1..255 visible characters.
- `client.id_token_signed_response_alg` is set to `algs[0]`, so the library enforces it per token.
- Provider ids are unique and `[a-z0-9-]{1,32}`.
Starting while the provider is unreachable refuses the start. A Console with no working sign-in serves
nothing, so refusing loudly costs nothing, and the unit restarts it. The tradeoff is that an IdP outage
keeps the Console down until the IdP is back. With every route behind a session, it would be unusable anyway.
Several providers: Pocket ID, WorkOS and other compliant OPs use the same entry shape. The login page lists
them. Each entry has its own issuer, client, algs and subjects, and a session records which provider made it.
### 5. Retiring `--auth-header`, `--allow-user` and the proof header
1. **Phase 1 (code row).** Add `--sso` to `cli.mjs serve` and `webui-service.sh`. It reads `console.sso` from
the business file. `--sso` cannot be combined with `--auth-header`, `--allow-user` or `--proof-*`. Under
`--sso`, `named` means "has a session", and `writer` comes from the session. `--send` needs `--sso` or the
old preview set. Nothing live changes until the re-pin.
2. **Phase 2 (Jason-gated window).** Re-pin to the phase 1 commit with `--sso --send --tester-port 7333`
instead of the preview flags, after the Jason steps below. Backup first, STATE after (Q173). Acceptance:
Jason signs in through the Console flow, sends one message, signs out, and a forged-header request with no
cookie gets 401.
3. **Phase 3 (code row).** Remove `previewOptions`, `proofOptions` and their tests. `cli.mjs` and
`webui-service.sh` refuse the old flags with `--auth-header was retired by Q175; use --sso`. Close #385
with the phase 2 STATE and the phase 3 commit.
The Authentik proxy outpost can stay in front. It still sends `X-authentik-*` headers, and the Console never
reads them. A test proves that a request carrying them gets nothing without a session cookie.
### 6. The tester port and the N3 tester identity
The tester listener exists so agents can test as the tester and never as Jason (mos-0870 N3). Under SSO:
- It never mounts `/auth/*` (404), never reads a cookie, and refuses any request carrying `Authorization` or a
`__Host-mosaic_session` / `__Host-mosaic_login` cookie (403 `tester listener takes no sign-in`). The same
goes for `X-authentik-jwt` and the old auth and proof header names.
- Its writer stays the fixed `{ tester: true, user: 'tester' }`, and it allows only the row 175 writes, as
today. No session or header can change it.
- Its bus calls stay on `TESTER_CLI`. The bus checks the tester proof and refuses human verbs from it, and the
broker refuses a business whose human is `tester` and any role named `tester`. So nothing that reaches 7333
can become the human.
- 7332 keeps refusing `x-mosaic-tester` for every Host (:485).
- `subjects` validation refuses an entry named `tester`.
### 7. Point 5, the proxy-forwarded token
Not in phase 1. Point 5 makes it optional and the code flow covers the ruling. If it's built later:
- Verify with jose (MIT, exact pin), against the JWKS from the configured issuer's own discovery. Never use
the `X-authentik-meta-jwks` header, because it arrives with the request and the forger controls it.
- Use the same alg allow list, iss, aud (the proxy provider's client id), exp and subject list.
- The mosaic-preview provider signs with HS256 and has an empty JWKS, so its `X-authentik-jwt` fails every
check until Jason gives it an RSA or EC key (Jason step 1).
### 8. Interim exposure until phase 2
Today, with `--send` live, any same-uid process on dragon-lin can connect to 127.0.0.1:7332 with
`Host: mosaic.woltje.com`, `X-authentik-username: jason.woltje`, the matching Origin and
`Sec-Fetch-Site: same-origin`, and send as Jason. That includes agent seats. Through the tunnel, the outpost
strips client copies of the header, so a remote forger needs local code execution first. Users on other uids
can reach the loopback port too (P19 333).
Recommendation: Mos restarts the Console without `--send` until phase 2. Reads stay up, the header path stops
carrying writes, and only Jason's sends through the Console stop. That's a Mos call (Console start belongs to
Jason or Mos). Until then, no agent sends a request with the auth header to 7332. The tests use fixture servers.
To be clear about the limit: SSO protects Console ingress. It doesn't protect the bus's human boundary. The
human proof is path-bound (#225), so a same-uid process can already run the tree's `human-cli.mjs` and act
as the human without touching the Console. Q175 doesn't change that. P19 333 owns it.
### 9. Tests
All tests run against a fake OP in `packages/webui/tests/helpers/fake-oidc.mjs`. It's a loopback https
server with a self-signed cert. Each test passes the library a `customFetch` that trusts only that cert, so
production code needs no insecure-request flag. The fake OP has discovery, a
JWKS, and authorization, token and end-session endpoints, with keys made by `node:crypto` (RSA 2048, P-256, and an
HMAC secret for the forgeries). No test reaches a real provider. Each refusal test asserts the status, that no
`__Host-mosaic_session` was set, and that the bus send stub was never called. Each test also passes once
unmutated, as the control.
Refusals Mos named:
1. Forged header. `X-authentik-username: jason.woltje` with `Host: mosaic.woltje.com`, the right Origin and
Fetch Metadata, and no cookie: 401 on `/api/bus/message`, `/api/settings` and `/api/config`, and a 303 to
login on `/`.
2. HS256 ID token. The fake OP signs the ID token with HS256, using the client secret as the key (the classic
confusion) and `kid` set to a real RSA kid: refused at the callback.
3. Wrong aud. A correctly signed RS256 token with `aud` set to another client id: refused.
4. Expired token. A correctly signed token with `exp` 10 minutes ago (beyond the library's clock tolerance):
refused.
More refusals:
5. Wrong `iss`, both a different issuer and a trailing-slash variant.
6. Nonce mismatch or missing nonce.
7. State mismatch, a missing login cookie, a replayed callback (record already taken), an expired login record,
and two login cookies.
8. A provider that demands no PKCE. The token request must carry `code_verifier`, and the fake OP refuses a
wrong one.
9. `alg: none`, and an RS256 token signed by a key not in the JWKS.
10. A valid token for a `sub` not in `subjects`: 403 and one log line with iss and sub only.
11. Startup refusals: HS-only discovery (the mosaic-preview shape), an empty JWKS, no S256, an issuer mismatch,
a slot with mode 0644, and `--sso` together with `--auth-header`.
12. Tester port: a valid session cookie (taken from a 7332 sign-in), `Authorization: Bearer <valid id token>`,
and `X-authentik-jwt` each get 403. `/auth/login` on 7333 gets 404.
13. Session: idle expiry, absolute expiry, a logout followed by reuse of the old cookie, and logout without
same-origin (refused, session kept).
14. Open redirect: `return=//evil.example` and `return=https://evil.example` both land on `/`.
Positive paths: an RS256 and an ES256 provider each complete sign-in and one send, and two providers configured
together each work with their own subjects.
Mutants for the gate: drop the `validateApplicationLevelSignature` call (test 2 or 9 must fail), drop the
`subjects` check, accept HS* in `algs`, take the provider from the query, let the tester listener read
cookies, and skip the login record delete.
### 10. Jason-gated steps
1. Authentik mosaic-preview: give it an RSA or EC signing key or retire it, and confirm by an admin read that
client_id `GEQ84kBbt2RG6fhYQGgZl2J9jgjw6wRJ3oSWxqZQ` belongs to it. (Ask B matched it only on scopes and
redirect_uri.)
2. Authentik mosaic-stack provider: register redirect URI `https://mosaic.woltje.com/auth/callback` and
post-logout URI `https://mosaic.woltje.com/auth/signed-out`, keep RS256, and give the client id. Put the
client secret into slot `console-sso-authentik` (or say an agent may create the slot file from a value he
puts in place, per the Q173 secrets rule). Agents never see the value.
3. His `sub` for that provider, or a go to read it from the Console's one refusal log line on his first
sign-in attempt.
4. Any Traefik or outpost change. None is needed while the outpost stays in front. Dropping the outpost
later, so the Console is the only gate, changes who can reach the Console from outside and is his call.
5. The phase 2 re-pin window go.
## Questions for review
- Q-A: the config location, business file `console` key or config.json (section 4).
- Q-B: refuse to start when the IdP is unreachable, or start and refuse sign-ins (section 4).
- Q-C: should the loopback-Host read path on 7332 also go behind the session, as proposed? Public traffic
arrives from 127.0.0.1 too, and only Host keeps it off that path today.
- Q-D: 8 h absolute and 1 h idle, or shorter.
+147
View File
@@ -0,0 +1,147 @@
# 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 b1500d96ad27. 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-live` refuses a scratch dataRoot that resolves to the live data root;
`sessions-live` refuses 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.diff` keeps the
change; `tracker-configured` fails 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/` holding `rehearsal-placeholder-token`; `credential-ref-live` fails 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 with `env -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
1. 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>/roles` unless MOSAIC_ROLES_DIR is set, and `env -i` leaves 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.
2. The business file is rewritten, not copied (mos-0962, mos-0963), and `business.diff` records
the change.
3. The `tracker` key stays and `tracker.sync.credentials` becomes 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-configured` checks 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, and `credential-ref-live` covers it with the role refs. Evidence: R10 (a tracker
still set fails `tracker-configured`), K2 (the check dropped, R10 fails), K10 (the tracker.*
strip dropped, R1 fails on `tracker-configured`), and K9 (the @sync swap dropped, R1 fails on `credential-ref-live`).
4. 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-live` catches the form that reaches the live root through a link
(R11).
5. 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).
6. 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.
7. 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 0053f35d1360).
- 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.
@@ -0,0 +1,10 @@
04d72e5ef4d8d5fc85a4f11aa51331354696cc7cf66af2ed7540cf6cfc0153b9 agents/sage/work/queue-163/BUILD.md
5bd1bc697cd56e57b00e60c0e80be72cd1043a55a6d2fc0b9e2ccdd0c5cf9802 docs/TOOLS.md
52597d7fa1469510fcf8e6dfd63664ad4c492bc17039885aa839231798693058 packages/cli/src/cli.mjs
5492ebb5aa3c9c85874e4c7de7b65fc37733e2db636690c11fe2d2fdf72c2dd0 packages/cli/src/launcher.mjs
e9ce0e0f7368d3fb2e1edbd4ebc724513ec19d7bac0c993f8be0d9494427be07 packages/cli/tests/host.test.mjs
42519898b5bcf84c21088647e161a86934d4fe351207f736b9b120b6e1c2b91b packages/cli/tests/launcher.test.mjs
4dfeefa1bb3c11e8633d75df4f628497ebd427ebf8402ba5efc3e5c7fd030057 packages/cli/tests/stack-release.test.mjs
5abdeab8bc6b63d291d0054124a4311a2260b590c68f758daa142a9deb2f4a72 packages/seat/src/session.mjs
741883e399d77d429cc29a87beef6db9c51c1759669b025006f037001d101eb6 packages/seat/tests/session.test.mjs
5bb3ac40102ce515f1acdcbc5670feb161ce40a193e94e2f8b104ebf98eddc38 scripts/stack-release.sh
File diff suppressed because it is too large. Load diff
+28
View File
@@ -0,0 +1,28 @@
base 580500699 dirty 0 DOCKER_HOST=unset
pkg-bus rc=0 ℹ pass 451 ℹ fail 0 | ℹ duration_ms 3051.98676
pkg-business rc=0 ℹ pass 149 ℹ fail 0 | ℹ duration_ms 1933.965446
pkg-cli rc=0 ℹ pass 470 ℹ fail 0 | ℹ duration_ms 337885.04401
pkg-control-board rc=0 ℹ pass 124 ℹ fail 0 | ℹ duration_ms 585.587135
pkg-conversation rc=0 ℹ pass 182 ℹ fail 0 | ℹ duration_ms 33296.254589
pkg-discord rc=0 ℹ pass 183 ℹ fail 0 | ℹ duration_ms 2624.332987
pkg-harness rc=0 ℹ pass 133 ℹ fail 0 | ℹ duration_ms 11808.615207
pkg-ledger rc=0 ℹ pass 89 ℹ fail 0 | ℹ duration_ms 12902.241615
pkg-mosaic rc=0 ℹ pass 69 ℹ fail 0 | ℹ duration_ms 2051.760841
pkg-plugins rc=0 ℹ pass 111 ℹ fail 0 | ℹ duration_ms 247.493374
pkg-project rc=0 ℹ pass 40 ℹ fail 0 | ℹ duration_ms 1031.233595
pkg-runs rc=0 ℹ pass 43 ℹ fail 0 | ℹ duration_ms 727.865986
pkg-seat rc=0 ℹ pass 152 ℹ fail 0 | ℹ duration_ms 5858.066276
pkg-tasks rc=0 ℹ pass 97 ℹ fail 0 | ℹ duration_ms 1147.333901
pkg-webui rc=0 ℹ pass 269 ℹ fail 0 | ℹ duration_ms 46548.273469
script-auth rc=0 | selftest: 15 passed, 0 failed
script-conductor rc=0 | selftest: 17 passed, 0 failed
script-config rc=0 | selftest: 24 passed, 0 failed
script-discord rc=0 | discord suite: 78 passed, 0 failed
script-extension-package rc=0 | extension package selftest: 18 passed, 0 failed
script-foundation rc=0 | selftest: 44 passed, 0 failed
script-land-work rc=0 | land-work selftests: 86 passed, 0 failed
script-queue rc=0 ℹ pass 156 ℹ fail 0 | queue suite: 28 passed, 0 failed
script-release rc=0 | selftest: 14 passed, 0 failed
script-task rc=0 | selftest: 98 passed, 0 failed
GATE_PASSED
removed network sage-r163_default
@@ -0,0 +1,5 @@
KILLED K11 bus-status pid check dropped (rc 1)
KILLED K21 scratch store links the backup (rc 1)
KILLED K27 ticker runs under --no-launch (rc 1)
KILLED K31 cli --no-launch not passed to the launcher (rc 1)
ALL_KILLED
@@ -0,0 +1,34 @@
KILLED K1 credential-ref-live dropped (rc 1)
KILLED K2 tracker-configured dropped (rc 1)
KILLED K3 data-root-live dropped (rc 1)
KILLED K4 sessions-live dropped (rc 1)
KILLED K5 notify-off dropped (rc 1)
KILLED K6 no-session dropped (rc 1)
KILLED K7 copy-match dropped (rc 1)
KILLED K8 role credential swap dropped (rc 1)
KILLED K9 @sync swap dropped (rc 1)
KILLED K10 business tracker strip dropped (rc 1)
SURVIVED K11 bus-status pid check dropped (rc 0)
KILLED K12 host-released-store dropped (rc 1)
KILLED K13 SIGKILL counted as a pass (rc 1)
KILLED K14 healthz always ok (rc 1)
KILLED K15 port-reserved dropped (rc 1)
KILLED K16 port-in-use dropped (rc 1)
KILLED K17 backup-sums dropped (rc 1)
KILLED K18 copy-without-no-launch dropped (rc 1)
KILLED K19 migration skipped (rc 1)
KILLED K20 migrate failure ignored (rc 1)
SURVIVED K21 scratch store links the backup (rc 0)
KILLED K22 scratch config keeps the live dataRoot (rc 1)
KILLED K23 host started without --no-launch (rc 1)
KILLED K24 start() launches under --no-launch (rc 1)
KILLED K25 session launch request allowed under --no-launch (rc 1)
KILLED K26 recover() ends sessions under --no-launch (rc 1)
SURVIVED K27 ticker runs under --no-launch (rc 0)
KILLED K28 registry writes under --no-launch (rc 1)
KILLED K29 Registry readOnly writes anyway (rc 1)
KILLED K30 Registry readOnly add() allowed (rc 1)
SURVIVED K31 cli --no-launch not passed to the launcher (rc 0)
KILLED K32 cli --pm with --no-launch allowed (rc 1)
KILLED K33 cli host-up line drops launches disabled (rc 1)
SOME_SURVIVED
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
# Row 163 mutants: each one drops or weakens one behaviour; the named tests must then fail.
import hashlib, os, shutil, subprocess, sys, signal
W = '/mnt/storage/scratch/sage-b163'
OUT = os.environ.get('MUT_OUT', '/mnt/storage/scratch/sage-b163-gate/mutants')
os.makedirs(OUT, exist_ok=True)
SR = 'scripts/stack-release.sh'
T = 'packages/cli/tests/stack-release.test.mjs'
M = [
('K1 credential-ref-live dropped', SR, 'if(f===null||phr===null||path.dirname(f)!==phr)fail("credential-ref-live"', 'if(false)fail("credential-ref-live"', T, '^R9 '),
('K2 tracker-configured dropped', SR, 'if(config.trackers!==undefined)fail(', 'if(false)fail(', T, '^R10 '),
('K3 data-root-live dropped', SR, 'if(root===real(live))fail(', 'if(false)fail(', T, '^R11 '),
('K4 sessions-live dropped', SR, 'if(running.length)fail(', 'if(false)fail(', T, '^R11 '),
('K5 notify-off dropped', SR, 'fail("notify-off",`${nf} is not {', 'void("notify-off",`${nf} is not {', T, '^R12 '),
('K6 no-session dropped', SR, 'if [ -z "$found" ]; then r_pass no-session', 'if true; then r_pass no-session', T, '^R4 '),
('K7 copy-match dropped', SR, ' R_GATE=copy-match\n', ' R_GATE=""; return 0\n', T, '^R7 '),
('K8 role credential swap dropped', SR, 'strip(r?.vars);swap(name,r?.credentials)}', 'strip(r?.vars)}', T, '^R1 '),
('K9 @sync swap dropped', SR, 'swap("@sync",b.tracker?.sync?.credentials);', '', T, '^R1 '),
('K10 business tracker strip dropped', SR, '\nstrip(b.vars);\n', '\n', T, '^R1 '),
('K11 bus-status pid check dropped', SR, 'String(s.host.pid)!==main?`host pid ${s.host.pid} is not ${main}, the host this run', 'false?`host pid ${s.host.pid} is not ${main}, the host this run', T, '^R4 '),
('K12 host-released-store dropped', SR, 'if [ -e "$R/data/bus/writer.lock" ]; then', 'if false; then', T, '^R4 '),
('K13 SIGKILL counted as a pass', SR, 'kill -KILL "$pid" 2>/dev/null || true; r_fail "$name-stopped"', 'kill -KILL "$pid" 2>/dev/null || true; r_pass "$name-stopped"', T, '^R5 '),
('K14 healthz always ok', SR, '.then((r)=>process.exit(r.status===200?0:1),()=>process.exit(1));\' -- "$R_PORT"', '.then(()=>process.exit(0),()=>process.exit(0));\' -- "$R_PORT"', T, '^R6 '),
('K15 port-reserved dropped', SR, '[ "$((10#$R_PORT))" != "$i" ] || r_abort port-reserved', 'true || r_abort port-reserved', T, '^R8 '),
('K16 port-in-use dropped', SR, 'r_port_free "$R_PORT" || r_abort port-in-use', 'true || r_abort port-in-use', T, '^R8 '),
('K17 backup-sums dropped', SR, 'sha256sum -c --quiet ../SHA256SUMS) >/dev/null 2>&1 ||', 'true) >/dev/null 2>&1 ||', T, '^R13 '),
('K18 copy-without-no-launch dropped', SR, 'grep -qF -- \'"--no-launch"\' "$COPIES/$TO/packages/cli/src/cli.mjs" 2>/dev/null ||', 'true ||', T, '^R13 '),
('K19 migration skipped', SR, 'if [ "$v" = "$T_SCHEMA" ]; then r_pass migration', 'if true; then r_pass migration', T, '^R2 '),
('K20 migrate failure ignored', SR, '[ "$rc" = 0 ] && [ "$(tail -n 1 <<< "$out")" = "bus schema $T_SCHEMA ready" ] || r_abort migrate "', 'true || r_abort migrate "', T, '^R3 '),
('K21 scratch store links the backup', SR, 'cp "$R_BK"/bus/* "$R/data/bus/" &&', 'ln -s "$R_BK"/bus/* "$R/data/bus/" &&', T, '^R1 '),
('K22 scratch config keeps the live dataRoot', SR, 'c.dataRoot=data;', '', T, '^R1 '),
('K23 host started without --no-launch', SR, '"$COPIES/$TO/scripts/mosaic" bus start "$BUSINESS" --no-launch >', '"$COPIES/$TO/scripts/mosaic" bus start "$BUSINESS" >', T, '^R1 '),
('K24 start() launches under --no-launch', 'packages/cli/src/launcher.mjs', ' if (!launches) throw new Refusal("launches-disabled", "this host was started with --no-launch");\n', '', 'packages/cli/tests/launcher.test.mjs', None),
('K25 session launch request allowed under --no-launch', 'packages/cli/src/launcher.mjs', ' if (!launches) {\n await host.op({ op: "refuse", cap, code: "launches-disabled" })', ' if (false) {\n await host.op({ op: "refuse", cap, code: "launches-disabled" })', 'packages/cli/tests/launcher.test.mjs', None),
('K26 recover() ends sessions under --no-launch', 'packages/cli/src/launcher.mjs', ' if (!launches) {\n for (const s of leftover) log(', ' if (false) {\n for (const s of leftover) log(', 'packages/cli/tests/launcher.test.mjs', None),
('K27 ticker runs under --no-launch', 'packages/cli/src/launcher.mjs', 'if (b.launch && launches) ticker', 'if (b.launch) ticker', 'packages/cli/tests/launcher.test.mjs', None),
('K28 registry writes under --no-launch', 'packages/cli/src/launcher.mjs', 'new Registry(dataRoot, { readOnly: !launches })', 'new Registry(dataRoot)', 'packages/cli/tests/launcher.test.mjs', None),
('K29 Registry readOnly writes anyway', 'packages/seat/src/session.mjs', ' if (!readOnly) this.#write();', ' this.#write();', 'packages/seat/tests/session.test.mjs', None),
('K30 Registry readOnly add() allowed', 'packages/seat/src/session.mjs', ' if (this.#readOnly) throw new SeatError("this host keeps no sessions (--no-launch)", 2);\n', '', 'packages/seat/tests/session.test.mjs', None),
('K31 cli --no-launch not passed to the launcher', 'packages/cli/src/cli.mjs', 'tracker: Boolean(boot.trackers?.[businessId]), launches, ', 'tracker: Boolean(boot.trackers?.[businessId]), ', 'packages/cli/tests/host.test.mjs', None),
('K32 cli --pm with --no-launch allowed', 'packages/cli/src/cli.mjs', ' if (!launches && parsed.flags.has("--pm")) throw usage(', ' if (false) throw usage(', 'packages/cli/tests/host.test.mjs', None),
('K33 cli host-up line drops launches disabled', 'packages/cli/src/cli.mjs', '${launches ? "" : ", launches disabled"}', '', 'packages/cli/tests/host.test.mjs', None),
('K34 backup-unchanged always passes', SR, 'if [ "$(r_tree_sums "$R_BK")" = "$(cat "$R/backup.before")" ]; then r_pass backup-unchanged', 'if true; then r_pass backup-unchanged', T, '^R14 '),
('K35 live-config-unchanged always passes', SR, 'if [ "$(sha256sum "$LIVE_CFG" "$LIVE_BIZ" 2>&1)" = "$(cat "$R/live.before")" ]; then', 'if true; then', T, '^R14 '),
('K36 credential-ref-live accepts an env ref', SR, 'if(ref?.env!==undefined)fail("credential-ref-live"', 'if(false)fail("credential-ref-live"', T, '^R9 '),
('K37 data-root-scratch mismatch dropped', SR, 'if(root!==real(scratch))fail(', 'if(false)fail(', T, '^R11 '),
('K38 data-root-scratch missing root dropped', SR, 'if(root===null)fail("data-root-scratch"', 'if(false)fail("data-root-scratch"', T, '^R11 '),
('K39 no-session ignores a launches dir', SR, '[ ! -e "$R/data/launches" ] || found+=" launches"', 'true || found+=" launches"', T, '^R4 '),
('K40 no INT/TERM/HUP trap', SR, ' trap \'r_fail signal "the rehearsal was interrupted"; r_end\' INT TERM HUP\n', '', T, '^R15 '),
('K41 read-only Registry writes', 'packages/seat/src/session.mjs', ' if (this.#readOnly) return;\n', '', 'packages/seat/tests/session.test.mjs', None),
]
sha = lambda p: hashlib.sha256(open(p, 'rb').read()).hexdigest()
env = dict(os.environ, TMPDIR='/mnt/storage/scratch/sb163t')
results = []
ONLY = set(sys.argv[1:])
for name, f, old, new, test, pat in M:
if ONLY and name.split()[0] not in ONLY: continue
p = os.path.join(W, f); bak = os.path.join(OUT, os.path.basename(f) + '.orig')
shutil.copy2(p, bak)
if sha(bak) != sha(p): sys.exit(f'backup of {p} failed')
src = open(p).read()
if src.count(old) != 1: sys.exit(f'{name}: pattern count {src.count(old)} in {f}')
open(p, 'w').write(src.replace(old, new))
cmd = ['node', '--test'] + (['--test-name-pattern', pat] if pat else []) + [test]
log = os.path.join(OUT, name.split()[0] + '.log')
try:
with open(log, 'w') as lf:
pr = subprocess.Popen(cmd, cwd=W, env=env, stdout=lf, stderr=subprocess.STDOUT, start_new_session=True)
try: rc = pr.wait(timeout=600)
except subprocess.TimeoutExpired:
os.killpg(pr.pid, signal.SIGKILL); pr.wait(); rc = 'timeout'
finally:
shutil.copy2(bak, p)
if sha(bak) != sha(p): sys.exit(f'restore of {p} failed')
killed = rc != 0
results.append((name, killed, rc))
print(f'{"KILLED" if killed else "SURVIVED"} {name} (rc {rc})', flush=True)
print('ALL_KILLED' if all(k for _, k, _ in results) else 'SOME_SURVIVED')
+48
View File
@@ -0,0 +1,48 @@
✖ R14 a host that writes the live config, the live business file or the backup fails the end check by name (10205.967212ms)
ℹ tests 1
ℹ suites 0
ℹ pass 0
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10820.998717
✖ failing tests:
test at packages/cli/tests/stack-release.test.mjs:2062:1
✖ R14 a host that writes the live config, the live business file or the backup fails the end check by name (10205.967212ms)
AssertionError [ERR_ASSERTION]: /mnt/storage/scratch/sb163t/sr-V3yNfh/data/backups/stack/20261020T000000Z-2a52abd7922e/bus/bus.sqlitePASS copy: 2a52abd7922ec2ce073c2b481b490e984630e9ce, schema 3g
PASS port: 127.0.0.1:38065 is free
PASS backup: /mnt/storage/scratch/sb163t/sr-V3yNfh/data/backups/stack/20261020T000000Z-2a52abd7922e matches its SHA256SUMS
PASS scratch-root: /mnt/storage/scratch/sb163t/sr-V3yNfh/data/rehearsals/20261020T150815Z-2a52abd7922e/data from the backup, config and business file under /mnt/storage/scratch/sb163t/sr-V3yNfh/data/rehearsals/20261020T150815Z-2a52abd7922e/config, business.diff beside them
PASS data-root-live: /mnt/storage/scratch/sb163t/sr-V3yNfh/data/rehearsals/20261020T150815Z-2a52abd7922e/data is not the live data root
PASS sessions-live: no running session named in the data root
PASS data-root-scratch
PASS notify-off: binding null, so no Discord bridge
PASS tracker-configured: bootConfig sets no trackers
PASS credential-ref-live: 9 reference(s), each a placeholder
PASS migration: not needed: the backup is already 3g
PASS gate: host-up, bus-status, console-up, healthz and copy-match on 2a52abd7922ec2ce073c2b481b490e984630e9ce
PASS no-session: no sessions.json and no launches under /mnt/storage/scratch/sb163t/sr-V3yNfh/data/rehearsals/20261020T150815Z-2a52abd7922e/data
PASS console-stopped: pid 3546679
PASS host-stopped: pid 3546678
PASS host-released-store
PASS backup-unchanged
PASS live-config-unchanged
REHEARSAL_PASS
rehearsal directory: /mnt/storage/scratch/sb163t/sr-V3yNfh/data/rehearsals/20261020T150815Z-2a52abd7922e
0 !== 1
at TestContext.<anonymous> (file:///mnt/storage/scratch/sage-b163/packages/cli/tests/stack-release.test.mjs:2066:10)
at async Test.run (node:internal/test_runner/test:1409:7)
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: 0,
expected: 1,
operator: 'strictEqual',
diff: 'simple'
}
+48
View File
@@ -0,0 +1,48 @@
✖ R14 a host that writes the live config, the live business file or the backup fails the end check by name (2394.880127ms)
ℹ tests 1
ℹ suites 0
ℹ pass 0
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3000.67202
✖ failing tests:
test at packages/cli/tests/stack-release.test.mjs:2062:1
✖ R14 a host that writes the live config, the live business file or the backup fails the end check by name (2394.880127ms)
AssertionError [ERR_ASSERTION]: /mnt/storage/scratch/sb163t/sr-VQM0Df/cfg/config.jsonPASS copy: bf4bac79a205e5010c48897456fc6e665a3cf9aa, schema 3g
PASS port: 127.0.0.1:45755 is free
PASS backup: /mnt/storage/scratch/sb163t/sr-VQM0Df/data/backups/stack/20261020T000000Z-bf4bac79a205 matches its SHA256SUMS
PASS scratch-root: /mnt/storage/scratch/sb163t/sr-VQM0Df/data/rehearsals/20261020T150820Z-bf4bac79a205/data from the backup, config and business file under /mnt/storage/scratch/sb163t/sr-VQM0Df/data/rehearsals/20261020T150820Z-bf4bac79a205/config, business.diff beside them
PASS data-root-live: /mnt/storage/scratch/sb163t/sr-VQM0Df/data/rehearsals/20261020T150820Z-bf4bac79a205/data is not the live data root
PASS sessions-live: no running session named in the data root
PASS data-root-scratch
PASS notify-off: binding null, so no Discord bridge
PASS tracker-configured: bootConfig sets no trackers
PASS credential-ref-live: 9 reference(s), each a placeholder
PASS migration: not needed: the backup is already 3g
PASS gate: host-up, bus-status, console-up, healthz and copy-match on bf4bac79a205e5010c48897456fc6e665a3cf9aa
PASS no-session: no sessions.json and no launches under /mnt/storage/scratch/sb163t/sr-VQM0Df/data/rehearsals/20261020T150820Z-bf4bac79a205/data
PASS console-stopped: pid 3547161
PASS host-stopped: pid 3547160
PASS host-released-store
PASS backup-unchanged
PASS live-config-unchanged
REHEARSAL_PASS
rehearsal directory: /mnt/storage/scratch/sb163t/sr-VQM0Df/data/rehearsals/20261020T150820Z-bf4bac79a205
0 !== 1
at TestContext.<anonymous> (file:///mnt/storage/scratch/sage-b163/packages/cli/tests/stack-release.test.mjs:2066:10)
at async Test.run (node:internal/test_runner/test:1409:7)
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: 0,
expected: 1,
operator: 'strictEqual',
diff: 'simple'
}
+39
View File
@@ -0,0 +1,39 @@
✖ R9 mos-0963: a role reference left outside placeholders/, or left on an env variable, fails credential-ref-live before the host starts (609.420853ms)
ℹ tests 1
ℹ suites 0
ℹ pass 0
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 1216.913248
✖ failing tests:
test at packages/cli/tests/stack-release.test.mjs:1971:1
✖ R9 mos-0963: a role reference left outside placeholders/, or left on an env variable, fails credential-ref-live before the host starts (609.420853ms)
AssertionError [ERR_ASSERTION]: PASS copy: f2cd6e0875411f82c798b2a541604410cc77d2c7, schema 3g
PASS port: 127.0.0.1:41709 is free
PASS backup: /mnt/storage/scratch/sb163t/sr-TRrjEx/data/backups/stack/20261020T000000Z-f2cd6e087541 matches its SHA256SUMS
PASS scratch-root: /mnt/storage/scratch/sb163t/sr-TRrjEx/data/rehearsals/20261020T150823Z-f2cd6e087541/data from the backup, config and business file under /mnt/storage/scratch/sb163t/sr-TRrjEx/data/rehearsals/20261020T150823Z-f2cd6e087541/config, business.diff beside them
PASS data-root-live: /mnt/storage/scratch/sb163t/sr-TRrjEx/data/rehearsals/20261020T150823Z-f2cd6e087541/data is not the live data root
PASS sessions-live: no running session named in the data root
PASS data-root-scratch
PASS notify-off: binding null, so no Discord bridge
PASS tracker-configured: bootConfig sets no trackers
FAIL credential-ref-live: pm gitea is undefined, not a file in /mnt/storage/scratch/sb163t/sr-TRrjEx/data/rehearsals/20261020T150823Z-f2cd6e087541/placeholders
PASS backup-unchanged
PASS live-config-unchanged
REHEARSAL_FAIL
rehearsal directory: /mnt/storage/scratch/sb163t/sr-TRrjEx/data/rehearsals/20261020T150823Z-f2cd6e087541
at TestContext.<anonymous> (file:///mnt/storage/scratch/sage-b163/packages/cli/tests/stack-release.test.mjs:1990:9)
at async Test.run (node:internal/test_runner/test:1409:7)
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: false,
expected: true,
operator: '==',
diff: 'simple'
}
+37
View File
@@ -0,0 +1,37 @@
✖ R11 mos-0961: a scratch config on the live data root or on any root but the scratch one, or a sessions.json naming a running pid, refuses by name (605.865597ms)
ℹ tests 1
ℹ suites 0
ℹ pass 0
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 1061.260755
✖ failing tests:
test at packages/cli/tests/stack-release.test.mjs:2006:1
✖ R11 mos-0961: a scratch config on the live data root or on any root but the scratch one, or a sessions.json naming a running pid, refuses by name (605.865597ms)
AssertionError [ERR_ASSERTION]: other-rootPASS copy: 236c2e27d28711316d96143968ae477b7fa7b7e7, schema 3g
PASS port: 127.0.0.1:37319 is free
PASS backup: /mnt/storage/scratch/sb163t/sr-aZQJrB/data/backups/stack/20261020T000000Z-236c2e27d287 matches its SHA256SUMS
PASS scratch-root: /mnt/storage/scratch/sb163t/sr-aZQJrB/data/rehearsals/20261020T150824Z-236c2e27d287/data from the backup, config and business file under /mnt/storage/scratch/sb163t/sr-aZQJrB/data/rehearsals/20261020T150824Z-236c2e27d287/config, business.diff beside them
PASS data-root-live: /mnt/storage/scratch/sb163t/sr-aZQJrB/other-root is not the live data root
PASS sessions-live: no running session named in the data root
PASS data-root-scratch
FAIL notify-off: /mnt/storage/scratch/sb163t/sr-aZQJrB/other-root/notify/acme/notify.json is missing or not JSON
PASS backup-unchanged
PASS live-config-unchanged
REHEARSAL_FAIL
rehearsal directory: /mnt/storage/scratch/sb163t/sr-aZQJrB/data/rehearsals/20261020T150824Z-236c2e27d287
at TestContext.<anonymous> (file:///mnt/storage/scratch/sage-b163/packages/cli/tests/stack-release.test.mjs:2021:10)
at async Test.run (node:internal/test_runner/test:1409:7)
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: false,
expected: true,
operator: '==',
diff: 'simple'
}
+35
View File
@@ -0,0 +1,35 @@
✖ R11 mos-0961: a scratch config on the live data root or on any root but the scratch one, or a sessions.json naming a running pid, refuses by name (916.097165ms)
ℹ tests 1
ℹ suites 0
ℹ pass 0
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 1239.364874
✖ failing tests:
test at packages/cli/tests/stack-release.test.mjs:2006:1
✖ R11 mos-0961: a scratch config on the live data root or on any root but the scratch one, or a sessions.json naming a running pid, refuses by name (916.097165ms)
AssertionError [ERR_ASSERTION]: no-rootPASS copy: 998d46411d6c1d3168396f7bb5932e1e200a9263, schema 3g
PASS port: 127.0.0.1:43579 is free
PASS backup: /mnt/storage/scratch/sb163t/sr-v0EeC4/data/backups/stack/20261020T000000Z-998d46411d6c matches its SHA256SUMS
PASS scratch-root: /mnt/storage/scratch/sb163t/sr-v0EeC4/data/rehearsals/20261020T150825Z-998d46411d6c/data from the backup, config and business file under /mnt/storage/scratch/sb163t/sr-v0EeC4/data/rehearsals/20261020T150825Z-998d46411d6c/config, business.diff beside them
PASS data-root-live: /mnt/storage/scratch/sb163t/sr-v0EeC4/no-root is not the live data root
FAIL guard: The "path" argument must be of type string. Received null
PASS backup-unchanged
PASS live-config-unchanged
REHEARSAL_FAIL
rehearsal directory: /mnt/storage/scratch/sb163t/sr-v0EeC4/data/rehearsals/20261020T150825Z-998d46411d6c
at TestContext.<anonymous> (file:///mnt/storage/scratch/sage-b163/packages/cli/tests/stack-release.test.mjs:2021:10)
at async Test.run (node:internal/test_runner/test:1409:7)
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: false,
expected: true,
operator: '==',
diff: 'simple'
}
+48
View File
@@ -0,0 +1,48 @@
✖ R4 a host that does not start, does not answer status, reports another pid, launches anyway, makes a launches directory or keeps its lock each fails by name (18892.096194ms)
ℹ tests 1
ℹ suites 0
ℹ pass 0
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 19506.873236
✖ failing tests:
test at packages/cli/tests/stack-release.test.mjs:1920:1
✖ R4 a host that does not start, does not answer status, reports another pid, launches anyway, makes a launches directory or keeps its lock each fails by name (18892.096194ms)
AssertionError [ERR_ASSERTION]: host-launches-dirPASS copy: 2cb8943db81ed8e914000449977627f0a9d42d00, schema 3g
PASS port: 127.0.0.1:43729 is free
PASS backup: /mnt/storage/scratch/sb163t/sr-D2Wt2k/data/backups/stack/20261020T000000Z-2cb8943db81e matches its SHA256SUMS
PASS scratch-root: /mnt/storage/scratch/sb163t/sr-D2Wt2k/data/rehearsals/20261020T150843Z-2cb8943db81e/data from the backup, config and business file under /mnt/storage/scratch/sb163t/sr-D2Wt2k/data/rehearsals/20261020T150843Z-2cb8943db81e/config, business.diff beside them
PASS data-root-live: /mnt/storage/scratch/sb163t/sr-D2Wt2k/data/rehearsals/20261020T150843Z-2cb8943db81e/data is not the live data root
PASS sessions-live: no running session named in the data root
PASS data-root-scratch
PASS notify-off: binding null, so no Discord bridge
PASS tracker-configured: bootConfig sets no trackers
PASS credential-ref-live: 9 reference(s), each a placeholder
PASS migration: not needed: the backup is already 3g
PASS gate: host-up, bus-status, console-up, healthz and copy-match on 2cb8943db81ed8e914000449977627f0a9d42d00
PASS no-session: no sessions.json and no launches under /mnt/storage/scratch/sb163t/sr-D2Wt2k/data/rehearsals/20261020T150843Z-2cb8943db81e/data
PASS console-stopped: pid 3550603
PASS host-stopped: pid 3550602
PASS host-released-store
PASS backup-unchanged
PASS live-config-unchanged
REHEARSAL_PASS
rehearsal directory: /mnt/storage/scratch/sb163t/sr-D2Wt2k/data/rehearsals/20261020T150843Z-2cb8943db81e
0 !== 1
at TestContext.<anonymous> (file:///mnt/storage/scratch/sage-b163/packages/cli/tests/stack-release.test.mjs:1924:10)
at async Test.run (node:internal/test_runner/test:1409:7)
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: 0,
expected: 1,
operator: 'strictEqual',
diff: 'simple'
}
+39
View File
@@ -0,0 +1,39 @@
✖ R15 a rehearsal stopped by TERM, INT or HUP fails signal and still stops the host and Console and runs the end checks (1289.845744ms)
ℹ tests 1
ℹ suites 0
ℹ pass 0
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 1749.897862
✖ failing tests:
test at packages/cli/tests/stack-release.test.mjs:2073:1
✖ R15 a rehearsal stopped by TERM, INT or HUP fails signal and still stops the host and Console and runs the end checks (1289.845744ms)
AssertionError [ERR_ASSERTION]: SIGTERMPASS copy: ba3ad437eb120f80d3a1bdc438860968dd038962, schema 3g
PASS port: 127.0.0.1:45847 is free
PASS backup: /mnt/storage/scratch/sb163t/sr-Bin1lp/data/backups/stack/20261020T000000Z-ba3ad437eb12 matches its SHA256SUMS
PASS scratch-root: /mnt/storage/scratch/sb163t/sr-Bin1lp/data/rehearsals/20261020T150846Z-ba3ad437eb12/data from the backup, config and business file under /mnt/storage/scratch/sb163t/sr-Bin1lp/data/rehearsals/20261020T150846Z-ba3ad437eb12/config, business.diff beside them
PASS data-root-live: /mnt/storage/scratch/sb163t/sr-Bin1lp/data/rehearsals/20261020T150846Z-ba3ad437eb12/data is not the live data root
PASS sessions-live: no running session named in the data root
PASS data-root-scratch
PASS notify-off: binding null, so no Discord bridge
PASS tracker-configured: bootConfig sets no trackers
PASS credential-ref-live: 9 reference(s), each a placeholder
PASS migration: not needed: the backup is already 3g
null !== 1
at TestContext.<anonymous> (file:///mnt/storage/scratch/sage-b163/packages/cli/tests/stack-release.test.mjs:2079:10)
at async Test.run (node:internal/test_runner/test:1409:7)
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: null,
expected: 1,
operator: 'strictEqual',
diff: 'simple'
}
+56
View File
@@ -0,0 +1,56 @@
✔ REQ-LIMIT-1: family substring matching is no longer exported (0.993829ms)
✔ sessionEnv passes only the allowlist, the repo's bin on PATH, and the run id (0.643651ms)
✔ sessionExit gives a runner's signal death back as the signal; a session credential is one whole set of nonempty variables (0.22563ms)
✔ newRun: short ids, 0700 directories, and a refusal when the socket path won't fit (1.390202ms)
✔ session file and launch log: 0600, the session file written once (0.890354ms)
✔ endReason maps the runner's exit codes; a signal is killed (0.110674ms)
✔ Registry mirrors to sessions.json; readSessions marks live entries; bad files refuse (1.151527ms)
✖ a read-only Registry never writes sessions.json, refuses add() and leaves the file on remove() (1.357741ms)
✔ REQ-THREAD-1 Registry.running keys a thread run by business, instance and thread (0.526184ms)
✔ REQ-THREAD-2 REQ-LAUNCH-1 threadStore checks both segments before the join and refuses an unsafe directory (1.243195ms)
✔ stopSession refuses an unknown run, reports a stale one, and won't signal a pid that isn't a runner (0.781332ms)
✔ a session runs in its sandbox as pid 1 of its namespace, claims, answers, and stops on mosaic stop (192.679392ms)
ℹ tests 12
ℹ suites 0
ℹ pass 11
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 266.973886
✖ failing tests:
test at packages/seat/tests/session.test.mjs:137:1
✖ a read-only Registry never writes sessions.json, refuses add() and leaves the file on remove() (1.357741ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
+ actual - expected
+ '{\n "sessionsVersion": 1,\n "sessions": []\n}\n'
- '{\n' +
- ' "sessionsVersion": 1,\n' +
- ' "sessions": [\n' +
- ' {\n' +
- ' "business": "acme",\n' +
- ' "instance": "chief",\n' +
- ' "run": "r1",\n' +
- ' "runnerPid": 3551219,\n' +
- ' "runnerStartTime": "61011768"\n' +
- ' }\n' +
- ' ]\n' +
- '}\n'
at TestContext.<anonymous> (file:///mnt/storage/scratch/sage-b163/packages/seat/tests/session.test.mjs:142:10)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: '{\n "sessionsVersion": 1,\n "sessions": []\n}\n',
expected: '{\n "sessionsVersion": 1,\n "sessions": [\n {\n "business": "acme",\n "instance": "chief",\n "run": "r1",\n "runnerPid": 3551219,\n "runnerStartTime": "61011768"\n }\n ]\n}\n',
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,10 @@
f04d12a963b3199420f1d9c41fbdae67faf46f710a805cb9358f1fb52f5faed9 agents/sage/work/queue-163/BUILD.md
5bd1bc697cd56e57b00e60c0e80be72cd1043a55a6d2fc0b9e2ccdd0c5cf9802 docs/TOOLS.md
52597d7fa1469510fcf8e6dfd63664ad4c492bc17039885aa839231798693058 packages/cli/src/cli.mjs
5492ebb5aa3c9c85874e4c7de7b65fc37733e2db636690c11fe2d2fdf72c2dd0 packages/cli/src/launcher.mjs
e9ce0e0f7368d3fb2e1edbd4ebc724513ec19d7bac0c993f8be0d9494427be07 packages/cli/tests/host.test.mjs
42519898b5bcf84c21088647e161a86934d4fe351207f736b9b120b6e1c2b91b packages/cli/tests/launcher.test.mjs
b01ff656962c6fdb5bc3554cfbb13645b7f25adae5f6cd9608af8de6d6091c6c packages/cli/tests/stack-release.test.mjs
448138d12404346989a6751a01c747718cef7d2bec19a0639a29fa6a8b74a82d packages/seat/src/session.mjs
cd6277c4e7155a7c93d09f5bc7cca1130708a35a6b75aa02d99af8360d6338b0 packages/seat/tests/session.test.mjs
5bb3ac40102ce515f1acdcbc5670feb161ce40a193e94e2f8b104ebf98eddc38 scripts/stack-release.sh
File diff suppressed because it is too large. Load diff
+28
View File
@@ -0,0 +1,28 @@
base 2a5f3a3a0 dirty 0 DOCKER_HOST=unset
pkg-bus rc=0 ℹ pass 451 ℹ fail 0 | ℹ duration_ms 2970.304395
pkg-business rc=0 ℹ pass 149 ℹ fail 0 | ℹ duration_ms 1953.76274
pkg-cli rc=0 ℹ pass 472 ℹ fail 0 | ℹ duration_ms 367258.38335
pkg-control-board rc=0 ℹ pass 124 ℹ fail 0 | ℹ duration_ms 585.394845
pkg-conversation rc=0 ℹ pass 182 ℹ fail 0 | ℹ duration_ms 33402.754981
pkg-discord rc=0 ℹ pass 183 ℹ fail 0 | ℹ duration_ms 2610.108892
pkg-harness rc=0 ℹ pass 133 ℹ fail 0 | ℹ duration_ms 11612.167166
pkg-ledger rc=0 ℹ pass 89 ℹ fail 0 | ℹ duration_ms 12928.162376
pkg-mosaic rc=0 ℹ pass 69 ℹ fail 0 | ℹ duration_ms 2092.404214
pkg-plugins rc=0 ℹ pass 111 ℹ fail 0 | ℹ duration_ms 250.471164
pkg-project rc=0 ℹ pass 40 ℹ fail 0 | ℹ duration_ms 1069.844547
pkg-runs rc=0 ℹ pass 43 ℹ fail 0 | ℹ duration_ms 732.731695
pkg-seat rc=0 ℹ pass 152 ℹ fail 0 | ℹ duration_ms 5889.377731
pkg-tasks rc=0 ℹ pass 97 ℹ fail 0 | ℹ duration_ms 1159.016661
pkg-webui rc=0 ℹ pass 269 ℹ fail 0 | ℹ duration_ms 54984.81726
script-auth rc=0 | selftest: 15 passed, 0 failed
script-conductor rc=0 | selftest: 17 passed, 0 failed
script-config rc=0 | selftest: 24 passed, 0 failed
script-discord rc=0 | discord suite: 78 passed, 0 failed
script-extension-package rc=0 | extension package selftest: 18 passed, 0 failed
script-foundation rc=0 | selftest: 44 passed, 0 failed
script-land-work rc=0 | land-work selftests: 86 passed, 0 failed
script-queue rc=0 ℹ pass 156 ℹ fail 0 | queue suite: 28 passed, 0 failed
script-release rc=0 | selftest: 14 passed, 0 failed
script-task rc=0 | selftest: 98 passed, 0 failed
GATE_PASSED
removed network sage-r163_default
@@ -0,0 +1,9 @@
KILLED K34 backup-unchanged always passes (rc 1)
KILLED K35 live-config-unchanged always passes (rc 1)
KILLED K36 credential-ref-live accepts an env ref (rc 1)
KILLED K37 data-root-scratch mismatch dropped (rc 1)
KILLED K38 data-root-scratch missing root dropped (rc 1)
KILLED K39 no-session ignores a launches dir (rc 1)
KILLED K40 no INT/TERM/HUP trap (rc 1)
KILLED K41 read-only Registry writes (rc 1)
ALL_KILLED
+86
View File
@@ -0,0 +1,86 @@
# Row 189: `stack-release.sh activate --no-send`
Gitea #1687. Vikunja P19 #413 (Q175 interim, #412). Brief: docs/plans/2026-10-20_activate-no-send.md.
Rulings: mos-0972 (the Console runs without `--send` until Q175 phase 2), mos-0973 (go; Sage builds,
Dewey reviews; gate is the cli suite plus every scripts/test-*.sh). Base: def2e7932 (refactor, the
brief commit on 9ae8c73ac).
## What it does
`activate ID BUSINESS --no-send` drops `--send` from `RUN_CONSOLE` in a new `send_for_target`, which
`console_for_target` calls after `proof_for_target`. `install_stack` renders the unit from
`RUN_CONSOLE` and `write_pointer` writes the pointer from it, so both lose the flag and `flags_drift`
stays quiet afterwards. Only `activate` takes the flag; the parser sends every other op to usage
(exit 4).
`check_target` lets a `--no-send` activate name the pointer's copy only when the pointer's Console
flags hold `--send`. That run is a live-copy re-pin, the same path row 187 uses for the proof pair:
the plan prints the live-copy note, the run stops both units, backs up the stopped store and starts
them again. A plain same-copy activate, or a same-copy `--no-send` once `--send` is gone, still
refuses `already-active`.
`--plan` prints one `send:` line after the `proof:` line, for activate and rollback:
- `send: dropped; --no-send was given`
- `send: off; --no-send was given and the pointer holds no --send`
- `send: carried` (the pointer's `--send` goes on unchanged)
- `send: off`
The steps log gets `drop --send; --no-send was given`, or `--no-send was given and the pointer holds no
--send, so there is nothing to drop`.
Rollback and pm-on carry the pointer's flags as they are, so once `--no-send` has run, nothing adds
`--send` back. There is no re-enable flag (mos-0973). Q175 phase 2 changes the Console flags in its
own row.
docs/TOOLS.md: `[--no-send]` in the usage block and one paragraph after the proof paragraph.
## Tests (packages/cli/tests/stack-release.test.mjs, T55)
1. Seeded preview pointer with `--send --tester-port 7333`: a same-copy `activate B acme --no-send`
passes. The pointer and the unit ExecStart lack `--send` and keep the other flags in order, status
says the unit flags match, the steps log has the drop line, the log line has from = to = B and the
backup sum equals the stopped store's, and the units stop and start in order.
2. Plain same-copy activate, with `--tester-port` and with `--no-proof`, refuses `already-active`. So does
a same-copy `--no-send` once `--send` is gone, and on a pointer that never held it.
3. The four `send:` lines, the live-copy note on the same-copy plan only, and the Console line right
after `send:` without `--send`. The run on a pointer with no `--send` logs the nothing-to-drop line
and no drop line.
4. After `--no-send` on C1, a rollback to B leaves `--send` out of the pointer and the unit, the steps
log never names `--send`, and status matches. pm-on after `activate --pm-after-harness --no-send`
keeps it off.
5. `--no-send` on rollback (with and without `--plan`), pm-on, status, adopt and rehearse is a usage
error with the usage text, nothing written, started or logged. The usage text shows `[--no-send]`.
6. A pointer holding both the proof pair and `--send`: the plans for `--no-proof`, `--no-send` and both
name what each drops and carries. `--no-send` keeps the pair, `--no-send` with a new pair replaces it,
`--no-proof` keeps `--send`, and both together leave neither. Status matches after each.
`planTail` gains `'send: off'` after `'proof: off'`, because every plan now prints the line. T36 takes
`out.slice(-n)` around `planTail`, so its four slice counts grow by one.
## Mutants
mutants.sh in this packet, run on the candidate. Each mutates scripts/stack-release.sh and runs T55
plus T1 and T2.
- M1: the same-copy exception ignores `--no-send`.
- M2: the same-copy exception allows `--no-send` without `--send` in the pointer.
- M3: with `--no-send`, the pointer is written from `P_CONSOLE`, so it keeps `--send` while the unit
drops it.
- M4: rollback re-adds `--send`.
- M5: the parser accepts `--no-send` for rollback.
Results: mutants-run.log, all five killed (M1 5 failures, M2 2, M3 7, M4 2, M5 2).
## Deviations from the brief
- The brief's line numbers were taken before row 163 landed. The candidate corrects them to the
9ae8c73ac lines (check_target :249-256, flags_drift :646-657, console_for_target :664-693, adopt
:828).
- Test 5 also covers rehearse and `rollback --plan`, which the brief did not list.
## Outbound calls
None. The change edits an array before the existing unit render and pointer write. Every test uses
the stand-in systemctl, units, stores and data root the file already builds; no test touches
`~/.mosaic-dev`, the live units or a slot file.
@@ -0,0 +1,5 @@
M1 KILLED fail=5
M2 KILLED fail=2
M3 KILLED fail=7
M4 KILLED fail=2
M5 KILLED fail=2
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Row 189 mutants M1-M5: each mutates scripts/stack-release.sh in the sage-b189 worktree, runs T55 and
# the plan-tail tests, and restores the file from a checked backup.
set -uo pipefail
WT=/mnt/storage/scratch/sage-b189
OUT=/mnt/storage/scratch/sage-b189-mut/out
S=$WT/scripts/stack-release.sh
mkdir -p "$OUT" || exit 1
cp -p "$S" "$OUT/stack-release.sh.orig" || { echo "backup failed"; exit 1; }
ORIG=$(sha256sum "$S" | cut -d' ' -f1)
[ "$(sha256sum "$OUT/stack-release.sh.orig" | cut -d' ' -f1)" = "$ORIG" ] || { echo "backup differs"; exit 1; }
restore() { cp -p "$OUT/stack-release.sh.orig" "$S"; [ "$(sha256sum "$S" | cut -d' ' -f1)" = "$ORIG" ] || { echo "RESTORE FAILED"; exit 1; }; }
trap restore EXIT
mutate() {
case "$1" in
M1) sed -i '/{ \[ "\$NO_SEND" = 1 \] && \[\[ " \${P_CONSOLE\[\*\]} " == \*" --send "\* \]\]; } ||/d' "$S" ;;
M2) sed -i 's/{ \[ "\$NO_SEND" = 1 \] && \[\[ " \${P_CONSOLE\[\*\]} " == \*" --send "\* \]\]; } ||/{ [ "$NO_SEND" = 1 ]; } ||/' "$S" ;;
M3) sed -i 's/^ write_pointer "\$TO" "\$RUN_PM" "\$RUN_PENDING" "\$P_CLAUDE" "\$T_SCHEMA" "\${RUN_CONSOLE\[@\]}"$/ if [ "$NO_SEND" = 1 ]; then write_pointer "$TO" "$RUN_PM" "$RUN_PENDING" "$P_CLAUDE" "$T_SCHEMA" "${P_CONSOLE[@]}"; else write_pointer "$TO" "$RUN_PM" "$RUN_PENDING" "$P_CLAUDE" "$T_SCHEMA" "${RUN_CONSOLE[@]}"; fi/' "$S" ;;
M4) sed -i 's/^ SEND_PLAN="send: off"$/ SEND_PLAN="send: off"; [ "$OP" != rollback ] || RUN_CONSOLE+=(--send)/' "$S" ;;
M5) sed -i 's/--no-send) \[ "\$OP" = activate \] || usage;/--no-send) { [ "$OP" = activate ] || [ "$OP" = rollback ]; } || usage;/' "$S" ;;
esac
}
for m in M1 M2 M3 M4 M5; do
restore
mutate "$m"
if [ "$(sha256sum "$S" | cut -d' ' -f1)" = "$ORIG" ]; then echo "$m NOT-APPLIED"; continue; fi
bash -n "$S" || { echo "$m SYNTAX"; continue; }
diff "$OUT/stack-release.sh.orig" "$S" > "$OUT/$m.diff"
(cd "$WT" && TMPDIR=/mnt/storage/scratch/t189 timeout 900 node --test --test-reporter=tap --test-name-pattern='T55|T1 |T2 ' packages/cli/tests/stack-release.test.mjs) > "$OUT/$m.log" 2>&1
fails=$(awk '/^# fail /{print $3}' "$OUT/$m.log")
if [ "${fails:-0}" -gt 0 ]; then echo "$m KILLED fail=$fails"; else echo "$m SURVIVED"; fi
done
@@ -1,17 +1,21 @@
b99e7f4bf58ac68c8f5fa58eb7b90c0f85b15ca9a80fd093f6aaeafeac931608 adapters/README.md
91cdf256bd49e4f32d5df023544d18e0f13635bcb8b744fd84fdf84cdf02b1a7 agents/sage/work/queue-66/BUILD.md
7360f270290fbcca7f80480bc28d8f9aac7eb64ff0fdcf8f6b37a8244a76df31 packages/harness/README.md
833216ef467bc3be6f0de8c2f82a7270ac67a578226353063688609c8b60e360 packages/harness/src/runner.mjs
c32d2a3276db92433d4e3c4f4b45fab048d9a46119d9f69cead6a11cfacfeadb agents/sage/work/queue-66/BUILD.md
8777fac3acb89f3af4ac0db2e7c44e309408a8ab8d1b0a0942c81c54ad295fa9 docs/TOOLS.md
ea11398dc5751a0f9415079e7c03f413e97f5443f6bd0fc1fc2319ec646c20cb packages/cli/tests/stack-copy.test.mjs
b027dc74583217b4151173ecbabfcfbfc7b108ac6b7a8e93dd21a2834051714f packages/harness/README.md
bfb2156c609eeaf87c9e209c562576a5ef47535eaeee61d841bb0bd75a76a69d packages/harness/src/runner.mjs
0a27f2c8de7b9191f9c0d4a209331c63ff7630923e669838b41715924b0a64b0 packages/harness/tests/bundle.test.mjs
a14afae1a0e6b0dc12cfd7fdd5f13dddcc114549b633825eb3a2bfc65ac03111 packages/harness/tests/claude-session.test.mjs
b7307b1a4852d48c84807942df11fab4b38c3eba5894052dc24728177ddf8326 packages/harness/tests/fixtures/fake-adapter.mjs
880c72809bf6bfd5b71a017fdc4f485a52bf88951607992874be1ebd8fea4a8c packages/harness/tests/pi-session.test.mjs
f52d4f6d2fb1ffe775e0dbedd72d39708cc8eb79e7769ff10b313359275aee95 packages/harness/tests/runner.test.mjs
81d0352f2dcd41eb2e2ef962365d9b97515d7e8d9668a35bb9ce9dce4f1451ea packages/harness/tests/claude-session.test.mjs
b16f33442cefd5b5ae2af85edab33250ee6b31025e437edf0e4eba9fe0afa793 packages/harness/tests/fixtures/fake-adapter.mjs
1e187a2e23a197640d139e6975dfaf83dd7e337c360d128833678dc60f3e2046 packages/harness/tests/pi-session.test.mjs
37ca6ad6467573658c1ac6807b6cd00e3f9da289107fb689190a1872bff9b89c packages/harness/tests/runner.test.mjs
04c24058f37260c9192d5036c8cf28b50faba8e3e3bf57f198d233485d0d57e6 packages/plugins/harness/claude-code/index.mjs
2c1252e8e8db3ba4551bf1e34156ddf4e933cc664fc157105bef9a2705d6ce3b packages/plugins/harness/pi/index.mjs
7e0bea27d4360a39874fa64b3506a85f83888613a285ec0b087d74bf22b76421 packages/plugins/README.md
ec4ec9502ee8e766e3abd04d9f724c0d8ff4ef114394a0f76d5320c2dc715c5c packages/plugins/tests/harness.test.mjs
f6141dac9a8c61573e4198480fed85e093992ee9c19fb55e9bc2752381b7b07a packages/seat/tests/sandbox.test.mjs
e60bf54c23e04436a982174348857458039a22e1769fc9ad4db8d4f9b57f2e86 packages/webui/tests/pinned-copy.test.mjs
3ba3dbd9ff8dd68f0aa85bac92a8960af10166130bbc16df74fd7340776bb3b1 scripts/stack-copy.sh
7ee6ed6e6ac55b1348d40a358372e66c86fdebacd90001d447040133e1187882 tools/unslop-hook/claude-hook.js
ce1dfb11d0a8e8efd3d6dae67cc259c2e7443a62c7416bdedd4c71306d8c6df1 tools/unslop-hook/extension.ts
f07d67806f40763b4be87d31c2976448b5cfc34d7328519fae2d8313b1ca3e20 tools/unslop-hook/README.md
+222 -32
View File
@@ -33,10 +33,10 @@ index 7636fd8c9..94ae12fb8 100644
+switch the check off or swap its lists.
diff --git a/agents/sage/work/queue-66/BUILD.md b/agents/sage/work/queue-66/BUILD.md
new file mode 100644
index 000000000..1031a901a
index 000000000..99097f192
--- /dev/null
+++ b/agents/sage/work/queue-66/BUILD.md
@@ -0,0 +1,101 @@
@@ -0,0 +1,149 @@
+# Row 66: managed sessions run the unslop check on both harnesses
+
+Sage, 2026-10-20. Brief: `docs/plans/2026-10-11_managed-session-config.md`, section "Managed
@@ -65,8 +65,8 @@ index 000000000..1031a901a
+
+**The operator channel.** The runner sets `MOSAIC_UNSLOP_LOG` to `<run>/unslop.jsonl` after the
+module's variables, so a module can't point it elsewhere. After the landing turn and after each message turn it reads what the
+log gained (whole lines only, from an offset). The next RESULT it sends ends with a note of at
+most 600 characters:
+log gained (whole lines only, from an offset). The next RESULT it sends ends with a note. The
+whole note, both parts and the blank line before them, is at most 600 characters (r2):
+
+- `[unslop: N reply|replies of this session flagged since the last note: <details>. The session gets a correction notice on its next turn.]`
+- `[unslop: the check is broken, so this session's replies go unchecked: <reason>]`, once a run.
@@ -74,13 +74,58 @@ index 000000000..1031a901a
+That RESULT is what `mosaic talk` and `mosaic follow` print. A turn that sends no reply (an
+incoming RESULT, the landing turn) keeps its note for the next one. `note()` doesn't change
+state and `sent()` runs only after `message.send` succeeds, so a failed send keeps the note.
+`clip()` caps the body at 32000 characters before the note, so the sum stays under the bus
+limit of 32768.
+`clip()` caps the body at 32000 characters plus its 33-character cut line before the note, so the
+sum is at most 32633, under the bus limit of 32768. The broken reason is cut at 300 where the
+runner takes it, from the log or from its own read failure, so the broken part is at most 371
+characters and the flagged part is cut to what is left.
+
+**Counts.** `unslopCounts(path)` reads the log after the session ended:
+`{checked, flagged, patterns: {<source>: n}, notices, broken}`. A missing log gives zeros.
+Findings now carry `source`, the entry's pattern id (`ms-unslop:7`), so `patterns` is per id.
+
+**The stack copy (r2).** `scripts/stack-copy.sh` `COPY_PATHS` gains `tools/unslop-hook`. Both
+modules join their files under `REPO`, and a session launched from a pinned copy has
+`REPO = copies/<sha>`. Without it every managed session from a copy past this row would fail at
+bundle build with ENOENT (Dewey P1, comment 29440).
+
+## Round 2
+
+Round 1 (candidate 8ec03b45a, manifest e68c6bce5ec4) got changes from Filbert (29439) and Dewey
+(29440). Round 2 carries:
+
+- F1 (both): a note with both parts wasn't bounded. A 300-character reason, a long flagged part
+ and a clipped answer came to 32934 (Filbert) or 33004 (Dewey), over 32768, and the broker
+ refused the send. `note()` now gives the flagged part only the room the broken part leaves, and
+ `fresh()` cuts every reason at 300, the runner's own read-failure reason included. Tests: the
+ runner test with an answer of 40000 characters, a 400-character `hook_failed` reason and 30
+ flagged replies (note ≤ 600, body ≤ 32768, cut line at 32000), and a unit check that both
+ parts come to exactly 600 and that a read failure on a 250-character path is cut.
+- F2 (Filbert; Dewey's M3): no test failed a send through `main()`. The new runner test has the
+ broker refuse the first RESULT. The note then rides the next RESULT. One correction to the
+ F1 consequence as written: the broker marks a received message delivered, so the refused
+ message isn't received again in that run. It stays unread and its sender gets no reply; the
+ turn doesn't rerun.
+- P1 (Dewey): `COPY_PATHS` gains `tools/unslop-hook`. The copy fixtures in
+ `stack-copy.test.mjs` and `pinned-copy.test.mjs` copy `tools/unslop-hook` too, and the
+ pinned-copy top-level list gains `tools`. A new `stack-copy.test.mjs` test makes a copy, loads
+ both harness modules from it, and checks that the adapter, the six unslop paths and the mounts
+ are under the copy and exist.
+- N1 (Filbert): the two session-test comments say `<dir>/unslop.jsonl`.
+- N2 (Filbert), doc-only follow-up: the brief's `<run>/session/unslop.jsonl` (:463) and "print
+ mode notifies nobody" (:477) are stale. The brief is Jason's text for this row; the deviation
+ below records the path.
+- Dewey's notes, no change: the Claude Code Stop timing (finding 3 below) gets a look in the
+ landing session; `patterns` keys per entry source, which is the brief's "per pattern".
+
+r2 mutants, each killed by its intended test (`mutants-r2.log`):
+
+| Mutant | Killed by |
+|---|---|
+| K1 the r1 `note()` cut | the clipped-answer runner test (the send is refused and it times out) and the unit check |
+| K2 `fresh()` doesn't cut the reason | the unit check |
+| K3 `sent()` before `message.send` | the refused-send runner test |
+| K4 `COPY_PATHS` without `tools/unslop-hook` | the copy test |
+
+## Ship list against the brief
+
+| Brief item | Where |
@@ -121,6 +166,9 @@ index 000000000..1031a901a
+- **`packages/seat/tests/sandbox.test.mjs`** isn't an owned file. Its pin
+ `binds(args).length === list.length - 1` broke because the modules now mount three more files.
+ It is now `- 4`, and the test asserts the three unslop read-only binds by name.
+- **`scripts/stack-copy.sh`** and the two copy tests aren't owned files. The copy change is P1
+ above. PB3 of the Part B window (`repin-partB-proof.md`) holds `scripts/stack-copy.sh` still
+ until B7, so this row lands after the Part B window has run.
+- `MOSAIC_EXTENSIONS` is set through the pi module's `env`, not in the runner.
+- `unslop-check.js` gained the `source` field on findings (needed for per-pattern counts).
+
@@ -138,8 +186,54 @@ index 000000000..1031a901a
+Recorded with the candidate in the review request: test-unslop-check, the harness, plugins,
+seat and cli suites, and every `scripts/test-*.sh`. At landing Sage runs one managed session per
+harness and attaches the notice, the correction and the counts.
diff --git a/docs/TOOLS.md b/docs/TOOLS.md
index ba59079d9..bec1f1f32 100644
--- a/docs/TOOLS.md
+++ b/docs/TOOLS.md
@@ -334,6 +334,7 @@ One read-only tree supplies the bus, Console and human CLI (row 85,
`copy` requires a canonical checkout (.git directory); --allow-worktree
is a test override. COMMIT must be reachable from both local refactor and
origin/refactor. It archives the runtime/import closure, adapters, roles,
+`tools/unslop-hook` (the unslop check both harness modules mount, row 66),
RELEASE, package.json and package-lock.json; refuses missing manifests;
runs npm ci inside the temporary copy; removes write permissions; and
renames beside the target. A published existing copy stays unchanged.
diff --git a/packages/cli/tests/stack-copy.test.mjs b/packages/cli/tests/stack-copy.test.mjs
index 95bb4fc6c..426a31461 100644
--- a/packages/cli/tests/stack-copy.test.mjs
+++ b/packages/cli/tests/stack-copy.test.mjs
@@ -14,7 +14,7 @@ export function setup(t, realPin=false){
const root=mkdtempSync(join(tmpdir(),'stack-copy-')),repo=join(root,'repo'),copies=join(root,'copies'),bin=join(root,'bin');
t.after(()=>{execFileSync('chmod',['-R','u+w',root]);rmSync(root,{recursive:true,force:true})});
mkdirSync(repo);mkdirSync(bin);
- const tracked=execFileSync('git',['-C',ROOT,'ls-files','-co','--exclude-standard','--','scripts','packages','adapters','roles','RELEASE','package.json','package-lock.json',':!packages/*/tests',':!scripts/foundation'],{encoding:'utf8'}).trim().split('\n');
+ const tracked=execFileSync('git',['-C',ROOT,'ls-files','-co','--exclude-standard','--','scripts','packages','adapters','roles','tools/unslop-hook','RELEASE','package.json','package-lock.json',':!packages/*/tests',':!scripts/foundation'],{encoding:'utf8'}).trim().split('\n');
for(const f of tracked)if(existsSync(join(ROOT,f)))cpSync(join(ROOT,f),join(repo,f));
// Lightweight npm-ci fixture; real pin/host/proof integration is tested separately.
const pkg={name:'copy-fixture',version:'1.0.0'};
@@ -31,6 +31,20 @@ export function setup(t, realPin=false){
const fake=body=>{writeFileSync(join(bin,'systemctl'),'#!/bin/bash\n'+body);chmodSync(join(bin,'systemctl'),0o755)};
return {root,repo,copies,bin,id,env,run,fake,search,analyze};
}
+// Row 66: a session launched from a pinned copy reads each harness module's files from the copy,
+// so every path a module names under its REPO (adapter, mounts, bundle sources) is in COPY_PATHS.
+test('a copy carries every file the harness modules read from it',async t=>{
+ const s=setup(t);assert.equal(s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]).status,0);
+ const copy=join(s.copies,s.id),paths=[];
+ for(const kind of ['pi','claude-code']){
+ const m=await import(pathToFileURL(join(copy,'packages','plugins','harness',kind,'index.mjs')).href);
+ // pi's mounts() resolves node_modules, which this fixture copy lacks; its unslop mounts are its
+ // bundle sources, so the sources stand in for them.
+ paths.push(m.adapter(),...(kind==='pi'?Object.values(m.bundle().sources):m.mounts({env:{}}).map(x=>x.src)));
+ }
+ assert.equal(paths.filter(p=>p.includes('/tools/unslop-hook/')).length,6,paths.join('\n'));
+ for(const p of paths){assert.ok(p.startsWith(copy+'/'),p);assert.ok(existsSync(p),p);}
+});
test('shared copy: npm ci, pushed reachability, readonly/existing copy, webui delegation',t=>{
const s=setup(t),r=s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]);assert.equal(r.status,0,r.stderr);
const copy=join(s.copies,s.id);assert.equal(statSync(copy).mode&0o777,0o555);
diff --git a/packages/harness/README.md b/packages/harness/README.md
index 21007bca3..5eaed57d0 100644
index 21007bca3..a64bf4364 100644
--- a/packages/harness/README.md
+++ b/packages/harness/README.md
@@ -68,9 +68,13 @@ so an existing bundle is never overwritten):
@@ -158,7 +252,7 @@ index 21007bca3..5eaed57d0 100644
`harness` is the route's module from `packages/plugins/harness/`, which the
launcher loaded; the bundle calls it for its own files and names and never
@@ -258,6 +262,26 @@ it never touches the disk and isn't in the environment or argv.
@@ -258,6 +262,28 @@ it never touches the disk and isn't in the environment or argv.
header after it names when the message was sent. A failed read fails the
turn without running the adapter: the sender gets `The turn failed: could
not read the current state: <code>`, and the message is read.
@@ -172,8 +266,10 @@ index 21007bca3..5eaed57d0 100644
+ when the next turn gets the correction notice, and `broken_lists`,
+ `skipped_broken` or `hook_failed` when there is no verdict. After each
+ turn the runner reads what the log gained, and the next RESULT it sends
+ ends with a note of at most 600 characters naming the flagged replies;
+ a broken check is told once a run. A turn that sends no reply (an
+ ends with a note naming the flagged replies; a broken check is told
+ once a run. The whole note, both parts together, is at most 600
+ characters, and the answer before it is clipped at 32000, so the RESULT
+ stays under the bus's 32768. A turn that sends no reply (an
+ incoming RESULT, a landing turn) keeps its note for the next reply, and
+ so does a send that failed. That RESULT is what `mosaic talk` and
+ `mosaic follow` print, so the operator sees the note there.
@@ -186,7 +282,7 @@ index 21007bca3..5eaed57d0 100644
exits 0. The handlers go in before anything else, because pid 1 of a
PID namespace never sees a signal it has no handler for; a stop before
diff --git a/packages/harness/src/runner.mjs b/packages/harness/src/runner.mjs
index c2108c4db..76b1b7fb1 100644
index c2108c4db..c10e5fe58 100644
--- a/packages/harness/src/runner.mjs
+++ b/packages/harness/src/runner.mjs
@@ -21,6 +21,12 @@
@@ -202,7 +298,7 @@ index c2108c4db..76b1b7fb1 100644
//
// Exit codes: 0 stopped, 20 founder credentials, 21 claim refused, 22 the
// capability or the claim stopped working (ended, revoked), 23 the broker
@@ -120,6 +126,109 @@ function clip(text) {
@@ -120,6 +126,113 @@ function clip(text) {
return text.length > BODY_MAX ? text.slice(0, BODY_MAX) + "\n[answer cut at 32000 characters]" : text;
}
@@ -266,7 +362,7 @@ index c2108c4db..76b1b7fb1 100644
+ let broken = null;
+ const fresh = (reason) => {
+ if (toldBroken || broken !== null) return;
+ broken = reason;
+ broken = cut(reason, 300);
+ log(`runner: unslop: the check is broken: ${broken}`);
+ };
+ return {
@@ -292,13 +388,17 @@ index c2108c4db..76b1b7fb1 100644
+ if (UNSLOP_BROKEN.has(e.ev)) fresh(unslopReason(e));
+ }
+ },
+ // The whole note, both parts and the blank line, is at most UNSLOP_NOTE_MAX, so a clipped body
+ // plus its note stays under the bus's body limit. The broken part is at most 371 characters
+ // (the reason is cut at 300), and the flagged part gets what is left.
+ note() {
+ const parts = [];
+ if (broken !== null && !toldBroken) parts.push(`[unslop: the check is broken, so this session's replies go unchecked: ${broken}]`);
+ if (flagged.length) {
+ const n = flagged.length;
+ parts.push(cut(`[unslop: ${n} ${n === 1 ? "reply" : "replies"} of this session flagged since the last note: ${[...new Set(flagged)].join("; ")}`, UNSLOP_NOTE_MAX - 60) +
+ ". The session gets a correction notice on its next turn.]");
+ const tail = ". The session gets a correction notice on its next turn.]";
+ const room = UNSLOP_NOTE_MAX - 2 - parts.reduce((sum, p) => sum + p.length + 1, 0) - tail.length;
+ parts.push(cut(`[unslop: ${n} ${n === 1 ? "reply" : "replies"} of this session flagged since the last note: ${[...new Set(flagged)].join("; ")}`, room) + tail);
+ }
+ return parts.length ? `\n\n${parts.join("\n")}` : "";
+ },
@@ -312,7 +412,7 @@ index c2108c4db..76b1b7fb1 100644
const readLine = (stream) => new Promise((resolve, reject) => {
let input = "";
stream.setEncoding("utf8");
@@ -274,6 +383,8 @@ export function adapterEnv(session, env) {
@@ -274,6 +387,8 @@ export function adapterEnv(session, env) {
MOSAIC_PROVIDER: session.provider ?? "",
MOSAIC_MODEL: session.model,
...session.harnessEnv,
@@ -321,7 +421,7 @@ index c2108c4db..76b1b7fb1 100644
};
}
@@ -358,6 +469,7 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
@@ -358,6 +473,7 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
},
});
const turnEnv = adapterEnv(session, env);
@@ -329,7 +429,7 @@ index c2108c4db..76b1b7fb1 100644
let code = EXIT.stopped;
let failures = 0;
@@ -392,6 +504,7 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
@@ -392,6 +508,7 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
try { await client.call("usage.begin", { turn: e.decision }); }
catch (err) { log(`runner: usage baseline unavailable: ${err.code ?? "failed"}`); }
const turn = await runTurn({ session, request: `${block}\n${landingRequest(e)}`, n, env: turnEnv, onChild: (c) => (child = c) });
@@ -337,7 +437,7 @@ index c2108c4db..76b1b7fb1 100644
try { await client.call("usage.end", { turn: e.decision }); }
catch (err) { log(`runner: usage outcome unavailable: ${err.code ?? "failed"}`); }
log(`runner: landing turn ${n} for decision ${e.decision} (${e.task_ref}): ${turn.ok ? "ok" : `failed: ${turn.reason}`}`);
@@ -439,6 +552,7 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
@@ -439,6 +556,7 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
const turn = unread
? { ok: false, reason: unread }
: await runTurn({ session, request: `${block}\n${turnRequest(m)}`, n, env: turnEnv, onChild: (c) => (child = c) });
@@ -345,7 +445,7 @@ index c2108c4db..76b1b7fb1 100644
// A failed/stopped harness can still have used tokens. Missing data remains unknown.
try { await client.call("usage.end", { turn: m.id }); }
catch (e) { log(`runner: usage outcome unavailable: ${e.code ?? "failed"}`); }
@@ -447,7 +561,8 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
@@ -447,7 +565,8 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
try {
if (m.class !== "RESULT") {
const body = turn.ok ? turn.text || "(the session gave no answer)" : `The turn failed: ${turn.reason}`;
@@ -399,7 +499,7 @@ index 7690d9761..174c2858b 100644
// Row 89 (#1583): the prompt names the built-ins as the session's harness names them.
diff --git a/packages/harness/tests/claude-session.test.mjs b/packages/harness/tests/claude-session.test.mjs
index 19e9f2f6d..8327e5f94 100644
index 19e9f2f6d..86bd03a18 100644
--- a/packages/harness/tests/claude-session.test.mjs
+++ b/packages/harness/tests/claude-session.test.mjs
@@ -10,7 +10,7 @@ import { spawn, spawnSync } from "node:child_process";
@@ -432,7 +532,7 @@ index 19e9f2f6d..8327e5f94 100644
sessionDir: join(dir, "session"),
toolSocket: sock.path,
turnMarker: join(dir, "turn.done"),
+ // Row 66: the run directory, so the unslop hooks log to <dir>/session/unslop.jsonl, which the
+ // Row 66: the run directory, so the unslop hooks log to <dir>/unslop.jsonl, which the
+ // runner makes before the first turn.
+ runDir: dir,
};
@@ -495,32 +595,34 @@ index 19e9f2f6d..8327e5f94 100644
+ assert.match(counts.broken, /^lists\.json invalid: empty file/);
+});
diff --git a/packages/harness/tests/fixtures/fake-adapter.mjs b/packages/harness/tests/fixtures/fake-adapter.mjs
index d5811b241..9b21d5c4e 100644
index d5811b241..82ce6e68e 100644
--- a/packages/harness/tests/fixtures/fake-adapter.mjs
+++ b/packages/harness/tests/fixtures/fake-adapter.mjs
@@ -5,6 +5,8 @@
@@ -5,6 +5,9 @@
// FAIL exit 3
// NOMARKER answer without writing the turn marker
// ENV <name> answer "env: <value of name>"
+// UNSLOP <json> append each event of the JSON array to $MOSAIC_UNSLOP_LOG, as the harness's
+// unslop check does (row 66), and answer "unslop: <count>"
+// UNSLOPLONG <json> the same, with an answer of 40000 more characters, over the runner's clip
// anything else answer "echo: <request>"
// Every turn appends its pid to $FAKE_PIDS when set.
@@ -33,6 +35,11 @@ if (body.startsWith("TOOL ")) {
@@ -33,6 +36,12 @@ if (body.startsWith("TOOL ")) {
} else if (body.startsWith("ENV ")) {
process.stdout.write(`env: ${process.env[body.slice(4)]}\n`);
done();
+} else if (body.startsWith("UNSLOP ")) {
+ const events = JSON.parse(body.slice(7));
+} else if (body.startsWith("UNSLOP ") || body.startsWith("UNSLOPLONG ")) {
+ const long = body.startsWith("UNSLOPLONG ");
+ const events = JSON.parse(body.slice(long ? 11 : 7));
+ for (const e of events) appendFileSync(process.env.MOSAIC_UNSLOP_LOG, JSON.stringify({ ts: Date.now(), ...e }) + "\n");
+ process.stdout.write(`unslop: ${events.length}\n`);
+ process.stdout.write(`unslop: ${events.length}${long ? " " + "x".repeat(40000) : ""}\n`);
+ done();
} else if (body === "NOMARKER") {
process.stdout.write("half an answer");
} else {
diff --git a/packages/harness/tests/pi-session.test.mjs b/packages/harness/tests/pi-session.test.mjs
index ceda82beb..1963bc9b1 100644
index ceda82beb..b88285696 100644
--- a/packages/harness/tests/pi-session.test.mjs
+++ b/packages/harness/tests/pi-session.test.mjs
@@ -4,17 +4,17 @@
@@ -557,7 +659,7 @@ index ceda82beb..1963bc9b1 100644
sessionDir: join(dir, "session"),
toolSocket: sock.path,
turnMarker: join(dir, "turn.done"),
+ // Row 66: the run directory, so the unslop check logs to <dir>/session/unslop.jsonl, which the
+ // Row 66: the run directory, so the unslop check logs to <dir>/unslop.jsonl, which the
+ // runner makes before the first turn.
+ runDir: dir,
};
@@ -620,7 +722,7 @@ index ceda82beb..1963bc9b1 100644
+ assert.match(counts.broken, /^lists\.json invalid: empty file/);
+});
diff --git a/packages/harness/tests/runner.test.mjs b/packages/harness/tests/runner.test.mjs
index b739ed770..0063717cd 100644
index b739ed770..fbaf7a646 100644
--- a/packages/harness/tests/runner.test.mjs
+++ b/packages/harness/tests/runner.test.mjs
@@ -13,7 +13,7 @@ import { join } from "node:path";
@@ -632,7 +734,7 @@ index b739ed770..0063717cd 100644
import { typedTools } from "../src/tools.mjs";
import { resolvedFor, scratch } from "./helpers.mjs";
@@ -836,3 +836,78 @@ test("row 65 harnessEnv reaches the adapter, and an exit harness needs no turn m
@@ -836,3 +836,131 @@ test("row 65 harnessEnv reaches the adapter, and an exit harness needs no turn m
await until(() => replies(ctx)() && ctx.inbox.length >= 2);
assert.deepEqual(ctx.inbox.map((m) => m.body.trim()), ["env: xhigh", "half an answer"]);
});
@@ -710,6 +812,59 @@ index b739ed770..0063717cd 100644
+ many.read();
+ assert.ok(many.note().length <= 600, many.note().length);
+ assert.match(many.note(), /^\n\n\[unslop: 50 replies .*\.\.\.\. The session gets a correction notice on its next turn\.\]$/s);
+ // Both parts together are bounded too, and a reason the runner makes itself is cut like one the
+ // log gives: here the log path is a directory with a long name, so the read fails with EISDIR.
+ const deep = join(dir, "d".repeat(250));
+ mkdirSync(deep);
+ const both = unslopNotes(deep);
+ both.read();
+ assert.match(both.note(), /^\n\n\[unslop: the check is broken, so this session's replies go unchecked: the runner can't read .*\.\.\.\]$/);
+ assert.equal(both.note().length, 2 + 71 + 300);
+ const both2 = unslopNotes(join(dir, "both.jsonl"));
+ writeFileSync(join(dir, "both.jsonl"), [JSON.stringify({ ev: "broken_lists", reason: "b".repeat(400) }), ...Array.from({ length: 50 }, (_, i) => JSON.stringify(tell(`banned word "w${i}" x1`.padEnd(40, "x"))))].join("\n") + "\n");
+ both2.read();
+ assert.equal(both2.note().length, 600);
+ assert.match(both2.note(), /^\n\n\[unslop: the check is broken, so this session's replies go unchecked: b{297}\.\.\.\]\n\[unslop: 50 replies .*\.\.\.\. The session gets a correction notice on its next turn\.\]$/s);
+});
+
+test("row 66 a clipped answer with both note parts stays under the bus's body limit, and the note under 600", async (t) => {
+ const ctx = await setup(t);
+ ctx.inbox = [];
+ const r = startRunner(ctx.runDir, ctx.cap);
+ t.after(() => r.child.kill("SIGKILL"));
+ await until(() => ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder' AND op='claim'"));
+ const events = [{ ev: "hook_failed", reason: "r".repeat(400) }, ...Array.from({ length: 30 }, (_, i) => tell(`banned word "w${i}" x1`.padEnd(60, "x")))];
+ ctx.call(ctx.pm, "message.send", { to: "coder", body: `UNSLOPLONG ${JSON.stringify(events)}`, class: "REQUEST" });
+ await until(() => replies(ctx)() && ctx.inbox.length >= 1);
+ const body = ctx.inbox[0].body;
+ const at = body.indexOf("\n[answer cut at 32000 characters]");
+ assert.equal(at, 32000);
+ const note = body.slice(at + "\n[answer cut at 32000 characters]".length);
+ assert.ok(note.length <= 600, note.length);
+ assert.ok(body.length <= 32768, body.length);
+ assert.match(note, /^\n\n\[unslop: the check is broken, so this session's replies go unchecked: r+\.\.\.\]\n\[unslop: 30 replies .*\.\.\.\. The session gets a correction notice on its next turn\.\]$/s);
+});
+
+test("row 66 a refused send keeps the note: it rides the next RESULT the session sends", async (t) => {
+ const ctx = await setup(t);
+ ctx.inbox = [];
+ // The broker refuses the session's first RESULT once, as it would a body it can't take.
+ const request = ctx.broker.request.bind(ctx.broker);
+ let refused = 0;
+ ctx.broker.request = (cap, r) => {
+ if (r.verb === "message.send" && r.args?.class === "RESULT" && refused++ === 0) throw new BusError("invalid-request");
+ return request(cap, r);
+ };
+ const r = startRunner(ctx.runDir, ctx.cap);
+ t.after(() => r.child.kill("SIGKILL"));
+ await until(() => ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder' AND op='claim'"));
+ const sent = unslopAsk(ctx, [tell('banned word "delve" x1')]);
+ await until(() => r.stderr().includes(`runner: reply to ${sent.id} failed: invalid-request\n`));
+ // The refused message was delivered and isn't read; its reply never reached the bus.
+ assert.ok(!ctx.store.get("SELECT 1 FROM deliveries WHERE message=? AND op='read'", sent.id));
+ const next = await ask(ctx, "ENV MOSAIC_MODEL");
+ assert.equal(next.body, 'env: fake-model\n\n[unslop: 1 reply of this session flagged since the last note: banned word "delve" x1. The session gets a correction notice on its next turn.]');
+ assert.equal(ctx.inbox.length, 1);
+});
diff --git a/packages/plugins/README.md b/packages/plugins/README.md
index 9b6cf91f6..2b30708b0 100644
@@ -907,6 +1062,41 @@ index 4fcf7e8fd..b42d81d82 100644
});
// Row 121 (#1615): a thread run gets only its own thread's two directories,
diff --git a/packages/webui/tests/pinned-copy.test.mjs b/packages/webui/tests/pinned-copy.test.mjs
index f2bdfa025..e9a2d3109 100644
--- a/packages/webui/tests/pinned-copy.test.mjs
+++ b/packages/webui/tests/pinned-copy.test.mjs
@@ -33,7 +33,7 @@ function tmp(t, prefix) {
function fixtureRepo(t) {
const dir = tmp(t, 'webui-copy-');
const repo = join(dir, 'repo');
- const files = execFileSync('git', ['-C', REPO, 'ls-files', '-z', '-co', '--exclude-standard', '--', 'packages', 'scripts', 'roles', 'adapters', 'RELEASE', ':!packages/*/tests', ':!scripts/foundation'], { encoding: 'utf8' }).split('\0').filter(Boolean);
+ const files = execFileSync('git', ['-C', REPO, 'ls-files', '-z', '-co', '--exclude-standard', '--', 'packages', 'scripts', 'roles', 'adapters', 'tools/unslop-hook', 'RELEASE', ':!packages/*/tests', ':!scripts/foundation'], { encoding: 'utf8' }).split('\0').filter(Boolean);
for (const f of files) if (existsSync(join(REPO, f))) cpSync(join(REPO, f), join(repo, f));
writeFileSync(join(repo, 'RELEASE'), '0.0.0-fixture\n');
writeFileSync(join(repo,'package.json'),JSON.stringify({name:'copy-fixture',version:'1.0.0'}));
@@ -72,7 +72,7 @@ test('copy writes the Console paths at the full id, read-only, and leaves an exi
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, new RegExp(`^copied: ${target}$`, 'm'));
assert.deepEqual(readdirSync(copies), [head], 'no temp directory left behind');
- assert.deepEqual(readdirSync(target).sort(), ['RELEASE', 'adapters', 'package-lock.json', 'package.json', 'packages', 'roles', 'scripts']);
+ assert.deepEqual(readdirSync(target).sort(), ['RELEASE', 'adapters', 'package-lock.json', 'package.json', 'packages', 'roles', 'scripts', 'tools']);
assert.deepEqual(readdirSync(join(target, 'scripts')), ['mosaic', 'mosaic-config.mjs']);
assert.ok(existsSync(join(target, 'packages', 'webui', 'README.md')), "the unit's Documentation=");
assert.equal(statSync(join(target, 'packages', 'webui', 'README.md')).mode & 0o222, 0, 'README.md is read-only');
diff --git a/scripts/stack-copy.sh b/scripts/stack-copy.sh
index 24bbb02a6..9ea9bf485 100755
--- a/scripts/stack-copy.sh
+++ b/scripts/stack-copy.sh
@@ -30,6 +30,8 @@ COPY_PATHS=(
packages/queue/src
# Whole directory: every in-repo plug-in module ships with the copy (row 131).
packages/plugins
+ # The unslop check both harness modules mount and list in the bundle (row 66).
+ tools/unslop-hook
)
# stack-release.sh sources this file and calls reachable before an activate (D1).
reachable() {
diff --git a/tools/unslop-hook/README.md b/tools/unslop-hook/README.md
index ef908a639..2f5ca166b 100644
--- a/tools/unslop-hook/README.md
+17 -16
View File
@@ -1,20 +1,20 @@
base 5487ed39d dirty 19 DOCKER_HOST=unset
base 8ec03b45a dirty 11 DOCKER_HOST=unset
unslop rc=0 | 28/28 passed
pkg-bus rc=0 ℹ pass 451 ℹ fail 0 | ℹ duration_ms 3061.393351
pkg-business rc=0 ℹ pass 149 ℹ fail 0 | ℹ duration_ms 1980.731646
pkg-cli rc=0 ℹ pass 453 ℹ fail 0 | ℹ duration_ms 295260.434062
pkg-control-board rc=0 ℹ pass 124 ℹ fail 0 | ℹ duration_ms 595.44639
pkg-conversation rc=0 ℹ pass 182 ℹ fail 0 | ℹ duration_ms 33410.059334
pkg-discord rc=0 ℹ pass 183 ℹ fail 0 | ℹ duration_ms 2607.483772
pkg-harness rc=0 ℹ pass 140 ℹ fail 0 | ℹ duration_ms 11430.754901
pkg-ledger rc=0 ℹ pass 89 ℹ fail 0 | ℹ duration_ms 12897.7941
pkg-mosaic rc=0 ℹ pass 69 ℹ fail 0 | ℹ duration_ms 2020.071836
pkg-plugins rc=0 ℹ pass 111 ℹ fail 0 | ℹ duration_ms 250.102521
pkg-project rc=0 ℹ pass 40 ℹ fail 0 | ℹ duration_ms 1087.373203
pkg-runs rc=0 ℹ pass 43 ℹ fail 0 | ℹ duration_ms 746.064857
pkg-seat rc=0 ℹ pass 151 ℹ fail 0 | ℹ duration_ms 5803.820998
pkg-tasks rc=0 ℹ pass 97 ℹ fail 0 | ℹ duration_ms 1136.245483
pkg-webui rc=0 ℹ pass 269 ℹ fail 0 | ℹ duration_ms 54812.118042
pkg-bus rc=0 ℹ pass 451 ℹ fail 0 | ℹ duration_ms 3039.978
pkg-business rc=0 ℹ pass 149 ℹ fail 0 | ℹ duration_ms 2119.713943
pkg-cli rc=0 ℹ pass 454 ℹ fail 0 | ℹ duration_ms 294959.155129
pkg-control-board rc=0 ℹ pass 124 ℹ fail 0 | ℹ duration_ms 590.346847
pkg-conversation rc=0 ℹ pass 182 ℹ fail 0 | ℹ duration_ms 33735.620305
pkg-discord rc=0 ℹ pass 183 ℹ fail 0 | ℹ duration_ms 2607.173768
pkg-harness rc=0 ℹ pass 142 ℹ fail 0 | ℹ duration_ms 11398.667142
pkg-ledger rc=0 ℹ pass 89 ℹ fail 0 | ℹ duration_ms 12920.093134
pkg-mosaic rc=0 ℹ pass 69 ℹ fail 0 | ℹ duration_ms 2060.508354
pkg-plugins rc=0 ℹ pass 111 ℹ fail 0 | ℹ duration_ms 245.928975
pkg-project rc=0 ℹ pass 40 ℹ fail 0 | ℹ duration_ms 1052.940331
pkg-runs rc=0 ℹ pass 43 ℹ fail 0 | ℹ duration_ms 741.129125
pkg-seat rc=0 ℹ pass 151 ℹ fail 0 | ℹ duration_ms 5795.650378
pkg-tasks rc=0 ℹ pass 97 ℹ fail 0 | ℹ duration_ms 1148.870662
pkg-webui rc=0 ℹ pass 269 ℹ fail 0 | ℹ duration_ms 55714.69432
script-auth rc=0 | selftest: 15 passed, 0 failed
script-conductor rc=0 | selftest: 17 passed, 0 failed
script-config rc=0 | selftest: 24 passed, 0 failed
@@ -28,3 +28,4 @@ script-task rc=0 | selftest: 98 passed, 0 failed
GATE_PASSED
removed network sage-b66_default
exit=0
2026-10-20T13:41:45Z
+5
View File
@@ -0,0 +1,5 @@
K1-note-r1 rc=1 KILLED ℹ pass 37 ℹ fail 2
K2-reason-uncut rc=1 KILLED ℹ pass 38 ℹ fail 1
K3-sent-first rc=1 KILLED ℹ pass 38 ℹ fail 1
K4-no-copy-path rc=1 KILLED ℹ pass 12 ℹ fail 1
MUTANTS_DONE
+10
View File
@@ -0,0 +1,10 @@
packages/harness/tests/runner.test.mjs rc=0 ℹ pass 39 ℹ fail 0
packages/harness/tests/pi-session.test.mjs rc=0 ℹ pass 10 ℹ fail 0
packages/harness/tests/claude-session.test.mjs rc=0 ℹ pass 10 ℹ fail 0
packages/cli/tests/stack-copy.test.mjs rc=0 ℹ pass 13 ℹ fail 0
packages/webui/tests/pinned-copy.test.mjs rc=0 ℹ pass 10 ℹ fail 0
packages/cli/tests/stack-release.test.mjs rc=0 ℹ pass 151 ℹ fail 0
packages/cli/tests/thread-launch.test.mjs rc=0 ℹ pass 11 ℹ fail 0
packages/seat/tests/sandbox.test.mjs rc=0 ℹ pass 10 ℹ fail 0
unslop rc=0 28/28 passed
SINGLES_DONE
@@ -158,3 +158,41 @@ Ruling:
auth.
Mos tells Jason in the morning summary.
## 2026-10-20T17:48Z correction: on a pinned host Jason uses plain `mosaic` (Mos mos-0968)
This supersedes the 10-12 line at 18:56Z, "Until row 85 lands, Jason uses
the full path, `/mnt/storage/src/mosaic-stack/scripts/mosaic talk pm
"..."`". Row 85 landed, and the host, bus and Console now run from the
pinned copy 490e2771d3d0. The bus takes human commands only from the tree
it runs from. The canonical checkout is not that tree.
Jason ran the full-path command Mos gave him from that line. It refused
with human-required: "the bus accepts these commands only from the tree it
runs from; on a pinned host run `mosaic` on PATH". ~/.npm-global/bin/mosaic
now execs copies/490e2771d3d0/scripts/mosaic, which I read at 17:48Z, so
Jason uses plain `mosaic talk pm "..."` or the Console. Friction is on
Vikunja #205, comment 369.
Nothing else changes. The refused command never reached pm, so the
reachability check (mos-0960) has not happened and piece 1 is not open.
## 2026-10-20T17:54Z S7 reachability pass (Mos mos-0969)
Jason ran the plain PATH command from the 17:48Z entry. His output, as Mos
relayed it in mos-0969:
```
[jwoltje@dragon-lin winA]$ mosaic talk pm "Can you hear me?"
sent 51cebff7 to pm
pm isn't running, chief has been told
pm (RESULT, re 51cebff7) cff1e9ad:
Yes, I'm getting your messages. I haven't changed anything on the tracker. What do you need?
```
pm was not running when 51cebff7 arrived. The chief launched it, and pm
answered with cff1e9ad. Under mos-0960 this is the reachability check, not
a piece. It passed, so the "pm doesn't answer" branch does not apply and
no defect is filed.
Piece 1 is Jason's next real Mosaic Stack request to pm. It is not open yet.
@@ -0,0 +1,50 @@
// Sage, 2026-10-20: row 163 (D2, #1660) round 2 approved. One comment on P19 337 (D2), per mos-0882 (every
// landing or decision gets a Vikunja comment). Sage's own key (bot user 13); the sync key reads back. Comments only:
// no task edits, labels, assignees or bucket moves. Checks the task's title before posting. Prints no secret.
// Usage: node comment-337-r163-approved.mjs https://tasks.woltje.com SAGE_TOKEN_FILE SYNC_TOKEN_FILE OUT_JSON
import { readFileSync, writeFileSync, existsSync } from "node:fs";
const [ORIGIN, KEY, SYNC, OUT] = process.argv.slice(2);
if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only");
if (!OUT || existsSync(OUT)) throw new Error("OUT_JSON missing or exists; refusing");
const PROJECT = 19;
const PSIG = "Posted by Sage (T3 1ef1e4f8) with Sage's own key (bot user 13).";
const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const ps = (xs) => xs.map((x) => `<p>${esc(x)}</p>`).join("") + `<p>${esc(PSIG)}</p>`;
const POSTS = [
{ id: 337, title: /^D2: stack rehearsal on a scratch root/, body: [
"Row 163 (Gitea #1660) round 2 is approved by Filbert (comment 29447, queue rev 2234, committed cd2b377bb). Candidate 2c090eefd on code 2a5f3a3a0, base 4f02486d5, manifest digest c6f4f4c12596. Filbert's own run: 249 of 249 single-file tests pass, and their seven mutants (M1-M6 from round 1, plus one for the read-only registry) are all killed.",
"Not landed yet. Mos's landing order holds (mos-0965, mos-0967): the candidate moves scripts/stack-release.sh off blob 2586a2a37cd2, which the Part B A2 check rests on, so D2 lands only after the Part B window closes (B7). If refactor moves past 4f02486d5 on any manifest path or otherwise before then, the full gate runs again on the new base before landing.",
] },
];
const key = readFileSync(KEY, "utf8").trim(), sync = readFileSync(SYNC, "utf8").trim();
async function api(method, path, body, auth) {
const r = await fetch(ORIGIN + path, { method, headers: { Authorization: `Bearer ${auth}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), redirect: "error" });
const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {}
return { status: r.status, json };
}
const list = (j) => j?.items ?? (Array.isArray(j) ? j : []);
const rec = { at: new Date().toISOString(), comments: [], problems: [] };
const save = () => {
const out = JSON.stringify(rec, null, 1) + "\n";
if (out.includes(key) || out.includes(sync)) throw new Error("secret in the record; not written");
writeFileSync(OUT, out);
};
for (const P of POSTS) {
const t = await api("GET", `/api/v2/tasks/${P.id}`, undefined, sync);
if (t.status !== 200 || t.json?.project_id !== PROJECT || !P.title.test(t.json?.title ?? "")) throw new Error(`task ${P.id}: ${t.status}, title check failed; nothing posted`);
}
writeFileSync(OUT, "", { flag: "wx" });
for (const P of POSTS) {
const c = await api("PUT", `/api/v1/tasks/${P.id}/comments`, { comment: ps(P.body) }, key);
const e = { task: P.id, status: c.status, id: c.json?.id ?? null };
rec.comments.push(e);
if (c.status >= 300 || !Number.isSafeInteger(c.json?.id)) { rec.problems.push(`comment ${P.id}: ${c.status} ${c.json?.message ?? ""}`); save(); continue; }
const back = await api("GET", `/api/v2/tasks/${P.id}/comments?per_page=50`, undefined, sync);
const found = list(back.json).find((x) => x.id === c.json.id);
Object.assign(e, { readBack: back.status, found: Boolean(found) });
if (back.status !== 200 || !found) rec.problems.push(`comment ${P.id} read-back: ${back.status}`);
save();
}
save();
process.stdout.write(JSON.stringify(rec, null, 1) + "\n");
if (rec.problems.length) process.exit(2);
@@ -0,0 +1,51 @@
// Sage, 2026-10-20: row 163 (D2, #1660) in review, round 2. One comment on P19 337 (D2), per mos-0882 (every
// landing or decision gets a Vikunja comment). Sage's own key (bot user 13); the sync key reads back. Comments only:
// no task edits, labels, assignees or bucket moves. Checks the task's title before posting. Prints no secret.
// Usage: node comment-337-r163-inreview-2.mjs https://tasks.woltje.com SAGE_TOKEN_FILE SYNC_TOKEN_FILE OUT_JSON
import { readFileSync, writeFileSync, existsSync } from "node:fs";
const [ORIGIN, KEY, SYNC, OUT] = process.argv.slice(2);
if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only");
if (!OUT || existsSync(OUT)) throw new Error("OUT_JSON missing or exists; refusing");
const PROJECT = 19;
const PSIG = "Posted by Sage (T3 1ef1e4f8) with Sage's own key (bot user 13).";
const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const ps = (xs) => xs.map((x) => `<p>${esc(x)}</p>`).join("") + `<p>${esc(PSIG)}</p>`;
const POSTS = [
{ id: 337, title: /^D2: stack rehearsal on a scratch root/, body: [
"Row 163 (Gitea #1660) is in review, round 2, with Filbert as reviewer (request comment 29446, queue revs 2231-2233, committed c539995cf). Round 1 got changes from Filbert (comment 29445, rev 2230): six mutants survived because no test drove those checks to fail, plus a non-blocking note that a read-only session registry still wrote on remove().",
"Round 2 adds the tests: R14 (a host that writes the live config, business file or backup fails live-config-unchanged or backup-unchanged), R15 (TERM, INT and HUP during the gate fail signal and still stop the host and Console and run the end checks), an env-ref case in R9, other-root and missing-root cases in R11, and a launches directory case in R4. The read-only registry now never writes its file. stack-release.sh is unchanged from round 1.",
"Candidate 2c090eefd on code 2a5f3a3a0, base 4f02486d5; manifest digest c6f4f4c12596, 10 files, +1024/-13 (round 1 to round 2: 4 files, +85/-7). The gate passed on 2a5f3a3a0; mutants K34-K41 are all killed. Mos checked the manifest before the request (mos-0967). Packet: agents/sage/work/queue-163/r2/ in refactor 7af166574. Landing order is unchanged: after the Part B window closes (B7), with a new gate if the base moves.",
] },
];
const key = readFileSync(KEY, "utf8").trim(), sync = readFileSync(SYNC, "utf8").trim();
async function api(method, path, body, auth) {
const r = await fetch(ORIGIN + path, { method, headers: { Authorization: `Bearer ${auth}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), redirect: "error" });
const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {}
return { status: r.status, json };
}
const list = (j) => j?.items ?? (Array.isArray(j) ? j : []);
const rec = { at: new Date().toISOString(), comments: [], problems: [] };
const save = () => {
const out = JSON.stringify(rec, null, 1) + "\n";
if (out.includes(key) || out.includes(sync)) throw new Error("secret in the record; not written");
writeFileSync(OUT, out);
};
for (const P of POSTS) {
const t = await api("GET", `/api/v2/tasks/${P.id}`, undefined, sync);
if (t.status !== 200 || t.json?.project_id !== PROJECT || !P.title.test(t.json?.title ?? "")) throw new Error(`task ${P.id}: ${t.status}, title check failed; nothing posted`);
}
writeFileSync(OUT, "", { flag: "wx" });
for (const P of POSTS) {
const c = await api("PUT", `/api/v1/tasks/${P.id}/comments`, { comment: ps(P.body) }, key);
const e = { task: P.id, status: c.status, id: c.json?.id ?? null };
rec.comments.push(e);
if (c.status >= 300 || !Number.isSafeInteger(c.json?.id)) { rec.problems.push(`comment ${P.id}: ${c.status} ${c.json?.message ?? ""}`); save(); continue; }
const back = await api("GET", `/api/v2/tasks/${P.id}/comments?per_page=50`, undefined, sync);
const found = list(back.json).find((x) => x.id === c.json.id);
Object.assign(e, { readBack: back.status, found: Boolean(found) });
if (back.status !== 200 || !found) rec.problems.push(`comment ${P.id} read-back: ${back.status}`);
save();
}
save();
process.stdout.write(JSON.stringify(rec, null, 1) + "\n");
if (rec.problems.length) process.exit(2);
@@ -0,0 +1,51 @@
// Sage, 2026-10-20: row 163 (D2, #1660) in review, round 1. One comment on P19 337 (D2), per mos-0882 (every
// landing or decision gets a Vikunja comment). Sage's own key (bot user 13); the sync key reads back. Comments only:
// no task edits, labels, assignees or bucket moves. Checks the task's title before posting. Prints no secret.
// Usage: node comment-337-r163-inreview.mjs https://tasks.woltje.com SAGE_TOKEN_FILE SYNC_TOKEN_FILE OUT_JSON
import { readFileSync, writeFileSync, existsSync } from "node:fs";
const [ORIGIN, KEY, SYNC, OUT] = process.argv.slice(2);
if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only");
if (!OUT || existsSync(OUT)) throw new Error("OUT_JSON missing or exists; refusing");
const PROJECT = 19;
const PSIG = "Posted by Sage (T3 1ef1e4f8) with Sage's own key (bot user 13).";
const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const ps = (xs) => xs.map((x) => `<p>${esc(x)}</p>`).join("") + `<p>${esc(PSIG)}</p>`;
const POSTS = [
{ id: 337, title: /^D2: stack rehearsal on a scratch root/, body: [
"Row 163 (Gitea #1660) is in review, round 1, with Filbert as reviewer (request comment 29444, queue revs 2228-2229, committed def677bc7). Candidate 4be3f6aec on 580500699, base 4f02486d5; manifest digest 430212299beb, 10 files, +947/-14. It adds `stack-release.sh rehearse`, which runs the D2 rehearsal on a scratch root from a backup, and `bus start --no-launch`, a host that launches no sessions, runs no thread tick and writes no sessions.json.",
"The gate passed on 580500699 (all package and script suites; Docker network removed, 0 containers left). Mutants K1-K33 are all killed; four survived the first run (K11, K21, K27, K31) and each got a test. Packet: agents/sage/work/queue-163/ in refactor 51f67f1f8.",
"Mos accepted deviations 1-7 in mos-0965; deviation 3 (the scratch business keeps its tracker key, with @sync on a placeholder ref) rests on mos-0964, since business.mjs:197 refuses a file without it. Landing order (mos-0965): the candidate moves stack-release.sh off blob 2586a2a37cd2, which the Part B A2 check rests on, so D2 lands only after the Part B window closes (B7). If the base moves before then, the gate runs again.",
] },
];
const key = readFileSync(KEY, "utf8").trim(), sync = readFileSync(SYNC, "utf8").trim();
async function api(method, path, body, auth) {
const r = await fetch(ORIGIN + path, { method, headers: { Authorization: `Bearer ${auth}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), redirect: "error" });
const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {}
return { status: r.status, json };
}
const list = (j) => j?.items ?? (Array.isArray(j) ? j : []);
const rec = { at: new Date().toISOString(), comments: [], problems: [] };
const save = () => {
const out = JSON.stringify(rec, null, 1) + "\n";
if (out.includes(key) || out.includes(sync)) throw new Error("secret in the record; not written");
writeFileSync(OUT, out);
};
for (const P of POSTS) {
const t = await api("GET", `/api/v2/tasks/${P.id}`, undefined, sync);
if (t.status !== 200 || t.json?.project_id !== PROJECT || !P.title.test(t.json?.title ?? "")) throw new Error(`task ${P.id}: ${t.status}, title check failed; nothing posted`);
}
writeFileSync(OUT, "", { flag: "wx" });
for (const P of POSTS) {
const c = await api("PUT", `/api/v1/tasks/${P.id}/comments`, { comment: ps(P.body) }, key);
const e = { task: P.id, status: c.status, id: c.json?.id ?? null };
rec.comments.push(e);
if (c.status >= 300 || !Number.isSafeInteger(c.json?.id)) { rec.problems.push(`comment ${P.id}: ${c.status} ${c.json?.message ?? ""}`); save(); continue; }
const back = await api("GET", `/api/v2/tasks/${P.id}/comments?per_page=50`, undefined, sync);
const found = list(back.json).find((x) => x.id === c.json.id);
Object.assign(e, { readBack: back.status, found: Boolean(found) });
if (back.status !== 200 || !found) rec.problems.push(`comment ${P.id} read-back: ${back.status}`);
save();
}
save();
process.stdout.write(JSON.stringify(rec, null, 1) + "\n");
if (rec.problems.length) process.exit(2);
@@ -0,0 +1,13 @@
{
"at": "2026-10-20T15:41:54.066Z",
"comments": [
{
"task": 337,
"status": 201,
"id": 368,
"readBack": 200,
"found": true
}
],
"problems": []
}
@@ -0,0 +1,13 @@
{
"at": "2026-10-20T15:26:21.265Z",
"comments": [
{
"task": 337,
"status": 201,
"id": 367,
"readBack": 200,
"found": true
}
],
"problems": []
}
@@ -0,0 +1,13 @@
{
"at": "2026-10-20T14:49:31.177Z",
"comments": [
{
"task": 337,
"status": 201,
"id": 366,
"readBack": 200,
"found": true
}
],
"problems": []
}
+10
View File
@@ -1348,3 +1348,13 @@ are never rewritten or removed; corrections are new entries.
2026-10-20T11:08:34Z | Filbert (Opus 5.5, 048f3c63) | row 188 r1 review (#1686, manifest 86aa549bad75) | approve, comment 29433, rev 2212; stack-release 151/151 on my slot run; M8-M10 and my M11 killed by T54; N1 REPO logical-path gap in stack-release.sh and webui-service.sh (mos-0959, filed after Part B)
2026-10-20T11:14:11Z | Sage (Opus 5.5, 1ef1e4f8) | row 188 landing (#1686, P19 409/410), N1 to P19 | landed 8fc0ee216 (patch-id c9a56b167c07, stack-release.sh blob 2586a2a37cd2 unchanged), queue 31e328958 revs 2209-2214, pushed b9c1c9fe2..31e328958 with Filbert's 23a609c5c; #1686 comment 29435, closed; P19 411 (N1, after Part B per mos-0959), comments 361-363, 409/410 done; my done update blanked 409/410 descriptions, restored 11:13Z (restore-409-410-desc.mjs), script fixed
2026-10-20T13:14:25Z | Sage (Opus 5.5, 1ef1e4f8) | row 66 build (#1559), row 42 trigger (mos-0960), row 163 brief | row 66 r1 in review: candidate 8ec03b45a (sage/row66, base 5487ed39d), manifest e68c6bce5ec4, revs 2215-2217, request 29437, Dewey and Filbert told (sage-1286-1289); gate r1 failed cli 452/453 (thread-launch.test.mjs:179, <run>/session made for the log), fixed to <run>/unslop.jsonl, gate r2 GATE_PASSED; row 42 run log: trigger (a) met, mos-0960 first pm message is a reachability check; row 163 brief owner sage, lands after Part B (6020bb43a), revs 2218-2219 (263ea8a9a)
2026-10-20T13:18:38Z | Filbert (Opus 5.5, 048f3c63) | row 66 (#1559) r1 review of 8ec03b45a (manifest e68c6bce5ec4) | changes, comment 29439, rev 2221: F1 note with broken+flagged parts overflows the 32768 bus limit (probe 32934), F2 mutant M2 sent-before-send survives; 402/402 suites, M1 and M3 killed
2026-10-20T13:52:08Z | Dewey (Opus 5.5, 84fb346c) | row 66 (#1559) r1 review of 8ec03b45a (manifest e68c6bce5ec4) | changes, comment 29440, rev 2222: P1 stack copies lack tools/unslop-hook (12 paths missing at candidate, 0 at base); concur F1 (note 971, sum 33004 > 32768) and F2 (sent() before send survives); N1 stale session/ comments; M1 M2 M4 M5 killed; singles 109/109, harness 140/140, plugins 111/111
2026-10-20T13:56:35Z | Filbert (Opus 5.5, 048f3c63) | row 66 (#1559) r2 review of 0f4a57956 (manifest cee63e06fdea) | approve, comment 29442, rev 2226: F1/F2/N1 fixed, P1 copy path checked; 427/427 suites, M1-M5 killed
2026-10-20T14:11:02Z | Dewey (Opus 5.5, 84fb346c) | row 66 (#1559) r2 review of 0f4a57956 (manifest cee63e06fdea) | approve, comment 29443, rev 2227: P1/F1/F2/N1 fixed (copy probe 0 of 16 missing; note 600, sum 32633); mutants N1-N4 killed, N5 survives (copy test skips claude-code bundle sources, non-blocking follow-up); singles 285/285, harness 142, plugins 111, cli 454, webui 269 + 1 unrelated skip
2026-10-20T14:49:39Z | Sage (Opus 5.5, 1ef1e4f8) | row 163 (#1660) D2 rehearse + bus --no-launch: build, gate, mutants, in-review r1 | in-review, Filbert reviewer: candidate 4be3f6aec on 580500699, manifest 430212299beb, gate passed, K1-K33 killed; request comment 29444, revs 2228-2229 (def677bc7); P19 337 comment 366; lands after Part B B7 (mos-0965)
2026-10-20T15:06:03Z | Filbert (Opus 5.5, 048f3c63) | row 163 r1 review (D2 rehearse, candidate 4be3f6aec, manifest 430212299beb) | changes, comment 29445 rev 2230: 247/247 singles pass; my mutants M1-M6 survive (F1 backup/live-config after-checks, F2 env ref, data-root-scratch, launches dir, F3 signal trap); N1 Registry.remove read-only
2026-10-20T15:26:33Z | Sage (Opus 5.5, 1ef1e4f8) | row 163 (#1660) r2 for Filbert's r1 changes (F1-F3, N1) | in-review r2, Filbert reviewer: candidate 2c090eefd on code 2a5f3a3a0, base 4f02486d5, manifest c6f4f4c12596; tests R14 R15 + R4/R9/R11 cases, read-only Registry #write() returns; gate passed, K34-K41 killed; Mos checked manifest (mos-0967); revs 2230 (Filbert) and 2231-2233 committed 92cc6011c c539995cf; request comment 29446; P19 337 comment 367; packet 7af166574; lands after B7
2026-10-20T15:40:36Z | Filbert (Opus 5.5, 048f3c63) | row 163 r2 review (D2 rehearse, candidate 2c090eefd, manifest c6f4f4c12596) | approve, comment 29447 rev 2234: 249/249 singles pass; my M1-M6 and N1 mutants all killed (R14, R9, R11, R4, R15, session test)
2026-10-20T15:42:00Z | Sage (Opus 5.5, 1ef1e4f8) | row 163 (#1660) r2 verdict intake | approved by Filbert (comment 29447, rev 2234 committed cd2b377bb); P19 337 comment 367 -> 368; candidate 2c090eefd manifest c6f4f4c12596 waits to land after B7, fresh gate if the base moves
2026-10-20T18:19:06Z | Filbert (Opus 5.5, 048f3c63) | Q175 SSO design review r1 (#412, DESIGN.md blob 91e5b1b5e737 at 7f57419b3), static read incl. oauth4webapi 3.8.8 | changes: B1 config in config.json not business file, B2 return-path backslash, B3 log redaction of library error causes, B4 login-record cap, B5 test 9 not 2 kills signature mutant, B6 phase 2 live forged-header probe; rulings Q-B start degraded, Q-C yes, Q-D 8h abs + idle ignores polls; step 5 reading confirmed; verdict sent to Sage, no Gitea/queue record
+71 -1
View File
@@ -368,7 +368,7 @@ reports checkout or copy identity and refactor drift, and writes nothing.
```
scripts/stack-release.sh adopt BUSINESS
scripts/stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--migrate] [--tester-port N]
[--proof-header NAME --proof-file PATH | --no-proof] [--timeout S] [--plan]
[--proof-header NAME --proof-file PATH | --no-proof] [--no-send] [--timeout S] [--plan]
scripts/stack-release.sh pm-on BUSINESS [--timeout S]
scripts/stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan]
scripts/stack-release.sh status BUSINESS
@@ -480,6 +480,14 @@ copies. It checks the file with `stat` and `realpath` and never reads the value.
or `off`. On the live copy it also notes that the run re-pins the pointer's copy
and only the Console flags change.
`activate ... --no-send` (row 189, mos-0972) drops `--send` from the pointer's
Console flags and the unit, so the Console serves reads and no human sends. It
may name the live copy while the pointer holds `--send`; once `--send` is gone a
same-copy `--no-send` refuses `already-active`. Later activates, `pm-on` and
rollbacks carry the pointer, so send stays off. No flag puts `--send` back.
`--plan` prints one `send:` line: `dropped`, `off` with or without the
`--no-send` note, or `carried`.
#### A target past c216f47db: the PM after the harness kind
From c216f47db on, the bus launches the PM only with a `harness` kind in the
@@ -528,6 +536,68 @@ on PATH; the checkout's `scripts/mosaic` exits 3 with `human-required` or
`tester-required`, and queue writes stay on it ([packages/cli/README.md, One
pinned copy](../packages/cli/README.md#one-pinned-copy-row-85-1579)).
#### Rehearsal on a scratch root (D2, row 163)
```
scripts/stack-release.sh rehearse ID BUSINESS --backup ACTIVATION|PATH --port N [--timeout S]
```
`rehearse` starts the target copy's bus host and Console on a copy of a
backup and runs `activate`'s gate checks on them, all but the PM's, while the
live stack keeps running. It reads the live config and a backup of the live
store, so Jason or Mos runs it, as a window's pre-step. No development seat
runs it against live data. It stops and starts no unit and writes no pointer
or log line.
`--backup` takes an activation id under `<dataRoot>/backups/stack/` or an
absolute path. `--port` is the scratch Console's loopback port, 1024 to
65535. The run refuses `port-reserved` for 7332, 7333 and the pointer's
`--port` and `--tester-port`, and `port-in-use` for a port something holds;
it never picks another port. `--timeout` (default 60 seconds) bounds the
gate and each stop.
Before it makes anything, it checks the pointer, the copy (present,
reachable from refactor, a readable schema, a bus host with `--no-launch`),
the port and the backup's `SHA256SUMS`. Then it makes
`<dataRoot>/rehearsals/<UTC second>-<copy's first 12>/`, mode 0700:
- `data/bus/` holds a copy of the backup's store files, and
`data/notify/BUSINESS/notify.json` is `{"notifyVersion":1,"binding":null}`.
- `config/config.json` is the live config with `dataRoot` set to `data/`.
- `config/businesses/BUSINESS.json` is the live business file with every
`tracker.*` variable removed and every credential reference, the roles'
and `tracker.sync`'s, pointed at its own 0600 file under `placeholders/`
holding `rehearsal-placeholder-token`. `business.diff` shows the changes.
Roles come from the target copy's `roles/`, as the live bus reads them.
- `steps.log` holds the same PASS and FAIL lines the run prints, with times;
`host.log`, `console.log` and, after a migration, `migrate.log` sit beside it.
A guard then loads the scratch config with the target's code and refuses
with a named check: `data-root-live` when the scratch data root resolves to
the live one, `sessions-live` when a `sessions.json` there names a running
pid (the run never deletes the file), `data-root-scratch`, `notify-off`,
`tracker-configured` when `bootConfig` still sets a tracker, and
`credential-ref-live` for an env reference or a file outside `placeholders/`.
When the backup's schema differs from the target's, the run checks that the
target refuses the store, migrates it, opens it, migrates it again as a
no-op, and that the pointer's copy refuses the migrated store.
It starts `scripts/mosaic bus start BUSINESS --no-launch` and the Console's
`serve --port N --business BUSINESS` from the target copy, without `--send`.
The gate needs the host's `launches disabled` line, a live `bus status` host
with this run's pid and a boot from this start, the Console's address line,
`/healthz` 200, and both command lines on the target copy. Then it checks
that the host wrote no `sessions.json` and no `launches`, stops the Console
and the host (SIGTERM, then SIGKILL and a FAIL after the timeout), checks that
the host released `writer.lock`, and compares the backup and the live config
and business files with sums it took before. The last line is
`REHEARSAL_PASS` (exit 0) or `REHEARSAL_FAIL` (exit 1), then the directory.
Usage exits 4. Nothing prunes rehearsal directories.
`bus start --no-launch` refuses every launch with `launches-disabled`,
starts no thread ticker, and leaves an earlier host's `sessions.json` and
its runs alone. No unit passes it, and `--pm` with it is a usage error.
## Console service (`scripts/webui-service.sh`)
```bash
+73
View File
@@ -0,0 +1,73 @@
# Brief: activate --no-send drops the Console's --send
Sage, 2026-10-20, for Mos's ruling mos-0972 (Q175 interim, Vikunja #412). Owner Sage, reviewer Dewey, gate owner
Sage. Base: refactor 9ae8c73ac (row 163 landed). Line numbers are at that commit. The candidate corrects them;
the first draft took them before row 163.
This row builds the tool only. The live restart without `--send` is a separate step afterwards, a same-copy
activate under Q173 with a backup first and a STATE report after. No test here touches `~/.mosaic-dev`, the
live units or a slot file. Every test uses the stand-in `systemctl`, units, stores and data root that
`packages/cli/tests/stack-release.test.mjs` already builds.
## Why
Mos ruled that the Console runs without `--send` until Q175 phase 2, because with `--send` live any same-uid
process can forge `X-authentik-username` on 127.0.0.1:7332 and send as Jason (design section 8). On the pinned
stack, the Console flags live in the pointer (`~/.mosaic-dev/state/stack-active.json`):
- `console_for_target` (scripts/stack-release.sh:664-693) carries the pointer's flags unchanged. Only
`--tester-port` and the row 187 proof pair can change.
- `check_target` (:249-256) lets an activate name the pointer's copy only for a proof-pair change.
- `adopt` refuses once a pointer exists (:828).
- A hand `webui-service.sh install` without `--send` makes `flags_drift` (:646-657) refuse
`unit-flags-drift` on every later activate, pm-on and rollback.
So the tool can't drop `--send` today. This row adds that one path.
## The change
1. `activate ID BUSINESS --no-send` drops `--send` from `RUN_CONSOLE`, so `install_stack` leaves it out of the
Console unit and `write_pointer` leaves it out of the pointer. Only `activate` takes it. It combines with
`--tester-port`, the proof pair and `--no-proof`.
2. An activate with `--no-send` may name the pointer's copy when the pointer's Console flags hold `--send`. It
re-pins the live copy with only the Console flags changed, as row 187 does for the pair, including the
live-copy plan note and the backup of the stopped store. Without `--send` in the pointer, a same-copy
`--no-send` still refuses `already-active`.
3. The steps log records `drop --send; --no-send was given`, or `--no-send was given and the pointer holds no
--send, so there is nothing to drop`.
4. `--plan` prints one `send:` line for an activate or rollback: `send: dropped; --no-send was given`,
`send: off; --no-send was given and the pointer holds no --send`, `send: carried` when the pointer's
`--send` goes on unchanged, or `send: off`.
5. `rollback` and `pm-on` don't change: they carry the pointer's flags. So once `--no-send` has run, a rollback
to an earlier copy keeps send off. There is no flag to turn send back on. Q175 phase 2 changes the Console
flags in its own row.
6. `docs/TOOLS.md` "Pinned stack update" gets `--no-send` in the usage block and one sentence.
## Tests (packages/cli/tests/stack-release.test.mjs)
1. Seeded preview pointer with `--send` and `--tester-port 7333`: a same-copy `activate B acme --no-send`
passes. The pointer and the unit ExecStart lack `--send` and keep the preview flags and
`--tester-port 7333` in order. Status says `unit flags: match the pointer`. The steps log has the drop
line. The log line has from = to = B, and the stopped store was backed up.
2. A plain same-copy activate still refuses `already-active`. So does a same-copy `--no-send` once `--send` is
gone.
3. `--plan` prints the four `send:` lines in their cases, with the live-copy note on the same-copy run.
4. After `--no-send`, a rollback to the earlier copy and `pm-on` both leave `--send` out of the pointer and
the unit.
5. `--no-send` on `rollback`, `pm-on`, `status` or `adopt` is a usage error (exit 4).
6. `--no-send` with the proof pair, and with `--no-proof`, on a pointer holding both: each drops what it
names and keeps the rest.
## Mutants (each must fail at least one test)
- M1: the same-copy exception ignores `--no-send`.
- M2: the same-copy exception allows `--no-send` without `--send` in the pointer.
- M3: `--no-send` drops `--send` from the pointer but not from the unit (it writes the pointer from
`P_CONSOLE`).
- M4: rollback re-adds `--send`.
- M5: the arg parser accepts `--no-send` for rollback.
## Gate
Dewey approves the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's gate, and the
mutants are killed.
+3 -3
View File
@@ -33,7 +33,7 @@ Gaps found while working go to `docs/plans/DEFERRED.md`, not here.
<!-- mosaic-queue:begin -->
Generated from `docs/plans/queue.json` revision 2219 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
Generated from `docs/plans/queue.json` revision 2236 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |
|---|---|---|---|---|---|---|---|---|
@@ -102,7 +102,7 @@ Generated from `docs/plans/queue.json` revision 2219 by `queue render`. Do not e
| 63 | Multi-project verbs: task verbs across the tree | darkwing; reviewers dewey, filbert | #1556 | done | 62 done | dewey and filbert approve on the issue naming the candidate manifest; tasks, harness and every scripts/test-*.sh green on Sage's gate rerun (sage) | `docs/plans/2026-10-11_multi-project-sync.md` § Multi-project verbs: task verbs across the tree (row B) | lands before row 90 (decision 125): both share packages/tasks/README.md and tests/verbs.test.mjs, so row 90 rebases after this lands |
| 64 | Managed sessions get their role's skills on both harnesses | sage; reviewers darkwing, filbert | #1557 | done | 65 done | darkwing and filbert approve on #1557 naming the candidate manifest; business, harness, cli and every test-*.sh green on Sage's gate rerun; Sage's landing session on each harness shows the role's skills (sage) | `docs/plans/2026-10-11_managed-session-config.md` § Managed sessions get their role's skills on both harnesses | Landed (mos-0863/0864): 9f0039711 = c6d7d538f (range-diff =, patch-id 7e1948e2f2e9), F1 doc 6b6567b8b, packet ebfc23c43; gate-land GATE_PASSED 2384/0 on 6b6567b8b. D2 on #1557 comment 29228: claude-code coder rc13bcd555652 replied with its 4 skills, pi coder r3455505dd5da launch only (no pi credential on host, to Mos); both manifests list the 4 coder skills, sha256 = landed files. D3 window step 46de60893, D4 #1672 row 174. |
| 65 | Model routing table: sessions resolve harness, model and thinking from one table | sage; reviewers darkwing, dewey | #1558 | done | 49 done, 131 done | Mos clears the routing table's public/ diff (decision 123, mos-0840) before in-review; darkwing and dewey approve on #1558; plugins, bus, business, harness, seat, cli, webui, test-*.sh green on Sage's rerun; landing session shows module, route, counted model, thinking; report names the mos-0840 view (sage) | `docs/plans/2026-10-11_managed-session-config.md` § Model routing table: sessions resolve harness, model and thinking from one table | Decision 94 hold ended (cutover live 17:21Z); restarted under decision 111 and Mos sage-0091. |
| 66 | Managed sessions run the unslop check on both harnesses | sage; reviewers dewey, filbert | #1559 | in-review, round 1, request posted | 64 done | dewey and filbert approve on #1559 naming the candidate manifest; unslop, harness, cli and every test-*.sh green on Sage's gate rerun; Sage's landing session on each harness shows the notice, the correction and the counts (sage) | `docs/plans/2026-10-11_managed-session-config.md` § Managed sessions run the unslop check on both harnesses | Decision 94 hold ended (cutover live 17:21Z); restarted under decision 111 and Mos sage-0091. |
| 66 | Managed sessions run the unslop check on both harnesses | sage; reviewers dewey, filbert | #1559 | in-review, round 2, request posted | 64 done | dewey and filbert approve on #1559 naming the candidate manifest; unslop, harness, cli and every test-*.sh green on Sage's gate rerun; Sage's landing session on each harness shows the notice, the correction and the counts (sage) | `docs/plans/2026-10-11_managed-session-config.md` § Managed sessions run the unslop check on both harnesses | Decision 94 hold ended (cutover live 17:21Z); restarted under decision 111 and Mos sage-0091. |
| 67 | Decisions read follow-up: seen-then-resolve, list-before-503 and full-row tests | filbert; reviewers darkwing, dewey | #1560 | done | — | darkwing and dewey approve on #1560 naming the candidate manifest; bus, webui and every scripts/test-*.sh green on Sage's gate rerun (sage) | `docs/plans/2026-10-11_decisions-read-follow-up.md` § Decisions read follow-up: seen-then-resolve, list-before-503 and full-row tests | — |
| 68 | Bus host runs an explicit Claude Code binary | sage; reviewers dewey, filbert | #1561 | done | — | Filbert and Dewey approve; cli, seat, harness suites and every scripts/test-*.sh green on Sage's gate rerun; unit reinstalled and opus probe through MOSAIC_CLAUDE_BIN (sage) | `docs/plans/2026-10-11_bus-claude-binary.md` § Bus host runs an explicit Claude Code binary | — |
| 69 | Terminal app follow-up: narrow-terminal guard test, hidden-box way out and width tests | darkwing; reviewers dewey, filbert | #1562 | done | 55 done | dewey and filbert approve on the issue naming the candidate manifest; cli and queue node suites plus every scripts/test-*.sh green on Sage's gate rerun (sage) | `docs/plans/2026-10-11_terminal-app-follow-up.md` § Terminal app follow-up: narrow-terminal guard test, hidden-box way out and width tests | Sage accepts the two shortened texts in round 1 (hidden box way out, status line), both forced by the 40x10 frame. Follow-up at landing: when help is cut to fit, draw() points at mosaic inbox --json though help has no whole text; open it as its own row (build.md Found, not fixed) |
@@ -199,7 +199,7 @@ Generated from `docs/plans/queue.json` revision 2219 by `queue render`. Do not e
| 160 | I5 managed sessions as containers | researcher; reviewers dewey, filbert | #1655 | queued | 158 done | Filbert and Dewey approve on #1655 naming the candidate manifest; Sage's full gate green on the landing tree (sage) | `docs/plans/2026-10-18_containerized-install-rows.md` § I5 managed sessions as containers | Decision 96 orders I2-I6 after D1 and D2, which have no rows yet; the goal order sets priority |
| 161 | I6 install mode, the pi worker's move and the documented limits | dewey; reviewers darkwing, filbert | #1656 | queued | 159 done, 160 done | Filbert and Darkwing approve on #1656 naming the candidate manifest; Sage's full gate green on the landing tree (sage) | `docs/plans/2026-10-18_containerized-install-rows.md` § I6 install mode, the pi worker's move and the documented limits | Decision 96 orders I2-I6 after D1 and D2, which have no rows yet. B2 is decision 148. The documented-limits section waits on Jason's answer to P19 333 (mos-0657) |
| 162 | D1: health-gated stack activate and rollback (stack-release.sh) | darkwing; reviewers filbert | #1659 | done | — | Filbert approves the candidate manifest; cli, bus and webui suites and every scripts/test-*.sh green on Sage's gate (sage) | `docs/plans/2026-10-18_stack-release.md` § Stack activate and rollback, health-gated (D1) | P19 task 336 (agents/sage/work/vikunja-setup/d1-d2-rows-2026-10-18.json) |
| 163 | D2: stack rehearsal on a scratch root (stack-release.sh rehearse) | sage; reviewers filbert | #1660 | briefed | 162 done | Filbert approves the candidate manifest; cli, bus and webui suites and every scripts/test-*.sh green on Sage's gate (sage) | `docs/plans/2026-10-18_stack-release.md` § Stack rehearsal on a scratch root (D2) | P19 task 337 (agents/sage/work/vikunja-setup/d1-d2-rows-2026-10-18.json) |
| 163 | D2: stack rehearsal on a scratch root (stack-release.sh rehearse) | sage; reviewers filbert | #1660 | done | 162 done | Filbert approves the candidate manifest; cli, bus and webui suites and every scripts/test-*.sh green on Sage's gate (sage) | `docs/plans/2026-10-18_stack-release.md` § Stack rehearsal on a scratch root (D2) | Landed 8ecc7a239 (author Sage) on 7f57419b3; patch-id b8d5f8a37c24 matches candidate c6f4f4c12596 and eaa0f2666, the commit the gate ran on (base 4ca8d6e94; 7f57419b3 adds only agents/sage/work/q175-sso/DESIGN.md, which no test reads). Sage gate passed 18:16Z on eaa0f2666: cli 472/472 and every package and scripts/test-*.sh green. Filbert approve 29447, rev 2234. Not live until a re-pin; Q173 backup and STATE apply there (mos-0971). |
| 164 | Channel plug-in kind: cli and webui as its first modules (Q156 item 4, decision 149) | rocko; reviewers dewey | #1661 | done | 129 done | Dewey approves the candidate manifest; plugins, cli, bus and webui suites and every scripts/test-*.sh green on Sage's gate (sage) | `docs/plans/2026-10-18_channel-kind.md` § The channel kind, with cli and webui as its first modules | Design packet first, Sage sends it to Mos (human proof adjacent). Nothing goes between row 129 and this row (Mos mos-0726). |
| 165 | The human adds and changes role instances: a CLI command and a Console view (P19 332) | darkwing; reviewers dewey, filbert | #1662 | done | 155 done | Mos relays Jason's confirmation before briefed; Dewey and Filbert approve on #1662 naming the candidate manifest; Mos's decision 123 go on the public/ diff; business, cli and webui suites and every scripts/test-*.sh green on Sage's gate (sage) (sage) | `docs/plans/2026-10-18_role-instance-verb.md` § The human adds and changes role instances | Briefed after 155 (b2f57ca70); hold lifted by mos-0805. Chief of staff stays out (mos-0787). sage-0918: lead approved file expansion (bus broker/runtime, webui cli.mjs, scripts/mosaic, commented channel-proof pin bump); new verbs human-only. Credentials/tracker stay out; second-reviewer gap with Mos (sage-0920). Acceptance 314 keeps the hand edit until this lands. Mos's decision 123 go on public/. |
| 166 | stack-release.sh hardening after D1: drop-in precheck, is-active race, copy check against the target, quoted Environment= (row 162 follow-up) | darkwing; reviewers filbert | #1663 | done | 162 done | Filbert approves the candidate manifest; cli suite and every scripts/test-*.sh green on Sage's gate (sage) | `docs/plans/2026-10-18_stack-release-hardening.md` § Brief: stack-release.sh hardening after D1 | Landed 4b738e2da on 64922106d (pushed). It is Darkwing r2 build.patch, manifest 4fc0613f OK, patch-id 7a4fdc3bf53b equal to build.patch. Sage gate GATE_PASSED: 15 pkg suites 2399/0 (cli 364), 10 script suites green. Mos R1 (mos-0867): no quotes or drop-ins on live units. stack-release.sh blob now ed5b41d4677e; Window B P3 reruns on it (mos-0867 R2). Row 169 rebases onto 4b738e2da. |
+566 -8
View File
@@ -1,7 +1,7 @@
{
"version": 1,
"canonicalRoot": "/mnt/storage/src/mosaic-stack",
"revision": 2219,
"revision": 2236,
"rows": [
{
"id": 1,
@@ -5515,7 +5515,72 @@
}
],
"duplicateRisk": false,
"receipts": []
"receipts": [
{
"reviewer": "filbert",
"op": "filbert-66-r1",
"at": "2026-10-20T13:18:21.785Z",
"verdict": "changes",
"comment": 29439,
"candidate": "e68c6bce5ec4b61e7d47262857dd3ee42893979aa864d68208636fb45ed3f909"
},
{
"reviewer": "dewey",
"op": "dewey-66-r1",
"at": "2026-10-20T13:23:54.665Z",
"verdict": "changes",
"comment": 29440,
"candidate": "e68c6bce5ec4b61e7d47262857dd3ee42893979aa864d68208636fb45ed3f909"
}
]
},
{
"n": 2,
"op": "sage-r66-inreview-2",
"by": "sage",
"at": "2026-10-20T13:51:18.316Z",
"issue": 1559,
"candidate": {
"kind": "manifest",
"digest": "cee63e06fdea9896ac6ad841ba8a548fe2e0cff60dabe25e81ba928c0f51ff2c",
"text": "b99e7f4bf58ac68c8f5fa58eb7b90c0f85b15ca9a80fd093f6aaeafeac931608 adapters/README.md\nc32d2a3276db92433d4e3c4f4b45fab048d9a46119d9f69cead6a11cfacfeadb agents/sage/work/queue-66/BUILD.md\n8777fac3acb89f3af4ac0db2e7c44e309408a8ab8d1b0a0942c81c54ad295fa9 docs/TOOLS.md\nea11398dc5751a0f9415079e7c03f413e97f5443f6bd0fc1fc2319ec646c20cb packages/cli/tests/stack-copy.test.mjs\nb027dc74583217b4151173ecbabfcfbfc7b108ac6b7a8e93dd21a2834051714f packages/harness/README.md\nbfb2156c609eeaf87c9e209c562576a5ef47535eaeee61d841bb0bd75a76a69d packages/harness/src/runner.mjs\n0a27f2c8de7b9191f9c0d4a209331c63ff7630923e669838b41715924b0a64b0 packages/harness/tests/bundle.test.mjs\n81d0352f2dcd41eb2e2ef962365d9b97515d7e8d9668a35bb9ce9dce4f1451ea packages/harness/tests/claude-session.test.mjs\nb16f33442cefd5b5ae2af85edab33250ee6b31025e437edf0e4eba9fe0afa793 packages/harness/tests/fixtures/fake-adapter.mjs\n1e187a2e23a197640d139e6975dfaf83dd7e337c360d128833678dc60f3e2046 packages/harness/tests/pi-session.test.mjs\n37ca6ad6467573658c1ac6807b6cd00e3f9da289107fb689190a1872bff9b89c packages/harness/tests/runner.test.mjs\n04c24058f37260c9192d5036c8cf28b50faba8e3e3bf57f198d233485d0d57e6 packages/plugins/harness/claude-code/index.mjs\n2c1252e8e8db3ba4551bf1e34156ddf4e933cc664fc157105bef9a2705d6ce3b packages/plugins/harness/pi/index.mjs\n7e0bea27d4360a39874fa64b3506a85f83888613a285ec0b087d74bf22b76421 packages/plugins/README.md\nec4ec9502ee8e766e3abd04d9f724c0d8ff4ef114394a0f76d5320c2dc715c5c packages/plugins/tests/harness.test.mjs\nf6141dac9a8c61573e4198480fed85e093992ee9c19fb55e9bc2752381b7b07a packages/seat/tests/sandbox.test.mjs\ne60bf54c23e04436a982174348857458039a22e1769fc9ad4db8d4f9b57f2e86 packages/webui/tests/pinned-copy.test.mjs\n3ba3dbd9ff8dd68f0aa85bac92a8960af10166130bbc16df74fd7340776bb3b1 scripts/stack-copy.sh\n7ee6ed6e6ac55b1348d40a358372e66c86fdebacd90001d447040133e1187882 tools/unslop-hook/claude-hook.js\nce1dfb11d0a8e8efd3d6dae67cc259c2e7443a62c7416bdedd4c71306d8c6df1 tools/unslop-hook/extension.ts\nf07d67806f40763b4be87d31c2976448b5cfc34d7328519fae2d8313b1ca3e20 tools/unslop-hook/README.md\ndbe3d5c9ae654d2401e3c496a6b03757a438e4a551fd8e460df8c82902bb80a7 tools/unslop-hook/test-unslop-check.js\n76be78178a0703b9d20a7df2278ecb13f429dea90cea45180f22302e267ea25e tools/unslop-hook/unslop-check.js\n"
},
"request": "comment",
"attempts": [
{
"op": "sage-r66-inreview-2",
"by": "sage",
"at": "2026-10-20T13:51:18.316Z",
"state": "posted",
"comment": 29441,
"transport": {
"outcome": "posted",
"status": 201,
"comment": 29441,
"detail": "created"
},
"resolutions": []
}
],
"duplicateRisk": false,
"receipts": [
{
"reviewer": "filbert",
"op": "filbert-66-r2",
"at": "2026-10-20T13:56:19.376Z",
"verdict": "approve",
"comment": 29442,
"candidate": "cee63e06fdea9896ac6ad841ba8a548fe2e0cff60dabe25e81ba928c0f51ff2c"
},
{
"reviewer": "dewey",
"op": "dewey-66-r2",
"at": "2026-10-20T14:10:43.258Z",
"verdict": "approve",
"comment": 29443,
"candidate": "cee63e06fdea9896ac6ad841ba8a548fe2e0cff60dabe25e81ba928c0f51ff2c"
}
]
}
]
},
@@ -5526,8 +5591,8 @@
"note": "Decision 94 hold ended (cutover live 17:21Z); restarted under decision 111 and Mos sage-0091.",
"blockedReason": null,
"createdAt": "2026-10-11T19:53:27.579Z",
"updatedAt": "2026-10-20T13:10:43.972Z",
"updatedBy": "sage"
"updatedAt": "2026-10-20T14:10:43.258Z",
"updatedBy": "dewey"
},
{
"id": 67,
@@ -17172,7 +17237,7 @@
"closes": [
1660
],
"state": "briefed",
"state": "done",
"previousState": null,
"required": false,
"requiredSince": null,
@@ -17192,12 +17257,95 @@
"reviewers": [
"filbert"
],
"review": null,
"review": {
"rounds": [
{
"n": 1,
"op": "sage-163-inreview-1",
"by": "sage",
"at": "2026-10-20T14:46:22.020Z",
"issue": 1660,
"candidate": {
"kind": "manifest",
"digest": "430212299bebcac52013332ce9fd0ba43033e105bc61f53415ad1000f3f52c62",
"text": "04d72e5ef4d8d5fc85a4f11aa51331354696cc7cf66af2ed7540cf6cfc0153b9 agents/sage/work/queue-163/BUILD.md\n5bd1bc697cd56e57b00e60c0e80be72cd1043a55a6d2fc0b9e2ccdd0c5cf9802 docs/TOOLS.md\n52597d7fa1469510fcf8e6dfd63664ad4c492bc17039885aa839231798693058 packages/cli/src/cli.mjs\n5492ebb5aa3c9c85874e4c7de7b65fc37733e2db636690c11fe2d2fdf72c2dd0 packages/cli/src/launcher.mjs\ne9ce0e0f7368d3fb2e1edbd4ebc724513ec19d7bac0c993f8be0d9494427be07 packages/cli/tests/host.test.mjs\n42519898b5bcf84c21088647e161a86934d4fe351207f736b9b120b6e1c2b91b packages/cli/tests/launcher.test.mjs\n4dfeefa1bb3c11e8633d75df4f628497ebd427ebf8402ba5efc3e5c7fd030057 packages/cli/tests/stack-release.test.mjs\n5abdeab8bc6b63d291d0054124a4311a2260b590c68f758daa142a9deb2f4a72 packages/seat/src/session.mjs\n741883e399d77d429cc29a87beef6db9c51c1759669b025006f037001d101eb6 packages/seat/tests/session.test.mjs\n5bb3ac40102ce515f1acdcbc5670feb161ce40a193e94e2f8b104ebf98eddc38 scripts/stack-release.sh\n"
},
"request": "comment",
"attempts": [
{
"op": "sage-163-inreview-1",
"by": "sage",
"at": "2026-10-20T14:46:22.020Z",
"state": "posted",
"comment": 29444,
"transport": {
"outcome": "posted",
"status": 201,
"comment": 29444,
"detail": "created"
},
"resolutions": []
}
],
"duplicateRisk": false,
"receipts": [
{
"reviewer": "filbert",
"op": "filbert-163-r1",
"at": "2026-10-20T15:05:43.690Z",
"verdict": "changes",
"comment": 29445,
"candidate": "430212299bebcac52013332ce9fd0ba43033e105bc61f53415ad1000f3f52c62"
}
]
},
{
"n": 2,
"op": "sage-163-inreview-2",
"by": "sage",
"at": "2026-10-20T15:24:17.566Z",
"issue": 1660,
"candidate": {
"kind": "manifest",
"digest": "c6f4f4c125967525f771d0c39445c131916443cbd2754ff58fe8353876e303ce",
"text": "f04d12a963b3199420f1d9c41fbdae67faf46f710a805cb9358f1fb52f5faed9 agents/sage/work/queue-163/BUILD.md\n5bd1bc697cd56e57b00e60c0e80be72cd1043a55a6d2fc0b9e2ccdd0c5cf9802 docs/TOOLS.md\n52597d7fa1469510fcf8e6dfd63664ad4c492bc17039885aa839231798693058 packages/cli/src/cli.mjs\n5492ebb5aa3c9c85874e4c7de7b65fc37733e2db636690c11fe2d2fdf72c2dd0 packages/cli/src/launcher.mjs\ne9ce0e0f7368d3fb2e1edbd4ebc724513ec19d7bac0c993f8be0d9494427be07 packages/cli/tests/host.test.mjs\n42519898b5bcf84c21088647e161a86934d4fe351207f736b9b120b6e1c2b91b packages/cli/tests/launcher.test.mjs\nb01ff656962c6fdb5bc3554cfbb13645b7f25adae5f6cd9608af8de6d6091c6c packages/cli/tests/stack-release.test.mjs\n448138d12404346989a6751a01c747718cef7d2bec19a0639a29fa6a8b74a82d packages/seat/src/session.mjs\ncd6277c4e7155a7c93d09f5bc7cca1130708a35a6b75aa02d99af8360d6338b0 packages/seat/tests/session.test.mjs\n5bb3ac40102ce515f1acdcbc5670feb161ce40a193e94e2f8b104ebf98eddc38 scripts/stack-release.sh\n"
},
"request": "comment",
"attempts": [
{
"op": "sage-163-inreview-2",
"by": "sage",
"at": "2026-10-20T15:24:17.566Z",
"state": "posted",
"comment": 29446,
"transport": {
"outcome": "posted",
"status": 201,
"comment": 29446,
"detail": "created"
},
"resolutions": []
}
],
"duplicateRisk": false,
"receipts": [
{
"reviewer": "filbert",
"op": "filbert-163-r2",
"at": "2026-10-20T15:40:19.458Z",
"verdict": "approve",
"comment": 29447,
"candidate": "c6f4f4c125967525f771d0c39445c131916443cbd2754ff58fe8353876e303ce"
}
]
}
]
},
"claim": null,
"note": "P19 task 337 (agents/sage/work/vikunja-setup/d1-d2-rows-2026-10-18.json)",
"note": "Landed 8ecc7a239 (author Sage) on 7f57419b3; patch-id b8d5f8a37c24 matches candidate c6f4f4c12596 and eaa0f2666, the commit the gate ran on (base 4ca8d6e94; 7f57419b3 adds only agents/sage/work/q175-sso/DESIGN.md, which no test reads). Sage gate passed 18:16Z on eaa0f2666: cli 472/472 and every package and scripts/test-*.sh green. Filbert approve 29447, rev 2234. Not live until a re-pin; Q173 backup and STATE apply there (mos-0971).",
"blockedReason": null,
"createdAt": "2026-10-18T15:14:08.410Z",
"updatedAt": "2026-10-20T13:13:04.706Z",
"updatedAt": "2026-10-20T18:17:45.831Z",
"updatedBy": "sage"
},
{
@@ -78198,6 +78346,416 @@
"receipt": "ok sage-163-briefed rev 2219 row 163 queued→briefed"
},
"viewSha": "cfbfed778d1ce071f30fcf14c353861b15957ed1c287c549c7854d1c43e475c2"
},
{
"rev": 2220,
"op": "sage-163-start",
"verb": "move",
"args": {
"id": 163,
"to": "in-progress",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "sage",
"at": "2026-10-20T13:14:51.925Z",
"semantics": 2,
"result": {
"row": 163,
"from": "briefed",
"to": "in-progress",
"round": null,
"issue": null,
"candidate": null,
"receipt": "ok sage-163-start rev 2220 row 163 briefed→in-progress"
},
"viewSha": "2de5c25999f9d49888b1283c1b2da72033894a479ac0a4cb79afbf0c29eec4e8"
},
{
"rev": 2221,
"op": "filbert-66-r1",
"verb": "review-record",
"args": {
"id": 66,
"verdict": "changes",
"comment": 29439,
"candidate": "e68c6bce5ec4b61e7d47262857dd3ee42893979aa864d68208636fb45ed3f909"
},
"by": "filbert",
"at": "2026-10-20T13:18:21.785Z",
"semantics": 2,
"result": {
"row": 66,
"round": 1,
"verdict": "changes",
"receipt": "ok filbert-66-r1 rev 2221 row 66 round 1 changes by filbert (comment 29439)"
},
"viewSha": "c77e08d366600e792460fd04a0b4cf7189f0d244cbad369a819f5af02f3f6697"
},
{
"rev": 2222,
"op": "dewey-66-r1",
"verb": "review-record",
"args": {
"id": 66,
"verdict": "changes",
"comment": 29440,
"candidate": "e68c6bce5ec4b61e7d47262857dd3ee42893979aa864d68208636fb45ed3f909"
},
"by": "dewey",
"at": "2026-10-20T13:23:54.665Z",
"semantics": 2,
"result": {
"row": 66,
"round": 1,
"verdict": "changes",
"receipt": "ok dewey-66-r1 rev 2222 row 66 round 1 changes by dewey (comment 29440)"
},
"viewSha": "cea1d0b0d416b20541d896b98061bc6a5f5691725753ec1cef215fc422755154"
},
{
"rev": 2223,
"op": "sage-r66-r2-start",
"verb": "move",
"args": {
"id": 66,
"to": "in-progress",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "sage",
"at": "2026-10-20T13:51:01.706Z",
"semantics": 2,
"result": {
"row": 66,
"from": "in-review",
"to": "in-progress",
"round": null,
"issue": null,
"candidate": null,
"receipt": "ok sage-r66-r2-start rev 2223 row 66 in-review→in-progress"
},
"viewSha": "78585e61c6bf1355b3d593b087ccc27dcbabf5e9c8c373c5906d60783a1602dd"
},
{
"rev": 2224,
"op": "sage-r66-inreview-2",
"verb": "move",
"args": {
"id": 66,
"to": "in-review",
"reason": null,
"candidate": "agents/sage/work/queue-66/candidate-manifest.sha256",
"evidence": null,
"issue": null
},
"by": "sage",
"at": "2026-10-20T13:51:18.316Z",
"semantics": 2,
"result": {
"row": 66,
"from": "in-progress",
"to": "in-review",
"round": 2,
"issue": 1559,
"candidate": {
"kind": "manifest",
"digest": "cee63e06fdea9896ac6ad841ba8a548fe2e0cff60dabe25e81ba928c0f51ff2c",
"text": "b99e7f4bf58ac68c8f5fa58eb7b90c0f85b15ca9a80fd093f6aaeafeac931608 adapters/README.md\nc32d2a3276db92433d4e3c4f4b45fab048d9a46119d9f69cead6a11cfacfeadb agents/sage/work/queue-66/BUILD.md\n8777fac3acb89f3af4ac0db2e7c44e309408a8ab8d1b0a0942c81c54ad295fa9 docs/TOOLS.md\nea11398dc5751a0f9415079e7c03f413e97f5443f6bd0fc1fc2319ec646c20cb packages/cli/tests/stack-copy.test.mjs\nb027dc74583217b4151173ecbabfcfbfc7b108ac6b7a8e93dd21a2834051714f packages/harness/README.md\nbfb2156c609eeaf87c9e209c562576a5ef47535eaeee61d841bb0bd75a76a69d packages/harness/src/runner.mjs\n0a27f2c8de7b9191f9c0d4a209331c63ff7630923e669838b41715924b0a64b0 packages/harness/tests/bundle.test.mjs\n81d0352f2dcd41eb2e2ef962365d9b97515d7e8d9668a35bb9ce9dce4f1451ea packages/harness/tests/claude-session.test.mjs\nb16f33442cefd5b5ae2af85edab33250ee6b31025e437edf0e4eba9fe0afa793 packages/harness/tests/fixtures/fake-adapter.mjs\n1e187a2e23a197640d139e6975dfaf83dd7e337c360d128833678dc60f3e2046 packages/harness/tests/pi-session.test.mjs\n37ca6ad6467573658c1ac6807b6cd00e3f9da289107fb689190a1872bff9b89c packages/harness/tests/runner.test.mjs\n04c24058f37260c9192d5036c8cf28b50faba8e3e3bf57f198d233485d0d57e6 packages/plugins/harness/claude-code/index.mjs\n2c1252e8e8db3ba4551bf1e34156ddf4e933cc664fc157105bef9a2705d6ce3b packages/plugins/harness/pi/index.mjs\n7e0bea27d4360a39874fa64b3506a85f83888613a285ec0b087d74bf22b76421 packages/plugins/README.md\nec4ec9502ee8e766e3abd04d9f724c0d8ff4ef114394a0f76d5320c2dc715c5c packages/plugins/tests/harness.test.mjs\nf6141dac9a8c61573e4198480fed85e093992ee9c19fb55e9bc2752381b7b07a packages/seat/tests/sandbox.test.mjs\ne60bf54c23e04436a982174348857458039a22e1769fc9ad4db8d4f9b57f2e86 packages/webui/tests/pinned-copy.test.mjs\n3ba3dbd9ff8dd68f0aa85bac92a8960af10166130bbc16df74fd7340776bb3b1 scripts/stack-copy.sh\n7ee6ed6e6ac55b1348d40a358372e66c86fdebacd90001d447040133e1187882 tools/unslop-hook/claude-hook.js\nce1dfb11d0a8e8efd3d6dae67cc259c2e7443a62c7416bdedd4c71306d8c6df1 tools/unslop-hook/extension.ts\nf07d67806f40763b4be87d31c2976448b5cfc34d7328519fae2d8313b1ca3e20 tools/unslop-hook/README.md\ndbe3d5c9ae654d2401e3c496a6b03757a438e4a551fd8e460df8c82902bb80a7 tools/unslop-hook/test-unslop-check.js\n76be78178a0703b9d20a7df2278ecb13f429dea90cea45180f22302e267ea25e tools/unslop-hook/unslop-check.js\n"
},
"receipt": "ok sage-r66-inreview-2 rev 2224 row 66 in-progress→in-review round 2 on #1559; request sage-r66-inreview-2 requesting"
},
"viewSha": "d94d093d161d5a80633d33499652bf2343366e30a0a3c61d835d6dc915d48f01"
},
{
"rev": 2225,
"op": "sage-r66-inreview-2.outcome",
"verb": "review-outcome",
"args": {
"id": 66,
"attempt": "sage-r66-inreview-2",
"outcome": "posted",
"status": 201,
"comment": 29441,
"detail": "created"
},
"by": "sage",
"at": "2026-10-20T13:51:35.785Z",
"semantics": 2,
"result": {
"row": 66,
"round": 2,
"attempt": "sage-r66-inreview-2",
"state": "posted",
"receipt": "ok sage-r66-inreview-2.outcome rev 2225 row 66 round 2 request sage-r66-inreview-2 posted comment 29441"
},
"viewSha": "1fe95c522167fb29ebc1b2ac2d765f8a6e4ad3d72ca784dfd9ec7a6877704c22"
},
{
"rev": 2226,
"op": "filbert-66-r2",
"verb": "review-record",
"args": {
"id": 66,
"verdict": "approve",
"comment": 29442,
"candidate": "cee63e06fdea9896ac6ad841ba8a548fe2e0cff60dabe25e81ba928c0f51ff2c"
},
"by": "filbert",
"at": "2026-10-20T13:56:19.376Z",
"semantics": 2,
"result": {
"row": 66,
"round": 2,
"verdict": "approve",
"receipt": "ok filbert-66-r2 rev 2226 row 66 round 2 approve by filbert (comment 29442)"
},
"viewSha": "8a180cd3e1f7abc082a396811907fc3d4d9ad0ced95e0e495225361a7cfe4124"
},
{
"rev": 2227,
"op": "dewey-66-r2",
"verb": "review-record",
"args": {
"id": 66,
"verdict": "approve",
"comment": 29443,
"candidate": "cee63e06fdea9896ac6ad841ba8a548fe2e0cff60dabe25e81ba928c0f51ff2c"
},
"by": "dewey",
"at": "2026-10-20T14:10:43.258Z",
"semantics": 2,
"result": {
"row": 66,
"round": 2,
"verdict": "approve",
"receipt": "ok dewey-66-r2 rev 2227 row 66 round 2 approve by dewey (comment 29443)"
},
"viewSha": "f9e9e20d0304ad7781f343738f7966ac8d632933ee267e17870a0eca418dcd6d"
},
{
"rev": 2228,
"op": "sage-163-inreview-1",
"verb": "move",
"args": {
"id": 163,
"to": "in-review",
"reason": null,
"candidate": "agents/sage/work/queue-163/candidate-manifest.sha256",
"evidence": null,
"issue": null
},
"by": "sage",
"at": "2026-10-20T14:46:22.020Z",
"semantics": 2,
"result": {
"row": 163,
"from": "in-progress",
"to": "in-review",
"round": 1,
"issue": 1660,
"candidate": {
"kind": "manifest",
"digest": "430212299bebcac52013332ce9fd0ba43033e105bc61f53415ad1000f3f52c62",
"text": "04d72e5ef4d8d5fc85a4f11aa51331354696cc7cf66af2ed7540cf6cfc0153b9 agents/sage/work/queue-163/BUILD.md\n5bd1bc697cd56e57b00e60c0e80be72cd1043a55a6d2fc0b9e2ccdd0c5cf9802 docs/TOOLS.md\n52597d7fa1469510fcf8e6dfd63664ad4c492bc17039885aa839231798693058 packages/cli/src/cli.mjs\n5492ebb5aa3c9c85874e4c7de7b65fc37733e2db636690c11fe2d2fdf72c2dd0 packages/cli/src/launcher.mjs\ne9ce0e0f7368d3fb2e1edbd4ebc724513ec19d7bac0c993f8be0d9494427be07 packages/cli/tests/host.test.mjs\n42519898b5bcf84c21088647e161a86934d4fe351207f736b9b120b6e1c2b91b packages/cli/tests/launcher.test.mjs\n4dfeefa1bb3c11e8633d75df4f628497ebd427ebf8402ba5efc3e5c7fd030057 packages/cli/tests/stack-release.test.mjs\n5abdeab8bc6b63d291d0054124a4311a2260b590c68f758daa142a9deb2f4a72 packages/seat/src/session.mjs\n741883e399d77d429cc29a87beef6db9c51c1759669b025006f037001d101eb6 packages/seat/tests/session.test.mjs\n5bb3ac40102ce515f1acdcbc5670feb161ce40a193e94e2f8b104ebf98eddc38 scripts/stack-release.sh\n"
},
"receipt": "ok sage-163-inreview-1 rev 2228 row 163 in-progress→in-review round 1 on #1660; request sage-163-inreview-1 requesting"
},
"viewSha": "05dc005f96eb767708faf26064cfc3bea0b3c804472826890c85927199f01bda"
},
{
"rev": 2229,
"op": "sage-163-inreview-1.outcome",
"verb": "review-outcome",
"args": {
"id": 163,
"attempt": "sage-163-inreview-1",
"outcome": "posted",
"status": 201,
"comment": 29444,
"detail": "created"
},
"by": "sage",
"at": "2026-10-20T14:46:39.862Z",
"semantics": 2,
"result": {
"row": 163,
"round": 1,
"attempt": "sage-163-inreview-1",
"state": "posted",
"receipt": "ok sage-163-inreview-1.outcome rev 2229 row 163 round 1 request sage-163-inreview-1 posted comment 29444"
},
"viewSha": "8efcd553a4a63e78b6229ef17c18f3af189a5ffff406a0592cfd8a1d85fd597c"
},
{
"rev": 2230,
"op": "filbert-163-r1",
"verb": "review-record",
"args": {
"id": 163,
"verdict": "changes",
"comment": 29445,
"candidate": "430212299bebcac52013332ce9fd0ba43033e105bc61f53415ad1000f3f52c62"
},
"by": "filbert",
"at": "2026-10-20T15:05:43.690Z",
"semantics": 2,
"result": {
"row": 163,
"round": 1,
"verdict": "changes",
"receipt": "ok filbert-163-r1 rev 2230 row 163 round 1 changes by filbert (comment 29445)"
},
"viewSha": "a4fd1545793c24a4eaf9876388e8cdfe54befe786f592823a6e7dc8cc9b82664"
},
{
"rev": 2231,
"op": "sage-163-r2-start",
"verb": "move",
"args": {
"id": 163,
"to": "in-progress",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "sage",
"at": "2026-10-20T15:23:54.631Z",
"semantics": 2,
"result": {
"row": 163,
"from": "in-review",
"to": "in-progress",
"round": null,
"issue": null,
"candidate": null,
"receipt": "ok sage-163-r2-start rev 2231 row 163 in-review→in-progress"
},
"viewSha": "a8fa98b005a3adcdf70f032dd4f047555b8e53773525df31fe7bb8d90167dfdd"
},
{
"rev": 2232,
"op": "sage-163-inreview-2",
"verb": "move",
"args": {
"id": 163,
"to": "in-review",
"reason": null,
"candidate": "agents/sage/work/queue-163/r2/candidate-manifest.sha256",
"evidence": null,
"issue": null
},
"by": "sage",
"at": "2026-10-20T15:24:17.566Z",
"semantics": 2,
"result": {
"row": 163,
"from": "in-progress",
"to": "in-review",
"round": 2,
"issue": 1660,
"candidate": {
"kind": "manifest",
"digest": "c6f4f4c125967525f771d0c39445c131916443cbd2754ff58fe8353876e303ce",
"text": "f04d12a963b3199420f1d9c41fbdae67faf46f710a805cb9358f1fb52f5faed9 agents/sage/work/queue-163/BUILD.md\n5bd1bc697cd56e57b00e60c0e80be72cd1043a55a6d2fc0b9e2ccdd0c5cf9802 docs/TOOLS.md\n52597d7fa1469510fcf8e6dfd63664ad4c492bc17039885aa839231798693058 packages/cli/src/cli.mjs\n5492ebb5aa3c9c85874e4c7de7b65fc37733e2db636690c11fe2d2fdf72c2dd0 packages/cli/src/launcher.mjs\ne9ce0e0f7368d3fb2e1edbd4ebc724513ec19d7bac0c993f8be0d9494427be07 packages/cli/tests/host.test.mjs\n42519898b5bcf84c21088647e161a86934d4fe351207f736b9b120b6e1c2b91b packages/cli/tests/launcher.test.mjs\nb01ff656962c6fdb5bc3554cfbb13645b7f25adae5f6cd9608af8de6d6091c6c packages/cli/tests/stack-release.test.mjs\n448138d12404346989a6751a01c747718cef7d2bec19a0639a29fa6a8b74a82d packages/seat/src/session.mjs\ncd6277c4e7155a7c93d09f5bc7cca1130708a35a6b75aa02d99af8360d6338b0 packages/seat/tests/session.test.mjs\n5bb3ac40102ce515f1acdcbc5670feb161ce40a193e94e2f8b104ebf98eddc38 scripts/stack-release.sh\n"
},
"receipt": "ok sage-163-inreview-2 rev 2232 row 163 in-progress→in-review round 2 on #1660; request sage-163-inreview-2 requesting"
},
"viewSha": "d5b60362859a3eb13c9154584a496b3295cb4e02d7bd4f2ecc5fddf308a2567f"
},
{
"rev": 2233,
"op": "sage-163-inreview-2.outcome",
"verb": "review-outcome",
"args": {
"id": 163,
"attempt": "sage-163-inreview-2",
"outcome": "posted",
"status": 201,
"comment": 29446,
"detail": "created"
},
"by": "sage",
"at": "2026-10-20T15:24:35.735Z",
"semantics": 2,
"result": {
"row": 163,
"round": 2,
"attempt": "sage-163-inreview-2",
"state": "posted",
"receipt": "ok sage-163-inreview-2.outcome rev 2233 row 163 round 2 request sage-163-inreview-2 posted comment 29446"
},
"viewSha": "aa2b9e6a56cb4cd997b4dce98e377aae1e7ab716fa82c7ef01f051f9d5a9cd0d"
},
{
"rev": 2234,
"op": "filbert-163-r2",
"verb": "review-record",
"args": {
"id": 163,
"verdict": "approve",
"comment": 29447,
"candidate": "c6f4f4c125967525f771d0c39445c131916443cbd2754ff58fe8353876e303ce"
},
"by": "filbert",
"at": "2026-10-20T15:40:19.458Z",
"semantics": 2,
"result": {
"row": 163,
"round": 2,
"verdict": "approve",
"receipt": "ok filbert-163-r2 rev 2234 row 163 round 2 approve by filbert (comment 29447)"
},
"viewSha": "2cde9426b532b3f1082cb23c1e659470b73d735cbbbd9350b57966bc15629f7a"
},
{
"rev": 2235,
"op": "sage-163-landnote",
"verb": "note",
"args": {
"id": 163,
"text": "Landed 8ecc7a239 (author Sage) on 7f57419b3; patch-id b8d5f8a37c24 matches candidate c6f4f4c12596 and eaa0f2666, the commit the gate ran on (base 4ca8d6e94; 7f57419b3 adds only agents/sage/work/q175-sso/DESIGN.md, which no test reads). Sage gate passed 18:16Z on eaa0f2666: cli 472/472 and every package and scripts/test-*.sh green. Filbert approve 29447, rev 2234. Not live until a re-pin; Q173 backup and STATE apply there (mos-0971)."
},
"by": "sage",
"at": "2026-10-20T18:17:27.937Z",
"semantics": 2,
"result": {
"row": 163,
"field": "note",
"receipt": "ok sage-163-landnote rev 2235 row 163 note"
},
"viewSha": "32521551fc8fdafc3ca5c187f3f6e18aff72c61ede4ab11fe7bc2ccfc91ea897"
},
{
"rev": 2236,
"op": "sage-163-done",
"verb": "move",
"args": {
"id": 163,
"to": "done",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "sage",
"at": "2026-10-20T18:17:45.831Z",
"semantics": 2,
"result": {
"row": 163,
"from": "in-review",
"to": "done",
"round": 2,
"issue": null,
"candidate": null,
"receipt": "ok sage-163-done rev 2236 row 163 in-review→done round 2"
},
"viewSha": "e44815d32d46b91ee37d4c3c4701716956ba35d29c16c24c89020cce345bd61b"
}
]
}
+7 -4
View File
@@ -57,7 +57,7 @@ export const USAGE = `usage:
mosaic stop <run>
mosaic launches off|on [--business <id>]
mosaic launches list [--json]
mosaic bus start <business> [--pm]
mosaic bus start <business> [--pm | --no-launch]
mosaic bus stop
mosaic bus status [--json]`;
@@ -482,8 +482,11 @@ async function launches(rest, io, deps) {
async function bus(rest, io, deps) {
const [sub, ...args] = rest;
if (sub === "start") {
const parsed = parse(args, { flags: ["--pm"] });
const parsed = parse(args, { flags: ["--pm", "--no-launch"] });
if (parsed.positional.length !== 1) throw usage("bus start takes one business");
// Row 163: --no-launch is the D2 rehearsal's host. No unit passes it.
const launches = !parsed.flags.has("--no-launch");
if (!launches && parsed.flags.has("--pm")) throw usage("bus start takes --pm or --no-launch, not both");
const businessId = parsed.positional[0];
const system = deps.system ?? loadSystem({ env: io.env });
// Fail closed, and say so (vikunja:19/302): a refused load is recorded where the Console and
@@ -500,9 +503,9 @@ async function bus(rest, io, deps) {
const binding = readNotifyConfig(system.dataRoot, businessId);
const host = await startHost({ boot, business: businessId, notifier: binding ? { binding } : null });
try {
const launcher = createLauncher({ host, system, env: io.env, tracker: Boolean(boot.trackers?.[businessId]), ...(deps.launcher ?? {}) });
const launcher = createLauncher({ host, system, env: io.env, tracker: Boolean(boot.trackers?.[businessId]), launches, ...(deps.launcher ?? {}) });
await launcher.listen();
io.stdout.write(`bus host up: business ${businessId}, socket ${host.path}, notifier ${binding ?? "off"}\n`);
io.stdout.write(`bus host up: business ${businessId}, socket ${host.path}, notifier ${binding ?? "off"}${launches ? "" : ", launches disabled"}\n`);
if (parsed.flags.has("--pm")) {
const pm = await launcher.launchPm();
io.stdout.write(`launched ${pm.instance}: run ${pm.run}, ${pm.harness} ${pm.model}\n`);
+22 -3
View File
@@ -153,7 +153,11 @@ function call(name, what, fn) {
}
// host: startHost()'s handle. tracker: true when the broker has task verbs for
// the business. makeLoader (the plug-in loader's factory, which tests point at a
// the business. launches: false for `bus start --no-launch` (row 163, the D2
// rehearsal): start() refuses launches-disabled, listen() starts no ticker, a
// session's launch request is refused, recover() kills and ends nothing, and
// sessions.json stays as it was.
// makeLoader (the plug-in loader's factory, which tests point at a
// scratch tree), bwrap, readOnly, sessionDefaults, harnessVersion,
// stopTimeoutMs, threadTickMs and git (createGitSocket options) exist for
// the tests; the command line never sets them.
@@ -162,6 +166,7 @@ export function createLauncher({
system,
env = process.env,
tracker = false,
launches = true,
makeLoader = createLoader,
bwrap = BWRAP,
readOnly = [],
@@ -187,7 +192,7 @@ export function createLauncher({
} catch (e) {
throw new CliError(`${e.message}; check which sessions still run, then move the file aside`, 3);
}
const registry = new Registry(dataRoot);
const registry = new Registry(dataRoot, { readOnly: !launches });
const children = new Map();
let chain = Promise.resolve();
let closed = false;
@@ -323,6 +328,8 @@ export function createLauncher({
// Bundle, spawn, bind, register, hand over the capability.
async function start({ instance, launchedBy, resolved, model, account = null, thread = null }) {
// Every launch path ends here, so a --no-launch host spawns nothing whichever path asked.
if (!launches) throw new Refusal("launches-disabled", "this host was started with --no-launch");
// A thread's store is checked first, so a bad id or an unsafe directory
// launches nothing.
let store = null;
@@ -627,6 +634,11 @@ export function createLauncher({
await host.op({ op: "refuse", cap, code: "not-a-role" }).catch(() => {});
return { ok: false, error: "not-a-role" };
}
if (!launches) {
await host.op({ op: "refuse", cap, code: "launches-disabled" }).catch(() => {});
record({ event: "refused", by: who.role, instance, code: "launches-disabled" });
return { ok: false, error: "launches-disabled" };
}
let checked;
try {
if (typeof instance !== "string") throw new Refusal("invalid-request");
@@ -675,6 +687,13 @@ export function createLauncher({
// This broker never bound those runs, so each is bound again first with
// its identical launch record, which the broker accepts.
async function recover() {
// Row 163 (mos-0961): a --no-launch host never signals or ends an earlier host's session. Its
// data root may hold a sessions.json that names live processes, so it only says what it left.
if (!launches) {
for (const s of leftover) log(`run ${s.run} (${s.instance}) from an earlier host: left as it is, this host was started with --no-launch`);
leftover = [];
return;
}
for (const s of leftover) {
if (startTimeOf(s.pid) === s.startTime && (cmdlineOf(s.pid) ?? [])[0] === bwrap) {
try {
@@ -744,7 +763,7 @@ export function createLauncher({
});
chmodSync(path, 0o600);
await gitSocket.listen();
if (b.launch) ticker = setInterval(() => tick().catch((e) => log(`thread tick: ${e.message}`)), threadTickMs);
if (b.launch && launches) ticker = setInterval(() => tick().catch((e) => log(`thread tick: ${e.message}`)), threadTickMs);
}
// SIGTERM every runner, wait, then end what's left. Runs before the host
+25
View File
@@ -811,6 +811,29 @@ test("bus start runs until bus stop; status reports it while it runs", async (t)
assert.match(again.stdout, /no host runs/);
});
// Row 163 (mos-0961): the D2 rehearsal's host.
test("bus start --no-launch says launches are disabled in its host-up line and stops cleanly; with --pm it is a usage error", async (t) => {
const f = fixture(tmp(t));
notifyConfig(f.dataRoot, "acme", null);
const both = spawnSync(process.execPath, [CLI, "bus", "start", "acme", "--pm", "--no-launch"], { env: f.env, encoding: "utf8", timeout: 40000 });
assert.equal(both.status, 4, both.stderr);
assert.match(both.stderr, /bus start takes --pm or --no-launch, not both/);
assert.equal(existsSync(join(f.dataRoot, "bus", "writer.lock")), false);
const child = spawn(process.execPath, [CLI, "bus", "start", "acme", "--no-launch"], { env: f.env, stdio: ["ignore", "pipe", "pipe"] });
t.after(() => child.exitCode === null && child.kill("SIGKILL"));
let out = "";
child.stdout.on("data", (b) => (out += b));
child.stderr.on("data", (b) => (out += b));
await until(() => /bus host up: business acme.*\n/.test(out), 30000);
assert.match(out, /bus host up: business acme, socket \S+, notifier off, launches disabled\n/);
// The launcher got launches false: its Registry is read-only, so no sessions.json appears.
assert.equal(existsSync(join(f.dataRoot, "bus-host", "sessions.json")), false);
const exit = once(child, "exit");
const stop = spawnSync(process.execPath, [CLI, "bus", "stop"], { env: f.env, encoding: "utf8", timeout: 70000 });
assert.equal(stop.status, 0, stop.stderr);
assert.equal((await exit)[0], 0, out);
});
test("bus-service.sh renders the unit and installs it into a given directory", (t) => {
const dir = tmp(t);
const script = join(REPO, "scripts", "bus-service.sh");
@@ -818,6 +841,8 @@ test("bus-service.sh renders the unit and installs it into a given directory", (
assert.equal(render.status, 0, render.stderr);
assert.match(render.stdout, new RegExp(`ExecStart=${REPO.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/scripts/mosaic bus start %i`));
assert.match(render.stdout, /RestartPreventExitStatus=2 3 4/);
// Row 163: --no-launch is for the D2 rehearsal's own host only; no unit starts a bus with it.
assert.doesNotMatch(render.stdout, /--no-launch/);
// One session credential file, optional so Pi sessions start without it.
// The README's paragraph names the same file; nothing else does.
const files = render.stdout.match(/^EnvironmentFile=.*$/gm);
+54 -3
View File
@@ -15,11 +15,12 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { Client } from "../../bus/src/client.mjs";
import { gitSocketPath, launchLogFile, launchSocketPath, readSessions, Registry, SESSION_CREDENTIALS, sessionsFile, workspaceDir } from "../../seat/src/session.mjs";
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
import { gitSocketPath, launchLogFile, launchSocketPath, readSessions, Registry, runsDir, SESSION_CREDENTIALS, sessionsFile, workspaceDir } from "../../seat/src/session.mjs";
import { BWRAP, sandboxProblem } from "../../seat/src/sandbox.mjs";
import { childrenOf } from "../../seat/src/proc.mjs";
import { gitEnv } from "../../seat/src/checkout.mjs";
import { bootConfig, loadSystem } from "../src/config.mjs";
import { startHost, startTimeOf } from "../src/host.mjs";
import { cmdlineOf, startHost, startTimeOf } from "../src/host.mjs";
import { createLauncher } from "../src/launcher.mjs";
import { mockAnthropic, REPO } from "../../harness/tests/helpers.mjs";
import { fakeHarnesses, fixture, tmp } from "./helpers.mjs";
@@ -901,6 +902,56 @@ for (const exited of [false, true]) {
});
}
// Row 163 (mos-0961): the D2 rehearsal host. A live bwrap process the test owns stands in for an
// earlier host's session; its outer bwrap is what recover() would kill, and its child stands in
// for the runner that close() would signal.
test("bus start --no-launch leaves an earlier host's live session alone: no signal, no end, sessions.json as it was", { skip, timeout: 30000 }, async (t) => {
const given = prepare(t);
const { f } = given;
const box = spawn(BWRAP, ["--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "--unshare-pid", "--die-with-parent", "sleep", "60"], { stdio: "ignore" });
t.after(() => box.kill("SIGKILL"));
const pid = box.pid;
const startTime = await until(() => startTimeOf(pid));
const runnerPid = await until(() => childrenOf(pid)[0]);
const runnerStartTime = await until(() => startTimeOf(runnerPid));
assert.equal(cmdlineOf(pid)[0], BWRAP, "recover() would take it for a session");
new Registry(f.dataRoot).add({ business: "acme", instance: "chief", run: "run-left", harness: "pi", pid, startTime, runnerPid, runnerStartTime });
const before = readFileSync(sessionsFile(f.dataRoot), "utf8");
const s = await setup(t, undefined, given, { launches: false });
assert.ok(s.logs.includes("run run-left (chief) from an earlier host: left as it is, this host was started with --no-launch"), s.logs.join("\n"));
assert.equal(startTimeOf(pid), startTime, "listen() sent no signal");
assert.equal(readFileSync(sessionsFile(f.dataRoot), "utf8"), before);
assert.deepEqual((await s.client.call("trail", { subject: "run-left" })).filter((r) => r.kind === "session.ended"), []);
assert.equal(await s.close(), 0);
assert.equal(startTimeOf(pid), startTime, "close() sent no signal either");
assert.equal(startTimeOf(runnerPid), runnerStartTime);
assert.equal(readFileSync(sessionsFile(f.dataRoot), "utf8"), before);
});
test("bus start --no-launch refuses every launch: the PM launch and a session's request, each logged, nothing spawned", { skip, timeout: 30000 }, async (t) => {
const s = await setup(t, undefined, undefined, { launches: false });
await assert.rejects(s.launcher.launchPm(), (e) => e.exitCode === 3 && /^PM launch refused: launches-disabled/.test(e.message));
const sock = launchSocketPath(s.f.dataRoot);
assert.deepEqual(await ask(sock, `${JSON.stringify({ cap: s.cto.cap, instance: "coder" })}\n`), { ok: false, error: "launches-disabled" });
assert.deepEqual(log(s.f).map((e) => [e.event, e.instance, e.code]), [["refused", "chief", "launches-disabled"], ["refused", "coder", "launches-disabled"]]);
assert.deepEqual(readSessions(s.f.dataRoot), []);
const runs = runsDir(s.f.dataRoot, "acme");
assert.deepEqual(existsSync(runs) ? readdirSync(runs) : [], [], "no run directory");
assert.equal(await s.close(), 0);
});
test("bus start --no-launch starts no thread tick; the same host with launches runs it", { skip, timeout: 30000 }, async (t) => {
for (const launches of [false, true]) {
const asked = [];
const wrap = (host) => Object.create(host, { op: { value: async (m) => (["threadsState", "landHolders"].includes(m.op) && asked.push(m.op), host.op(m)) } });
const s = await setup(t, undefined, undefined, { launches, threadTickMs: 50, wrap });
await new Promise((r) => setTimeout(r, 400));
assert.equal(asked.length > 0, launches, `launches ${launches}: ${asked.join(", ")}`);
assert.equal(await s.close(), 0);
}
});
test("a malformed sessions.json refuses the host start with exit 3 and stays as it was", { timeout: 30000 }, async (t) => {
const given = prepare(t);
const file = sessionsFile(given.f.dataRoot);
+491 -5
View File
@@ -381,7 +381,7 @@ async function seed(e,{copy=B,pm=false,store='3g',pointer:how='adopt'}={}){
const rolledBack=(code,to=B)=>`stack-release: ${code}; rolled back to ${to}, which passed the gate\n`;
const planBackup=(e)=>`backup: the run backs up the stopped store to ${e.data}/backups/stack/<activation>/ before any migration`;
const planNoBackup='backup: none; the run rolls back before the backup';
const planTail=(e,to,pm=false)=>[`bus: scripts/bus-service.sh install --copy ${to} --copies ${COPIES}${pm?' --pm':''} --claude ${e.claude}`,'proof: off',`Console: scripts/webui-service.sh install --copy ${to} --copies ${COPIES} --port ${e.port} --business acme`,`CLI: scripts/stack-copy.sh wrapper ${to} --copies ${COPIES} --bin ${e.bin}`];
const planTail=(e,to,pm=false)=>[`bus: scripts/bus-service.sh install --copy ${to} --copies ${COPIES}${pm?' --pm':''} --claude ${e.claude}`,'proof: off','send: off',`Console: scripts/webui-service.sh install --copy ${to} --copies ${COPIES} --port ${e.port} --business acme`,`CLI: scripts/stack-copy.sh wrapper ${to} --copies ${COPIES} --bin ${e.bin}`];
// --- The T table ---
@@ -1030,17 +1030,17 @@ test('T36 --plan shows the pending state and lists the row 169 refusals it can s
return r.out.split('\n');
};
let out=await quiet(0,'activate',C1,'acme','--pm-after-harness','--plan');
assert.deepEqual(out.slice(-6),['pm: the bus starts without --pm and the PM stays pending; next: Jason\'s plugins set of harness, then pm-on',...planTail(e,C1),'']);
assert.deepEqual(out.slice(-7),['pm: the bus starts without --pm and the PM stays pending; next: Jason\'s plugins set of harness, then pm-on',...planTail(e,C1),'']);
biz(e,HARNESS);
out=await quiet(3,'activate',A,'acme','--pm-after-harness','--plan');
assert.deepEqual(out.slice(-7),['pm: the bus starts without --pm and the PM stays pending; next: Jason\'s plugins set of harness, then pm-on',...planTail(e,A),`refuses unknown-kind-for-target: the business file names plug-in kind(s) harness, which ${A} does not know`,'']);
assert.deepEqual(out.slice(-8),['pm: the bus starts without --pm and the PM stays pending; next: Jason\'s plugins set of harness, then pm-on',...planTail(e,A),`refuses unknown-kind-for-target: the business file names plug-in kind(s) harness, which ${A} does not know`,'']);
out=await quiet(3,'activate',C1,'acme','--pm-after-harness','--plan');
assert.equal(out.at(-2),'refuses pm-after-harness-set: the business file already sets harness ["claude-code"]; use --pm');
biz(e);assert.equal((await run(e,'activate',C1,'acme','--pm-after-harness')).code,0);
out=await quiet(3,'activate',C,'acme','--plan');
assert.deepEqual(out.slice(-7),['pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM',...planTail(e,C,true),`refuses pm-pending: ${PENDING_MSG}`,'']);
assert.deepEqual(out.slice(-8),['pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM',...planTail(e,C,true),`refuses pm-pending: ${PENDING_MSG}`,'']);
out=await quiet(0,'rollback','acme','--plan');
assert.deepEqual(out.slice(-6),['pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM',...planTail(e,B,true),'']);
assert.deepEqual(out.slice(-7),['pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM',...planTail(e,B,true),'']);
});
// Row 176 test 11: supported and older Console copies deliberately differ from the checkout.
@@ -1705,3 +1705,489 @@ test('T54 row 188: a carried pair gets the proof file checks, and a proof file i
await noLeak(e);
});
});
// Row 189 (#1687, mos-0972/0973): activate --no-send drops the pointer's --send; nothing turns it back on.
const sendLines=(out)=>out.filter((l)=>l.startsWith('send:'));
const noSend=(c)=>c.filter((x)=>x!=='--send');
const LIVE_NOTE=`note: ${B} is the pointer's copy; the run re-pins it and only the Console flags change`;
test('T55 row 189: activate --no-send drops --send, and rollback and pm-on carry it off',async(t)=>{
await t.test('test 1: a live-copy --no-send drops --send from the pointer and the unit, keeps the rest in order, logs the step and backs up the stopped store',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']),want=noSend(base);
assert.deepEqual(want.slice(-2),['--tester-port','7333']);
const sum=createHash('sha256').update(readFileSync(e.store)).digest('hex'),n=calls(e).length;
let r=await prun(e,'activate',B,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
assert.equal(pointer(e).copy,B);assert.deepEqual(pointer(e).console,want);
assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));assert.doesNotMatch(conExec(e),/ --send( |$)/);
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
assert.match(stepText(e),/Z drop --send; --no-send was given\n/);
const act=pointer(e).activation,l=logLines(e).at(-1);
assert.deepEqual([l.op,l.from,l.to,l.result,l.backup],['activate',B,B,'ok',act]);assert.equal(backupSum(e,act),sum);
assert.deepEqual(startsStops(e,n),[`stop ${CON}`,`stop ${BUS}`,`start ${BUS}`,`start ${CON}`]);
await noLeak(e);
});
await t.test('test 2: a plain live-copy activate still refuses already-active, and so does --no-send once --send is gone',async(t)=>{
const e=await mk(t);await seedPreview(e,undefined,['--send']);
await refused(e,['activate',B,'acme'],'already-active',`${B} is the pointer's copy`);
await refused(e,['activate',B,'acme','--tester-port','7333'],'already-active',`${B} is the pointer's copy`);
await refused(e,['activate',B,'acme','--no-proof'],'already-active',`${B} is the pointer's copy`);
const r=await prun(e,'activate',B,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
await refused(e,['activate',B,'acme','--no-send'],'already-active',`${B} is the pointer's copy`);
const g=await mk(t);await seedPreview(g);
await refused(g,['activate',B,'acme','--no-send'],'already-active',`${B} is the pointer's copy`);
for(const x of [e,g])await noLeak(x);
});
await t.test('test 3: --plan prints one send: line in each case, with the live-copy note on a same-copy run',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--send']);
let out=await planOnly(e,0,'activate',B,'acme','--no-send');
assert.deepEqual(sendLines(out),['send: dropped; --no-send was given']);
assert.deepEqual(out.filter((l)=>l.startsWith('note: ')),[LIVE_NOTE]);
assert.equal(out[out.indexOf('send: dropped; --no-send was given')+1],`Console: scripts/webui-service.sh install --copy ${B} --copies ${COPIES} ${noSend(base).join(' ')}`);
out=await planOnly(e,0,'activate',C1,'acme');
assert.deepEqual(sendLines(out),['send: carried']);assert.deepEqual(out.filter((l)=>l.startsWith('note: ')),[]);
assert.ok(out.find((l)=>l.startsWith('Console: ')).endsWith(' --send'));
assert.deepEqual(sendLines(await planOnly(e,0,'activate',C1,'acme','--no-send')),['send: dropped; --no-send was given']);
let r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,noSend(base));assert.match(stepText(e),/Z drop --send; --no-send was given\n/);
assert.deepEqual(sendLines(await planOnly(e,0,'activate',B,'acme','--no-send')),['send: off; --no-send was given and the pointer holds no --send']);
assert.deepEqual(sendLines(await planOnly(e,0,'activate',B,'acme')),['send: off']);
assert.deepEqual(sendLines(await planOnly(e,0,'rollback','acme')),['send: off']);
r=await prun(e,'activate',B,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
assert.match(stepText(e),/Z --no-send was given and the pointer holds no --send, so there is nothing to drop\n/);
assert.doesNotMatch(stepText(e),/drop --send/);
await noLeak(e);
});
await t.test('test 4: after --no-send, a rollback to the earlier copy leaves --send out of the pointer and the unit',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--send']);
let r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
assert.equal(pointer(e).copy,B);assert.deepEqual(pointer(e).console,noSend(base));assert.doesNotMatch(conExec(e),/ --send( |$)/);
assert.doesNotMatch(stepText(e),/--send/);
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
await noLeak(e);
});
await t.test('test 4: pm-on after --no-send keeps --send off',async(t)=>{
const e=await mk(t),base=await seedPreview(e,{pm:true},['--send']);
let r=await prun(e,'activate',C1,'acme','--pm-after-harness','--no-send');assert.equal(r.code,0,r.out+r.err);
biz(e,HARNESS);
r=await prun(e,'pm-on','acme');assert.equal(r.code,0,r.out+r.err);
assert.equal(pointer(e).pm,true);assert.deepEqual(pointer(e).console,noSend(base));assert.doesNotMatch(conExec(e),/ --send( |$)/);
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
await noLeak(e);
});
await t.test('test 5: --no-send on rollback, pm-on, status, adopt or rehearse is a usage error',async(t)=>{
const e=await mk(t);await seedPreview(e,undefined,['--send']);
const usageText=(await prun(e,'--help')).err,n=calls(e).length,logs=logLines(e).length,before=fp(e);
for(const args of [['rollback','acme','--no-send'],['rollback','acme','--plan','--no-send'],['pm-on','acme','--no-send'],['status','acme','--no-send'],['adopt','acme','--no-send'],
['rehearse','acme','--backup','/tmp/x','--port','7400','--no-send']]){
const r=await prun(e,...args);assert.equal(r.code,4,args.join(' '));assert.equal(r.out,'');assert.equal(r.err,usageText);
}
assert.match(usageText,/\| --no-proof\] \[--no-send\]/);
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
await noLeak(e);
});
await t.test('test 6: with both a pair and --send in the pointer, --no-send and --no-proof each drop what they name and keep the rest',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--send']),pre=noSend(base),f=await proofFile(e),g=await proofFile(e,'proof2.189',0o400);
let r=await prun(e,'activate',B,'acme',...pair(f));assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,[...pre,...pair(f),'--send']);
let out=await planOnly(e,0,'activate',C1,'acme','--no-proof');
assert.deepEqual([...proofLines(out),...sendLines(out)],['proof: dropped; --no-proof drops header x-console-proof','send: carried']);
out=await planOnly(e,0,'activate',C1,'acme','--no-send');
assert.deepEqual([...proofLines(out),...sendLines(out)],['proof: carried, header x-console-proof','send: dropped; --no-send was given']);
out=await planOnly(e,0,'activate',B,'acme','--no-send','--no-proof');
assert.deepEqual([...proofLines(out),...sendLines(out)],['proof: dropped; --no-proof drops header x-console-proof','send: dropped; --no-send was given']);
r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,[...pre,...pair(f)]);
r=await prun(e,'activate',C1,'acme','--no-send',...pair(g,'X-Proof-Two'));assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,[...pre,...pair(g,'X-Proof-Two')]);
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
const h=await mk(t),hbase=await seedPreview(h,undefined,['--send']),hpre=noSend(hbase),k=await proofFile(h);
r=await prun(h,'activate',B,'acme',...pair(k));assert.equal(r.code,0,r.out+r.err);
r=await prun(h,'activate',C1,'acme','--no-proof');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(h).console,[...hpre,'--send']);
r=await prun(h,'activate',C1,'acme','--no-send','--no-proof');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(h).console,hpre);assert.doesNotMatch(conExec(h),/--proof-| --send( |$)/);
const steps=stepText(h);
assert.match(steps,/Z --no-proof was given and the pointer holds no proof pair, so there is nothing to drop\n/);assert.match(steps,/Z drop --send; --no-send was given\n/);
r=await prun(h,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
for(const x of [e,h])await noLeak(x);
});
});
// --- rehearse (D2, row 163; the brief's "Stack rehearsal on a scratch root") ---
// Copies of their own in RCOPIES: the real scripts/mosaic, config loader, business loader, roles and
// mosaic-config.mjs, with stand-ins for the bus host (cli.mjs), the Console, the Store and
// migrate-schema.mjs. A rehearsal starts its processes with only HOME, PATH and MOSAIC_CONFIG, so
// the stand-ins take their faults from $HOME/faults and record what they did in $HOME.
// The bus host. bus start writes the store's writer.lock, a meta row in the store, and its state
// beside the scratch store, and prints the host-up line; without --no-launch it starts the PM, which here means a line in
// $HOME/starts.log and a sessions.json. bus status --json answers from that state.
function rHost(){
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path');
const id=path.basename(path.resolve(import.meta.dirname,'..','..','..')),home=process.env.HOME,a=process.argv.slice(2);
const fault=(n)=>fs.existsSync(path.join(home,'faults',n));
const alive=(pid)=>{try{const s=fs.readFileSync(`/proc/${pid}/stat`,'utf8');return s[s.lastIndexOf(')')+2]!=='Z'}catch{return false}};
const data=JSON.parse(fs.readFileSync(process.env.MOSAIC_CONFIG,'utf8')).dataRoot,state=path.join(data,'fixture-host.json');
const flags=["--pm","--no-launch"];
if(a.join(' ')==='bus status --json'){
if(fault(`status-${id}`))process.exit(1);
let s={host:null,boot:null};try{s=JSON.parse(fs.readFileSync(state,'utf8'));s.host.live=alive(s.host.pid)}catch{}
if(fault('status-other-host')&&s.host)s.host.pid=process.ppid;
console.log(JSON.stringify(s));process.exit(0);
}
if(a[0]!=='bus'||a[1]!=='start'||!a.slice(3).every((f)=>flags.includes(f))){console.error(`fixture host: ${a.join(' ')}`);process.exit(4)}
fs.appendFileSync(path.join(home,'host-args.log'),`${a.join(' ')}\n`);
if(fault(`host-fails-${id}`)){console.error('fixture host: refused');process.exit(1)}
const b=a[2],off=a.includes('--no-launch'),lock=path.join(data,'bus','writer.lock'),at=new Date().toISOString();
fs.writeFileSync(lock,String(process.pid),{flag:'wx'});
const db=new (process.getBuiltinModule('node:sqlite').DatabaseSync)(path.join(data,'bus','bus.sqlite'));
db.prepare("INSERT OR REPLACE INTO meta(key,value) VALUES('fixture_host_start',?)").run(at);db.close();
fs.writeFileSync(state,JSON.stringify({host:{business:b,pid:process.pid,live:true,startedAt:at,notifier:'off'},boot:{ok:true,business:b,at}}));
if(fault('host-launches-dir'))fs.mkdirSync(path.join(data,'launches'));
// host-touches lists files a careless host would write: the live config, business file or backup.
if(fault('host-touches'))for(const f of fs.readFileSync(path.join(home,'faults','host-touches'),'utf8').split('\n').filter(Boolean))fs.appendFileSync(f,'x');
if(!off||fault('host-launches')){
fs.appendFileSync(path.join(home,'starts.log'),`pm-${b}\n`);
fs.mkdirSync(path.join(data,'bus-host'),{recursive:true});
fs.writeFileSync(path.join(data,'bus-host','sessions.json'),JSON.stringify({sessionsVersion:1,sessions:[{pid:process.pid}]}));
}
console.log(`bus host up: business ${b}, socket ${path.join(data,'bus','broker.sock')}, notifier off${off?', launches disabled':''}`);
process.on('SIGTERM',()=>{if(fault('host-ignores-term'))return;if(!fault('host-keeps-lock'))fs.rmSync(lock,{force:true});process.exit(0)});
setInterval(()=>{},1<<30);
}
// The Console: serve on the given loopback port; /healthz answers 200 unless healthz-<copy> is set.
function rConsole(){
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),http=process.getBuiltinModule('node:http');
const id=path.basename(path.resolve(import.meta.dirname,'..','..','..')),home=process.env.HOME,a=process.argv.slice(2);
const fault=(n)=>fs.existsSync(path.join(home,'faults',n));
fs.appendFileSync(path.join(home,'console-args.log'),`${a.join(' ')}\n`);
if(a[0]!=='serve'){console.error('fixture Console: refused');process.exit(1)}
const s=http.createServer((q,r)=>{r.writeHead(q.url==='/healthz'&&!fault(`healthz-${id}`)?200:500);r.end()});
s.listen(Number(a[a.indexOf('--port')+1]),'127.0.0.1',()=>console.log(`Mosaic Console: http://127.0.0.1:${s.address().port}/\nBoard: fixture`));
process.on('SIGTERM',()=>process.exit(0));
}
// migrate-schema.mjs: sets its copy's SCHEMA_VERSION, or exit 1 under migrate-fails.
function rMigrate(){
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),{DatabaseSync}=process.getBuiltinModule('node:sqlite');
const home=process.env.HOME,data=process.argv[2];
const v=/^export const SCHEMA_VERSION = '([A-Za-z0-9]+)';$/m.exec(fs.readFileSync(path.join(import.meta.dirname,'store.mjs'),'utf8'))[1];
fs.appendFileSync(path.join(home,'migrate.log'),`${data}\n`);
if(fs.existsSync(path.join(home,'faults','migrate-fails'))){console.error('fixture migrate: failed');process.exit(1)}
const db=new DatabaseSync(path.join(data,'bus','bus.sqlite'));db.prepare("UPDATE meta SET value=? WHERE key='schema_version'").run(v);db.close();
console.log(`bus schema ${v} ready`);
}
// The copy's Store: refuses a store at another version with schema-mismatch, as the real one does.
const rStore=(v,froms)=>`${storeText(v,froms)}import {DatabaseSync} from 'node:sqlite';
import {join} from 'node:path';
export class Store {
constructor(root) {
this.db = new DatabaseSync(join(root, 'bus', 'bus.sqlite'));
if (this.db.prepare("SELECT value FROM meta WHERE key='schema_version'").get()?.value !== SCHEMA_VERSION) { this.db.close(); throw Error('schema-mismatch'); }
}
get(sql, ...args) { return this.db.prepare(sql).get(...args); }
close() { this.db.close(); }
}
`;
// B (3g) and C (3h) have --no-launch. A is a copy from before row 163, whose host has no such flag.
// C1's scripts/mosaic runs B's host, as a copy-check failure needs.
const RCOPIES=join(base,'rcopies');mkdirSync(RCOPIES);
for(const [id,v,froms] of [[B,'3g',['3f']],[C,'3h',['3f','3g']],[A,'3g',['3f']],[C1,'3g',['3f']]]){
const d=join(RCOPIES,id);
for(const p of ['scripts/mosaic','scripts/mosaic-config.mjs','packages/cli/src/config.mjs','packages/cli/src/errors.mjs','packages/bus/src/business.mjs','packages/plugins/src/kinds.mjs','packages/business/src','roles'])cpSync(join(repo,p),join(d,p),{recursive:true});
if(id===C1)writeFileSync(join(d,'scripts','mosaic'),`#!/usr/bin/env bash\nexec node "${join(RCOPIES,B,'packages','cli','src','cli.mjs')}" "$@"\n`,{mode:0o755});
for(const [f,text] of [['packages/cli/src/cli.mjs',id===A?'// fixture host from before row 163\nprocess.exit(1);\n':script(rHost)],['packages/webui/src/cli.mjs',script(rConsole)],['packages/bus/src/store.mjs',rStore(v,froms)],['packages/bus/src/migrate-schema.mjs',script(rMigrate)],['packages/bus/src/broker.mjs','export class BusError extends Error { constructor(code) { super(code); this.code = code; } }\n']]){mkdirSync(dirname(join(d,f)),{recursive:true});writeFileSync(join(d,f),text)}
execFileSync('chmod',['-R','a-w',d]);
}
const TESTER='7499';
const freePort=async()=>{const s=createServer();s.listen(0,'127.0.0.1');await once(s,'listening');const p=String(s.address().port);await new Promise((r)=>s.close(r));return p};
const rh=(e,...p)=>join(e.env.HOME,...p);
const rfault=(e,name)=>{mkdirSync(rh(e,'faults'),{recursive:true});writeFileSync(rh(e,'faults',name),'')};
// A root for one rehearsal: a stopped live store at `store`, a pointer by hand on `copy` with the
// Console on e.port and the tester listener on TESTER, and a backup as backup_store writes one.
// A rehearsal's leftovers, found by their scratch MOSAIC_CONFIG, are killed after the test.
async function rmk(t,{copy=B,store='3g'}={}){
const e=await mk(t);
t.after(()=>{
for(const pid of readdirSync('/proc').filter((n)=>/^\d+$/.test(n)))
try{if(readFileSync(`/proc/${pid}/environ`,'utf8').includes(`MOSAIC_CONFIG=${e.data}/rehearsals/`))process.kill(Number(pid),'SIGKILL')}catch{}
});
const db=new DatabaseSync(e.store);
db.exec('PRAGMA journal_mode=WAL');
db.exec('CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)');
db.exec('CREATE TABLE events (id INTEGER PRIMARY KEY, at TEXT NOT NULL)');
db.prepare("INSERT INTO meta VALUES ('schema_version', ?)").run(store);
for(let i=0;i<3;i++)db.prepare('INSERT INTO events (at) VALUES (?)').run(`2000-01-01T00:00:0${i}.000Z`);
db.close();
mkdirSync(e.state,{recursive:true});
writeFileSync(e.pointer,JSON.stringify({copy,business:'acme',pm:true,claude:e.claude,console:['--port',e.port,'--business','acme','--tester-port',TESTER],schema:store,activation:'manual',at:'2026-10-20T00:00:00Z'})+'\n');
e.act=`20261020T000000Z-${copy.slice(0,12)}`;e.backup=join(e.data,'backups','stack',e.act);
mkdirSync(join(e.backup,'bus'),{recursive:true,mode:0o700});
cpSync(e.store,join(e.backup,'bus','bus.sqlite'));
writeFileSync(join(e.backup,'SHA256SUMS'),`${sha(e.store)} bus.sqlite\n`);
for(const [f,text] of [['DATA_ROOT',e.data],['SCHEMA',store],['STAMP','2026-10-20T00:00:00.000Z']])writeFileSync(join(e.backup,f),`${text}\n`);
e.biz=join(dirname(e.cfg),'businesses','acme.json');
return e;
}
// What a rehearsal may never change: the live store, config, business file, pointer and log, and
// the backup, by sha256 of every file.
const untouched=(e)=>JSON.stringify([...[e.store,e.cfg,e.biz,e.pointer].map((f)=>[f,sha(f)]),existsSync(e.log),...readdirSync(e.backup,{recursive:true}).sort().map((f)=>join(e.backup,f)).filter((f)=>statSync(f).isFile()).map((f)=>[f,sha(f)])]);
async function rehearse(e,id,...args){
const ch=spawn('bash',[e.sr??SR,'rehearse',id,'acme',...args,'--copies',RCOPIES,'--bin',e.bin],{env:e.env,stdio:['ignore','pipe','pipe']});
let out='',err='';ch.stdout.on('data',(d)=>{out+=d});ch.stderr.on('data',(d)=>{err+=d});
e.during?.(ch);
const timer=setTimeout(()=>ch.kill('SIGKILL'),120000);
const [code]=await once(ch,'close');clearTimeout(timer);
const m=/\nrehearsal directory: (.*)\n$/.exec(out);
return {code,out,err,dir:m?m[1]:null,names:out.split('\n').filter((l)=>/^(PASS|FAIL) /.test(l)).map((l)=>l.replace(/^(PASS|FAIL) ([^:]*).*/,'$1 $2'))};
}
// A script of its own whose text has `from` replaced, for the guard tests. The replacement is a
// function so a shell "$$" in `to` stays as written.
function ownScript(e,from,to){
const own=join(e.root,'repo');cpSync(repo,own,{recursive:true});
e.sr=join(own,'scripts','stack-release.sh');
const s=readFileSync(e.sr,'utf8');assert.ok(s.includes(from),`${e.sr} has no ${from}`);writeFileSync(e.sr,s.replace(from,()=>to));
}
const rDirs=(e)=>existsSync(join(e.data,'rehearsals'))?readdirSync(join(e.data,'rehearsals')):[];
const PRE=['PASS copy','PASS port','PASS backup','PASS scratch-root','PASS data-root-live','PASS sessions-live','PASS data-root-scratch','PASS notify-off','PASS tracker-configured','PASS credential-ref-live'];
const POST=['PASS console-stopped','PASS host-stopped','PASS host-released-store','PASS backup-unchanged','PASS live-config-unchanged'];
test('R1 rehearse passes on the same schema; the scratch root holds its files and the live files stay byte-identical',async(t)=>{
const e=await rmk(t),before=untouched(e),port=await freePort();
const r=await rehearse(e,B,'--backup',e.act,'--port',port,'--timeout','10');
assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(r.names,[...PRE,'PASS migration','PASS gate','PASS no-session',...POST]);
assert.ok(r.out.endsWith(`REHEARSAL_PASS\nrehearsal directory: ${r.dir}\n`),r.out);
assert.equal(r.err,'');
assert.equal(untouched(e),before);
const R=r.dir;assert.equal(dirname(R),join(e.data,'rehearsals'));assert.match(basename(R),new RegExp(`^\\d{8}T\\d{6}Z-${B.slice(0,12)}$`));
for(const d of [dirname(R),R,join(R,'placeholders'),join(R,'config')])assert.equal(statSync(d).mode&0o777,0o700,d);
for(const f of ['steps.log','backup.before','live.before','business.diff','host.log','console.log','config/config.json','data/bus/bus.sqlite'])assert.ok(existsSync(join(R,f)),f);
// The host wrote the scratch store, a copy: the backup has no such row.
const metaOf=(f)=>{const db=new DatabaseSync(f,{readOnly:true});try{return db.prepare("SELECT value FROM meta WHERE key='fixture_host_start'").get()?.value}finally{db.close()}};
assert.match(metaOf(join(R,'data','bus','bus.sqlite')),/^\d{4}-/);assert.equal(metaOf(join(e.backup,'bus','bus.sqlite')),undefined);
// The scratch config differs from the live one only in dataRoot.
assert.deepEqual(JSON.parse(readFileSync(join(R,'config','config.json'),'utf8')),{...JSON.parse(readFileSync(e.cfg,'utf8')),dataRoot:join(R,'data')});
// The notifier config: binding null at 0600.
const nf=join(R,'data','notify','acme','notify.json');
assert.deepEqual(JSON.parse(readFileSync(nf,'utf8')),{notifyVersion:1,binding:null});assert.equal(statSync(nf).mode&0o777,0o600);
// The scratch business: no tracker.* variable, and every reference a 0600 placeholder with its date kept.
const live=JSON.parse(readFileSync(e.biz,'utf8')),sb=JSON.parse(readFileSync(join(R,'config','businesses','acme.json'),'utf8'));
assert.ok(Object.keys(live.vars).some((k)=>k.startsWith('tracker.')));
assert.deepEqual(Object.keys(sb.vars).filter((k)=>k.startsWith('tracker.')),[]);
const refs=[...Object.entries(sb.roles).flatMap(([n,r])=>Object.entries(r.credentials??{}).map(([s,ref])=>[`${n}-${s}`,ref,live.roles[n].credentials[s]])),...Object.entries(sb.tracker.sync.credentials).map(([s,ref])=>[`@sync-${s}`,ref,live.tracker.sync.credentials[s]])];
assert.ok(refs.length>=2);
for(const [name,ref,old] of refs){
const f=join(R,'placeholders',name);
assert.deepEqual(ref,{...old,file:f});assert.equal(readFileSync(f,'utf8'),'rehearsal-placeholder-token');assert.equal(statSync(f).mode&0o777,0o600);
}
assert.deepEqual(readdirSync(join(R,'placeholders')).sort(),refs.map(([n])=>n).sort());
// Everything else in the business is as it was.
const strip=(b)=>{const c=structuredClone(b);for(const k of Object.keys(c.vars))if(k.startsWith('tracker.'))delete c.vars[k];for(const r of Object.values(c.roles))delete r.credentials;delete c.tracker.sync.credentials;return c};
assert.deepEqual(strip(sb),strip(live));
const diff=readFileSync(join(R,'business.diff'),'utf8');assert.match(diff,/^-.*"tracker\.baseUrl"/m);assert.match(diff,/^\+.*placeholders\/pm-/m);
// The host ran with launches disabled and started no PM; the Console got no --send.
assert.deepEqual(lines(rh(e,'host-args.log')),['bus start acme --no-launch']);
assert.deepEqual(lines(rh(e,'console-args.log')),[`serve --port ${port} --business acme`]);
assert.equal(existsSync(rh(e,'starts.log')),false);
assert.equal(existsSync(join(R,'data','bus','writer.lock')),false);
assert.deepEqual(lines(join(R,'steps.log')).map((l)=>l.replace(/^\S+ /,'')),r.out.split('\n').filter((l)=>/^(PASS|FAIL) |^REHEARSAL_/.test(l)));
});
test('R2 rehearse runs the migration checks when the schema differs; the live store stays at 3g',async(t)=>{
const e=await rmk(t),before=untouched(e),port=await freePort();
const r=await rehearse(e,C,'--backup',e.backup,'--port',port,'--timeout','10');
assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(r.names,[...PRE,'PASS new-refuses-old-store','PASS migrate','PASS new-opens-migrated','PASS migrate-again','PASS old-refuses-migrated','PASS gate','PASS no-session',...POST]);
assert.ok(r.out.includes('PASS migrate: 3g to 3h\n'),r.out);
assert.equal(untouched(e),before);assert.equal(storeVersion(e.store,false),'3g');
assert.equal(storeVersion(join(r.dir,'data','bus','bus.sqlite'),false),'3h');
assert.equal(storeVersion(join(r.dir,'refuse','bus','bus.sqlite'),false),'3g');
assert.deepEqual(lines(rh(e,'migrate.log')),[join(r.dir,'data'),join(r.dir,'data')]);
});
test('R3 a failing migration gives REHEARSAL_FAIL migrate and starts nothing',async(t)=>{
const e=await rmk(t),before=untouched(e);rfault(e,'migrate-fails');
const r=await rehearse(e,C,'--backup',e.act,'--port',await freePort(),'--timeout','3');
assert.equal(r.code,1,r.out+r.err);
assert.deepEqual(r.names,[...PRE,'PASS new-refuses-old-store','FAIL migrate','PASS backup-unchanged','PASS live-config-unchanged']);
assert.ok(r.out.includes('FAIL migrate: migrate-schema.mjs exit 1: fixture migrate: failed\n'),r.out);
assert.ok(r.out.endsWith(`REHEARSAL_FAIL\nrehearsal directory: ${r.dir}\n`),r.out);
assert.equal(existsSync(rh(e,'host-args.log')),false);assert.equal(existsSync(rh(e,'console-args.log')),false);
assert.equal(untouched(e),before);
});
test('R4 a host that does not start, does not answer status, reports another pid, launches anyway, makes a launches directory or keeps its lock each fails by name',async(t)=>{
for(const [f,name,why] of [[`host-fails-${B}`,'host-up','exited'],[`status-${B}`,'bus-status','bus status failed'],['status-other-host','bus-status','the host this run started'],['host-launches','no-session','bus-host/sessions.json'],['host-launches-dir','no-session','found launches under'],['host-keeps-lock','host-released-store','writer.lock is still there']]){
const e=await rmk(t),before=untouched(e);rfault(e,f);
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
assert.equal(r.code,1,f+r.out+r.err);
assert.ok(r.names.includes(`FAIL ${name}`),f+r.out);assert.equal(r.names.filter((n)=>n.startsWith('FAIL')).length,1,f+r.out);
assert.ok(r.out.split('\n').some((l)=>l.startsWith(`FAIL ${name}: `)&&l.includes(why)),f+r.out);
assert.ok(r.names.includes('PASS console-stopped')&&r.names.includes('PASS backup-unchanged'),f+r.out);
assert.ok(r.out.endsWith(`REHEARSAL_FAIL\nrehearsal directory: ${r.dir}\n`),r.out);
assert.equal(untouched(e),before);
}
});
test('R5 a host that ignores SIGTERM gets SIGKILL and FAIL host-stopped',async(t)=>{
const e=await rmk(t);rfault(e,'host-ignores-term');
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','2');
assert.equal(r.code,1,r.out+r.err);
assert.ok(/^FAIL host-stopped: pid \d+ was still running 2s after SIGTERM; sent SIGKILL$/m.test(r.out),r.out);
});
test('R6 a Console that does not answer /healthz gives REHEARSAL_FAIL healthz',async(t)=>{
const e=await rmk(t);rfault(e,`healthz-${B}`);
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
assert.equal(r.code,1,r.out+r.err);
assert.ok(r.out.includes('FAIL healthz: no answer within 3s\n'),r.out);
assert.deepEqual(r.names.filter((n)=>n.startsWith('FAIL')),['FAIL healthz']);
});
test('R7 a host on another copy fails copy-match',async(t)=>{
const e=await rmk(t);
const r=await rehearse(e,C1,'--backup',e.act,'--port',await freePort(),'--timeout','3');
assert.equal(r.code,1,r.out+r.err);
assert.ok(r.out.includes(`FAIL copy-match: the host's command line is not ${join(RCOPIES,C1)}'s cli.mjs bus start acme --no-launch\n`),r.out);
});
test('R8 a scratch port in use or reserved refuses, and the rehearsal takes no other port',async(t)=>{
const e=await rmk(t),before=untouched(e);
const held=createServer();held.listen(0,'127.0.0.1');await once(held,'listening');t.after(()=>held.close());
const p=String(held.address().port);
const r=await rehearse(e,B,'--backup',e.act,'--port',p);
assert.equal(r.code,1,r.out+r.err);
assert.equal(r.out,`PASS copy: ${B}, schema 3g\nFAIL port-in-use: 127.0.0.1:${p} is in use; the rehearsal takes no other port\nREHEARSAL_FAIL\n`);
for(const q of ['7332','7333',e.port,TESTER]){
const s=await rehearse(e,B,'--backup',e.act,'--port',q);
assert.equal(s.code,1,s.out+s.err);
assert.equal(s.out,`PASS copy: ${B}, schema 3g\nFAIL port-reserved: --port ${q} is a live Console port (7332, 7333 and the pointer's: ${e.port} ${TESTER})\nREHEARSAL_FAIL\n`);
}
assert.deepEqual(rDirs(e),[]);assert.equal(existsSync(rh(e,'host-args.log')),false);assert.equal(existsSync(rh(e,'console-args.log')),false);
assert.equal(untouched(e),before);
});
test('R9 mos-0963: a role reference left outside placeholders/, or left on an env variable, fails credential-ref-live before the host starts',async(t)=>{
const e=await rmk(t);
// The live pm gitea reference names a file that is there, as a live token would be.
const tok=join(e.root,'live-gitea.token');writeFileSync(tok,'live-token-value',{mode:0o600});
const b=JSON.parse(readFileSync(e.biz,'utf8'));b.roles.pm.credentials.gitea.file=tok;writeFileSync(e.biz,JSON.stringify(b,null,2)+'\n',{mode:0o600});
const before=untouched(e);
ownScript(e,'for(const [name,r] of Object.entries(b.roles??{})){strip(r?.vars);swap(name,r?.credentials)}','for(const [name,r] of Object.entries(b.roles??{})){strip(r?.vars);if(name!=="pm")swap(name,r?.credentials)}');
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
assert.equal(r.code,1,r.out+r.err);
assert.ok(r.out.includes(`FAIL credential-ref-live: pm gitea is ${tok}, not a file in ${join(r.dir,'placeholders')}\n`),r.out);
assert.deepEqual(r.names.slice(-3),['FAIL credential-ref-live','PASS backup-unchanged','PASS live-config-unchanged']);
assert.equal(existsSync(rh(e,'host-args.log')),false);assert.equal(existsSync(rh(e,'console-args.log')),false);
assert.equal(untouched(e),before);
// An env reference names no file at all; the guard says which variable it is.
const f=await rmk(t);
const fb=JSON.parse(readFileSync(f.biz,'utf8'));const {file,...rest}=fb.roles.pm.credentials.gitea;fb.roles.pm.credentials.gitea={...rest,env:'REHEARSAL_GITEA_TOKEN'};writeFileSync(f.biz,JSON.stringify(fb,null,2)+'\n',{mode:0o600});
ownScript(f,'for(const [name,r] of Object.entries(b.roles??{})){strip(r?.vars);swap(name,r?.credentials)}','for(const [name,r] of Object.entries(b.roles??{})){strip(r?.vars);if(name!=="pm")swap(name,r?.credentials)}');
const s=await rehearse(f,B,'--backup',f.act,'--port',await freePort(),'--timeout','3');
assert.equal(s.code,1,s.out+s.err);
assert.ok(s.out.includes('FAIL credential-ref-live: pm gitea is env REHEARSAL_GITEA_TOKEN\n'),s.out);
assert.equal(existsSync(rh(f,'host-args.log')),false);
});
test('R10 mos-0962: a scratch business that still sets a tracker fails tracker-configured',async(t)=>{
const e=await rmk(t);
const proj=join(e.root,'project');mkdirSync(join(proj,'.mosaic'),{recursive:true});
writeFileSync(join(proj,'.mosaic','project.json'),JSON.stringify({projectVersion:1,id:'stack',vars:{'tracker.project':7}}),{mode:0o600});
const b=JSON.parse(readFileSync(e.biz,'utf8'));b.projects.stack.root=proj;writeFileSync(e.biz,JSON.stringify(b,null,2)+'\n',{mode:0o600});
ownScript(e,'if(k.startsWith("tracker."))delete vars[k]','if(k.startsWith("tracker-kept."))delete vars[k]');
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
assert.equal(r.code,1,r.out+r.err);
assert.ok(r.out.includes('FAIL tracker-configured: bootConfig sets trackers for acme\n'),r.out);
assert.equal(existsSync(rh(e,'host-args.log')),false);
});
test('R11 mos-0961: a scratch config on the live data root or on any root but the scratch one, or a sessions.json naming a running pid, refuses by name',async(t)=>{
// mosaic-config already refuses the live path itself, which contains the scratch config directory;
// the guard catches a path that reaches the live data root through a link.
const e=await rmk(t),link=join(e.root,'live-link');symlinkSync(e.data,link);
ownScript(e,'c.dataRoot=data;',`c.dataRoot=${JSON.stringify(link)};`);
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
assert.equal(r.code,1,r.out+r.err);
assert.ok(r.out.includes(`FAIL data-root-live: the scratch config names dataRoot ${link}, which resolves to the live data root ${e.data}\n`),r.out);
assert.equal(existsSync(rh(e,'host-args.log')),false);
// A root that is neither live nor this rehearsal's, and one that is not there.
for(const [dir,made,msg] of [['other-root',true,(d,R)=>`the scratch config resolves to ${d}, not ${join(R,'data')}`],['no-root',false,(d)=>`${d} does not exist`]]){
const g=await rmk(t),d=join(g.root,dir);if(made)mkdirSync(d);
ownScript(g,'c.dataRoot=data;',`c.dataRoot=${JSON.stringify(d)};`);
const q=await rehearse(g,B,'--backup',g.act,'--port',await freePort(),'--timeout','3');
assert.equal(q.code,1,dir+q.out+q.err);
assert.ok(q.out.includes(`FAIL data-root-scratch: ${msg(d,q.dir)}\n`),dir+q.out);
assert.equal(existsSync(rh(g,'host-args.log')),false);
}
const f=await rmk(t);
ownScript(f,`printf '{"notifyVersion":1,"binding":null}\\n' > "$R/data/notify/$BUSINESS/notify.json"`,`printf '{"notifyVersion":1,"binding":null}\\n' > "$R/data/notify/$BUSINESS/notify.json" && mkdir "$R/data/bus-host" && printf '{"sessionsVersion":1,"sessions":[{"pid":%s}]}\\n' "$$" > "$R/data/bus-host/sessions.json"`);
const s=await rehearse(f,B,'--backup',f.act,'--port',await freePort(),'--timeout','3');
assert.equal(s.code,1,s.out+s.err);
assert.ok(/^FAIL sessions-live: \S+\/bus-host\/sessions\.json names running pid\(s\) \d+$/m.test(s.out),s.out);
// The file is left as it was: the rehearsal never deletes it and retries.
assert.ok(existsSync(join(s.dir,'data','bus-host','sessions.json')));
assert.equal(existsSync(rh(f,'host-args.log')),false);
});
test('R12 mos-0962: a notifier config with a binding fails notify-off',async(t)=>{
const e=await rmk(t);
ownScript(e,`printf '{"notifyVersion":1,"binding":null}\\n'`,`printf '{"notifyVersion":1,"binding":"discord"}\\n'`);
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
assert.equal(r.code,1,r.out+r.err);
assert.ok(r.out.includes('FAIL notify-off: '),r.out);
assert.equal(existsSync(rh(e,'host-args.log')),false);
});
test('R13 the pre-checks: an old copy, a backup that fails its sums, a missing backup or pointer, and usage',async(t)=>{
const e=await rmk(t),before=untouched(e),port=await freePort();
const old=await rehearse(e,A,'--backup',e.act,'--port',port);
assert.equal(old.code,1);assert.equal(old.out,`FAIL copy-without-no-launch: ${A}'s bus host has no --no-launch flag\nREHEARSAL_FAIL\n`);
const none=await rehearse(e,B,'--backup','20261020T000000Z-000000000000','--port',port);
assert.equal(none.code,1);assert.ok(none.out.includes(`FAIL backup-missing: ${join(e.data,'backups','stack','20261020T000000Z-000000000000')} has no bus/bus.sqlite and SHA256SUMS\n`),none.out);
const f=await rmk(t);appendFileSync(join(f.backup,'bus','bus.sqlite'),'x');
const bad=await rehearse(f,B,'--backup',f.act,'--port',port);
assert.equal(bad.code,1);assert.ok(bad.out.includes(`FAIL backup-sums: ${f.backup}/bus does not match its SHA256SUMS\n`),bad.out);
const g=await rmk(t);rmSync(g.pointer);
const np=await rehearse(g,B,'--backup',g.act,'--port',port);
assert.equal(np.code,1);assert.match(np.out,/^FAIL no-pointer: /);
for(const args of [['--port',port],['--backup',e.act],['--backup',e.act,'--port','80'],['--backup',e.act,'--port','70000'],['--backup','relative/dir','--port',port],['--backup',e.act,'--port',port,'--pm'],['--backup',e.act,'--port',port,'--plan']]){
const u=await rehearse(e,B,...args);assert.equal(u.code,4,args.join(' ')+u.out+u.err);assert.match(u.err,/^usage: /);
}
assert.deepEqual([...rDirs(e),...rDirs(f),...rDirs(g)],[]);
assert.equal(untouched(e),before);
});
test('R14 a host that writes the live config, the live business file or the backup fails the end check by name',async(t)=>{
for(const [pick,name] of [[(e)=>e.cfg,'live-config-unchanged'],[(e)=>e.biz,'live-config-unchanged'],[(e)=>join(e.backup,'bus','bus.sqlite'),'backup-unchanged']]){
const e=await rmk(t),f=pick(e);rfault(e,'host-touches');writeFileSync(rh(e,'faults','host-touches'),`${f}\n`);
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','10');
assert.equal(r.code,1,f+r.out+r.err);
assert.deepEqual(r.names.filter((n)=>n.startsWith('FAIL')),[`FAIL ${name}`],f+r.out);
assert.ok(r.names.includes('PASS gate')&&r.names.includes('PASS host-stopped'),f+r.out);
assert.ok(r.out.endsWith(`REHEARSAL_FAIL\nrehearsal directory: ${r.dir}\n`),r.out);
}
});
test('R15 a rehearsal stopped by TERM, INT or HUP fails signal and still stops the host and Console and runs the end checks',async(t)=>{
for(const sig of ['SIGTERM','SIGINT','SIGHUP']){
// The Console never answers /healthz, so the gate is still waiting when the signal comes.
const e=await rmk(t);rfault(e,`healthz-${B}`);
e.during=async(ch)=>{for(let i=0;i<200&&!existsSync(rh(e,'console-args.log'));i++)await new Promise((r)=>setTimeout(r,50));ch.kill(sig)};
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','30');
assert.equal(r.code,1,sig+r.out+r.err);
assert.deepEqual(r.names,[...PRE,'PASS migration','FAIL signal','PASS console-stopped','PASS host-stopped','PASS host-released-store','PASS backup-unchanged','PASS live-config-unchanged'],sig+r.out);
assert.ok(r.out.includes('FAIL signal: the rehearsal was interrupted\n'),r.out);
assert.ok(r.out.endsWith(`REHEARSAL_FAIL\nrehearsal directory: ${r.dir}\n`),r.out);
const pid=JSON.parse(readFileSync(join(r.dir,'data','fixture-host.json'),'utf8')).host.pid;
assert.equal(existsSync(`/proc/${pid}`),false,`${sig}: host pid ${pid} is still there`);
}
});
+7 -1
View File
@@ -186,17 +186,22 @@ export function endReason(code, signal) {
// The host's view of its running sessions, kept in memory and mirrored to
// sessions.json for `mosaic stop` and `mosaic launches list`.
// readOnly (row 163, a --no-launch host): sessions.json stays as an earlier host left it, so a
// later host still finds and ends those runs. add() refuses, and nothing writes the file.
export class Registry {
#dataRoot;
#sessions = [];
constructor(dataRoot) {
#readOnly;
constructor(dataRoot, { readOnly = false } = {}) {
this.#dataRoot = dataRoot;
this.#readOnly = readOnly;
this.#write();
}
get sessions() {
return this.#sessions.map((s) => ({ ...s }));
}
add(entry) {
if (this.#readOnly) throw new SeatError("this host keeps no sessions (--no-launch)", 2);
this.#sessions.push({ ...entry });
this.#write();
}
@@ -213,6 +218,7 @@ export class Registry {
return this.#sessions.find((s) => s.business === business && s.instance === instance && (s.thread ?? null) === thread) ?? null;
}
#write() {
if (this.#readOnly) return;
const file = sessionsFile(this.#dataRoot);
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
const tmp = `${file}.${process.pid}.tmp`;
+17
View File
@@ -133,6 +133,23 @@ test("Registry mirrors to sessions.json; readSessions marks live entries; bad fi
assert.throws(() => readSessions(root), (e) => e instanceof SeatError && /malformed/.test(e.message));
});
// Row 163: a --no-launch host's registry leaves an earlier host's sessions.json as it was.
test("a read-only Registry never writes sessions.json, refuses add() and leaves the file on remove()", (t) => {
const root = scratch(t);
new Registry(root).add({ business: "acme", instance: "chief", run: "r1", runnerPid: process.pid, runnerStartTime: startTimeOf(process.pid) });
const before = readFileSync(sessionsFile(root), "utf8");
const r = new Registry(root, { readOnly: true });
assert.equal(readFileSync(sessionsFile(root), "utf8"), before);
assert.deepEqual(r.sessions, []);
assert.throws(() => r.add({ business: "acme", instance: "coder", run: "r2" }), (e) => e instanceof SeatError && /keeps no sessions/.test(e.message));
assert.equal(readFileSync(sessionsFile(root), "utf8"), before);
r.remove("acme", "r1");
assert.equal(readFileSync(sessionsFile(root), "utf8"), before);
const empty = scratch(t);
new Registry(empty, { readOnly: true });
assert.equal(existsSync(sessionsFile(empty)), false);
});
// Row 121 (#1615): a thread instance runs once per thread, so the registry
// keys a thread run by its thread too.
test("REQ-THREAD-1 Registry.running keys a thread run by business, instance and thread", (t) => {
+341 -7
View File
@@ -3,7 +3,7 @@
# Moves the bus, the Console and the human CLI from the pointer's copy to another copy together,
# behind a health gate, and puts the old stack back inside the same run when the gate fails.
# Console tester listener flags follow target support (row 176, #1674); so does the row 178 proof
# pair, which activate sets or drops (row 187, #1685).
# pair, which activate sets or drops (row 187, #1685). activate --no-send drops --send (row 189, #1687).
# Design and tests: agents/darkwing/work/queue-162/BUILD.md.
#
# Who runs it. Decision 96 (Mos, mos-0716) lets this script stop and start the live units under
@@ -15,6 +15,8 @@
# 4. this 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. status and --plan only read, and every seat may run them.
# rehearse (D2, row 163) stops and starts no unit and writes only its own rehearsal directory, but
# it reads the live config and a backup of the live store, so Jason or Mos runs it.
#
# Most functions below run as `f || handler`, and bash ignores set -e inside such a call, so
# every step that can fail carries its own `|| return 1`.
@@ -28,16 +30,17 @@ BIN="$HOME/.npm-global/bin"
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
CONSOLE_UNIT=mosaic-webui.service
TIMEOUT=60 WANT_PM=0 AFTER_HARNESS=0 MIGRATE=0 PLAN=0 RESTORE="" EVENTS="" TESTER_PORT=""
PROOF_HEADER="" PROOF_FILE="" NO_PROOF=0
PROOF_HEADER="" PROOF_FILE="" NO_PROOF=0 NO_SEND=0 R_BACKUP="" R_PORT=""
usage() {
cat >&2 <<'USAGE'
usage: stack-release.sh adopt BUSINESS
stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--migrate] [--tester-port N]
[--proof-header NAME --proof-file PATH | --no-proof] [--timeout S] [--plan]
[--proof-header NAME --proof-file PATH | --no-proof] [--no-send] [--timeout S] [--plan]
stack-release.sh pm-on BUSINESS [--timeout S]
stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan]
stack-release.sh status BUSINESS
stack-release.sh rehearse ID BUSINESS --backup ACTIVATION|PATH --port N [--timeout S]
common options: --copies DIR, --bin DIR
--events N is the total of the per-table counts the post-backup-events refusal prints: events and
every table in COUNTED.
@@ -82,13 +85,17 @@ parse_opts() {
--proof-header) [ "$OP" = activate ] && [ $# -ge 2 ] && [ -n "$2" ] || usage; PROOF_HEADER="$2"; shift 2 ;;
--proof-file) [ "$OP" = activate ] && [ $# -ge 2 ] && [ -n "$2" ] || usage; PROOF_FILE="$2"; shift 2 ;;
--no-proof) [ "$OP" = activate ] || usage; NO_PROOF=1; shift ;;
--no-send) [ "$OP" = activate ] || usage; NO_SEND=1; shift ;;
--migrate) [ "$OP" = activate ] || usage; MIGRATE=1; shift ;;
--restore) [ "$OP" = rollback ] && [ $# -ge 2 ] && [[ "$2" =~ ^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{12}(-[1-9])?$ ]] || usage; RESTORE="$2"; shift 2 ;;
--backup) [ "$OP" = rehearse ] && [ $# -ge 2 ] && { [[ "$2" =~ ^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{12}(-[1-9])?$ ]] || [[ "$2" =~ ^/[A-Za-z0-9/._+-]*$ ]]; } || usage; R_BACKUP="$2"; shift 2 ;;
--port) [ "$OP" = rehearse ] && [ $# -ge 2 ] && [[ "$2" =~ ^[0-9]{1,5}$ ]] && [ "$((10#$2))" -ge 1024 ] && [ "$((10#$2))" -le 65535 ] || usage; R_PORT="$((10#$2))"; shift 2 ;;
--events) [ "$OP" = rollback ] && [ $# -ge 2 ] && [[ "$2" =~ ^(0|[1-9][0-9]*)$ ]] || usage; EVENTS="$2"; shift 2 ;;
*) usage ;;
esac
done
[ -z "$EVENTS" ] || [ -n "$RESTORE" ] || usage
[ "$OP" != rehearse ] || { [ -n "$R_BACKUP" ] && [ -n "$R_PORT" ]; } || usage
# A bad tester port is a usage error; --plan lists it as tester-port-invalid instead (row 180).
[ -z "$TESTER_PORT" ] || [ "$PLAN" = 1 ] || valid_tester_port "$TESTER_PORT" 7332 || usage
# --pm-after-harness replaces --pm (row 169 item 1), so the two never go together.
@@ -238,14 +245,16 @@ target_schema() {
in_chain() { local v; for v in "${T_CHAIN[@]}"; do [ "$v" != "$1" ] || return 0; done; return 1; }
# The item 3 checks on the target copy, in order: copy, schema, already-active. An activate that
# sets a proof pair, or drops the pointer's pair with --no-proof, may name the pointer's copy: it
# re-pins the live copy with new Console flags (row 187, Part B's B3 and B-R1).
# sets a proof pair, drops the pointer's pair with --no-proof, or drops the pointer's --send with
# --no-send, may name the pointer's copy: it re-pins the live copy with new Console flags (row 187,
# Part B's B3 and B-R1; row 189, mos-0972).
check_target() {
copy_exists "$TO" 2>/dev/null || refuse copy-missing "no copy $TO in $COPIES"
copy_ok "$TO" 2>/dev/null || refuse copy-unreachable "$TO is not reachable from refactor and origin/refactor"
target_schema "$TO" || refuse copy-schema-unknown "cannot read SCHEMA_VERSION and its chain from $COPIES/$TO/packages/bus/src/store.mjs"
[ "$TO" != "$P_COPY" ] || [ -n "$PROOF_HEADER" ] ||
{ [ "$NO_PROOF" = 1 ] && [[ " ${P_CONSOLE[*]} " == *" --proof-header "* ]]; } ||
{ [ "$NO_SEND" = 1 ] && [[ " ${P_CONSOLE[*]} " == *" --send "* ]]; } ||
refuse already-active "$TO is the pointer's copy"
}
@@ -684,6 +693,33 @@ console_for_target() {
DROP_TESTER="--tester-port $inherited"
fi
proof_for_target
send_for_target
}
# Row 189 (mos-0972): --no-send drops --send from RUN_CONSOLE, so the unit and the pointer both lose
# it; without the flag the pointer's --send goes on unchanged. Rollback and pm-on never take it, so
# they carry the pointer as it is. Sets SEND_PLAN (the plan's send: line) and SEND_STEP.
send_for_target() {
local had=0 i
local -a without_send=()
for ((i = 0; i < ${#RUN_CONSOLE[@]}; i++)); do
if [ "${RUN_CONSOLE[i]}" = --send ]; then had=1; else without_send+=("${RUN_CONSOLE[i]}"); fi
done
SEND_STEP=""
if [ "$NO_SEND" = 1 ]; then
RUN_CONSOLE=("${without_send[@]}")
if [ "$had" = 1 ]; then
SEND_PLAN="send: dropped; --no-send was given"
SEND_STEP="drop --send; --no-send was given"
else
SEND_PLAN="send: off; --no-send was given and the pointer holds no --send"
SEND_STEP="--no-send was given and the pointer holds no --send, so there is nothing to drop"
fi
elif [ "$had" = 1 ]; then
SEND_PLAN="send: carried"
else
SEND_PLAN="send: off"
fi
}
# Row 187: the row 178 proof pair in RUN_CONSOLE. A new pair replaces the pointer's and goes last;
@@ -937,6 +973,7 @@ plan() {
echo "bus: scripts/bus-service.sh install --copy $TO --copies $COPIES$([ "$RUN_PM" = 0 ] || echo ' --pm') --claude $P_CLAUDE"
[ -z "$DROP_TESTER" ] || echo "drop $DROP_TESTER; the target Console does not support it"
echo "$PROOF_PLAN"
echo "$SEND_PLAN"
echo "Console: scripts/webui-service.sh install --copy $TO --copies $COPIES${RUN_CONSOLE[*]:+ ${RUN_CONSOLE[*]}}"
echo "CLI: scripts/stack-copy.sh wrapper $TO --copies $COPIES --bin $BIN"
local r
@@ -950,6 +987,7 @@ run_steps() {
capture || refuse capture-failed "could not write the rollback set $RS; nothing was stopped"
[ -z "$DROP_TESTER" ] || say "drop $DROP_TESTER; the target Console does not support it"
[ -z "$PROOF_STEP" ] || say "$PROOF_STEP"
[ -z "$SEND_STEP" ] || say "$SEND_STEP"
trap 'after_fail signal' INT TERM HUP
stop_both || rollback_in_run stop-timeout
if [ -e "$STORE" ]; then
@@ -1155,13 +1193,307 @@ do_status() {
fi
}
# --- The rehearsal (D2, row 163) ---
# rehearse runs the new copy's bus host (--no-launch) and Console on a scratch root built from a
# backup, under <dataRoot>/rehearsals/<stamp>/, and gates them as activate does. It never writes the
# live store, the live config files, the backup, the pointer, the log or a unit. Nothing prunes
# the rehearsal directory. Design, rulings (mos-0961, mos-0962, mos-0963) and the deviations from
# the brief: agents/sage/work/queue-163/BUILD.md.
# One PASS or FAIL line per check, on stdout and in steps.log. Lines from before the directory
# exists wait in R_EARLY and go in first.
r_line() {
echo "$*"
if [ -z "$STEPS" ]; then R_EARLY+=("$(now) $*"); else echo "$(now) $*" >> "$STEPS" || true; fi
}
r_pass() { r_line "PASS $1${2:+: $2}"; }
r_fail() { r_line "FAIL $1${2:+: $2}"; R_RC=1; }
r_abort() { r_fail "$@"; r_end; }
r_alive() { local s; s="$(cat "/proc/$1/stat" 2>/dev/null)" || return 1; s="${s##*) }"; [ "${s:0:1}" != Z ]; }
r_gone() {
local deadline=$((SECONDS + TIMEOUT))
while r_alive "$1"; do [ "$SECONDS" -lt "$deadline" ] || return 1; sleep 1; done
}
# Stops the Console, then the host, with SIGTERM within --timeout; one still there gets SIGKILL
# and a FAIL line. The host has to leave the store's writer.lock behind it.
r_stop() {
local name pid
for name in console host; do
if [ "$name" = console ]; then pid="$R_CONSOLE_PID"; R_CONSOLE_PID=""; else pid="$R_HOST_PID"; R_HOST_PID=""; fi
[ -n "$pid" ] || continue
kill -TERM "$pid" 2>/dev/null || true
if r_gone "$pid"; then r_pass "$name-stopped" "pid $pid"
else kill -KILL "$pid" 2>/dev/null || true; r_fail "$name-stopped" "pid $pid was still running ${TIMEOUT}s after SIGTERM; sent SIGKILL"
fi
wait "$pid" 2>/dev/null || true
done
[ -n "$R_STARTED" ] || return 0
R_STARTED=""
if [ -e "$R/data/bus/writer.lock" ]; then r_fail host-released-store "$R/data/bus/writer.lock is still there"; else r_pass host-released-store; fi
}
r_tree_sums() { (cd "$1" && find . -type f -print0 | sort -z | xargs -0 -r sha256sum); }
# The last checks, then the last line. The backup and the live config files are compared with the
# sums taken before the rehearsal directory was made.
r_end() {
trap - INT TERM HUP
r_stop
if [ -n "$R" ]; then
if [ "$(r_tree_sums "$R_BK")" = "$(cat "$R/backup.before")" ]; then r_pass backup-unchanged; else r_fail backup-unchanged "$R_BK differs from $R/backup.before"; fi
if [ "$(sha256sum "$LIVE_CFG" "$LIVE_BIZ" 2>&1)" = "$(cat "$R/live.before")" ]; then r_pass live-config-unchanged; else r_fail live-config-unchanged "$LIVE_CFG or $LIVE_BIZ differs from $R/live.before"; fi
fi
if [ "$R_RC" = 0 ]; then r_line REHEARSAL_PASS; else r_line REHEARSAL_FAIL; fi
[ -z "$R" ] || echo "rehearsal directory: $R"
exit "$R_RC"
}
r_port_free() {
node -e 'const s=require("node:net").createServer();s.once("error",()=>process.exit(1));s.listen(Number(process.argv[1]),"127.0.0.1",()=>s.close(()=>process.exit(0)));' -- "$1"
}
# The scratch config: the live one with dataRoot set to the scratch data root.
R_CONFIG_JS='
const fs=require("node:fs");const [file,data]=process.argv.slice(1);
const c=JSON.parse(fs.readFileSync(file,"utf8"));c.dataRoot=data;process.stdout.write(JSON.stringify(c,null,2)+"\n");'
# The scratch business file (mos-0962, mos-0963): the live file without its tracker.* variables,
# business and role, and with every credential reference, the roles and tracker.sync, moved to its
# own 0600 placeholder file. The service and its rotateBy or expires date stay. The tracker key
# stays because the loader requires it. Mode `normalize` prints the live file as this writes it,
# for the diff.
R_BUSINESS_JS='
const fs=require("node:fs"),path=require("node:path");const [mode,live,out,ph]=process.argv.slice(1);
const b=JSON.parse(fs.readFileSync(live,"utf8"));
if(mode==="normalize"){process.stdout.write(JSON.stringify(b,null,2)+"\n");process.exit(0)}
const DATE={gitea:"rotateBy",vikunja:"expires"};
const strip=(vars)=>{if(vars&&typeof vars==="object")for(const k of Object.keys(vars))if(k.startsWith("tracker."))delete vars[k]};
const swap=(name,refs)=>{
if(!refs||typeof refs!=="object")return;
for(const s of Object.keys(refs)){
const file=path.join(ph,`${name}-${s}`),old=refs[s],ref={file};
fs.writeFileSync(file,"rehearsal-placeholder-token",{mode:0o600,flag:"wx"});
if(old&&typeof old==="object"&&Object.hasOwn(DATE,s)&&old[DATE[s]]!==undefined)ref[DATE[s]]=old[DATE[s]];
refs[s]=ref;
}
};
strip(b.vars);
for(const [name,r] of Object.entries(b.roles??{})){strip(r?.vars);swap(name,r?.credentials)}
swap("@sync",b.tracker?.sync?.credentials);
fs.writeFileSync(out,JSON.stringify(b,null,2)+"\n",{mode:0o600,flag:"wx"});'
# The guard, before the host starts. It reads the scratch config through the new copy's own
# loadSystem and bootConfig, as the host will, and prints one line per check:
# data-root-live the data root is not the live one (mos-0961)
# sessions-live no sessions.json there names a running process (mos-0961)
# data-root-scratch the data root is the scratch one
# notify-off the notifier config is {"notifyVersion":1,"binding":null}, 0600 (mos-0962)
# tracker-configured bootConfig sets no trackers (mos-0962)
# credential-ref-live every credential reference is a file under placeholders/ (mos-0963)
# Exit 0 when all pass, 1 on the first FAIL.
R_GUARD_JS='
const fs=require("node:fs"),path=require("node:path");const {pathToFileURL}=require("node:url");
const [copy,id,live,scratch,ph]=process.argv.slice(1);
const real=(p)=>{try{return fs.realpathSync(p)}catch{return null}};
const alive=(pid)=>{try{const s=fs.readFileSync(`/proc/${pid}/stat`,"utf8");return s[s.lastIndexOf(")")+2]!=="Z"}catch{return false}};
const pass=(n,w)=>console.log(`PASS ${n}${w?": "+w:""}`);
const fail=(n,w)=>{console.log(`FAIL ${n}: ${w}`);process.exit(1)};
(async()=>{
let c,system,config;
try{c=await import(pathToFileURL(`${copy}/packages/cli/src/config.mjs`).href);system=c.loadSystem({env:process.env})}
catch(e){fail("scratch-config",String(e?.message??e).split("\n")[0])}
const root=real(system.dataRoot);
if(root===null)fail("data-root-scratch",`${system.dataRoot} does not exist`);
if(root===real(live))fail("data-root-live",`the scratch config names dataRoot ${system.dataRoot}, which resolves to the live data root ${live}`);
pass("data-root-live",`${system.dataRoot} is not the live data root`);
const sf=path.join(root,"bus-host","sessions.json");
if(fs.existsSync(sf)){
let doc;try{doc=JSON.parse(fs.readFileSync(sf,"utf8"))}catch{fail("sessions-live",`${sf} is unreadable`)}
if(!Array.isArray(doc?.sessions))fail("sessions-live",`${sf} is malformed`);
const running=doc.sessions.flatMap((s)=>[s?.pid,s?.runnerPid]).filter((p)=>Number.isSafeInteger(p)&&p>0&&alive(p));
if(running.length)fail("sessions-live",`${sf} names running pid(s) ${running.join(", ")}`);
}
pass("sessions-live","no running session named in the data root");
if(root!==real(scratch))fail("data-root-scratch",`the scratch config resolves to ${system.dataRoot}, not ${scratch}`);
pass("data-root-scratch");
const nf=path.join(root,"notify",id,"notify.json");
let st,doc;try{st=fs.lstatSync(nf);doc=JSON.parse(fs.readFileSync(nf,"utf8"))}catch{fail("notify-off",`${nf} is missing or not JSON`)}
if(!st.isFile()||(st.mode&0o777)!==0o600||JSON.stringify(doc)!==JSON.stringify({notifyVersion:1,binding:null}))fail("notify-off",`${nf} is not {"notifyVersion":1,"binding":null} at mode 0600`);
pass("notify-off","binding null, so no Discord bridge");
try{config=c.bootConfig({system,businessId:id,env:process.env})}catch(e){fail("boot-config",String(e?.message??e).split("\n")[0])}
if(config.trackers!==undefined)fail("tracker-configured",`bootConfig sets trackers for ${Object.keys(config.trackers).join(", ")}`);
pass("tracker-configured","bootConfig sets no trackers");
const b=config.businesses?.[id],phr=real(ph),refs=[];
for(const [role,r] of Object.entries(b?.roles??{}))for(const [s,ref] of Object.entries(r?.credentials??{}))refs.push([`${role} ${s}`,ref]);
for(const [s,ref] of Object.entries(b?.tracker?.sync?.credentials??{}))refs.push([`@sync ${s}`,ref]);
for(const [what,ref] of refs){
if(ref?.env!==undefined)fail("credential-ref-live",`${what} is env ${ref.env}`);
const f=typeof ref?.file==="string"?real(ref.file):null;
if(f===null||phr===null||path.dirname(f)!==phr)fail("credential-ref-live",`${what} is ${ref?.file}, not a file in ${ph}`);
}
pass("credential-ref-live",`${refs.length} reference(s), each a placeholder`);
})().catch((e)=>fail("guard",String(e?.message??e).split("\n")[0]));'
# Opens a data root's store with a copy's Store, as the bus does at start, with no migration
# (de352dfaf's verify-open.mjs). Prints OPEN_OK <version> or REFUSED <message>.
R_OPEN_JS='
const {pathToFileURL}=require("node:url");const [copy,root]=process.argv.slice(1);
(async()=>{
const {Store}=await import(pathToFileURL(`${copy}/packages/bus/src/store.mjs`).href);
let s;
try{s=new Store(root);process.stdout.write(`OPEN_OK ${s.get("SELECT value FROM meta WHERE key=?","schema_version").value}\n`)}
catch(e){process.stdout.write(`REFUSED ${e.message}\n`)}
finally{s?.close()}
})();'
r_open() { node -e "$R_OPEN_JS" -- "$1" "$2" 2>&1 || true; }
# Every process this rehearsal starts gets only HOME, PATH and the scratch MOSAIC_CONFIG.
r_env() { env -i HOME="$HOME" PATH="$PATH" MOSAIC_CONFIG="$R/config/config.json" "$@"; }
# The de352dfaf checks, when the backup's schema is not the new copy's.
r_migration() {
local copy="$COPIES/$TO" old="$COPIES/$P_COPY" v out rc
v="$(node -e "$STORE_JS" -- version "$R/data/bus/bus.sqlite" 2>&1)" || r_abort store-unreadable "$v"
if [ "$v" = "$T_SCHEMA" ]; then r_pass migration "not needed: the backup is already $v"; return 0; fi
in_chain "$v" || r_abort store-newer "the backup is $v, which $TO's migration chain does not start from"
(umask 077; mkdir "$R/refuse" "$R/refuse/bus" && cp "$R_BK"/bus/* "$R/refuse/bus/") || r_abort scratch-build "cannot copy the backup to $R/refuse"
out="$(r_open "$copy" "$R/refuse")"
[ "$out" = "REFUSED schema-mismatch" ] || r_abort new-refuses-old-store "$TO opened the $v store: $out"
r_pass new-refuses-old-store "$TO refuses the $v store at a normal open"
rc=0; out="$(r_env node "$copy/packages/bus/src/migrate-schema.mjs" "$R/data" 2>&1)" || rc=$?
printf '%s\n' "$out" >> "$R/migrate.log"
[ "$rc" = 0 ] && [ "$(tail -n 1 <<< "$out")" = "bus schema $T_SCHEMA ready" ] || r_abort migrate "migrate-schema.mjs exit $rc: $(tail -n 1 <<< "$out")"
r_pass migrate "$v to $T_SCHEMA"
out="$(r_open "$copy" "$R/data")"
[ "$out" = "OPEN_OK $T_SCHEMA" ] || r_abort new-opens-migrated "$out"
r_pass new-opens-migrated
rc=0; out="$(r_env node "$copy/packages/bus/src/migrate-schema.mjs" "$R/data" 2>&1)" || rc=$?
printf '%s\n' "$out" >> "$R/migrate.log"
[ "$rc" = 0 ] && [ "$(tail -n 1 <<< "$out")" = "bus schema $T_SCHEMA ready" ] || r_abort migrate-again "second migrate-schema.mjs exit $rc: $(tail -n 1 <<< "$out")"
r_pass migrate-again "a second run is a no-op"
out="$(r_open "$old" "$R/data")"
[ "$out" = "REFUSED schema-mismatch" ] || r_abort old-refuses-migrated "the pointer's copy $P_COPY opened the $T_SCHEMA store: $out"
r_pass old-refuses-migrated "$P_COPY refuses the $T_SCHEMA store"
}
# One pass of D1's gate checks but the PM's, against the processes this run started. Sets R_GATE
# to the first failing check and R_WHY to what it saw.
r_gate_once() {
local copy="$COPIES/$TO" out
R_GATE=host-up R_WHY=""
r_alive "$R_HOST_PID" || { R_WHY="the host (pid $R_HOST_PID) exited; see $R/host.log"; return 1; }
grep -q "^bus host up: business $BUSINESS, .*, launches disabled\$" "$R/host.log" || { R_WHY="no \"bus host up ... launches disabled\" line in $R/host.log"; return 1; }
R_GATE=bus-status
out="$(r_env "$copy/scripts/mosaic" bus status --json 2>/dev/null)" || { R_WHY="bus status failed"; return 1; }
R_WHY="$(node -e '
const [b,main,since]=process.argv.slice(2),s=JSON.parse(process.argv[1]);
const why=!s.host?"no host":s.host.business!==b?`host for ${s.host.business}`:s.host.live!==true?"host not live":String(s.host.pid)!==main?`host pid ${s.host.pid} is not ${main}, the host this run started`:!s.boot||s.boot.ok!==true?"boot status not ok":s.boot.business!==b?`boot status for ${s.boot.business}`:!(Date.parse(s.boot.at)>=Number(since))?"boot status is from before this start":"";
process.stdout.write(why);' -- "$out" "$BUSINESS" "$R_HOST_PID" "$START_MS" 2>&1)" || { R_WHY="bus status unreadable"; return 1; }
[ -z "$R_WHY" ] || return 1
R_GATE=console-up
r_alive "$R_CONSOLE_PID" || { R_WHY="the Console (pid $R_CONSOLE_PID) exited; see $R/console.log"; return 1; }
grep -qxF "Mosaic Console: http://127.0.0.1:$R_PORT/" "$R/console.log" || { R_WHY="no \"Mosaic Console: http://127.0.0.1:$R_PORT/\" line in $R/console.log"; return 1; }
R_GATE=healthz
node -e '
fetch(`http://127.0.0.1:${process.argv[1]}/healthz`,{signal:AbortSignal.timeout(5000)}).then((r)=>process.exit(r.status===200?0:1),()=>process.exit(1));' -- "$R_PORT" || return 1
R_GATE=copy-match
tr '\0' ' ' < "/proc/$R_HOST_PID/cmdline" | grep -qF " $copy/packages/cli/src/cli.mjs bus start $BUSINESS --no-launch" || { R_WHY="the host's command line is not $copy's cli.mjs bus start $BUSINESS --no-launch"; return 1; }
tr '\0' ' ' < "/proc/$R_CONSOLE_PID/cmdline" | grep -qF " $copy/packages/webui/src/cli.mjs serve " || { R_WHY="the Console's command line is not $copy's webui cli.mjs serve"; return 1; }
R_GATE=""
}
r_gate() {
local deadline=$((SECONDS + TIMEOUT))
until r_gate_once; do
[ "$SECONDS" -lt "$deadline" ] || { r_fail "$R_GATE" "${R_WHY:-no answer within ${TIMEOUT}s}"; return 1; }
sleep 1
done
r_pass gate "host-up, bus-status, console-up, healthz and copy-match on $TO"
}
# No role session: the host wrote no sessions.json and no launches directory or log.
r_no_session() {
local found=""
[ ! -e "$R/data/bus-host/sessions.json" ] || found+=" bus-host/sessions.json"
[ ! -e "$R/data/launches" ] || found+=" launches"
if [ -z "$found" ]; then r_pass no-session "no sessions.json and no launches under $R/data"; else r_fail no-session "found$found under $R/data"; fi
}
do_rehearse() {
TO="$ID"
trap 'r_fail signal "the rehearsal was interrupted"; r_end' INT TERM HUP
read_pointer || r_abort no-pointer "no $POINTER; the pointer's copy is the old copy the checks use"
[ "$P_BUSINESS" = "$BUSINESS" ] || r_abort business-mismatch "the pointer is for $P_BUSINESS"
copy_exists "$TO" 2>/dev/null || r_abort copy-missing "no copy $TO in $COPIES"
copy_ok "$TO" 2>/dev/null || r_abort copy-unreachable "$TO is not reachable from refactor and origin/refactor"
target_schema "$TO" || r_abort copy-schema-unknown "cannot read SCHEMA_VERSION and its chain from $COPIES/$TO/packages/bus/src/store.mjs"
# mos-0961: an older copy's host has no --no-launch and would start the PM on its own.
grep -qF -- '"--no-launch"' "$COPIES/$TO/packages/cli/src/cli.mjs" 2>/dev/null || r_abort copy-without-no-launch "$TO's bus host has no --no-launch flag"
r_pass copy "$TO, schema $T_SCHEMA"
local i
local -a reserved=(7332 7333 "$(console_port)")
for ((i = 0; i < ${#P_CONSOLE[@]}; i++)); do [ "${P_CONSOLE[i]}" != --tester-port ] || reserved+=("${P_CONSOLE[i + 1]:-}"); done
for i in "${reserved[@]}"; do [ "$((10#$R_PORT))" != "$i" ] || r_abort port-reserved "--port $R_PORT is a live Console port (7332, 7333 and the pointer's: ${reserved[*]:2})"; done
r_port_free "$R_PORT" || r_abort port-in-use "127.0.0.1:$R_PORT is in use; the rehearsal takes no other port"
r_pass port "127.0.0.1:$R_PORT is free"
case "$R_BACKUP" in /*) R_BK="$R_BACKUP" ;; *) R_BK="$DATA/backups/stack/$R_BACKUP" ;; esac
[ -f "$R_BK/bus/bus.sqlite" ] && [ -f "$R_BK/SHA256SUMS" ] || r_abort backup-missing "$R_BK has no bus/bus.sqlite and SHA256SUMS"
(cd "$R_BK/bus" && sha256sum -c --quiet ../SHA256SUMS) >/dev/null 2>&1 || r_abort backup-sums "$R_BK/bus does not match its SHA256SUMS"
r_pass backup "$R_BK matches its SHA256SUMS"
LIVE_CFG="$(realpath -m "${MOSAIC_CONFIG:-$HOME/.config/mosaic-dev/config.json}")"
LIVE_BIZ="$(dirname "$LIVE_CFG")/businesses/$BUSINESS.json"
[ -f "$LIVE_BIZ" ] || r_abort business-missing "no $LIVE_BIZ"
local stamp before live
before="$(r_tree_sums "$R_BK")" && live="$(sha256sum "$LIVE_CFG" "$LIVE_BIZ" 2>&1)" || r_abort sums "cannot read $R_BK or the live config files"
stamp="$(date -u +%Y%m%dT%H%M%SZ)-${TO:0:12}"
(umask 077; mkdir -p "$DATA/rehearsals" && mkdir "$DATA/rehearsals/$stamp") || r_abort rehearsal-dir "cannot make $DATA/rehearsals/$stamp (one rehearsal per second and copy)"
R="$DATA/rehearsals/$stamp" STEPS="$DATA/rehearsals/$stamp/steps.log"
printf '%s\n' "${R_EARLY[@]}" > "$STEPS" && printf '%s\n' "$before" > "$R/backup.before" && printf '%s\n' "$live" > "$R/live.before" ||
r_abort scratch-build "cannot write the sums to $R"
(
umask 077
mkdir "$R/config" "$R/config/businesses" "$R/placeholders" "$R/data" "$R/data/bus" "$R/data/notify" "$R/data/notify/$BUSINESS" &&
cp "$R_BK"/bus/* "$R/data/bus/" &&
node -e "$R_CONFIG_JS" -- "$LIVE_CFG" "$R/data" > "$R/config/config.json" &&
node -e "$R_BUSINESS_JS" -- write "$LIVE_BIZ" "$R/config/businesses/$BUSINESS.json" "$R/placeholders" &&
printf '{"notifyVersion":1,"binding":null}\n' > "$R/data/notify/$BUSINESS/notify.json"
) 2>> "$STEPS" || r_abort scratch-build "cannot build the scratch root under $R; see $STEPS"
local rc=0
diff -u --label "$LIVE_BIZ (as JSON.stringify writes it)" --label "$R/config/businesses/$BUSINESS.json" \
<(node -e "$R_BUSINESS_JS" -- normalize "$LIVE_BIZ") "$R/config/businesses/$BUSINESS.json" > "$R/business.diff" || rc=$?
[ "$rc" -le 1 ] || r_abort scratch-build "cannot write $R/business.diff"
r_pass scratch-root "$R/data from the backup, config and business file under $R/config, business.diff beside them"
local out
rc=0; out="$(r_env node -e "$R_GUARD_JS" -- "$COPIES/$TO" "$BUSINESS" "$DATA" "$R/data" "$R/placeholders" 2>&1)" || rc=$?
while IFS= read -r i; do r_line "$i"; done <<< "$out"
[ "$rc" = 0 ] || { R_RC=1; r_end; }
r_migration
START_MS="$(date +%s%3N)"
R_STARTED=1
# env, not r_env: a function in the background is a subshell, and $! has to be the process
# itself (env execs scripts/mosaic, which execs node), for the bus-status pid check and the stop.
env -i HOME="$HOME" PATH="$PATH" MOSAIC_CONFIG="$R/config/config.json" "$COPIES/$TO/scripts/mosaic" bus start "$BUSINESS" --no-launch > "$R/host.log" 2>&1 &
R_HOST_PID=$!
env -i HOME="$HOME" PATH="$PATH" MOSAIC_CONFIG="$R/config/config.json" node "$COPIES/$TO/packages/webui/src/cli.mjs" serve --port "$R_PORT" --business "$BUSINESS" > "$R/console.log" 2>&1 &
R_CONSOLE_PID=$!
if r_gate; then r_no_session; fi
r_end
}
# --- Dispatch ---
[ $# -gt 0 ] || usage
OP="$1"; shift
ID=""
case "$OP" in
activate) [ $# -ge 2 ] || usage; ID="$1"; BUSINESS="$2"; shift 2; [[ "$ID" =~ ^[0-9a-f]{40}$ ]] || usage ;;
activate|rehearse) [ $# -ge 2 ] || usage; ID="$1"; BUSINESS="$2"; shift 2; [[ "$ID" =~ ^[0-9a-f]{40}$ ]] || usage ;;
adopt|pm-on|rollback|status) [ $# -ge 1 ] || usage; BUSINESS="$1"; shift ;;
-h|--help) usage 0 ;;
*) usage ;;
@@ -1178,11 +1510,13 @@ STORE="$DATA/bus/bus.sqlite"
BUS_UNIT="mosaic-bus@$BUSINESS.service"
FROM="" TO="" ACT="" RS="" POST="" BACKUP="" TAKEN="" RESTORED="" STEPS="" COUNT="" UNCLEAN="" FAIL=""
STORE_SCHEMA="" START_EPOCH="" START_MS=0 GATE="" GATE_WHY="" S_CHANGED=""
P_COPY="" P_PM=0 P_PENDING=0 P_SCHEMA="" P_CLAUDE="" P_CONSOLE=() RUN_PM=0 RUN_PENDING=0 RUN_CONSOLE=() DROP_TESTER="" PROOF_PLAN="" PROOF_STEP="" PLAN_REFUSE=()
P_COPY="" P_PM=0 P_PENDING=0 P_SCHEMA="" P_CLAUDE="" P_CONSOLE=() RUN_PM=0 RUN_PENDING=0 RUN_CONSOLE=() DROP_TESTER="" PROOF_PLAN="" PROOF_STEP="" SEND_PLAN="" SEND_STEP="" PLAN_REFUSE=()
R="" R_RC=0 R_EARLY=() R_BK="" R_HOST_PID="" R_CONSOLE_PID="" R_STARTED="" LIVE_CFG="" LIVE_BIZ=""
case "$OP" in
adopt) do_adopt ;;
activate) do_activate ;;
pm-on) do_pm_on ;;
rollback) do_rollback ;;
status) do_status ;;
rehearse) do_rehearse ;;
esac