@khorsheed/dsh-ankh-guard 0.1.0 → 0.2.0
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 +28 -0
- package/README.en.md +75 -29
- package/README.i18n.yaml +2 -2
- package/README.md +74 -29
- package/lib/cli.js +2383 -209
- package/lib/client.js +257 -0
- package/lib/exit-agent.js +5 -2
- package/lib/index.js +722 -38
- package/lib/invariant.js +1 -1
- package/lib/preflight-runner.js +125 -47
- package/lib/processes-BjZgJjQr.js +344 -0
- package/lib/restart-context-D6nISh28.js +1245 -0
- package/lib/restart-context-DUyExi9O.js +1245 -0
- package/lib/{state-Dhx9VG44.js → state-4f7yny39.js} +60 -13
- package/lib/state-CZMypGkB.js +323 -0
- package/lib/test-seam-DnvLWTeO.js +119 -0
- package/lib/test-seam-cli.js +24 -0
- package/lib/test-seam-dwvaKjRp.js +459 -0
- package/lib/test-seam.js +2 -0
- package/lib/types/browser-handoff.d.ts +55 -0
- package/lib/types/browser-handoff.js +489 -0
- package/lib/types/cli.d.ts +34 -4
- package/lib/types/cli.js +1487 -225
- package/lib/types/client/index.d.ts +15 -0
- package/lib/types/client/index.js +264 -0
- package/lib/types/deployment-proof.d.ts +24 -0
- package/lib/types/deployment-proof.js +314 -0
- package/lib/types/exit-agent.js +2 -0
- package/lib/types/git.d.ts +12 -3
- package/lib/types/git.js +69 -7
- package/lib/types/index.d.ts +66 -3
- package/lib/types/index.js +157 -39
- package/lib/types/launch-spec.d.ts +263 -0
- package/lib/types/launch-spec.js +823 -0
- package/lib/types/preflight-runner.d.ts +23 -12
- package/lib/types/preflight-runner.js +152 -57
- package/lib/types/processes.d.ts +38 -6
- package/lib/types/processes.js +236 -10
- package/lib/types/restart-context.d.ts +50 -0
- package/lib/types/restart-context.js +106 -0
- package/lib/types/restart-request.d.ts +32 -0
- package/lib/types/restart-request.js +128 -0
- package/lib/types/state-files.d.ts +30 -0
- package/lib/types/state-files.js +55 -0
- package/lib/types/state.d.ts +29 -2
- package/lib/types/state.js +52 -7
- package/lib/types/temp-artifact.d.ts +15 -0
- package/lib/types/temp-artifact.js +17 -0
- package/lib/types/test-seam-cli.d.ts +3 -0
- package/lib/types/test-seam-cli.js +27 -0
- package/lib/types/test-seam.d.ts +55 -0
- package/lib/types/test-seam.js +112 -0
- package/lib/types/transition.d.ts +118 -0
- package/lib/types/transition.js +717 -0
- package/package.json +29 -9
- package/scripts/dsh-watchdog.sh +1388 -80
- package/scripts/install-launchd.sh +43 -5
- package/scripts/install-systemd.sh +43 -5
- package/scripts/on-install.js +1 -1
- package/skills/dsh-self-restart-guard/SKILL.md +38 -12
- package/lib/processes-hCAmwma-.js +0 -127
- package/lib/restart-context-DmnQXNf-.js +0 -421
package/lib/types/processes.js
CHANGED
|
@@ -5,17 +5,190 @@
|
|
|
5
5
|
* caller goes through here.
|
|
6
6
|
*/
|
|
7
7
|
import { execFileSync } from 'node:child_process';
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
10
|
+
function executable(name, candidates) {
|
|
11
|
+
return candidates.find(candidate => existsSync(candidate)) ?? name;
|
|
12
|
+
}
|
|
13
|
+
// dsh tool sessions intentionally carry a narrow PATH on macOS. Never turn a
|
|
14
|
+
// missing /usr/sbin entry into "no listener"; use the platform's canonical
|
|
15
|
+
// absolute paths first and retain PATH lookup only as a portable fallback.
|
|
16
|
+
const LSOF = executable('lsof', ['/usr/sbin/lsof', '/usr/bin/lsof']);
|
|
17
|
+
const PS = executable('ps', ['/bin/ps', '/usr/bin/ps']);
|
|
18
|
+
const PGREP = executable('pgrep', ['/usr/bin/pgrep', '/bin/pgrep']);
|
|
19
|
+
const SYSCTL = executable('sysctl', ['/usr/sbin/sysctl', '/sbin/sysctl']);
|
|
20
|
+
const PYTHON = executable('python3', ['/usr/bin/python3', '/opt/homebrew/bin/python3']);
|
|
21
|
+
const DARWIN_START_TOKEN = [
|
|
22
|
+
'import ctypes,struct,sys',
|
|
23
|
+
'p=int(sys.argv[1])',
|
|
24
|
+
'b=ctypes.create_string_buffer(136)',
|
|
25
|
+
'n=ctypes.CDLL("/usr/lib/libproc.dylib").proc_pidinfo(p,3,0,b,136)',
|
|
26
|
+
'n == 136 or sys.exit(1)',
|
|
27
|
+
's,u=struct.unpack_from("QQ",b.raw,120)',
|
|
28
|
+
'print(f"darwin:{s}:{u}",end="")',
|
|
29
|
+
].join(';');
|
|
30
|
+
/** Every process listening on a TCP port. An empty list also covers unavailable lsof. */
|
|
31
|
+
export function findPidsOnPort(port) {
|
|
32
|
+
try {
|
|
33
|
+
const out = execFileSync(LSOF, [`-tiTCP:${port}`, '-sTCP:LISTEN', '-P'], { encoding: 'utf8', stdio: 'pipe' }).trim();
|
|
34
|
+
return [...new Set(out.split('\n').map(Number).filter(pid => Number.isInteger(pid) && pid > 0))];
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** TCP listen ports owned directly by one PID, using the same absolute lsof resolution. */
|
|
41
|
+
export function listeningPortsForPid(pid) {
|
|
42
|
+
try {
|
|
43
|
+
const out = execFileSync(LSOF, ['-nP', '-a', '-p', String(pid), '-iTCP', '-sTCP:LISTEN'], { encoding: 'utf8', stdio: 'pipe' });
|
|
44
|
+
return [...new Set([...out.matchAll(/:(\d+) \(LISTEN\)/g)].map(match => Number(match[1])).filter(Number.isInteger))];
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
8
50
|
/** The first process listening on a TCP port, or null when none is (via lsof). */
|
|
9
51
|
export function findPidOnPort(port) {
|
|
52
|
+
return findPidsOnPort(port)[0]?.toString() ?? null;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Current identity for a live process, or null when it cannot be proved.
|
|
56
|
+
* Linux exposes a boot-scoped kernel start tick. Other POSIX hosts do not,
|
|
57
|
+
* so bind the start instant to the boot, uid, session, and settled command.
|
|
58
|
+
* This materially strengthens macOS ps(1)'s second-granularity lstart; all
|
|
59
|
+
* guard captures occur after the watchdog/child has reached its steady argv.
|
|
60
|
+
*/
|
|
61
|
+
export function processIdentity(pid) {
|
|
62
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
63
|
+
return null;
|
|
64
|
+
try {
|
|
65
|
+
const status = execFileSync(PS, ['-o', 'stat=', '-p', String(pid)], { encoding: 'utf8', stdio: 'pipe' }).trim();
|
|
66
|
+
if (status === '' || status.startsWith('Z'))
|
|
67
|
+
return null;
|
|
68
|
+
if (process.platform === 'darwin') {
|
|
69
|
+
try {
|
|
70
|
+
const nativeStart = execFileSync(PYTHON, ['-c', DARWIN_START_TOKEN, String(pid)], {
|
|
71
|
+
encoding: 'utf8', stdio: 'pipe',
|
|
72
|
+
}).trim();
|
|
73
|
+
if (/^darwin:\d+:\d+$/.test(nativeStart))
|
|
74
|
+
return { pid, startToken: nativeStart };
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// Minimal macOS installations can lack python3; use the strengthened
|
|
78
|
+
// ps evidence below rather than silently dropping identity checks.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const procStat = `/proc/${pid}/stat`;
|
|
82
|
+
if (existsSync(procStat)) {
|
|
83
|
+
const stat = readFileSync(procStat, 'utf8');
|
|
84
|
+
const commandEnd = stat.lastIndexOf(')');
|
|
85
|
+
const fields = commandEnd < 0 ? [] : stat.slice(commandEnd + 1).trim().split(/\s+/);
|
|
86
|
+
const startTicks = fields[19];
|
|
87
|
+
if (startTicks === undefined || startTicks === '')
|
|
88
|
+
return null;
|
|
89
|
+
let bootId = 'unknown-boot';
|
|
90
|
+
try {
|
|
91
|
+
bootId = readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim() || bootId;
|
|
92
|
+
}
|
|
93
|
+
catch { /* optional */ }
|
|
94
|
+
return { pid, startToken: `linux:${bootId}:${startTicks}` };
|
|
95
|
+
}
|
|
96
|
+
const processEvidence = execFileSync(PS, ['-o', 'sess=', '-o', 'uid=', '-o', 'lstart=', '-o', 'command=', '-p', String(pid)], {
|
|
97
|
+
encoding: 'utf8', stdio: 'pipe',
|
|
98
|
+
}).trim();
|
|
99
|
+
if (processEvidence === '')
|
|
100
|
+
return null;
|
|
101
|
+
let bootEvidence = 'unknown-boot';
|
|
102
|
+
try {
|
|
103
|
+
bootEvidence = execFileSync(SYSCTL, ['-n', 'kern.boottime'], {
|
|
104
|
+
encoding: 'utf8', stdio: 'pipe',
|
|
105
|
+
}).trim() || bootEvidence;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// The start instant + uid/session remains a portable fallback.
|
|
109
|
+
}
|
|
110
|
+
const startToken = `posix:${createHash('sha256').update(bootEvidence).update('\0').update(processEvidence).digest('hex')}`;
|
|
111
|
+
return { pid, startToken };
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/** Whether the same, non-recycled process is still alive. */
|
|
118
|
+
export function processIdentityMatches(identity) {
|
|
119
|
+
return processIdentity(identity.pid)?.startToken === identity.startToken;
|
|
120
|
+
}
|
|
121
|
+
function parentPid(pid) {
|
|
122
|
+
try {
|
|
123
|
+
const value = Number(execFileSync(PS, ['-o', 'ppid=', '-p', String(pid)], { encoding: 'utf8', stdio: 'pipe' }).trim());
|
|
124
|
+
return Number.isInteger(value) && value > 0 ? value : null;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** The live POSIX process-group id for one PID, or null when unavailable. */
|
|
131
|
+
export function processGroupId(pid) {
|
|
132
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
133
|
+
return null;
|
|
10
134
|
try {
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
return first !== undefined && first !== '' ? first : null;
|
|
135
|
+
const value = Number(execFileSync(PS, ['-o', 'pgid=', '-p', String(pid)], { encoding: 'utf8', stdio: 'pipe' }).trim());
|
|
136
|
+
return Number.isInteger(value) && value > 0 ? value : null;
|
|
14
137
|
}
|
|
15
138
|
catch {
|
|
16
139
|
return null;
|
|
17
140
|
}
|
|
18
141
|
}
|
|
142
|
+
/** Whether candidate is root itself or a live descendant of root. */
|
|
143
|
+
export function pidBelongsToTree(root, candidate) {
|
|
144
|
+
let cursor = candidate;
|
|
145
|
+
const seen = new Set();
|
|
146
|
+
while (cursor > 0 && !seen.has(cursor)) {
|
|
147
|
+
if (cursor === root)
|
|
148
|
+
return true;
|
|
149
|
+
seen.add(cursor);
|
|
150
|
+
const parent = parentPid(cursor);
|
|
151
|
+
if (parent === null)
|
|
152
|
+
return false;
|
|
153
|
+
cursor = parent;
|
|
154
|
+
}
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Prove that exactly one port listener belongs to a supervisor and return the
|
|
159
|
+
* direct child root through which that supervisor owns it. This is captured
|
|
160
|
+
* before cutover; the successor must stop this identity, never an arbitrary
|
|
161
|
+
* process discovered later from the shared port.
|
|
162
|
+
*/
|
|
163
|
+
export function findOwnedListener(port, supervisorPid) {
|
|
164
|
+
const listeners = findPidsOnPort(port);
|
|
165
|
+
if (listeners.length !== 1)
|
|
166
|
+
return null;
|
|
167
|
+
const listenerPid = listeners[0];
|
|
168
|
+
if (listenerPid === undefined)
|
|
169
|
+
return null;
|
|
170
|
+
const listener = processIdentity(listenerPid);
|
|
171
|
+
if (listener === null)
|
|
172
|
+
return null;
|
|
173
|
+
let cursor = listenerPid;
|
|
174
|
+
let childRoot = null;
|
|
175
|
+
const seen = new Set();
|
|
176
|
+
while (cursor > 0 && !seen.has(cursor)) {
|
|
177
|
+
seen.add(cursor);
|
|
178
|
+
const parent = parentPid(cursor);
|
|
179
|
+
if (parent === supervisorPid) {
|
|
180
|
+
childRoot = cursor;
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
if (parent === null)
|
|
184
|
+
return null;
|
|
185
|
+
cursor = parent;
|
|
186
|
+
}
|
|
187
|
+
if (childRoot === null)
|
|
188
|
+
return null;
|
|
189
|
+
const child = processIdentity(childRoot);
|
|
190
|
+
return child === null ? null : { child, listener };
|
|
191
|
+
}
|
|
19
192
|
/** POSIX single-quote one word for a shell command line. */
|
|
20
193
|
function shellQuote(word) {
|
|
21
194
|
return `'${word.replace(/'/g, "'\\''")}'`;
|
|
@@ -34,7 +207,7 @@ export function discoverLaunchCommand(pid) {
|
|
|
34
207
|
let argv;
|
|
35
208
|
let cwd;
|
|
36
209
|
try {
|
|
37
|
-
argv = execFileSync(
|
|
210
|
+
argv = execFileSync(PS, ['-o', 'command=', '-p', pid], { encoding: 'utf8', stdio: 'pipe' }).trim();
|
|
38
211
|
if (argv === '')
|
|
39
212
|
return null;
|
|
40
213
|
}
|
|
@@ -42,7 +215,7 @@ export function discoverLaunchCommand(pid) {
|
|
|
42
215
|
throw new Error(`ps unavailable: ${String(error)}`);
|
|
43
216
|
}
|
|
44
217
|
try {
|
|
45
|
-
const out = execFileSync(
|
|
218
|
+
const out = execFileSync(LSOF, ['-a', '-p', pid, '-d', 'cwd', '-Fn'], { encoding: 'utf8', stdio: 'pipe' });
|
|
46
219
|
const match = /^n(.+)$/m.exec(out);
|
|
47
220
|
if (match === null)
|
|
48
221
|
return null;
|
|
@@ -54,7 +227,7 @@ export function discoverLaunchCommand(pid) {
|
|
|
54
227
|
const TRANSIENT = new Set(['DSH_ANKH_RESTART_DRIVER', 'DSH_SESSION_ID', 'DSH_SESSION_JSONL', 'DSH_WEB_URL', 'DSH_SHELL']);
|
|
55
228
|
const env = {};
|
|
56
229
|
try {
|
|
57
|
-
const out = execFileSync(
|
|
230
|
+
const out = execFileSync(PS, ['eww', '-o', 'command', '-p', pid], { encoding: 'utf8', stdio: 'pipe' });
|
|
58
231
|
for (const token of out.split(/\s+/)) {
|
|
59
232
|
const eq = token.indexOf('=');
|
|
60
233
|
if (eq > 0 && token.slice(0, eq).startsWith('DSH_') && !TRANSIENT.has(token.slice(0, eq)))
|
|
@@ -76,10 +249,11 @@ export function discoverLaunchCommand(pid) {
|
|
|
76
249
|
* setsid'd — so the sweep walks `pgrep -P` instead. `pgrep` missing or
|
|
77
250
|
* returning nothing is fine: the pid itself still gets the signal.
|
|
78
251
|
*/
|
|
79
|
-
|
|
252
|
+
function signalFrozenTree(pid, signal) {
|
|
253
|
+
let safe = true;
|
|
80
254
|
let children = [];
|
|
81
255
|
try {
|
|
82
|
-
const out = execFileSync(
|
|
256
|
+
const out = execFileSync(PGREP, ['-P', String(pid)], { encoding: 'utf8', stdio: 'pipe' }).trim();
|
|
83
257
|
children = out === '' ? [] : out.split('\n');
|
|
84
258
|
}
|
|
85
259
|
catch {
|
|
@@ -87,13 +261,65 @@ export function killPidTree(pid, signal) {
|
|
|
87
261
|
}
|
|
88
262
|
for (const raw of children) {
|
|
89
263
|
const child = Number(raw);
|
|
90
|
-
if (Number.isInteger(child)
|
|
91
|
-
|
|
264
|
+
if (!Number.isInteger(child) || child <= 0)
|
|
265
|
+
continue;
|
|
266
|
+
try {
|
|
267
|
+
process.kill(child, 'SIGSTOP');
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
// pgrep and SIGSTOP are separate syscalls. Verify the frozen PID is still
|
|
273
|
+
// this frozen parent's child before recursively signalling it; if not,
|
|
274
|
+
// resume the unrelated process and refuse to claim a complete sweep.
|
|
275
|
+
if (parentPid(child) !== pid) {
|
|
276
|
+
safe = false;
|
|
277
|
+
try {
|
|
278
|
+
process.kill(child, 'SIGCONT');
|
|
279
|
+
}
|
|
280
|
+
catch { /* already gone */ }
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
safe = signalFrozenTree(child, signal) && safe;
|
|
92
284
|
}
|
|
93
285
|
try {
|
|
94
286
|
process.kill(pid, signal);
|
|
287
|
+
if (signal !== 'SIGKILL')
|
|
288
|
+
process.kill(pid, 'SIGCONT');
|
|
95
289
|
}
|
|
96
290
|
catch {
|
|
97
291
|
// already gone
|
|
98
292
|
}
|
|
293
|
+
return safe;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Freeze, then revalidate, then signal an authorized process identity. A
|
|
297
|
+
* mismatch resumes the PID without delivering the requested signal.
|
|
298
|
+
*/
|
|
299
|
+
export function signalProcessIdentity(identity, signal) {
|
|
300
|
+
try {
|
|
301
|
+
process.kill(identity.pid, 'SIGSTOP');
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
return 'gone';
|
|
305
|
+
}
|
|
306
|
+
if (!processIdentityMatches(identity)) {
|
|
307
|
+
try {
|
|
308
|
+
process.kill(identity.pid, 'SIGCONT');
|
|
309
|
+
}
|
|
310
|
+
catch { /* already gone */ }
|
|
311
|
+
return 'mismatch';
|
|
312
|
+
}
|
|
313
|
+
return signalFrozenTree(identity.pid, signal) ? 'signalled' : 'mismatch';
|
|
314
|
+
}
|
|
315
|
+
export function killPidTree(pid, signal) {
|
|
316
|
+
try {
|
|
317
|
+
// Freeze before enumeration so a wrapper cannot exit and orphan its
|
|
318
|
+
// listener to PID 1 between pgrep and delivery of the requested signal.
|
|
319
|
+
process.kill(pid, 'SIGSTOP');
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
return; // already gone
|
|
323
|
+
}
|
|
324
|
+
signalFrozenTree(pid, signal);
|
|
99
325
|
}
|
|
@@ -10,6 +10,19 @@ export interface RestartRecord {
|
|
|
10
10
|
* guard) — written on the respawn so the recovery is reported, not silent.
|
|
11
11
|
*/
|
|
12
12
|
unexpected?: boolean;
|
|
13
|
+
/**
|
|
14
|
+
* The watchdog recovered repeated boot failures by restoring the last
|
|
15
|
+
* healthy profile composition (the newest plugin change was unmounted).
|
|
16
|
+
*/
|
|
17
|
+
compositionRecovered?: boolean;
|
|
18
|
+
/** What the composition rollback changed (the unmounted rows), for the report. */
|
|
19
|
+
detail?: string;
|
|
20
|
+
/** Durable launch-configuration cutover outcome and receipt location. */
|
|
21
|
+
cutover?: {
|
|
22
|
+
id: string;
|
|
23
|
+
outcome: 'target-ready' | 'restored' | 'awaiting-user' | 'prepare-failed';
|
|
24
|
+
receipt: string;
|
|
25
|
+
};
|
|
13
26
|
reportedAt?: number;
|
|
14
27
|
}
|
|
15
28
|
/**
|
|
@@ -126,6 +139,25 @@ export interface InstanceLaunch {
|
|
|
126
139
|
/** Epoch milliseconds when recorded. */
|
|
127
140
|
recordedAt: number;
|
|
128
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* Record a composition rollback recovery: repeated boot failures whose
|
|
144
|
+
* subject lived outside the checkout (a freshly installed plugin is the
|
|
145
|
+
* common case) were recovered by restoring the last healthy profile
|
|
146
|
+
* composition. The service is UP again minus the newest plugin change — the
|
|
147
|
+
* recovery must be reported, never silent.
|
|
148
|
+
*
|
|
149
|
+
* Pending-record policy: a pending record with real diagnostics (error /
|
|
150
|
+
* unexpected) is kept; a BARE exit outcome (exitAt/pid only) is merged over —
|
|
151
|
+
* the watchdog knows the boot actually failed and recovered, so its record is
|
|
152
|
+
* the truthful one, and it inherits the exit record's initiator so the report
|
|
153
|
+
* still reaches its owner.
|
|
154
|
+
* @param stateDir - state directory.
|
|
155
|
+
* @param now - epoch milliseconds of the recovery boot.
|
|
156
|
+
* @param detail - what changed in the rolled-back composition (e.g. the
|
|
157
|
+
* unmounted bundle rows), for the report text.
|
|
158
|
+
* @returns whether the record was written.
|
|
159
|
+
*/
|
|
160
|
+
export declare function writeCompositionRecovery(stateDir: string, now: number, detail?: string): boolean;
|
|
129
161
|
/**
|
|
130
162
|
* Persist the launch record (atomic). The instance-facing side of
|
|
131
163
|
* {@link writeInstanceLaunch}: only an `instance`-sourced record may be
|
|
@@ -187,4 +219,22 @@ export declare function writeSkillRegistration(stateDir: string, record: SkillRe
|
|
|
187
219
|
* @returns the record, or null.
|
|
188
220
|
*/
|
|
189
221
|
export declare function readSkillRegistration(stateDir: string): SkillRegistrationRecord | null;
|
|
222
|
+
/** Minimal log-event view this helper reads (the session-persistence event shape). */
|
|
223
|
+
interface ParkedProbeEvent {
|
|
224
|
+
type: string;
|
|
225
|
+
seq?: number;
|
|
226
|
+
data: Record<string, unknown>;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Whether the session's last turn ended PARKED on user input: interrupted
|
|
230
|
+
* while waiting on an open `ask_user_question` call, or on an approval
|
|
231
|
+
* (`approval/asked` with no `approval/decided` in the same turn / after the
|
|
232
|
+
* turn's start). A parked turn has no interrupted WORK — the card persists in
|
|
233
|
+
* the log and the user answers whenever — so the restart resume must leave
|
|
234
|
+
* such sessions alone (no resume, no continue injection, no replayed card).
|
|
235
|
+
* @param events - the session's durable events (crash-repair already applied).
|
|
236
|
+
* @returns whether the last interrupted turn was parked on user input.
|
|
237
|
+
*/
|
|
238
|
+
export declare function isParkedOnUserInput(events: readonly ParkedProbeEvent[]): boolean;
|
|
239
|
+
export {};
|
|
190
240
|
//# sourceMappingURL=restart-context.d.ts.map
|
|
@@ -50,6 +50,22 @@ export function restartContextText(record, canaryPending) {
|
|
|
50
50
|
if (record.exitAt === undefined && record.error === undefined)
|
|
51
51
|
return '';
|
|
52
52
|
const time = record.exitAt !== undefined ? new Date(record.exitAt).toISOString() : '未知时间';
|
|
53
|
+
if (record.cutover !== undefined) {
|
|
54
|
+
if (record.cutover.outcome === 'target-ready') {
|
|
55
|
+
return `[ankh-guard] 服务于 ${time} 完成启动配置切换;新 supervisor 与 child 已接管,应用就绪证明(受保护宿主需要时包含认证交接)和金丝雀均通过。耐久回执:${record.cutover.receipt}。请读取回执中的 PID、配置摘要、认证、重试与恢复字段,并向用户生成最终报告。`;
|
|
56
|
+
}
|
|
57
|
+
if (record.cutover.outcome === 'restored') {
|
|
58
|
+
return `[ankh-guard] 服务于 ${time} 尝试切换启动配置失败,但已按重启前批准的策略恢复上一份完整启动配置并重新就绪。耐久回执:${record.cutover.receipt}。请读取回执并向用户报告失败、重试和恢复结果。`;
|
|
59
|
+
}
|
|
60
|
+
if (record.cutover.outcome === 'awaiting-user') {
|
|
61
|
+
return `[ankh-guard] 服务于 ${time} 切换启动配置失败;批准的策略是停留等待用户,watchdog 未擅自重置仓库。耐久回执:${record.cutover.receipt}。请读取回执并向用户报告当前等待点。`;
|
|
62
|
+
}
|
|
63
|
+
return `[ankh-guard] 启动配置切换在停止旧实例前失败,上一份启动配置仍有效。耐久回执:${record.cutover.receipt}。请读取回执并向用户报告准备阶段失败。`;
|
|
64
|
+
}
|
|
65
|
+
if (record.compositionRecovered === true) {
|
|
66
|
+
const what = record.detail !== undefined ? `回滚内容:${record.detail}。` : '';
|
|
67
|
+
return `[ankh-guard] 服务于 ${time} 前后连续启动失败,watchdog 已自动回滚到上次健康的 profile 组合并恢复。${what}原组合已备份到 state 的 composition-backup-* 目录。建议用户修复或卸载相关插件后重新安装验证。请向用户简要回报本次自动恢复与上述建议。`;
|
|
68
|
+
}
|
|
53
69
|
if (record.unexpected === true) {
|
|
54
70
|
return `[ankh-guard] 服务最近发生过一次非计划退出(崩溃或被手动停止):${time},watchdog 已自动拉起实例。请向用户简要回报这次非计划重启。`;
|
|
55
71
|
}
|
|
@@ -79,6 +95,19 @@ export function continueAndReportText(record, canaryPending) {
|
|
|
79
95
|
if (record.exitAt === undefined && record.error === undefined)
|
|
80
96
|
return '';
|
|
81
97
|
const time = record.exitAt !== undefined ? new Date(record.exitAt).toISOString() : '未知时间';
|
|
98
|
+
if (record.cutover !== undefined) {
|
|
99
|
+
const outcome = record.cutover.outcome === 'target-ready'
|
|
100
|
+
? '新启动配置的应用就绪证明(受保护宿主需要时包含认证交接)与金丝雀均已通过'
|
|
101
|
+
: record.cutover.outcome === 'restored'
|
|
102
|
+
? '目标启动失败,上一份完整启动配置已恢复并重新就绪'
|
|
103
|
+
: record.cutover.outcome === 'awaiting-user'
|
|
104
|
+
? '目标启动失败,watchdog 正按批准策略等待用户'
|
|
105
|
+
: '切换在停止旧实例前失败,上一份启动配置仍有效';
|
|
106
|
+
return `[ankh-guard] 服务于 ${time} 发生启动配置切换:${outcome}。你上次正在进行的回合被中断(日志已标记 interrupted)。耐久回执:${record.cutover.receipt}。请检查当前状态并继续未完成的任务,读取回执后向用户报告 supervisor/child PID、配置摘要、认证、重试与恢复结果。`;
|
|
107
|
+
}
|
|
108
|
+
if (record.compositionRecovered === true) {
|
|
109
|
+
return `[ankh-guard] 服务于 ${time} 前后连续启动失败,watchdog 已自动回滚到上次健康的 profile 组合并恢复(最近的插件变更已卸载)。你上次正在进行的回合被中断(日志已标记 interrupted)。请检查当前状态并继续未完成的任务,并向用户简要回报本次自动恢复;若任务已不再适用,简要说明原因后停止。`;
|
|
110
|
+
}
|
|
82
111
|
if (record.unexpected === true) {
|
|
83
112
|
return `[ankh-guard] 服务于 ${time} 发生非计划退出(崩溃或被手动停止),watchdog 已自动拉起实例。你上次正在进行的回合被中断(日志已标记 interrupted)。请检查当前状态并继续未完成的任务,并向用户简要回报这次非计划重启;若任务已不再适用,简要说明原因后停止。`;
|
|
84
113
|
}
|
|
@@ -152,6 +181,37 @@ export function writeAdoptionRecord(stateDir, now, initiator) {
|
|
|
152
181
|
})}\n`);
|
|
153
182
|
return true;
|
|
154
183
|
}
|
|
184
|
+
/**
|
|
185
|
+
* Record a composition rollback recovery: repeated boot failures whose
|
|
186
|
+
* subject lived outside the checkout (a freshly installed plugin is the
|
|
187
|
+
* common case) were recovered by restoring the last healthy profile
|
|
188
|
+
* composition. The service is UP again minus the newest plugin change — the
|
|
189
|
+
* recovery must be reported, never silent.
|
|
190
|
+
*
|
|
191
|
+
* Pending-record policy: a pending record with real diagnostics (error /
|
|
192
|
+
* unexpected) is kept; a BARE exit outcome (exitAt/pid only) is merged over —
|
|
193
|
+
* the watchdog knows the boot actually failed and recovered, so its record is
|
|
194
|
+
* the truthful one, and it inherits the exit record's initiator so the report
|
|
195
|
+
* still reaches its owner.
|
|
196
|
+
* @param stateDir - state directory.
|
|
197
|
+
* @param now - epoch milliseconds of the recovery boot.
|
|
198
|
+
* @param detail - what changed in the rolled-back composition (e.g. the
|
|
199
|
+
* unmounted bundle rows), for the report text.
|
|
200
|
+
* @returns whether the record was written.
|
|
201
|
+
*/
|
|
202
|
+
export function writeCompositionRecovery(stateDir, now, detail) {
|
|
203
|
+
const pending = pendingRestartRecord(stateDir);
|
|
204
|
+
if (pending !== null && (pending.error !== undefined || pending.unexpected === true || pending.compositionRecovered === true))
|
|
205
|
+
return false;
|
|
206
|
+
mkdirSync(stateDir, { recursive: true });
|
|
207
|
+
atomicWrite(restartRecordFile(stateDir), `${JSON.stringify({
|
|
208
|
+
exitAt: now,
|
|
209
|
+
compositionRecovered: true,
|
|
210
|
+
...(detail !== undefined && detail !== '' ? { detail } : {}),
|
|
211
|
+
...(pending?.initiator !== undefined ? { initiator: pending.initiator } : {}),
|
|
212
|
+
})}\n`);
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
155
215
|
/**
|
|
156
216
|
* Persist the launch record (atomic). The instance-facing side of
|
|
157
217
|
* {@link writeInstanceLaunch}: only an `instance`-sourced record may be
|
|
@@ -287,3 +347,49 @@ export function readSkillRegistration(stateDir) {
|
|
|
287
347
|
return null;
|
|
288
348
|
}
|
|
289
349
|
}
|
|
350
|
+
/** The tool whose open call means the turn is parked waiting for the human. */
|
|
351
|
+
const USER_INPUT_TOOL = 'ask_user_question';
|
|
352
|
+
/**
|
|
353
|
+
* Whether the session's last turn ended PARKED on user input: interrupted
|
|
354
|
+
* while waiting on an open `ask_user_question` call, or on an approval
|
|
355
|
+
* (`approval/asked` with no `approval/decided` in the same turn / after the
|
|
356
|
+
* turn's start). A parked turn has no interrupted WORK — the card persists in
|
|
357
|
+
* the log and the user answers whenever — so the restart resume must leave
|
|
358
|
+
* such sessions alone (no resume, no continue injection, no replayed card).
|
|
359
|
+
* @param events - the session's durable events (crash-repair already applied).
|
|
360
|
+
* @returns whether the last interrupted turn was parked on user input.
|
|
361
|
+
*/
|
|
362
|
+
export function isParkedOnUserInput(events) {
|
|
363
|
+
let parkedTurn;
|
|
364
|
+
let parkedTurnStartSeq = -1;
|
|
365
|
+
for (const event of events) {
|
|
366
|
+
const reason = event.data['reason'];
|
|
367
|
+
if (event.type === 'turn/end' && reason?.kind === 'interrupted') {
|
|
368
|
+
parkedTurn = event.data['turn'];
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (parkedTurn === undefined)
|
|
372
|
+
return false;
|
|
373
|
+
for (const event of events) {
|
|
374
|
+
if (event.type === 'turn/start' && event.data['turn'] === parkedTurn) {
|
|
375
|
+
parkedTurnStartSeq = event.seq ?? -1;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
let lastToolName;
|
|
379
|
+
let pendingApproval = false;
|
|
380
|
+
for (const event of events) {
|
|
381
|
+
// Approval events are not guaranteed to carry a turn — fall back to the
|
|
382
|
+
// turn's start sequence boundary.
|
|
383
|
+
const inTurn = event.data['turn'] === parkedTurn
|
|
384
|
+
|| (parkedTurnStartSeq >= 0 && (event.seq ?? -1) >= parkedTurnStartSeq);
|
|
385
|
+
if (!inTurn)
|
|
386
|
+
continue;
|
|
387
|
+
if (event.type === 'tool/call')
|
|
388
|
+
lastToolName = event.data['name'];
|
|
389
|
+
if (event.type === 'approval/asked')
|
|
390
|
+
pendingApproval = true;
|
|
391
|
+
if (event.type === 'approval/decided')
|
|
392
|
+
pendingApproval = false;
|
|
393
|
+
}
|
|
394
|
+
return lastToolName === USER_INPUT_TOOL || pendingApproval;
|
|
395
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** One guarded-restart request. */
|
|
2
|
+
export interface RestartRequest {
|
|
3
|
+
/** The successor launch command (the target profile's `dsh web …` line). */
|
|
4
|
+
start: string;
|
|
5
|
+
/** The dsh profile the composition preflight dry-runs. */
|
|
6
|
+
profile: string;
|
|
7
|
+
/** The session id the restart report returns to — required, never invented. */
|
|
8
|
+
initiator: string;
|
|
9
|
+
}
|
|
10
|
+
/** The structured verdict; terminal hint text never crosses this seam. */
|
|
11
|
+
export type RestartRequestResult = {
|
|
12
|
+
accepted: true;
|
|
13
|
+
via: 'restart' | 'reconfigure';
|
|
14
|
+
detail: string;
|
|
15
|
+
} | {
|
|
16
|
+
accepted: false;
|
|
17
|
+
stage: string;
|
|
18
|
+
reason: string;
|
|
19
|
+
detail: string;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Validate and dispatch one restart request through the guard's own gate
|
|
23
|
+
* chain. A refusal never stops the running instance.
|
|
24
|
+
* @param request - the successor command, target profile, and owning session.
|
|
25
|
+
* @param context - the guard's resolved state dir and credential repo.
|
|
26
|
+
* @returns the structured verdict with bounded human detail for display.
|
|
27
|
+
*/
|
|
28
|
+
export declare function requestRestart(request: RestartRequest, context: {
|
|
29
|
+
stateDir: string;
|
|
30
|
+
repoDir: string;
|
|
31
|
+
}): Promise<RestartRequestResult>;
|
|
32
|
+
//# sourceMappingURL=restart-request.d.ts.map
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process guarded-restart trigger for UI-grade callers (the mode
|
|
3
|
+
* switcher's host half). Dispatches on supervision — no live watchdog: the
|
|
4
|
+
* `restart` verb's self-contained stop→start→canary; supervised:
|
|
5
|
+
* `reconfigure`'s transactional cutover, the only safe way to change the
|
|
6
|
+
* launch command under a live watchdog (its respawn would otherwise fight
|
|
7
|
+
* the new command). The trigger shells out to this package's own CLI, so
|
|
8
|
+
* the gate chain (credential → preflight → marker/lock) keeps exactly one
|
|
9
|
+
* behavior source and the detached driver keeps its proven lifetime rules;
|
|
10
|
+
* the structured verdict returns through DSH_ANKH_VERDICT_FILE, never
|
|
11
|
+
* scraped from the human stderr text.
|
|
12
|
+
*
|
|
13
|
+
* Never import the CLI module from the plugin graph: a runtime edge pulls
|
|
14
|
+
* cli.ts into a shared tsdown chunk, evicting its code from lib/cli.js so
|
|
15
|
+
* the direct-invocation guard never fires and the built CLI silently exits
|
|
16
|
+
* 0 on every call (observed 2026-09-09; type-only imports are erased and
|
|
17
|
+
* stay safe).
|
|
18
|
+
*/
|
|
19
|
+
import { spawn } from 'node:child_process';
|
|
20
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
21
|
+
import { tmpdir } from 'node:os';
|
|
22
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import { readInstanceLaunch } from "./restart-context.js";
|
|
25
|
+
import { liveWatchdogPid } from "./state-files.js";
|
|
26
|
+
/** Bounded human detail carried alongside the structured verdict, for display. */
|
|
27
|
+
const DETAIL_CAP = 4096;
|
|
28
|
+
/**
|
|
29
|
+
* argv (after process.execPath) that runs this package's CLI with the given
|
|
30
|
+
* args — the same source/built split as the CLI's own cliInvocation, resolved
|
|
31
|
+
* from this module's directory (src/ and lib/ keep their entries side by
|
|
32
|
+
* side). The two copies must not be unified through an import: see the module
|
|
33
|
+
* doc for what a runtime edge into the CLI module does to the built bundle.
|
|
34
|
+
*/
|
|
35
|
+
function cliEntryArgs(args) {
|
|
36
|
+
// Check the FILE path, not the directory: '…/src' has no trailing
|
|
37
|
+
// separator and never matches '/src/' — cliInvocation gets this right
|
|
38
|
+
// because it tests the module file itself.
|
|
39
|
+
const modulePath = fileURLToPath(import.meta.url);
|
|
40
|
+
const here = dirname(modulePath);
|
|
41
|
+
if (modulePath.includes(`${sep}src${sep}`)) {
|
|
42
|
+
const tsx = join(resolve(here, '../../../node_modules'), 'tsx', 'dist', 'esm', 'index.mjs');
|
|
43
|
+
if (existsSync(tsx))
|
|
44
|
+
return ['--import', tsx, join(here, 'cli.ts'), ...args];
|
|
45
|
+
return [join(here, 'cli.ts'), ...args];
|
|
46
|
+
}
|
|
47
|
+
return [join(here, 'cli.js'), ...args];
|
|
48
|
+
}
|
|
49
|
+
/** Read the caller-side verdict file, or null when the CLI never wrote one. */
|
|
50
|
+
function readVerdict(file) {
|
|
51
|
+
try {
|
|
52
|
+
const value = JSON.parse(readFileSync(file, 'utf8'));
|
|
53
|
+
return typeof value.stage === 'string' && typeof value.reason === 'string'
|
|
54
|
+
? { stage: value.stage, reason: value.reason }
|
|
55
|
+
: null;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Validate and dispatch one restart request through the guard's own gate
|
|
63
|
+
* chain. A refusal never stops the running instance.
|
|
64
|
+
* @param request - the successor command, target profile, and owning session.
|
|
65
|
+
* @param context - the guard's resolved state dir and credential repo.
|
|
66
|
+
* @returns the structured verdict with bounded human detail for display.
|
|
67
|
+
*/
|
|
68
|
+
export async function requestRestart(request, context) {
|
|
69
|
+
if (request.start.trim() === '') {
|
|
70
|
+
return { accepted: false, stage: 'usage', reason: 'start (the successor launch command) is required', detail: '' };
|
|
71
|
+
}
|
|
72
|
+
if (request.profile.trim() === '') {
|
|
73
|
+
return { accepted: false, stage: 'usage', reason: 'profile (the dsh profile to preflight) is required', detail: '' };
|
|
74
|
+
}
|
|
75
|
+
if (request.initiator.trim() === '') {
|
|
76
|
+
return { accepted: false, stage: 'usage', reason: 'initiator (the session id the restart report returns to) is required — never invent one', detail: '' };
|
|
77
|
+
}
|
|
78
|
+
const port = readInstanceLaunch(context.stateDir)?.port;
|
|
79
|
+
if (port === undefined) {
|
|
80
|
+
return { accepted: false, stage: 'usage', reason: "no instance launch record in the state dir — the guard does not know this instance's port", detail: '' };
|
|
81
|
+
}
|
|
82
|
+
const via = liveWatchdogPid(context.stateDir) === null ? 'restart' : 'reconfigure';
|
|
83
|
+
const argv = via === 'restart'
|
|
84
|
+
? [
|
|
85
|
+
'restart', '--port', String(port), '--start', request.start, '--profile', request.profile,
|
|
86
|
+
'--initiator', request.initiator, '--state-dir', context.stateDir, '--repo', context.repoDir,
|
|
87
|
+
]
|
|
88
|
+
: [
|
|
89
|
+
'reconfigure', '--start', request.start, '--profile', request.profile,
|
|
90
|
+
'--on-failure', 'restore-previous', '--initiator', request.initiator, '--state-dir', context.stateDir,
|
|
91
|
+
];
|
|
92
|
+
const verdictDir = mkdtempSync(join(tmpdir(), 'ankh-restart-request-'));
|
|
93
|
+
const verdictFile = join(verdictDir, 'verdict.json');
|
|
94
|
+
// This instance's own supervision variables must not leak into the CLI's
|
|
95
|
+
// children: a WD_STATE_DIR would retarget every watchdog they spawn (the
|
|
96
|
+
// same scrub the restart verb applies to the instance it starts).
|
|
97
|
+
const env = { ...process.env, DSH_ANKH_VERDICT_FILE: verdictFile };
|
|
98
|
+
delete env.DSH_ANKH_RESTART_DRIVER;
|
|
99
|
+
for (const key of Object.keys(env)) {
|
|
100
|
+
if (key.startsWith('WD_'))
|
|
101
|
+
delete env[key];
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const { code, output } = await new Promise((resolvePromise, rejectPromise) => {
|
|
105
|
+
let captured = '';
|
|
106
|
+
const child = spawn(process.execPath, cliEntryArgs(argv), { stdio: ['ignore', 'pipe', 'pipe'], env });
|
|
107
|
+
const append = (chunk) => { if (captured.length < DETAIL_CAP)
|
|
108
|
+
captured += chunk.toString('utf8'); };
|
|
109
|
+
child.stdout.on('data', append);
|
|
110
|
+
child.stderr.on('data', append);
|
|
111
|
+
child.once('error', rejectPromise);
|
|
112
|
+
child.once('exit', exitCode => { resolvePromise({ code: exitCode, output: captured }); });
|
|
113
|
+
});
|
|
114
|
+
const detail = output.trim();
|
|
115
|
+
if (code === 0)
|
|
116
|
+
return { accepted: true, via, detail };
|
|
117
|
+
const verdict = readVerdict(verdictFile);
|
|
118
|
+
return {
|
|
119
|
+
accepted: false,
|
|
120
|
+
stage: verdict?.stage ?? 'unknown',
|
|
121
|
+
reason: verdict?.reason ?? detail.split('\n', 1)[0] ?? `guard CLI exited ${code ?? 'signal'}`,
|
|
122
|
+
detail,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
rmSync(verdictDir, { recursive: true, force: true });
|
|
127
|
+
}
|
|
128
|
+
}
|