dsh-autorestart 0.8.1 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -49,9 +49,17 @@ import {
49
49
  setHandoff,
50
50
  setRecoveryOutcome,
51
51
  setPhase,
52
+ setRestartPhase,
53
+ setRestartProgress,
54
+ clearRestartPhase,
52
55
  stampResumedGoal,
53
56
  writeState,
54
57
  } from './lib/checkpoint.js'
58
+ import {
59
+ cancelRefusalReason,
60
+ describeRestartPhase,
61
+ RESTART_PHASES,
62
+ } from './lib/restart-phase.js'
55
63
  import {
56
64
  bootPlan,
57
65
  composeHandoff,
@@ -117,13 +125,18 @@ import {
117
125
  } from './lib/watchdog-tier.js'
118
126
  import {
119
127
  buildPending,
128
+ clearPending,
120
129
  clearReady,
121
- readReady,
130
+ readPending,
122
131
  readReport,
123
132
  writePending,
124
- writeReady,
125
133
  writeReport,
126
134
  } from './lib/watchdog.js'
135
+ import {
136
+ disarmWatchdog as runDisarmWatchdog,
137
+ markReady,
138
+ readDisarmRecord,
139
+ } from './lib/watchdog-disarm.js'
127
140
  import {
128
141
  ARBITERS,
129
142
  planWatchdogArbiter,
@@ -374,7 +387,7 @@ function snapGraceChoice(value, fallback = DEFAULT_RESTART_GRACE_MS) {
374
387
  * `pluginVersion()`), but a bundled installation may not ship that file — so
375
388
  * the constant below is the last resort. A test keeps the two in sync.
376
389
  */
377
- const PLUGIN_VERSION_FALLBACK = '0.8.1'
390
+ const PLUGIN_VERSION_FALLBACK = '0.8.3'
378
391
 
379
392
  /**
380
393
  * The version of the graceful-shutdown FEATURE, not the version of the package.
@@ -1398,12 +1411,16 @@ function backupsDirFor(dir) {
1398
1411
  * error: string|null,
1399
1412
  * }}
1400
1413
  */
1401
- export function takePreRestartSnapshot({
1414
+ export async function takePreRestartSnapshot({
1402
1415
  config = {},
1403
1416
  stateDir = '',
1404
1417
  log = console,
1405
1418
  profile = null,
1406
1419
  createBackup: createBackupFn = createBackup,
1420
+ // The REAL progress of the copy, and the way out of it: both are handed to
1421
+ // the backup itself, so the numbers on screen are the numbers on disk.
1422
+ onProgress = null,
1423
+ shouldCancel = null,
1407
1424
  } = {}) {
1408
1425
  const startedAt = Date.now()
1409
1426
  const info = profile && typeof profile === 'object'
@@ -1427,17 +1444,33 @@ export function takePreRestartSnapshot({
1427
1444
  }
1428
1445
  let backup = null
1429
1446
  try {
1430
- backup = createBackupFn({
1447
+ backup = await createBackupFn({
1431
1448
  profileDir,
1432
1449
  backupsDir: backupsDirFor(stateDir),
1433
1450
  includePlugins: config.backupIncludePlugins !== false,
1434
1451
  maxBackups: config.maxBackups,
1452
+ onProgress: typeof onProgress === 'function' ? onProgress : null,
1453
+ shouldCancel: typeof shouldCancel === 'function' ? shouldCancel : null,
1435
1454
  })
1436
1455
  } catch (error) {
1437
1456
  // A backup that throws must not take the restart down with it.
1438
1457
  backup = { ok: false, error: String(error) }
1439
1458
  }
1440
1459
  const durationMs = Math.max(0, Date.now() - startedAt)
1460
+ if (backup && backup.cancelled === true) {
1461
+ // The user called it off: the half-written snapshot was already removed by
1462
+ // the copy loop, and there is nothing to report as a rollback point.
1463
+ log.warn('the pre-restart backup was cancelled by the user', { durationMs })
1464
+ return {
1465
+ backup,
1466
+ profileDir,
1467
+ profile: info,
1468
+ requested: true,
1469
+ durationMs,
1470
+ cancelled: true,
1471
+ error: null,
1472
+ }
1473
+ }
1441
1474
  if (backup && backup.ok === true) {
1442
1475
  log.warn('pre-restart backup written', {
1443
1476
  dir: backup.dir,
@@ -1645,6 +1678,17 @@ export function createRuntime(ctx, config, options = {}) {
1645
1678
  let recoverTimer
1646
1679
  let disposed = false
1647
1680
  let lastPersistError
1681
+ /**
1682
+ * THE LAST DISARM of the watchdog: the record of "the host is up, so nothing
1683
+ * may roll it back" (see lib/watchdog-disarm.js). It is kept on `runtime` so
1684
+ * the settings page and the diagnostics can show a FAILED disarm instead of
1685
+ * the silence that caused the 0.8.2 incident.
1686
+ */
1687
+ let watchdogDisarm = readDisarmRecord(path.dirname(stateFile))
1688
+ /** The disarm that is currently running (one at a time; null when idle). */
1689
+ let disarmInFlight = null
1690
+ /** A disarm was asked for while one was running: run once more afterwards. */
1691
+ let disarmPendingAgain = false
1648
1692
  /**
1649
1693
  * The tool registrations that failed. A failed registration is recorded and
1650
1694
  * logged, never thrown: the plugin still loads and the other tools still work.
@@ -2605,6 +2649,96 @@ const titleOfSummary = (item) => {
2605
2649
  */
2606
2650
  const stateDirOf = () => path.dirname(stateFile)
2607
2651
 
2652
+ /**
2653
+ * THE STAGE LINE of a restart ("waiting / backing up / restarting / back").
2654
+ *
2655
+ * It is written ONCE per stage change and persisted IMMEDIATELY, because the
2656
+ * whole point of it is the first second after the click: a stage that reaches
2657
+ * the interface only after the 156 MB snapshot would tell the user nothing
2658
+ * about the ten seconds they spent wondering whether the button worked.
2659
+ *
2660
+ * @param {string} phase one of RESTART_PHASES
2661
+ * @param {{ blocked?: number, now?: number }} [options]
2662
+ * @returns {object|null} the recorded stage
2663
+ */
2664
+ const markRestartStage = (phase, { blocked = 0, now = Date.now() } = {}) => {
2665
+ if (!RESTART_PHASES.includes(phase)) return null
2666
+ const record = setRestartPhase(state, { phase, blocked, now })
2667
+ persist()
2668
+ return record
2669
+ }
2670
+
2671
+ /**
2672
+ * Record the REAL progress of the running snapshot (files and bytes).
2673
+ *
2674
+ * The copy loop calls this between files; the checkpoint is written with a
2675
+ * small throttle, because a state write per copied file would slow the copy
2676
+ * down for no gain. The in-memory record is always current, so a `state=1`
2677
+ * poll that arrives between two writes still sees the newest numbers.
2678
+ *
2679
+ * @param {{ files?: number, bytes?: number, total?: number, filesTotal?: number, now?: number }} progress
2680
+ */
2681
+ let lastProgressPersist = 0
2682
+ const recordSnapshotProgress = (progress) => {
2683
+ const now = Date.now()
2684
+ const record = setRestartProgress(state, { ...progress, now })
2685
+ if (now - lastProgressPersist >= 250) {
2686
+ lastProgressPersist = now
2687
+ persist()
2688
+ }
2689
+ return record
2690
+ }
2691
+
2692
+ /** Forget the stage (cancel, or a restart that could not be started). */
2693
+ const clearRestartStage = () => {
2694
+ if (clearRestartPhase(state)) persist()
2695
+ }
2696
+
2697
+ /**
2698
+ * The stage as every surface shows it (state route, health route, client).
2699
+ *
2700
+ * @returns {object} the described stage (always an object, never null)
2701
+ */
2702
+ const restartStageInfo = () => {
2703
+ const record = state.restartPhase
2704
+ const blocked = (() => {
2705
+ try {
2706
+ return countBlockerTasks(gate().blockedBy)
2707
+ } catch {
2708
+ return 0
2709
+ }
2710
+ })()
2711
+ return describeRestartPhase({
2712
+ phase: record && typeof record === 'object' ? record.phase : 'idle',
2713
+ startedAt: record && typeof record === 'object' ? record.startedAt : null,
2714
+ at: record && typeof record === 'object' ? record.at : null,
2715
+ progress: record && typeof record === 'object' ? record.progress : null,
2716
+ blocking: record && typeof record === 'object' && Number.isFinite(record.blocked) ? record.blocked : blocked,
2717
+ // The measured size of the pre-restart snapshot: it survives the stage
2718
+ // change, so a page that missed the copy can still report it.
2719
+ snapshotBytes: record && typeof record === 'object' ? record.snapshotBytes : null,
2720
+ now: Date.now(),
2721
+ })
2722
+ }
2723
+
2724
+ /**
2725
+ * A cancel request that arrived while the snapshot was being written.
2726
+ *
2727
+ * The backup loop asks this between files: the copy stops, its half-written
2728
+ * directory is removed, and the host never restarts. A flag (not a thrown
2729
+ * error) because the loop is synchronous and must finish its bookkeeping.
2730
+ */
2731
+ let snapshotCancelRequested = false
2732
+ const requestSnapshotCancel = () => {
2733
+ snapshotCancelRequested = true
2734
+ return true
2735
+ }
2736
+ const snapshotCancelled = () => snapshotCancelRequested === true
2737
+ const resetSnapshotCancel = () => {
2738
+ snapshotCancelRequested = false
2739
+ }
2740
+
2741
+
2608
2742
  /**
2609
2743
  * The durable record of the last restart: the snapshot that was taken, the
2610
2744
  * verdict of the new instance and its four boot checks.
@@ -2616,17 +2750,14 @@ const titleOfSummary = (item) => {
2616
2750
  /**
2617
2751
  * The version of THIS plugin (the one the user runs).
2618
2752
  *
2753
+ * It delegates to the module-level reader ON PURPOSE: a local copy used to
2754
+ * look for `../package.json`, which is one directory ABOVE the plugin, so
2755
+ * every answer was `unknown` — and the health route reported that instead of
2756
+ * the version that is really loaded.
2757
+ *
2619
2758
  * @returns {string}
2620
2759
  */
2621
- const pluginVersion = () => {
2622
- try {
2623
- const raw = fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')
2624
- const parsed = JSON.parse(raw)
2625
- return typeof parsed.version === 'string' && parsed.version !== '' ? parsed.version : 'unknown'
2626
- } catch {
2627
- return 'unknown'
2628
- }
2629
- }
2760
+ const ownVersion = () => pluginVersion()
2630
2761
 
2631
2762
  /**
2632
2763
  * Write (or update) the durable record of the restart.
@@ -2960,7 +3091,7 @@ const titleOfSummary = (item) => {
2960
3091
  * @returns {string}
2961
3092
  */
2962
3093
  const diagnosticsText = () => buildDiagnostics({
2963
- version: pluginVersion(),
3094
+ version: ownVersion(),
2964
3095
  platform: env.platform,
2965
3096
  hostKind: hostKindOf().kind,
2966
3097
  isDesktopApp: env.isDesktopApp === true,
@@ -2970,6 +3101,9 @@ const titleOfSummary = (item) => {
2970
3101
  lastRestart: lastRestartVerdict(),
2971
3102
  stateDir: stateDirOf(),
2972
3103
  watchdog: watchdogInfo(),
3104
+ // DID THE DISARM WORK? A failed disarm leaves an armed watchdog behind,
3105
+ // and that is the first thing a bug report has to show.
3106
+ watchdogDisarm: summarizeDisarm(watchdogDisarm),
2973
3107
  })
2974
3108
 
2975
3109
  /**
@@ -3540,13 +3674,50 @@ const titleOfSummary = (item) => {
3540
3674
  // app relaunch and the custom restart command share this single entry, so
3541
3675
  // a hosted host that is restarted by a command is just as recoverable as
3542
3676
  // the desktop app.
3677
+ //
3678
+ // THE STAGE IS ON SCREEN BEFORE THE COPY STARTS, and the copy reports the
3679
+ // files and bytes it really writes. That is the fix of "I clicked restart
3680
+ // and nothing happened for ten seconds": the ten seconds ARE the snapshot,
3681
+ // and now they say so.
3543
3682
  const stateDir = path.dirname(stateFile)
3544
- const snapshot = snapshotFn({
3545
- config,
3546
- stateDir,
3547
- log,
3548
- profile: resolveProfileDirDetailed(),
3549
- })
3683
+ resetSnapshotCancel()
3684
+ markRestartStage('snapshotting')
3685
+ let cancelled = false
3686
+ let snapshot
3687
+ try {
3688
+ snapshot = await snapshotFn({
3689
+ config,
3690
+ stateDir,
3691
+ log,
3692
+ profile: resolveProfileDirDetailed(),
3693
+ onProgress: (progress) => {
3694
+ recordSnapshotProgress(progress)
3695
+ },
3696
+ shouldCancel: () => snapshotCancelled(),
3697
+ })
3698
+ } finally {
3699
+ // The flag belongs to ONE snapshot; a stale `true` would cancel the
3700
+ // next restart before it began.
3701
+ const wasCancelled = snapshotCancelled()
3702
+ resetSnapshotCancel()
3703
+ // Two ways a copy can be called off: the flag this process set (the
3704
+ // cancel route) or the snapshot reporting it itself. Both mean the same
3705
+ // thing, and both must stop the restart.
3706
+ cancelled = wasCancelled || (snapshot !== null && typeof snapshot === 'object' && snapshot.cancelled === true)
3707
+ }
3708
+ if (cancelled) {
3709
+ // The user called it off while the files were being copied: the
3710
+ // half-written snapshot is already gone (createBackup removes it), the
3711
+ // host does not restart, and the request is WITHDRAWN — a restart the
3712
+ // user cancelled must not be retried five seconds later.
3713
+ log.warn('the restart was cancelled while the snapshot was being written')
3714
+ clearRestartStage()
3715
+ releaseRequesters()
3716
+ cancelRestart(state, { now: Date.now() })
3717
+ persist()
3718
+ stopPolling()
3719
+ return
3720
+ }
3550
3721
  // The snapshot is the rollback point of BOTH branches.
3551
3722
  const backup = snapshot && snapshot.backup ? snapshot.backup : null
3552
3723
  const profileDir = snapshot && snapshot.profileDir ? snapshot.profileDir : null
@@ -3677,6 +3848,10 @@ const titleOfSummary = (item) => {
3677
3848
  // looked at "the spawn did not throw", so a command that does not
3678
3849
  // exist or that exits with an error was reported as a successful
3679
3850
  // restart.
3851
+ //
3852
+ // FROM HERE ON THE RESTART CANNOT BE CANCELLED, and the interface says
3853
+ // so: the host is on its way out.
3854
+ markRestartStage('restarting')
3680
3855
  const outcome = await runCommandFn({
3681
3856
  command,
3682
3857
  timeoutMs: config.restartCommandTimeoutMs,
@@ -3814,8 +3989,15 @@ const titleOfSummary = (item) => {
3814
3989
  watchScript,
3815
3990
  })
3816
3991
  log.warn('scheduled detached restart:', result)
3817
- if (!result || result.ok !== true) rollbackRestart((result && result.error) || 'detached restart refused')
3818
- else armGateWatchdog()
3992
+ if (!result || result.ok !== true) {
3993
+ markRestartStage('waiting', { blocked: 0 })
3994
+ rollbackRestart((result && result.error) || 'detached restart refused')
3995
+ } else {
3996
+ // The helper is scheduled: the host is going down, and from here on a
3997
+ // cancel would be a promise this plugin cannot keep.
3998
+ markRestartStage('restarting')
3999
+ armGateWatchdog()
4000
+ }
3819
4001
  }
3820
4002
  } catch (error) {
3821
4003
  rollbackRestart(String(error))
@@ -4059,6 +4241,162 @@ const titleOfSummary = (item) => {
4059
4241
  get bootId() {
4060
4242
  return bootId
4061
4243
  },
4244
+ /**
4245
+ * THE STATE DIRECTORY of this boot — the checkpoint, the watchdog markers
4246
+ * and the scheduling-item leftovers all live in it.
4247
+ *
4248
+ * It is exposed ON PURPOSE: the health route is a module-level function
4249
+ * and used to read the directory from a variable that only existed inside
4250
+ * this factory. The lookup threw `ReferenceError: stateDirOf is not
4251
+ * defined` on every request, an empty `catch {}` ate it, and a healthy
4252
+ * host left an armed watchdog behind (the 0.8.2 incident). Anything a
4253
+ * route needs must be reachable through `runtime`, never through a
4254
+ * closure it cannot see.
4255
+ */
4256
+ get stateDir() {
4257
+ return path.dirname(stateFile)
4258
+ },
4259
+ /**
4260
+ * The version of the plugin that is actually LOADED in this process.
4261
+ *
4262
+ * The health route reports it so a real-machine check can tell "the new
4263
+ * code is running" from "the old code is still loaded".
4264
+ */
4265
+ get pluginVersion() {
4266
+ return ownVersion()
4267
+ },
4268
+ /**
4269
+ * DISARM THE WATCHDOG: "this host is up, so nothing may roll it back".
4270
+ *
4271
+ * The ready marker is written on the FIRST tick of the first attempt (so a
4272
+ * caller inside an HTTP route has it on disk before it answers), the
4273
+ * pending marker and the scheduling item are cleaned with retries, and the
4274
+ * record is kept on `runtime` and written next to the markers.
4275
+ *
4276
+ * One run at a time: a second call while one is running returns the same
4277
+ * promise instead of starting a second `schtasks` dance.
4278
+ *
4279
+ * @param {string} [source] where the call came from ('boot', 'health-route', …)
4280
+ * @returns {Promise<object>} the record
4281
+ */
4282
+ disarmWatchdog(source = 'boot') {
4283
+ if (disarmInFlight !== null) {
4284
+ // One run at a time (two `schtasks /Delete` calls fight each other),
4285
+ // but a request that arrives DURING a run is remembered: a pending
4286
+ // marker written in the meantime must still be cleaned, not lost.
4287
+ disarmPendingAgain = true
4288
+ return disarmInFlight
4289
+ }
4290
+ const one = (where) => {
4291
+ const port = Number(runtime.webPort)
4292
+ return runDisarmWatchdog({
4293
+ stateDir: stateDirOf(),
4294
+ bootId,
4295
+ port: Number.isFinite(port) && port > 0 ? port : null,
4296
+ source: where,
4297
+ log,
4298
+ })
4299
+ .then((record) => {
4300
+ watchdogDisarm = record
4301
+ if (record.ok === true) {
4302
+ log.info('the watchdog is disarmed (' + where + ')', {
4303
+ attempts: record.attempts,
4304
+ tier: record.tier,
4305
+ })
4306
+ }
4307
+ return record
4308
+ })
4309
+ .catch((error) => {
4310
+ // The disarm must never reject: the health route and the boot path
4311
+ // both call it without a `catch`.
4312
+ watchdogDisarm = {
4313
+ at: new Date().toISOString(),
4314
+ source: String(where),
4315
+ bootId,
4316
+ attempts: 0,
4317
+ ok: false,
4318
+ ready: false,
4319
+ pending: null,
4320
+ tier: null,
4321
+ scheduleRemoved: null,
4322
+ scheduleError: null,
4323
+ errors: [{
4324
+ attempt: 0,
4325
+ step: 'disarm',
4326
+ name: error && error.name ? String(error.name) : 'Error',
4327
+ message: error && error.message ? String(error.message) : String(error),
4328
+ }],
4329
+ }
4330
+ log.warn('the watchdog could not be disarmed:', String(error))
4331
+ return watchdogDisarm
4332
+ })
4333
+ }
4334
+ const drive = async () => {
4335
+ let record = await one(source)
4336
+ while (disarmPendingAgain === true) {
4337
+ disarmPendingAgain = false
4338
+ record = await one(String(source) + '+again')
4339
+ }
4340
+ return record
4341
+ }
4342
+ const running = drive().then(
4343
+ (record) => {
4344
+ disarmInFlight = null
4345
+ disarmPendingAgain = false
4346
+ return record
4347
+ },
4348
+ (error) => {
4349
+ disarmInFlight = null
4350
+ disarmPendingAgain = false
4351
+ throw error
4352
+ },
4353
+ )
4354
+ disarmInFlight = running
4355
+ return running
4356
+ },
4357
+ /**
4358
+ * The record of the last disarm (null when this host never disarmed one).
4359
+ */
4360
+ get watchdogDisarm() {
4361
+ return watchdogDisarm
4362
+ },
4363
+ // THE STAGE OF A RESTART, as every surface reads it. `markRestartStage`
4364
+ // writes it and persists immediately (that is what makes the first stage
4365
+ // appear within the first second after the click), `recordSnapshotProgress`
4366
+ // carries the REAL bytes of the running copy, and `restartStageInfo` is the
4367
+ // single description (text included) that the state route, the health route
4368
+ // and the settings page share.
4369
+ markRestartStage,
4370
+ recordSnapshotProgress,
4371
+ clearRestartStage,
4372
+ restartStageInfo,
4373
+ requestSnapshotCancel,
4374
+ snapshotCancelled,
4375
+ resetSnapshotCancel,
4376
+ /**
4377
+ * The stage of a restart, cut down for the health route (no long texts).
4378
+ *
4379
+ * @returns {object} `{ phase, at, blocking, progress, slow, cancellable }`
4380
+ */
4381
+ restartStageBrief() {
4382
+ const info = restartStageInfo()
4383
+ return {
4384
+ phase: info.phase,
4385
+ at: info.at,
4386
+ elapsedMs: info.elapsedMs,
4387
+ // How many tasks still hold the gate: the centred overlay prints the
4388
+ // number, so it travels as a number and not only inside `text`.
4389
+ blocking: info.blocking,
4390
+ // The size of the pre-restart snapshot, on every stage (bytes).
4391
+ snapshotBytes: info.snapshotBytes,
4392
+ progress: info.progress === null
4393
+ ? null
4394
+ : { files: info.progress.files, bytes: info.progress.bytes, total: info.progress.total, percent: info.progress.percent, ageMs: info.progress.ageMs },
4395
+ slow: info.slow,
4396
+ cancellable: info.cancellable,
4397
+ cancelReason: info.cancelReason,
4398
+ }
4399
+ },
4062
4400
  /**
4063
4401
  * Where the effective `backupIncludePlugins` value came from:
4064
4402
  * `config` (the profile row), `fallback-file` (the degraded path) or
@@ -5149,6 +5487,10 @@ function registerTools(ctx, runtime, config) {
5149
5487
  if (typeof args.defer_seconds === 'number' && Number.isFinite(args.defer_seconds) && args.defer_seconds > 0) {
5150
5488
  state.restart.requestedAt = now + Math.min(args.defer_seconds, 3600) * 1000
5151
5489
  }
5490
+ // THE FIRST STAGE, RIGHT NOW — not when the backup starts. The interface
5491
+ // polls this out of the state route, so the click produces a visible
5492
+ // answer in well under a second.
5493
+ runtime.markRestartStage('waiting', { blocked: countBlockerTasks(runtime.gate().blockedBy) })
5152
5494
  runtime.persist()
5153
5495
  runtime.startPolling()
5154
5496
  log.info('restart requested', { generation: state.generation, sessionId, resume, parkGoals })
@@ -6077,6 +6419,9 @@ function buildParkRoute(ctx, runtime, config) {
6077
6419
  resume: true,
6078
6420
  now: Date.now(),
6079
6421
  })
6422
+ // The first stage, in the same breath as the request: the answer to
6423
+ // this POST is already "waiting for the sessions", never silence.
6424
+ runtime.markRestartStage('waiting', { blocked: blockingSessionIds(runtime).length })
6080
6425
  runtime.persist()
6081
6426
  runtime.startPolling()
6082
6427
  }
@@ -6172,6 +6517,28 @@ function buildCancelRoute(ctx, runtime, config) {
6172
6517
  })
6173
6518
  return
6174
6519
  }
6520
+ // THE STAGE DECIDES WHETHER A CANCEL IS HONEST.
6521
+ //
6522
+ // While the snapshot is being written the copy is stopped between two
6523
+ // files and its half-written directory is removed — the host never
6524
+ // restarts. Once the host is on its way out (`restarting`) a cancel
6525
+ // would be a promise this plugin cannot keep, so it is REFUSED with the
6526
+ // reason, and the button is greyed out on the same answer.
6527
+ const stage = runtime.restartStageInfo()
6528
+ if (stage.cancellable !== true) {
6529
+ const reason = cancelRefusalReason(stage.phase) || 'the host is already restarting'
6530
+ runtime.log.warn(`the cancel route refused the request of session ${sessionId}: ${reason}`)
6531
+ answer(409, {
6532
+ ok: false,
6533
+ cancelled: false,
6534
+ reason: 'not-cancellable',
6535
+ stage: stage.phase,
6536
+ error: reason,
6537
+ remainingRequesters: 0,
6538
+ sessionId,
6539
+ })
6540
+ return
6541
+ }
6175
6542
  const outcome = cancelOwnRestart(runtime.state, { sessionId, now: Date.now() })
6176
6543
  if (!outcome.removed) {
6177
6544
  // Nothing was changed: the caller is not a requester (or the
@@ -6194,7 +6561,14 @@ function buildCancelRoute(ctx, runtime, config) {
6194
6561
  }
6195
6562
  // Release ONLY this session's continuation request.
6196
6563
  runtime.releaseRequesters([sessionId])
6197
- if (outcome.cancelled) runtime.stopPolling()
6564
+ if (outcome.cancelled) {
6565
+ // The whole restart is off. If the snapshot is being written right
6566
+ // now, the copy stops at the next file and removes its half-written
6567
+ // directory (the host never restarts); otherwise the stage just goes.
6568
+ if (stage.phase === 'snapshotting') runtime.requestSnapshotCancel()
6569
+ runtime.clearRestartStage()
6570
+ runtime.stopPolling()
6571
+ }
6198
6572
  runtime.persist()
6199
6573
  runtime.log.info(
6200
6574
  outcome.cancelled
@@ -7377,6 +7751,39 @@ function buildSleepRoute(ctx, runtime, config, sleep) {
7377
7751
  }
7378
7752
 
7379
7753
  /** Expose a tiny read-only health route for humans and verification scripts. */
7754
+ /**
7755
+ * The disarm record, cut down to what a reader outside the process needs.
7756
+ *
7757
+ * The health route answers with it, so a real-machine check can tell "the
7758
+ * watchdog was stood down" from "it is still armed" without reading a log.
7759
+ *
7760
+ * @param {object|null} record
7761
+ * @returns {object|null}
7762
+ */
7763
+ function summarizeDisarm(record) {
7764
+ if (!record || typeof record !== 'object') return null
7765
+ const errors = Array.isArray(record.errors) ? record.errors : []
7766
+ return {
7767
+ ok: record.ok === true,
7768
+ at: typeof record.at === 'string' ? record.at : null,
7769
+ source: typeof record.source === 'string' ? record.source : null,
7770
+ attempts: Number.isFinite(Number(record.attempts)) ? Number(record.attempts) : 0,
7771
+ ready: record.ready === true,
7772
+ pending: record.pending === true,
7773
+ tier: typeof record.tier === 'string' ? record.tier : null,
7774
+ scheduleRemoved: record.scheduleRemoved === true ? true : (record.scheduleRemoved === false ? false : null),
7775
+ // Only the first failure: the route answer must stay small, and one named
7776
+ // error is what makes the failure diagnosable.
7777
+ error: errors.length > 0
7778
+ ? {
7779
+ step: typeof errors[0].step === 'string' ? errors[0].step : null,
7780
+ name: typeof errors[0].name === 'string' ? errors[0].name : null,
7781
+ message: typeof errors[0].message === 'string' ? errors[0].message : null,
7782
+ }
7783
+ : null,
7784
+ }
7785
+ }
7786
+
7380
7787
  function registerHealthRoute(ctx, runtime, config, sleep) {
7381
7788
  const routePath = typeof config.healthPath === 'string' && config.healthPath.startsWith('/')
7382
7789
  ? config.healthPath
@@ -7471,16 +7878,25 @@ function registerHealthRoute(ctx, runtime, config, sleep) {
7471
7878
  }
7472
7879
  }
7473
7880
  const verdict = runtime.gate()
7881
+ // The restart stage (waiting / snapshotting / restarting / back), the
7882
+ // real snapshot progress and the "is a cancel still honest" answer —
7883
+ // computed ONCE per answer so every field of the payload agrees.
7884
+ const stage = runtime.restartStageInfo()
7474
7885
  // Best effort: label the blocking sessions with their titles when the
7475
7886
  // session controller knows them. The title is optional, so an entry
7476
7887
  // without one is sent exactly as the gate produced it. The goal
7477
7888
  // reason (`goal-active` / `goal-exhausted`) is attached as well, so
7478
7889
  // the client can tell a goal-driven blocker from a busy session.
7479
- const blockedBy = goalAwareBlockingDetails(runtime, verdict.blockedBy).map((entry) => {
7480
- if (!entry || typeof entry !== 'object') return entry
7481
- const title = runtime.sessionTitle(entry.sessionId)
7482
- return title ? { ...entry, title } : entry
7483
- })
7890
+ // A blocker that is not an object (an empty string still reported by
7891
+ // an older route, a null) is not a blocker: it has no session and no
7892
+ // name, and rendering one produced the bare ",," the user saw. It is
7893
+ // dropped HERE, so the interface never receives one.
7894
+ const blockedBy = goalAwareBlockingDetails(runtime, verdict.blockedBy)
7895
+ .filter((entry) => entry && typeof entry === 'object')
7896
+ .map((entry) => {
7897
+ const title = runtime.sessionTitle(entry.sessionId)
7898
+ return title ? { ...entry, title } : entry
7899
+ })
7484
7900
  stateBody = JSON.stringify({
7485
7901
  ok: true,
7486
7902
  pendingRestart,
@@ -7488,6 +7904,12 @@ function registerHealthRoute(ctx, runtime, config, sleep) {
7488
7904
  generation: live.generation,
7489
7905
  waiters,
7490
7906
  blockedBy,
7907
+ // THE ONE NUMBER both client surfaces read: the capsule and the
7908
+ // panel it opens. Counting the blockers in the browser is what made
7909
+ // "1 个会话在忙" sit next to "还在等 0 个任务" — the two surfaces
7910
+ // counted different filtered copies of the same list (user report,
7911
+ // 2026-10-29). A blocker without a readable name is still a task.
7912
+ busyCount: countBlockerTasks(verdict.blockedBy),
7491
7913
  allowRestart: verdict.allow === true,
7492
7914
  // WHY the gate says what it says. Without this, "allowRestart is
7493
7915
  // false and blockedBy is empty" looks like a contradiction: the
@@ -7506,6 +7928,14 @@ function registerHealthRoute(ctx, runtime, config, sleep) {
7506
7928
  // long the restart has been waiting, why, and who holds it — the
7507
7929
  // request itself is never dropped.
7508
7930
  longWait: runtime.longWaitInfo(),
7931
+ // THE STAGE OF THE RESTART, for the pill and the settings page:
7932
+ // which of the four stages is on screen, the real copy progress of
7933
+ // the snapshot, whether it is going slower than expected, and
7934
+ // whether a cancel would still be honest (`cancellable`).
7935
+ restartPhase: stage,
7936
+ // The copy progress on its own as well, so a client that only wants
7937
+ // the numbers does not have to walk the stage object.
7938
+ progress: stage.progress,
7509
7939
  updatedAt: live.updatedAt,
7510
7940
  // The park route, so the client never has to guess the prefix, and
7511
7941
  // the goals this plugin already disarmed (they are re-armed after
@@ -7630,27 +8060,64 @@ function registerHealthRoute(ctx, runtime, config, sleep) {
7630
8060
  // THE READY MARKER: this host answers its own health route, so it is
7631
8061
  // up. The watchdog (which waits for the relaunched host) sees the
7632
8062
  // marker and stands down — no rollback of a host that is fine.
8063
+ //
8064
+ // THE STATE DIRECTORY COMES FROM `runtime`, NEVER FROM A CLOSURE:
8065
+ // 0.8.2 read it through a variable that only existed inside the runtime
8066
+ // factory, so every request threw `ReferenceError: stateDirOf is not
8067
+ // defined`, the `catch {}` swallowed it, and a healthy host left the
8068
+ // pending marker AND the scheduled task behind (the watchdog would have
8069
+ // rolled a healthy profile back). The failure is recorded now, and the
8070
+ // marker is written first because the marker alone is what stops a
8071
+ // rollback.
7633
8072
  try {
7634
- const marker = readReady(stateDirOf())
7635
- if (!marker || marker.bootId !== runtime.bootId) {
7636
- writeReady(stateDirOf(), { bootId: runtime.bootId, port: runtime.port ?? null })
7637
- // The host is up: the watchdog has nothing to do. Its scheduling
7638
- // item and its pending marker go NOW — nothing may be left behind.
7639
- if (readPending(stateDirOf()) !== null) {
7640
- clearPending(stateDirOf())
7641
- try {
7642
- removeScheduledTask({ taskName: 'dsh-autorestart-watchdog' })
7643
- } catch {
7644
- // The watchdog removes it itself when it runs.
8073
+ const stateDir = typeof runtime.stateDir === 'string' ? runtime.stateDir : ''
8074
+ if (stateDir !== '') {
8075
+ const marked = markReady({
8076
+ stateDir,
8077
+ bootId: runtime.bootId,
8078
+ port: runtime.webPort ?? null,
8079
+ })
8080
+ if (marked.ok !== true) {
8081
+ runtime.watchdogReadyError = {
8082
+ at: Date.now(),
8083
+ message: marked.detail,
8084
+ name: marked.error && marked.error.name ? String(marked.error.name) : null,
7645
8085
  }
7646
8086
  }
8087
+ // The marker alone stands the watchdog down; the pending marker and
8088
+ // the scheduling item are cleaned with retries in the background, so
8089
+ // `schtasks` never delays this answer.
8090
+ runtime.disarmWatchdog('health-route')
8091
+ }
8092
+ } catch (error) {
8093
+ // NEVER silent: a swallowed error here leaves an armed watchdog behind.
8094
+ // It is recorded on `runtime` (this scope has no logger) so the settings
8095
+ // page and the diagnostics can show it instead of pretending all is well.
8096
+ runtime.watchdogReadyError = {
8097
+ at: Date.now(),
8098
+ name: error && error.name ? String(error.name) : 'Error',
8099
+ message: String(error && error.message ? error.message : error),
8100
+ }
8101
+ // One retry with the bare minimum: the marker alone is what the
8102
+ // watchdog actually waits for.
8103
+ try {
8104
+ markReady({
8105
+ stateDir: typeof runtime.stateDir === 'string' ? runtime.stateDir : '',
8106
+ bootId: runtime.bootId,
8107
+ port: runtime.webPort ?? null,
8108
+ })
8109
+ runtime.watchdogReadyError.retry = 'ok'
8110
+ } catch (again) {
8111
+ runtime.watchdogReadyError.retry = String(again && again.message ? again.message : again)
7647
8112
  }
7648
- } catch {
7649
- // The marker is a convenience: the watchdog also asks the health route.
7650
8113
  }
7651
8114
  const body = JSON.stringify({
7652
8115
  ok: true,
7653
8116
  plugin: name,
8117
+ // The version of the code that is LOADED (not the fallback constant):
8118
+ // a real-machine check can tell "the new build is running" from "the
8119
+ // old one is still in memory".
8120
+ version: runtime.pluginVersion ?? PLUGIN_VERSION_FALLBACK,
7654
8121
  bootId: runtime.bootId,
7655
8122
  pid: process.pid,
7656
8123
  phase: state.phase,
@@ -7662,6 +8129,15 @@ function registerHealthRoute(ctx, runtime, config, sleep) {
7662
8129
  manualRestartBlocks: runtime.manualRestartBlocks ?? 0,
7663
8130
  indexTaps: runtime.indexTaps ?? 0,
7664
8131
  configSchema: Config !== undefined,
8132
+ // DID THE DISARM WORK? A failed disarm means an armed watchdog is still
8133
+ // out there; it must be visible from outside the process, not only in
8134
+ // a log line nobody reads.
8135
+ watchdog: summarizeDisarm(runtime.watchdogDisarm),
8136
+ // THE RESTART STAGE, read from the health route as well: a script (or a
8137
+ // user watching the console) can tell "waiting" from "backing up 120 MB
8138
+ // of 156 MB" without opening the interface.
8139
+ restartPhase: runtime.restartStageBrief(),
8140
+ progress: runtime.restartStageBrief().progress,
7665
8141
  schemastery: Config === undefined
7666
8142
  ? (schemastery === undefined ? 'unresolved' : 'construction-failed')
7667
8143
  : (schemasterySource === 'bare-specifier' ? 'bare' : 'asar-fallback'),
@@ -7938,6 +8414,12 @@ export function apply(ctx, rawConfig, options = {}) {
7938
8414
  // Record this boot in the checkpoint even when there is nothing to recover.
7939
8415
  persistSafely(runtime)
7940
8416
 
8417
+ // THE LAST STAGE OF THE RESTART: this host IS the new one, so the interface
8418
+ // says "back — carrying on". Written here (not after the recovery) so a boot
8419
+ // that finds nothing to resume still shows the end of the story instead of a
8420
+ // spinner left over from the old process.
8421
+ if (runtime.isNewBoot) runtime.markRestartStage('back')
8422
+
7941
8423
  trackAgents(ctx, runtime, config)
7942
8424
 
7943
8425
  // The boot recovery pass is the plugin's primary boot duty, so it is scheduled
@@ -7955,8 +8437,7 @@ export function apply(ctx, rawConfig, options = {}) {
7955
8437
  })
7956
8438
  sleep.restore()
7957
8439
  }
7958
- if (runtime.isNewBoot && config.resumeOnBoot) {
7959
- runtime.recoverTimer = ctx.timeout(() => {
8440
+ if (runtime.isNewBoot && config.resumeOnBoot) { runtime.recoverTimer = ctx.timeout(() => {
7960
8441
  recoverAfterRestart(ctx, runtime, config)
7961
8442
  .then(() => {
7962
8443
  runtime.recovery = { ran: true, ok: true, detail: 'the boot recovery finished' }
@@ -7995,6 +8476,19 @@ export function apply(ctx, rawConfig, options = {}) {
7995
8476
  registerSystemPrompt(ctx, runtime)
7996
8477
  registerHealthRoute(ctx, runtime, config, sleep)
7997
8478
 
8479
+ // DISARM THE WATCHDOG AT BOOT — WITHOUT WAITING FOR ANYBODY TO ASK.
8480
+ //
8481
+ // This is the fix of the 0.8.2 incident: the ready marker and the cleanup of
8482
+ // the pending marker / scheduled task hung on the health route being hit.
8483
+ // The plugin is fully loaded at this point (every route is registered, the
8484
+ // boot recovery is scheduled), so this instance is "up" in the only sense
8485
+ // the watchdog cares about: it must not be rolled back.
8486
+ //
8487
+ // The call never rejects (see runtime.disarmWatchdog) and never blocks the
8488
+ // load: the marker is written synchronously on its first tick, the
8489
+ // `schtasks` cleanup runs with retries behind it.
8490
+ runtime.disarmWatchdog('boot')
8491
+
7998
8492
  ctx.effect(() => () => {
7999
8493
  runtime.markDisposed()
8000
8494
  runtime.stopPolling()