@khorsheed/dsh-ankh-guard 0.3.2 → 0.4.1
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 +9 -0
- package/README.en.md +10 -5
- package/README.i18n.yaml +2 -2
- package/README.md +10 -5
- package/lib/cli.js +136 -47
- package/lib/preflight-runner.js +37 -3
- package/lib/types/cli.d.ts +13 -0
- package/lib/types/cli.js +121 -21
- package/lib/types/preflight-runner.d.ts +27 -0
- package/lib/types/preflight-runner.js +43 -2
- package/lib/types/transition.d.ts +50 -10
- package/lib/types/transition.js +71 -22
- package/package.json +10 -10
- package/scripts/dsh-watchdog.sh +108 -5
package/lib/preflight-runner.js
CHANGED
|
@@ -26,6 +26,13 @@ import { pathToFileURL } from "node:url";
|
|
|
26
26
|
* activation — with the webserver port pinned to 0 (OS-assigned) so the
|
|
27
27
|
* dry-run never collides with the live instance;
|
|
28
28
|
* - every registered client bundle artifact exists on disk;
|
|
29
|
+
* - every registered agent preset is USABLE, not merely loaded: preset rows
|
|
30
|
+
* mount on the registry's standing scopes beside the profile tree, so a row
|
|
31
|
+
* whose module stopped resolving (a folded companion's retired package name,
|
|
32
|
+
* a base version predating its ./tool entry) never fails the boot itself —
|
|
33
|
+
* it fails every SESSION of that preset later (the picker shows 加载失败,
|
|
34
|
+
* resume answers "never started"; 3080, 2026-09-28). The audit reads the
|
|
35
|
+
* preset registry's own `broken` diagnostic back from the dry-run boot;
|
|
29
36
|
* - dispose rolls every effect back.
|
|
30
37
|
*
|
|
31
38
|
* Exit codes (the contract the guard consumes):
|
|
@@ -293,6 +300,30 @@ function missingClientArtifacts(ctx) {
|
|
|
293
300
|
return missing;
|
|
294
301
|
}
|
|
295
302
|
/**
|
|
303
|
+
* The preset-roster half of the verdict. A profile can boot clean while one
|
|
304
|
+
* of its agent presets is BROKEN: preset rows mount on the registry's
|
|
305
|
+
* standing scopes, not on the profile root the dry-run boots, so a row whose
|
|
306
|
+
* module stopped resolving never fails the boot — it surfaces later as the
|
|
307
|
+
* preset picker's 加载失败 badge and `resume failed … never started` on every
|
|
308
|
+
* session of that preset (3080, 2026-09-28: the dev preset named
|
|
309
|
+
* `@khorsheed/dsh-worktrees/tool` while the installed worktrees predated the
|
|
310
|
+
* entry). The registry already computes this verdict: it activates every
|
|
311
|
+
* registered preset eagerly and records a mount failure as `broken`, and its
|
|
312
|
+
* `list()` re-audits mounted trees after the loader settles, so rows still
|
|
313
|
+
* waiting on a host service report their pending reason instead of passing
|
|
314
|
+
* silently. Fail the dry-run on any broken preset — the restart this gate
|
|
315
|
+
* protects would serve those broken sessions. A host whose registry face is
|
|
316
|
+
* absent or list-less degrades to no findings: the audit never invents one.
|
|
317
|
+
*/
|
|
318
|
+
async function brokenAgentPresets(ctx) {
|
|
319
|
+
const registry = ctx.get?.("agentPresets");
|
|
320
|
+
if (registry === void 0 || typeof registry.list !== "function") return [];
|
|
321
|
+
return (await registry.list()).flatMap((row) => row !== null && typeof row === "object" && typeof row.id === "string" && typeof row.broken === "string" ? [{
|
|
322
|
+
id: row.id,
|
|
323
|
+
broken: row.broken
|
|
324
|
+
}] : []);
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
296
327
|
* Boot the profile's full tree once, tear it down, and report the verdict on
|
|
297
328
|
* the process streams. No HMR, no user-patch watchers, no signal wiring —
|
|
298
329
|
* preflight is one-shot.
|
|
@@ -356,9 +387,12 @@ async function runPreflight(profile, patchFiles = [], root = resolveHarnessRoot(
|
|
|
356
387
|
});
|
|
357
388
|
appReady.commit();
|
|
358
389
|
const missing = missingClientArtifacts(ctx);
|
|
390
|
+
const brokenPresets = await brokenAgentPresets(ctx);
|
|
359
391
|
await ctx.fiber.dispose();
|
|
360
|
-
if (missing.length > 0) {
|
|
361
|
-
process.stderr.write(`preflight FAIL: profile ${JSON.stringify(profile)} boots but client bundle artifacts are missing or unreadable:\n${missing.map((line) => ` - ${line}`).join("\n")}\nrun \`pnpm run build\` before launch\n`);
|
|
392
|
+
if (missing.length > 0 || brokenPresets.length > 0) {
|
|
393
|
+
if (missing.length > 0) process.stderr.write(`preflight FAIL: profile ${JSON.stringify(profile)} boots but client bundle artifacts are missing or unreadable:\n${missing.map((line) => ` - ${line}`).join("\n")}\nrun \`pnpm run build\` before launch\n`);
|
|
394
|
+
if (brokenPresets.length > 0) process.stderr.write(`preflight FAIL: profile ${JSON.stringify(profile)} boots but ${brokenPresets.length} agent preset(s) are broken — every session on them fails to resume (the preset picker shows 加载失败):\n${brokenPresets.map((preset) => ` - ${preset.id}: ${preset.broken.split("\n").join("\n ")}`).join("\n")}\nfix the named row (install the package it names, repoint a folded companion row to the core package's ./tool entry, or disable the row) or remove the preset, then re-run preflight
|
|
395
|
+
`);
|
|
362
396
|
return 1;
|
|
363
397
|
}
|
|
364
398
|
process.stdout.write(`preflight PASS: profile ${JSON.stringify(profile)} boots clean\n`);
|
|
@@ -425,4 +459,4 @@ if (isDirectInvocation(import.meta.url)) {
|
|
|
425
459
|
process.exitCode = await runPreflight(profile, patchFiles, resolveHarnessRoot(), binding);
|
|
426
460
|
}
|
|
427
461
|
//#endregion
|
|
428
|
-
export { composePreflightPatches, parsePreflightArgs, resolveHarnessRoot, runPreflight };
|
|
462
|
+
export { brokenAgentPresets, composePreflightPatches, parsePreflightArgs, resolveHarnessRoot, runPreflight };
|
package/lib/types/cli.d.ts
CHANGED
|
@@ -19,6 +19,7 @@ interface CliOptions {
|
|
|
19
19
|
delayMs: number | undefined;
|
|
20
20
|
stopTimeoutMs: number | undefined;
|
|
21
21
|
supervisorYieldTimeoutMs: number | undefined;
|
|
22
|
+
bootTimeoutMs: number | undefined;
|
|
22
23
|
log: string | undefined;
|
|
23
24
|
foreground: boolean;
|
|
24
25
|
rollback: boolean;
|
|
@@ -61,6 +62,18 @@ export declare function parse(argv: readonly string[]): {
|
|
|
61
62
|
positionals: readonly string[];
|
|
62
63
|
options: CliOptions;
|
|
63
64
|
};
|
|
65
|
+
/**
|
|
66
|
+
* How the spawned watchdog should invoke the guard CLI. The executable is
|
|
67
|
+
* always the ABSOLUTE process.execPath, never a bare `node`: the watchdog runs
|
|
68
|
+
* under whatever PATH spawned it, and a launcher chain (launchd/systemd) has a
|
|
69
|
+
* minimal PATH without homebrew — a bare `node` there makes every guard
|
|
70
|
+
* invocation the watchdog issues (canary, record-proven-deployment, cutover
|
|
71
|
+
* events) fail with command-not-found (the 2026-09-30 launchd incident). The
|
|
72
|
+
* source form additionally needs tsx with an absolute path (the watchdog runs
|
|
73
|
+
* with a deployment cwd that resolves no node_modules).
|
|
74
|
+
* @returns the command prefix (verb args are appended by the watchdog).
|
|
75
|
+
*/
|
|
76
|
+
export declare function guardInvocation(): string;
|
|
64
77
|
/**
|
|
65
78
|
* How the guard invokes the dsh app's `preflight` mode: the source form runs
|
|
66
79
|
* `node --import <repo>/node_modules/tsx/dist/esm/index.mjs <repo>/apps/cli/src/bin.ts`,
|
package/lib/types/cli.js
CHANGED
|
@@ -220,12 +220,14 @@ commands:
|
|
|
220
220
|
[--home DIR] [--repo DIR] [--harness-root DIR] [--profile NAME] [--browser-handoff required|off]
|
|
221
221
|
--preflight-surface source|built [--preflight-runner FILE] --preflight-install-anchor FILE
|
|
222
222
|
--candidate-probe-command "CMD"
|
|
223
|
-
[--transition-file FILE] [--delay-ms MS] [--supervisor-yield-timeout-ms MS] [--preflight-timeout-ms MS]
|
|
223
|
+
[--transition-file FILE] [--delay-ms MS] [--supervisor-yield-timeout-ms MS] [--preflight-timeout-ms MS]
|
|
224
|
+
[--boot-timeout-ms MS] [--state-dir DIR]
|
|
224
225
|
restart --port N --start "CMD" [--pid PID] [--timeout-ms MS] [--delay-ms MS] [--stop-timeout-ms MS] [--rollback]
|
|
225
226
|
[--profile NAME] [--harness-root DIR] [--preflight-timeout-ms MS] [--state-dir DIR] [--repo DIR] [--max-age MIN]
|
|
226
227
|
schedule-exit [--port N] --delay-ms MS [--initiator ID] [--log FILE] [--profile NAME]
|
|
227
|
-
[--harness-root DIR] [--preflight-timeout-ms MS] [--state-dir DIR] [--repo DIR]
|
|
228
|
+
[--harness-root DIR] [--preflight-timeout-ms MS] [--boot-timeout-ms MS] [--state-dir DIR] [--repo DIR]
|
|
228
229
|
supervise --port N --start "CMD" [--foreground] [--log FILE] [--state-dir DIR] [--repo DIR] [--harness-root DIR] [--home DIR]
|
|
230
|
+
[--cutover-id ID] [--boot-timeout-ms MS]
|
|
229
231
|
flags:
|
|
230
232
|
--state-dir DIR state directory (default: $DSH_HOME/state, else <cwd>/.dsh-guard-state)
|
|
231
233
|
--repo DIR repository the credential binds to (default: cwd)
|
|
@@ -252,6 +254,16 @@ flags:
|
|
|
252
254
|
(agent-driven graceful self-restart: schedule, complete, then restart);
|
|
253
255
|
schedule-exit: delay before the detached exit agent kills the host;
|
|
254
256
|
reconfigure: grace after successor supervisor claim before old-child stop
|
|
257
|
+
--boot-timeout-ms MS reconfigure/schedule-exit/supervise: readiness budget for one
|
|
258
|
+
boot (the watchdog's WD_BOOT_TIMEOUT, whole seconds, default 60).
|
|
259
|
+
reconfigure/supervise hand it to the spawned watchdog's environment;
|
|
260
|
+
schedule-exit records it in the restart marker for the respawn's boot
|
|
261
|
+
window only (the running watchdog then falls back to its own budget)
|
|
262
|
+
--cutover-id ID supervise: resume the durable launch-cutover transaction after its
|
|
263
|
+
supervisor chain died (the id is in launch-status / the receipt);
|
|
264
|
+
without it a bare supervise on an awaiting-user receipt only holds
|
|
265
|
+
the claim and consumes operator control markers, never booting the
|
|
266
|
+
rejected side
|
|
255
267
|
--log FILE supervise (detached only — with --foreground the external supervisor's
|
|
256
268
|
redirection owns the log) / schedule-exit: log file (default: <state-dir>/*.log)
|
|
257
269
|
--home DIR supervise: the dsh home the supervised instance boots with (profiles,
|
|
@@ -293,6 +305,7 @@ export function parse(argv) {
|
|
|
293
305
|
const options = {
|
|
294
306
|
stateDir: '', repoDir: '', harnessRoot: '', home: '', maxAgeMinutes: 10, port: undefined, command: undefined, run: false, runArgv: undefined, message: undefined, detail: undefined,
|
|
295
307
|
start: undefined, pid: undefined, timeoutMs: undefined, delayMs: undefined, stopTimeoutMs: undefined, supervisorYieldTimeoutMs: undefined,
|
|
308
|
+
bootTimeoutMs: undefined,
|
|
296
309
|
log: undefined,
|
|
297
310
|
foreground: false, rollback: false, force: false, sync: false, initiator: undefined, profile: undefined, preflightTimeoutMs: undefined,
|
|
298
311
|
preflightSurface: undefined, preflightRunner: undefined, preflightInstallAnchor: undefined, candidateProbeCommand: undefined,
|
|
@@ -418,6 +431,17 @@ export function parse(argv) {
|
|
|
418
431
|
i++;
|
|
419
432
|
break;
|
|
420
433
|
}
|
|
434
|
+
case '--boot-timeout-ms': {
|
|
435
|
+
const raw = flagValue(arg, true);
|
|
436
|
+
const n = Number(raw);
|
|
437
|
+
// The wrapper's budget is whole seconds; sub-second values are
|
|
438
|
+
// rejected rather than silently rounded away.
|
|
439
|
+
if (raw === undefined || !Number.isInteger(n) || n < 1000)
|
|
440
|
+
throw new Error('--boot-timeout-ms must be an integer >= 1000');
|
|
441
|
+
options.bootTimeoutMs = n;
|
|
442
|
+
i++;
|
|
443
|
+
break;
|
|
444
|
+
}
|
|
421
445
|
case '--foreground':
|
|
422
446
|
options.foreground = true;
|
|
423
447
|
break;
|
|
@@ -586,12 +610,17 @@ function verifyRepoCredential(stateDir, repoDir, maxAgeMinutes) {
|
|
|
586
610
|
return verifyCredential(loadState(stateDir), currentHead(repoDir), Date.now(), maxAgeMinutes, isWorkingTreeClean(repoDir));
|
|
587
611
|
}
|
|
588
612
|
/**
|
|
589
|
-
* How the spawned watchdog should invoke the guard CLI
|
|
590
|
-
*
|
|
591
|
-
*
|
|
613
|
+
* How the spawned watchdog should invoke the guard CLI. The executable is
|
|
614
|
+
* always the ABSOLUTE process.execPath, never a bare `node`: the watchdog runs
|
|
615
|
+
* under whatever PATH spawned it, and a launcher chain (launchd/systemd) has a
|
|
616
|
+
* minimal PATH without homebrew — a bare `node` there makes every guard
|
|
617
|
+
* invocation the watchdog issues (canary, record-proven-deployment, cutover
|
|
618
|
+
* events) fail with command-not-found (the 2026-09-30 launchd incident). The
|
|
619
|
+
* source form additionally needs tsx with an absolute path (the watchdog runs
|
|
620
|
+
* with a deployment cwd that resolves no node_modules).
|
|
592
621
|
* @returns the command prefix (verb args are appended by the watchdog).
|
|
593
622
|
*/
|
|
594
|
-
function guardInvocation() {
|
|
623
|
+
export function guardInvocation() {
|
|
595
624
|
const cliPath = fileURLToPath(import.meta.url);
|
|
596
625
|
if (cliPath.includes(`${sep}src${sep}`)) {
|
|
597
626
|
// Source form: locate the tsx loader relative to this file's own
|
|
@@ -600,10 +629,10 @@ function guardInvocation() {
|
|
|
600
629
|
const nodeModules = resolve(dirname(cliPath), '../../../node_modules');
|
|
601
630
|
const tsx = join(nodeModules, 'tsx', 'dist', 'esm', 'index.mjs');
|
|
602
631
|
if (existsSync(tsx))
|
|
603
|
-
return
|
|
604
|
-
return
|
|
632
|
+
return `${process.execPath} --import ${tsx} ${cliPath}`;
|
|
633
|
+
return `${process.execPath} ${cliPath}`;
|
|
605
634
|
}
|
|
606
|
-
return
|
|
635
|
+
return `${process.execPath} ${cliPath}`;
|
|
607
636
|
}
|
|
608
637
|
/**
|
|
609
638
|
* argv (after process.execPath) that runs the exit agent, with the same
|
|
@@ -1540,7 +1569,7 @@ export async function runCli(argv, io) {
|
|
|
1540
1569
|
}
|
|
1541
1570
|
const watchdogPid = liveWatchdogPid(stateDir);
|
|
1542
1571
|
if (watchdogPid === null) {
|
|
1543
|
-
io.stderr(`${command} refused: no live watchdog can consume the durable control request\n`);
|
|
1572
|
+
io.stderr(`${command} refused: no live watchdog can consume the durable control request. Start the consumer first: \`supervise --state-dir ${stateDir}\` holds the awaiting-user cutover ${transaction.receipt.id} without launching anything, or \`supervise --cutover-id ${transaction.receipt.id} --state-dir ${stateDir}\` resumes the selected side directly\n`);
|
|
1544
1573
|
return 1;
|
|
1545
1574
|
}
|
|
1546
1575
|
const requested = command === 'restore-previous' ? 'restore-previous' : 'abort';
|
|
@@ -1873,20 +1902,34 @@ export async function runCli(argv, io) {
|
|
|
1873
1902
|
const snapshotStartedAt = Date.now();
|
|
1874
1903
|
let snapshot;
|
|
1875
1904
|
try {
|
|
1905
|
+
// The copy runs synchronously before any stop; without progress output
|
|
1906
|
+
// a multi-GB prepare looked exactly like a hang (2026-09-27: 848 s of
|
|
1907
|
+
// silence dragging the host checkout's node_modules into the snapshot).
|
|
1908
|
+
let lastProgressAt = 0;
|
|
1909
|
+
const onProgress = (progress) => {
|
|
1910
|
+
const now = Date.now();
|
|
1911
|
+
if (now - lastProgressAt < 2000)
|
|
1912
|
+
return;
|
|
1913
|
+
lastProgressAt = now;
|
|
1914
|
+
io.stdout(`preflight snapshot: ${progress.files} files / ${Math.round(progress.bytes / 1024 / 1024)} MB copied…\n`);
|
|
1915
|
+
};
|
|
1876
1916
|
snapshot = transitionPlan === undefined
|
|
1877
|
-
? createPreflightSnapshot(target.home)
|
|
1878
|
-
: createTransitionPreflightSnapshot(transitionPlan);
|
|
1917
|
+
? createPreflightSnapshot(target.home, { onProgress })
|
|
1918
|
+
: createTransitionPreflightSnapshot(transitionPlan, { onProgress });
|
|
1879
1919
|
}
|
|
1880
1920
|
catch (error) {
|
|
1881
1921
|
return refuse('preflight-snapshot', `reconfigure refused: could not prepare an isolated${transitionPlan === undefined ? '' : ' transitioned'} home: ${String(error)}\n`);
|
|
1882
1922
|
}
|
|
1883
1923
|
// A large home copy eats the credential's freshness window: the post-boot
|
|
1884
1924
|
// canary revalidates the same credential, so a slow prepare can expire it
|
|
1885
|
-
// mid-cutover and force a restore (observed with a 24 GB scratch tree
|
|
1886
|
-
//
|
|
1925
|
+
// mid-cutover and force a restore (observed with a 24 GB scratch tree).
|
|
1926
|
+
// The snapshot copies only the composition's boot inputs
|
|
1927
|
+
// (SNAPSHOT_INCLUDED_TOP_LEVEL), so size tracks the host's boot surface —
|
|
1928
|
+
// warn when it still comes in slow.
|
|
1887
1929
|
const snapshotMs = Date.now() - snapshotStartedAt;
|
|
1930
|
+
io.stdout(`isolated home snapshot ready: ${snapshot.copiedFiles} files / ${Math.round(snapshot.copiedBytes / 1024 / 1024)} MB in ${Math.round(snapshotMs / 1000)}s\n`);
|
|
1888
1931
|
if (snapshotMs > options.maxAgeMinutes * 60_000 / 2) {
|
|
1889
|
-
io.stdout(`note: the isolated-home snapshot took ${Math.round(snapshotMs / 1000)}s — over half the ${options.maxAgeMinutes}min credential window; re-record the credential immediately before reconfigure
|
|
1932
|
+
io.stdout(`note: the isolated-home snapshot took ${Math.round(snapshotMs / 1000)}s — over half the ${options.maxAgeMinutes}min credential window; re-record the credential immediately before reconfigure\n`);
|
|
1890
1933
|
}
|
|
1891
1934
|
try {
|
|
1892
1935
|
const timeout = options.preflightTimeoutMs ?? DEFAULT_PREFLIGHT_TIMEOUT_MS;
|
|
@@ -1956,6 +1999,9 @@ export async function runCli(argv, io) {
|
|
|
1956
1999
|
'--takeover-from', String(previousSupervisorPid), '--cutover-id', cutoverId,
|
|
1957
2000
|
'--delay-ms', String(options.delayMs ?? 5000),
|
|
1958
2001
|
'--supervisor-yield-timeout-ms', String(options.supervisorYieldTimeoutMs ?? 15_000),
|
|
2002
|
+
// The successor watchdog's readiness budget rides the driver argv;
|
|
2003
|
+
// the durable spec stays free of per-restart tuning.
|
|
2004
|
+
...(options.bootTimeoutMs !== undefined ? ['--boot-timeout-ms', String(options.bootTimeoutMs)] : []),
|
|
1959
2005
|
...(initiator !== undefined ? ['--initiator', initiator] : []),
|
|
1960
2006
|
];
|
|
1961
2007
|
const cutoverDriverEnv = { ...process.env };
|
|
@@ -2218,10 +2264,21 @@ export async function runCli(argv, io) {
|
|
|
2218
2264
|
io.stderr(`supervise refused: cutover ${options.cutoverId} is not the selected launch transaction\n`);
|
|
2219
2265
|
return 1;
|
|
2220
2266
|
}
|
|
2267
|
+
// A receipt parked in awaiting-user must never auto-boot the rejected
|
|
2268
|
+
// side — but exiting here leaves NO live consumer for the operator
|
|
2269
|
+
// control markers, and abort-cutover/restore-previous then refuse with
|
|
2270
|
+
// "no live watchdog" (the 2026-09-30 mid-cutover wedge). Hold instead:
|
|
2271
|
+
// spawn the watchdog in its parked mode, which claims the pidfile and
|
|
2272
|
+
// consumes those markers without launching anything.
|
|
2273
|
+
let parkedCutover = false;
|
|
2221
2274
|
if (options.cutoverId === undefined && transaction?.receipt.phase === 'awaiting-user') {
|
|
2222
|
-
|
|
2223
|
-
return 0;
|
|
2275
|
+
parkedCutover = true;
|
|
2224
2276
|
}
|
|
2277
|
+
// An explicit --cutover-id resume against a live PARKED holder: the
|
|
2278
|
+
// driver-started event below flips the receipt out of awaiting-user,
|
|
2279
|
+
// which is the hold's release signal. Remember the entry phase so the
|
|
2280
|
+
// pidfile branch below knows the live owner is expected to let go.
|
|
2281
|
+
const resumeFromAwaitingUser = options.cutoverId !== undefined && transaction?.receipt.phase === 'awaiting-user';
|
|
2225
2282
|
// A detached watchdog spawned from a sandboxed turn is reaped with it —
|
|
2226
2283
|
// refuse before claiming anything. Foreground mode is driven by the
|
|
2227
2284
|
// external supervisor (launchd/systemd) and stays exempt.
|
|
@@ -2277,7 +2334,30 @@ export async function runCli(argv, io) {
|
|
|
2277
2334
|
}
|
|
2278
2335
|
if (durable === null)
|
|
2279
2336
|
writeStableLaunchSpec(stateDir, spec);
|
|
2280
|
-
if (
|
|
2337
|
+
if (resumeFromAwaitingUser) {
|
|
2338
|
+
// The live owner is the awaiting-user hold (or a wrapper still
|
|
2339
|
+
// parked on its crash page). The hold releases its claim as
|
|
2340
|
+
// soon as the driver-started event above flips the receipt out
|
|
2341
|
+
// of awaiting-user — wait for that release, bounded: an owner
|
|
2342
|
+
// that keeps the claim is not the hold, and waiting behind it
|
|
2343
|
+
// forever would wedge the explicit resume it never sees.
|
|
2344
|
+
const existingIdentity = processIdentity(existingPid);
|
|
2345
|
+
if (existingIdentity === null) {
|
|
2346
|
+
io.stderr(`supervise refused: could not capture watchdog ${existingPid} start identity before waiting\n`);
|
|
2347
|
+
return 1;
|
|
2348
|
+
}
|
|
2349
|
+
io.stdout(`watchdog ${existing} holds the parked cutover — waiting for it to release, then resuming\n`);
|
|
2350
|
+
const releaseDeadline = Date.now() + 15_000;
|
|
2351
|
+
while (processIdentityMatches(existingIdentity) && Date.now() < releaseDeadline)
|
|
2352
|
+
await sleep(250);
|
|
2353
|
+
if (processIdentityMatches(existingIdentity)) {
|
|
2354
|
+
io.stderr(`supervise refused: watchdog ${existingPid} did not release the parked cutover ${options.cutoverId ?? ''} within 15000 ms — it is not the awaiting-user hold; settle the transaction with \`abort-cutover --state-dir ${stateDir}\` or \`restore-previous --state-dir ${stateDir}\`, or stop that watchdog and retry\n`);
|
|
2355
|
+
return 1;
|
|
2356
|
+
}
|
|
2357
|
+
waitedForWatchdog = true;
|
|
2358
|
+
io.stdout(`watchdog ${existing} released the parked cutover — resuming\n`);
|
|
2359
|
+
}
|
|
2360
|
+
else if (options.foreground) {
|
|
2281
2361
|
// Foreground = an external supervisor (launchd KeepAlive) runs
|
|
2282
2362
|
// THIS process. Exiting 0 here would read as an intentional stop
|
|
2283
2363
|
// under `KeepAlive SuccessfulExit: false`, so the job would go
|
|
@@ -2320,8 +2400,10 @@ export async function runCli(argv, io) {
|
|
|
2320
2400
|
return 1;
|
|
2321
2401
|
}
|
|
2322
2402
|
if (options.cutoverId === undefined && transaction?.receipt.phase === 'awaiting-user') {
|
|
2323
|
-
|
|
2324
|
-
|
|
2403
|
+
// Settled into awaiting-user while this supervisor waited behind the
|
|
2404
|
+
// old owner: park exactly like the entry check above instead of
|
|
2405
|
+
// booting the rejected target — or exiting and leaving no consumer.
|
|
2406
|
+
parkedCutover = true;
|
|
2325
2407
|
}
|
|
2326
2408
|
io.stdout('launch state refreshed after wait — using the durable selected specification\n');
|
|
2327
2409
|
}
|
|
@@ -2398,6 +2480,11 @@ export async function runCli(argv, io) {
|
|
|
2398
2480
|
// adoption; the detached form waits for the current owner to exit.
|
|
2399
2481
|
WD_WAIT_OWNER: options.takeoverFrom !== undefined || !options.foreground ? '1' : '0',
|
|
2400
2482
|
WD_GUARD: guardInvocation(),
|
|
2483
|
+
// One boot's readiness budget, in the wrapper's whole-seconds unit.
|
|
2484
|
+
// Ceiling: never round a requested budget DOWN into a tighter window.
|
|
2485
|
+
...(options.bootTimeoutMs !== undefined
|
|
2486
|
+
? { WD_BOOT_TIMEOUT: String(Math.ceil(options.bootTimeoutMs / 1000)) }
|
|
2487
|
+
: {}),
|
|
2401
2488
|
...(options.takeoverFrom !== undefined ? {
|
|
2402
2489
|
WD_TAKEOVER_FROM: String(options.takeoverFrom),
|
|
2403
2490
|
WD_TAKEOVER_FROM_START: previousSupervisorStart ?? '',
|
|
@@ -2418,11 +2505,18 @@ export async function runCli(argv, io) {
|
|
|
2418
2505
|
WD_PREVIOUS_CHILD_START: previousOwnership.childStartToken,
|
|
2419
2506
|
WD_PREVIOUS_LISTENER_PID: String(previousOwnership.listenerPid),
|
|
2420
2507
|
WD_PREVIOUS_LISTENER_START: previousOwnership.listenerStartToken,
|
|
2508
|
+
// Parked hold: claim supervision and consume operator control
|
|
2509
|
+
// markers, but never launch — the receipt's selected side was
|
|
2510
|
+
// explicitly rejected and waits for a user decision.
|
|
2511
|
+
...(parkedCutover ? { WD_CUTOVER_PARKED: '1' } : {}),
|
|
2421
2512
|
...(transaction.state.transition === undefined ? {} : {
|
|
2422
2513
|
WD_TRANSITION_PLAN_SHA256: transaction.state.transition.planSha256,
|
|
2423
2514
|
}),
|
|
2424
2515
|
} : {}),
|
|
2425
2516
|
};
|
|
2517
|
+
if (parkedCutover && transaction !== null) {
|
|
2518
|
+
io.stdout(`supervise: cutover ${transaction.receipt.id} is waiting for user action — holding the supervision claim WITHOUT launching the rejected ${transaction.state.selected} side (receipt ${stateFile(stateDir, 'launchCutover')}). The operator verbs now have a live consumer: \`abort-cutover --state-dir ${stateDir}\` applies the pre-approved ${transaction.receipt.recovery.policy} policy, \`restore-previous --state-dir ${stateDir}\` restores the complete previous spec. To end the hold without settling: write the stop marker (\`touch ${stateFile(stateDir, 'watchdogStop')}\`). To resume the rejected side: \`supervise --cutover-id ${transaction.receipt.id} --state-dir ${stateDir}\`\n`);
|
|
2519
|
+
}
|
|
2426
2520
|
if (options.foreground) {
|
|
2427
2521
|
// Run the watchdog inline: the CLI process stays alive as the
|
|
2428
2522
|
// watchdog's parent, so an external supervisor (launchd KeepAlive)
|
|
@@ -2460,7 +2554,9 @@ export async function runCli(argv, io) {
|
|
|
2460
2554
|
const claimDeadline = Date.now() + 5_000;
|
|
2461
2555
|
while (Date.now() < claimDeadline && spawnError === undefined) {
|
|
2462
2556
|
if (liveWatchdogPid(stateDir) === spawnedPid) {
|
|
2463
|
-
io.stdout(
|
|
2557
|
+
io.stdout(parkedCutover && transaction !== null
|
|
2558
|
+
? `watchdog spawned and parked on cutover ${transaction.receipt.id} (pid ${spawnedPid}) — nothing launched; consuming abort-cutover/restore-previous markers; log ${logPath}\n`
|
|
2559
|
+
: `watchdog spawned and ready (pid ${spawnedPid}) — supervises :${spec.port}, log ${logPath}\n`);
|
|
2464
2560
|
return 0;
|
|
2465
2561
|
}
|
|
2466
2562
|
try {
|
|
@@ -2570,6 +2666,10 @@ export async function runCli(argv, io) {
|
|
|
2570
2666
|
writeFileSync(stateFile(stateDir, 'restartRequested'), `${JSON.stringify({
|
|
2571
2667
|
reason: 'scheduled self-restart',
|
|
2572
2668
|
requestedAt: Date.now(),
|
|
2669
|
+
// A one-boot readiness budget for the respawn: the live watchdog
|
|
2670
|
+
// applies it over its own WD_BOOT_TIMEOUT for boots attempted
|
|
2671
|
+
// while this marker is pending, then falls back to its default.
|
|
2672
|
+
...(options.bootTimeoutMs === undefined ? {} : { bootTimeoutMs: options.bootTimeoutMs }),
|
|
2573
2673
|
...(gate.authorization === undefined ? {} : { authorization: gate.authorization }),
|
|
2574
2674
|
...(initiator !== undefined ? { initiator } : {}),
|
|
2575
2675
|
})}\n`);
|
|
@@ -19,6 +19,13 @@
|
|
|
19
19
|
* activation — with the webserver port pinned to 0 (OS-assigned) so the
|
|
20
20
|
* dry-run never collides with the live instance;
|
|
21
21
|
* - every registered client bundle artifact exists on disk;
|
|
22
|
+
* - every registered agent preset is USABLE, not merely loaded: preset rows
|
|
23
|
+
* mount on the registry's standing scopes beside the profile tree, so a row
|
|
24
|
+
* whose module stopped resolving (a folded companion's retired package name,
|
|
25
|
+
* a base version predating its ./tool entry) never fails the boot itself —
|
|
26
|
+
* it fails every SESSION of that preset later (the picker shows 加载失败,
|
|
27
|
+
* resume answers "never started"; 3080, 2026-09-28). The audit reads the
|
|
28
|
+
* preset registry's own `broken` diagnostic back from the dry-run boot;
|
|
22
29
|
* - dispose rolls every effect back.
|
|
23
30
|
*
|
|
24
31
|
* Exit codes (the contract the guard consumes):
|
|
@@ -91,6 +98,26 @@ export interface PreflightComposition {
|
|
|
91
98
|
* @returns the patch stack and composed rows.
|
|
92
99
|
*/
|
|
93
100
|
export declare function composePreflightPatches(profile: string, patchFiles: readonly string[], root: string, home?: string, binding?: PreflightHostBinding): Promise<PreflightComposition>;
|
|
101
|
+
/**
|
|
102
|
+
* The preset-roster half of the verdict. A profile can boot clean while one
|
|
103
|
+
* of its agent presets is BROKEN: preset rows mount on the registry's
|
|
104
|
+
* standing scopes, not on the profile root the dry-run boots, so a row whose
|
|
105
|
+
* module stopped resolving never fails the boot — it surfaces later as the
|
|
106
|
+
* preset picker's 加载失败 badge and `resume failed … never started` on every
|
|
107
|
+
* session of that preset (3080, 2026-09-28: the dev preset named
|
|
108
|
+
* `@khorsheed/dsh-worktrees/tool` while the installed worktrees predated the
|
|
109
|
+
* entry). The registry already computes this verdict: it activates every
|
|
110
|
+
* registered preset eagerly and records a mount failure as `broken`, and its
|
|
111
|
+
* `list()` re-audits mounted trees after the loader settles, so rows still
|
|
112
|
+
* waiting on a host service report their pending reason instead of passing
|
|
113
|
+
* silently. Fail the dry-run on any broken preset — the restart this gate
|
|
114
|
+
* protects would serve those broken sessions. A host whose registry face is
|
|
115
|
+
* absent or list-less degrades to no findings: the audit never invents one.
|
|
116
|
+
*/
|
|
117
|
+
export declare function brokenAgentPresets(ctx: unknown): Promise<Array<{
|
|
118
|
+
id: string;
|
|
119
|
+
broken: string;
|
|
120
|
+
}>>;
|
|
94
121
|
/**
|
|
95
122
|
* Boot the profile's full tree once, tear it down, and report the verdict on
|
|
96
123
|
* the process streams. No HMR, no user-patch watchers, no signal wiring —
|
|
@@ -19,6 +19,13 @@
|
|
|
19
19
|
* activation — with the webserver port pinned to 0 (OS-assigned) so the
|
|
20
20
|
* dry-run never collides with the live instance;
|
|
21
21
|
* - every registered client bundle artifact exists on disk;
|
|
22
|
+
* - every registered agent preset is USABLE, not merely loaded: preset rows
|
|
23
|
+
* mount on the registry's standing scopes beside the profile tree, so a row
|
|
24
|
+
* whose module stopped resolving (a folded companion's retired package name,
|
|
25
|
+
* a base version predating its ./tool entry) never fails the boot itself —
|
|
26
|
+
* it fails every SESSION of that preset later (the picker shows 加载失败,
|
|
27
|
+
* resume answers "never started"; 3080, 2026-09-28). The audit reads the
|
|
28
|
+
* preset registry's own `broken` diagnostic back from the dry-run boot;
|
|
22
29
|
* - dispose rolls every effect back.
|
|
23
30
|
*
|
|
24
31
|
* Exit codes (the contract the guard consumes):
|
|
@@ -377,6 +384,31 @@ function missingClientArtifacts(ctx) {
|
|
|
377
384
|
}
|
|
378
385
|
return missing;
|
|
379
386
|
}
|
|
387
|
+
/**
|
|
388
|
+
* The preset-roster half of the verdict. A profile can boot clean while one
|
|
389
|
+
* of its agent presets is BROKEN: preset rows mount on the registry's
|
|
390
|
+
* standing scopes, not on the profile root the dry-run boots, so a row whose
|
|
391
|
+
* module stopped resolving never fails the boot — it surfaces later as the
|
|
392
|
+
* preset picker's 加载失败 badge and `resume failed … never started` on every
|
|
393
|
+
* session of that preset (3080, 2026-09-28: the dev preset named
|
|
394
|
+
* `@khorsheed/dsh-worktrees/tool` while the installed worktrees predated the
|
|
395
|
+
* entry). The registry already computes this verdict: it activates every
|
|
396
|
+
* registered preset eagerly and records a mount failure as `broken`, and its
|
|
397
|
+
* `list()` re-audits mounted trees after the loader settles, so rows still
|
|
398
|
+
* waiting on a host service report their pending reason instead of passing
|
|
399
|
+
* silently. Fail the dry-run on any broken preset — the restart this gate
|
|
400
|
+
* protects would serve those broken sessions. A host whose registry face is
|
|
401
|
+
* absent or list-less degrades to no findings: the audit never invents one.
|
|
402
|
+
*/
|
|
403
|
+
export async function brokenAgentPresets(ctx) {
|
|
404
|
+
const registry = ctx.get?.('agentPresets');
|
|
405
|
+
if (registry === undefined || typeof registry.list !== 'function')
|
|
406
|
+
return [];
|
|
407
|
+
const rows = await registry.list();
|
|
408
|
+
return rows.flatMap(row => row !== null && typeof row === 'object' && typeof row.id === 'string' && typeof row.broken === 'string'
|
|
409
|
+
? [{ id: row.id, broken: row.broken }]
|
|
410
|
+
: []);
|
|
411
|
+
}
|
|
380
412
|
/**
|
|
381
413
|
* Boot the profile's full tree once, tear it down, and report the verdict on
|
|
382
414
|
* the process streams. No HMR, no user-patch watchers, no signal wiring —
|
|
@@ -462,11 +494,20 @@ export async function runPreflight(profile, patchFiles = [], root = resolveHarne
|
|
|
462
494
|
// that point.
|
|
463
495
|
appReady.commit();
|
|
464
496
|
const missing = missingClientArtifacts(ctx);
|
|
497
|
+
// The registry settles pending rows against the finished loader tree, so
|
|
498
|
+
// the audit runs after boot completion and before dispose.
|
|
499
|
+
const brokenPresets = await brokenAgentPresets(ctx);
|
|
465
500
|
// A repeated dispose returns the settled single-shot result when boot
|
|
466
501
|
// already tore the tree down, so this is safe on every path.
|
|
467
502
|
await ctx.fiber.dispose();
|
|
468
|
-
if (missing.length > 0) {
|
|
469
|
-
|
|
503
|
+
if (missing.length > 0 || brokenPresets.length > 0) {
|
|
504
|
+
if (missing.length > 0) {
|
|
505
|
+
process.stderr.write(`preflight FAIL: profile ${JSON.stringify(profile)} boots but client bundle artifacts are missing or unreadable:\n${missing.map(line => ` - ${line}`).join('\n')}\nrun \`pnpm run build\` before launch\n`);
|
|
506
|
+
}
|
|
507
|
+
if (brokenPresets.length > 0) {
|
|
508
|
+
process.stderr.write(`preflight FAIL: profile ${JSON.stringify(profile)} boots but ${brokenPresets.length} agent preset(s) are broken — every session on them fails to resume (the preset picker shows 加载失败):\n${brokenPresets.map(preset => ` - ${preset.id}: ${preset.broken.split('\n').join('\n ')}`).join('\n')}\n`
|
|
509
|
+
+ 'fix the named row (install the package it names, repoint a folded companion row to the core package\'s ./tool entry, or disable the row) or remove the preset, then re-run preflight\n');
|
|
510
|
+
}
|
|
470
511
|
return 1;
|
|
471
512
|
}
|
|
472
513
|
process.stdout.write(`preflight PASS: profile ${JSON.stringify(profile)} boots clean\n`);
|
|
@@ -94,24 +94,64 @@ export declare function rollbackTransition(reference: TransitionReference, expec
|
|
|
94
94
|
*/
|
|
95
95
|
export declare function readTransitionRecord(reference: TransitionReference, expectedHome: string, stateDir: string, cutoverId: string): TransitionRecord;
|
|
96
96
|
/**
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
97
|
+
* Top-level home entries the preflight snapshot copies — the ALLOWLIST of
|
|
98
|
+
* inputs the launcher's boot actually reads: the profile trees, the home-level
|
|
99
|
+
* patch layer, settings, and the credential/identity stores. Everything else
|
|
100
|
+
* (plugin data: sessions, state, local-agent sub-homes, tarballs, scratch, …)
|
|
101
|
+
* is excluded BY DEFAULT, so a newly installed plugin's data directory can
|
|
102
|
+
* never silently join the copy — this list moves only when the HOST's boot
|
|
103
|
+
* starts reading a new home input, and a miss fails the dry-run loudly with
|
|
104
|
+
* the missing path rather than degrading into a slow copy. The denylist this
|
|
105
|
+
* replaced failed twice the other way: a 24 GB scratch tree expired the
|
|
106
|
+
* credential mid-cutover (canary failed, restored), and on 2026-09-27 the
|
|
107
|
+
* local-agent sub-home's absolute links dragged the host checkout's entire
|
|
108
|
+
* node_modules into a ~4 GB / 848 s prepare.
|
|
109
|
+
*/
|
|
110
|
+
export declare const SNAPSHOT_INCLUDED_TOP_LEVEL: readonly string[];
|
|
111
|
+
/** Copy progress, reported from the file branch of {@link copySnapshotNode}. */
|
|
112
|
+
export interface SnapshotProgress {
|
|
113
|
+
files: number;
|
|
114
|
+
bytes: number;
|
|
115
|
+
skippedRuntimeEntries: number;
|
|
116
|
+
}
|
|
117
|
+
export interface PreflightSnapshotOptions {
|
|
118
|
+
/**
|
|
119
|
+
* Top-level home entries to copy (default: {@link SNAPSHOT_INCLUDED_TOP_LEVEL}).
|
|
120
|
+
* `createTransitionPreflightSnapshot` unions its plan's operation roots in.
|
|
121
|
+
*/
|
|
122
|
+
includeTopLevel?: readonly string[];
|
|
123
|
+
/** Invoked as the copy advances; the caller throttles its own output. */
|
|
124
|
+
onProgress?: (progress: SnapshotProgress) => void;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Clone a live home's BOOT INPUTS while retaining a contained package-link
|
|
128
|
+
* graph. Only the allowlisted top-level entries are copied (see
|
|
129
|
+
* {@link SNAPSHOT_INCLUDED_TOP_LEVEL}) — plugin data directories are excluded
|
|
130
|
+
* by default, so the copy's size is bounded by what the composition's boot
|
|
131
|
+
* reads, not by whatever the home happens to hold. Internal links are rebuilt
|
|
132
|
+
* against copied nodes; external targets are deduplicated in a snapshot-owned
|
|
133
|
+
* materialization area. No retained link resolves outside the snapshot root,
|
|
134
|
+
* so writes through pnpm/Cordis links cannot reach live bytes. Runtime entries
|
|
135
|
+
* without copyable content (sockets, FIFOs — and links to them) are skipped
|
|
136
|
+
* and counted, never copied. Device nodes still fail closed.
|
|
104
137
|
* @param sourceHome - Live dsh home to read.
|
|
105
|
-
* @
|
|
138
|
+
* @param options - Include-list override and progress callback.
|
|
139
|
+
* @returns Isolated home, an idempotent cleanup callback, and copy statistics.
|
|
106
140
|
*/
|
|
107
|
-
export declare function createPreflightSnapshot(sourceHome: string): {
|
|
141
|
+
export declare function createPreflightSnapshot(sourceHome: string, options?: PreflightSnapshotOptions): {
|
|
108
142
|
home: string;
|
|
109
143
|
root: string;
|
|
110
144
|
skippedRuntimeEntries: number;
|
|
145
|
+
copiedFiles: number;
|
|
146
|
+
copiedBytes: number;
|
|
111
147
|
cleanup(): void;
|
|
112
148
|
};
|
|
113
|
-
export declare function createTransitionPreflightSnapshot(plan: TransitionPlan): {
|
|
149
|
+
export declare function createTransitionPreflightSnapshot(plan: TransitionPlan, options?: PreflightSnapshotOptions): {
|
|
114
150
|
home: string;
|
|
151
|
+
root: string;
|
|
152
|
+
skippedRuntimeEntries: number;
|
|
153
|
+
copiedFiles: number;
|
|
154
|
+
copiedBytes: number;
|
|
115
155
|
cleanup(): void;
|
|
116
156
|
};
|
|
117
157
|
export {};
|