diff --git a/agents/sage/work/queue-163/candidate-manifest.sha256 b/agents/sage/work/queue-163/candidate-manifest.sha256 new file mode 100644 index 000000000..f20cf8878 --- /dev/null +++ b/agents/sage/work/queue-163/candidate-manifest.sha256 @@ -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 diff --git a/agents/sage/work/queue-163/candidate.diff b/agents/sage/work/queue-163/candidate.diff new file mode 100644 index 000000000..6486b28f3 --- /dev/null +++ b/agents/sage/work/queue-163/candidate.diff @@ -0,0 +1,1196 @@ +diff --git a/agents/sage/work/queue-163/BUILD.md b/agents/sage/work/queue-163/BUILD.md +new file mode 100644 +index 000000000..84270fa3d +--- /dev/null ++++ b/agents/sage/work/queue-163/BUILD.md +@@ -0,0 +1,119 @@ ++# 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 ++`/rehearsals/-/` (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 ++ `/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 `/rehearsals//`; ++- 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=`; ++- 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 ++ `/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. +diff --git a/docs/TOOLS.md b/docs/TOOLS.md +index ba59079d9..f2892ca04 100644 +--- a/docs/TOOLS.md ++++ b/docs/TOOLS.md +@@ -528,6 +528,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 `/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 ++`/rehearsals/-/`, 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 +diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs +index 37b11ea0d..10ba53e95 100644 +--- a/packages/cli/src/cli.mjs ++++ b/packages/cli/src/cli.mjs +@@ -57,7 +57,7 @@ export const USAGE = `usage: + mosaic stop + mosaic launches off|on [--business ] + mosaic launches list [--json] +- mosaic bus start [--pm] ++ mosaic bus start [--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`); +diff --git a/packages/cli/src/launcher.mjs b/packages/cli/src/launcher.mjs +index fb0f9f647..5bd36a86d 100644 +--- a/packages/cli/src/launcher.mjs ++++ b/packages/cli/src/launcher.mjs +@@ -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 +diff --git a/packages/cli/tests/host.test.mjs b/packages/cli/tests/host.test.mjs +index 63dab06c3..3be5f1fa8 100644 +--- a/packages/cli/tests/host.test.mjs ++++ b/packages/cli/tests/host.test.mjs +@@ -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); +diff --git a/packages/cli/tests/launcher.test.mjs b/packages/cli/tests/launcher.test.mjs +index 06f4abe49..bd8b6ea18 100644 +--- a/packages/cli/tests/launcher.test.mjs ++++ b/packages/cli/tests/launcher.test.mjs +@@ -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); +diff --git a/packages/cli/tests/stack-release.test.mjs b/packages/cli/tests/stack-release.test.mjs +index b5f9c2119..67a96cefa 100644 +--- a/packages/cli/tests/stack-release.test.mjs ++++ b/packages/cli/tests/stack-release.test.mjs +@@ -1705,3 +1705,335 @@ test('T54 row 188: a carried pair gets the proof file checks, and a proof file i + await noLeak(e); + }); + }); ++ ++// --- 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(!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- 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}); ++ 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 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-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/ 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); ++}); ++ ++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 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); ++ 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); ++}); +diff --git a/packages/seat/src/session.mjs b/packages/seat/src/session.mjs +index fa3e8ae77..d844c79b4 100644 +--- a/packages/seat/src/session.mjs ++++ b/packages/seat/src/session.mjs +@@ -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, and add() refuses. + export class Registry { + #dataRoot; + #sessions = []; +- constructor(dataRoot) { ++ #readOnly; ++ constructor(dataRoot, { readOnly = false } = {}) { + this.#dataRoot = dataRoot; +- this.#write(); ++ this.#readOnly = readOnly; ++ if (!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(); + } +diff --git a/packages/seat/tests/session.test.mjs b/packages/seat/tests/session.test.mjs +index 23d288c2b..36c99463c 100644 +--- a/packages/seat/tests/session.test.mjs ++++ b/packages/seat/tests/session.test.mjs +@@ -133,6 +133,21 @@ 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 and refuses add()", (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); ++ 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) => { +diff --git a/scripts/stack-release.sh b/scripts/stack-release.sh +index 2586a2a37..0053f35d1 100755 +--- a/scripts/stack-release.sh ++++ b/scripts/stack-release.sh +@@ -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,7 +30,7 @@ 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 R_BACKUP="" R_PORT="" + + usage() { + cat >&2 <<'USAGE' +@@ -38,6 +40,7 @@ usage: stack-release.sh adopt BUSINESS + 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. +@@ -84,11 +87,14 @@ parse_opts() { + --no-proof) [ "$OP" = activate ] || usage; NO_PROOF=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. +@@ -1155,13 +1161,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 /rehearsals//, 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 or REFUSED . ++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 ;; +@@ -1179,10 +1479,12 @@ 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=() ++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 diff --git a/agents/sage/work/queue-163/gate.out b/agents/sage/work/queue-163/gate.out new file mode 100644 index 000000000..5e05c2799 --- /dev/null +++ b/agents/sage/work/queue-163/gate.out @@ -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 diff --git a/agents/sage/work/queue-163/mutants-rerun.log b/agents/sage/work/queue-163/mutants-rerun.log new file mode 100644 index 000000000..50aa3c0ba --- /dev/null +++ b/agents/sage/work/queue-163/mutants-rerun.log @@ -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 diff --git a/agents/sage/work/queue-163/mutants-run.log b/agents/sage/work/queue-163/mutants-run.log new file mode 100644 index 000000000..6d70c7aa9 --- /dev/null +++ b/agents/sage/work/queue-163/mutants-run.log @@ -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 diff --git a/agents/sage/work/queue-163/mutants.py b/agents/sage/work/queue-163/mutants.py new file mode 100644 index 000000000..7a24661a2 --- /dev/null +++ b/agents/sage/work/queue-163/mutants.py @@ -0,0 +1,70 @@ +#!/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 = '/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), +] +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')