@khorsheed/dsh-ankh-guard 0.1.1 → 0.3.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 (59) hide show
  1. package/CHANGELOG.md +33 -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 +2349 -182
  6. package/lib/client.js +344 -0
  7. package/lib/exit-agent.js +5 -2
  8. package/lib/index.js +746 -40
  9. package/lib/invariant.js +1 -1
  10. package/lib/preflight-runner.js +125 -47
  11. package/lib/restart-context-DUyExi9O.js +1245 -0
  12. package/lib/restart-context-DlaDZWKq.js +1250 -0
  13. package/lib/{state-Dhx9VG44.js → state-CZMypGkB.js} +97 -13
  14. package/lib/test-seam-cli.js +24 -0
  15. package/lib/test-seam-dwvaKjRp.js +459 -0
  16. package/lib/test-seam.js +2 -0
  17. package/lib/types/browser-handoff.d.ts +55 -0
  18. package/lib/types/browser-handoff.js +489 -0
  19. package/lib/types/cli.d.ts +34 -4
  20. package/lib/types/cli.js +1421 -166
  21. package/lib/types/client/index.d.ts +15 -0
  22. package/lib/types/client/index.js +348 -0
  23. package/lib/types/deployment-proof.d.ts +24 -0
  24. package/lib/types/deployment-proof.js +314 -0
  25. package/lib/types/exit-agent.js +2 -0
  26. package/lib/types/git.d.ts +12 -3
  27. package/lib/types/git.js +69 -7
  28. package/lib/types/index.d.ts +66 -3
  29. package/lib/types/index.js +192 -41
  30. package/lib/types/launch-spec.d.ts +263 -0
  31. package/lib/types/launch-spec.js +823 -0
  32. package/lib/types/preflight-runner.d.ts +23 -12
  33. package/lib/types/preflight-runner.js +152 -57
  34. package/lib/types/processes.d.ts +38 -6
  35. package/lib/types/processes.js +236 -10
  36. package/lib/types/restart-context.d.ts +50 -0
  37. package/lib/types/restart-context.js +117 -6
  38. package/lib/types/restart-request.d.ts +32 -0
  39. package/lib/types/restart-request.js +128 -0
  40. package/lib/types/state-files.d.ts +30 -0
  41. package/lib/types/state-files.js +55 -0
  42. package/lib/types/state.d.ts +29 -2
  43. package/lib/types/state.js +52 -7
  44. package/lib/types/temp-artifact.d.ts +15 -0
  45. package/lib/types/temp-artifact.js +17 -0
  46. package/lib/types/test-seam-cli.d.ts +3 -0
  47. package/lib/types/test-seam-cli.js +27 -0
  48. package/lib/types/test-seam.d.ts +55 -0
  49. package/lib/types/test-seam.js +112 -0
  50. package/lib/types/transition.d.ts +118 -0
  51. package/lib/types/transition.js +717 -0
  52. package/package.json +30 -11
  53. package/scripts/dsh-watchdog.sh +1388 -80
  54. package/scripts/install-launchd.sh +43 -5
  55. package/scripts/install-systemd.sh +43 -5
  56. package/scripts/on-install.js +1 -1
  57. package/skills/dsh-self-restart-guard/SKILL.md +38 -12
  58. package/lib/processes-hCAmwma-.js +0 -127
  59. 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
@@ -2,12 +2,17 @@
2
2
  * Restart-record context injection and interrupted-session continuity. After
3
3
  * a scheduled restart, the FULL report waits for the initiating session's
4
4
  * root agent, whenever it resumes (session restore is lazy, so no other
5
- * session is ever woken for reporting); a record without an initiator is
6
- * claimed by the first root agent created. Separately, a snapshot written at
7
- * SIGTERM time (`interrupted-sessions.json`) records which root sessions had
8
- * a live turn when the process stopped, so the next restart boot can resume
9
- * those sessions and queue a "continue" turn. Pure logic reads the durable
10
- * files; the plugin wires them into `agent/created` and `agent.followup`.
5
+ * session is ever woken for reporting). A record without an initiator splits
6
+ * by kind: one carrying diagnostics someone must hear about (an unplanned
7
+ * recovery, a composition rollback, a failed restart) is claimed by the
8
+ * first root agent created, while a bare planned outcome — the restart was
9
+ * driven from outside the host and the operator's terminal already has the
10
+ * announcement — is settled without waking anyone. Separately, a snapshot
11
+ * written at SIGTERM time (`interrupted-sessions.json`) records which root
12
+ * sessions had a live turn when the process stopped, so the next restart
13
+ * boot can resume those sessions and queue a "continue" turn. Pure logic
14
+ * reads the durable files; the plugin wires them into `agent/created` and
15
+ * `agent.followup`.
11
16
  */
12
17
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
13
18
  import { stateFile } from "./state-files.js";
@@ -50,6 +55,22 @@ export function restartContextText(record, canaryPending) {
50
55
  if (record.exitAt === undefined && record.error === undefined)
51
56
  return '';
52
57
  const time = record.exitAt !== undefined ? new Date(record.exitAt).toISOString() : '未知时间';
58
+ if (record.cutover !== undefined) {
59
+ if (record.cutover.outcome === 'target-ready') {
60
+ return `[ankh-guard] 服务于 ${time} 完成启动配置切换;新 supervisor 与 child 已接管,应用就绪证明(受保护宿主需要时包含认证交接)和金丝雀均通过。耐久回执:${record.cutover.receipt}。请读取回执中的 PID、配置摘要、认证、重试与恢复字段,并向用户生成最终报告。`;
61
+ }
62
+ if (record.cutover.outcome === 'restored') {
63
+ return `[ankh-guard] 服务于 ${time} 尝试切换启动配置失败,但已按重启前批准的策略恢复上一份完整启动配置并重新就绪。耐久回执:${record.cutover.receipt}。请读取回执并向用户报告失败、重试和恢复结果。`;
64
+ }
65
+ if (record.cutover.outcome === 'awaiting-user') {
66
+ return `[ankh-guard] 服务于 ${time} 切换启动配置失败;批准的策略是停留等待用户,watchdog 未擅自重置仓库。耐久回执:${record.cutover.receipt}。请读取回执并向用户报告当前等待点。`;
67
+ }
68
+ return `[ankh-guard] 启动配置切换在停止旧实例前失败,上一份启动配置仍有效。耐久回执:${record.cutover.receipt}。请读取回执并向用户报告准备阶段失败。`;
69
+ }
70
+ if (record.compositionRecovered === true) {
71
+ const what = record.detail !== undefined ? `回滚内容:${record.detail}。` : '';
72
+ return `[ankh-guard] 服务于 ${time} 前后连续启动失败,watchdog 已自动回滚到上次健康的 profile 组合并恢复。${what}原组合已备份到 state 的 composition-backup-* 目录。建议用户修复或卸载相关插件后重新安装验证。请向用户简要回报本次自动恢复与上述建议。`;
73
+ }
53
74
  if (record.unexpected === true) {
54
75
  return `[ankh-guard] 服务最近发生过一次非计划退出(崩溃或被手动停止):${time},watchdog 已自动拉起实例。请向用户简要回报这次非计划重启。`;
55
76
  }
@@ -79,6 +100,19 @@ export function continueAndReportText(record, canaryPending) {
79
100
  if (record.exitAt === undefined && record.error === undefined)
80
101
  return '';
81
102
  const time = record.exitAt !== undefined ? new Date(record.exitAt).toISOString() : '未知时间';
103
+ if (record.cutover !== undefined) {
104
+ const outcome = record.cutover.outcome === 'target-ready'
105
+ ? '新启动配置的应用就绪证明(受保护宿主需要时包含认证交接)与金丝雀均已通过'
106
+ : record.cutover.outcome === 'restored'
107
+ ? '目标启动失败,上一份完整启动配置已恢复并重新就绪'
108
+ : record.cutover.outcome === 'awaiting-user'
109
+ ? '目标启动失败,watchdog 正按批准策略等待用户'
110
+ : '切换在停止旧实例前失败,上一份启动配置仍有效';
111
+ return `[ankh-guard] 服务于 ${time} 发生启动配置切换:${outcome}。你上次正在进行的回合被中断(日志已标记 interrupted)。耐久回执:${record.cutover.receipt}。请检查当前状态并继续未完成的任务,读取回执后向用户报告 supervisor/child PID、配置摘要、认证、重试与恢复结果。`;
112
+ }
113
+ if (record.compositionRecovered === true) {
114
+ return `[ankh-guard] 服务于 ${time} 前后连续启动失败,watchdog 已自动回滚到上次健康的 profile 组合并恢复(最近的插件变更已卸载)。你上次正在进行的回合被中断(日志已标记 interrupted)。请检查当前状态并继续未完成的任务,并向用户简要回报本次自动恢复;若任务已不再适用,简要说明原因后停止。`;
115
+ }
82
116
  if (record.unexpected === true) {
83
117
  return `[ankh-guard] 服务于 ${time} 发生非计划退出(崩溃或被手动停止),watchdog 已自动拉起实例。你上次正在进行的回合被中断(日志已标记 interrupted)。请检查当前状态并继续未完成的任务,并向用户简要回报这次非计划重启;若任务已不再适用,简要说明原因后停止。`;
84
118
  }
@@ -152,6 +186,37 @@ export function writeAdoptionRecord(stateDir, now, initiator) {
152
186
  })}\n`);
153
187
  return true;
154
188
  }
189
+ /**
190
+ * Record a composition rollback recovery: repeated boot failures whose
191
+ * subject lived outside the checkout (a freshly installed plugin is the
192
+ * common case) were recovered by restoring the last healthy profile
193
+ * composition. The service is UP again minus the newest plugin change — the
194
+ * recovery must be reported, never silent.
195
+ *
196
+ * Pending-record policy: a pending record with real diagnostics (error /
197
+ * unexpected) is kept; a BARE exit outcome (exitAt/pid only) is merged over —
198
+ * the watchdog knows the boot actually failed and recovered, so its record is
199
+ * the truthful one, and it inherits the exit record's initiator so the report
200
+ * still reaches its owner.
201
+ * @param stateDir - state directory.
202
+ * @param now - epoch milliseconds of the recovery boot.
203
+ * @param detail - what changed in the rolled-back composition (e.g. the
204
+ * unmounted bundle rows), for the report text.
205
+ * @returns whether the record was written.
206
+ */
207
+ export function writeCompositionRecovery(stateDir, now, detail) {
208
+ const pending = pendingRestartRecord(stateDir);
209
+ if (pending !== null && (pending.error !== undefined || pending.unexpected === true || pending.compositionRecovered === true))
210
+ return false;
211
+ mkdirSync(stateDir, { recursive: true });
212
+ atomicWrite(restartRecordFile(stateDir), `${JSON.stringify({
213
+ exitAt: now,
214
+ compositionRecovered: true,
215
+ ...(detail !== undefined && detail !== '' ? { detail } : {}),
216
+ ...(pending?.initiator !== undefined ? { initiator: pending.initiator } : {}),
217
+ })}\n`);
218
+ return true;
219
+ }
155
220
  /**
156
221
  * Persist the launch record (atomic). The instance-facing side of
157
222
  * {@link writeInstanceLaunch}: only an `instance`-sourced record may be
@@ -287,3 +352,49 @@ export function readSkillRegistration(stateDir) {
287
352
  return null;
288
353
  }
289
354
  }
355
+ /** The tool whose open call means the turn is parked waiting for the human. */
356
+ const USER_INPUT_TOOL = 'ask_user_question';
357
+ /**
358
+ * Whether the session's last turn ended PARKED on user input: interrupted
359
+ * while waiting on an open `ask_user_question` call, or on an approval
360
+ * (`approval/asked` with no `approval/decided` in the same turn / after the
361
+ * turn's start). A parked turn has no interrupted WORK — the card persists in
362
+ * the log and the user answers whenever — so the restart resume must leave
363
+ * such sessions alone (no resume, no continue injection, no replayed card).
364
+ * @param events - the session's durable events (crash-repair already applied).
365
+ * @returns whether the last interrupted turn was parked on user input.
366
+ */
367
+ export function isParkedOnUserInput(events) {
368
+ let parkedTurn;
369
+ let parkedTurnStartSeq = -1;
370
+ for (const event of events) {
371
+ const reason = event.data['reason'];
372
+ if (event.type === 'turn/end' && reason?.kind === 'interrupted') {
373
+ parkedTurn = event.data['turn'];
374
+ }
375
+ }
376
+ if (parkedTurn === undefined)
377
+ return false;
378
+ for (const event of events) {
379
+ if (event.type === 'turn/start' && event.data['turn'] === parkedTurn) {
380
+ parkedTurnStartSeq = event.seq ?? -1;
381
+ }
382
+ }
383
+ let lastToolName;
384
+ let pendingApproval = false;
385
+ for (const event of events) {
386
+ // Approval events are not guaranteed to carry a turn — fall back to the
387
+ // turn's start sequence boundary.
388
+ const inTurn = event.data['turn'] === parkedTurn
389
+ || (parkedTurnStartSeq >= 0 && (event.seq ?? -1) >= parkedTurnStartSeq);
390
+ if (!inTurn)
391
+ continue;
392
+ if (event.type === 'tool/call')
393
+ lastToolName = event.data['name'];
394
+ if (event.type === 'approval/asked')
395
+ pendingApproval = true;
396
+ if (event.type === 'approval/decided')
397
+ pendingApproval = false;
398
+ }
399
+ return lastToolName === USER_INPUT_TOOL || pendingApproval;
400
+ }
@@ -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