@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.
Files changed (62) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.en.md +75 -29
  3. package/README.i18n.yaml +2 -2
  4. package/README.md +74 -29
  5. package/lib/cli.js +2383 -209
  6. package/lib/client.js +257 -0
  7. package/lib/exit-agent.js +5 -2
  8. package/lib/index.js +722 -38
  9. package/lib/invariant.js +1 -1
  10. package/lib/preflight-runner.js +125 -47
  11. package/lib/processes-BjZgJjQr.js +344 -0
  12. package/lib/restart-context-D6nISh28.js +1245 -0
  13. package/lib/restart-context-DUyExi9O.js +1245 -0
  14. package/lib/{state-Dhx9VG44.js → state-4f7yny39.js} +60 -13
  15. package/lib/state-CZMypGkB.js +323 -0
  16. package/lib/test-seam-DnvLWTeO.js +119 -0
  17. package/lib/test-seam-cli.js +24 -0
  18. package/lib/test-seam-dwvaKjRp.js +459 -0
  19. package/lib/test-seam.js +2 -0
  20. package/lib/types/browser-handoff.d.ts +55 -0
  21. package/lib/types/browser-handoff.js +489 -0
  22. package/lib/types/cli.d.ts +34 -4
  23. package/lib/types/cli.js +1487 -225
  24. package/lib/types/client/index.d.ts +15 -0
  25. package/lib/types/client/index.js +264 -0
  26. package/lib/types/deployment-proof.d.ts +24 -0
  27. package/lib/types/deployment-proof.js +314 -0
  28. package/lib/types/exit-agent.js +2 -0
  29. package/lib/types/git.d.ts +12 -3
  30. package/lib/types/git.js +69 -7
  31. package/lib/types/index.d.ts +66 -3
  32. package/lib/types/index.js +157 -39
  33. package/lib/types/launch-spec.d.ts +263 -0
  34. package/lib/types/launch-spec.js +823 -0
  35. package/lib/types/preflight-runner.d.ts +23 -12
  36. package/lib/types/preflight-runner.js +152 -57
  37. package/lib/types/processes.d.ts +38 -6
  38. package/lib/types/processes.js +236 -10
  39. package/lib/types/restart-context.d.ts +50 -0
  40. package/lib/types/restart-context.js +106 -0
  41. package/lib/types/restart-request.d.ts +32 -0
  42. package/lib/types/restart-request.js +128 -0
  43. package/lib/types/state-files.d.ts +30 -0
  44. package/lib/types/state-files.js +55 -0
  45. package/lib/types/state.d.ts +29 -2
  46. package/lib/types/state.js +52 -7
  47. package/lib/types/temp-artifact.d.ts +15 -0
  48. package/lib/types/temp-artifact.js +17 -0
  49. package/lib/types/test-seam-cli.d.ts +3 -0
  50. package/lib/types/test-seam-cli.js +27 -0
  51. package/lib/types/test-seam.d.ts +55 -0
  52. package/lib/types/test-seam.js +112 -0
  53. package/lib/types/transition.d.ts +118 -0
  54. package/lib/types/transition.js +717 -0
  55. package/package.json +29 -9
  56. package/scripts/dsh-watchdog.sh +1388 -80
  57. package/scripts/install-launchd.sh +43 -5
  58. package/scripts/install-systemd.sh +43 -5
  59. package/scripts/on-install.js +1 -1
  60. package/skills/dsh-self-restart-guard/SKILL.md +38 -12
  61. package/lib/processes-hCAmwma-.js +0 -127
  62. package/lib/restart-context-DmnQXNf-.js +0 -421
@@ -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 out = execFileSync('lsof', [`-tiTCP:${port}`, '-sTCP:LISTEN', '-P'], { encoding: 'utf8', stdio: 'pipe' }).trim();
12
- const first = out.split('\n')[0];
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('ps', ['-o', 'command=', '-p', pid], { encoding: 'utf8', stdio: 'pipe' }).trim();
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('lsof', ['-a', '-p', pid, '-d', 'cwd', '-Fn'], { encoding: 'utf8', stdio: 'pipe' });
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('ps', ['eww', '-o', 'command', '-p', pid], { encoding: 'utf8', stdio: 'pipe' });
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
- export function killPidTree(pid, signal) {
252
+ function signalFrozenTree(pid, signal) {
253
+ let safe = true;
80
254
  let children = [];
81
255
  try {
82
- const out = execFileSync('pgrep', ['-P', String(pid)], { encoding: 'utf8', stdio: 'pipe' }).trim();
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) && child > 0)
91
- killPidTree(child, signal);
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
+ }