@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/types/transition.js
CHANGED
|
@@ -499,12 +499,26 @@ function snapshotCopyError(source, error) {
|
|
|
499
499
|
return new Error(`preflight snapshot could not safely copy ${source}: ${String(error)}`);
|
|
500
500
|
}
|
|
501
501
|
/**
|
|
502
|
-
* Top-level home entries
|
|
503
|
-
*
|
|
504
|
-
*
|
|
505
|
-
*
|
|
502
|
+
* Top-level home entries the preflight snapshot copies — the ALLOWLIST of
|
|
503
|
+
* inputs the launcher's boot actually reads: the profile trees, the home-level
|
|
504
|
+
* patch layer, settings, and the credential/identity stores. Everything else
|
|
505
|
+
* (plugin data: sessions, state, local-agent sub-homes, tarballs, scratch, …)
|
|
506
|
+
* is excluded BY DEFAULT, so a newly installed plugin's data directory can
|
|
507
|
+
* never silently join the copy — this list moves only when the HOST's boot
|
|
508
|
+
* starts reading a new home input, and a miss fails the dry-run loudly with
|
|
509
|
+
* the missing path rather than degrading into a slow copy. The denylist this
|
|
510
|
+
* replaced failed twice the other way: a 24 GB scratch tree expired the
|
|
511
|
+
* credential mid-cutover (canary failed, restored), and on 2026-09-27 the
|
|
512
|
+
* local-agent sub-home's absolute links dragged the host checkout's entire
|
|
513
|
+
* node_modules into a ~4 GB / 848 s prepare.
|
|
506
514
|
*/
|
|
507
|
-
const
|
|
515
|
+
export const SNAPSHOT_INCLUDED_TOP_LEVEL = [
|
|
516
|
+
'profiles',
|
|
517
|
+
'settings.yaml',
|
|
518
|
+
'cordis.patch.yml',
|
|
519
|
+
'.credentials.yaml',
|
|
520
|
+
'.anonymous-user-id',
|
|
521
|
+
];
|
|
508
522
|
function canonicalSnapshotSource(source) {
|
|
509
523
|
try {
|
|
510
524
|
return realpathSync(source);
|
|
@@ -558,7 +572,7 @@ function copySnapshotNode(source, destination, context) {
|
|
|
558
572
|
});
|
|
559
573
|
try {
|
|
560
574
|
for (const name of readdirSync(canonical)) {
|
|
561
|
-
if (canonical === context.rootCanonical &&
|
|
575
|
+
if (canonical === context.rootCanonical && !context.includeTopLevel.has(name))
|
|
562
576
|
continue;
|
|
563
577
|
copySnapshotNode(join(canonical, name), join(destination, name), context);
|
|
564
578
|
}
|
|
@@ -574,6 +588,13 @@ function copySnapshotNode(source, destination, context) {
|
|
|
574
588
|
copyFileSync(canonical, destination, constants.COPYFILE_FICLONE);
|
|
575
589
|
chmodSync(destination, linkMetadata.mode & 0o7777);
|
|
576
590
|
utimesSync(destination, linkMetadata.atime, linkMetadata.mtime);
|
|
591
|
+
context.copiedFiles++;
|
|
592
|
+
context.copiedBytes += linkMetadata.size;
|
|
593
|
+
context.onProgress?.({
|
|
594
|
+
files: context.copiedFiles,
|
|
595
|
+
bytes: context.copiedBytes,
|
|
596
|
+
skippedRuntimeEntries: context.skippedRuntimeEntries,
|
|
597
|
+
});
|
|
577
598
|
}
|
|
578
599
|
catch (error) {
|
|
579
600
|
throw snapshotCopyError(source, error);
|
|
@@ -581,12 +602,19 @@ function copySnapshotNode(source, destination, context) {
|
|
|
581
602
|
}
|
|
582
603
|
/**
|
|
583
604
|
* Preserve Node's ancestor node_modules lookup for an external package while
|
|
584
|
-
* avoiding one copy per package link.
|
|
585
|
-
*
|
|
605
|
+
* avoiding one copy per package link. The anchor is the package's OWN parent
|
|
606
|
+
* node_modules — the LAST node_modules segment in the resolved target: pnpm
|
|
607
|
+
* store links resolve to …/.pnpm/<name>@<version>/node_modules/<name>, where
|
|
608
|
+
* that parent already holds the package's dependency siblings, so the lookup
|
|
609
|
+
* survives at the tightest scope. Anchoring the FIRST segment instead dragged
|
|
610
|
+
* the whole multi-GB store root into the snapshot (observed 2026-09-27: 32
|
|
611
|
+
* profile links pulled in the host checkout's entire root node_modules).
|
|
612
|
+
* Other external targets are materialized individually and still deduplicated
|
|
613
|
+
* by canonical path.
|
|
586
614
|
*/
|
|
587
615
|
function externalMaterializationAnchor(target) {
|
|
588
616
|
const parsed = resolve(target).split(sep);
|
|
589
|
-
const nodeModulesIndex = parsed.
|
|
617
|
+
const nodeModulesIndex = parsed.lastIndexOf('node_modules');
|
|
590
618
|
if (nodeModulesIndex >= 0) {
|
|
591
619
|
const prefix = parsed.slice(0, nodeModulesIndex + 1).join(sep) || sep;
|
|
592
620
|
return { source: prefix, destination: 'node_modules' };
|
|
@@ -665,17 +693,21 @@ function finalizeSnapshotDirectories(context) {
|
|
|
665
693
|
}
|
|
666
694
|
}
|
|
667
695
|
/**
|
|
668
|
-
* Clone a live home while retaining a contained package-link
|
|
669
|
-
*
|
|
670
|
-
*
|
|
671
|
-
*
|
|
672
|
-
*
|
|
673
|
-
*
|
|
674
|
-
*
|
|
696
|
+
* Clone a live home's BOOT INPUTS while retaining a contained package-link
|
|
697
|
+
* graph. Only the allowlisted top-level entries are copied (see
|
|
698
|
+
* {@link SNAPSHOT_INCLUDED_TOP_LEVEL}) — plugin data directories are excluded
|
|
699
|
+
* by default, so the copy's size is bounded by what the composition's boot
|
|
700
|
+
* reads, not by whatever the home happens to hold. Internal links are rebuilt
|
|
701
|
+
* against copied nodes; external targets are deduplicated in a snapshot-owned
|
|
702
|
+
* materialization area. No retained link resolves outside the snapshot root,
|
|
703
|
+
* so writes through pnpm/Cordis links cannot reach live bytes. Runtime entries
|
|
704
|
+
* without copyable content (sockets, FIFOs — and links to them) are skipped
|
|
705
|
+
* and counted, never copied. Device nodes still fail closed.
|
|
675
706
|
* @param sourceHome - Live dsh home to read.
|
|
676
|
-
* @
|
|
707
|
+
* @param options - Include-list override and progress callback.
|
|
708
|
+
* @returns Isolated home, an idempotent cleanup callback, and copy statistics.
|
|
677
709
|
*/
|
|
678
|
-
export function createPreflightSnapshot(sourceHome) {
|
|
710
|
+
export function createPreflightSnapshot(sourceHome, options = {}) {
|
|
679
711
|
const root = mkdtempSync(join(tmpdir(), 'ankh-transition-preflight-'));
|
|
680
712
|
const home = join(root, 'home');
|
|
681
713
|
try {
|
|
@@ -684,31 +716,48 @@ export function createPreflightSnapshot(sourceHome) {
|
|
|
684
716
|
const context = {
|
|
685
717
|
externalRoot: join(root, 'materialized'),
|
|
686
718
|
rootCanonical: source,
|
|
719
|
+
includeTopLevel: new Set(options.includeTopLevel ?? SNAPSHOT_INCLUDED_TOP_LEVEL),
|
|
687
720
|
destinations: new Map(),
|
|
688
721
|
pendingLinks: [],
|
|
689
722
|
directories: [],
|
|
690
723
|
skippedRuntimeEntries: 0,
|
|
724
|
+
copiedFiles: 0,
|
|
725
|
+
copiedBytes: 0,
|
|
726
|
+
...(options.onProgress === undefined ? {} : { onProgress: options.onProgress }),
|
|
691
727
|
};
|
|
692
728
|
copySnapshotNode(source, home, context);
|
|
693
729
|
resolveSnapshotLinks(context);
|
|
694
730
|
assertSnapshotLinksContained(root, realpathSync(root));
|
|
695
731
|
finalizeSnapshotDirectories(context);
|
|
696
|
-
return {
|
|
732
|
+
return {
|
|
733
|
+
home,
|
|
734
|
+
root,
|
|
735
|
+
skippedRuntimeEntries: context.skippedRuntimeEntries,
|
|
736
|
+
copiedFiles: context.copiedFiles,
|
|
737
|
+
copiedBytes: context.copiedBytes,
|
|
738
|
+
cleanup: () => { rmSync(root, { recursive: true, force: true }); },
|
|
739
|
+
};
|
|
697
740
|
}
|
|
698
741
|
catch (error) {
|
|
699
742
|
rmSync(root, { recursive: true, force: true });
|
|
700
743
|
throw error;
|
|
701
744
|
}
|
|
702
745
|
}
|
|
703
|
-
export function createTransitionPreflightSnapshot(plan) {
|
|
704
|
-
|
|
746
|
+
export function createTransitionPreflightSnapshot(plan, options = {}) {
|
|
747
|
+
// A transition rehearses the plan's exact operation paths, so their
|
|
748
|
+
// top-level roots join the copy even when they are not boot inputs.
|
|
749
|
+
const operationRoots = plan.operations.map(operation => operation.path.split(sep)[0]);
|
|
750
|
+
const snapshot = createPreflightSnapshot(plan.home, {
|
|
751
|
+
...options,
|
|
752
|
+
includeTopLevel: [...new Set([...(options.includeTopLevel ?? SNAPSHOT_INCLUDED_TOP_LEVEL), ...operationRoots])],
|
|
753
|
+
});
|
|
705
754
|
try {
|
|
706
755
|
const stateDir = join(snapshot.root, 'guard-state');
|
|
707
756
|
mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
708
757
|
const rebound = { ...plan, home: snapshot.home };
|
|
709
758
|
const reference = prepareTransition(rebound, snapshot.home, stateDir, 'preflight');
|
|
710
759
|
applyTransition(reference, snapshot.home, stateDir, 'preflight');
|
|
711
|
-
return
|
|
760
|
+
return snapshot;
|
|
712
761
|
}
|
|
713
762
|
catch (error) {
|
|
714
763
|
snapshot.cleanup();
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@khorsheed/dsh-ankh-guard",
|
|
3
3
|
"description": "Hard gate for self-modification restarts: a green-build credential bound to the git HEAD, checked before any restart of the running instance",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.4.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
|
@@ -44,14 +44,14 @@
|
|
|
44
44
|
"license": "MIT",
|
|
45
45
|
"peerDependencies": {
|
|
46
46
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
47
|
-
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
|
|
48
|
-
"@deepseek-ai/dsh-agent-preset-registry": "^0.1.0-rc.6",
|
|
49
|
-
"@deepseek-ai/dsh-agent-presets": "^0.1.0-rc.6",
|
|
50
|
-
"@deepseek-ai/dsh-client-connection": "^0.1.0-rc.6",
|
|
51
|
-
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
|
|
52
|
-
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
|
|
53
|
-
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
|
|
54
|
-
"@deepseek-ai/dsh-session-persistence": "^0.1.0-rc.6",
|
|
47
|
+
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6 || ^0.2.0-rc.1",
|
|
48
|
+
"@deepseek-ai/dsh-agent-preset-registry": "^0.1.0-rc.6 || ^0.2.0-rc.1",
|
|
49
|
+
"@deepseek-ai/dsh-agent-presets": "^0.1.0-rc.6 || ^0.2.0-rc.1",
|
|
50
|
+
"@deepseek-ai/dsh-client-connection": "^0.1.0-rc.6 || ^0.2.0-rc.1",
|
|
51
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6 || ^0.2.0-rc.1",
|
|
52
|
+
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6 || ^0.2.0-rc.1",
|
|
53
|
+
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6 || ^0.2.0-rc.1",
|
|
54
|
+
"@deepseek-ai/dsh-session-persistence": "^0.1.0-rc.6 || ^0.2.0-rc.1",
|
|
55
55
|
"@deepseek-ai/schemastery": "^3.18.1"
|
|
56
56
|
},
|
|
57
57
|
"peerDependenciesMeta": {
|
|
@@ -90,7 +90,7 @@
|
|
|
90
90
|
},
|
|
91
91
|
"compat": {
|
|
92
92
|
"minHost": "0.1.5-rc.1",
|
|
93
|
-
"notes": "0.1.5-rc.1 full-line boot-verified 2026-09-25 (42 packages including capture) through the three compat layers: preset-registry dual-name probe, dual-shape typert codecs, and typert faces carrying zod@4; 0.1.7-rc.2 is the 3080 production-verified line; composition-preflight runs via the standalone preflight-runner and degrades to a notice when no live harness checkout resolves; original-tab browser handoff feature-probes optional WebServer/connection auth seams; reversible state quarantine was exercised in a live 0.1.1-rc.2 to 0.1.2-alpha.4 cutover",
|
|
93
|
+
"notes": "0.1.5-rc.1 full-line boot-verified 2026-09-25 (42 packages including capture) through the three compat layers: preset-registry dual-name probe, dual-shape typert codecs, and typert faces carrying zod@4; 0.1.7-rc.2 is the 3080 production-verified line; composition-preflight runs via the standalone preflight-runner and degrades to a notice when no live harness checkout resolves; original-tab browser handoff feature-probes optional WebServer/connection auth seams; reversible state quarantine was exercised in a live 0.1.1-rc.2 to 0.1.2-alpha.4 cutover; since 0.4.0 the preflight verdict also fails on any agent preset the registry marks broken — preset rows mount on standing scopes beside the profile tree, so a clean-booting profile whose presets cannot serve sessions (picker 加载失败, resume never-started) no longer passes the gate",
|
|
94
94
|
"verifiedHost": "0.1.7-rc.2"
|
|
95
95
|
}
|
|
96
96
|
}
|
package/scripts/dsh-watchdog.sh
CHANGED
|
@@ -48,6 +48,11 @@
|
|
|
48
48
|
# WD_CUTOVER_ID=ID durable launch-cutover receipt transaction
|
|
49
49
|
# WD_CUTOVER_POLICY= restore-previous or wait-for-user (approved pre-stop)
|
|
50
50
|
# WD_CUTOVER_DELAY_SECONDS=N grace after supervisor claim before old-child stop
|
|
51
|
+
# WD_CUTOVER_PARKED=1 hold the supervision claim while the receipt sits in
|
|
52
|
+
# awaiting-user: never launch the rejected side, consume
|
|
53
|
+
# abort/restore control markers, exit on watchdog-stop,
|
|
54
|
+
# and release the claim when a --cutover-id resume flips
|
|
55
|
+
# the receipt out of awaiting-user
|
|
51
56
|
# WD_PREVIOUS_CHILD_*=PID/start token authoritative old supervisor child root
|
|
52
57
|
# WD_PREVIOUS_LISTENER_*=PID/start token listener inside that old child tree
|
|
53
58
|
# WD_READY_STABILITY_SECONDS=N unchanged child/listener proof window (default 3)
|
|
@@ -61,7 +66,9 @@
|
|
|
61
66
|
# WD_TEST_BREAK=1 launch a command that always fails (give-up testing)
|
|
62
67
|
#
|
|
63
68
|
# Markers under the state directory (written by the app or the agent):
|
|
64
|
-
# restart-requested.json -> intentional restart: respawn + canary + clear
|
|
69
|
+
# restart-requested.json -> intentional restart: respawn + canary + clear;
|
|
70
|
+
# an optional integer bootTimeoutMs field overrides
|
|
71
|
+
# WD_BOOT_TIMEOUT for boots attempted while it is pending
|
|
65
72
|
# watchdog-stop -> exit the watchdog without respawn
|
|
66
73
|
set -u
|
|
67
74
|
|
|
@@ -219,6 +226,19 @@ cutover_control_action() {
|
|
|
219
226
|
' "$CONTROL_RESTORE_FILE" "$CONTROL_ABORT_FILE" "$CONTROL_FILE" "$CUTOVER_ID" 2>/dev/null
|
|
220
227
|
}
|
|
221
228
|
|
|
229
|
+
# The durable receipt's current phase, empty when unreadable. The parked hold
|
|
230
|
+
# treats an unreadable receipt as still parked (hold, never flap): only a
|
|
231
|
+
# cleanly read non-awaiting-user phase releases the claim.
|
|
232
|
+
cutover_receipt_phase() {
|
|
233
|
+
node -e '
|
|
234
|
+
const fs = require("fs")
|
|
235
|
+
try {
|
|
236
|
+
const value = JSON.parse(fs.readFileSync(process.argv[1], "utf8"))
|
|
237
|
+
if (typeof value?.phase === "string") process.stdout.write(value.phase)
|
|
238
|
+
} catch {}
|
|
239
|
+
' "$STATE_DIR/launch-cutover.json" 2>/dev/null
|
|
240
|
+
}
|
|
241
|
+
|
|
222
242
|
# The host owns the shape of its per-process launch URL. Discovery is generic:
|
|
223
243
|
# the first HTTP URL printed by THIS attempt whose authority is exactly the
|
|
224
244
|
# supervised loopback authority and whose query is non-empty. No parameter
|
|
@@ -1239,6 +1259,57 @@ if [ -n "${ANKH_GUARD_TEST_RUN_DIR:-}" ]; then wd_sleep 0.01; fi
|
|
|
1239
1259
|
test_event_self "${ANKH_GUARD_TEST_PROCESS_ROLE:-watchdog}" keepalive-first-tick
|
|
1240
1260
|
test_event_self "${ANKH_GUARD_TEST_PROCESS_ROLE:-watchdog}" ready
|
|
1241
1261
|
|
|
1262
|
+
# Parked awaiting-user hold (WD_CUTOVER_PARKED=1, spawned by a bare `supervise`
|
|
1263
|
+
# that found the receipt waiting for a user decision): the selected side was
|
|
1264
|
+
# explicitly rejected, so NOTHING is launched here. The hold keeps exactly one
|
|
1265
|
+
# live consumer for the operator control verbs — without it, a supervisor chain
|
|
1266
|
+
# that died mid-cutover wedges the transaction: abort-cutover/restore-previous
|
|
1267
|
+
# refuse with "no live watchdog", and a bare supervise used to exit 0.
|
|
1268
|
+
if [ "${WD_CUTOVER_PARKED:-0}" = "1" ]; then
|
|
1269
|
+
wd_log "launch cutover $CUTOVER_ID is awaiting user action — holding the supervision claim WITHOUT launching the rejected $CUTOVER_ROLE side; settle: abort-cutover / restore-previous; exit: the watchdog-stop marker; resume the rejected side: supervise --cutover-id $CUTOVER_ID"
|
|
1270
|
+
while true; do
|
|
1271
|
+
# Same ownership discipline as the main loop: self-heal a deleted claim,
|
|
1272
|
+
# yield to a different live owner.
|
|
1273
|
+
if [ "$SUPERVISE" = "1" ]; then
|
|
1274
|
+
if [ ! -f "$PIDFILE" ]; then (set -C; echo $$ > "$PIDFILE") 2>/dev/null || true; fi
|
|
1275
|
+
pidowner=$(cat "$PIDFILE" 2>/dev/null)
|
|
1276
|
+
if [ -n "$pidowner" ] && [ "$pidowner" != "$$" ] && kill -0 "$pidowner" 2>/dev/null; then
|
|
1277
|
+
wd_log "pidfile now owned by live pid $pidowner — yielding the parked hold"
|
|
1278
|
+
yielded=1
|
|
1279
|
+
exit 75
|
|
1280
|
+
fi
|
|
1281
|
+
fi
|
|
1282
|
+
# Deliberate stop: exit without respawn or settlement, exactly like the
|
|
1283
|
+
# main loop's stop-marker path.
|
|
1284
|
+
if [ -f "$STOP_MARKER" ]; then
|
|
1285
|
+
wd_log "stop marker present — exiting the parked hold"
|
|
1286
|
+
rm -f "$STOP_MARKER" "$PIDFILE"
|
|
1287
|
+
exit 0
|
|
1288
|
+
fi
|
|
1289
|
+
# Operator control before the release check: a restore decision consumes
|
|
1290
|
+
# the marker and breaks into the ordinary cutover resume path below, which
|
|
1291
|
+
# boots the previous complete launch specification.
|
|
1292
|
+
if handle_cutover_control; then
|
|
1293
|
+
if [ "$control_result" = "restore" ]; then
|
|
1294
|
+
wd_log "operator control selected the previous complete launch specification"
|
|
1295
|
+
break
|
|
1296
|
+
fi
|
|
1297
|
+
wd_log "operator control consumed; cutover $CUTOVER_ID remains parked awaiting user action"
|
|
1298
|
+
continue
|
|
1299
|
+
fi
|
|
1300
|
+
# A `supervise --cutover-id` resume records driver-started, flipping the
|
|
1301
|
+
# receipt out of awaiting-user — this hold's release signal. Exit non-zero
|
|
1302
|
+
# like a yield so an external supervisor (launchd/systemd) restarts its
|
|
1303
|
+
# stable launcher, which then waits behind the resuming chain.
|
|
1304
|
+
phase=$(cutover_receipt_phase)
|
|
1305
|
+
if [ -n "$phase" ] && [ "$phase" != "awaiting-user" ]; then
|
|
1306
|
+
wd_log "cutover $CUTOVER_ID left awaiting-user (phase $phase) — releasing the claim for the resuming supervisor"
|
|
1307
|
+
exit 75
|
|
1308
|
+
fi
|
|
1309
|
+
wd_sleep 1
|
|
1310
|
+
done
|
|
1311
|
+
fi
|
|
1312
|
+
|
|
1242
1313
|
write_cutover_restart_marker() {
|
|
1243
1314
|
node -e '
|
|
1244
1315
|
const fs = require("fs")
|
|
@@ -1252,6 +1323,21 @@ write_cutover_restart_marker() {
|
|
|
1252
1323
|
' "$RESTART_MARKER" "$CUTOVER_ID" "${WD_INITIATOR:-}"
|
|
1253
1324
|
}
|
|
1254
1325
|
|
|
1326
|
+
# A scheduled exit may carry a one-boot readiness budget (schedule-exit
|
|
1327
|
+
# --boot-timeout-ms), recorded in the restart marker as whole milliseconds.
|
|
1328
|
+
# Validate before trusting: an unreadable or out-of-shape marker changes
|
|
1329
|
+
# nothing — the watchdog's own WD_BOOT_TIMEOUT stays in effect.
|
|
1330
|
+
restart_marker_boot_timeout() {
|
|
1331
|
+
node -e '
|
|
1332
|
+
const fs = require("fs")
|
|
1333
|
+
try {
|
|
1334
|
+
const value = JSON.parse(fs.readFileSync(process.argv[1], "utf8"))
|
|
1335
|
+
const ms = value?.bootTimeoutMs
|
|
1336
|
+
if (Number.isInteger(ms) && ms >= 1000) process.stdout.write(String(Math.ceil(ms / 1000)))
|
|
1337
|
+
} catch {}
|
|
1338
|
+
' "$RESTART_MARKER" 2>/dev/null
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1255
1341
|
consume_takeover_control() {
|
|
1256
1342
|
if handle_cutover_control; then
|
|
1257
1343
|
if [ "$control_result" = "wait" ]; then
|
|
@@ -1460,9 +1546,19 @@ while true; do
|
|
|
1460
1546
|
current_listener_start=''
|
|
1461
1547
|
# Boot window: transport-up is not enough. A protected root can answer 401;
|
|
1462
1548
|
# ready_probe completes the process's announced launch-URL cookie exchange.
|
|
1549
|
+
# The budget is the watchdog's own WD_BOOT_TIMEOUT unless the pending restart
|
|
1550
|
+
# marker carries a one-boot override (schedule-exit --boot-timeout-ms).
|
|
1551
|
+
attempt_boot_timeout=$BOOT_TIMEOUT
|
|
1552
|
+
if [ -f "$RESTART_MARKER" ]; then
|
|
1553
|
+
marker_boot_timeout=$(restart_marker_boot_timeout)
|
|
1554
|
+
case "$marker_boot_timeout" in
|
|
1555
|
+
''|*[!0-9]*) ;;
|
|
1556
|
+
*) attempt_boot_timeout=$marker_boot_timeout ;;
|
|
1557
|
+
esac
|
|
1558
|
+
fi
|
|
1463
1559
|
up=0
|
|
1464
|
-
readiness_failure_detail="readiness not proven within ${
|
|
1465
|
-
boot_limit=$(( $(date +%s) +
|
|
1560
|
+
readiness_failure_detail="readiness not proven within ${attempt_boot_timeout}s"
|
|
1561
|
+
boot_limit=$(( $(date +%s) + attempt_boot_timeout ))
|
|
1466
1562
|
while [ "$(date +%s)" -lt "$boot_limit" ]; do
|
|
1467
1563
|
if [ -n "$(cutover_control_action)" ]; then
|
|
1468
1564
|
readiness_failure_detail="operator control interrupted readiness"
|
|
@@ -1499,8 +1595,15 @@ while true; do
|
|
|
1499
1595
|
redact_launch_urls_in_output
|
|
1500
1596
|
# Mirror the captured output into the watchdog log: with plain redirection
|
|
1501
1597
|
# (see the launch site) the attempt log is the only place the failure was
|
|
1502
|
-
# written, and the watchdog log is where an operator looks first.
|
|
1503
|
-
|
|
1598
|
+
# written, and the watchdog log is where an operator looks first. An EMPTY
|
|
1599
|
+
# attempt log is itself the signal — "the instance never wrote a line" is
|
|
1600
|
+
# what distinguished a dead-on-arrival boot from a noisy one in the
|
|
1601
|
+
# 2026-09-30 launchd incident, and mirroring nothing hid it for an hour.
|
|
1602
|
+
if [ -s "$ATTEMPT_LOG" ]; then
|
|
1603
|
+
sed 's/^/[instance] /' "$ATTEMPT_LOG" 2>/dev/null
|
|
1604
|
+
else
|
|
1605
|
+
wd_log "attempt $current_attempt produced no output — the instance never wrote a line (captured log $ATTEMPT_LOG is empty)"
|
|
1606
|
+
fi
|
|
1504
1607
|
|
|
1505
1608
|
if [ -n "$CUTOVER_ID" ] && [ -n "$(cutover_control_action)" ]; then
|
|
1506
1609
|
failures=$((failures + 1))
|