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