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/README.md +108 -5
- package/client.js +158 -20
- package/cordis.patch.yml +8 -0
- package/docs/README.zh.md +63 -6
- package/index.js +454 -27
- package/lib/checkpoint.js +78 -0
- package/lib/sleep.js +217 -33
- package/locale/en.json +8 -1
- package/locale/zh.json +8 -1
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
1162
|
-
|
|
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
|
-
/**
|
|
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:
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4464
|
+
// The intent is recorded too: it survives a restart.
|
|
4465
|
+
sleep.arm()
|
|
4066
4466
|
} else {
|
|
4067
|
-
sleep.
|
|
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)
|
|
4514
|
-
|
|
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)
|