dsh-autorestart 0.6.0 → 0.6.2

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
@@ -75,6 +75,13 @@ import {
75
75
  runCommandNow,
76
76
  scheduleDetachedRestart,
77
77
  } from './lib/restart.js'
78
+ import { compareSemver } from './lib/semver.js'
79
+ import { isWorkSessionEvent } from './lib/activity.js'
80
+ import {
81
+ createQueuedTracker,
82
+ DEFAULT_QUEUED_GRACE_MS,
83
+ normalizeQueuedGraceMs,
84
+ } from './lib/queued.js'
78
85
  import {
79
86
  createSleepController,
80
87
  COUNTDOWN_CHOICES,
@@ -86,34 +93,52 @@ import {
86
93
  } from './lib/sleep.js'
87
94
 
88
95
  /**
89
- * The grace window: how long the host waits after the LAST conversation
90
- * finished before it is really restarted or switched off.
96
+ * The RESTART reserve: how long the host waits after the LAST conversation
97
+ * finished before it is really restarted.
91
98
  *
92
99
  * This plugin can only see the DSH agents. Other plugins (a memory plugin
93
100
  * that writes its notes a few seconds after the conversation ended, a summary,
94
101
  * an MCP callback) queue their work with a timer of their own — work that is
95
- * invisible here. A short, configurable grace window is left for it.
102
+ * invisible here. A short, configurable reserve window is left for it.
103
+ *
104
+ * The reserve belongs to the RESTART only: a restart is a daily operation, so
105
+ * it should happen as soon as the conversations are done. Default: none.
106
+ *
107
+ * The graceful shutdown has NO reserve of its own: it has a VISIBLE countdown
108
+ * (`sleepCountdownSeconds`), and that countdown IS the wait before the
109
+ * machine goes off. A second, invisible window next to it would only say the
110
+ * same thing twice — so there is none.
96
111
  *
97
112
  * The values are the ones the settings page offers (in milliseconds).
98
113
  */
99
- const GRACE_CHOICES_MS = Object.freeze([0, 3000, 5000, 10000, 20000, 30000])
114
+ const GRACE_CHOICES_MS = Object.freeze([0, 5000, 10000, 20000, 30000, 60000])
115
+
116
+ /** The RESTART reserve that is used when nothing was configured. */
117
+ const DEFAULT_RESTART_GRACE_MS = 0
100
118
 
101
- /** The grace window that is used when nothing was configured. */
102
- const DEFAULT_GRACE_MS = 10000
119
+ /**
120
+ * How often the "the restart is waiting for a long time" line may be logged.
121
+ *
122
+ * The restart gate polls every few seconds, so an unthrottled line would
123
+ * flood the log with the same sentence. Once a minute is enough to say that
124
+ * the wait is still going on.
125
+ */
126
+ const LONG_WAIT_LOG_INTERVAL_MS = 60000
103
127
 
104
128
  /**
105
- * Snap a grace value onto the nearest offered choice.
129
+ * Snap a reserve value onto the nearest offered choice.
106
130
  *
107
131
  * A profile that carries a value that is not offered (a hand-edited row, an
108
132
  * older build) must not break the boot: the value is snapped onto the
109
133
  * nearest choice, exactly like the countdown length is.
110
134
  *
111
135
  * @param {unknown} value
136
+ * @param {number} [fallback] the value used when the input is not a number
112
137
  * @returns {number} one of {@link GRACE_CHOICES_MS}
113
138
  */
114
- function snapGraceChoice(value) {
139
+ function snapGraceChoice(value, fallback = DEFAULT_RESTART_GRACE_MS) {
115
140
  const raw = Number(value)
116
- if (!Number.isFinite(raw) || raw < 0) return DEFAULT_GRACE_MS
141
+ if (!Number.isFinite(raw) || raw < 0) return fallback
117
142
  let best = GRACE_CHOICES_MS[0]
118
143
  let bestDistance = Math.abs(raw - best)
119
144
  for (const choice of GRACE_CHOICES_MS) {
@@ -133,17 +158,36 @@ function snapGraceChoice(value) {
133
158
  * `pluginVersion()`), but a bundled installation may not ship that file — so
134
159
  * the constant below is the last resort. A test keeps the two in sync.
135
160
  */
136
- const PLUGIN_VERSION_FALLBACK = '0.6.0'
161
+ const PLUGIN_VERSION_FALLBACK = '0.6.2'
162
+
163
+ /**
164
+ * The version of the graceful-shutdown FEATURE, not the version of the package.
165
+ *
166
+ * The one-time announcement is bound to this constant. It used to follow the
167
+ * version in package.json, and that made every release resurrect the card the
168
+ * user had already answered (0.5.0 -> 0.6.1 brought "收工关机" back). A release
169
+ * is not news about the feature, so the two are decoupled.
170
+ *
171
+ * WHEN TO RAISE IT: only when the feature itself changed so much that the
172
+ * user has to hear about it again (the shutdown became a completely different
173
+ * thing, the button moved to another place, ...). Any new value works: the
174
+ * recorded version no longer matches, so the announcement is shown once more.
175
+ *
176
+ * WHEN NOT TO RAISE IT: a release, a bug fix, a small addition, a new option
177
+ * that the documentation explains. None of those deserve a card that
178
+ * interrupts the user a second time.
179
+ */
180
+ const SLEEP_FEATURE_VERSION = '0.5.0'
137
181
 
138
182
  /** The cached version of this plugin (read once per process). */
139
183
  let pluginVersionCache = null
140
184
 
141
185
  /**
142
- * The version of this plugin.
186
+ * The version of this plugin (the PACKAGE version).
143
187
  *
144
- * It is the version of the package itself: the feature announcements are
145
- * bound to it, so a new version of the plugin can announce a new feature (and
146
- * the announcement of an old version never comes back).
188
+ * It says which release is installed - not what the user has been told about.
189
+ * The feature announcements are bound to `SLEEP_FEATURE_VERSION`, so a release
190
+ * never brings an answered announcement back.
147
191
  *
148
192
  * @returns {string}
149
193
  */
@@ -169,19 +213,51 @@ function pluginVersion() {
169
213
  *
170
214
  * The install prompt only ever reaches a fresh installation, so an
171
215
  * installation that already existed would never learn about a new feature.
172
- * Every entry of this list is therefore announced once per version: the
173
- * announcement is shown until the user presses "知道了", and the Host records
174
- * the version it has shown, so it never comes back.
216
+ * Every entry of this list is therefore announced once per FEATURE version:
217
+ * the announcement is shown until the user presses "知道了", and the Host
218
+ * records the feature version it has shown, so it never comes back.
219
+ *
220
+ * The version of an entry is the version of the FEATURE, never the version of
221
+ * the package - see `SLEEP_FEATURE_VERSION`.
175
222
  *
176
223
  * @returns {{ feature: string, version: string }[]}
177
224
  */
178
225
  function featureAnnouncements() {
179
226
  return [
180
227
  // The graceful shutdown ("收工关机") and its moon button.
181
- { feature: 'sleep', version: pluginVersion() },
228
+ { feature: 'sleep', version: SLEEP_FEATURE_VERSION },
182
229
  ]
183
230
  }
184
231
 
232
+ /**
233
+ * Whether the user has already seen the announcement of one feature.
234
+ *
235
+ * The record is the version that was announced when the user answered. It
236
+ * counts as seen as soon as it is NOT OLDER than the current feature version
237
+ * - not only when it is exactly that version:
238
+ *
239
+ * - the announcement used to carry the version of the PACKAGE, so a user
240
+ * who answered it on the release 0.6.1 recorded "0.6.1" while the feature
241
+ * itself stands at "0.5.0". With an equality check that record would
242
+ * look stale, and the very same card would be shown to a user who already
243
+ * dismissed it;
244
+ * - a record that is not a version at all counts as seen as well: a broken
245
+ * record must not make the card come back for ever.
246
+ *
247
+ * A missing record counts as NOT seen: a missing record must never swallow an
248
+ * announcement.
249
+ *
250
+ * @param {unknown} recorded the version in the state file
251
+ * @param {string} current the current feature version
252
+ * @returns {boolean} true when the user has seen it
253
+ */
254
+ function isFeatureSeen(recorded, current) {
255
+ if (typeof recorded !== 'string' || recorded.trim() === '') return false
256
+ const order = compareSemver(recorded, current)
257
+ if (order === null) return true
258
+ return order >= 0
259
+ }
260
+
185
261
  /**
186
262
  * `@deepseek-ai/schemastery` resolves inside modules that live in the dsh installation,
187
263
  * but NOT from this bundle: a profile-installed bundle is a symlink into the user's
@@ -328,8 +404,19 @@ function buildConfigSchema() {
328
404
  // Other plugins queue work with timers of their own (a memory plugin
329
405
  // writing its notes, a summary, an MCP callback) and that work is
330
406
  // invisible here — so a short, configurable grace window is left for it.
331
- restartGraceMs: schemastery.number().min(0).default(DEFAULT_GRACE_MS)
332
- .description('Milliseconds to wait after the last conversation finished before the host is really restarted or switched off: the grace window for work other plugins queue with a timer (0 / 3 / 5 / 10 / 20 / 30 seconds, default 10).'),
407
+ // The RESTART reserve: the extra wait after the last conversation
408
+ // finished, before the host is really restarted. A restart is a daily
409
+ // operation, so the default is none (0 / 5 / 10 / 20 / 30 / 60 seconds).
410
+ restartGraceMs: schemastery.number().min(0).default(DEFAULT_RESTART_GRACE_MS)
411
+ .description('Milliseconds to wait after the last conversation finished before the host is really RESTARTED: the reserve for work other plugins queue with a timer (0 / 5 / 10 / 20 / 30 / 60 seconds, default 0 = no extra wait).'),
412
+ // The graceful shutdown has no reserve of its own: the visible countdown
413
+ // (sleepCountdownSeconds) IS the wait before the machine goes off.
414
+ // The upper bound of a QUEUED inbox message: a message that nobody
415
+ // claims within this window is stuck, and it stops blocking the restart.
416
+ // Without it a single unclaimed message kept the restart pending for
417
+ // ever (the "the restart never happens" defect).
418
+ queuedGraceMs: schemastery.number().min(0).default(DEFAULT_QUEUED_GRACE_MS)
419
+ .description('Milliseconds an unclaimed (queued) inbox message may block the restart: a message that is still unclaimed after this window is treated as stuck and no longer blocks the restart (0 = no upper bound, the old behaviour; default 60000).'),
333
420
  autoReloadPage: schemastery.boolean().default(true)
334
421
  .description('Reload the web page automatically when the host boot token changes.'),
335
422
  blockManualRestart: schemastery.boolean().default(true)
@@ -396,7 +483,11 @@ const DEFAULTS = {
396
483
  restartCommand: '',
397
484
  relaunchDelaySeconds: 2,
398
485
  settleMs: 2000,
399
- restartGraceMs: DEFAULT_GRACE_MS,
486
+ restartGraceMs: DEFAULT_RESTART_GRACE_MS,
487
+ // The upper bound of a queued (unclaimed) inbox message: after this time it
488
+ // is treated as stuck and stops blocking the restart. 0 switches the
489
+ // protection off (the old behaviour: a queued message blocks for ever).
490
+ queuedGraceMs: DEFAULT_QUEUED_GRACE_MS,
400
491
  autoReloadPage: true,
401
492
  blockManualRestart: true,
402
493
  healthPath: '/__dsh_autorestart/health',
@@ -443,9 +534,12 @@ function normalizeConfig(raw) {
443
534
  // profile that still carries an old value (15, 45, …) is snapped onto the
444
535
  // nearest one instead of breaking, and 0 means "no countdown at all".
445
536
  config.sleepCountdownSeconds = snapCountdownChoice(config.sleepCountdownSeconds)
446
- // The grace window is one of the offered choices as well: a hand-edited
537
+ // The restart reserve is one of the offered choices as well: a hand-edited
447
538
  // profile value is snapped onto the nearest one instead of breaking.
448
- config.restartGraceMs = snapGraceChoice(config.restartGraceMs)
539
+ config.restartGraceMs = snapGraceChoice(config.restartGraceMs, DEFAULT_RESTART_GRACE_MS)
540
+ // The upper bound of a queued message is a plain number: a wrong or negative
541
+ // value falls back to the default, and 0 switches the protection off.
542
+ config.queuedGraceMs = normalizeQueuedGraceMs(config.queuedGraceMs)
449
543
  if (typeof config.sleepCommand !== 'string') config.sleepCommand = ''
450
544
  if (typeof config.showSleepButton !== 'boolean') config.showSleepButton = true
451
545
  return config
@@ -1181,7 +1275,43 @@ export function createRuntime(ctx, config, options = {}) {
1181
1275
  /** When the last conversation activity was observed; the settle window starts here. */
1182
1276
  let lastActivityAt = Date.now()
1183
1277
 
1184
- /** Record conversation activity (any agent turn, session event or queued message). */
1278
+ /**
1279
+ * When a busy conversation was seen for the last time.
1280
+ *
1281
+ * The quiet window measures the time since the last REAL work: a running
1282
+ * turn or a queued message. The host polls the agent list every few
1283
+ * seconds, so this marker follows the work exactly — and it is NOT reset by
1284
+ * the routine session events the interface produces while it synchronises
1285
+ * (those used to reset the window every 2-3 seconds, so a window longer
1286
+ * than that interval could never fill and the restart never fired).
1287
+ */
1288
+ let lastBusyAt = 0
1289
+
1290
+ /**
1291
+ * When the "the restart is waiting for a long time" line was logged last.
1292
+ *
1293
+ * The gate polls every few seconds, so the line is rate limited: at most one
1294
+ * per minute for the SAME restart request. A new request (`requestedAt`
1295
+ * changed) logs right away.
1296
+ */
1297
+ let longWaitLog = { at: 0, requestedAt: 0 }
1298
+
1299
+ /**
1300
+ * When the diagnostic line of the restart gate was logged last.
1301
+ *
1302
+ * The gate is evaluated every few seconds, so the line is rate limited to
1303
+ * once a minute: it says what the gate decided and why, which is the first
1304
+ * thing to look at when "the restart never happens" is reported.
1305
+ */
1306
+ let lastDecisionLogAt = 0
1307
+
1308
+ /**
1309
+ * Record REAL conversation activity (an agent turn that started or ended, a
1310
+ * message that was queued, a session event that says work happened).
1311
+ *
1312
+ * The routine session events of the interface do NOT go through here: they
1313
+ * would keep the quiet window from ever filling.
1314
+ */
1185
1315
  const noteActivity = () => {
1186
1316
  lastActivityAt = Date.now()
1187
1317
  }
@@ -1201,6 +1331,53 @@ export function createRuntime(ctx, config, options = {}) {
1201
1331
  return count
1202
1332
  }
1203
1333
 
1334
+ /**
1335
+ * A short human-readable duration for the log ("45s", "2m 5s", "1h 3m").
1336
+ *
1337
+ * @param {number} ms
1338
+ * @returns {string}
1339
+ */
1340
+ const describeWait = (ms) => {
1341
+ const total = Math.max(0, Math.round(Number(ms) || 0))
1342
+ const seconds = Math.floor(total / 1000)
1343
+ if (seconds < 60) return `${seconds}s`
1344
+ const minutes = Math.floor(seconds / 60)
1345
+ if (minutes < 60) {
1346
+ const rest = seconds % 60
1347
+ return rest === 0 ? `${minutes}m` : `${minutes}m ${rest}s`
1348
+ }
1349
+ const hours = Math.floor(minutes / 60)
1350
+ const restMinutes = minutes % 60
1351
+ return restMinutes === 0 ? `${hours}h` : `${hours}h ${restMinutes}m`
1352
+ }
1353
+
1354
+ /**
1355
+ * The readable names of the sessions that hold the restart.
1356
+ *
1357
+ * A session without a title falls back to its id: the log has to say WHO
1358
+ * holds the restart, and an empty name would say nothing.
1359
+ *
1360
+ * @param {any[]} blockedBy
1361
+ * @returns {string}
1362
+ */
1363
+ const describeBlockers = (blockedBy) => {
1364
+ const list = Array.isArray(blockedBy) ? blockedBy : []
1365
+ const names = []
1366
+ for (const entry of list) {
1367
+ if (!entry || typeof entry !== 'object') continue
1368
+ const name = typeof entry.name === 'string' && entry.name.trim() !== ''
1369
+ ? entry.name.trim()
1370
+ : (typeof entry.label === 'string' && entry.label.trim() !== ''
1371
+ ? entry.label.trim()
1372
+ : String(entry.sessionId || ''))
1373
+ if (name === '') continue
1374
+ names.push(name)
1375
+ }
1376
+ if (names.length === 0) return 'another conversation'
1377
+ if (names.length === 1) return names[0]
1378
+ return names.join(', ')
1379
+ }
1380
+
1204
1381
  /**
1205
1382
  * Whether this agent is a subagent (a delegated child), and which session
1206
1383
  * created it.
@@ -1277,10 +1454,28 @@ export function createRuntime(ctx, config, options = {}) {
1277
1454
  }
1278
1455
  }
1279
1456
 
1457
+ /**
1458
+ * The tracker of the QUEUED messages.
1459
+ *
1460
+ * It remembers for how long a session has had an unclaimed inbox message, so
1461
+ * a message that nobody claims stops blocking the restart instead of
1462
+ * blocking it for ever. A session that is RUNNING is never judged here: a
1463
+ * live turn always blocks, under every grace setting.
1464
+ */
1465
+ const queuedTracker = createQueuedTracker()
1466
+
1467
+ /** The sessions the tracker saw in the last round (for the cleanup). */
1468
+ const queuedSessions = new Set()
1469
+
1280
1470
  /**
1281
1471
  * Every conversation that is still working, subagents and teammates included.
1282
1472
  * 'running' is a live turn; 'queued' is inbox work that has not started yet.
1283
1473
  *
1474
+ * A queued message only blocks the restart until the grace window
1475
+ * (`queuedGraceMs`) is over: after that it is stuck — nobody will ever claim
1476
+ * it — so it stops blocking the restart and is named in the log. A running
1477
+ * conversation ALWAYS blocks.
1478
+ *
1284
1479
  * Every entry carries the kind of the blocker and a readable name, so the
1285
1480
  * interface can tell a session from a subagent and never has to show a raw
1286
1481
  * session id.
@@ -1293,15 +1488,52 @@ export function createRuntime(ctx, config, options = {}) {
1293
1488
  } catch {
1294
1489
  agents = []
1295
1490
  }
1491
+ const seen = new Set()
1296
1492
  for (const agent of agents) {
1297
1493
  try {
1298
1494
  if (!agent || !agent.id) continue
1299
1495
  const sessionId = String(agent.id)
1496
+ seen.add(sessionId)
1300
1497
  const described = describeBlocker(sessionId, agent)
1301
- if (agent.status === 'running') out.push({ sessionId, reason: 'running', ...described })
1302
- else if (pendingInboxCount(agent) > 0) out.push({ sessionId, reason: 'queued', ...described })
1498
+ if (agent.status === 'running') {
1499
+ // A live turn ALWAYS blocks the restart — under every grace
1500
+ // setting. The queued timer of that session is dropped: the work is
1501
+ // being done, so it is not a stuck queue.
1502
+ queuedTracker.forget(sessionId)
1503
+ out.push({ sessionId, reason: 'running', ...described })
1504
+ } else if (pendingInboxCount(agent) > 0) {
1505
+ const observed = queuedTracker.observe(sessionId, true, config.queuedGraceMs)
1506
+ if (observed.becameStale) {
1507
+ // The message has been waiting longer than the grace window and
1508
+ // nobody claimed it: it is stuck. The session is named here, so
1509
+ // the user can see WHICH conversation holds the queue.
1510
+ log.warn(
1511
+ `the queued message of session ${sessionId} has been waiting ${describeWait(observed.ageMs)} and nobody claimed it; it no longer blocks the restart`,
1512
+ {
1513
+ sessionId,
1514
+ ageMs: observed.ageMs,
1515
+ queuedGraceMs: config.queuedGraceMs,
1516
+ },
1517
+ )
1518
+ }
1519
+ if (observed.blocking) out.push({ sessionId, reason: 'queued', ...described })
1520
+ } else {
1521
+ // No queued work any more: the timer of that session is dropped, so
1522
+ // a NEW queue starts counting from zero.
1523
+ queuedTracker.observe(sessionId, false, config.queuedGraceMs)
1524
+ }
1303
1525
  } catch {}
1304
1526
  }
1527
+ // A session that is gone (closed) drops its record as well.
1528
+ for (const sessionId of queuedSessions) {
1529
+ if (!seen.has(sessionId)) queuedTracker.forget(sessionId)
1530
+ }
1531
+ queuedSessions.clear()
1532
+ for (const sessionId of seen) queuedSessions.add(sessionId)
1533
+ // Real work is still going on: the quiet window starts over. This is the
1534
+ // marker that makes the quiet window measure "since the last real work"
1535
+ // instead of "since the last session event of any kind".
1536
+ if (out.length > 0) lastBusyAt = Date.now()
1305
1537
  return out
1306
1538
  }
1307
1539
 
@@ -1643,13 +1875,33 @@ export function createRuntime(ctx, config, options = {}) {
1643
1875
  */
1644
1876
  const pendingRestart = () => state.restart !== null && state.restart !== undefined
1645
1877
 
1646
- /** How long the host has been quiet (no conversation activity at all). */
1647
- const quietForMs = () => Math.max(0, Date.now() - lastActivityAt)
1878
+ /**
1879
+ * The moment the quiet window started.
1880
+ *
1881
+ * It is the LATER of the two markers:
1882
+ * - `lastActivityAt`: the last real activity signal (a turn that started
1883
+ * or ended, a message that was queued);
1884
+ * - `lastBusyAt`: the last moment a conversation was seen working (a
1885
+ * running turn or a queued message).
1886
+ *
1887
+ * The routine session events of the interface (a title, a projection, a
1888
+ * context request) are NOT activity: they arrive every two to three
1889
+ * seconds, so counting them would keep the quiet window from ever filling
1890
+ * — and the restart would never fire.
1891
+ *
1892
+ * @returns {number} a timestamp in milliseconds
1893
+ */
1894
+ const quietSince = () => Math.max(lastActivityAt, lastBusyAt)
1895
+
1896
+ /** How long the host has been quiet (no real conversation activity at all). */
1897
+ const quietForMs = () => Math.max(0, Date.now() - quietSince())
1648
1898
 
1649
1899
  /**
1650
- * The grace window: how much longer the host waits after the last
1651
- * conversation finished, so that work other plugins queue with a timer (a
1652
- * memory plugin writing its notes, a summary) still gets its chance.
1900
+ * The RESTART reserve: how much longer the host waits after the last
1901
+ * conversation finished before it is really restarted.
1902
+ *
1903
+ * A restart is a daily operation, so the default is none: the host restarts
1904
+ * as soon as the conversations are done and the settle window has passed.
1653
1905
  */
1654
1906
  const graceMs = () => config.restartGraceMs
1655
1907
 
@@ -1664,11 +1916,45 @@ export function createRuntime(ctx, config, options = {}) {
1664
1916
  /** The two-layer restart gate: no busy conversation AND a quiet window. */
1665
1917
  const gate = () => restartGate(state, {
1666
1918
  busyAgents: busyAgents(),
1667
- lastActivityAt,
1919
+ lastActivityAt: quietSince(),
1668
1920
  now: Date.now(),
1669
1921
  settleMs: effectiveSettleMs(),
1670
1922
  })
1671
1923
 
1924
+ /**
1925
+ * The description of a LONG wait, or null when the restart is not waiting
1926
+ * for the long time yet.
1927
+ *
1928
+ * A pending restart that passed the hard deadline while a conversation was
1929
+ * still running is not "just a little longer": it is a long wait, and the
1930
+ * user has to SEE it — how long it has been waiting, why, and who holds it.
1931
+ * The request itself is never dropped: the restart stays pending and fires
1932
+ * as soon as the gate is quiet again.
1933
+ *
1934
+ * @param {number} [now]
1935
+ * @returns {{ since: number, reason: string, waitedMs: number, blockedBy: any[] }|null}
1936
+ */
1937
+ const longWaitInfo = (now = Date.now()) => {
1938
+ const restart = state.restart
1939
+ if (!restart || typeof restart !== 'object') return null
1940
+ const requestedAt = Number.isFinite(restart.requestedAt) ? restart.requestedAt : 0
1941
+ if (requestedAt <= 0) return null
1942
+ const hardMs = Number.isFinite(config.deferHardMs) ? config.deferHardMs : 0
1943
+ // The long wait begins when the hard deadline is reached.
1944
+ const since = requestedAt + hardMs
1945
+ if (now < since) return null
1946
+ const verdict = gate()
1947
+ if (verdict.allow) return null
1948
+ const blockedBy = goalAwareBlockingDetails(runtime, verdict.blockedBy)
1949
+ if (blockedBy.length === 0) return null
1950
+ return {
1951
+ since,
1952
+ reason: 'hard-deadline-waiting-for-conversations',
1953
+ waitedMs: Math.max(0, now - requestedAt),
1954
+ blockedBy,
1955
+ }
1956
+ }
1957
+
1672
1958
  /**
1673
1959
  * Cached session titles, used to label the blocking sessions in the
1674
1960
  * client's "pending restart" details panel.
@@ -2021,7 +2307,7 @@ const titleOfSummary = (item) => {
2021
2307
  const decision = restartDecision(state, {
2022
2308
  anyAgentRunning: busy.length > 0,
2023
2309
  busyAgents: busy,
2024
- lastActivityAt,
2310
+ lastActivityAt: quietSince(),
2025
2311
  settleMs: effectiveSettleMs(),
2026
2312
  now,
2027
2313
  softMs: config.deferSoftMs,
@@ -2030,6 +2316,23 @@ const titleOfSummary = (item) => {
2030
2316
  forceRestartWhenBusy: config.forceRestartWhenBusy,
2031
2317
  })
2032
2318
  if (decision.log) log.info(decision.log)
2319
+ // The diagnostic line of the pending restart: what the gate decided and
2320
+ // why. It is rate limited (once a minute) so the 3s poll does not flood
2321
+ // the log, and it is the first thing to look at when "the restart never
2322
+ // happens" is reported.
2323
+ if (now - lastDecisionLogAt >= LONG_WAIT_LOG_INTERVAL_MS) {
2324
+ lastDecisionLogAt = now
2325
+ log.info(
2326
+ `restart gate: action=${decision.action} reason=${decision.reason} busyCount=${busy.length} quietForMs=${Math.max(0, now - quietSince())} effectiveSettleMs=${effectiveSettleMs()}`,
2327
+ {
2328
+ action: decision.action,
2329
+ reason: decision.reason,
2330
+ busyCount: busy.length,
2331
+ quietForMs: Math.max(0, now - quietSince()),
2332
+ effectiveSettleMs: effectiveSettleMs(),
2333
+ },
2334
+ )
2335
+ }
2033
2336
  switch (decision.action) {
2034
2337
  case 'wait':
2035
2338
  return
@@ -2040,6 +2343,28 @@ const titleOfSummary = (item) => {
2040
2343
  }
2041
2344
  return
2042
2345
  }
2346
+ case 'long-wait': {
2347
+ // The hard deadline is over and a conversation still holds the
2348
+ // restart. The request is KEPT — the user asked for a restart, it must
2349
+ // never be dropped silently — and the wait becomes VISIBLE: a clear
2350
+ // log line (rate limited, so the 3s poll does not flood the log) and
2351
+ // the `longWait` block of the state payload.
2352
+ const requestedAt = Number.isFinite(state.restart.requestedAt) ? state.restart.requestedAt : now
2353
+ const waitedMs = Math.max(0, now - requestedAt)
2354
+ const lastAt = longWaitLog.requestedAt === requestedAt ? longWaitLog.at : 0
2355
+ if (now - lastAt >= LONG_WAIT_LOG_INTERVAL_MS) {
2356
+ longWaitLog = { at: now, requestedAt }
2357
+ log.warn(
2358
+ `the restart has been waiting for ${describeWait(waitedMs)}; ${describeBlockers(decision.blockedBy)} still holds it — cancel the restart or wait for that work to finish`,
2359
+ {
2360
+ waitedMs,
2361
+ blockedBy: decision.blockedBy,
2362
+ reason: decision.reason,
2363
+ },
2364
+ )
2365
+ }
2366
+ return
2367
+ }
2043
2368
  case 'restart':
2044
2369
  case 'force-restart': {
2045
2370
  void executeRestart()
@@ -2132,11 +2457,16 @@ const titleOfSummary = (item) => {
2132
2457
  /**
2133
2458
  * The feature announcements the user has not seen yet.
2134
2459
  *
2135
- * A feature is pending when the recorded version is not the current one: a
2136
- * user who has seen the announcement of version 0.6.0 never sees it again,
2137
- * and a user who upgrades to a newer version sees the announcement of that
2138
- * version. A feature without a record is always pending — a missing record
2139
- * must never swallow an announcement.
2460
+ * A feature is pending when the recorded version is OLDER than the current
2461
+ * one: a user who has seen the announcement of the feature version 0.5.0
2462
+ * never sees it again - not with the next release of the plugin, because
2463
+ * the announcement follows the FEATURE and not the package. A user who
2464
+ * answered the card on an intermediate release (the announcement used to
2465
+ * carry the package version) has seen it as well: a record that is not
2466
+ * older than the current version never brings the card back.
2467
+ *
2468
+ * A feature without a record is always pending - a missing record must
2469
+ * never swallow an announcement.
2140
2470
  *
2141
2471
  * @returns {{ feature: string, version: string }[]}
2142
2472
  */
@@ -2144,7 +2474,7 @@ const titleOfSummary = (item) => {
2144
2474
  const seen = state.seenFeatureVersions && typeof state.seenFeatureVersions === 'object'
2145
2475
  ? state.seenFeatureVersions
2146
2476
  : {}
2147
- return featureAnnouncements().filter((entry) => seen[entry.feature] !== entry.version)
2477
+ return featureAnnouncements().filter((entry) => !isFeatureSeen(seen[entry.feature], entry.version))
2148
2478
  }
2149
2479
 
2150
2480
  /**
@@ -2294,13 +2624,23 @@ const titleOfSummary = (item) => {
2294
2624
  */
2295
2625
  pendingRestart,
2296
2626
  /**
2297
- * How long the host has been quiet (no conversation activity at all).
2627
+ * How long the host has been quiet (no REAL conversation activity: a
2628
+ * running turn, a queued message, a turn that started or ended).
2629
+ *
2630
+ * The routine session events of the interface do not count.
2298
2631
  *
2299
2632
  * @returns {number}
2300
2633
  */
2301
2634
  quietForMs,
2302
2635
  /**
2303
- * The grace window: the extra wait after the last conversation finished.
2636
+ * The moment the quiet window started.
2637
+ *
2638
+ * @returns {number} a timestamp in milliseconds
2639
+ */
2640
+ quietSince,
2641
+ /**
2642
+ * The RESTART reserve: the extra wait after the last conversation
2643
+ * finished, before the host is really restarted.
2304
2644
  *
2305
2645
  * @returns {number} milliseconds
2306
2646
  */
@@ -2321,6 +2661,19 @@ const titleOfSummary = (item) => {
2321
2661
  noteActivity,
2322
2662
  busyAgents,
2323
2663
  gate,
2664
+ /**
2665
+ * The description of a long wait (null when there is none).
2666
+ *
2667
+ * @param {number} [now]
2668
+ * @returns {{ since: number, reason: string, waitedMs: number, blockedBy: any[] }|null}
2669
+ */
2670
+ longWaitInfo,
2671
+ /**
2672
+ * The upper bound of a queued (unclaimed) inbox message.
2673
+ *
2674
+ * @returns {number} milliseconds
2675
+ */
2676
+ queuedGraceMs: () => config.queuedGraceMs,
2324
2677
  sessionTitle,
2325
2678
  disarmGoal,
2326
2679
  disarmBlockingGoals,
@@ -3050,6 +3403,7 @@ function registerTools(ctx, runtime, config) {
3050
3403
  quietForMs: { type: 'number' },
3051
3404
  graceMs: { type: 'number' },
3052
3405
  effectiveSettleMs: { type: 'number' },
3406
+ queuedGraceMs: { type: 'number' },
3053
3407
  blockedBy: {
3054
3408
  type: 'array',
3055
3409
  items: {
@@ -3101,10 +3455,12 @@ function registerTools(ctx, runtime, config) {
3101
3455
  pendingRestart: state.restart !== null,
3102
3456
  gateAllow: verdict.allow,
3103
3457
  quietForMs: Math.round(verdict.quietForMs),
3104
- // The grace window (the extra wait after the last conversation
3458
+ // The RESTART reserve (the extra wait after the last conversation
3105
3459
  // finished) and the quiet window the gate really uses.
3106
3460
  graceMs: runtime.graceMs(),
3107
3461
  effectiveSettleMs: runtime.effectiveSettleMs(),
3462
+ // The upper bound of a queued (unclaimed) inbox message.
3463
+ queuedGraceMs: runtime.queuedGraceMs(),
3108
3464
  blockedBy: blockingDetails(verdict.blockedBy),
3109
3465
  parkedGoals: parkedGoalDetails(),
3110
3466
  recovery: recoveryList(state),
@@ -3360,7 +3716,7 @@ function injectPageHeartbeat(html, bootId, routePath) {
3360
3716
  const AUTORESTART_PROMPT = [
3361
3717
  '## Restart policy (dsh-autorestart)',
3362
3718
  '- When DeepSeek Harness itself must restart (for example to load an installed or updated plugin bundle), do NOT kill or relaunch the app yourself with pwsh/taskkill/Stop-Process/npm scripts. A hard guard blocks such commands anyway (denials mention `blockManualRestart`).',
3363
- '- Call the `continuum_restart` tool instead (optional arguments: `reason`, `resume`, `defer_seconds`, `parkGoals`). It waits until every conversation is idle (no running turn, no queued inbox work) AND the host has been quiet for `settleMs` (default 2000 ms); the detached helper re-checks that gate immediately before killing, so other conversations are never killed mid-turn. It then relaunches the app, and after boot this plugin resumes the session and continues the work automatically.',
3719
+ '- Call the `continuum_restart` tool instead (optional arguments: `reason`, `resume`, `defer_seconds`, `parkGoals`). It waits until every conversation is idle (no running turn — a running turn ALWAYS blocks; an inbox message nobody claimed blocks only until `queuedGraceMs`, default 60000 ms, after which it is treated as stuck and no longer blocks) AND the host has been quiet for `settleMs` (default 2000 ms); the detached helper re-checks that gate immediately before killing, so other conversations are never killed mid-turn. It then relaunches the app, and after boot this plugin resumes the session and continues the work automatically.',
3364
3720
  '- A goal-driven session is DISARMED automatically before the restart (its current round still finishes, nothing is lost), because such a session would otherwise keep the restart waiting forever: disarming only removes the process-local continuation authority, so no new round starts and the session goes idle. After the restart the goal is re-armed and continues. A goal whose round budget is spent cannot be continued and is reported instead. You can disarm your own goal on demand with `continuum_park_goal`, and retry a stuck recovery with `continuum_recover_now`.',
3365
3721
  '- Persist anything the next turn needs before restarting (workspace files, `todo_write`, goal state); do not rely on in-memory state.',
3366
3722
  '- Inspect the lifecycle with `continuum_status`; cancel a pending restart with `continuum_cancel_restart`.',
@@ -3834,7 +4190,8 @@ function buildHandoffRoute(ctx, runtime, config) {
3834
4190
  *
3835
4191
  * The record lives in the checkpoint file, so an announcement that was shown
3836
4192
  * once never comes back — not with the next boot and not with the next
3837
- * version of the plugin.
4193
+ * release of the plugin. Only a new FEATURE version (see
4194
+ * `SLEEP_FEATURE_VERSION`) brings it back, on purpose.
3838
4195
  */
3839
4196
  function buildAnnouncementsRoute(ctx, runtime, config) {
3840
4197
  const routePath = resolveAnnouncementsPath(config)
@@ -3961,7 +4318,7 @@ function buildChoiceRoute(ctx, runtime, config) {
3961
4318
  res.end(JSON.stringify({
3962
4319
  ok: false,
3963
4320
  error: 'no-choice',
3964
- instruction: 'Send { "includePlugins": true | false } and/or { "backupIncludePlugins": true | false } and/or { "backupOnRestart": true | false } and/or { "showSleepButton": true | false } and/or { "sleepCountdownSeconds": 0 | 10 | 20 | 30 | 60 } and/or { "restartGraceMs": 0 | 3000 | 5000 | 10000 | 20000 | 30000 }.',
4321
+ instruction: 'Send { "includePlugins": true | false } and/or { "backupIncludePlugins": true | false } and/or { "backupOnRestart": true | false } and/or { "showSleepButton": true | false } and/or { "sleepCountdownSeconds": 0 | 10 | 20 | 30 | 60 } and/or { "restartGraceMs": 0 | 5000 | 10000 | 20000 | 30000 | 60000 }.',
3965
4322
  }))
3966
4323
  return
3967
4324
  }
@@ -4100,16 +4457,16 @@ function sleepDryRun() {
4100
4457
  * happened). The sleep feature counts down on screen itself, so it only needs
4101
4458
  * the busy check — and it keeps `settleMs` out of the picture entirely.
4102
4459
  *
4103
- * The GRACE window is the exception: it is not a settle window but the extra
4104
- * wait for work this plugin cannot see (a memory plugin writing its notes a
4105
- * few seconds after the conversation ended). It is honoured BEFORE the
4106
- * countdown starts, so the countdown is never pushed back by it.
4460
+ * The shutdown has NO invisible reserve of its own: the visible countdown
4461
+ * (`sleepCountdownSeconds`) IS the wait before the machine goes off, and it is
4462
+ * on screen, so the user can see it and cancel it. A second, hidden window
4463
+ * next to it would only say the same thing twice.
4107
4464
  *
4108
4465
  * The restart gate itself is untouched: the restart feature keeps its quiet
4109
- * window exactly as it was (plus the same grace).
4466
+ * window exactly as it was (plus its own restart reserve).
4110
4467
  *
4111
4468
  * @param {object} runtime
4112
- * @returns {{ allow: boolean, reason: 'busy'|'grace'|'quiet', blockedBy: any[] }}
4469
+ * @returns {{ allow: boolean, reason: 'busy'|'quiet', blockedBy: any[] }}
4113
4470
  */
4114
4471
  function sleepGate(runtime) {
4115
4472
  // A pending restart goes FIRST: the shutdown waits for it — and for the
@@ -4141,16 +4498,8 @@ function sleepGate(runtime) {
4141
4498
  }
4142
4499
  const list = Array.isArray(busy) ? busy : []
4143
4500
  if (list.length > 0) return { allow: false, reason: 'busy', blockedBy: list }
4144
- // The grace window: the last conversation may have finished a moment ago,
4145
- // and other plugins still have work queued that this plugin cannot see.
4146
- let quiet = 0
4147
- let grace = 0
4148
- try { quiet = Number(runtime.quietForMs()) } catch { quiet = 0 }
4149
- try { grace = Number(runtime.graceMs()) } catch { grace = 0 }
4150
- if (!Number.isFinite(quiet) || quiet < 0) quiet = 0
4151
- if (Number.isFinite(grace) && grace > 0 && quiet < grace) {
4152
- return { allow: false, reason: 'grace', blockedBy: [] }
4153
- }
4501
+ // Nobody is busy and no restart is pending: the countdown may run. There is
4502
+ // no invisible reserve — the countdown on screen is the wait.
4154
4503
  return { allow: true, reason: 'quiet', blockedBy: [] }
4155
4504
  }
4156
4505
 
@@ -4232,11 +4581,11 @@ function createSleepService(ctx, runtime, config, options = {}) {
4232
4581
  busyAgents: () => runtime.busyAgents(),
4233
4582
  gate,
4234
4583
  titleOf: (sessionId) => runtime.sessionTitle(sessionId),
4235
- // The grace window: after the last conversation finished the machine
4236
- // waits a little longer, so that work this plugin cannot see (a memory
4237
- // plugin writing its notes, a summary) still gets its chance.
4238
- quietForMs: () => runtime.quietForMs(),
4239
- graceMs: () => runtime.graceMs(),
4584
+ // The shutdown has NO invisible reserve: the countdown on screen IS the
4585
+ // wait before the machine goes off. The controller keeps the parameter for
4586
+ // compatibility, but the plugin never injects a window — and it never
4587
+ // injects the RESTART reserve either.
4588
+ graceMs: () => 0,
4240
4589
  // A pending restart goes first: the shutdown waits for it.
4241
4590
  restartPending: () => {
4242
4591
  const pending = runtime.pendingRestart()
@@ -4371,9 +4720,10 @@ function sleepStatusPayload(sleep, config, runtime) {
4371
4720
  armed: status.armed,
4372
4721
  showSleepButton: config.showSleepButton !== false,
4373
4722
  countdownSeconds: status.countdownSeconds,
4374
- // The grace window and how long the host has been quiet: both are needed
4375
- // to tell "the machine is waiting for the grace" from "somebody is busy".
4376
- graceMs: config.restartGraceMs,
4723
+ // The RESTART reserve is reported next to the countdown, so the settings
4724
+ // page can show it. The shutdown itself has NO invisible reserve: the
4725
+ // visible countdown is the wait before the machine goes off.
4726
+ restartGraceMs: config.restartGraceMs,
4377
4727
  quietForMs: runtime && typeof runtime.quietForMs === 'function' ? runtime.quietForMs() : 0,
4378
4728
  // When the shutdown was armed: the intent has to survive a restart, so the
4379
4729
  // time is reported for the troubleshooting.
@@ -4515,10 +4865,14 @@ function registerHealthRoute(ctx, runtime, config, sleep) {
4515
4865
  generation: state.generation,
4516
4866
  pendingRestart: state.restart !== null,
4517
4867
  settleMs: config.settleMs,
4518
- // The grace window and the quiet window the gate really uses: both
4519
- // are needed to tell "waiting for the grace" from "somebody is busy".
4868
+ // The RESTART reserve and the quiet window the gate really uses:
4869
+ // both are needed to tell "waiting for the reserve" from "somebody
4870
+ // is busy".
4520
4871
  graceMs: config.restartGraceMs,
4521
4872
  effectiveSettleMs: runtime.effectiveSettleMs(),
4873
+ // The upper bound of a queued (unclaimed) inbox message: after this
4874
+ // window it stops blocking the restart.
4875
+ queuedGraceMs: config.queuedGraceMs,
4522
4876
  quietForMs: typeof verdict.quietForMs === 'number' ? verdict.quietForMs : 0,
4523
4877
  busyCount: verdict.blockedBy.length,
4524
4878
  agentCount,
@@ -4581,11 +4935,23 @@ function registerHealthRoute(ctx, runtime, config, sleep) {
4581
4935
  waiters,
4582
4936
  blockedBy,
4583
4937
  allowRestart: verdict.allow === true,
4938
+ // WHY the gate says what it says. Without this, "allowRestart is
4939
+ // false and blockedBy is empty" looks like a contradiction: the
4940
+ // reason tells the two apart (an empty blocker list with the
4941
+ // reason "settling" means the quiet window is not full yet).
4942
+ reason: typeof verdict.reason === 'string' ? verdict.reason : 'unknown',
4584
4943
  quietForMs: typeof verdict.quietForMs === 'number' ? verdict.quietForMs : 0,
4585
4944
  settleMs: config.settleMs,
4586
- // The grace window and the quiet window the gate really uses.
4945
+ // The RESTART reserve and the quiet window the gate really uses.
4587
4946
  graceMs: config.restartGraceMs,
4588
4947
  effectiveSettleMs: runtime.effectiveSettleMs(),
4948
+ // The upper bound of a queued (unclaimed) inbox message.
4949
+ queuedGraceMs: config.queuedGraceMs,
4950
+ // The long wait: present only when the restart passed the hard
4951
+ // deadline while a conversation was still running. It says how
4952
+ // long the restart has been waiting, why, and who holds it — the
4953
+ // request itself is never dropped.
4954
+ longWait: runtime.longWaitInfo(),
4589
4955
  updatedAt: live.updatedAt,
4590
4956
  // The park route, so the client never has to guess the prefix, and
4591
4957
  // the goals this plugin already disarmed (they are re-armed after
@@ -4611,7 +4977,10 @@ function registerHealthRoute(ctx, runtime, config, sleep) {
4611
4977
  waiters: [],
4612
4978
  blockedBy: [],
4613
4979
  allowRestart: false,
4980
+ reason: 'unknown',
4614
4981
  quietForMs: 0,
4982
+ queuedGraceMs: config.queuedGraceMs,
4983
+ longWait: null,
4615
4984
  parkUrl: resolveParkPath(config),
4616
4985
  recoverUrl: resolveRecoverPath(config),
4617
4986
  choiceUrl: resolveChoicePath(config),
@@ -4780,9 +5149,20 @@ function trackAgents(ctx, runtime, config) {
4780
5149
  }
4781
5150
  })
4782
5151
 
4783
- // Any session event or newly queued inbox message restarts the settle window.
4784
- ctx.on('session/event', () => {
5152
+ // A session event only restarts the settle window when it says that WORK
5153
+ // happened (a message, a tool, a turn). The host produces session events
5154
+ // every two to three seconds while the interface polls and synchronises —
5155
+ // counting those as activity kept the quiet window from ever filling, so a
5156
+ // restart with a grace window of ten seconds NEVER fired. When the type of
5157
+ // an event cannot be read, it is not activity: the plugin would rather
5158
+ // restart a little early than never restart at all.
5159
+ //
5160
+ // The hard guarantee is elsewhere and untouched: a conversation that is
5161
+ // really running blocks the restart through `busyAgents()`, which is never
5162
+ // relaxed.
5163
+ ctx.on('session/event', (payload) => {
4785
5164
  try {
5165
+ if (!isWorkSessionEvent(payload)) return
4786
5166
  runtime.noteActivity()
4787
5167
  } catch {}
4788
5168
  })
@@ -4967,4 +5347,6 @@ export {
4967
5347
  resolveAnnouncementsPath,
4968
5348
  featureAnnouncements,
4969
5349
  pluginVersion,
5350
+ PLUGIN_VERSION_FALLBACK,
5351
+ SLEEP_FEATURE_VERSION,
4970
5352
  }