johns-harness 2026.9.26 → 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 +13 -0
- package/README.md +9 -9
- package/dist/compact-B247y5Qt.js +33 -21
- package/dist/daemon-cli.js +12 -76
- package/dist/launchd-lifecycle.js +281 -0
- package/dist/migrate-cutover-worker.js +15 -0
- package/dist/migrate-cutover.js +357 -0
- package/dist/migrate-service.js +3 -2
- package/dist/migrate-transaction.js +1 -1
- package/dist/migrate.js +17 -7
- package/dist/pi-embedded-C6ITuRXf.js +33 -21
- package/dist/pi-embedded-DoQsYfIY.js +33 -21
- package/dist/plugin-sdk/dispatch-BP0viZiL.js +2 -1
- package/dist/plugin-sdk/dispatch-CQsjmw7g.js +2 -1
- package/dist/plugin-sdk/dispatch-Cerq29sy.js +2 -1
- package/dist/plugin-sdk/dispatch-Cndjtt0g.js +2 -1
- package/dist/plugin-sdk/dispatch-UogiJYul.js +2 -1
- package/dist/plugin-sdk/reply-DbZnH8-h.js +2 -1
- package/dist/reply-C5LKjXcC.js +33 -21
- package/dist/restart-B-xYDDdI.js +7 -57
- package/dist/restart-BzjDMmkD.js +7 -57
- package/dist/service-D4k98v0P.js +12 -76
- package/dist/service-DKIwLdlY.js +12 -76
- package/dist/update-cli-CmEq1bdv.js +7 -19
- package/dist/update-cli-Cm_l3W29.js +7 -19
- package/docs/MIGRATION-CUTOVER.md +70 -0
- package/docs/PATCHES.md +93 -1
- package/package.json +1 -1
- package/scripts/check-packed-artifact.mjs +23 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
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
|
+
|
|
11
|
+
## 2026.9.27
|
|
12
|
+
|
|
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.
|
|
14
|
+
- Roll back with `npm i -g johns-harness@2026.9.26`.
|
|
15
|
+
|
|
3
16
|
## 2026.9.26
|
|
4
17
|
|
|
5
18
|
- Family 73: the `content-story`, `dashboard` and `cold-outreach` skills are removed from the bundled set on request, leaving eight generic bundled skills (`code-ops`, `web-research`, `plan-archival`, `todos`, `arch-viz`, `gcp-logs`, `activity-logging`, `outlook`). They load by description match exactly as before, workspace copies override them, and `enabled: false` and `allowBundled` still apply. Nothing else changes from 2026.9.25.
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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 #
|
|
89
|
-
bash scripts/verify-patches.sh #
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
package/dist/compact-B247y5Qt.js
CHANGED
|
@@ -7894,9 +7894,7 @@ function resolveTranscriptPolicy(params) {
|
|
|
7894
7894
|
|
|
7895
7895
|
//#region johnness local active-session pressure guard
|
|
7896
7896
|
const LOCAL_PRESSURE_GUARD_DEFAULTS = {
|
|
7897
|
-
|
|
7898
|
-
messageCount: 220,
|
|
7899
|
-
heapUsedBytes: 3221225472,
|
|
7897
|
+
contextRatio: 0.7,
|
|
7900
7898
|
minIntervalMs: 300000,
|
|
7901
7899
|
minMessagesAfterCompaction: 24
|
|
7902
7900
|
};
|
|
@@ -7907,14 +7905,24 @@ function localPressureGuardNumber(value, fallback) {
|
|
|
7907
7905
|
function localPressureGuardSettings(cfg) {
|
|
7908
7906
|
const raw = cfg?.agents?.defaults?.activeSessionPressure ?? cfg?.session?.activePressure ?? {};
|
|
7909
7907
|
if (raw?.enabled === false) return null;
|
|
7908
|
+
/* JOHNNESS_PATCH_CONTEXT_WINDOW_COMPACTION: legacy transcriptBytes/messageCount/heapUsedBytes keys are accepted without error and ignored. Config surface: enabled, contextRatio, minIntervalMs, minMessagesAfterCompaction. */
|
|
7909
|
+
const contextRatio = localPressureGuardNumber(raw.contextRatio, LOCAL_PRESSURE_GUARD_DEFAULTS.contextRatio);
|
|
7910
7910
|
return {
|
|
7911
|
-
|
|
7912
|
-
messageCount: localPressureGuardNumber(raw.messageCount ?? raw.maxMessages, LOCAL_PRESSURE_GUARD_DEFAULTS.messageCount),
|
|
7913
|
-
heapUsedBytes: localPressureGuardNumber(raw.heapUsedBytes ?? raw.maxHeapUsedBytes, LOCAL_PRESSURE_GUARD_DEFAULTS.heapUsedBytes),
|
|
7911
|
+
contextRatio: contextRatio > 0 ? contextRatio : LOCAL_PRESSURE_GUARD_DEFAULTS.contextRatio,
|
|
7914
7912
|
minIntervalMs: localPressureGuardNumber(raw.minIntervalMs, LOCAL_PRESSURE_GUARD_DEFAULTS.minIntervalMs),
|
|
7915
7913
|
minMessagesAfterCompaction: localPressureGuardNumber(raw.minMessagesAfterCompaction, LOCAL_PRESSURE_GUARD_DEFAULTS.minMessagesAfterCompaction)
|
|
7916
7914
|
};
|
|
7917
7915
|
}
|
|
7916
|
+
/* JOHNNESS_PATCH_CONTEXT_WINDOW_COMPACTION: single pressure trigger — estimated active-context tokens vs the active model's declared context window; the window falls back to DEFAULT_CONTEXT_TOKENS when undeclared. See docs/PATCHES.md family 74. */
|
|
7917
|
+
function localPressureGuardContextWindowTokens(model) {
|
|
7918
|
+
const declared = typeof model?.contextWindow === "number" && Number.isFinite(model.contextWindow) ? Math.floor(model.contextWindow) : 0;
|
|
7919
|
+
return declared > 0 ? declared : DEFAULT_CONTEXT_TOKENS;
|
|
7920
|
+
}
|
|
7921
|
+
function localPressureGuardContextThreshold(settings, contextWindowTokens, cfg) {
|
|
7922
|
+
const reserveTokensFloor = localPressureGuardNumber(cfg?.agents?.defaults?.compaction?.reserveTokensFloor, 2e4);
|
|
7923
|
+
const floor = Math.max(reserveTokensFloor, 50000);
|
|
7924
|
+
return Math.max(Math.ceil(settings.contextRatio * contextWindowTokens), floor);
|
|
7925
|
+
}
|
|
7918
7926
|
/* JOHNNESS_PATCH_APPEND_ONLY_PRESSURE_BASELINE: derive a restart-safe byte baseline from the append-only JSONL so transcriptBytes pressure measures NEW growth since the last successful compaction rather than total (never-shrinking) file size. See docs/PATCHES.md family 22. Scans getEntries() newest->oldest for the newest SUCCESSFUL johnness.active-pressure-refresh marker (failed !== true) and returns its recorded post-compaction size (postCompactionBytes, else transcriptBytes). Returns 0 for legacy sessions with no such marker, so a first over-limit session still compacts once. */
|
|
7919
7927
|
function computeActivePressureBaselineBytes(sessionManager) {
|
|
7920
7928
|
try {
|
|
@@ -8072,12 +8080,12 @@ function compactionRotationNumber(value, fallback) {
|
|
|
8072
8080
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
|
|
8073
8081
|
}
|
|
8074
8082
|
function compactionRotationSettings(cfg) {
|
|
8075
|
-
const pressure = localPressureGuardSettings(cfg) ?? { transcriptBytes: 1048576 };
|
|
8076
8083
|
const raw = cfg?.agents?.defaults?.activeSessionPressure ?? cfg?.session?.activePressure ?? {};
|
|
8077
8084
|
if (raw?.rotateAfterCompaction === false) return null;
|
|
8078
|
-
|
|
8085
|
+
/* JOHNNESS_PATCH_CONTEXT_WINDOW_COMPACTION: the legacy transcriptBytes threshold no longer exists; the rotation size budget keeps its historical 1 MiB-derived default so default rotation behavior is unchanged. */
|
|
8086
|
+
const threshold = 1048576;
|
|
8079
8087
|
const configuredMax = compactionRotationNumber(raw.rotationMaxBytes, 0);
|
|
8080
|
-
const maxBytes = configuredMax > 0 ? configuredMax :
|
|
8088
|
+
const maxBytes = configuredMax > 0 ? configuredMax : Math.max(131072, Math.min(524288, Math.floor(threshold * .45)));
|
|
8081
8089
|
return {
|
|
8082
8090
|
maxBytes,
|
|
8083
8091
|
maxMessages: Math.max(4, Math.floor(compactionRotationNumber(raw.rotationMaxMessages, 80))),
|
|
@@ -8213,18 +8221,20 @@ async function maybeRefreshActiveSessionForPressure(params) {
|
|
|
8213
8221
|
const heapUsedBytes = typeof process?.memoryUsage === "function" ? process.memoryUsage().heapUsed : 0;
|
|
8214
8222
|
const __hasCompactedContext = Array.isArray(params.session.messages) && params.session.messages.some((message) => message?.role === "compactionSummary");
|
|
8215
8223
|
const __alreadyRotated = params.sessionManager.getEntries?.().some((entry) => entry?.type === "custom" && entry?.customType === "johnness.compaction-session-rotation");
|
|
8216
|
-
|
|
8224
|
+
/* JOHNNESS_PATCH_CONTEXT_WINDOW_COMPACTION: the existing-compacted-lineage rotation follows the same single context-token trigger; the byte gate is gone (family 74). */
|
|
8225
|
+
const __cwcWindowTokens = localPressureGuardContextWindowTokens(params.session?.model);
|
|
8226
|
+
const __cwcThresholdTokens = localPressureGuardContextThreshold(settings, __cwcWindowTokens, params.config);
|
|
8227
|
+
const __cwcEstimatedTokens = estimateMessagesTokens(messages);
|
|
8228
|
+
if (__cwcEstimatedTokens >= __cwcThresholdTokens && __hasCompactedContext && !__alreadyRotated) {
|
|
8217
8229
|
const __existingRotation = await rotateCompactedSessionLineage({ config: params.config, session: params.session, sessionManager: params.sessionManager, sessionFile: params.sessionFile, sessionKey: params.sessionKey, sessionId: params.sessionId, reason: "existing-compacted-lineage" });
|
|
8218
8230
|
if (__existingRotation.rotated) return { refreshed: true, rotated: true, reasons: ["existing-compacted-lineage"], transcriptBytes: __existingRotation.transcriptBytes, messageCount: __existingRotation.messageCount, heapUsedBytes, sessionId: __existingRotation.sessionId, sessionFile: __existingRotation.sessionFile, oldSessionId: __existingRotation.oldSessionId, oldSessionFile: __existingRotation.oldSessionFile };
|
|
8219
8231
|
}
|
|
8220
8232
|
const reasons = [];
|
|
8221
|
-
/*
|
|
8233
|
+
/* JOHNNESS_PATCH_CONTEXT_WINDOW_COMPACTION: single trigger — estimated active-context tokens vs the context-window threshold (family 74). Legacy byte/message/heap heuristics no longer gate; the family 22 append-only baseline below is computed and recorded for observability only. */
|
|
8222
8234
|
const __aopBaselineBytes = computeActivePressureBaselineBytes(params.sessionManager);
|
|
8223
8235
|
const __aopEffectiveBytes = effectiveTranscriptPressureBytes(transcriptBytes, __aopBaselineBytes);
|
|
8224
|
-
if (
|
|
8225
|
-
if (
|
|
8226
|
-
if (settings.heapUsedBytes > 0 && heapUsedBytes >= settings.heapUsedBytes) reasons.push("heapUsed=" + heapUsedBytes + "/" + settings.heapUsedBytes);
|
|
8227
|
-
if (reasons.length === 0) return { refreshed: false, reason: "below threshold", transcriptBytes, messageCount, heapUsedBytes };
|
|
8236
|
+
if (__cwcEstimatedTokens >= __cwcThresholdTokens) reasons.push("contextTokens=" + __cwcEstimatedTokens + "/" + __cwcThresholdTokens);
|
|
8237
|
+
if (reasons.length === 0) return { refreshed: false, reason: "below threshold", contextTokens: __cwcEstimatedTokens, contextThreshold: __cwcThresholdTokens, baselineBytes: __aopBaselineBytes, effectiveTranscriptBytes: __aopEffectiveBytes, transcriptBytes, messageCount, heapUsedBytes };
|
|
8228
8238
|
let last = 0;
|
|
8229
8239
|
let lastFailed = false;
|
|
8230
8240
|
let lastBytes = 0;
|
|
@@ -16150,13 +16160,14 @@ async function maybeCompactActiveRunForPressure(params) {
|
|
|
16150
16160
|
} catch {}
|
|
16151
16161
|
const heapUsedBytes = typeof process?.memoryUsage === "function" ? process.memoryUsage().heapUsed : 0;
|
|
16152
16162
|
const reasons = [];
|
|
16153
|
-
/*
|
|
16163
|
+
/* JOHNNESS_PATCH_CONTEXT_WINDOW_COMPACTION: single trigger — estimated active-context tokens vs the context-window threshold (family 74). Legacy byte/message/heap heuristics no longer gate; the family 22 append-only baseline below is computed and recorded for observability only. */
|
|
16154
16164
|
const __aopBaselineBytes = computeActivePressureBaselineBytes(params.sessionManager);
|
|
16155
16165
|
const __aopEffectiveBytes = effectiveTranscriptPressureBytes(transcriptBytes, __aopBaselineBytes);
|
|
16156
|
-
|
|
16157
|
-
|
|
16158
|
-
|
|
16159
|
-
if (
|
|
16166
|
+
const __cwcWindowTokens = localPressureGuardContextWindowTokens(params.session?.model);
|
|
16167
|
+
const __cwcThresholdTokens = localPressureGuardContextThreshold(settings, __cwcWindowTokens, params.config);
|
|
16168
|
+
const __cwcEstimatedTokens = estimateMessagesTokens(messages);
|
|
16169
|
+
if (__cwcEstimatedTokens >= __cwcThresholdTokens) reasons.push("contextTokens=" + __cwcEstimatedTokens + "/" + __cwcThresholdTokens);
|
|
16170
|
+
if (reasons.length === 0) return { refreshed: false, reason: "below threshold", contextTokens: __cwcEstimatedTokens, contextThreshold: __cwcThresholdTokens, baselineBytes: __aopBaselineBytes, effectiveTranscriptBytes: __aopEffectiveBytes, transcriptBytes, messageCount, heapUsedBytes };
|
|
16160
16171
|
let last = 0;
|
|
16161
16172
|
let lastFailed = false;
|
|
16162
16173
|
let lastBytes = 0;
|
|
@@ -43932,7 +43943,8 @@ function withLoopbackBrowserAuth(url, init) {
|
|
|
43932
43943
|
}
|
|
43933
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.";
|
|
43934
43945
|
function resolveBrowserFetchOperatorHint(url) {
|
|
43935
|
-
|
|
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.";
|
|
43936
43948
|
}
|
|
43937
43949
|
function normalizeErrorMessage(err) {
|
|
43938
43950
|
if (err instanceof Error && err.message.trim().length > 0) return err.message.trim();
|
package/dist/daemon-cli.js
CHANGED
|
@@ -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
|
-
|
|
17462
|
-
await
|
|
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
|
|
17502
|
-
|
|
17503
|
-
|
|
17504
|
-
|
|
17505
|
-
|
|
17506
|
-
|
|
17507
|
-
|
|
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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
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
|
+
}
|