johns-harness 2026.9.27 → 2026.9.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026.9.28
4
+
5
+ - Family 76: `johnness gateway restart` from inside a gateway's own launchd process group no longer unregisters its supervisor. The old `bootout -> wait -> bootstrap` sequence killed the caller before it could bootstrap, which caused a multi-hour outage when a cron issued the restart. All three service implementations, both internal restart bundles and both updater bundles now share `dist/launchd-lifecycle.js`: a loaded service restarts with `kickstart -k` only (no bootout, unload or bootstrap fallback), a missing service may bootstrap its explicitly selected plist, and real plist replacement (`gateway install --force`) stages validated bytes and hands off to an independently registered, durably armed completer with an fsynced phase journal, retry under launchd, and prior-plist restore after five failed bootstraps. Explicit stop/uninstall cancels any pending completer. Updaters snapshot the helper before npm replaces the installed tree. Success requires a changed launchd-owned PID and, when a port is declared, `/healthz` plus listener ownership by that PID; receipts live in `<stateDir>/lifecycle/<uuid>/`. Ten browser timeout messages now suggest a non-browser alternative instead of a whole-gateway restart. Covered by lifecycle unit, fault-injection, replay and packed-artifact tests plus five real disposable LaunchAgent self-restart/reload cases (`JOHNNESS_TEST_LAUNCHD=1`); verifier check 76.1.
6
+ - Family 75: explicit `johnness migrate --cutover --yes` and forward-only `--resume` activate verified offline copies with a durable fsynced service journal. A detached launchd completer registers a gated native supervisor before disabling or stopping the exact selected legacy job. Gateway runtime imports remain gated until the old process exits; no raw PID killing, legacy service restoration, old-state deletion or old-plist deletion is performed.
7
+ - Success requires launchd running, matching selected state/config/port, a unique listener owned by the supervised PID, no duplicate selected-state writer and HTTP health. Failed health gates keep native KeepAlive and the completer loaded, retain the journal, return nonzero and alert independently via direct Telegram HTTPS and/or macOS notification. Every CLI entrypoint reports incomplete selected-state migrations; completed instances warn about conflicting CLI port/config overrides.
8
+ - Default migration preparation and every dry-run remain nonactivating. Live `--force` snapshots cannot activate. Service recovery is `johnness migrate --resume --to <selected-state>`, never a rollback to the legacy system. See `docs/MIGRATION-CUTOVER.md` for the explicit offline-copy and macOS ownership boundary.
9
+ - Add 40 regression tests, including 25 actual SIGKILL boundaries with isolated service adapters, real gated shell exec, real loopback listener ownership/fleet isolation, CLI preparation/cutover/resume/verification, latency and timeout alerts. The source and installed verifier now run 468 checks. Package and lockfile release versions are aligned.
10
+
3
11
  ## 2026.9.27
4
12
 
5
13
  - Family 74: the local active-session pressure guard compacts on exactly one trigger, estimated active-context tokens against the active model's declared context window (`ceil(0.7 x window)`, floored at `max(compaction.reserveTokensFloor, 50000)`; undeclared windows fall back to the runtime default, `DEFAULT_CONTEXT_TOKENS`). The fixed 1 MB transcript-bytes, 220-message and 3 GiB heap backstops are removed as triggers, so a session with 221 small messages and 528 KB no longer compacts early while a large context still compacts on real token pressure. The 5-minute cooldown, the 24-message floor and the failed-attempt growth gate remain as anti-thrash throttles, and the emergency compaction path is untouched. `contextRatio`, `minIntervalMs` and `minMessagesAfterCompaction` are the config surface; legacy `transcriptBytes`/`messageCount`/`heapUsedBytes` keys are accepted and ignored.
package/README.md CHANGED
@@ -15,10 +15,10 @@ Bring your agents, memory, credentials, and scheduled jobs into John's Harness.
15
15
  ```sh
16
16
  npm i -g johns-harness && johnness migrate
17
17
  ```
18
- The command backs up your state, creates `~/.johnness`, rewrites settings, and checks the result.
19
- If your gateway is running, it shows the exact stop command before making changes.
20
- Review any script updates, then follow the printed commands to start the new gateway.
21
- Your original installation stays intact. Preview with `johnness migrate --dry-run`; undo with `johnness migrate --rollback`.
18
+ Preparation backs up offline state, creates `~/.johnness`, rewrites settings, and verifies the result. It does not change services; `--force` is a nonactivating live-snapshot rehearsal only.
19
+ On macOS, review attention items, then use `johnness migrate --cutover --to ~/.johnness --yes`. The detached completer registers a gated native LaunchAgent before stopping the exact selected old job, then requires supervisor, listener PID and HTTP health agreement.
20
+ Interrupted cutovers resume forward with `johnness migrate --resume --to ~/.johnness`. Every CLI entrypoint reports an incomplete journal. No service cutover rolls back or deletes the old plist/state.
21
+ Preview without writes using `--dry-run`. See [migration cutover](docs/MIGRATION-CUTOVER.md) for ownership requirements, limitations and recovery.
22
22
 
23
23
  ## Quick start
24
24
 
@@ -85,8 +85,8 @@ node johnness.mjs --help
85
85
  ### Verify
86
86
 
87
87
  ```bash
88
- npm test # 915 tests against the installed dependency tree
89
- bash scripts/verify-patches.sh # 459 checks across every patch family
88
+ npm test # 990 tests against the installed dependency tree
89
+ bash scripts/verify-patches.sh # 468 checks across every patch family
90
90
  ```
91
91
 
92
92
  ## Launch a swarm with one command
@@ -132,7 +132,7 @@ Coding agents today run as isolated processes. Two agents on the same repository
132
132
  - **Memory built for production.** Tiered recall that traces from consolidated reflections down to the verbatim record, surviving compaction and shared across the swarm. Installed and enabled by default.
133
133
  - **Production authority per commit.** Promotion is granted to an exact SHA, never to a branch name, so one worker's authorization never covers another worker's commits.
134
134
  - **An append-only action log.** Agents record every consequential action to `ACTION_LOG.md` and read it before acting.
135
- - **A fixed runtime surface.** The core does not grow on a schedule. New mechanisms enter only to solve a demonstrated production failure, one narrow, tested, reversible patch at a time: 74 patch families, each with a regression test and a rollback path.
135
+ - **A fixed runtime surface.** The core does not grow on a schedule. New mechanisms enter only to solve a demonstrated production failure, one narrow, tested, reversible patch at a time: 76 patch families, each with a regression test and an explicit recovery path.
136
136
  - **Reliability with a definition.** Bounded retries, explicit failure states, exact-once continuation, context-pressure recovery, durable delivery, and supervised gateway recovery.
137
137
  - **Coding first, phone optional.** Direct the work at a high level from a chat channel while agents plan, implement, test, coordinate, and stage releases unattended.
138
138
  - **Local voice.** Optional native Whisper transcription of inbound audio, entirely on your machine. Off by default. [Setup](docs/NATIVE-WHISPER-MUSE.md).
@@ -199,7 +199,7 @@ The log is a [`RULES.md`](RULES.md) workspace convention enforced through the ac
199
199
 
200
200
  ## Patch families
201
201
 
202
- The runtime carries 74 patch families plus the 65.1 safe-update amendment. Each is a production fix applied at the source with a regression test, a durable patch marker, and a rollback path. A verifier runs 459 checks against the package tree and installed dependencies, and 915 tests run against the installed dependency tree.
202
+ The runtime carries 76 patch families plus the 65.1 safe-update amendment. Each is a production fix applied at the source with a regression test, a durable patch marker, and an explicit recovery path. A verifier runs 468 checks against the package tree and installed dependencies, and 990 tests run against the installed dependency tree.
203
203
 
204
204
  Every family follows the same six steps: reproduce the failure, trace the exact runtime path, make the smallest source-level change that restores the invariant, add a regression test and a patch marker, run the verifier, and retain rollback artifacts. The full index: [`docs/PATCHES.md`](docs/PATCHES.md).
205
205
 
@@ -218,7 +218,7 @@ Every family follows the same six steps: reproduce the failure, trace the exact
218
218
  ## Documentation
219
219
 
220
220
  - [`docs/index.md`](docs/index.md): runtime docs, from gateway and channels to plugins, nodes, and CLI reference.
221
- - [`docs/PATCHES.md`](docs/PATCHES.md): the 74 patch families, one record each.
221
+ - [`docs/PATCHES.md`](docs/PATCHES.md): the 76 patch families, one record each.
222
222
  - [`docs/NATIVE-WHISPER-MUSE.md`](docs/NATIVE-WHISPER-MUSE.md): local Whisper transcription setup.
223
223
  - [`RULES.md`](RULES.md): the default operating rules, including Rule Zero on production authorization.
224
224
 
@@ -43943,7 +43943,8 @@ function withLoopbackBrowserAuth(url, init) {
43943
43943
  }
43944
43944
  const BROWSER_TOOL_MODEL_HINT = "Do NOT retry the browser tool — it will keep failing. Use an alternative approach or inform the user that the browser is currently unavailable.";
43945
43945
  function resolveBrowserFetchOperatorHint(url) {
43946
- return !isAbsoluteHttp(url) ? `Restart the Johnness gateway (Johnness.app menubar, or \`${formatCliCommand("johnness gateway")}\`).` : "If this is a sandboxed session, ensure the sandbox browser is running.";
43946
+ // JOHNNESS_PATCH_LAUNCHD_SAFE_RESTART_76: browser failures are not gateway failures.
43947
+ return !isAbsoluteHttp(url) ? "The browser control operation failed. Report the browser blocker or use a non-browser alternative. Do not restart the gateway for a browser timeout." : "If this is a sandboxed session, ensure the sandbox browser is running.";
43947
43948
  }
43948
43949
  function normalizeErrorMessage(err) {
43949
43950
  if (err instanceof Error && err.message.trim().length > 0) return err.message.trim();
@@ -1,3 +1,4 @@
1
+ import { requestLaunchdOperation, waitForLaunchdOperation, cancelLaunchdOperations, lifecycleIdentity, writeLaunchdPlist, prepareLaunchdRestart, runPreparedLaunchdRestart } from "./launchd-lifecycle.js"; // JOHNNESS_PATCH_LAUNCHD_SAFE_RESTART_76
1
2
  // JOHNNESS_PATCH_CATALOG_REFRESH_65
2
3
  // JOHNNESS_PATCH_NATIVE_STATE_MIGRATION_59
3
4
  import { createRequire } from "node:module";
@@ -17364,6 +17365,7 @@ async function readLaunchAgentRuntime(env) {
17364
17365
  };
17365
17366
  }
17366
17367
  async function uninstallLaunchAgent({ env, stdout }) {
17368
+ cancelLaunchdOperations({ domain: resolveGuiDomain(), label: resolveLaunchAgentLabel({ env }), stateDir: resolveGatewayStateDir(env) });
17367
17369
  const domain = resolveGuiDomain();
17368
17370
  const label = resolveLaunchAgentLabel({ env });
17369
17371
  const plistPath = resolveLaunchAgentPlistPath(env);
@@ -17420,6 +17422,7 @@ async function waitForPidExit(pid) {
17420
17422
  }
17421
17423
  }
17422
17424
  async function stopLaunchAgent({ stdout, env }) {
17425
+ cancelLaunchdOperations({ domain: resolveGuiDomain(), label: resolveLaunchAgentLabel({ env }), stateDir: resolveGatewayStateDir(env) });
17423
17426
  const domain = resolveGuiDomain();
17424
17427
  const label = resolveLaunchAgentLabel({ env });
17425
17428
  const res = await execLaunchctl(["bootout", `${domain}/${label}`]);
@@ -17431,18 +17434,6 @@ async function installLaunchAgent({ env, stdout, programArguments, workingDirect
17431
17434
  await fs.mkdir(logDir, { recursive: true });
17432
17435
  const domain = resolveGuiDomain();
17433
17436
  const label = resolveLaunchAgentLabel({ env });
17434
- for (const legacyLabel of resolveLegacyGatewayLaunchAgentLabels(env.JOHNNESS_PROFILE)) {
17435
- const legacyPlistPath = resolveLaunchAgentPlistPathForLabel(env, legacyLabel);
17436
- await execLaunchctl([
17437
- "bootout",
17438
- domain,
17439
- legacyPlistPath
17440
- ]);
17441
- await execLaunchctl(["unload", legacyPlistPath]);
17442
- try {
17443
- await fs.unlink(legacyPlistPath);
17444
- } catch {}
17445
- }
17446
17437
  const plistPath = resolveLaunchAgentPlistPathForLabel(env, label);
17447
17438
  await fs.mkdir(path.dirname(plistPath), { recursive: true });
17448
17439
  const plist = buildLaunchAgentPlist({
@@ -17458,35 +17449,8 @@ async function installLaunchAgent({ env, stdout, programArguments, workingDirect
17458
17449
  stderrPath,
17459
17450
  environment
17460
17451
  });
17461
- await fs.writeFile(plistPath, plist, "utf8");
17462
- await execLaunchctl([
17463
- "bootout",
17464
- domain,
17465
- plistPath
17466
- ]);
17467
- await execLaunchctl(["unload", plistPath]);
17468
- await execLaunchctl(["enable", `${domain}/${label}`]);
17469
- const boot = await execLaunchctl([
17470
- "bootstrap",
17471
- domain,
17472
- plistPath
17473
- ]);
17474
- if (boot.code !== 0) {
17475
- const detail = (boot.stderr || boot.stdout).trim();
17476
- if (isUnsupportedGuiDomain(detail)) throw new Error([
17477
- `launchctl bootstrap failed: ${detail}`,
17478
- `LaunchAgent install requires a logged-in macOS GUI session for this user (${domain}).`,
17479
- "This usually means you are running from SSH/headless context or as the wrong user (including sudo).",
17480
- "Fix: sign in to the macOS desktop as the target user and rerun `johnness gateway install --force`.",
17481
- "Headless deployments should use a dedicated logged-in user session or a custom LaunchDaemon (not shipped): https://docs.openclaw.ai/gateway"
17482
- ].join("\n"));
17483
- throw new Error(`launchctl bootstrap failed: ${detail}`);
17484
- }
17485
- await execLaunchctl([
17486
- "kickstart",
17487
- "-k",
17488
- `${domain}/${label}`
17489
- ]);
17452
+ const receipt = requestLaunchdOperation({ domain, label, plistPath, stateDir: resolveGatewayStateDir(env), mode: "reload", plist });
17453
+ await waitForLaunchdOperation(receipt);
17490
17454
  writeFormattedLines(stdout, [{
17491
17455
  label: "Installed LaunchAgent",
17492
17456
  value: plistPath
@@ -17498,41 +17462,13 @@ async function installLaunchAgent({ env, stdout, programArguments, workingDirect
17498
17462
  }
17499
17463
  async function restartLaunchAgent({ stdout, env }) {
17500
17464
  const serviceEnv = env ?? process.env;
17501
- const domain = resolveGuiDomain();
17502
- const label = resolveLaunchAgentLabel({ env: serviceEnv });
17503
- const plistPath = resolveLaunchAgentPlistPath(serviceEnv);
17504
- const runtime = await execLaunchctl(["print", `${domain}/${label}`]);
17505
- const previousPid = runtime.code === 0 ? parseLaunchctlPrint(runtime.stdout || runtime.stderr || "").pid : void 0;
17506
- const stop = await execLaunchctl(["bootout", `${domain}/${label}`]);
17507
- if (stop.code !== 0 && !isLaunchctlNotLoaded(stop)) throw new Error(`launchctl bootout failed: ${stop.stderr || stop.stdout}`.trim());
17508
- if (typeof previousPid === "number") await waitForPidExit(previousPid);
17509
- const boot = await execLaunchctl([
17510
- "bootstrap",
17511
- domain,
17512
- plistPath
17513
- ]);
17514
- if (boot.code !== 0) {
17515
- const detail = (boot.stderr || boot.stdout).trim();
17516
- if (isUnsupportedGuiDomain(detail)) throw new Error([
17517
- `launchctl bootstrap failed: ${detail}`,
17518
- `LaunchAgent restart requires a logged-in macOS GUI session for this user (${domain}).`,
17519
- "This usually means you are running from SSH/headless context or as the wrong user (including sudo).",
17520
- "Fix: sign in to the macOS desktop as the target user and rerun `johnness gateway restart`.",
17521
- "Headless deployments should use a dedicated logged-in user session or a custom LaunchDaemon (not shipped): https://docs.openclaw.ai/gateway"
17522
- ].join("\n"));
17523
- throw new Error(`launchctl bootstrap failed: ${detail}`);
17524
- }
17525
- const start = await execLaunchctl([
17526
- "kickstart",
17527
- "-k",
17528
- `${domain}/${label}`
17529
- ]);
17530
- if (start.code !== 0) throw new Error(`launchctl kickstart failed: ${start.stderr || start.stdout}`.trim());
17531
- try {
17532
- stdout.write(`${formatLine("Restarted LaunchAgent", `${domain}/${label}`)}\n`);
17533
- } catch (err) {
17534
- if (err?.code !== "EPIPE") throw err;
17535
- }
17465
+ const receipt = requestLaunchdOperation({
17466
+ domain: resolveGuiDomain(), label: resolveLaunchAgentLabel({ env: serviceEnv }),
17467
+ plistPath: resolveLaunchAgentPlistPath(serviceEnv), stateDir: resolveGatewayStateDir(serviceEnv), mode: "restart"
17468
+ });
17469
+ try { stdout.write(`LaunchAgent restart requested; durable receipt: ${receipt}\n`); } catch (err) { if (err?.code !== "EPIPE") throw err; }
17470
+ await waitForLaunchdOperation(receipt);
17471
+ try { stdout.write(`${formatLine("Restarted LaunchAgent", resolveLaunchAgentLabel({ env: serviceEnv }))}\n`); } catch (err) { if (err?.code !== "EPIPE") throw err; }
17536
17472
  }
17537
17473
  //#endregion
17538
17474
  //#region src/daemon/arg-split.ts
@@ -0,0 +1,281 @@
1
+ // JOHNNESS_PATCH_LAUNCHD_SAFE_RESTART_76
2
+ // This package is dist-first. Copied verbatim into each independent launchd worker.
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import os from "node:os";
6
+ import { randomUUID } from "node:crypto";
7
+ import { spawnSync } from "node:child_process";
8
+ import { pathToFileURL, fileURLToPath } from "node:url";
9
+
10
+ const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
11
+ const pauseSync = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
12
+ const escapeXml = (s) => String(s).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
13
+ const missing = (r) => /could not find service|no such process|service .*not found/i.test(r.stderr || r.stdout || "");
14
+ export function launchctl(args) {
15
+ const r = spawnSync("/bin/launchctl", args, { encoding: "utf8", timeout: 15000, maxBuffer: 1024 * 1024 });
16
+ return { code: r.status ?? 1, stdout: r.stdout || "", stderr: r.stderr || r.error?.code || "" };
17
+ }
18
+ export function writeLaunchdPlist(file, text) {
19
+ const tmp = `${file}.${process.pid}.tmp`;
20
+ const fd = fs.openSync(tmp, "w", 0o600);
21
+ try { fs.writeFileSync(fd, text); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
22
+ fs.renameSync(tmp, file);
23
+ const parent = fs.openSync(path.dirname(file), "r");
24
+ try { fs.fsyncSync(parent); } finally { fs.closeSync(parent); }
25
+ }
26
+ export function atomicJson(file, value) {
27
+ const tmp = `${file}.${process.pid}.tmp`;
28
+ const fd = fs.openSync(tmp, "w", 0o600);
29
+ try { fs.writeFileSync(fd, JSON.stringify(value)); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
30
+ fs.renameSync(tmp, file);
31
+ const parent = fs.openSync(path.dirname(file), "r");
32
+ try { fs.fsyncSync(parent); } finally { fs.closeSync(parent); }
33
+ }
34
+ const read = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
35
+ export function lifecycleIdentity(env = process.env) {
36
+ const home = env.HOME?.trim() || env.USERPROFILE?.trim() || os.homedir();
37
+ const rawProfile = env.JOHNNESS_PROFILE?.trim();
38
+ const profile = rawProfile?.toLowerCase() === "default" ? "" : rawProfile;
39
+ const label = env.JOHNNESS_LAUNCHD_LABEL?.trim() || (profile && profile !== "default" ? `ai.johnness.${profile}` : "ai.johnness.gateway");
40
+ return { domain: `gui/${process.getuid()}`, label, plistPath: path.join(home, "Library", "LaunchAgents", `${label}.plist`), stateDir: env.JOHNNESS_STATE_DIR?.trim() ? path.resolve(env.JOHNNESS_STATE_DIR.trim().replace(/^~(?=$|[\\/])/, home)) : path.join(home, profile ? `.johnness-${profile}` : ".johnness") };
41
+ }
42
+ function validate(p) {
43
+ if (!/^gui\/\d+$/.test(p.domain) || !/^[A-Za-z0-9_.-]+$/.test(p.label) || !path.isAbsolute(p.plistPath) || !path.isAbsolute(p.stateDir)) throw new Error("Invalid launchd lifecycle identity");
44
+ if (!["restart", "reload"].includes(p.mode)) throw new Error("Invalid launchd lifecycle operation");
45
+ }
46
+ function runtime(run, target) {
47
+ const r = run(["print", target]);
48
+ if (r.code && !missing(r)) throw new Error(`launchctl print failed (${r.code}); refusing lifecycle mutation`);
49
+ return { loaded: r.code === 0, pid: Number(r.stdout.match(/^\s*pid = (\d+)\s*$/m)?.[1]) || null };
50
+ }
51
+ function plistPort(file) {
52
+ const r = spawnSync("/usr/bin/plutil", ["-convert", "json", "-o", "-", file], { encoding: "utf8", timeout: 5000 });
53
+ if (r.status !== 0) throw new Error("Invalid target LaunchAgent plist");
54
+ const p = JSON.parse(r.stdout), args = p.ProgramArguments || [];
55
+ const i = args.indexOf("--port");
56
+ const port = Number(i >= 0 ? args[i + 1] : p.EnvironmentVariables?.JOHNNESS_GATEWAY_PORT);
57
+ return { label: p.Label, port: Number.isInteger(port) && port > 0 && port < 65536 ? port : null };
58
+ }
59
+ function workerPlist(p, file) {
60
+ return `<?xml version="1.0"?><plist version="1.0"><dict><key>Label</key><string>${p.workerLabel}</string><key>ProgramArguments</key><array><string>${escapeXml(process.execPath)}</string><string>--max-old-space-size=256</string><string>${escapeXml(path.join(path.dirname(file), "worker.mjs"))}</string><string>--worker</string><string>${escapeXml(file)}</string></array><key>RunAtLoad</key><true/><key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict><key>ThrottleInterval</key><integer>2</integer><key>EnvironmentVariables</key><dict><key>PATH</key><string>/usr/bin:/bin:/usr/sbin:/sbin</string><key>HOME</key><string>${escapeXml(os.homedir())}</string></dict><key>StandardOutPath</key><string>/dev/null</string><key>StandardErrorPath</key><string>/dev/null</string></dict></plist>`;
61
+ }
62
+ /** Register and verify an independent job BEFORE granting permission for any effect.
63
+ * Death before arming leaves the target untouched. Death afterwards is irrelevant.
64
+ * No detached child, AbandonProcessGroup, inherited secrets or gateway imports. */
65
+ export function requestLaunchdOperation(params, deps = {}) {
66
+ const p = { ...params, mode: params.mode || "restart" };
67
+ validate(p);
68
+ const run = deps.run || launchctl;
69
+ const target = `${p.domain}/${p.label}`;
70
+ const initial = runtime(run, target);
71
+ const operations = path.join(p.stateDir, "lifecycle");
72
+ fs.mkdirSync(operations, { recursive: true, mode: 0o700 });
73
+ // Coalesce an already armed operation for this exact target. Never stack reloads.
74
+ for (const name of fs.readdirSync(operations)) {
75
+ const file = path.join(operations, name, "request.json");
76
+ try {
77
+ const old = read(file), receipt = read(path.join(path.dirname(file), "receipt.json"));
78
+ if (old.target === target && !["complete", "failed", "cancelled"].includes(receipt.status) && fs.existsSync(path.join(path.dirname(file), "armed")) && run(["print", `${p.domain}/${old.workerLabel}`]).code === 0) throw new Error("A launchd lifecycle operation is already pending for this service");
79
+ } catch (e) { if (e.message.startsWith("A launchd")) throw e; }
80
+ }
81
+ const lock = path.join(operations, `${p.label}.lock`);
82
+ try { fs.mkdirSync(lock, { mode: 0o700 }); } catch (error) {
83
+ if (error.code !== "EEXIST") throw error;
84
+ let owner; try { owner = read(path.join(lock, "owner.json")); } catch {}
85
+ // Never steal an armed job's ownership while its independently supervised completer exists.
86
+ if (Date.now() - fs.statSync(lock).mtimeMs < 30000 || (owner && run(["print", `${p.domain}/${owner.workerLabel}`]).code === 0)) throw new Error("A launchd lifecycle operation is already pending for this service");
87
+ fs.rmSync(lock, { recursive: true }); fs.mkdirSync(lock, { mode: 0o700 });
88
+ }
89
+ const id = randomUUID(), dir = path.join(operations, id);
90
+ fs.mkdirSync(dir, { mode: 0o700 });
91
+ const file = path.join(dir, "request.json");
92
+ try {
93
+ // Validate staged bytes before granting permission to unload; keep the old plist for recovery.
94
+ if (p.mode === "reload") {
95
+ if (typeof p.plist !== "string") throw new Error("Reload requires staged plist bytes");
96
+ p.stagedPlist = path.join(dir, "next.plist");
97
+ fs.writeFileSync(p.stagedPlist, p.plist, { mode: 0o600 }); delete p.plist;
98
+ if (fs.existsSync(p.plistPath)) {
99
+ p.previousPlist = path.join(dir, "previous.plist"); fs.copyFileSync(p.plistPath, p.previousPlist); fs.chmodSync(p.previousPlist, 0o600);
100
+ }
101
+ }
102
+ const plist = plistPort(p.stagedPlist || p.plistPath);
103
+ if (plist.label !== p.label) throw new Error("LaunchAgent plist label does not match selected service");
104
+ Object.assign(p, { lock, id, target, previousPid: initial.pid, initiallyLoaded: initial.loaded, port: plist.port || p.port, workerLabel: `ai.johnness.lifecycle.${id}`, createdAt: Date.now() });
105
+ atomicJson(path.join(lock, "owner.json"), { workerLabel: p.workerLabel });
106
+ p.workerPlist = path.join(dir, "worker.plist");
107
+ atomicJson(file, p);
108
+ atomicJson(path.join(dir, "receipt.json"), { status: "prepared", target, previousPid: p.previousPid, createdAt: p.createdAt });
109
+ fs.copyFileSync(fileURLToPath(import.meta.url), path.join(dir, "worker.mjs"));
110
+ fs.writeFileSync(p.workerPlist, workerPlist(p, file), { mode: 0o600 });
111
+ for (const f of ["worker.mjs", "worker.plist", ...(p.stagedPlist ? ["next.plist"] : []), ...(p.previousPlist ? ["previous.plist"] : [])]) { const fd = fs.openSync(path.join(dir, f), "r"); fs.fsyncSync(fd); fs.closeSync(fd); }
112
+ const boot = run(["bootstrap", p.domain, p.workerPlist]);
113
+ if (boot.code) throw new Error(`Could not register independent lifecycle worker (${boot.code}); target unchanged`);
114
+ const deadline = Date.now() + 10000;
115
+ while (!fs.existsSync(path.join(dir, "ready")) && Date.now() < deadline) pauseSync(25);
116
+ if (!fs.existsSync(path.join(dir, "ready")) || run(["print", `${p.domain}/${p.workerLabel}`]).code !== 0) throw new Error("Lifecycle worker not ready; target unchanged");
117
+ // This fsynced permission record is the ownership handoff, not a success receipt.
118
+ atomicJson(path.join(dir, "armed"), { at: Date.now() });
119
+ return path.join(dir, "receipt.json");
120
+ } catch (error) {
121
+ if (!fs.existsSync(path.join(dir, "armed"))) {
122
+ if (p.workerLabel) run(["bootout", `${p.domain}/${p.workerLabel}`]);
123
+ fs.rmSync(lock, { recursive: true, force: true });
124
+ fs.rmSync(dir, { recursive: true, force: true });
125
+ }
126
+ throw error;
127
+ }
128
+ }
129
+ export async function waitForLaunchdOperation(file, timeoutMs = 100000) {
130
+ const deadline = Date.now() + timeoutMs;
131
+ while (Date.now() < deadline) {
132
+ const receipt = read(file);
133
+ if (receipt.status === "complete") return receipt;
134
+ if (["failed", "cancelled"].includes(receipt.status)) throw new Error(`LaunchAgent lifecycle ${receipt.status}: ${receipt.detail || "see receipt"} (${file})`);
135
+ await pause(100);
136
+ }
137
+ throw new Error(`LaunchAgent lifecycle still pending; independent worker owns recovery (${file})`);
138
+ }
139
+ export function cancelLaunchdOperations(params) {
140
+ const operations = path.join(params.stateDir, "lifecycle"), target = `${params.domain}/${params.label}`;
141
+ if (!fs.existsSync(operations)) return;
142
+ for (const id of fs.readdirSync(operations)) {
143
+ const dir = path.join(operations, id);
144
+ let p; try { p = read(path.join(dir, "request.json")); } catch { continue; }
145
+ if (p.target !== target) continue;
146
+ if (["complete", "failed", "cancelled"].includes(read(path.join(dir, "receipt.json")).status)) continue;
147
+ // Remove the completer first. An explicit stop/uninstall must never resurrect.
148
+ const r = launchctl(["bootout", `${p.domain}/${p.workerLabel}`]);
149
+ if (r.code && !missing(r)) throw new Error("Cannot cancel lifecycle worker; target not stopped");
150
+ atomicJson(path.join(dir, "receipt.json"), { status: "cancelled", target, at: Date.now() });
151
+ fs.rmSync(p.workerPlist, { force: true });
152
+ if (p.lock) fs.rmSync(p.lock, { recursive: true, force: true });
153
+ }
154
+ }
155
+ function descendant(pid, ancestor) {
156
+ for (let n = 0; n < 12 && pid > 1; n++) {
157
+ if (pid === ancestor) return true;
158
+ const r = spawnSync("/bin/ps", ["-o", "ppid=", "-p", String(pid)], { encoding: "utf8", timeout: 2000 });
159
+ pid = Number(r.stdout?.trim());
160
+ }
161
+ return false;
162
+ }
163
+ async function healthy(p, current, deps) {
164
+ if (!current.loaded || !current.pid || (p.previousPid && p.previousPid === current.pid)) return false;
165
+ if (!p.port) return true; // Non-gateway/node services: supervised replacement, not HTTP health.
166
+ if (deps.health) return deps.health(p, current);
167
+ const r = spawnSync("/usr/sbin/lsof", ["-nP", `-iTCP:${p.port}`, "-sTCP:LISTEN", "-Fp"], { encoding: "utf8", timeout: 5000 });
168
+ const pids = [...new Set([...String(r.stdout).matchAll(/^p(\d+)$/gm)].map((m) => Number(m[1])))];
169
+ if (!pids.length || !pids.every((pid) => descendant(pid, current.pid))) return false;
170
+ try { return (await fetch(`http://127.0.0.1:${p.port}/healthz`, { signal: AbortSignal.timeout(2000) })).ok; } catch { return false; }
171
+ }
172
+ /** Restartable state machine. Checkpoints precede effects; real supervisor state
173
+ * reconciles a missing effect acknowledgement after SIGKILL. Exported for fault tests. */
174
+ export async function executeLaunchdOperation(p, receiptFile, deps = {}) {
175
+ const run = deps.run || launchctl;
176
+ const save = (status, extra = {}) => { atomicJson(receiptFile, { ...read(receiptFile), status, ...extra, at: Date.now() }); deps.checkpoint?.(status); };
177
+ const effect = (args) => { const r = run(args); deps.effect?.(args); return r; };
178
+ let receipt = read(receiptFile);
179
+ if (["complete", "failed", "cancelled"].includes(receipt.status)) return;
180
+ let current = runtime(run, p.target);
181
+ if (receipt.status === "prepared" || receipt.status === "ready") {
182
+ if (p.mode === "reload") {
183
+ const r = effect(["enable", p.target]);
184
+ if (r.code) throw new Error(`launchctl enable failed (${r.code})`);
185
+ }
186
+ save(p.mode === "reload" ? "publish-intent" : "restart-intent");
187
+ receipt = read(receiptFile);
188
+ }
189
+ if (receipt.status === "publish-intent") {
190
+ writeLaunchdPlist(p.plistPath, fs.readFileSync(p.stagedPlist, "utf8"));
191
+ save("unload-intent"); receipt = read(receiptFile);
192
+ }
193
+ if (receipt.status === "unload-intent") {
194
+ if (current.loaded) {
195
+ const r = effect(["bootout", p.target]);
196
+ if (r.code && !missing(r)) throw new Error(`launchctl bootout failed (${r.code})`);
197
+ }
198
+ save("bootstrap-intent"); receipt = read(receiptFile);
199
+ current = runtime(run, p.target);
200
+ }
201
+ if (receipt.status === "restart-intent") {
202
+ if (!current.loaded) {
203
+ if (p.initiallyLoaded) { save("failed", { detail: "Service was explicitly removed; refusing to resurrect it" }); return; }
204
+ save("bootstrap-intent"); receipt = read(receiptFile);
205
+ } else if (!p.previousPid || current.pid === p.previousPid) {
206
+ const r = effect(["kickstart", "-k", p.target]);
207
+ if (r.code) { save("failed", { detail: `launchctl kickstart failed (${r.code}); registration preserved` }); return; }
208
+ save("health-pending", { healthStartedAt: Date.now() }); receipt = read(receiptFile);
209
+ } else { save("health-pending", { healthStartedAt: Date.now() }); receipt = read(receiptFile); }
210
+ }
211
+ if (receipt.status === "rollback-intent") {
212
+ if (p.previousPlist) {
213
+ writeLaunchdPlist(p.plistPath, fs.readFileSync(p.previousPlist, "utf8"));
214
+ }
215
+ current = runtime(run, p.target);
216
+ if (!current.loaded) {
217
+ const r = effect(["bootstrap", p.domain, p.plistPath]);
218
+ if (r.code && !runtime(run, p.target).loaded) throw new Error("Rollback bootstrap failed; independent worker retains recovery ownership");
219
+ }
220
+ save("health-pending", { rolledBack: true, healthStartedAt: Date.now() }); receipt = read(receiptFile);
221
+ }
222
+ if (receipt.status === "bootstrap-intent") {
223
+ current = runtime(run, p.target);
224
+ if (!current.loaded) {
225
+ const r = effect(["bootstrap", p.domain, p.plistPath]);
226
+ if (r.code && !runtime(run, p.target).loaded) {
227
+ const attempts = (receipt.bootstrapAttempts || 0) + 1;
228
+ save(attempts >= 5 && p.previousPlist ? "rollback-intent" : "bootstrap-intent", { bootstrapAttempts: attempts });
229
+ throw new Error(`launchctl bootstrap failed (${r.code}); independent worker will retry`);
230
+ }
231
+ }
232
+ // bootstrap already starts RunAtLoad jobs. Never kill the newly booted process.
233
+ save("health-pending", { healthStartedAt: Date.now() }); receipt = read(receiptFile);
234
+ }
235
+ const deadline = (receipt.healthStartedAt || Date.now()) + (deps.healthTimeoutMs ?? 90000);
236
+ do {
237
+ current = runtime(run, p.target);
238
+ if (await healthy(p, current, deps)) { save(receipt.rolledBack ? "failed" : "complete", { ...(receipt.rolledBack ? { detail: "New plist failed to bootstrap; previous plist restored and healthy" } : {}), pid: current.pid, health: p.port ? "http-and-supervised-listener" : "supervised-process" }); return; }
239
+ if (!current.loaded) { save("failed", { detail: "Service removed while waiting for health; not resurrected" }); return; }
240
+ await (deps.pause || pause)(200);
241
+ } while (Date.now() < deadline);
242
+ save("failed", { detail: "Replacement health timeout; target remains supervised" });
243
+ }
244
+ async function worker(file) {
245
+ const p = read(file); validate(p);
246
+ const dir = path.dirname(file), receipt = path.join(dir, "receipt.json");
247
+ // Readiness proves code can execute independently before the parent arms it.
248
+ atomicJson(path.join(dir, "ready"), { pid: process.pid });
249
+ while (!fs.existsSync(path.join(dir, "armed"))) {
250
+ if (Date.now() - p.createdAt > 15000) { atomicJson(receipt, { status: "cancelled", detail: "Requester disappeared before arming; target untouched" }); break; }
251
+ await pause(25);
252
+ }
253
+ if (fs.existsSync(path.join(dir, "armed"))) await executeLaunchdOperation(p, receipt);
254
+ // Terminal receipt is durable before removing this worker's registration.
255
+ fs.rmSync(p.workerPlist, { force: true });
256
+ for (const f of [p.stagedPlist, p.previousPlist]) if (f) fs.rmSync(f, { force: true });
257
+ if (p.lock) fs.rmSync(p.lock, { recursive: true, force: true });
258
+ launchctl(["bootout", `${p.domain}/${p.workerLabel}`]);
259
+ }
260
+ /** Snapshot the dependency-free implementation BEFORE npm replaces the package. */
261
+ export function prepareLaunchdRestart(env = process.env, port) {
262
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "johnness-launchd-update-"));
263
+ fs.chmodSync(dir, 0o700);
264
+ fs.copyFileSync(fileURLToPath(import.meta.url), path.join(dir, "lifecycle.mjs"));
265
+ const file = path.join(dir, "restart.json");
266
+ atomicJson(file, { ...lifecycleIdentity(env), mode: "restart", port });
267
+ return file;
268
+ }
269
+ export async function runPreparedLaunchdRestart(file) {
270
+ const dir = path.dirname(file);
271
+ const isolated = await import(pathToFileURL(path.join(dir, "lifecycle.mjs")).href);
272
+ const receipt = isolated.requestLaunchdOperation(read(file));
273
+ fs.rmSync(dir, { recursive: true, force: true });
274
+ return receipt; // Handed off, not a claim of health.
275
+ }
276
+ if (process.argv[2] === "--worker" && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url))) {
277
+ worker(process.argv[3]).catch((err) => {
278
+ try { const f = path.join(path.dirname(process.argv[3]), "receipt.json"); atomicJson(f, { ...read(f), lastError: err.message, retryAt: Date.now() }); } catch {}
279
+ process.exitCode = 1;
280
+ }); // launchd retries; never dump config or commands.
281
+ }
@@ -0,0 +1,15 @@
1
+ // JOHNNESS_PATCH_NATIVE_STATE_MIGRATION_59
2
+ // JOHNNESS_PATCH_CRASH_SAFE_CUTOVER_75
3
+ import { resumeCutover, waitForActivation } from './migrate-cutover.js';
4
+ const [mode, target, id] = process.argv.slice(2);
5
+ try {
6
+ if (mode === 'gate' && target && id) await waitForActivation(target, id);
7
+ else if (mode === 'resume' && target) {
8
+ // Let the submitting CLI release its intent lock. launchd retries nonzero exits.
9
+ await new Promise(resolve => setTimeout(resolve, 1000));
10
+ await resumeCutover(target);
11
+ } else throw new Error('Invalid worker command');
12
+ } catch {
13
+ process.stderr.write('JOHNNESS MIGRATION INCOMPLETE: run johnness migrate --resume. Forward recovery only.\n');
14
+ process.exitCode = 6;
15
+ }