dsh-autorestart 0.8.4 → 0.8.6

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 (42) hide show
  1. package/CHANGELOG.md +313 -0
  2. package/README.md +465 -40
  3. package/assets/icon-9c245e5b.ico +0 -0
  4. package/assets/icon-rescue-1902bb15.ico +0 -0
  5. package/assets/icon-rescue-1902bb15.png +0 -0
  6. package/client.js +3002 -170
  7. package/cordis.patch.yml +12 -0
  8. package/docs/README.zh.md +231 -23
  9. package/docs/platform-matrix.md +171 -55
  10. package/docs/real-machine-verification-0.8.6.md +202 -0
  11. package/index.js +13272 -8513
  12. package/lib/autostart-advice.js +31 -2
  13. package/lib/backup.js +2768 -21
  14. package/lib/checkpoint.js +214 -7
  15. package/lib/cookie-clear.js +260 -0
  16. package/lib/lifecycle.js +23 -40
  17. package/lib/plugin-compare.js +499 -0
  18. package/lib/plugin-failure.js +382 -0
  19. package/lib/plugin-recover.js +1557 -0
  20. package/lib/plugin-watch.js +213 -0
  21. package/lib/rescue-paths.js +982 -56
  22. package/lib/restart-phase.js +5 -0
  23. package/lib/restart-verdict.js +379 -0
  24. package/lib/restart.js +244 -7
  25. package/lib/scheduler-capability.js +19 -3
  26. package/lib/sleep.js +190 -29
  27. package/lib/watchdog-disarm.js +46 -2
  28. package/lib/watchdog-schedule.js +834 -17
  29. package/lib/watchdog-tier.js +8 -1
  30. package/lib/watchdog.js +1222 -47
  31. package/locale/en.json +175 -68
  32. package/locale/zh.json +175 -68
  33. package/package.json +3 -2
  34. package/screenshots.json +6 -6
  35. package/scripts/README.md +6 -3
  36. package/scripts/build-icon.mjs +998 -0
  37. package/scripts/clear-dsh-auth-cookies.mjs +257 -0
  38. package/scripts/emergency-disable.sh +69 -2
  39. package/scripts/emergency-toggle.ps1 +85 -69
  40. package/scripts/rescue-strings.zh.json +36 -18
  41. package/scripts/restore-profile.ps1 +182 -1
  42. package/scripts/watchdog.mjs +432 -79
package/lib/watchdog.js CHANGED
@@ -18,17 +18,47 @@
18
18
  * When there is no port to ask at all (no instance record, no health
19
19
  * URL), one more round is waited and the leftovers are looked at again;
20
20
  * 4. take the shared lock (`rollback.lock`) — exactly ONE of us restores;
21
- * 5. restore the snapshot, then START THE HOST AGAIN;
22
- * 6. wait for the ready marker once more: it came → `recovered`, it did not
23
- * → `failed` — and then we STOP (at most one restore and one start).
21
+ * 5. THE PASS — up to `maxAttempts` times (3 by default, and NEVER endless):
22
+ * a. ask again whether the host came up or is alive (a host that came
23
+ * up between two attempts must not be started a second time);
24
+ * b. restore the snapshot on the first pass, and again on a later pass
25
+ * only when the restore itself failed (the snapshot is the same
26
+ * file tree: copying it twice would not change anything);
27
+ * c. CLEAR THE STALE HANDSHAKE COOKIES after the restore and before
28
+ * the start (see lib/cookie-clear.js: without it the relaunched
29
+ * host dies with 431 on its very first request);
30
+ * d. START THE HOST AGAIN and wait for the ready marker;
31
+ * e. not ready and attempts left? wait the backoff (15s, then 45s) and
32
+ * go back to (a);
33
+ * 6. the marker came → `recovered`; the attempts ran out → `failed` with
34
+ * "tried N times, no further rollback will be attempted"; a start that
35
+ * could not be issued at all → `failed` with `stage:'relaunch'`.
24
36
  *
25
- * Whatever happens, the scheduling item and the pending marker go; the report
26
- * stays.
37
+ * A cleanup that fails never blocks a rescue (the cookie script exits 0 for
38
+ * the same reason), but it is ALWAYS written into the report (`cookieClear`):
39
+ * a cleanup that could not run must never look like one that did.
40
+ *
41
+ * Whatever happens, the report is written FIRST, then the pending marker goes,
42
+ * and only then the scheduling item — because `removeSchedule` can stop the very
43
+ * service this process runs in (2026-11-01), and anything written after that
44
+ * never reaches the disk. The report is written from the `finally`, so an
45
+ * exception inside the flow still leaves a report behind.
27
46
  */
28
47
 
29
48
  import fs from 'node:fs';
30
49
  import path from 'node:path';
31
50
 
51
+ import {
52
+ CERTIFICATION_EVIDENCE,
53
+ collectLinkedPlugins,
54
+ decideLinkRestore,
55
+ isUsableRestorePoint,
56
+ LINK_ACTIONS,
57
+ LINKS_DIR,
58
+ MANIFEST_NAME,
59
+ restorePointRefusal,
60
+ } from './backup.js';
61
+
32
62
  /** The marker that says "a restart is coming, a watchdog is watching". */
33
63
  export const PENDING_NAME = 'watchdog-pending.json';
34
64
  /** The marker that says "the new host is up and healthy". */
@@ -47,6 +77,78 @@ export const DEFAULT_POLL_MS = 2000;
47
77
  /** How long it waits for the host after the restore. */
48
78
  export const DEFAULT_READY_TIMEOUT_MS = 90000;
49
79
 
80
+ /**
81
+ * How many pass-and-start attempts one watchdog run may make.
82
+ *
83
+ * The flow used to be "one restore, one start, then stop". One start is not
84
+ * enough: the desktop app can die while it boots (a 431 on its first request
85
+ * killed it after 14 seconds on 2026-10-30), and a host that died is a host
86
+ * that has to be started again. It is a NUMBER, not "as often as you like":
87
+ * a run that never ends is a run that never reports.
88
+ */
89
+ export const DEFAULT_MAX_ATTEMPTS = 3;
90
+ /**
91
+ * The hard ceiling of `maxAttempts`.
92
+ *
93
+ * A pending marker is a file on disk, so a bad or hand-edited value must not
94
+ * be able to turn the watchdog into an endless loop.
95
+ */
96
+ export const MAX_ATTEMPTS_CEILING = 10;
97
+
98
+ /**
99
+ * HOW OFTEN THE WATCHDOG MAY PUT A COPY BACK = ONCE (user decision, 2026-11-01).
100
+ *
101
+ * Restoring the same directory again cannot change anything: the bytes are
102
+ * identical, so a second and third restore of the same copy is the same attempt
103
+ * repeated — measured on the real machine, three attempts restored the same copy
104
+ * three times and failed the same way three times, which reads like "we tried
105
+ * hard" while nothing new was tried at all.
106
+ *
107
+ * So: the restore happens at most once per run. The START may still be repeated
108
+ * (`maxAttempts`), because a host that died for an unrelated reason can come up
109
+ * on the next try.
110
+ */
111
+ export const MAX_RESTORES = 1;
112
+
113
+ /**
114
+ * The waits BETWEEN two attempts, in milliseconds (15s, then 45s).
115
+ *
116
+ * The first wait is short (a host that died on its first request is worth
117
+ * starting again at once); the second is longer (a host that dies twice is
118
+ * dying for a reason, and hammering the disk with relaunches helps nobody).
119
+ * The last entry repeats when there are more attempts than entries.
120
+ */
121
+ export const DEFAULT_BACKOFF_MS = Object.freeze([15000, 45000]);
122
+
123
+ /**
124
+ * How many attempts this run may make (the marker, clamped and defaulted).
125
+ *
126
+ * An OLD marker carries no `maxAttempts`: it is read as `DEFAULT_MAX_ATTEMPTS`,
127
+ * never as "one" — a marker written before this field existed must not turn
128
+ * the watchdog back into the one-shot process that failed on 2026-10-30.
129
+ *
130
+ * @param {object|null} pending
131
+ * @returns {number}
132
+ */
133
+ export function maxAttemptsOf(pending) {
134
+ const raw = pending && typeof pending === 'object' ? pending.maxAttempts : undefined;
135
+ const wanted = Number(raw);
136
+ if (!Number.isFinite(wanted) || wanted < 1) return DEFAULT_MAX_ATTEMPTS;
137
+ return Math.min(MAX_ATTEMPTS_CEILING, Math.trunc(wanted));
138
+ }
139
+
140
+ /**
141
+ * The wait AFTER a given attempt (1-based), in milliseconds.
142
+ *
143
+ * @param {number} attempt
144
+ * @returns {number}
145
+ */
146
+ export function backoffMsFor(attempt) {
147
+ const index = Math.max(0, Math.trunc(Number(attempt) || 0) - 1);
148
+ const table = DEFAULT_BACKOFF_MS;
149
+ return table[Math.min(index, table.length - 1)];
150
+ }
151
+
50
152
  /**
51
153
  * Write a JSON file (no BOM, so every reader can parse it).
52
154
  *
@@ -59,22 +161,104 @@ function writeJson(file, value) {
59
161
  }
60
162
 
61
163
  /**
62
- * Read a JSON file (a file that cannot be read is a file that is not there).
164
+ * Read a JSON file and say WHAT happened (a file that cannot be read is not
165
+ * the same thing as a file that is not there).
166
+ *
167
+ * WHY THE THREE STATES (2026-10-30, review finding F4): the old reader returned
168
+ * `null` for BOTH "there is no such file" and "the file cannot be parsed". The
169
+ * watchdog therefore read a `watchdog-pending.json` that had been written BY
170
+ * POWERSHELL (UTF-8 **with** a byte order mark) as "there is no pending marker",
171
+ * cleaned up its scheduling item and exited 0 - looking like a run, doing
172
+ * nothing at all. That is exactly the failure this whole file exists to
173
+ * prevent, so "missing" and "corrupt" are separate answers now, and the byte
174
+ * order mark is stripped before parsing.
175
+ *
176
+ * @param {string} file
177
+ * @returns {{
178
+ * state: 'ok'|'missing'|'corrupt',
179
+ * value: object|null,
180
+ * error: string|null,
181
+ * }}
182
+ */
183
+ export function readJsonState(file) {
184
+ let text = ''
185
+ try {
186
+ text = fs.readFileSync(file, 'utf8');
187
+ } catch (error) {
188
+ const code = error && typeof error.code === 'string' ? error.code : '';
189
+ if (code === 'ENOENT' || code === 'ENOTDIR') {
190
+ return { state: 'missing', value: null, error: null };
191
+ }
192
+ return {
193
+ state: 'corrupt',
194
+ value: null,
195
+ error: 'the file exists but cannot be read: ' + (error && error.message ? error.message : String(error)),
196
+ };
197
+ }
198
+ // Windows PowerShell 5.1 writes UTF-8 WITH a byte order mark, and
199
+ // `JSON.parse` refuses a leading U+FEFF. Stripping it is not cosmetic: a
200
+ // marker written by a rescue script has to be readable by this module.
201
+ const cleaned = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
202
+ let parsed = null;
203
+ try {
204
+ parsed = JSON.parse(cleaned);
205
+ } catch (error) {
206
+ return {
207
+ state: 'corrupt',
208
+ value: null,
209
+ error: 'the file cannot be parsed: ' + (error && error.message ? error.message : String(error)),
210
+ };
211
+ }
212
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
213
+ return { state: 'corrupt', value: null, error: 'the file does not hold a JSON object' };
214
+ }
215
+ return { state: 'ok', value: parsed, error: null };
216
+ }
217
+
218
+ /**
219
+ * Read a JSON file (the value, or null when there is nothing usable).
220
+ *
221
+ * The PLUGIN side keeps this shape: `readPending` / `readReady` / `readReport`
222
+ * are imported by index.js, and a caller that only wants "the object or
223
+ * nothing" must not have to unwrap a state machine. The watchdog CLI uses
224
+ * {@link readJsonState} (and {@link readPendingState}) because it has to tell
225
+ * "no marker" from "a marker it cannot read".
63
226
  *
64
227
  * @param {string} file
65
228
  * @returns {object|null}
66
229
  */
67
230
  function readJson(file) {
68
- try {
69
- const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
70
- return parsed && typeof parsed === 'object' ? parsed : null;
71
- } catch {
72
- return null;
73
- }
231
+ return readJsonState(file).value;
74
232
  }
75
233
 
76
234
  /**
77
- * The pending marker: what the watchdog has to know.
235
+ * The pending marker WITH the reason it could not be used.
236
+ *
237
+ * @param {string} stateDir
238
+ * @returns {{ state: 'ok'|'missing'|'corrupt', pending: object|null, error: string|null }}
239
+ */
240
+ export function readPendingState(stateDir) {
241
+ const read = readJsonState(pendingPath(stateDir));
242
+ return { state: read.state, pending: read.value, error: read.error };
243
+ }
244
+
245
+ /**
246
+ * THE PENDING MARKER: what the watchdog has to know.
247
+ *
248
+ * `restoreTargetDir` USED TO BE CALLED `snapshotDir` AND THAT NAME LIED
249
+ * (2026-11-01): the directory stored here is never the snapshot of this
250
+ * restart — it is the copy the watchdog may restore, which since the strict mode
251
+ * is "the newest copy that was CERTIFIED and that no system intervention is on
252
+ * record for". The old name is still accepted as an input alias so a caller
253
+ * that was not updated keeps working, but the marker itself only carries the
254
+ * new name.
255
+ *
256
+ * `restoreBlocked` / `restoreBlockedReason` / `restoreCandidates` are written by
257
+ * the ARMING side when it found no copy it is allowed to restore. The watchdog
258
+ * must not restore in that case (strict mode: no trusted restore point), and the
259
+ * report has to be able to say WHICH copies were looked at and why each was
260
+ * refused — a rescue that quietly does nothing is the incident this whole change
261
+ * is about.
78
262
  *
79
263
  * @param {{
80
264
  * tier?: string,
@@ -82,7 +266,11 @@ function readJson(file) {
82
266
  * taskName?: string,
83
267
  * unitId?: string,
84
268
  * pidPath?: string,
269
+ * restoreTargetDir?: string,
85
270
  * snapshotDir?: string,
271
+ * restoreBlocked?: boolean,
272
+ * restoreBlockedReason?: string,
273
+ * restoreCandidates?: Array<object>|null,
86
274
  * profileDir?: string,
87
275
  * restoreCommand?: object|null,
88
276
  * relaunchCommand?: object|null,
@@ -95,6 +283,7 @@ function readJson(file) {
95
283
  * readyTimeoutMs?: number,
96
284
  * hostKind?: string,
97
285
  * attempts?: number,
286
+ * maxAttempts?: number,
98
287
  * now?: number,
99
288
  * }} options
100
289
  * @returns {object}
@@ -106,7 +295,11 @@ export function buildPending({
106
295
  taskName = '',
107
296
  unitId = '',
108
297
  pidPath = '',
298
+ restoreTargetDir = '',
109
299
  snapshotDir = '',
300
+ restoreBlocked = false,
301
+ restoreBlockedReason = '',
302
+ restoreCandidates = null,
110
303
  profileDir = '',
111
304
  restoreCommand = null,
112
305
  relaunchCommand = null,
@@ -119,8 +312,12 @@ export function buildPending({
119
312
  readyTimeoutMs = DEFAULT_READY_TIMEOUT_MS,
120
313
  hostKind = '',
121
314
  attempts = 1,
315
+ maxAttempts = DEFAULT_MAX_ATTEMPTS,
122
316
  now = Date.now(),
123
317
  } = {}) {
318
+ const target = typeof restoreTargetDir === 'string' && restoreTargetDir !== ''
319
+ ? restoreTargetDir
320
+ : (typeof snapshotDir === 'string' ? snapshotDir : '')
124
321
  return {
125
322
  version: 1,
126
323
  at: new Date(now).toISOString(),
@@ -130,7 +327,10 @@ export function buildPending({
130
327
  taskName,
131
328
  unitId,
132
329
  pidPath,
133
- snapshotDir,
330
+ restoreTargetDir: target,
331
+ restoreBlocked: restoreBlocked === true || target === '',
332
+ restoreBlockedReason: typeof restoreBlockedReason === 'string' ? restoreBlockedReason : '',
333
+ restoreCandidates: Array.isArray(restoreCandidates) ? restoreCandidates : [],
134
334
  profileDir,
135
335
  restoreCommand,
136
336
  relaunchCommand,
@@ -143,9 +343,271 @@ export function buildPending({
143
343
  readyTimeoutMs,
144
344
  hostKind,
145
345
  attempts,
346
+ maxAttempts,
146
347
  };
147
348
  }
148
349
 
350
+ /**
351
+ * WHY A COPY MAY NOT BE RESTORED — one word per reason, so a report can group
352
+ * them instead of repeating a sentence per copy.
353
+ */
354
+ export const RESTORE_EXCLUSIONS = Object.freeze({
355
+ /**
356
+ * No certification evidence at all: an old copy, a snapshot nobody checked, OR
357
+ * — since 2026-11-01 — a copy that carries a stamp (`certifiedBy=full-checks`)
358
+ * but no criterion that can be re-checked. The last one is the historical
359
+ * pollution: its stamp says who signed, never what was checked, so it is
360
+ * refused exactly like a copy with no stamp at all. See
361
+ * `CERTIFICATION_EVIDENCE` in lib/backup.js.
362
+ */
363
+ NOT_CERTIFIED: 'not-certified',
364
+ /**
365
+ * The copy has no restart record saying the instance came up with matching
366
+ * content (2026-11-02: the selector reads the record, not a verdict). The old
367
+ * value above is kept for anything that still compares against it; nothing new
368
+ * is emitted in that vocabulary.
369
+ */
370
+ NOT_CONTENT_VERIFIED: 'not-content-verified',
371
+ /** The boot that certified it had to be rescued by the system. */
372
+ INTERVENED: 'intervened',
373
+ /** Certified, but nobody recorded whether that boot needed help. */
374
+ INTERVENTION_UNKNOWN: 'intervention-unknown',
375
+ });
376
+
377
+ /**
378
+ * CHOOSE THE ONE COPY THE WATCHDOG MAY RESTORE (2026-11-01, strict mode).
379
+ *
380
+ * The rule the user decided on: a copy may only be restored when it is CERTIFIED
381
+ * — and "certified" now means BOTH halves: a real source AND a criterion that can
382
+ * be re-checked (`certificationEvidence === 'fingerprint-match'`), as computed by
383
+ * `isCertifiedBackup` in lib/backup.js. A copy that merely names a source is NOT
384
+ * certified: the two broken configurations on the hosted instance (71B / 70B of
385
+ * `cordis.patch.yml`, both stamped `certifiedBy=full-checks` by the old code) are
386
+ * exactly that shape, and they must never be picked again.
387
+ *
388
+ * AND nothing on record may say the boot that certified it had to be rescued
389
+ * (`intervention` is not true). Two consequences, both deliberate:
390
+ *
391
+ * - `intervention === null` means "nobody recorded it". It is NOT treated as
392
+ * "no intervention": a certified copy whose provenance is unknown is not
393
+ * trusted, and the report says so in those words. That is the difference
394
+ * between "we checked" and "we did not hear otherwise".
395
+ * - `intervention === true` is refused even when `certifiedBy` is
396
+ * 'full-checks'. A copy whose boot needed rescuing describes a state that
397
+ * was already being fixed by somebody else; restoring it would put the
398
+ * repaired state back under a config that broke.
399
+ *
400
+ * NO FALLBACK: when nothing qualifies, the answer is `target: null` with
401
+ * `strict: true` and the full `excluded` list. It never degrades to "try the
402
+ * newest copy anyway" — the user chose strict mode, and a silent fallback is
403
+ * exactly how the bad copy got certified in the first place.
404
+ *
405
+ * @param {{ candidates?: Array<object> }} [options] the copies, newest first
406
+ * @returns {{
407
+ * target: string|null,
408
+ * targetName: string|null,
409
+ * strict: boolean,
410
+ * reason: string,
411
+ * excluded: Array<{ dir: string|null, name: string|null, certifiedBy: string, certificationEvidence: string|null, intervention: boolean|null, exclusion: string }>,
412
+ * notes: string[],
413
+ * }}
414
+ */
415
+ export function selectRestoreTarget({ candidates = [] } = {}) {
416
+ const list = Array.isArray(candidates) ? candidates : []
417
+ const excluded = []
418
+ const notes = []
419
+ for (const candidate of list) {
420
+ if (candidate === null || typeof candidate !== 'object') continue
421
+ const dir = typeof candidate.dir === 'string' && candidate.dir !== '' ? candidate.dir : null
422
+ const name = typeof candidate.name === 'string' && candidate.name !== '' ? candidate.name : null
423
+ const certifiedBy = typeof candidate.certifiedBy === 'string' && candidate.certifiedBy !== ''
424
+ ? candidate.certifiedBy
425
+ : 'none'
426
+ const intervention = typeof candidate.intervention === 'boolean' ? candidate.intervention : null
427
+ // THE QUALIFICATION IS A RECORDED FACT, NOT AN ADJECTIVE (2026-11-02).
428
+ //
429
+ // It used to be `certified === true`, i.e. "somebody stamped this copy AND the
430
+ // stamp says fingerprint-match". The user's objection is the stamp itself: a
431
+ // backup is either something that was observed to come up, or it is not, and
432
+ // the selector has to read that observation rather than a verdict written next
433
+ // to it. The fact lives in `boot`: `started` (the instance came up) and
434
+ // `contentMatched` (the content that boot ran is this copy's content).
435
+ //
436
+ // ONE PREDICATE, TWO CALLERS (2026-11-02): the decision is made by
437
+ // `isUsableRestorePoint` in lib/backup.js, which single-plugin recovery asks as
438
+ // well. Two rulers over the same directory was a bug: the same copy could be
439
+ // "a restore point" to one path and "not a restore point" to the other.
440
+ //
441
+ // AN OLD COPY IS TRANSLATED, NOT DROPPED (the same rule as `readBackupMeta`):
442
+ // a meta with no `boot` record but with the legacy stamp
443
+ // (`certifiedBy` + `certificationEvidence: 'fingerprint-match'`) is an
444
+ // observation from an older build, and refusing every one of them would make
445
+ // every existing install unable to restore anything. It is read here as the
446
+ // observation it recorded; nothing is *written* in that vocabulary any more.
447
+ const observed = candidate.boot !== null && typeof candidate.boot === 'object' ? candidate.boot : null
448
+ const boot = observed !== null
449
+ ? observed
450
+ : (candidate.certifiedBy !== 'none' && candidate.certifiedBy !== undefined
451
+ && candidate.certificationEvidence === CERTIFICATION_EVIDENCE.FINGERPRINT_MATCH)
452
+ ? { started: true, contentMatched: true, observedBy: 'an older build' }
453
+ : null
454
+ const usable = isUsableRestorePoint({ boot, intervention })
455
+ if (usable !== true) {
456
+ const reason = restorePointRefusal({ boot, intervention })
457
+ const exclusion = boot === null || boot.started !== true || boot.contentMatched !== true
458
+ ? RESTORE_EXCLUSIONS.NOT_CONTENT_VERIFIED
459
+ : (intervention === true ? RESTORE_EXCLUSIONS.INTERVENED : RESTORE_EXCLUSIONS.INTERVENTION_UNKNOWN)
460
+ excluded.push({
461
+ dir,
462
+ name,
463
+ certifiedBy: certifiedBy === 'none' ? 'none' : certifiedBy,
464
+ certificationEvidence: typeof candidate.certificationEvidence === 'string'
465
+ ? candidate.certificationEvidence
466
+ : null,
467
+ intervention,
468
+ exclusion,
469
+ })
470
+ // SAID OUT LOUD: silence here would read like "there is nothing to restore",
471
+ // which is a different fact. The sentence names what is missing, never who
472
+ // signed off on it.
473
+ if (exclusion !== RESTORE_EXCLUSIONS.INTERVENED) notes.push('a copy was not used: ' + reason)
474
+ continue
475
+ }
476
+ if (dir === null) {
477
+ // A copy without a directory cannot be restored from, whatever it says.
478
+ excluded.push({ dir, name, certifiedBy, intervention, exclusion: RESTORE_EXCLUSIONS.NOT_CONTENT_VERIFIED })
479
+ continue
480
+ }
481
+ if (excluded.length > 0) {
482
+ notes.push(excluded.length + ' newer cop' + (excluded.length === 1 ? 'y was' : 'ies were')
483
+ + ' skipped before this one (see the excluded list)')
484
+ }
485
+ return {
486
+ target: dir,
487
+ targetName: name,
488
+ strict: false,
489
+ interventionUnknown: false,
490
+ reason: 'its restart record says the instance came up with matching content, and no system intervention is on record',
491
+ excluded,
492
+ notes,
493
+ }
494
+ }
495
+ // THE FIRST INCIDENT ON A FRESH INSTALL (user decision, 2026-11-02).
496
+ //
497
+ // A copy is usable when its record says the instance came up AND that no rescue
498
+ // was needed (`intervention === false`). On a machine that has never been
499
+ // rescued nobody can know that second fact yet: a restore report is written when
500
+ // a restore was CONSIDERED, so the very first restart's copy says
501
+ // `intervention: null` - and refusing it would leave the machine with NO restore
502
+ // point at the one moment it needs one.
503
+ //
504
+ // So there is ONE named fallback, deliberately narrow:
505
+ // - it fires only when EVERY refused copy was refused for `intervention` being
506
+ // unrecorded (`INTERVENTION_UNKNOWN`). A single copy that was actually
507
+ // rescued (`intervention === true`), or whose content never came up, keeps
508
+ // the strict answer: nothing is restored;
509
+ // - it takes the NEWEST such copy (the list arrives newest-first);
510
+ // - it SAYS SO, in the reason and in a note. It is never dressed up as a
511
+ // verified copy.
512
+ const refusals = excluded.map((entry) => entry.exclusion)
513
+ const allUnknownIntervention = refusals.length > 0
514
+ && refusals.every((exclusion) => exclusion === RESTORE_EXCLUSIONS.INTERVENTION_UNKNOWN)
515
+ if (allUnknownIntervention) {
516
+ const fallback = list.find((candidate) => candidate !== null
517
+ && typeof candidate === 'object'
518
+ && typeof candidate.dir === 'string'
519
+ && candidate.dir !== '')
520
+ if (fallback !== undefined) {
521
+ notes.push(
522
+ 'the copy that was used is the newest one, and NOBODY RECORDED whether that boot was rescued'
523
+ + ' (intervention unknown) - it is used because every copy on this machine is in that state,'
524
+ + ' which is what a machine nobody ever had to rescue looks like',
525
+ )
526
+ return {
527
+ target: fallback.dir,
528
+ targetName: typeof fallback.name === 'string' ? fallback.name : null,
529
+ strict: false,
530
+ interventionUnknown: true,
531
+ reason: 'no copy has a recorded boot that came up with matching content and no recorded rescue;'
532
+ + ' the newest copy is used anyway because nobody recorded whether ITS boot was rescued'
533
+ + ' (intervention unknown) - the choice is named here, not assumed',
534
+ excluded,
535
+ notes,
536
+ }
537
+ }
538
+ }
539
+ return {
540
+ target: null,
541
+ targetName: null,
542
+ strict: true,
543
+ interventionUnknown: false,
544
+ reason: list.length === 0
545
+ ? 'there is no backup at all, so there is no restore point'
546
+ : 'no backup qualifies: none of the ' + list.length + ' copies has a restart record saying the instance came up with matching content AND is free of a recorded intervention',
547
+ excluded,
548
+ notes,
549
+ }
550
+ }
551
+
552
+ /**
553
+ * WHAT THE MARKER SAYS ABOUT THE RESTORE — normalized, in one place.
554
+ *
555
+ * A marker WITHOUT `restoreTargetDir` was written by an older version, which
556
+ * stored a "known-good" directory under `snapshotDir` and no certification
557
+ * evidence at all. Reading such a marker as "restore it" would restore a copy
558
+ * whose provenance nobody recorded — the strict answer is "blocked, and say
559
+ * why".
560
+ *
561
+ * @param {object|null|undefined} pending
562
+ * @returns {{ dir: string, blocked: boolean, reason: string, candidates: Array<object>, legacy: boolean }}
563
+ */
564
+ export function restoreTargetInfoOf(pending) {
565
+ const marker = pending !== null && pending !== undefined && typeof pending === 'object' ? pending : {}
566
+ const dir = typeof marker.restoreTargetDir === 'string' ? marker.restoreTargetDir : ''
567
+ const legacyDir = typeof marker.snapshotDir === 'string' ? marker.snapshotDir : ''
568
+ const candidates = Array.isArray(marker.restoreCandidates) ? marker.restoreCandidates : []
569
+ if (dir !== '') {
570
+ return {
571
+ dir,
572
+ blocked: false,
573
+ reason: typeof marker.restoreBlockedReason === 'string' ? marker.restoreBlockedReason : '',
574
+ candidates,
575
+ legacy: false,
576
+ }
577
+ }
578
+ if (marker.restoreBlocked === true) {
579
+ return {
580
+ dir: '',
581
+ blocked: true,
582
+ reason: typeof marker.restoreBlockedReason === 'string' && marker.restoreBlockedReason !== ''
583
+ ? marker.restoreBlockedReason
584
+ : 'no backup qualifies as a trusted restore point',
585
+ candidates,
586
+ legacy: false,
587
+ }
588
+ }
589
+ if (legacyDir !== '') {
590
+ // An OLD marker: it names a directory, but records nothing about the restart
591
+ // that produced it (no boot record, no intervention). Strict mode says: do not
592
+ // restore it, and say why in facts rather than in a verdict.
593
+ return {
594
+ dir: '',
595
+ blocked: true,
596
+ reason: 'this restart was armed by an older version, which recorded no boot record for the copy — nothing says whether that content ever came up, so it is not restored',
597
+ candidates,
598
+ legacy: true,
599
+ }
600
+ }
601
+ return {
602
+ dir: '',
603
+ blocked: true,
604
+ reason: 'this restart was armed without a restore target',
605
+ candidates,
606
+ legacy: false,
607
+ }
608
+ }
609
+
610
+
149
611
  /**
150
612
  * @param {string} stateDir
151
613
  * @returns {string}
@@ -236,6 +698,65 @@ export function clearReady(stateDir) {
236
698
  }
237
699
  }
238
700
 
701
+ /**
702
+ * The time inside a marker, in milliseconds, or `null` when it carries none.
703
+ *
704
+ * `at` is written as an ISO string (see {@link writeReady}), but an older or
705
+ * hand-written marker may carry milliseconds, so both are understood.
706
+ *
707
+ * @param {unknown} value
708
+ * @returns {number|null}
709
+ */
710
+ function markerAtMs(value) {
711
+ if (value === null || typeof value !== 'object') return null;
712
+ const raw = value.at;
713
+ if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
714
+ if (typeof raw === 'string' && raw !== '') {
715
+ const parsed = Date.parse(raw);
716
+ if (Number.isFinite(parsed)) return parsed;
717
+ }
718
+ return null;
719
+ }
720
+
721
+ /**
722
+ * DOES THIS READY MARKER BELONG TO THIS RESTART?
723
+ *
724
+ * THE BLOCKER THIS EXISTS FOR (2026-11-01, hosted web instance on port 5601):
725
+ * the host was restarted with a broken profile and never came up. The ready
726
+ * marker of the PREVIOUS boot was still on disk (written 09:54:21, the pending
727
+ * marker of the new restart at 09:57:10 — 2m49s apart). {@link readReady} only
728
+ * answered "there is a marker", the flow read that as "the new host is up",
729
+ * waited 0 ms and reported `result: 'not-needed'` with
730
+ * `restored: false, relaunched: false, attempts: 0` while `ss -ltn` showed no
731
+ * listener on 5601 for more than two minutes. A rescue that does nothing must
732
+ * not be indistinguishable from a rescue that was not needed.
733
+ *
734
+ * A ready marker is the receipt of ONE restart: it may only be believed when it
735
+ * was written AT OR AFTER that restart's pending marker. An older marker is the
736
+ * receipt of an earlier boot and says nothing about this one.
737
+ *
738
+ * Two deliberate leniencies, both to keep "a host that is up is never rolled
739
+ * back" (the worst thing this file can do) intact:
740
+ * - a marker without a usable `at` (a rescue script wrote it by hand) cannot
741
+ * be attributed to a restart at all, so it is accepted as before;
742
+ * - a dependency that answers with a bare boolean (only a flow test injects
743
+ * one) carries no timestamp either, so it is accepted as before.
744
+ * In both cases the flow still asks the health route before touching anything,
745
+ * so a live host is protected by that answer instead of by the clock.
746
+ *
747
+ * @param {unknown} ready the value read from watchdog-ready.json (or a dep's answer)
748
+ * @param {number|string|null|undefined} pendingAt the `at` of THIS restart's pending marker
749
+ * @returns {boolean} true when the marker may be believed
750
+ */
751
+ export function readyIsCurrent(ready, pendingAt) {
752
+ if (!ready) return false;
753
+ const since = markerAtMs({ at: pendingAt });
754
+ if (since === null) return true;
755
+ const atMs = markerAtMs(ready);
756
+ if (atMs === null) return true;
757
+ return atMs >= since;
758
+ }
759
+
239
760
  /**
240
761
  * Write the report of the last watchdog run.
241
762
  *
@@ -246,6 +767,88 @@ export function writeReport(stateDir, report) {
246
767
  writeJson(reportPath(stateDir), report);
247
768
  }
248
769
 
770
+ /** The `outcome` written when the host came up and the watchdog stood down. */
771
+ export const REPORT_OUTCOME_DISARMED = 'disarmed';
772
+
773
+ /**
774
+ * WRITE THE ENDING OF A RESTART THAT WENT FINE (2026-11-01).
775
+ *
776
+ * WHAT WENT WRONG WITHOUT THIS: when a restart succeeded, nobody wrote anything.
777
+ * The report kept the shape it had when the watchdog was ARMED (`result:'armed'`),
778
+ * so `last-watchdog-report.json` said "armed" for a restart that had finished
779
+ * hours earlier — while `health` honestly answered `ready:true` and
780
+ * `scheduleRemoved:true`. Two sources, one of them frozen at the beginning.
781
+ *
782
+ * A report is a record of what HAPPENED, so the ending belongs in it: the host
783
+ * came up, and the watchdog has nothing left to do.
784
+ *
785
+ * EVERY EXISTING FIELD IS PRESERVED VERBATIM (`...previous`): `tier`, `arbiter`,
786
+ * `waitedMs`, `restored`, `relaunched`, `error`, … keep their names and their
787
+ * meaning, because health, the settings page and the watchdog itself all read
788
+ * this file. Only the ending is added, plus `previousResult`, so a reader can
789
+ * still tell what the file said before this call.
790
+ *
791
+ * Never throws: a failed report must not take the boot down with it.
792
+ *
793
+ * @param {string} stateDir
794
+ * @param {{
795
+ * now?: number,
796
+ * record?: object|null,
797
+ * }} [options]
798
+ * @returns {object|null} the merged report (null when there is no state directory)
799
+ */
800
+ export function recordDisarmOutcome(stateDir, { now = Date.now(), record = null } = {}) {
801
+ const dir = typeof stateDir === 'string' ? stateDir.trim() : '';
802
+ if (dir === '') return null;
803
+ const previous = (() => {
804
+ try {
805
+ const value = readReport(dir);
806
+ return value !== null && typeof value === 'object' && Array.isArray(value) !== true ? value : {};
807
+ } catch {
808
+ return {};
809
+ }
810
+ })();
811
+ const stamp = Number.isFinite(Number(now)) ? Number(now) : Date.now();
812
+ const merged = {
813
+ ...previous,
814
+ // THE ENDING: the host is up, so nothing may roll it back.
815
+ hostReady: true,
816
+ disarmed: true,
817
+ disarmedAt: new Date(stamp).toISOString(),
818
+ outcome: REPORT_OUTCOME_DISARMED,
819
+ // The old value is kept beside the new one: "it said armed, and then it was
820
+ // disarmed" is a fact worth being able to read back.
821
+ previousResult: typeof previous.result === 'string' ? previous.result : null,
822
+ result: REPORT_OUTCOME_DISARMED,
823
+ stage: REPORT_OUTCOME_DISARMED,
824
+ ready: true,
825
+ // WHAT THE DISARM ITSELF DID, including the probe tasks it cleaned up.
826
+ disarm: record !== null && typeof record === 'object'
827
+ ? {
828
+ at: typeof record.at === 'string' ? record.at : null,
829
+ source: typeof record.source === 'string' ? record.source : null,
830
+ attempts: Number.isFinite(Number(record.attempts)) ? Number(record.attempts) : null,
831
+ ok: record.ok === true,
832
+ scheduleRemoved: record.scheduleRemoved === true
833
+ ? true
834
+ : (record.scheduleRemoved === false ? false : null),
835
+ probesRemoved: record.probes && Array.isArray(record.probes.removed)
836
+ ? record.probes.removed.slice(0, 50)
837
+ : [],
838
+ probesFailed: record.probes && Array.isArray(record.probes.failed)
839
+ ? record.probes.failed.slice(0, 50)
840
+ : [],
841
+ }
842
+ : null,
843
+ };
844
+ try {
845
+ writeReport(dir, merged);
846
+ } catch {
847
+ // A report that cannot be written is not a reason to fail a boot.
848
+ }
849
+ return merged;
850
+ }
851
+
249
852
  /**
250
853
  * @param {string} stateDir
251
854
  * @returns {object|null}
@@ -299,6 +902,85 @@ export function releaseLock(lockFile) {
299
902
  }
300
903
  }
301
904
 
905
+ /**
906
+ * The links a restore has to decide about, with everything the decision needs.
907
+ *
908
+ * It is the UNION of two lists, and both halves matter:
909
+ *
910
+ * - the links the LIVE profile still has (`collectLinkedPlugins`), and
911
+ * - the links the BACKUP recorded (`manifest.json` → `links`).
912
+ *
913
+ * A plugin whose `node_modules` entry was deleted is gone from the first list
914
+ * but still in the second — and that is exactly the case this feature exists
915
+ * for ("the plugin that does the rescuing was removed with the profile").
916
+ *
917
+ * WHERE THE COPY COMES FROM is called `restoreTargetDir` (2026-11-01): it is the
918
+ * copy the watchdog is allowed to restore, never "the snapshot of this restart"
919
+ * — the old name said the wrong thing and invited exactly that confusion. The
920
+ * old name is still accepted as an alias so an older caller keeps working.
921
+ *
922
+ * @param {{ restoreTargetDir?: string, snapshotDir?: string, profileDir?: string, collect?: Function }} [options]
923
+ * @returns {Array<{
924
+ * name: string, target: string, targetExists: boolean, targetReadable: boolean,
925
+ * hasBackup: boolean, backupPath: string,
926
+ * }>}
927
+ */
928
+ export function linkPlanOf({ restoreTargetDir = '', snapshotDir = '', profileDir = '', collect = null } = {}) {
929
+ const requested = typeof restoreTargetDir === 'string' && restoreTargetDir !== '' ? restoreTargetDir : snapshotDir;
930
+ const snapshot = typeof requested === 'string' ? requested : '';
931
+ const profile = typeof profileDir === 'string' ? profileDir : '';
932
+ if (snapshot === '') return [];
933
+ const gather = typeof collect === 'function' ? collect : collectLinkedPlugins;
934
+ const live = (() => {
935
+ try {
936
+ const collected = gather({ profileDir: profile });
937
+ return Array.isArray(collected && collected.links) ? collected.links : [];
938
+ } catch {
939
+ return [];
940
+ }
941
+ })();
942
+ const manifestRead = readJsonState(path.join(snapshot, MANIFEST_NAME));
943
+ const recorded = manifestRead.state === 'ok'
944
+ && manifestRead.value !== null
945
+ && Array.isArray(manifestRead.value.links)
946
+ ? manifestRead.value.links
947
+ : [];
948
+
949
+ const plan = [];
950
+ const seen = new Set();
951
+ for (const link of live) {
952
+ const name = typeof link.name === 'string' ? link.name : '';
953
+ if (name === '' || seen.has(name)) continue;
954
+ seen.add(name);
955
+ const backupPath = path.join(snapshot, LINKS_DIR, name);
956
+ plan.push({
957
+ name,
958
+ target: typeof link.target === 'string' ? link.target : '',
959
+ targetExists: link.exists === true,
960
+ targetReadable: link.readable === true,
961
+ hasBackup: fs.existsSync(backupPath),
962
+ backupPath,
963
+ });
964
+ }
965
+ for (const link of recorded) {
966
+ const name = link && typeof link.name === 'string' ? link.name : '';
967
+ if (name === '' || seen.has(name)) continue;
968
+ seen.add(name);
969
+ const backupPath = path.join(snapshot, LINKS_DIR, name);
970
+ plan.push({
971
+ name,
972
+ target: typeof link.target === 'string' ? link.target : '',
973
+ // The entry is not in the live profile any more: it was deleted.
974
+ targetExists: false,
975
+ targetReadable: false,
976
+ hasBackup: fs.existsSync(backupPath),
977
+ backupPath,
978
+ });
979
+ }
980
+ plan.sort((left, right) => (left.name < right.name ? -1 : (left.name > right.name ? 1 : 0)));
981
+ return plan;
982
+ }
983
+
302
984
  /**
303
985
  * Put the snapshot back.
304
986
  *
@@ -307,19 +989,135 @@ export function releaseLock(lockFile) {
307
989
  * deleted first — the broken files are overwritten, so a half-restored
308
990
  * profile is never worse than the broken one.)
309
991
  *
310
- * @param {{ snapshotDir?: string, profileDir?: string, copy?: Function }} options
311
- * @returns {{ ok: boolean, error: string|null, copied: string[] }}
992
+ * THE LINKED PLUGINS (2026-10-30). A `link:` installation puts a reparse point
993
+ * in `node_modules/<name>`, so the tree copy above brings back the LINK and
994
+ * nothing behind it. A host cannot start from a link whose target is gone, and
995
+ * this plugin — the one doing the rescuing — is itself installed that way. The
996
+ * decision is therefore made per link, with the SAME function the restore
997
+ * script uses (`decideLinkRestore` in lib/backup.js), so a manual restore and
998
+ * an automatic one cannot disagree:
999
+ *
1000
+ * - target there and readable, first attempt -> KEEP the link (the
1001
+ * development directory keeps working);
1002
+ * - target gone / unreadable, forced, or a later attempt -> REPLACE it with
1003
+ * the contents the backup holds;
1004
+ * - no backup content -> leave the link alone and say so (never delete what
1005
+ * cannot be replaced).
1006
+ *
1007
+ * @param {{
1008
+ * restoreTargetDir?: string, snapshotDir?: string, profileDir?: string, copy?: Function,
1009
+ * attempts?: number, forceLinkPlugins?: boolean,
1010
+ * collectLinks?: Function, decideLink?: Function,
1011
+ * exists?: Function, removeLink?: Function,
1012
+ * }} options
1013
+ * @returns {{ ok: boolean, error: string|null, copied: string[], links: Array<object> }}
312
1014
  */
313
- export function restoreFromSnapshot({ snapshotDir = '', profileDir = '', copy = null } = {}) {
314
- const from = String(snapshotDir ?? '');
1015
+ export function restoreFromSnapshot({
1016
+ restoreTargetDir = '',
1017
+ snapshotDir = '',
1018
+ profileDir = '',
1019
+ copy = null,
1020
+ attempts = 1,
1021
+ forceLinkPlugins = false,
1022
+ collectLinks = null,
1023
+ decideLink = null,
1024
+ exists = null,
1025
+ removeLink = null,
1026
+ } = {}) {
1027
+ const requested = typeof restoreTargetDir === 'string' && restoreTargetDir !== '' ? restoreTargetDir : snapshotDir;
1028
+ const from = String(requested ?? '');
315
1029
  const into = String(profileDir ?? '');
316
1030
  if (from === '' || into === '') {
317
- return { ok: false, error: 'the snapshot or the profile directory is unknown', copied: [] };
1031
+ return { ok: false, error: 'the snapshot or the profile directory is unknown', copied: [], links: [] };
318
1032
  }
319
1033
  const copied = [];
1034
+ const linkReport = [];
320
1035
  const copyInto = copy === null
321
1036
  ? (source, target) => { fs.cpSync(source, target, { recursive: true, force: true }); }
322
1037
  : copy;
1038
+ // A DANGLING reparse point is invisible to `fs.existsSync` (it follows the
1039
+ // link and the target is gone) but it IS an entry that `fs.cpSync` will try
1040
+ // to write into - which kills the whole process on Windows (0xC0000409).
1041
+ // `lstatSync` does not follow, so it answers "the entry is there".
1042
+ const isThere = typeof exists === 'function'
1043
+ ? exists
1044
+ : (target) => {
1045
+ try {
1046
+ fs.lstatSync(target);
1047
+ return true;
1048
+ } catch {
1049
+ return false;
1050
+ }
1051
+ };
1052
+ // The default remover deletes the REPARSE POINT, never the directory behind
1053
+ // it: on Windows `[System.IO.Directory]::Delete(path, false)` removes a
1054
+ // junction/symlink itself, while `rm -rf` would delete the target's contents
1055
+ // (that would take the development tree with it).
1056
+ const dropLink = typeof removeLink === 'function'
1057
+ ? removeLink
1058
+ : (target) => {
1059
+ try {
1060
+ fs.unlinkSync(target);
1061
+ } catch {
1062
+ fs.rmdirSync(target);
1063
+ }
1064
+ };
1065
+ // THE DEFAULT PLAN comes from lib/backup.js, so this file and the restore
1066
+ // script run the SAME decision (and the same rules) instead of two opinions
1067
+ // that drift apart. The CLI does not have to pass anything.
1068
+ const gather = typeof collectLinks === 'function'
1069
+ ? collectLinks
1070
+ : (input) => linkPlanOf({ snapshotDir: input.snapshotDir, profileDir: input.profileDir });
1071
+ const judge = typeof decideLink === 'function' ? decideLink : decideLinkRestore;
1072
+
1073
+ // -------------------------------------------------------------------------
1074
+ // ORDER MATTERS, and it was measured the hard way: `fs.cpSync` into a
1075
+ // node_modules that still holds a DANGLING junction/junction-like reparse
1076
+ // point kills the Node process outright on Windows (0xC0000409, no stack, no
1077
+ // message, nothing written) - the probe is in the task report. So every link
1078
+ // whose target is gone is removed BEFORE the tree copy, and only then are the
1079
+ // contents of the backup put in its place.
1080
+ // -------------------------------------------------------------------------
1081
+ const planned = [];
1082
+ try {
1083
+ for (const link of gather({ snapshotDir: from, profileDir: into })) {
1084
+ const verdict = judge({
1085
+ targetExists: link.targetExists === true,
1086
+ targetReadable: link.targetReadable === true,
1087
+ hasBackup: link.hasBackup === true,
1088
+ force: forceLinkPlugins === true,
1089
+ attempts,
1090
+ });
1091
+ const entry = {
1092
+ name: link.name,
1093
+ target: link.target,
1094
+ hasBackup: link.hasBackup === true,
1095
+ backupPath: link.backupPath,
1096
+ action: verdict.action,
1097
+ reason: verdict.reason,
1098
+ replace: verdict.replace === true,
1099
+ cleared: false,
1100
+ };
1101
+ // A link that points nowhere is cleared whether or not a copy is going in:
1102
+ // leaving it there crashes the copy (above) and cannot start a host.
1103
+ const broken = link.targetExists !== true || link.targetReadable !== true;
1104
+ if (entry.replace === true || broken === true) {
1105
+ const livePath = path.join(into, 'node_modules', link.name);
1106
+ if (isThere(livePath)) {
1107
+ try {
1108
+ dropLink(livePath);
1109
+ entry.cleared = true;
1110
+ } catch (error) {
1111
+ entry.clearError = error && error.message ? error.message : String(error);
1112
+ }
1113
+ }
1114
+ }
1115
+ planned.push(entry);
1116
+ }
1117
+ } catch (error) {
1118
+ linkReport.push({ name: '', action: 'error', reason: error && error.message ? error.message : String(error) });
1119
+ }
1120
+
323
1121
  try {
324
1122
  for (const [part, target] of [['config', into], ['node_modules', path.join(into, 'node_modules')]]) {
325
1123
  const source = path.join(from, part);
@@ -332,9 +1130,43 @@ export function restoreFromSnapshot({ snapshotDir = '', profileDir = '', copy =
332
1130
  ok: false,
333
1131
  error: error && error.message ? error.message : String(error),
334
1132
  copied,
1133
+ links: linkReport.concat(planned.map((entry) => ({
1134
+ name: entry.name,
1135
+ target: entry.target,
1136
+ action: entry.action,
1137
+ reason: entry.reason,
1138
+ }))),
335
1139
  };
336
1140
  }
337
- return { ok: copied.length > 0, error: copied.length > 0 ? null : 'the snapshot carries nothing to restore', copied };
1141
+
1142
+ // THE LINKS, one decision each, after the tree is back. A failure here is
1143
+ // reported, never thrown: the configuration and the tree are already restored.
1144
+ for (const entry of planned) {
1145
+ const reported = { name: entry.name, target: entry.target, action: entry.action, reason: entry.reason };
1146
+ if (entry.replace === true && entry.hasBackup === true) {
1147
+ const livePath = path.join(into, 'node_modules', entry.name);
1148
+ try {
1149
+ if (isThere(livePath)) dropLink(livePath);
1150
+ copyInto(entry.backupPath, livePath);
1151
+ reported.replaced = true;
1152
+ copied.push(path.join('node_modules', entry.name));
1153
+ } catch (error) {
1154
+ reported.replaced = false;
1155
+ reported.error = error && error.message ? error.message : String(error);
1156
+ }
1157
+ } else if (entry.cleared === true) {
1158
+ // No copy to put back: the dead link is gone and the report says so.
1159
+ reported.cleared = true;
1160
+ }
1161
+ linkReport.push(reported);
1162
+ }
1163
+
1164
+ return {
1165
+ ok: copied.length > 0,
1166
+ error: copied.length > 0 ? null : 'the snapshot carries nothing to restore',
1167
+ copied,
1168
+ links: linkReport,
1169
+ };
338
1170
  }
339
1171
 
340
1172
  /**
@@ -352,7 +1184,13 @@ async function waitForReady(deps, timeoutMs) {
352
1184
  if (deps.isWithdrawn && deps.isWithdrawn()) return false;
353
1185
  await deps.sleep(Math.min(deps.pollMs, Math.max(0, deadline - deps.now())));
354
1186
  }
355
- return deps.readReady() === true;
1187
+ // `Boolean(...)`, NOT `=== true`: the real reader answers with the marker
1188
+ // OBJECT (or null), so `=== true` made this last look never count — a host
1189
+ // whose marker appeared exactly at the deadline was reported as not coming
1190
+ // up. The value has already been filtered through `readyIsCurrent` by the
1191
+ // caller (see runWatchdogFlow), so a marker of an EARLIER restart is `null`
1192
+ // here and cannot be counted either.
1193
+ return Boolean(deps.readReady());
356
1194
  }
357
1195
 
358
1196
  /**
@@ -375,6 +1213,58 @@ function healthAnswer(value) {
375
1213
  return { state: typeof value === 'string' ? value : 'unknown', detail: null };
376
1214
  }
377
1215
 
1216
+ /**
1217
+ * Normalize the answer of the cookie cleanup.
1218
+ *
1219
+ * The cleanup script always exits 0 (a restart must never be blocked by a
1220
+ * cleanup), so the answer of the runner means "the cleanup process finished",
1221
+ * not "cookies were deleted". `removed` is the count when the runner knows it
1222
+ * and `null` when it does not — a count that was never measured is reported as
1223
+ * not measured, never as 0.
1224
+ *
1225
+ * @param {unknown} value
1226
+ * @returns {{ ok: boolean, removed: number|null, error: string|null }}
1227
+ */
1228
+ function cookieClearAnswer(value) {
1229
+ if (value === null || value === undefined) {
1230
+ return { ok: false, removed: null, error: 'the cookie cleanup was not run' };
1231
+ }
1232
+ if (typeof value === 'object') {
1233
+ const ok = value.ok === true;
1234
+ const count = Number(value.removed);
1235
+ const removed = value.removed === null || value.removed === undefined || !Number.isFinite(count)
1236
+ ? null
1237
+ : Math.trunc(count);
1238
+ const detail = typeof value.error === 'string' && value.error !== '' ? value.error : null;
1239
+ return { ok, removed, error: ok ? detail : (detail ?? 'the cookie cleanup failed') };
1240
+ }
1241
+ const ok = value === true;
1242
+ return { ok, removed: null, error: ok ? null : 'the cookie cleanup failed' };
1243
+ }
1244
+
1245
+ /**
1246
+ * Run the cleanup, and never let it break the rescue.
1247
+ *
1248
+ * A thrown cleanup is an answer like any other: `{ ok:false, error }` — the
1249
+ * caller records it in the report and goes on to start the host, because a
1250
+ * host that starts with stale cookies is still better than a host that never
1251
+ * starts at all.
1252
+ *
1253
+ * @param {object} deps
1254
+ * @returns {Promise<{ ok: boolean, removed: number|null, error: string|null }>}
1255
+ */
1256
+ async function runCookieClear(deps) {
1257
+ try {
1258
+ return cookieClearAnswer(await deps.clearCookies());
1259
+ } catch (error) {
1260
+ return {
1261
+ ok: false,
1262
+ removed: null,
1263
+ error: error && error.message ? error.message : String(error),
1264
+ };
1265
+ }
1266
+ }
1267
+
378
1268
  /**
379
1269
  * THE FLOW.
380
1270
  *
@@ -398,6 +1288,11 @@ export async function runWatchdogFlow({ pending, stateDir = '', deps = {} }) {
398
1288
  askHealth: () => 'unknown',
399
1289
  runRestore: () => ({ ok: false, error: 'no restore command' }),
400
1290
  runRelaunch: () => ({ ok: false, error: 'no relaunch command' }),
1291
+ // THE HANDSHAKE COOKIES (see lib/cookie-clear.js). The CLI runs the real
1292
+ // cleanup script; a flow test injects it. The default REFUSES instead of
1293
+ // pretending: a report that says "the cookies were cleared" when nobody
1294
+ // cleared them is worse than a report that says nobody tried.
1295
+ clearCookies: () => ({ ok: false, removed: null, error: 'no cookie cleanup is wired' }),
401
1296
  acquireLock: () => true,
402
1297
  releaseLock: () => {},
403
1298
  removeSchedule: () => {},
@@ -423,13 +1318,80 @@ export async function runWatchdogFlow({ pending, stateDir = '', deps = {} }) {
423
1318
  health: null,
424
1319
  extraRound: false,
425
1320
  restored: false,
1321
+ // WHICH COPY WAS RESTORED (2026-11-01). Until now the report said `restored:
1322
+ // true` and nothing else — "which copy did you put back?" was unanswerable
1323
+ // from the report, which is exactly the question a human asks afterwards.
1324
+ restoredFrom: null,
1325
+ // HOW OFTEN a restore was performed. It is 0 or 1 by design (see MAX_RESTORES):
1326
+ // restoring the SAME copy twice changes nothing, and doing it three times was
1327
+ // measured hitting the same wall three times.
1328
+ restores: 0,
426
1329
  relaunched: false,
427
1330
  ready: false,
1331
+ // THE STRICT-MODE ANSWER: when no copy qualifies, `strictMode` is true and
1332
+ // `restoreCandidates` holds every copy that was looked at, with the reason
1333
+ // each one was refused.
1334
+ strictMode: false,
1335
+ restoreTarget: null,
1336
+ restoreBlocked: false,
1337
+ restoreBlockedReason: null,
1338
+ restoreCandidates: [],
1339
+ // WAS THERE A READY MARKER AND WAS IT REFUSED? A marker of an EARLIER
1340
+ // restart (its `at` is before this restart's pending `at`) is read and
1341
+ // thrown away (see readyIsCurrent). The 2026-11-01 run said nothing at all
1342
+ // about this and looked like a run that had nothing to do, so the refusal
1343
+ // is REPORTED: `staleReadyIgnored` says it happened, `staleReadyAt` says
1344
+ // which marker it was.
1345
+ staleReadyIgnored: false,
1346
+ staleReadyAt: null,
428
1347
  error: null,
1348
+ // WHERE the run stood when it ended, so "the rescue failed" and "I never
1349
+ // got going" can never be confused: 'flow' (the flow ran), 'relaunch'
1350
+ // (the host could not be started again). The CLI writes 'import' and
1351
+ // 'config' for a run that could not even read its own marker.
1352
+ stage: 'flow',
1353
+ // HOW OFTEN this run was allowed to try, and how often it did.
1354
+ maxAttempts: maxAttemptsOf(pending),
1355
+ attempts: 0,
1356
+ // The LAST cookie cleanup, and one entry per attempt (never silent).
1357
+ cookieClear: null,
1358
+ attemptLog: [],
1359
+ };
1360
+ // THE READY MARKER OF AN EARLIER RESTART IS NOT A RECEIPT OF THIS ONE.
1361
+ //
1362
+ // Every read of the marker inside this flow goes through `readCurrentReady`,
1363
+ // never through `d.readReady()` directly: a marker written BEFORE this
1364
+ // restart's pending marker (`pending.at`) belongs to the boot that was
1365
+ // replaced, and believing it is exactly the 2026-11-01 blocker — the host
1366
+ // never came up, the old marker said "it is up", the flow waited 0 ms and
1367
+ // reported `result: 'not-needed'` with nothing restored and nothing started
1368
+ // while port 5601 stayed empty for more than two minutes.
1369
+ const pendingAt = pending && pending.at !== undefined ? pending.at : null;
1370
+ // WHAT THE MARKER SAYS THIS RUN MAY RESTORE — read once, reported always.
1371
+ // It is filled in BEFORE the first look at the ready marker so the fields also
1372
+ // exist for a run that ends with "not-needed": an operator reading the report
1373
+ // should never have to guess whether a target existed.
1374
+ const target = restoreTargetInfoOf(pending);
1375
+ report.restoreTarget = target.dir === '' ? null : target.dir;
1376
+ report.restoreBlocked = target.blocked;
1377
+ report.restoreBlockedReason = target.reason === '' ? null : target.reason;
1378
+ report.restoreCandidates = target.candidates;
1379
+ const readCurrentReady = () => {
1380
+ const value = d.readReady();
1381
+ // "Nothing there yet" is not a refused marker: it is the normal first look.
1382
+ if (!value) return value;
1383
+ if (readyIsCurrent(value, pendingAt)) return value;
1384
+ report.staleReadyIgnored = true;
1385
+ report.staleReadyAt = typeof value.at === 'string' ? value.at : null;
1386
+ return null;
429
1387
  };
1388
+ // `waitForReady` keeps its signature: it is handed the same dependencies with
1389
+ // ONE reader swapped for the filtered one, so a stale marker cannot make it
1390
+ // return true either.
1391
+ const readyDeps = { ...d, readReady: readCurrentReady };
430
1392
  try {
431
1393
  // (1) The new host writes the ready marker after its health check.
432
- const ready = await waitForReady(d, pending.timeoutMs);
1394
+ const ready = await waitForReady(readyDeps, pending.timeoutMs);
433
1395
  if (ready) {
434
1396
  report.result = 'not-needed';
435
1397
  report.ready = true;
@@ -444,7 +1406,7 @@ export async function runWatchdogFlow({ pending, stateDir = '', deps = {} }) {
444
1406
  // (2) ASK AGAIN. A host that is slow to start is not a host that is dead.
445
1407
  report.rechecked = true;
446
1408
  await d.sleep(Math.max(0, pending.recheckMs));
447
- if (d.readReady()) {
1409
+ if (readCurrentReady()) {
448
1410
  report.result = 'not-needed';
449
1411
  report.ready = true;
450
1412
  return report;
@@ -485,7 +1447,7 @@ export async function runWatchdogFlow({ pending, stateDir = '', deps = {} }) {
485
1447
  // still starting gets this last chance before anything is restored.
486
1448
  report.extraRound = true;
487
1449
  await d.sleep(Math.max(0, pending.recheckMs));
488
- if (d.readReady()) {
1450
+ if (readCurrentReady()) {
489
1451
  report.result = 'not-needed';
490
1452
  report.ready = true;
491
1453
  return report;
@@ -512,34 +1474,218 @@ export async function runWatchdogFlow({ pending, stateDir = '', deps = {} }) {
512
1474
  }
513
1475
  }
514
1476
 
515
- // (4) The lock: exactly ONE of us restores.
1477
+ // (4) THE LOCK: exactly ONE of us restores. And before it: STRICT MODE.
1478
+ //
1479
+ // A restore is only allowed when the marker names a copy the ARMING side was
1480
+ // allowed to pick — certified, and free of a recorded system intervention.
1481
+ // When it names none, the strict-mode decision the user took applies: do NOT
1482
+ // touch anything (no lock, no restore, no relaunch — a relaunch of a broken
1483
+ // profile is not a rescue), and say it out loud with the list of copies that
1484
+ // were refused, so a human knows exactly what to do next.
1485
+ //
1486
+ // "Not touching it" is NOT the same as staying silent: a run that ends
1487
+ // quietly looks like a run that found nothing to do, and that is how the bad
1488
+ // state stayed certified in the first place. (`target` is read at the top of
1489
+ // the flow, so the same fields are in a report that ends 'not-needed'.)
1490
+ if (target.dir === '' || target.blocked === true) {
1491
+ report.result = 'failed';
1492
+ report.stage = 'strict';
1493
+ report.strictMode = true;
1494
+ report.error = 'no trusted restore point: nothing was restored or started'
1495
+ + (target.reason === '' ? '' : ' (' + target.reason + ')');
1496
+ return report;
1497
+ }
516
1498
  if (!d.acquireLock()) {
517
1499
  report.result = 'abandoned';
518
1500
  report.error = 'another rollback is already running';
519
1501
  return report;
520
1502
  }
521
1503
  try {
522
- // (5) Restore the snapshot.
523
- const restored = d.runRestore();
524
- report.restored = restored !== null && restored !== undefined && restored.ok === true;
525
- if (!report.restored) {
1504
+ // (5) THE PASS: up to `maxAttempts` tries (3 by default, never endless).
1505
+ //
1506
+ // Every try AFTER the first asks the three questions of steps (1)-(3b)
1507
+ // again, because the world changed in between: the host we started may
1508
+ // have come up (a slow boot is not a dead host) and another instance may
1509
+ // be alive (a second start next to a running host is the double start
1510
+ // this whole file exists to prevent). The FIRST try does not ask again:
1511
+ // those three answers are a moment old and cannot have changed.
1512
+ //
1513
+ // The pass order is fixed and must stay that way:
1514
+ // ready? -> alive? -> health? -> restore -> CLEAR THE COOKIES -> start -> wait
1515
+ let restoreNeeded = true;
1516
+ for (let attempt = 1; attempt <= report.maxAttempts; attempt += 1) {
1517
+ if (attempt > 1) {
1518
+ if (readCurrentReady()) {
1519
+ report.result = 'not-needed';
1520
+ report.ready = true;
1521
+ report.error = null;
1522
+ break;
1523
+ }
1524
+ if (d.isWithdrawn()) {
1525
+ report.result = 'decided';
1526
+ report.error = 'the plugin decided on its own: the watchdog stood down';
1527
+ break;
1528
+ }
1529
+ if (await d.probeAlive()) {
1530
+ report.result = 'abandoned';
1531
+ report.error = 'an instance is alive: the host was not started again';
1532
+ break;
1533
+ }
1534
+ const again = healthAnswer(await d.askHealth());
1535
+ report.healthCheck = again.state;
1536
+ report.healthDetail = again.detail;
1537
+ if (again.state === 'healthy') {
1538
+ report.result = 'not-needed';
1539
+ report.health = 'healthy';
1540
+ report.error = 'the host answered: it is up, it was NOT started again'
1541
+ + (again.detail === null ? '' : ' (' + again.detail + ')');
1542
+ break;
1543
+ }
1544
+ if (again.state === 'unknown') {
1545
+ // There is still no port to ask: one more round, then look again.
1546
+ await d.sleep(Math.max(0, pending.recheckMs));
1547
+ if (readCurrentReady()) {
1548
+ report.result = 'not-needed';
1549
+ report.ready = true;
1550
+ report.error = null;
1551
+ break;
1552
+ }
1553
+ if (d.isWithdrawn()) {
1554
+ report.result = 'decided';
1555
+ report.error = 'the plugin decided on its own: the watchdog stood down';
1556
+ break;
1557
+ }
1558
+ if (await d.probeAlive()) {
1559
+ report.result = 'abandoned';
1560
+ report.error = 'an instance is alive: the host was not started again';
1561
+ break;
1562
+ }
1563
+ }
1564
+ }
1565
+
1566
+ report.attempts = attempt;
1567
+ const entry = {
1568
+ attempt,
1569
+ at: new Date(d.now()).toISOString(),
1570
+ restored: false,
1571
+ // WHICH COPY this attempt put back (null when it did not restore). A
1572
+ // report that only said "restored: true" could never answer "from
1573
+ // what", which is the first thing a human asks afterwards.
1574
+ restoredFrom: null,
1575
+ cookieClear: null,
1576
+ relaunched: false,
1577
+ ready: false,
1578
+ // The marker of an earlier restart was read and refused before this
1579
+ // attempt began: one entry says it, next to what this attempt did.
1580
+ staleReadyIgnored: report.staleReadyIgnored === true,
1581
+ backoffMs: 0,
1582
+ error: null,
1583
+ };
1584
+ report.attemptLog.push(entry);
1585
+
1586
+ // (d) Put the copy back — AT MOST ONCE (2026-11-01).
1587
+ //
1588
+ // Restoring the SAME copy again cannot change anything: the bytes are
1589
+ // identical. Measured on the real machine, three attempts restored the
1590
+ // same copy three times and hit the same wall three times. So a restore
1591
+ // happens once; what may be repeated is the START (`relaunch`), because
1592
+ // a host that died for an unrelated reason can come up on the next try.
1593
+ if (restoreNeeded && report.restores < MAX_RESTORES) {
1594
+ const restored = d.runRestore();
1595
+ report.restores += 1;
1596
+ report.restored = restored !== null && restored !== undefined && restored.ok === true;
1597
+ entry.restored = report.restored;
1598
+ if (!report.restored) {
1599
+ restoreNeeded = false;
1600
+ report.result = 'failed';
1601
+ report.error = (restored && restored.error ? restored.error : 'the restore failed')
1602
+ + ': the restore is NOT retried (the same copy would be restored again)';
1603
+ entry.error = report.error;
1604
+ break;
1605
+ }
1606
+ restoreNeeded = false;
1607
+ // The copy the watchdog actually put back: what the marker armed, or
1608
+ // what the restore implementation reports it used.
1609
+ const used = restored && typeof restored.dir === 'string' && restored.dir !== ''
1610
+ ? restored.dir
1611
+ : target.dir;
1612
+ report.restoredFrom = used === '' ? null : used;
1613
+ entry.restoredFrom = report.restoredFrom;
1614
+ }
1615
+
1616
+ // (e) THE HANDSHAKE COOKIES, AFTER the restore and BEFORE the start.
1617
+ //
1618
+ // The desktop app mints one `dsh-auth-*` cookie per launch, the cookie
1619
+ // lives 30 days, and the host answers 431 when the Cookie header
1620
+ // overflows (71 cookies, measured). Starting the host again with the
1621
+ // stale cookies therefore kills it again — that is what happened on
1622
+ // 2026-10-30. The cleanup runs once Chromium has left (the host is
1623
+ // confirmed dead above), and it never blocks the rescue: the script
1624
+ // itself always exits 0 for that reason. But it is ALWAYS written into
1625
+ // the report: a cleanup that could not run must never look like one
1626
+ // that did.
1627
+ const cookie = await runCookieClear(d);
1628
+ report.cookieClear = cookie;
1629
+ entry.cookieClear = cookie;
1630
+ if (!cookie.ok) {
1631
+ entry.error = 'the stale cookies could not be cleared: ' + (cookie.error || 'unknown reason');
1632
+ }
1633
+
1634
+ // (f) Start the host again — but look at the marker ONE last time: the
1635
+ // host may have finished its boot while the cleanup and the
1636
+ // restore ran, and a host that is up must not be started again.
1637
+ if (readCurrentReady()) {
1638
+ report.result = 'not-needed';
1639
+ report.ready = true;
1640
+ report.error = 'the host came up while the watchdog was working: it was NOT started again';
1641
+ break;
1642
+ }
1643
+ const relaunched = d.runRelaunch();
1644
+ report.relaunched = relaunched !== null && relaunched !== undefined && relaunched.ok === true;
1645
+ entry.relaunched = report.relaunched;
1646
+ if (!report.relaunched) {
1647
+ report.result = 'failed';
1648
+ // "I could not start it" is not "the rescue failed after trying":
1649
+ // the report says where it stood.
1650
+ report.stage = 'relaunch';
1651
+ report.error = relaunched && relaunched.error ? relaunched.error : 'the host could not be started again';
1652
+ entry.error = report.error;
1653
+ break;
1654
+ }
1655
+
1656
+ // (g) Did it come up this time?
1657
+ const readyAgain = await waitForReady(readyDeps, pending.readyTimeoutMs);
1658
+ report.ready = readyAgain;
1659
+ entry.ready = readyAgain;
1660
+ if (readyAgain) {
1661
+ report.result = 'recovered';
1662
+ report.error = null;
1663
+ break;
1664
+ }
526
1665
  report.result = 'failed';
527
- report.error = restored && restored.error ? restored.error : 'the restore failed';
528
- return report;
1666
+ report.error = 'the host did not come up after the restore';
1667
+ if (attempt < report.maxAttempts) {
1668
+ const wait = backoffMsFor(attempt);
1669
+ entry.backoffMs = wait;
1670
+ await d.sleep(wait);
1671
+ }
1672
+ }
1673
+ // The message of a run that ran out of attempts must SAY so: "failed"
1674
+ // alone would hide that the watchdog tried, and how often. It also has to
1675
+ // say that NOTHING ELSE WILL PICK IT UP: this is the last automatic step,
1676
+ // so a human has to look at the report. And it says WHICH copy was put
1677
+ // back and that the restore is not repeated — the two facts the reader
1678
+ // needs to decide what to do next.
1679
+ if (report.result === 'failed' && report.stage === 'flow' && report.relaunched === true) {
1680
+ report.error = 'the host did not come up after ' + report.attempts + ' attempt(s) of '
1681
+ + report.maxAttempts + ' (last error: ' + String(report.error) + '): the copy '
1682
+ + (report.restoredFrom === null ? '(unknown)' : String(report.restoredFrom))
1683
+ + ' was restored ONCE and is not restored again — no further rollback will be attempted';
529
1684
  }
530
- // (6) Start the host again.
531
- const relaunched = d.runRelaunch();
532
- report.relaunched = relaunched !== null && relaunched !== undefined && relaunched.ok === true;
533
- if (!report.relaunched) {
1685
+ if (report.result === 'unknown') {
534
1686
  report.result = 'failed';
535
- report.error = relaunched && relaunched.error ? relaunched.error : 'the host could not be started again';
536
- return report;
1687
+ report.error = 'the flow ended without an answer';
537
1688
  }
538
- // (7) Did it come up this time?
539
- const readyAgain = await waitForReady(d, pending.readyTimeoutMs);
540
- report.ready = readyAgain;
541
- report.result = readyAgain ? 'recovered' : 'failed';
542
- if (!readyAgain) report.error = 'the host did not come up after the restore';
543
1689
  } finally {
544
1690
  d.releaseLock();
545
1691
  }
@@ -548,18 +1694,47 @@ export async function runWatchdogFlow({ pending, stateDir = '', deps = {} }) {
548
1694
  report.error = error && error.message ? error.message : String(error);
549
1695
  } finally {
550
1696
  report.waitedMs = d.now() - startedAt;
551
- // The scheduling item and the pending marker go; the report stays.
1697
+ // ★ 收尾顺序是有理由的,不许调回去(2026-11-01 回归,由 14e9a6b 引入)。
1698
+ //
1699
+ // 为什么必须先写报告:最后一步 d.removeSchedule() 在 CLI 那条路上执行的是
1700
+ // `systemctl --user stop dsh-autorestart-watchdog.service` —— 那正是承载本
1701
+ // 进程的 service(见 scripts/watchdog.mjs 注进去的 removeSchedule)。service
1702
+ // 一停,systemd 就给这个进程发 SIGTERM,于是写在清理之后的报告永远到不了盘
1703
+ // 上:守夜人明明救成功了,客户打开报告看到的却还停在启动那一刻的 `armed`
1704
+ // (「武装好了,什么也没发生」)。这比没有守夜人更糟——下一次出问题,没人
1705
+ // 知道上一次是被救回来的。
1706
+ //
1707
+ // 所以顺序固定为:写报告 → 清 pending → 清理调度项(唯一可能终止自己的动作)。
1708
+ // test/watchdog-report-order.test.mjs 用「清理动作发生的那一刻,盘上必须已经
1709
+ // 是本次救援结果」守住这三行的顺序。
552
1710
  try {
553
- d.removeSchedule();
554
- } catch {
555
- // The scheduling item is cleaned up by the next run if this fails.
1711
+ d.writeReport(report);
1712
+ } catch (error) {
1713
+ // 报告写不出去是必须看得见的失败:stderr 一行日志 + 返回值上的字段,
1714
+ // CLI 用 reportWriteError 决定退出码。但不许因此跳过清理(调度项必须被
1715
+ // 清掉),所以这里只记录、不重抛。
1716
+ report.reportWritten = false;
1717
+ report.reportWriteError = error && error.message ? error.message : String(error);
1718
+ try {
1719
+ console.error(
1720
+ 'dsh-autorestart watchdog: the report could not be written to '
1721
+ + (stateDir === '' ? '(no state directory)' : stateDir)
1722
+ + ': ' + report.reportWriteError,
1723
+ );
1724
+ } catch {
1725
+ // 连 stderr 都写不出去也不再抛:清理仍然要发生。
1726
+ }
556
1727
  }
557
1728
  try {
558
1729
  if (stateDir !== '') clearPending(stateDir);
559
1730
  } catch {
560
1731
  // It is gone either way.
561
1732
  }
562
- d.writeReport(report);
1733
+ try {
1734
+ d.removeSchedule();
1735
+ } catch {
1736
+ // The scheduling item is cleaned up by the next run if this fails.
1737
+ }
563
1738
  }
564
1739
  return report;
565
1740
  }