dsh-autorestart 0.5.0 → 0.6.1
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 +129 -9
- package/client.js +158 -20
- package/cordis.patch.yml +8 -0
- package/docs/README.zh.md +69 -8
- package/index.js +528 -41
- package/lib/checkpoint.js +78 -0
- package/lib/semver.js +49 -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,
|
|
@@ -73,6 +75,7 @@ import {
|
|
|
73
75
|
runCommandNow,
|
|
74
76
|
scheduleDetachedRestart,
|
|
75
77
|
} from './lib/restart.js'
|
|
78
|
+
import { compareSemver } from './lib/semver.js'
|
|
76
79
|
import {
|
|
77
80
|
createSleepController,
|
|
78
81
|
COUNTDOWN_CHOICES,
|
|
@@ -83,6 +86,47 @@ import {
|
|
|
83
86
|
snapCountdownChoice,
|
|
84
87
|
} from './lib/sleep.js'
|
|
85
88
|
|
|
89
|
+
/**
|
|
90
|
+
* The grace window: how long the host waits after the LAST conversation
|
|
91
|
+
* finished before it is really restarted or switched off.
|
|
92
|
+
*
|
|
93
|
+
* This plugin can only see the DSH agents. Other plugins (a memory plugin
|
|
94
|
+
* that writes its notes a few seconds after the conversation ended, a summary,
|
|
95
|
+
* an MCP callback) queue their work with a timer of their own — work that is
|
|
96
|
+
* invisible here. A short, configurable grace window is left for it.
|
|
97
|
+
*
|
|
98
|
+
* The values are the ones the settings page offers (in milliseconds).
|
|
99
|
+
*/
|
|
100
|
+
const GRACE_CHOICES_MS = Object.freeze([0, 3000, 5000, 10000, 20000, 30000])
|
|
101
|
+
|
|
102
|
+
/** The grace window that is used when nothing was configured. */
|
|
103
|
+
const DEFAULT_GRACE_MS = 10000
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Snap a grace value onto the nearest offered choice.
|
|
107
|
+
*
|
|
108
|
+
* A profile that carries a value that is not offered (a hand-edited row, an
|
|
109
|
+
* older build) must not break the boot: the value is snapped onto the
|
|
110
|
+
* nearest choice, exactly like the countdown length is.
|
|
111
|
+
*
|
|
112
|
+
* @param {unknown} value
|
|
113
|
+
* @returns {number} one of {@link GRACE_CHOICES_MS}
|
|
114
|
+
*/
|
|
115
|
+
function snapGraceChoice(value) {
|
|
116
|
+
const raw = Number(value)
|
|
117
|
+
if (!Number.isFinite(raw) || raw < 0) return DEFAULT_GRACE_MS
|
|
118
|
+
let best = GRACE_CHOICES_MS[0]
|
|
119
|
+
let bestDistance = Math.abs(raw - best)
|
|
120
|
+
for (const choice of GRACE_CHOICES_MS) {
|
|
121
|
+
const distance = Math.abs(raw - choice)
|
|
122
|
+
if (distance < bestDistance) {
|
|
123
|
+
best = choice
|
|
124
|
+
bestDistance = distance
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return best
|
|
128
|
+
}
|
|
129
|
+
|
|
86
130
|
/**
|
|
87
131
|
* The version of this plugin, as a fallback.
|
|
88
132
|
*
|
|
@@ -90,17 +134,36 @@ import {
|
|
|
90
134
|
* `pluginVersion()`), but a bundled installation may not ship that file — so
|
|
91
135
|
* the constant below is the last resort. A test keeps the two in sync.
|
|
92
136
|
*/
|
|
93
|
-
const PLUGIN_VERSION_FALLBACK = '0.
|
|
137
|
+
const PLUGIN_VERSION_FALLBACK = '0.6.1'
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The version of the graceful-shutdown FEATURE, not the version of the package.
|
|
141
|
+
*
|
|
142
|
+
* The one-time announcement is bound to this constant. It used to follow the
|
|
143
|
+
* version in package.json, and that made every release resurrect the card the
|
|
144
|
+
* user had already answered (0.5.0 -> 0.6.1 brought "收工关机" back). A release
|
|
145
|
+
* is not news about the feature, so the two are decoupled.
|
|
146
|
+
*
|
|
147
|
+
* WHEN TO RAISE IT: only when the feature itself changed so much that the
|
|
148
|
+
* user has to hear about it again (the shutdown became a completely different
|
|
149
|
+
* thing, the button moved to another place, ...). Any new value works: the
|
|
150
|
+
* recorded version no longer matches, so the announcement is shown once more.
|
|
151
|
+
*
|
|
152
|
+
* WHEN NOT TO RAISE IT: a release, a bug fix, a small addition, a new option
|
|
153
|
+
* that the documentation explains. None of those deserve a card that
|
|
154
|
+
* interrupts the user a second time.
|
|
155
|
+
*/
|
|
156
|
+
const SLEEP_FEATURE_VERSION = '0.5.0'
|
|
94
157
|
|
|
95
158
|
/** The cached version of this plugin (read once per process). */
|
|
96
159
|
let pluginVersionCache = null
|
|
97
160
|
|
|
98
161
|
/**
|
|
99
|
-
* The version of this plugin.
|
|
162
|
+
* The version of this plugin (the PACKAGE version).
|
|
100
163
|
*
|
|
101
|
-
* It is
|
|
102
|
-
*
|
|
103
|
-
*
|
|
164
|
+
* It says which release is installed - not what the user has been told about.
|
|
165
|
+
* The feature announcements are bound to `SLEEP_FEATURE_VERSION`, so a release
|
|
166
|
+
* never brings an answered announcement back.
|
|
104
167
|
*
|
|
105
168
|
* @returns {string}
|
|
106
169
|
*/
|
|
@@ -126,19 +189,51 @@ function pluginVersion() {
|
|
|
126
189
|
*
|
|
127
190
|
* The install prompt only ever reaches a fresh installation, so an
|
|
128
191
|
* installation that already existed would never learn about a new feature.
|
|
129
|
-
* Every entry of this list is therefore announced once per version:
|
|
130
|
-
* announcement is shown until the user presses "知道了", and the Host
|
|
131
|
-
* the version it has shown, so it never comes back.
|
|
192
|
+
* Every entry of this list is therefore announced once per FEATURE version:
|
|
193
|
+
* the announcement is shown until the user presses "知道了", and the Host
|
|
194
|
+
* records the feature version it has shown, so it never comes back.
|
|
195
|
+
*
|
|
196
|
+
* The version of an entry is the version of the FEATURE, never the version of
|
|
197
|
+
* the package - see `SLEEP_FEATURE_VERSION`.
|
|
132
198
|
*
|
|
133
199
|
* @returns {{ feature: string, version: string }[]}
|
|
134
200
|
*/
|
|
135
201
|
function featureAnnouncements() {
|
|
136
202
|
return [
|
|
137
203
|
// The graceful shutdown ("收工关机") and its moon button.
|
|
138
|
-
{ feature: 'sleep', version:
|
|
204
|
+
{ feature: 'sleep', version: SLEEP_FEATURE_VERSION },
|
|
139
205
|
]
|
|
140
206
|
}
|
|
141
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Whether the user has already seen the announcement of one feature.
|
|
210
|
+
*
|
|
211
|
+
* The record is the version that was announced when the user answered. It
|
|
212
|
+
* counts as seen as soon as it is NOT OLDER than the current feature version
|
|
213
|
+
* - not only when it is exactly that version:
|
|
214
|
+
*
|
|
215
|
+
* - the announcement used to carry the version of the PACKAGE, so a user
|
|
216
|
+
* who answered it on the release 0.6.1 recorded "0.6.1" while the feature
|
|
217
|
+
* itself stands at "0.5.0". With an equality check that record would
|
|
218
|
+
* look stale, and the very same card would be shown to a user who already
|
|
219
|
+
* dismissed it;
|
|
220
|
+
* - a record that is not a version at all counts as seen as well: a broken
|
|
221
|
+
* record must not make the card come back for ever.
|
|
222
|
+
*
|
|
223
|
+
* A missing record counts as NOT seen: a missing record must never swallow an
|
|
224
|
+
* announcement.
|
|
225
|
+
*
|
|
226
|
+
* @param {unknown} recorded the version in the state file
|
|
227
|
+
* @param {string} current the current feature version
|
|
228
|
+
* @returns {boolean} true when the user has seen it
|
|
229
|
+
*/
|
|
230
|
+
function isFeatureSeen(recorded, current) {
|
|
231
|
+
if (typeof recorded !== 'string' || recorded.trim() === '') return false
|
|
232
|
+
const order = compareSemver(recorded, current)
|
|
233
|
+
if (order === null) return true
|
|
234
|
+
return order >= 0
|
|
235
|
+
}
|
|
236
|
+
|
|
142
237
|
/**
|
|
143
238
|
* `@deepseek-ai/schemastery` resolves inside modules that live in the dsh installation,
|
|
144
239
|
* but NOT from this bundle: a profile-installed bundle is a symlink into the user's
|
|
@@ -281,6 +376,12 @@ function buildConfigSchema() {
|
|
|
281
376
|
// quiet window that must pass after the last conversation activity
|
|
282
377
|
settleMs: schemastery.number().min(0).default(2000)
|
|
283
378
|
.description('Milliseconds the host must stay quiet after the last conversation activity before a restart may fire.'),
|
|
379
|
+
// The grace window: the extra wait after the last conversation finished.
|
|
380
|
+
// Other plugins queue work with timers of their own (a memory plugin
|
|
381
|
+
// writing its notes, a summary, an MCP callback) and that work is
|
|
382
|
+
// invisible here — so a short, configurable grace window is left for it.
|
|
383
|
+
restartGraceMs: schemastery.number().min(0).default(DEFAULT_GRACE_MS)
|
|
384
|
+
.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
385
|
autoReloadPage: schemastery.boolean().default(true)
|
|
285
386
|
.description('Reload the web page automatically when the host boot token changes.'),
|
|
286
387
|
blockManualRestart: schemastery.boolean().default(true)
|
|
@@ -347,6 +448,7 @@ const DEFAULTS = {
|
|
|
347
448
|
restartCommand: '',
|
|
348
449
|
relaunchDelaySeconds: 2,
|
|
349
450
|
settleMs: 2000,
|
|
451
|
+
restartGraceMs: DEFAULT_GRACE_MS,
|
|
350
452
|
autoReloadPage: true,
|
|
351
453
|
blockManualRestart: true,
|
|
352
454
|
healthPath: '/__dsh_autorestart/health',
|
|
@@ -393,6 +495,9 @@ function normalizeConfig(raw) {
|
|
|
393
495
|
// profile that still carries an old value (15, 45, …) is snapped onto the
|
|
394
496
|
// nearest one instead of breaking, and 0 means "no countdown at all".
|
|
395
497
|
config.sleepCountdownSeconds = snapCountdownChoice(config.sleepCountdownSeconds)
|
|
498
|
+
// The grace window is one of the offered choices as well: a hand-edited
|
|
499
|
+
// profile value is snapped onto the nearest one instead of breaking.
|
|
500
|
+
config.restartGraceMs = snapGraceChoice(config.restartGraceMs)
|
|
396
501
|
if (typeof config.sleepCommand !== 'string') config.sleepCommand = ''
|
|
397
502
|
if (typeof config.showSleepButton !== 'boolean') config.showSleepButton = true
|
|
398
503
|
return config
|
|
@@ -673,7 +778,7 @@ const INSTALL_CHOICE_FILE = 'install-choice.json'
|
|
|
673
778
|
* Read the recorded install choice from the fallback file.
|
|
674
779
|
*
|
|
675
780
|
* @param {string} stateDir
|
|
676
|
-
* @returns {{ includePlugins: boolean|null, backupOnRestart: boolean|null, showSleepButton: boolean|null, sleepCountdownSeconds: number|null }|null}
|
|
781
|
+
* @returns {{ includePlugins: boolean|null, backupOnRestart: boolean|null, showSleepButton: boolean|null, sleepCountdownSeconds: number|null, restartGraceMs: number|null }|null}
|
|
677
782
|
*/
|
|
678
783
|
function readInstallChoice(stateDir) {
|
|
679
784
|
try {
|
|
@@ -687,6 +792,7 @@ function readInstallChoice(stateDir) {
|
|
|
687
792
|
sleepCountdownSeconds: typeof parsed.sleepCountdownSeconds === 'number'
|
|
688
793
|
? parsed.sleepCountdownSeconds
|
|
689
794
|
: null,
|
|
795
|
+
restartGraceMs: typeof parsed.restartGraceMs === 'number' ? parsed.restartGraceMs : null,
|
|
690
796
|
}
|
|
691
797
|
} catch {
|
|
692
798
|
return null
|
|
@@ -700,7 +806,7 @@ function readInstallChoice(stateDir) {
|
|
|
700
806
|
* erase the recorded backup scope (and the other way round).
|
|
701
807
|
*
|
|
702
808
|
* @param {string} stateDir
|
|
703
|
-
* @param {{ includePlugins?: boolean, backupOnRestart?: boolean, showSleepButton?: boolean, sleepCountdownSeconds?: number }} values
|
|
809
|
+
* @param {{ includePlugins?: boolean, backupOnRestart?: boolean, showSleepButton?: boolean, sleepCountdownSeconds?: number, restartGraceMs?: number }} values
|
|
704
810
|
* @returns {string} the file that was written
|
|
705
811
|
*/
|
|
706
812
|
function writeInstallChoice(stateDir, values) {
|
|
@@ -710,6 +816,7 @@ function writeInstallChoice(stateDir, values) {
|
|
|
710
816
|
backupOnRestart: null,
|
|
711
817
|
showSleepButton: null,
|
|
712
818
|
sleepCountdownSeconds: null,
|
|
819
|
+
restartGraceMs: null,
|
|
713
820
|
}
|
|
714
821
|
const payload = {
|
|
715
822
|
includePlugins: typeof values.includePlugins === 'boolean' ? values.includePlugins : current.includePlugins,
|
|
@@ -718,6 +825,9 @@ function writeInstallChoice(stateDir, values) {
|
|
|
718
825
|
sleepCountdownSeconds: typeof values.sleepCountdownSeconds === 'number'
|
|
719
826
|
? values.sleepCountdownSeconds
|
|
720
827
|
: current.sleepCountdownSeconds,
|
|
828
|
+
restartGraceMs: typeof values.restartGraceMs === 'number'
|
|
829
|
+
? values.restartGraceMs
|
|
830
|
+
: current.restartGraceMs,
|
|
721
831
|
at: new Date().toISOString(),
|
|
722
832
|
}
|
|
723
833
|
fs.mkdirSync(stateDir, { recursive: true })
|
|
@@ -1143,9 +1253,89 @@ export function createRuntime(ctx, config, options = {}) {
|
|
|
1143
1253
|
return count
|
|
1144
1254
|
}
|
|
1145
1255
|
|
|
1256
|
+
/**
|
|
1257
|
+
* Whether this agent is a subagent (a delegated child), and which session
|
|
1258
|
+
* created it.
|
|
1259
|
+
*
|
|
1260
|
+
* The origin is the ONLY reliable marker: a forked session also carries a
|
|
1261
|
+
* parent session, but it is a top-level conversation, not a subagent.
|
|
1262
|
+
*
|
|
1263
|
+
* @param {any} agent
|
|
1264
|
+
* @returns {string|null} the parent session id, or null when this is not a
|
|
1265
|
+
* subagent
|
|
1266
|
+
*/
|
|
1267
|
+
const subagentParentOf = (agent) => {
|
|
1268
|
+
try {
|
|
1269
|
+
if (!agent || typeof agent !== 'object') return null
|
|
1270
|
+
const session = agent.session && typeof agent.session === 'object' ? agent.session : null
|
|
1271
|
+
const header = session && session.header && typeof session.header === 'object' ? session.header : null
|
|
1272
|
+
const origin = agent.origin != null ? agent.origin : (header ? header.origin : undefined)
|
|
1273
|
+
if (origin !== 'subagent') return null
|
|
1274
|
+
const parent = agent.parentId != null
|
|
1275
|
+
? agent.parentId
|
|
1276
|
+
: agent.parentSessionId != null
|
|
1277
|
+
? agent.parentSessionId
|
|
1278
|
+
: (header ? header.parentSession : undefined)
|
|
1279
|
+
if (parent == null || parent === '') return null
|
|
1280
|
+
return String(parent)
|
|
1281
|
+
} catch {
|
|
1282
|
+
return null
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
/**
|
|
1287
|
+
* The readable identity of one blocker: WHAT it is (a session or a
|
|
1288
|
+
* subagent) and HOW it is called.
|
|
1289
|
+
*
|
|
1290
|
+
* A subagent has no title of its own, so it is named after the session that
|
|
1291
|
+
* created it ("<that session>'s subagent"). A raw session id never reaches
|
|
1292
|
+
* the interface: an unknown name is a generic one.
|
|
1293
|
+
*
|
|
1294
|
+
* @param {string} sessionId
|
|
1295
|
+
* @param {any} agent
|
|
1296
|
+
* @returns {{ kind: 'session'|'subagent', label: string|null, name: string, parentTitle: string|null, parentSessionId: string|null }}
|
|
1297
|
+
*/
|
|
1298
|
+
const describeBlocker = (sessionId, agent) => {
|
|
1299
|
+
const parent = agent ? subagentParentOf(agent) : null
|
|
1300
|
+
if (parent === null) {
|
|
1301
|
+
let title = null
|
|
1302
|
+
try { title = sessionTitle(sessionId) } catch { /* a missing title is not a problem */ }
|
|
1303
|
+
const label = title ? String(title) : null
|
|
1304
|
+
return {
|
|
1305
|
+
kind: 'session',
|
|
1306
|
+
// The label is the title of the session itself.
|
|
1307
|
+
label,
|
|
1308
|
+
// The full name of the blocker: its title — or NOTHING when it has no
|
|
1309
|
+
// title. A generic name ("会话") says nothing and would end up in the
|
|
1310
|
+
// interface as "会话「会话」" or as a list of three identical
|
|
1311
|
+
// entries; the client decides what to write when the name is empty.
|
|
1312
|
+
name: label || '',
|
|
1313
|
+
parentTitle: null,
|
|
1314
|
+
parentSessionId: null,
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
let parentTitle = null
|
|
1318
|
+
try { parentTitle = sessionTitle(parent) } catch { /* a missing title is not a problem */ }
|
|
1319
|
+
const title = parentTitle ? String(parentTitle) : null
|
|
1320
|
+
return {
|
|
1321
|
+
kind: 'subagent',
|
|
1322
|
+
// The label is the title of the session that created it: the UI shows
|
|
1323
|
+
// "子智能体(<that title>)".
|
|
1324
|
+
label: title,
|
|
1325
|
+
// The full name of the blocker: "<that session>'s subagent".
|
|
1326
|
+
name: title ? `${title}的子智能体` : '子智能体',
|
|
1327
|
+
parentTitle: title,
|
|
1328
|
+
parentSessionId: parent,
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1146
1332
|
/**
|
|
1147
1333
|
* Every conversation that is still working, subagents and teammates included.
|
|
1148
1334
|
* 'running' is a live turn; 'queued' is inbox work that has not started yet.
|
|
1335
|
+
*
|
|
1336
|
+
* Every entry carries the kind of the blocker and a readable name, so the
|
|
1337
|
+
* interface can tell a session from a subagent and never has to show a raw
|
|
1338
|
+
* session id.
|
|
1149
1339
|
*/
|
|
1150
1340
|
const busyAgents = () => {
|
|
1151
1341
|
const out = []
|
|
@@ -1158,8 +1348,10 @@ export function createRuntime(ctx, config, options = {}) {
|
|
|
1158
1348
|
for (const agent of agents) {
|
|
1159
1349
|
try {
|
|
1160
1350
|
if (!agent || !agent.id) continue
|
|
1161
|
-
|
|
1162
|
-
|
|
1351
|
+
const sessionId = String(agent.id)
|
|
1352
|
+
const described = describeBlocker(sessionId, agent)
|
|
1353
|
+
if (agent.status === 'running') out.push({ sessionId, reason: 'running', ...described })
|
|
1354
|
+
else if (pendingInboxCount(agent) > 0) out.push({ sessionId, reason: 'queued', ...described })
|
|
1163
1355
|
} catch {}
|
|
1164
1356
|
}
|
|
1165
1357
|
return out
|
|
@@ -1492,12 +1684,41 @@ export function createRuntime(ctx, config, options = {}) {
|
|
|
1492
1684
|
|
|
1493
1685
|
const anyAgentRunning = () => busyAgents().length > 0
|
|
1494
1686
|
|
|
1495
|
-
/**
|
|
1687
|
+
/**
|
|
1688
|
+
* Whether a restart is still pending.
|
|
1689
|
+
*
|
|
1690
|
+
* A pending restart goes FIRST: the graceful shutdown waits for it — and for
|
|
1691
|
+
* the sessions that resume with it. The shutdown intent survives the
|
|
1692
|
+
* restart, so the machine goes off after everything is done.
|
|
1693
|
+
*
|
|
1694
|
+
* @returns {boolean}
|
|
1695
|
+
*/
|
|
1696
|
+
const pendingRestart = () => state.restart !== null && state.restart !== undefined
|
|
1697
|
+
|
|
1698
|
+
/** How long the host has been quiet (no conversation activity at all). */
|
|
1699
|
+
const quietForMs = () => Math.max(0, Date.now() - lastActivityAt)
|
|
1700
|
+
|
|
1701
|
+
/**
|
|
1702
|
+
* The grace window: how much longer the host waits after the last
|
|
1703
|
+
* conversation finished, so that work other plugins queue with a timer (a
|
|
1704
|
+
* memory plugin writing its notes, a summary) still gets its chance.
|
|
1705
|
+
*/
|
|
1706
|
+
const graceMs = () => config.restartGraceMs
|
|
1707
|
+
|
|
1708
|
+
/**
|
|
1709
|
+
* The quiet window the restart gate really uses.
|
|
1710
|
+
*
|
|
1711
|
+
* It is the settle window, but never shorter than the grace window: the
|
|
1712
|
+
* grace is the *extra* wait on top of the settle, not a replacement.
|
|
1713
|
+
*/
|
|
1714
|
+
const effectiveSettleMs = () => Math.max(config.settleMs, config.restartGraceMs)
|
|
1715
|
+
|
|
1716
|
+
/** The two-layer restart gate: no busy conversation AND a quiet window. */
|
|
1496
1717
|
const gate = () => restartGate(state, {
|
|
1497
1718
|
busyAgents: busyAgents(),
|
|
1498
1719
|
lastActivityAt,
|
|
1499
1720
|
now: Date.now(),
|
|
1500
|
-
settleMs:
|
|
1721
|
+
settleMs: effectiveSettleMs(),
|
|
1501
1722
|
})
|
|
1502
1723
|
|
|
1503
1724
|
/**
|
|
@@ -1512,7 +1733,43 @@ export function createRuntime(ctx, config, options = {}) {
|
|
|
1512
1733
|
*/
|
|
1513
1734
|
const titleCache = { at: 0, titles: new Map(), inFlight: false }
|
|
1514
1735
|
|
|
1515
|
-
/**
|
|
1736
|
+
/**
|
|
1737
|
+
* The title of one row of the session list.
|
|
1738
|
+
*
|
|
1739
|
+
* The title is a PROJECTION: the summary row carries it under
|
|
1740
|
+
* `projections.values.title` (the same place `dsh-session-reference` reads it
|
|
1741
|
+
* from), not under a `title` field of its own. A plain `title`/`name` is
|
|
1742
|
+
* accepted too, so a list with a different shape still works.
|
|
1743
|
+
*
|
|
1744
|
+
* @param {any} item one row of the session list
|
|
1745
|
+
* @returns {string} the title, or '' when the row carries none
|
|
1746
|
+
*/
|
|
1747
|
+
const titleOfSummary = (item) => {
|
|
1748
|
+
try {
|
|
1749
|
+
if (!item || typeof item !== 'object') return ''
|
|
1750
|
+
const projections = item.projections && typeof item.projections === 'object'
|
|
1751
|
+
? item.projections
|
|
1752
|
+
: null
|
|
1753
|
+
const values = projections && projections.values && typeof projections.values === 'object'
|
|
1754
|
+
? projections.values
|
|
1755
|
+
: null
|
|
1756
|
+
const candidates = [
|
|
1757
|
+
values ? values.title : undefined,
|
|
1758
|
+
item.title,
|
|
1759
|
+
item.name,
|
|
1760
|
+
]
|
|
1761
|
+
for (const candidate of candidates) {
|
|
1762
|
+
if (typeof candidate !== 'string') continue
|
|
1763
|
+
const title = candidate.trim()
|
|
1764
|
+
if (title !== '') return title
|
|
1765
|
+
}
|
|
1766
|
+
return ''
|
|
1767
|
+
} catch {
|
|
1768
|
+
return ''
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
/** Store the titles of one session-list answer. Never throws. */
|
|
1516
1773
|
const applySessionTitles = (payload) => {
|
|
1517
1774
|
const items = payload && Array.isArray(payload.items)
|
|
1518
1775
|
? payload.items
|
|
@@ -1520,9 +1777,13 @@ export function createRuntime(ctx, config, options = {}) {
|
|
|
1520
1777
|
const titles = new Map()
|
|
1521
1778
|
for (const item of items) {
|
|
1522
1779
|
if (!item || typeof item !== 'object') continue
|
|
1523
|
-
|
|
1780
|
+
// The session list names its rows `sessionId` (`id` is an older shape).
|
|
1781
|
+
const rawId = item.sessionId !== undefined && item.sessionId !== null
|
|
1782
|
+
? item.sessionId
|
|
1783
|
+
: item.id
|
|
1784
|
+
const id = rawId === undefined || rawId === null ? '' : String(rawId)
|
|
1524
1785
|
if (!id) continue
|
|
1525
|
-
const title =
|
|
1786
|
+
const title = titleOfSummary(item)
|
|
1526
1787
|
if (title) titles.set(id, title)
|
|
1527
1788
|
}
|
|
1528
1789
|
titleCache.titles = titles
|
|
@@ -1813,7 +2074,7 @@ export function createRuntime(ctx, config, options = {}) {
|
|
|
1813
2074
|
anyAgentRunning: busy.length > 0,
|
|
1814
2075
|
busyAgents: busy,
|
|
1815
2076
|
lastActivityAt,
|
|
1816
|
-
settleMs:
|
|
2077
|
+
settleMs: effectiveSettleMs(),
|
|
1817
2078
|
now,
|
|
1818
2079
|
softMs: config.deferSoftMs,
|
|
1819
2080
|
hardMs: config.deferHardMs,
|
|
@@ -1858,7 +2119,7 @@ export function createRuntime(ctx, config, options = {}) {
|
|
|
1858
2119
|
* is unavailable the choice degrades to a file in the plugin's state
|
|
1859
2120
|
* directory and a warning is logged, so the choice is never silently lost.
|
|
1860
2121
|
*
|
|
1861
|
-
* @param {{ backupOnRestart?: boolean, backupIncludePlugins?: boolean, showSleepButton?: boolean, sleepCountdownSeconds?: number }} changes
|
|
2122
|
+
* @param {{ backupOnRestart?: boolean, backupIncludePlugins?: boolean, showSleepButton?: boolean, sleepCountdownSeconds?: number, restartGraceMs?: number }} changes
|
|
1862
2123
|
* @returns {Promise<{ applied: 'config'|'fallback-file' }>}
|
|
1863
2124
|
*/
|
|
1864
2125
|
const persistConfigChoice = async (changes) => {
|
|
@@ -1883,6 +2144,10 @@ export function createRuntime(ctx, config, options = {}) {
|
|
|
1883
2144
|
wanted.sleepCountdownSeconds = changes.sleepCountdownSeconds
|
|
1884
2145
|
fallback.sleepCountdownSeconds = changes.sleepCountdownSeconds
|
|
1885
2146
|
}
|
|
2147
|
+
if (typeof changes.restartGraceMs === 'number') {
|
|
2148
|
+
wanted.restartGraceMs = changes.restartGraceMs
|
|
2149
|
+
fallback.restartGraceMs = changes.restartGraceMs
|
|
2150
|
+
}
|
|
1886
2151
|
const editor = resolveConfigEditor(ctx)
|
|
1887
2152
|
if (editor !== null) {
|
|
1888
2153
|
try {
|
|
@@ -1919,11 +2184,16 @@ export function createRuntime(ctx, config, options = {}) {
|
|
|
1919
2184
|
/**
|
|
1920
2185
|
* The feature announcements the user has not seen yet.
|
|
1921
2186
|
*
|
|
1922
|
-
* A feature is pending when the recorded version is
|
|
1923
|
-
* user who has seen the announcement of version 0.5.0
|
|
1924
|
-
*
|
|
1925
|
-
*
|
|
1926
|
-
*
|
|
2187
|
+
* A feature is pending when the recorded version is OLDER than the current
|
|
2188
|
+
* one: a user who has seen the announcement of the feature version 0.5.0
|
|
2189
|
+
* never sees it again - not with the next release of the plugin, because
|
|
2190
|
+
* the announcement follows the FEATURE and not the package. A user who
|
|
2191
|
+
* answered the card on an intermediate release (the announcement used to
|
|
2192
|
+
* carry the package version) has seen it as well: a record that is not
|
|
2193
|
+
* older than the current version never brings the card back.
|
|
2194
|
+
*
|
|
2195
|
+
* A feature without a record is always pending - a missing record must
|
|
2196
|
+
* never swallow an announcement.
|
|
1927
2197
|
*
|
|
1928
2198
|
* @returns {{ feature: string, version: string }[]}
|
|
1929
2199
|
*/
|
|
@@ -1931,7 +2201,7 @@ export function createRuntime(ctx, config, options = {}) {
|
|
|
1931
2201
|
const seen = state.seenFeatureVersions && typeof state.seenFeatureVersions === 'object'
|
|
1932
2202
|
? state.seenFeatureVersions
|
|
1933
2203
|
: {}
|
|
1934
|
-
return featureAnnouncements().filter((entry) => seen[entry.feature]
|
|
2204
|
+
return featureAnnouncements().filter((entry) => !isFeatureSeen(seen[entry.feature], entry.version))
|
|
1935
2205
|
}
|
|
1936
2206
|
|
|
1937
2207
|
/**
|
|
@@ -2074,6 +2344,31 @@ export function createRuntime(ctx, config, options = {}) {
|
|
|
2074
2344
|
stopPolling,
|
|
2075
2345
|
tick,
|
|
2076
2346
|
anyAgentRunning,
|
|
2347
|
+
/**
|
|
2348
|
+
* Whether a restart is still pending (the graceful shutdown waits for it).
|
|
2349
|
+
*
|
|
2350
|
+
* @returns {boolean}
|
|
2351
|
+
*/
|
|
2352
|
+
pendingRestart,
|
|
2353
|
+
/**
|
|
2354
|
+
* How long the host has been quiet (no conversation activity at all).
|
|
2355
|
+
*
|
|
2356
|
+
* @returns {number}
|
|
2357
|
+
*/
|
|
2358
|
+
quietForMs,
|
|
2359
|
+
/**
|
|
2360
|
+
* The grace window: the extra wait after the last conversation finished.
|
|
2361
|
+
*
|
|
2362
|
+
* @returns {number} milliseconds
|
|
2363
|
+
*/
|
|
2364
|
+
graceMs,
|
|
2365
|
+
/**
|
|
2366
|
+
* The quiet window the restart gate really uses: the settle window, but
|
|
2367
|
+
* never shorter than the grace window.
|
|
2368
|
+
*
|
|
2369
|
+
* @returns {number} milliseconds
|
|
2370
|
+
*/
|
|
2371
|
+
effectiveSettleMs,
|
|
2077
2372
|
executeRestart,
|
|
2078
2373
|
flushSessions,
|
|
2079
2374
|
releaseRequesters,
|
|
@@ -2782,7 +3077,9 @@ function registerTools(ctx, runtime, config) {
|
|
|
2782
3077
|
'Report the dsh-autorestart lifecycle: whether a restart is pending, which sessions were '
|
|
2783
3078
|
+ 'checkpointed as interrupted or continuation-pending, why each blocking session blocks '
|
|
2784
3079
|
+ '("goal-active" = an armed goal keeps it busy, "goal-exhausted" = its goal can no longer be '
|
|
2785
|
-
+ 'resumed), the goals this plugin disarmed, the
|
|
3080
|
+
+ 'resumed), the goals this plugin disarmed, how long the host has been quiet and how long '
|
|
3081
|
+
+ 'the grace window is (the extra wait after the last conversation finished, for work other '
|
|
3082
|
+
+ 'plugins queue with a timer), the last recovery action/detail per session, '
|
|
2786
3083
|
+ 'whether a session was handed off to a fresh session (target session id and time) or is over '
|
|
2787
3084
|
+ 'the model context window (such a session is never auto-resumed), whether the pre-restart '
|
|
2788
3085
|
+ 'backup also snapshots the installed plugins (backupIncludePlugins), the '
|
|
@@ -2808,7 +3105,33 @@ function registerTools(ctx, runtime, config) {
|
|
|
2808
3105
|
pendingRestart: { type: 'boolean' },
|
|
2809
3106
|
gateAllow: { type: 'boolean' },
|
|
2810
3107
|
quietForMs: { type: 'number' },
|
|
2811
|
-
|
|
3108
|
+
graceMs: { type: 'number' },
|
|
3109
|
+
effectiveSettleMs: { type: 'number' },
|
|
3110
|
+
blockedBy: {
|
|
3111
|
+
type: 'array',
|
|
3112
|
+
items: {
|
|
3113
|
+
type: 'object',
|
|
3114
|
+
// Every blocker says WHAT it is (`session` or `subagent`) and
|
|
3115
|
+
// HOW it is called (`label`: the title of the session, or of the
|
|
3116
|
+
// session that created the subagent).
|
|
3117
|
+
//
|
|
3118
|
+
// The title fields are NULLABLE: a session whose title could not
|
|
3119
|
+
// be resolved reports `null`, and a strict schema would reject
|
|
3120
|
+
// the whole tool output for it. The tool schema has no type
|
|
3121
|
+
// arrays, so "string or null" is declared as a `oneOf`.
|
|
3122
|
+
properties: {
|
|
3123
|
+
sessionId: { type: 'string' },
|
|
3124
|
+
reason: { type: 'string' },
|
|
3125
|
+
busyReason: { type: 'string' },
|
|
3126
|
+
kind: { type: 'string', enum: ['session', 'subagent', 'restart-pending'] },
|
|
3127
|
+
label: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
3128
|
+
name: { type: 'string' },
|
|
3129
|
+
parentTitle: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
3130
|
+
parentSessionId: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
3131
|
+
title: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
3132
|
+
},
|
|
3133
|
+
},
|
|
3134
|
+
},
|
|
2812
3135
|
parkedGoals: { type: 'array', items: { type: 'object' } },
|
|
2813
3136
|
recovery: { type: 'array', items: { type: 'object' } },
|
|
2814
3137
|
handoffs: { type: 'array', items: { type: 'object' } },
|
|
@@ -2835,6 +3158,10 @@ function registerTools(ctx, runtime, config) {
|
|
|
2835
3158
|
pendingRestart: state.restart !== null,
|
|
2836
3159
|
gateAllow: verdict.allow,
|
|
2837
3160
|
quietForMs: Math.round(verdict.quietForMs),
|
|
3161
|
+
// The grace window (the extra wait after the last conversation
|
|
3162
|
+
// finished) and the quiet window the gate really uses.
|
|
3163
|
+
graceMs: runtime.graceMs(),
|
|
3164
|
+
effectiveSettleMs: runtime.effectiveSettleMs(),
|
|
2838
3165
|
blockedBy: blockingDetails(verdict.blockedBy),
|
|
2839
3166
|
parkedGoals: parkedGoalDetails(),
|
|
2840
3167
|
recovery: recoveryList(state),
|
|
@@ -3564,7 +3891,8 @@ function buildHandoffRoute(ctx, runtime, config) {
|
|
|
3564
3891
|
*
|
|
3565
3892
|
* The record lives in the checkpoint file, so an announcement that was shown
|
|
3566
3893
|
* once never comes back — not with the next boot and not with the next
|
|
3567
|
-
*
|
|
3894
|
+
* release of the plugin. Only a new FEATURE version (see
|
|
3895
|
+
* `SLEEP_FEATURE_VERSION`) brings it back, on purpose.
|
|
3568
3896
|
*/
|
|
3569
3897
|
function buildAnnouncementsRoute(ctx, runtime, config) {
|
|
3570
3898
|
const routePath = resolveAnnouncementsPath(config)
|
|
@@ -3682,7 +4010,8 @@ function buildChoiceRoute(ctx, runtime, config) {
|
|
|
3682
4010
|
const hasBackup = Object.prototype.hasOwnProperty.call(body, 'backupOnRestart')
|
|
3683
4011
|
const hasShow = Object.prototype.hasOwnProperty.call(body, 'showSleepButton')
|
|
3684
4012
|
const hasSeconds = Object.prototype.hasOwnProperty.call(body, 'sleepCountdownSeconds')
|
|
3685
|
-
|
|
4013
|
+
const hasGrace = Object.prototype.hasOwnProperty.call(body, 'restartGraceMs')
|
|
4014
|
+
if (!hasInclude && !hasBackupInclude && !hasBackup && !hasShow && !hasSeconds && !hasGrace) {
|
|
3686
4015
|
// No key was asked for: there is nothing to persist.
|
|
3687
4016
|
res.statusCode = 400
|
|
3688
4017
|
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
@@ -3690,7 +4019,7 @@ function buildChoiceRoute(ctx, runtime, config) {
|
|
|
3690
4019
|
res.end(JSON.stringify({
|
|
3691
4020
|
ok: false,
|
|
3692
4021
|
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 }.',
|
|
4022
|
+
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
4023
|
}))
|
|
3695
4024
|
return
|
|
3696
4025
|
}
|
|
@@ -3751,6 +4080,17 @@ function buildChoiceRoute(ctx, runtime, config) {
|
|
|
3751
4080
|
}))
|
|
3752
4081
|
return
|
|
3753
4082
|
}
|
|
4083
|
+
if (hasGrace && GRACE_CHOICES_MS.indexOf(body.restartGraceMs) === -1) {
|
|
4084
|
+
res.statusCode = 400
|
|
4085
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
4086
|
+
res.setHeader('cache-control', 'no-store')
|
|
4087
|
+
res.end(JSON.stringify({
|
|
4088
|
+
ok: false,
|
|
4089
|
+
error: 'restartGraceMs-must-be-offered',
|
|
4090
|
+
instruction: `Send { "restartGraceMs": ${GRACE_CHOICES_MS.join(' | ')} }.`,
|
|
4091
|
+
}))
|
|
4092
|
+
return
|
|
4093
|
+
}
|
|
3754
4094
|
const changes = {}
|
|
3755
4095
|
// The route field is `includePlugins`, the configuration key is
|
|
3756
4096
|
// `backupIncludePlugins` (the profile row keeps the plugin's own name).
|
|
@@ -3759,6 +4099,7 @@ function buildChoiceRoute(ctx, runtime, config) {
|
|
|
3759
4099
|
if (hasBackup) changes.backupOnRestart = body.backupOnRestart === true
|
|
3760
4100
|
if (hasShow) changes.showSleepButton = body.showSleepButton === true
|
|
3761
4101
|
if (hasSeconds) changes.sleepCountdownSeconds = body.sleepCountdownSeconds
|
|
4102
|
+
if (hasGrace) changes.restartGraceMs = body.restartGraceMs
|
|
3762
4103
|
const result = await runtime.applyConfigChoice(changes)
|
|
3763
4104
|
// The one-time install prompt explained the features to the user, so
|
|
3764
4105
|
// the announcement of a fresh installation must not follow it.
|
|
@@ -3779,6 +4120,7 @@ function buildChoiceRoute(ctx, runtime, config) {
|
|
|
3779
4120
|
if (hasBackup) answer.backupOnRestart = body.backupOnRestart === true
|
|
3780
4121
|
if (hasShow) answer.showSleepButton = body.showSleepButton === true
|
|
3781
4122
|
if (hasSeconds) answer.sleepCountdownSeconds = body.sleepCountdownSeconds
|
|
4123
|
+
if (hasGrace) answer.restartGraceMs = body.restartGraceMs
|
|
3782
4124
|
res.statusCode = 200
|
|
3783
4125
|
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
3784
4126
|
res.setHeader('cache-control', 'no-store')
|
|
@@ -3816,13 +4158,39 @@ function sleepDryRun() {
|
|
|
3816
4158
|
* happened). The sleep feature counts down on screen itself, so it only needs
|
|
3817
4159
|
* the busy check — and it keeps `settleMs` out of the picture entirely.
|
|
3818
4160
|
*
|
|
4161
|
+
* The GRACE window is the exception: it is not a settle window but the extra
|
|
4162
|
+
* wait for work this plugin cannot see (a memory plugin writing its notes a
|
|
4163
|
+
* few seconds after the conversation ended). It is honoured BEFORE the
|
|
4164
|
+
* countdown starts, so the countdown is never pushed back by it.
|
|
4165
|
+
*
|
|
3819
4166
|
* The restart gate itself is untouched: the restart feature keeps its quiet
|
|
3820
|
-
* window exactly as it was.
|
|
4167
|
+
* window exactly as it was (plus the same grace).
|
|
3821
4168
|
*
|
|
3822
4169
|
* @param {object} runtime
|
|
3823
|
-
* @returns {{ allow: boolean, reason: 'busy'|'quiet', blockedBy: any[] }}
|
|
4170
|
+
* @returns {{ allow: boolean, reason: 'busy'|'grace'|'quiet', blockedBy: any[] }}
|
|
3824
4171
|
*/
|
|
3825
4172
|
function sleepGate(runtime) {
|
|
4173
|
+
// A pending restart goes FIRST: the shutdown waits for it — and for the
|
|
4174
|
+
// sessions that resume with it. The intent survives the restart, so the
|
|
4175
|
+
// machine goes off after everything is done, not before.
|
|
4176
|
+
let pending = false
|
|
4177
|
+
try {
|
|
4178
|
+
pending = typeof runtime.pendingRestart === 'function' && runtime.pendingRestart() === true
|
|
4179
|
+
} catch {
|
|
4180
|
+
pending = false
|
|
4181
|
+
}
|
|
4182
|
+
if (pending) {
|
|
4183
|
+
return {
|
|
4184
|
+
allow: false,
|
|
4185
|
+
reason: 'restart-pending',
|
|
4186
|
+
blockedBy: [{
|
|
4187
|
+
sessionId: 'restart-pending',
|
|
4188
|
+
kind: 'restart-pending',
|
|
4189
|
+
label: '待执行的重启',
|
|
4190
|
+
name: '待执行的重启',
|
|
4191
|
+
}],
|
|
4192
|
+
}
|
|
4193
|
+
}
|
|
3826
4194
|
let busy = []
|
|
3827
4195
|
try {
|
|
3828
4196
|
busy = runtime.busyAgents()
|
|
@@ -3831,6 +4199,16 @@ function sleepGate(runtime) {
|
|
|
3831
4199
|
}
|
|
3832
4200
|
const list = Array.isArray(busy) ? busy : []
|
|
3833
4201
|
if (list.length > 0) return { allow: false, reason: 'busy', blockedBy: list }
|
|
4202
|
+
// The grace window: the last conversation may have finished a moment ago,
|
|
4203
|
+
// and other plugins still have work queued that this plugin cannot see.
|
|
4204
|
+
let quiet = 0
|
|
4205
|
+
let grace = 0
|
|
4206
|
+
try { quiet = Number(runtime.quietForMs()) } catch { quiet = 0 }
|
|
4207
|
+
try { grace = Number(runtime.graceMs()) } catch { grace = 0 }
|
|
4208
|
+
if (!Number.isFinite(quiet) || quiet < 0) quiet = 0
|
|
4209
|
+
if (Number.isFinite(grace) && grace > 0 && quiet < grace) {
|
|
4210
|
+
return { allow: false, reason: 'grace', blockedBy: [] }
|
|
4211
|
+
}
|
|
3834
4212
|
return { allow: true, reason: 'quiet', blockedBy: [] }
|
|
3835
4213
|
}
|
|
3836
4214
|
|
|
@@ -3912,6 +4290,36 @@ function createSleepService(ctx, runtime, config, options = {}) {
|
|
|
3912
4290
|
busyAgents: () => runtime.busyAgents(),
|
|
3913
4291
|
gate,
|
|
3914
4292
|
titleOf: (sessionId) => runtime.sessionTitle(sessionId),
|
|
4293
|
+
// The grace window: after the last conversation finished the machine
|
|
4294
|
+
// waits a little longer, so that work this plugin cannot see (a memory
|
|
4295
|
+
// plugin writing its notes, a summary) still gets its chance.
|
|
4296
|
+
quietForMs: () => runtime.quietForMs(),
|
|
4297
|
+
graceMs: () => runtime.graceMs(),
|
|
4298
|
+
// A pending restart goes first: the shutdown waits for it.
|
|
4299
|
+
restartPending: () => {
|
|
4300
|
+
const pending = runtime.pendingRestart()
|
|
4301
|
+
if (!pending) return { pending: false, blockers: [] }
|
|
4302
|
+
return {
|
|
4303
|
+
pending: true,
|
|
4304
|
+
blockers: [{
|
|
4305
|
+
sessionId: 'restart-pending',
|
|
4306
|
+
kind: 'restart-pending',
|
|
4307
|
+
label: '待执行的重启',
|
|
4308
|
+
name: '待执行的重启',
|
|
4309
|
+
}],
|
|
4310
|
+
}
|
|
4311
|
+
},
|
|
4312
|
+
// The intent is spent when the machine went off, when the shutdown failed
|
|
4313
|
+
// and when the user cancelled it: the checkpoint must not keep it.
|
|
4314
|
+
onDisarm: (reason) => {
|
|
4315
|
+
try {
|
|
4316
|
+
clearSleep(runtime.state)
|
|
4317
|
+
runtime.persist()
|
|
4318
|
+
runtime.log.info('the shutdown intent was cleared:', reason)
|
|
4319
|
+
} catch (error) {
|
|
4320
|
+
runtime.log.warn('could not clear the shutdown intent:', String(error))
|
|
4321
|
+
}
|
|
4322
|
+
},
|
|
3915
4323
|
countdownSeconds: seconds,
|
|
3916
4324
|
hostLabel: final.shortLabel,
|
|
3917
4325
|
supported: final.supported,
|
|
@@ -3960,6 +4368,46 @@ function createSleepService(ctx, runtime, config, options = {}) {
|
|
|
3960
4368
|
return liveController
|
|
3961
4369
|
},
|
|
3962
4370
|
invocation,
|
|
4371
|
+
/**
|
|
4372
|
+
* Arm the shutdown AND remember the intent.
|
|
4373
|
+
*
|
|
4374
|
+
* The intent lives in the checkpoint file, so it survives a restart: a
|
|
4375
|
+
* shutdown asked for while a restart is pending waits for that restart —
|
|
4376
|
+
* and for the sessions that resume with it.
|
|
4377
|
+
*
|
|
4378
|
+
* @returns {object} the sleep status
|
|
4379
|
+
*/
|
|
4380
|
+
arm: () => {
|
|
4381
|
+
try {
|
|
4382
|
+
armSleep(runtime.state, {
|
|
4383
|
+
armedAt: Date.now(),
|
|
4384
|
+
countdownSeconds: config.sleepCountdownSeconds,
|
|
4385
|
+
})
|
|
4386
|
+
runtime.persist()
|
|
4387
|
+
} catch (error) {
|
|
4388
|
+
runtime.log.warn('could not record the shutdown intent:', String(error))
|
|
4389
|
+
}
|
|
4390
|
+
return liveController.arm()
|
|
4391
|
+
},
|
|
4392
|
+
/**
|
|
4393
|
+
* Cancel the shutdown: the intent is gone for good (the disarm hook of
|
|
4394
|
+
* the controller clears the checkpoint).
|
|
4395
|
+
*
|
|
4396
|
+
* @returns {object} the sleep status
|
|
4397
|
+
*/
|
|
4398
|
+
cancel: () => liveController.cancel(),
|
|
4399
|
+
/**
|
|
4400
|
+
* The recorded shutdown intent, if there is one.
|
|
4401
|
+
*
|
|
4402
|
+
* @returns {{ armed: true, armedAt: number, countdownSeconds: number|null }|null}
|
|
4403
|
+
*/
|
|
4404
|
+
intent: () => runtime.state.sleep,
|
|
4405
|
+
/**
|
|
4406
|
+
* Arm the shutdown from a recorded intent (the boot restore).
|
|
4407
|
+
*
|
|
4408
|
+
* @returns {object} the sleep status
|
|
4409
|
+
*/
|
|
4410
|
+
restore: () => liveController.arm(),
|
|
3963
4411
|
}
|
|
3964
4412
|
}
|
|
3965
4413
|
|
|
@@ -3968,17 +4416,26 @@ function createSleepService(ctx, runtime, config, options = {}) {
|
|
|
3968
4416
|
*
|
|
3969
4417
|
* @param {{ plan: object, controller: object, invocation: () => object }} sleep
|
|
3970
4418
|
* @param {object} config
|
|
4419
|
+
* @param {object} [runtime] the runtime (for the quiet window)
|
|
3971
4420
|
* @returns {object}
|
|
3972
4421
|
*/
|
|
3973
|
-
function sleepStatusPayload(sleep, config) {
|
|
4422
|
+
function sleepStatusPayload(sleep, config, runtime) {
|
|
3974
4423
|
const status = sleep.controller.status()
|
|
3975
4424
|
const invocation = sleep.invocation()
|
|
4425
|
+
const intent = typeof sleep.intent === 'function' ? sleep.intent() : null
|
|
3976
4426
|
return {
|
|
3977
4427
|
ok: true,
|
|
3978
4428
|
phase: status.phase,
|
|
3979
4429
|
armed: status.armed,
|
|
3980
4430
|
showSleepButton: config.showSleepButton !== false,
|
|
3981
4431
|
countdownSeconds: status.countdownSeconds,
|
|
4432
|
+
// The grace window and how long the host has been quiet: both are needed
|
|
4433
|
+
// to tell "the machine is waiting for the grace" from "somebody is busy".
|
|
4434
|
+
graceMs: config.restartGraceMs,
|
|
4435
|
+
quietForMs: runtime && typeof runtime.quietForMs === 'function' ? runtime.quietForMs() : 0,
|
|
4436
|
+
// When the shutdown was armed: the intent has to survive a restart, so the
|
|
4437
|
+
// time is reported for the troubleshooting.
|
|
4438
|
+
armedAt: intent && typeof intent.armedAt === 'number' ? intent.armedAt : null,
|
|
3982
4439
|
/**
|
|
3983
4440
|
* The pre-restart backup scope, straight from the effective configuration:
|
|
3984
4441
|
* the settings page reads its two switches from here.
|
|
@@ -4040,7 +4497,7 @@ function buildSleepRoute(ctx, runtime, config, sleep) {
|
|
|
4040
4497
|
}
|
|
4041
4498
|
const method = req && typeof req.method === 'string' ? req.method.toUpperCase() : 'GET'
|
|
4042
4499
|
if (method === 'GET') {
|
|
4043
|
-
answer(sleepStatusPayload(sleep, config))
|
|
4500
|
+
answer(sleepStatusPayload(sleep, config, runtime))
|
|
4044
4501
|
return
|
|
4045
4502
|
}
|
|
4046
4503
|
if (method !== 'POST') {
|
|
@@ -4062,11 +4519,12 @@ function buildSleepRoute(ctx, runtime, config, sleep) {
|
|
|
4062
4519
|
return
|
|
4063
4520
|
}
|
|
4064
4521
|
if (action === 'arm') {
|
|
4065
|
-
|
|
4522
|
+
// The intent is recorded too: it survives a restart.
|
|
4523
|
+
sleep.arm()
|
|
4066
4524
|
} else {
|
|
4067
|
-
sleep.
|
|
4525
|
+
sleep.cancel()
|
|
4068
4526
|
}
|
|
4069
|
-
answer(sleepStatusPayload(sleep, config))
|
|
4527
|
+
answer(sleepStatusPayload(sleep, config, runtime))
|
|
4070
4528
|
}).catch((error) => {
|
|
4071
4529
|
answer({ ok: false, error: describeError(error) }, 400)
|
|
4072
4530
|
})
|
|
@@ -4115,6 +4573,11 @@ function registerHealthRoute(ctx, runtime, config, sleep) {
|
|
|
4115
4573
|
generation: state.generation,
|
|
4116
4574
|
pendingRestart: state.restart !== null,
|
|
4117
4575
|
settleMs: config.settleMs,
|
|
4576
|
+
// The grace window and the quiet window the gate really uses: both
|
|
4577
|
+
// are needed to tell "waiting for the grace" from "somebody is busy".
|
|
4578
|
+
graceMs: config.restartGraceMs,
|
|
4579
|
+
effectiveSettleMs: runtime.effectiveSettleMs(),
|
|
4580
|
+
quietForMs: typeof verdict.quietForMs === 'number' ? verdict.quietForMs : 0,
|
|
4118
4581
|
busyCount: verdict.blockedBy.length,
|
|
4119
4582
|
agentCount,
|
|
4120
4583
|
})
|
|
@@ -4178,6 +4641,9 @@ function registerHealthRoute(ctx, runtime, config, sleep) {
|
|
|
4178
4641
|
allowRestart: verdict.allow === true,
|
|
4179
4642
|
quietForMs: typeof verdict.quietForMs === 'number' ? verdict.quietForMs : 0,
|
|
4180
4643
|
settleMs: config.settleMs,
|
|
4644
|
+
// The grace window and the quiet window the gate really uses.
|
|
4645
|
+
graceMs: config.restartGraceMs,
|
|
4646
|
+
effectiveSettleMs: runtime.effectiveSettleMs(),
|
|
4181
4647
|
updatedAt: live.updatedAt,
|
|
4182
4648
|
// The park route, so the client never has to guess the prefix, and
|
|
4183
4649
|
// the goals this plugin already disarmed (they are re-armed after
|
|
@@ -4508,12 +4974,31 @@ export function apply(ctx, rawConfig, options = {}) {
|
|
|
4508
4974
|
// The boot recovery pass is the plugin's primary boot duty, so it is scheduled
|
|
4509
4975
|
// BEFORE the auxiliary timers (the health-route registration retry below): a
|
|
4510
4976
|
// fallback registered later must never shadow or postpone the recovery pass.
|
|
4977
|
+
//
|
|
4978
|
+
// The recorded shutdown intent is restored AFTER the recovery: a shutdown
|
|
4979
|
+
// that was asked for before the restart must wait for the work that resumes
|
|
4980
|
+
// with it — the machine goes off after everything is done, not before.
|
|
4981
|
+
const restoreSleepIntent = () => {
|
|
4982
|
+
const intent = sleep.intent()
|
|
4983
|
+
if (!intent || intent.armed !== true) return
|
|
4984
|
+
log.info('the shutdown intent survived the restart; the machine goes off after the work is done', {
|
|
4985
|
+
armedAt: intent.armedAt,
|
|
4986
|
+
})
|
|
4987
|
+
sleep.restore()
|
|
4988
|
+
}
|
|
4511
4989
|
if (runtime.isNewBoot && config.resumeOnBoot) {
|
|
4512
4990
|
runtime.recoverTimer = ctx.timeout(() => {
|
|
4513
|
-
recoverAfterRestart(ctx, runtime, config)
|
|
4514
|
-
|
|
4515
|
-
|
|
4991
|
+
recoverAfterRestart(ctx, runtime, config)
|
|
4992
|
+
.then(() => { restoreSleepIntent() })
|
|
4993
|
+
.catch((error) => {
|
|
4994
|
+
log.warn('boot recovery failed:', String(error))
|
|
4995
|
+
restoreSleepIntent()
|
|
4996
|
+
})
|
|
4516
4997
|
}, Math.max(0, config.recoveryDelayMs))
|
|
4998
|
+
} else {
|
|
4999
|
+
// No recovery pass: the intent is restored right away (a hot reload must
|
|
5000
|
+
// not lose it either).
|
|
5001
|
+
restoreSleepIntent()
|
|
4517
5002
|
}
|
|
4518
5003
|
|
|
4519
5004
|
registerTools(ctx, runtime, config)
|
|
@@ -4540,4 +5025,6 @@ export {
|
|
|
4540
5025
|
resolveAnnouncementsPath,
|
|
4541
5026
|
featureAnnouncements,
|
|
4542
5027
|
pluginVersion,
|
|
5028
|
+
PLUGIN_VERSION_FALLBACK,
|
|
5029
|
+
SLEEP_FEATURE_VERSION,
|
|
4543
5030
|
}
|