@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.
@@ -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 };
@@ -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] [--state-dir DIR]
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: the built form runs
590
- * `node <cli>`; the source form needs tsx with an absolute path (the watchdog
591
- * runs with a deployment cwd that resolves no node_modules).
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 `node --import ${tsx} ${cliPath}`;
604
- return `node ${cliPath}`;
632
+ return `${process.execPath} --import ${tsx} ${cliPath}`;
633
+ return `${process.execPath} ${cliPath}`;
605
634
  }
606
- return `node ${cliPath}`;
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
- // scratch/ is now excluded; warn early when the remaining copy is slow).
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, and keep the home slim (top-level scratch/ is excluded from the copy)\n`);
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
- io.stderr(`supervise: cutover ${transaction.receipt.id} is waiting for user action; refusing to restart the rejected target automatically (receipt ${stateFile(stateDir, 'launchCutover')})\n`);
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 (options.foreground) {
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
- io.stderr(`supervise: cutover ${transaction.receipt.id} settled awaiting-user while this supervisor waited; refusing to restart the rejected target (receipt ${stateFile(stateDir, 'launchCutover')})\n`);
2324
- return 0;
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(`watchdog spawned and ready (pid ${spawnedPid}) — supervises :${spec.port}, log ${logPath}\n`);
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
- 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`);
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
- * Clone a live home while retaining a contained package-link graph. Internal
98
- * links are rebuilt against copied nodes; external targets are deduplicated in
99
- * a snapshot-owned materialization area. No retained link resolves outside the
100
- * snapshot root, so writes through pnpm/Cordis links cannot reach live bytes.
101
- * Runtime entries without copyable content (sockets, FIFOs — and links to
102
- * them) are skipped and counted, never copied; the top-level scratch/ tree is
103
- * excluded for size. Device nodes still fail closed.
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
- * @returns Isolated home, an idempotent cleanup callback, and the count of skipped runtime entries.
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 {};