dsh-autorestart 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -24,8 +24,10 @@ import os from 'node:os'
24
24
  import path from 'node:path'
25
25
 
26
26
  import {
27
+ armSleep,
27
28
  beginBoot,
28
29
  cancelRestart,
30
+ clearSleep,
29
31
  disarmedRecord,
30
32
  entryFor,
31
33
  handoffRecord,
@@ -83,6 +85,47 @@ import {
83
85
  snapCountdownChoice,
84
86
  } from './lib/sleep.js'
85
87
 
88
+ /**
89
+ * The grace window: how long the host waits after the LAST conversation
90
+ * finished before it is really restarted or switched off.
91
+ *
92
+ * This plugin can only see the DSH agents. Other plugins (a memory plugin
93
+ * that writes its notes a few seconds after the conversation ended, a summary,
94
+ * 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.
96
+ *
97
+ * The values are the ones the settings page offers (in milliseconds).
98
+ */
99
+ const GRACE_CHOICES_MS = Object.freeze([0, 3000, 5000, 10000, 20000, 30000])
100
+
101
+ /** The grace window that is used when nothing was configured. */
102
+ const DEFAULT_GRACE_MS = 10000
103
+
104
+ /**
105
+ * Snap a grace value onto the nearest offered choice.
106
+ *
107
+ * A profile that carries a value that is not offered (a hand-edited row, an
108
+ * older build) must not break the boot: the value is snapped onto the
109
+ * nearest choice, exactly like the countdown length is.
110
+ *
111
+ * @param {unknown} value
112
+ * @returns {number} one of {@link GRACE_CHOICES_MS}
113
+ */
114
+ function snapGraceChoice(value) {
115
+ const raw = Number(value)
116
+ if (!Number.isFinite(raw) || raw < 0) return DEFAULT_GRACE_MS
117
+ let best = GRACE_CHOICES_MS[0]
118
+ let bestDistance = Math.abs(raw - best)
119
+ for (const choice of GRACE_CHOICES_MS) {
120
+ const distance = Math.abs(raw - choice)
121
+ if (distance < bestDistance) {
122
+ best = choice
123
+ bestDistance = distance
124
+ }
125
+ }
126
+ return best
127
+ }
128
+
86
129
  /**
87
130
  * The version of this plugin, as a fallback.
88
131
  *
@@ -90,7 +133,7 @@ import {
90
133
  * `pluginVersion()`), but a bundled installation may not ship that file — so
91
134
  * the constant below is the last resort. A test keeps the two in sync.
92
135
  */
93
- const PLUGIN_VERSION_FALLBACK = '0.5.0'
136
+ const PLUGIN_VERSION_FALLBACK = '0.6.0'
94
137
 
95
138
  /** The cached version of this plugin (read once per process). */
96
139
  let pluginVersionCache = null
@@ -281,6 +324,12 @@ function buildConfigSchema() {
281
324
  // quiet window that must pass after the last conversation activity
282
325
  settleMs: schemastery.number().min(0).default(2000)
283
326
  .description('Milliseconds the host must stay quiet after the last conversation activity before a restart may fire.'),
327
+ // The grace window: the extra wait after the last conversation finished.
328
+ // Other plugins queue work with timers of their own (a memory plugin
329
+ // writing its notes, a summary, an MCP callback) and that work is
330
+ // 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).'),
284
333
  autoReloadPage: schemastery.boolean().default(true)
285
334
  .description('Reload the web page automatically when the host boot token changes.'),
286
335
  blockManualRestart: schemastery.boolean().default(true)
@@ -347,6 +396,7 @@ const DEFAULTS = {
347
396
  restartCommand: '',
348
397
  relaunchDelaySeconds: 2,
349
398
  settleMs: 2000,
399
+ restartGraceMs: DEFAULT_GRACE_MS,
350
400
  autoReloadPage: true,
351
401
  blockManualRestart: true,
352
402
  healthPath: '/__dsh_autorestart/health',
@@ -393,6 +443,9 @@ function normalizeConfig(raw) {
393
443
  // profile that still carries an old value (15, 45, …) is snapped onto the
394
444
  // nearest one instead of breaking, and 0 means "no countdown at all".
395
445
  config.sleepCountdownSeconds = snapCountdownChoice(config.sleepCountdownSeconds)
446
+ // The grace window is one of the offered choices as well: a hand-edited
447
+ // profile value is snapped onto the nearest one instead of breaking.
448
+ config.restartGraceMs = snapGraceChoice(config.restartGraceMs)
396
449
  if (typeof config.sleepCommand !== 'string') config.sleepCommand = ''
397
450
  if (typeof config.showSleepButton !== 'boolean') config.showSleepButton = true
398
451
  return config
@@ -673,7 +726,7 @@ const INSTALL_CHOICE_FILE = 'install-choice.json'
673
726
  * Read the recorded install choice from the fallback file.
674
727
  *
675
728
  * @param {string} stateDir
676
- * @returns {{ includePlugins: boolean|null, backupOnRestart: boolean|null, showSleepButton: boolean|null, sleepCountdownSeconds: number|null }|null}
729
+ * @returns {{ includePlugins: boolean|null, backupOnRestart: boolean|null, showSleepButton: boolean|null, sleepCountdownSeconds: number|null, restartGraceMs: number|null }|null}
677
730
  */
678
731
  function readInstallChoice(stateDir) {
679
732
  try {
@@ -687,6 +740,7 @@ function readInstallChoice(stateDir) {
687
740
  sleepCountdownSeconds: typeof parsed.sleepCountdownSeconds === 'number'
688
741
  ? parsed.sleepCountdownSeconds
689
742
  : null,
743
+ restartGraceMs: typeof parsed.restartGraceMs === 'number' ? parsed.restartGraceMs : null,
690
744
  }
691
745
  } catch {
692
746
  return null
@@ -700,7 +754,7 @@ function readInstallChoice(stateDir) {
700
754
  * erase the recorded backup scope (and the other way round).
701
755
  *
702
756
  * @param {string} stateDir
703
- * @param {{ includePlugins?: boolean, backupOnRestart?: boolean, showSleepButton?: boolean, sleepCountdownSeconds?: number }} values
757
+ * @param {{ includePlugins?: boolean, backupOnRestart?: boolean, showSleepButton?: boolean, sleepCountdownSeconds?: number, restartGraceMs?: number }} values
704
758
  * @returns {string} the file that was written
705
759
  */
706
760
  function writeInstallChoice(stateDir, values) {
@@ -710,6 +764,7 @@ function writeInstallChoice(stateDir, values) {
710
764
  backupOnRestart: null,
711
765
  showSleepButton: null,
712
766
  sleepCountdownSeconds: null,
767
+ restartGraceMs: null,
713
768
  }
714
769
  const payload = {
715
770
  includePlugins: typeof values.includePlugins === 'boolean' ? values.includePlugins : current.includePlugins,
@@ -718,6 +773,9 @@ function writeInstallChoice(stateDir, values) {
718
773
  sleepCountdownSeconds: typeof values.sleepCountdownSeconds === 'number'
719
774
  ? values.sleepCountdownSeconds
720
775
  : current.sleepCountdownSeconds,
776
+ restartGraceMs: typeof values.restartGraceMs === 'number'
777
+ ? values.restartGraceMs
778
+ : current.restartGraceMs,
721
779
  at: new Date().toISOString(),
722
780
  }
723
781
  fs.mkdirSync(stateDir, { recursive: true })
@@ -1143,9 +1201,89 @@ export function createRuntime(ctx, config, options = {}) {
1143
1201
  return count
1144
1202
  }
1145
1203
 
1204
+ /**
1205
+ * Whether this agent is a subagent (a delegated child), and which session
1206
+ * created it.
1207
+ *
1208
+ * The origin is the ONLY reliable marker: a forked session also carries a
1209
+ * parent session, but it is a top-level conversation, not a subagent.
1210
+ *
1211
+ * @param {any} agent
1212
+ * @returns {string|null} the parent session id, or null when this is not a
1213
+ * subagent
1214
+ */
1215
+ const subagentParentOf = (agent) => {
1216
+ try {
1217
+ if (!agent || typeof agent !== 'object') return null
1218
+ const session = agent.session && typeof agent.session === 'object' ? agent.session : null
1219
+ const header = session && session.header && typeof session.header === 'object' ? session.header : null
1220
+ const origin = agent.origin != null ? agent.origin : (header ? header.origin : undefined)
1221
+ if (origin !== 'subagent') return null
1222
+ const parent = agent.parentId != null
1223
+ ? agent.parentId
1224
+ : agent.parentSessionId != null
1225
+ ? agent.parentSessionId
1226
+ : (header ? header.parentSession : undefined)
1227
+ if (parent == null || parent === '') return null
1228
+ return String(parent)
1229
+ } catch {
1230
+ return null
1231
+ }
1232
+ }
1233
+
1234
+ /**
1235
+ * The readable identity of one blocker: WHAT it is (a session or a
1236
+ * subagent) and HOW it is called.
1237
+ *
1238
+ * A subagent has no title of its own, so it is named after the session that
1239
+ * created it ("<that session>'s subagent"). A raw session id never reaches
1240
+ * the interface: an unknown name is a generic one.
1241
+ *
1242
+ * @param {string} sessionId
1243
+ * @param {any} agent
1244
+ * @returns {{ kind: 'session'|'subagent', label: string|null, name: string, parentTitle: string|null, parentSessionId: string|null }}
1245
+ */
1246
+ const describeBlocker = (sessionId, agent) => {
1247
+ const parent = agent ? subagentParentOf(agent) : null
1248
+ if (parent === null) {
1249
+ let title = null
1250
+ try { title = sessionTitle(sessionId) } catch { /* a missing title is not a problem */ }
1251
+ const label = title ? String(title) : null
1252
+ return {
1253
+ kind: 'session',
1254
+ // The label is the title of the session itself.
1255
+ label,
1256
+ // The full name of the blocker: its title — or NOTHING when it has no
1257
+ // title. A generic name ("会话") says nothing and would end up in the
1258
+ // interface as "会话「会话」" or as a list of three identical
1259
+ // entries; the client decides what to write when the name is empty.
1260
+ name: label || '',
1261
+ parentTitle: null,
1262
+ parentSessionId: null,
1263
+ }
1264
+ }
1265
+ let parentTitle = null
1266
+ try { parentTitle = sessionTitle(parent) } catch { /* a missing title is not a problem */ }
1267
+ const title = parentTitle ? String(parentTitle) : null
1268
+ return {
1269
+ kind: 'subagent',
1270
+ // The label is the title of the session that created it: the UI shows
1271
+ // "子智能体(<that title>)".
1272
+ label: title,
1273
+ // The full name of the blocker: "<that session>'s subagent".
1274
+ name: title ? `${title}的子智能体` : '子智能体',
1275
+ parentTitle: title,
1276
+ parentSessionId: parent,
1277
+ }
1278
+ }
1279
+
1146
1280
  /**
1147
1281
  * Every conversation that is still working, subagents and teammates included.
1148
1282
  * 'running' is a live turn; 'queued' is inbox work that has not started yet.
1283
+ *
1284
+ * Every entry carries the kind of the blocker and a readable name, so the
1285
+ * interface can tell a session from a subagent and never has to show a raw
1286
+ * session id.
1149
1287
  */
1150
1288
  const busyAgents = () => {
1151
1289
  const out = []
@@ -1158,8 +1296,10 @@ export function createRuntime(ctx, config, options = {}) {
1158
1296
  for (const agent of agents) {
1159
1297
  try {
1160
1298
  if (!agent || !agent.id) continue
1161
- if (agent.status === 'running') out.push({ sessionId: String(agent.id), reason: 'running' })
1162
- else if (pendingInboxCount(agent) > 0) out.push({ sessionId: String(agent.id), reason: 'queued' })
1299
+ const sessionId = String(agent.id)
1300
+ 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 })
1163
1303
  } catch {}
1164
1304
  }
1165
1305
  return out
@@ -1492,12 +1632,41 @@ export function createRuntime(ctx, config, options = {}) {
1492
1632
 
1493
1633
  const anyAgentRunning = () => busyAgents().length > 0
1494
1634
 
1495
- /** The two-layer restart gate: no busy conversation AND a quiet settle window. */
1635
+ /**
1636
+ * Whether a restart is still pending.
1637
+ *
1638
+ * A pending restart goes FIRST: the graceful shutdown waits for it — and for
1639
+ * the sessions that resume with it. The shutdown intent survives the
1640
+ * restart, so the machine goes off after everything is done.
1641
+ *
1642
+ * @returns {boolean}
1643
+ */
1644
+ const pendingRestart = () => state.restart !== null && state.restart !== undefined
1645
+
1646
+ /** How long the host has been quiet (no conversation activity at all). */
1647
+ const quietForMs = () => Math.max(0, Date.now() - lastActivityAt)
1648
+
1649
+ /**
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.
1653
+ */
1654
+ const graceMs = () => config.restartGraceMs
1655
+
1656
+ /**
1657
+ * The quiet window the restart gate really uses.
1658
+ *
1659
+ * It is the settle window, but never shorter than the grace window: the
1660
+ * grace is the *extra* wait on top of the settle, not a replacement.
1661
+ */
1662
+ const effectiveSettleMs = () => Math.max(config.settleMs, config.restartGraceMs)
1663
+
1664
+ /** The two-layer restart gate: no busy conversation AND a quiet window. */
1496
1665
  const gate = () => restartGate(state, {
1497
1666
  busyAgents: busyAgents(),
1498
1667
  lastActivityAt,
1499
1668
  now: Date.now(),
1500
- settleMs: config.settleMs,
1669
+ settleMs: effectiveSettleMs(),
1501
1670
  })
1502
1671
 
1503
1672
  /**
@@ -1512,7 +1681,43 @@ export function createRuntime(ctx, config, options = {}) {
1512
1681
  */
1513
1682
  const titleCache = { at: 0, titles: new Map(), inFlight: false }
1514
1683
 
1515
- /** Store the titles of one session-list answer. Never throws. */
1684
+ /**
1685
+ * The title of one row of the session list.
1686
+ *
1687
+ * The title is a PROJECTION: the summary row carries it under
1688
+ * `projections.values.title` (the same place `dsh-session-reference` reads it
1689
+ * from), not under a `title` field of its own. A plain `title`/`name` is
1690
+ * accepted too, so a list with a different shape still works.
1691
+ *
1692
+ * @param {any} item one row of the session list
1693
+ * @returns {string} the title, or '' when the row carries none
1694
+ */
1695
+ const titleOfSummary = (item) => {
1696
+ try {
1697
+ if (!item || typeof item !== 'object') return ''
1698
+ const projections = item.projections && typeof item.projections === 'object'
1699
+ ? item.projections
1700
+ : null
1701
+ const values = projections && projections.values && typeof projections.values === 'object'
1702
+ ? projections.values
1703
+ : null
1704
+ const candidates = [
1705
+ values ? values.title : undefined,
1706
+ item.title,
1707
+ item.name,
1708
+ ]
1709
+ for (const candidate of candidates) {
1710
+ if (typeof candidate !== 'string') continue
1711
+ const title = candidate.trim()
1712
+ if (title !== '') return title
1713
+ }
1714
+ return ''
1715
+ } catch {
1716
+ return ''
1717
+ }
1718
+ }
1719
+
1720
+ /** Store the titles of one session-list answer. Never throws. */
1516
1721
  const applySessionTitles = (payload) => {
1517
1722
  const items = payload && Array.isArray(payload.items)
1518
1723
  ? payload.items
@@ -1520,9 +1725,13 @@ export function createRuntime(ctx, config, options = {}) {
1520
1725
  const titles = new Map()
1521
1726
  for (const item of items) {
1522
1727
  if (!item || typeof item !== 'object') continue
1523
- const id = item.id === undefined || item.id === null ? '' : String(item.id)
1728
+ // The session list names its rows `sessionId` (`id` is an older shape).
1729
+ const rawId = item.sessionId !== undefined && item.sessionId !== null
1730
+ ? item.sessionId
1731
+ : item.id
1732
+ const id = rawId === undefined || rawId === null ? '' : String(rawId)
1524
1733
  if (!id) continue
1525
- const title = typeof item.title === 'string' ? item.title.trim() : ''
1734
+ const title = titleOfSummary(item)
1526
1735
  if (title) titles.set(id, title)
1527
1736
  }
1528
1737
  titleCache.titles = titles
@@ -1813,7 +2022,7 @@ export function createRuntime(ctx, config, options = {}) {
1813
2022
  anyAgentRunning: busy.length > 0,
1814
2023
  busyAgents: busy,
1815
2024
  lastActivityAt,
1816
- settleMs: config.settleMs,
2025
+ settleMs: effectiveSettleMs(),
1817
2026
  now,
1818
2027
  softMs: config.deferSoftMs,
1819
2028
  hardMs: config.deferHardMs,
@@ -1858,7 +2067,7 @@ export function createRuntime(ctx, config, options = {}) {
1858
2067
  * is unavailable the choice degrades to a file in the plugin's state
1859
2068
  * directory and a warning is logged, so the choice is never silently lost.
1860
2069
  *
1861
- * @param {{ backupOnRestart?: boolean, backupIncludePlugins?: boolean, showSleepButton?: boolean, sleepCountdownSeconds?: number }} changes
2070
+ * @param {{ backupOnRestart?: boolean, backupIncludePlugins?: boolean, showSleepButton?: boolean, sleepCountdownSeconds?: number, restartGraceMs?: number }} changes
1862
2071
  * @returns {Promise<{ applied: 'config'|'fallback-file' }>}
1863
2072
  */
1864
2073
  const persistConfigChoice = async (changes) => {
@@ -1883,6 +2092,10 @@ export function createRuntime(ctx, config, options = {}) {
1883
2092
  wanted.sleepCountdownSeconds = changes.sleepCountdownSeconds
1884
2093
  fallback.sleepCountdownSeconds = changes.sleepCountdownSeconds
1885
2094
  }
2095
+ if (typeof changes.restartGraceMs === 'number') {
2096
+ wanted.restartGraceMs = changes.restartGraceMs
2097
+ fallback.restartGraceMs = changes.restartGraceMs
2098
+ }
1886
2099
  const editor = resolveConfigEditor(ctx)
1887
2100
  if (editor !== null) {
1888
2101
  try {
@@ -1920,7 +2133,7 @@ export function createRuntime(ctx, config, options = {}) {
1920
2133
  * The feature announcements the user has not seen yet.
1921
2134
  *
1922
2135
  * A feature is pending when the recorded version is not the current one: a
1923
- * user who has seen the announcement of version 0.5.0 never sees it again,
2136
+ * user who has seen the announcement of version 0.6.0 never sees it again,
1924
2137
  * and a user who upgrades to a newer version sees the announcement of that
1925
2138
  * version. A feature without a record is always pending — a missing record
1926
2139
  * must never swallow an announcement.
@@ -2074,6 +2287,31 @@ export function createRuntime(ctx, config, options = {}) {
2074
2287
  stopPolling,
2075
2288
  tick,
2076
2289
  anyAgentRunning,
2290
+ /**
2291
+ * Whether a restart is still pending (the graceful shutdown waits for it).
2292
+ *
2293
+ * @returns {boolean}
2294
+ */
2295
+ pendingRestart,
2296
+ /**
2297
+ * How long the host has been quiet (no conversation activity at all).
2298
+ *
2299
+ * @returns {number}
2300
+ */
2301
+ quietForMs,
2302
+ /**
2303
+ * The grace window: the extra wait after the last conversation finished.
2304
+ *
2305
+ * @returns {number} milliseconds
2306
+ */
2307
+ graceMs,
2308
+ /**
2309
+ * The quiet window the restart gate really uses: the settle window, but
2310
+ * never shorter than the grace window.
2311
+ *
2312
+ * @returns {number} milliseconds
2313
+ */
2314
+ effectiveSettleMs,
2077
2315
  executeRestart,
2078
2316
  flushSessions,
2079
2317
  releaseRequesters,
@@ -2782,7 +3020,9 @@ function registerTools(ctx, runtime, config) {
2782
3020
  'Report the dsh-autorestart lifecycle: whether a restart is pending, which sessions were '
2783
3021
  + 'checkpointed as interrupted or continuation-pending, why each blocking session blocks '
2784
3022
  + '("goal-active" = an armed goal keeps it busy, "goal-exhausted" = its goal can no longer be '
2785
- + 'resumed), the goals this plugin disarmed, the last recovery action/detail per session, '
3023
+ + 'resumed), the goals this plugin disarmed, how long the host has been quiet and how long '
3024
+ + 'the grace window is (the extra wait after the last conversation finished, for work other '
3025
+ + 'plugins queue with a timer), the last recovery action/detail per session, '
2786
3026
  + 'whether a session was handed off to a fresh session (target session id and time) or is over '
2787
3027
  + 'the model context window (such a session is never auto-resumed), whether the pre-restart '
2788
3028
  + 'backup also snapshots the installed plugins (backupIncludePlugins), the '
@@ -2808,7 +3048,33 @@ function registerTools(ctx, runtime, config) {
2808
3048
  pendingRestart: { type: 'boolean' },
2809
3049
  gateAllow: { type: 'boolean' },
2810
3050
  quietForMs: { type: 'number' },
2811
- blockedBy: { type: 'array', items: { type: 'object' } },
3051
+ graceMs: { type: 'number' },
3052
+ effectiveSettleMs: { type: 'number' },
3053
+ blockedBy: {
3054
+ type: 'array',
3055
+ items: {
3056
+ type: 'object',
3057
+ // Every blocker says WHAT it is (`session` or `subagent`) and
3058
+ // HOW it is called (`label`: the title of the session, or of the
3059
+ // session that created the subagent).
3060
+ //
3061
+ // The title fields are NULLABLE: a session whose title could not
3062
+ // be resolved reports `null`, and a strict schema would reject
3063
+ // the whole tool output for it. The tool schema has no type
3064
+ // arrays, so "string or null" is declared as a `oneOf`.
3065
+ properties: {
3066
+ sessionId: { type: 'string' },
3067
+ reason: { type: 'string' },
3068
+ busyReason: { type: 'string' },
3069
+ kind: { type: 'string', enum: ['session', 'subagent', 'restart-pending'] },
3070
+ label: { oneOf: [{ type: 'string' }, { type: 'null' }] },
3071
+ name: { type: 'string' },
3072
+ parentTitle: { oneOf: [{ type: 'string' }, { type: 'null' }] },
3073
+ parentSessionId: { oneOf: [{ type: 'string' }, { type: 'null' }] },
3074
+ title: { oneOf: [{ type: 'string' }, { type: 'null' }] },
3075
+ },
3076
+ },
3077
+ },
2812
3078
  parkedGoals: { type: 'array', items: { type: 'object' } },
2813
3079
  recovery: { type: 'array', items: { type: 'object' } },
2814
3080
  handoffs: { type: 'array', items: { type: 'object' } },
@@ -2835,6 +3101,10 @@ function registerTools(ctx, runtime, config) {
2835
3101
  pendingRestart: state.restart !== null,
2836
3102
  gateAllow: verdict.allow,
2837
3103
  quietForMs: Math.round(verdict.quietForMs),
3104
+ // The grace window (the extra wait after the last conversation
3105
+ // finished) and the quiet window the gate really uses.
3106
+ graceMs: runtime.graceMs(),
3107
+ effectiveSettleMs: runtime.effectiveSettleMs(),
2838
3108
  blockedBy: blockingDetails(verdict.blockedBy),
2839
3109
  parkedGoals: parkedGoalDetails(),
2840
3110
  recovery: recoveryList(state),
@@ -3682,7 +3952,8 @@ function buildChoiceRoute(ctx, runtime, config) {
3682
3952
  const hasBackup = Object.prototype.hasOwnProperty.call(body, 'backupOnRestart')
3683
3953
  const hasShow = Object.prototype.hasOwnProperty.call(body, 'showSleepButton')
3684
3954
  const hasSeconds = Object.prototype.hasOwnProperty.call(body, 'sleepCountdownSeconds')
3685
- if (!hasInclude && !hasBackupInclude && !hasBackup && !hasShow && !hasSeconds) {
3955
+ const hasGrace = Object.prototype.hasOwnProperty.call(body, 'restartGraceMs')
3956
+ if (!hasInclude && !hasBackupInclude && !hasBackup && !hasShow && !hasSeconds && !hasGrace) {
3686
3957
  // No key was asked for: there is nothing to persist.
3687
3958
  res.statusCode = 400
3688
3959
  res.setHeader('content-type', 'application/json; charset=utf-8')
@@ -3690,7 +3961,7 @@ function buildChoiceRoute(ctx, runtime, config) {
3690
3961
  res.end(JSON.stringify({
3691
3962
  ok: false,
3692
3963
  error: 'no-choice',
3693
- 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 }.',
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 }.',
3694
3965
  }))
3695
3966
  return
3696
3967
  }
@@ -3751,6 +4022,17 @@ function buildChoiceRoute(ctx, runtime, config) {
3751
4022
  }))
3752
4023
  return
3753
4024
  }
4025
+ if (hasGrace && GRACE_CHOICES_MS.indexOf(body.restartGraceMs) === -1) {
4026
+ res.statusCode = 400
4027
+ res.setHeader('content-type', 'application/json; charset=utf-8')
4028
+ res.setHeader('cache-control', 'no-store')
4029
+ res.end(JSON.stringify({
4030
+ ok: false,
4031
+ error: 'restartGraceMs-must-be-offered',
4032
+ instruction: `Send { "restartGraceMs": ${GRACE_CHOICES_MS.join(' | ')} }.`,
4033
+ }))
4034
+ return
4035
+ }
3754
4036
  const changes = {}
3755
4037
  // The route field is `includePlugins`, the configuration key is
3756
4038
  // `backupIncludePlugins` (the profile row keeps the plugin's own name).
@@ -3759,6 +4041,7 @@ function buildChoiceRoute(ctx, runtime, config) {
3759
4041
  if (hasBackup) changes.backupOnRestart = body.backupOnRestart === true
3760
4042
  if (hasShow) changes.showSleepButton = body.showSleepButton === true
3761
4043
  if (hasSeconds) changes.sleepCountdownSeconds = body.sleepCountdownSeconds
4044
+ if (hasGrace) changes.restartGraceMs = body.restartGraceMs
3762
4045
  const result = await runtime.applyConfigChoice(changes)
3763
4046
  // The one-time install prompt explained the features to the user, so
3764
4047
  // the announcement of a fresh installation must not follow it.
@@ -3779,6 +4062,7 @@ function buildChoiceRoute(ctx, runtime, config) {
3779
4062
  if (hasBackup) answer.backupOnRestart = body.backupOnRestart === true
3780
4063
  if (hasShow) answer.showSleepButton = body.showSleepButton === true
3781
4064
  if (hasSeconds) answer.sleepCountdownSeconds = body.sleepCountdownSeconds
4065
+ if (hasGrace) answer.restartGraceMs = body.restartGraceMs
3782
4066
  res.statusCode = 200
3783
4067
  res.setHeader('content-type', 'application/json; charset=utf-8')
3784
4068
  res.setHeader('cache-control', 'no-store')
@@ -3816,13 +4100,39 @@ function sleepDryRun() {
3816
4100
  * happened). The sleep feature counts down on screen itself, so it only needs
3817
4101
  * the busy check — and it keeps `settleMs` out of the picture entirely.
3818
4102
  *
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.
4107
+ *
3819
4108
  * The restart gate itself is untouched: the restart feature keeps its quiet
3820
- * window exactly as it was.
4109
+ * window exactly as it was (plus the same grace).
3821
4110
  *
3822
4111
  * @param {object} runtime
3823
- * @returns {{ allow: boolean, reason: 'busy'|'quiet', blockedBy: any[] }}
4112
+ * @returns {{ allow: boolean, reason: 'busy'|'grace'|'quiet', blockedBy: any[] }}
3824
4113
  */
3825
4114
  function sleepGate(runtime) {
4115
+ // A pending restart goes FIRST: the shutdown waits for it — and for the
4116
+ // sessions that resume with it. The intent survives the restart, so the
4117
+ // machine goes off after everything is done, not before.
4118
+ let pending = false
4119
+ try {
4120
+ pending = typeof runtime.pendingRestart === 'function' && runtime.pendingRestart() === true
4121
+ } catch {
4122
+ pending = false
4123
+ }
4124
+ if (pending) {
4125
+ return {
4126
+ allow: false,
4127
+ reason: 'restart-pending',
4128
+ blockedBy: [{
4129
+ sessionId: 'restart-pending',
4130
+ kind: 'restart-pending',
4131
+ label: '待执行的重启',
4132
+ name: '待执行的重启',
4133
+ }],
4134
+ }
4135
+ }
3826
4136
  let busy = []
3827
4137
  try {
3828
4138
  busy = runtime.busyAgents()
@@ -3831,6 +4141,16 @@ function sleepGate(runtime) {
3831
4141
  }
3832
4142
  const list = Array.isArray(busy) ? busy : []
3833
4143
  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
+ }
3834
4154
  return { allow: true, reason: 'quiet', blockedBy: [] }
3835
4155
  }
3836
4156
 
@@ -3912,6 +4232,36 @@ function createSleepService(ctx, runtime, config, options = {}) {
3912
4232
  busyAgents: () => runtime.busyAgents(),
3913
4233
  gate,
3914
4234
  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(),
4240
+ // A pending restart goes first: the shutdown waits for it.
4241
+ restartPending: () => {
4242
+ const pending = runtime.pendingRestart()
4243
+ if (!pending) return { pending: false, blockers: [] }
4244
+ return {
4245
+ pending: true,
4246
+ blockers: [{
4247
+ sessionId: 'restart-pending',
4248
+ kind: 'restart-pending',
4249
+ label: '待执行的重启',
4250
+ name: '待执行的重启',
4251
+ }],
4252
+ }
4253
+ },
4254
+ // The intent is spent when the machine went off, when the shutdown failed
4255
+ // and when the user cancelled it: the checkpoint must not keep it.
4256
+ onDisarm: (reason) => {
4257
+ try {
4258
+ clearSleep(runtime.state)
4259
+ runtime.persist()
4260
+ runtime.log.info('the shutdown intent was cleared:', reason)
4261
+ } catch (error) {
4262
+ runtime.log.warn('could not clear the shutdown intent:', String(error))
4263
+ }
4264
+ },
3915
4265
  countdownSeconds: seconds,
3916
4266
  hostLabel: final.shortLabel,
3917
4267
  supported: final.supported,
@@ -3960,6 +4310,46 @@ function createSleepService(ctx, runtime, config, options = {}) {
3960
4310
  return liveController
3961
4311
  },
3962
4312
  invocation,
4313
+ /**
4314
+ * Arm the shutdown AND remember the intent.
4315
+ *
4316
+ * The intent lives in the checkpoint file, so it survives a restart: a
4317
+ * shutdown asked for while a restart is pending waits for that restart —
4318
+ * and for the sessions that resume with it.
4319
+ *
4320
+ * @returns {object} the sleep status
4321
+ */
4322
+ arm: () => {
4323
+ try {
4324
+ armSleep(runtime.state, {
4325
+ armedAt: Date.now(),
4326
+ countdownSeconds: config.sleepCountdownSeconds,
4327
+ })
4328
+ runtime.persist()
4329
+ } catch (error) {
4330
+ runtime.log.warn('could not record the shutdown intent:', String(error))
4331
+ }
4332
+ return liveController.arm()
4333
+ },
4334
+ /**
4335
+ * Cancel the shutdown: the intent is gone for good (the disarm hook of
4336
+ * the controller clears the checkpoint).
4337
+ *
4338
+ * @returns {object} the sleep status
4339
+ */
4340
+ cancel: () => liveController.cancel(),
4341
+ /**
4342
+ * The recorded shutdown intent, if there is one.
4343
+ *
4344
+ * @returns {{ armed: true, armedAt: number, countdownSeconds: number|null }|null}
4345
+ */
4346
+ intent: () => runtime.state.sleep,
4347
+ /**
4348
+ * Arm the shutdown from a recorded intent (the boot restore).
4349
+ *
4350
+ * @returns {object} the sleep status
4351
+ */
4352
+ restore: () => liveController.arm(),
3963
4353
  }
3964
4354
  }
3965
4355
 
@@ -3968,17 +4358,26 @@ function createSleepService(ctx, runtime, config, options = {}) {
3968
4358
  *
3969
4359
  * @param {{ plan: object, controller: object, invocation: () => object }} sleep
3970
4360
  * @param {object} config
4361
+ * @param {object} [runtime] the runtime (for the quiet window)
3971
4362
  * @returns {object}
3972
4363
  */
3973
- function sleepStatusPayload(sleep, config) {
4364
+ function sleepStatusPayload(sleep, config, runtime) {
3974
4365
  const status = sleep.controller.status()
3975
4366
  const invocation = sleep.invocation()
4367
+ const intent = typeof sleep.intent === 'function' ? sleep.intent() : null
3976
4368
  return {
3977
4369
  ok: true,
3978
4370
  phase: status.phase,
3979
4371
  armed: status.armed,
3980
4372
  showSleepButton: config.showSleepButton !== false,
3981
4373
  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,
4377
+ quietForMs: runtime && typeof runtime.quietForMs === 'function' ? runtime.quietForMs() : 0,
4378
+ // When the shutdown was armed: the intent has to survive a restart, so the
4379
+ // time is reported for the troubleshooting.
4380
+ armedAt: intent && typeof intent.armedAt === 'number' ? intent.armedAt : null,
3982
4381
  /**
3983
4382
  * The pre-restart backup scope, straight from the effective configuration:
3984
4383
  * the settings page reads its two switches from here.
@@ -4040,7 +4439,7 @@ function buildSleepRoute(ctx, runtime, config, sleep) {
4040
4439
  }
4041
4440
  const method = req && typeof req.method === 'string' ? req.method.toUpperCase() : 'GET'
4042
4441
  if (method === 'GET') {
4043
- answer(sleepStatusPayload(sleep, config))
4442
+ answer(sleepStatusPayload(sleep, config, runtime))
4044
4443
  return
4045
4444
  }
4046
4445
  if (method !== 'POST') {
@@ -4062,11 +4461,12 @@ function buildSleepRoute(ctx, runtime, config, sleep) {
4062
4461
  return
4063
4462
  }
4064
4463
  if (action === 'arm') {
4065
- sleep.controller.arm()
4464
+ // The intent is recorded too: it survives a restart.
4465
+ sleep.arm()
4066
4466
  } else {
4067
- sleep.controller.cancel()
4467
+ sleep.cancel()
4068
4468
  }
4069
- answer(sleepStatusPayload(sleep, config))
4469
+ answer(sleepStatusPayload(sleep, config, runtime))
4070
4470
  }).catch((error) => {
4071
4471
  answer({ ok: false, error: describeError(error) }, 400)
4072
4472
  })
@@ -4115,6 +4515,11 @@ function registerHealthRoute(ctx, runtime, config, sleep) {
4115
4515
  generation: state.generation,
4116
4516
  pendingRestart: state.restart !== null,
4117
4517
  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".
4520
+ graceMs: config.restartGraceMs,
4521
+ effectiveSettleMs: runtime.effectiveSettleMs(),
4522
+ quietForMs: typeof verdict.quietForMs === 'number' ? verdict.quietForMs : 0,
4118
4523
  busyCount: verdict.blockedBy.length,
4119
4524
  agentCount,
4120
4525
  })
@@ -4178,6 +4583,9 @@ function registerHealthRoute(ctx, runtime, config, sleep) {
4178
4583
  allowRestart: verdict.allow === true,
4179
4584
  quietForMs: typeof verdict.quietForMs === 'number' ? verdict.quietForMs : 0,
4180
4585
  settleMs: config.settleMs,
4586
+ // The grace window and the quiet window the gate really uses.
4587
+ graceMs: config.restartGraceMs,
4588
+ effectiveSettleMs: runtime.effectiveSettleMs(),
4181
4589
  updatedAt: live.updatedAt,
4182
4590
  // The park route, so the client never has to guess the prefix, and
4183
4591
  // the goals this plugin already disarmed (they are re-armed after
@@ -4508,12 +4916,31 @@ export function apply(ctx, rawConfig, options = {}) {
4508
4916
  // The boot recovery pass is the plugin's primary boot duty, so it is scheduled
4509
4917
  // BEFORE the auxiliary timers (the health-route registration retry below): a
4510
4918
  // fallback registered later must never shadow or postpone the recovery pass.
4919
+ //
4920
+ // The recorded shutdown intent is restored AFTER the recovery: a shutdown
4921
+ // that was asked for before the restart must wait for the work that resumes
4922
+ // with it — the machine goes off after everything is done, not before.
4923
+ const restoreSleepIntent = () => {
4924
+ const intent = sleep.intent()
4925
+ if (!intent || intent.armed !== true) return
4926
+ log.info('the shutdown intent survived the restart; the machine goes off after the work is done', {
4927
+ armedAt: intent.armedAt,
4928
+ })
4929
+ sleep.restore()
4930
+ }
4511
4931
  if (runtime.isNewBoot && config.resumeOnBoot) {
4512
4932
  runtime.recoverTimer = ctx.timeout(() => {
4513
- recoverAfterRestart(ctx, runtime, config).catch((error) => {
4514
- log.warn('boot recovery failed:', String(error))
4515
- })
4933
+ recoverAfterRestart(ctx, runtime, config)
4934
+ .then(() => { restoreSleepIntent() })
4935
+ .catch((error) => {
4936
+ log.warn('boot recovery failed:', String(error))
4937
+ restoreSleepIntent()
4938
+ })
4516
4939
  }, Math.max(0, config.recoveryDelayMs))
4940
+ } else {
4941
+ // No recovery pass: the intent is restored right away (a hot reload must
4942
+ // not lose it either).
4943
+ restoreSleepIntent()
4517
4944
  }
4518
4945
 
4519
4946
  registerTools(ctx, runtime, config)