dsh-autorestart 0.2.1 → 0.3.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/LICENSE +0 -0
- package/README.md +199 -312
- package/README.zh.md +504 -0
- package/assets/icon-180.png +0 -0
- package/assets/screenshot-1.png +0 -0
- package/assets/screenshot-2.png +0 -0
- package/assets/screenshot-3.png +0 -0
- package/client.js +267 -8
- package/cordis.patch.yml +0 -0
- package/icon.svg +0 -0
- package/index.js +1393 -50
- package/lib/checkpoint.js +516 -2
- package/lib/lifecycle.js +411 -2
- package/lib/restart.js +0 -0
- package/locale/en.json +0 -0
- package/locale/zh.json +0 -0
- package/package.json +6 -3
- package/screenshots.json +5 -0
package/index.js
CHANGED
|
@@ -25,18 +25,38 @@ import path from 'node:path'
|
|
|
25
25
|
import {
|
|
26
26
|
beginBoot,
|
|
27
27
|
cancelRestart,
|
|
28
|
+
disarmedRecord,
|
|
29
|
+
entryFor,
|
|
30
|
+
handoffRecord,
|
|
28
31
|
markIdle,
|
|
29
32
|
markPendingResume,
|
|
30
33
|
markRunning,
|
|
34
|
+
parkedRecord,
|
|
31
35
|
pruneSessions,
|
|
32
36
|
readState,
|
|
33
37
|
recoverableSessions,
|
|
34
38
|
requestRestart,
|
|
39
|
+
setContextOverflow,
|
|
40
|
+
setDisarmedGoal,
|
|
35
41
|
setGoal,
|
|
42
|
+
setHandoff,
|
|
43
|
+
setRecoveryOutcome,
|
|
36
44
|
setPhase,
|
|
45
|
+
stampResumedGoal,
|
|
37
46
|
writeState,
|
|
38
47
|
} from './lib/checkpoint.js'
|
|
39
|
-
import {
|
|
48
|
+
import {
|
|
49
|
+
bootPlan,
|
|
50
|
+
composeHandoff,
|
|
51
|
+
contextOverflow,
|
|
52
|
+
continuationPrompt,
|
|
53
|
+
describeState,
|
|
54
|
+
GOAL_BLOCK_REASONS,
|
|
55
|
+
HANDOFF_DEFAULT_MAX_CHARS,
|
|
56
|
+
restartDecision,
|
|
57
|
+
restartGate,
|
|
58
|
+
selectGoalDisarms,
|
|
59
|
+
} from './lib/lifecycle.js'
|
|
40
60
|
import {
|
|
41
61
|
buildCustomCommand,
|
|
42
62
|
detectEnvironment,
|
|
@@ -130,11 +150,13 @@ function delay(ms) {
|
|
|
130
150
|
}
|
|
131
151
|
|
|
132
152
|
/**
|
|
133
|
-
* Hard dependencies. `sessionController` is
|
|
134
|
-
*
|
|
135
|
-
*
|
|
153
|
+
* Hard dependencies. `sessionController` is declared as a dependency because a
|
|
154
|
+
* session that is NOT live in this process can only be resumed through it
|
|
155
|
+
* (`resolveAgent`) or, as a fallback, through `prompt` (which resumes the cold
|
|
156
|
+
* session and admits the message in one step). It is still looked up
|
|
157
|
+
* defensively so a composition that provides it late still resumes.
|
|
136
158
|
*/
|
|
137
|
-
export const inject = ['agents', 'goals', 'tools', 'timer', 'systemPrompt']
|
|
159
|
+
export const inject = ['agents', 'goals', 'tools', 'timer', 'systemPrompt', 'sessionController']
|
|
138
160
|
|
|
139
161
|
/**
|
|
140
162
|
* Validated loader row config. Exported so the Plugin Manager / config editor can show
|
|
@@ -234,6 +256,193 @@ function resolveHealthPath(config) {
|
|
|
234
256
|
: '/__dsh_autorestart/health'
|
|
235
257
|
}
|
|
236
258
|
|
|
259
|
+
/**
|
|
260
|
+
* Resolve the park-route path: a sibling of the health route, under the same
|
|
261
|
+
* prefix (the health path without its last segment).
|
|
262
|
+
*
|
|
263
|
+
* @param {object} config
|
|
264
|
+
* @returns {string}
|
|
265
|
+
*/
|
|
266
|
+
function resolveParkPath(config) {
|
|
267
|
+
const healthPath = resolveHealthPath(config)
|
|
268
|
+
const index = healthPath.lastIndexOf('/')
|
|
269
|
+
const prefix = index > 0 ? healthPath.slice(0, index) : '/__dsh_autorestart'
|
|
270
|
+
return `${prefix}/park`
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Resolve the recovery-route path: a sibling of the health route, under the same
|
|
275
|
+
* prefix (`POST /recover` retries the boot recovery for the pending sessions).
|
|
276
|
+
*
|
|
277
|
+
* @param {object} config
|
|
278
|
+
* @returns {string}
|
|
279
|
+
*/
|
|
280
|
+
function resolveRecoverPath(config) {
|
|
281
|
+
const healthPath = resolveHealthPath(config)
|
|
282
|
+
const index = healthPath.lastIndexOf('/')
|
|
283
|
+
const prefix = index > 0 ? healthPath.slice(0, index) : '/__dsh_autorestart'
|
|
284
|
+
return `${prefix}/recover`
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Resolve the handoff-route path: a sibling of the health route, under the same
|
|
289
|
+
* prefix (`POST /handoff` hands an over-limit session off to a fresh session).
|
|
290
|
+
*
|
|
291
|
+
* @param {object} config
|
|
292
|
+
* @returns {string}
|
|
293
|
+
*/
|
|
294
|
+
function resolveHandoffPath(config) {
|
|
295
|
+
const healthPath = resolveHealthPath(config)
|
|
296
|
+
const index = healthPath.lastIndexOf('/')
|
|
297
|
+
const prefix = index > 0 ? healthPath.slice(0, index) : '/__dsh_autorestart'
|
|
298
|
+
return `${prefix}/handoff`
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* The blocking sessions with the reason each one blocks: a session that is
|
|
303
|
+
* held by an armed goal reports `goal-active` (that goal can be disarmed), a
|
|
304
|
+
* goal whose round budget is spent reports `goal-exhausted`, and the original
|
|
305
|
+
* busy reason is kept as `busyReason`.
|
|
306
|
+
*
|
|
307
|
+
* Never throws: a goal lookup must never break a status report.
|
|
308
|
+
*
|
|
309
|
+
* @param {object} runtime
|
|
310
|
+
* @param {any} blockedBy
|
|
311
|
+
* @returns {any[]}
|
|
312
|
+
*/
|
|
313
|
+
function goalAwareBlockingDetails(runtime, blockedBy) {
|
|
314
|
+
const list = Array.isArray(blockedBy) ? blockedBy : []
|
|
315
|
+
const reasons = new Map()
|
|
316
|
+
try {
|
|
317
|
+
const { disarms, reported } = selectGoalDisarms(list, {
|
|
318
|
+
getGoal: (sessionId) => runtime.goalFor(sessionId),
|
|
319
|
+
disarmedGoals: runtime.disarmedGoalRecords(),
|
|
320
|
+
parkedGoals: runtime.parkedGoalRecords(),
|
|
321
|
+
})
|
|
322
|
+
for (const item of disarms) reasons.set(item.sessionId, GOAL_BLOCK_REASONS.ACTIVE)
|
|
323
|
+
for (const item of reported) reasons.set(item.sessionId, item.reason)
|
|
324
|
+
} catch {
|
|
325
|
+
// A goal lookup must never break the status report.
|
|
326
|
+
}
|
|
327
|
+
return list.map((entry) => {
|
|
328
|
+
if (!entry || typeof entry !== 'object') return entry
|
|
329
|
+
const reason = reasons.get(entry.sessionId)
|
|
330
|
+
if (!reason) return entry
|
|
331
|
+
return { ...entry, busyReason: entry.reason, reason }
|
|
332
|
+
})
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* The goal records this plugin keeps, in both shapes: the current disarmed-goal
|
|
337
|
+
* records (`goalId` / `disarmedAt` / `resumedAt`, written by
|
|
338
|
+
* `ctx.goals.disarm`) and the LEGACY parked-goal records written by the
|
|
339
|
+
* previous pause-based build (`goalId` / `parkedAt` / `resumedAt`).
|
|
340
|
+
*
|
|
341
|
+
* The status field is still called `parkedGoals` so an older client keeps
|
|
342
|
+
* working; `kind` tells the two shapes apart.
|
|
343
|
+
*
|
|
344
|
+
* @param {object} runtime
|
|
345
|
+
* @returns {{sessionId: string, kind: 'disarmed'|'parked', goalId: string|null, disarmedAt: number|null, parkedAt: number|null, resumedAt: number|null, resumed: boolean}[]}
|
|
346
|
+
*/
|
|
347
|
+
function parkedGoalList(runtime) {
|
|
348
|
+
const out = []
|
|
349
|
+
const collect = (records, kind, atKey) => {
|
|
350
|
+
for (const [sessionId, record] of Object.entries(records || {})) {
|
|
351
|
+
if (!record || typeof record !== 'object') continue
|
|
352
|
+
out.push({
|
|
353
|
+
sessionId,
|
|
354
|
+
kind,
|
|
355
|
+
goalId: record.goalId != null ? String(record.goalId) : null,
|
|
356
|
+
disarmedAt: kind === 'disarmed' && Number.isFinite(record.disarmedAt) ? record.disarmedAt : null,
|
|
357
|
+
parkedAt: kind === 'parked' && Number.isFinite(record[atKey]) ? record[atKey] : null,
|
|
358
|
+
resumedAt: record.resumedAt != null ? record.resumedAt : null,
|
|
359
|
+
resumed: record.resumedAt != null,
|
|
360
|
+
})
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
collect(runtime.disarmedGoalRecords(), 'disarmed', 'disarmedAt')
|
|
364
|
+
collect(runtime.parkedGoalRecords(), 'parked', 'parkedAt')
|
|
365
|
+
return out
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* The self-reported outcome of the last recovery attempt, per session, plus the
|
|
370
|
+
* sessions that can still be recovered. Used by `continuum_status` and by the
|
|
371
|
+
* `?state=1` route so a stuck recovery is visible without reading the log.
|
|
372
|
+
*
|
|
373
|
+
* @param {object} state
|
|
374
|
+
* @returns {{sessionId: string, action: string|null, detail: string|null, pending: boolean, attempts: number, error: string|null}[]}
|
|
375
|
+
*/
|
|
376
|
+
function recoveryList(state) {
|
|
377
|
+
const out = []
|
|
378
|
+
try {
|
|
379
|
+
const sessions = state && typeof state === 'object' && state.sessions && typeof state.sessions === 'object'
|
|
380
|
+
? state.sessions
|
|
381
|
+
: {}
|
|
382
|
+
for (const [sessionId, entry] of Object.entries(sessions)) {
|
|
383
|
+
if (!entry || typeof entry !== 'object') continue
|
|
384
|
+
if (entry.lastRecoveryAction == null && entry.pendingResume !== true) continue
|
|
385
|
+
out.push({
|
|
386
|
+
sessionId: typeof entry.sessionId === 'string' && entry.sessionId ? entry.sessionId : sessionId,
|
|
387
|
+
action: entry.lastRecoveryAction != null ? String(entry.lastRecoveryAction) : null,
|
|
388
|
+
detail: entry.lastRecoveryDetail != null ? String(entry.lastRecoveryDetail) : null,
|
|
389
|
+
pending: entry.pendingResume === true,
|
|
390
|
+
attempts: Number.isFinite(entry.resumeAttempts) ? entry.resumeAttempts : 0,
|
|
391
|
+
error: entry.lastResumeError != null ? String(entry.lastResumeError) : null,
|
|
392
|
+
})
|
|
393
|
+
}
|
|
394
|
+
} catch {}
|
|
395
|
+
return out
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* The per-session handoff / context-overflow diagnostics of the checkpoint.
|
|
400
|
+
*
|
|
401
|
+
* Both records are the durable "this session outgrew the model window" markers:
|
|
402
|
+
* a handed-off session was replaced by a fresh one, an over-limit session is
|
|
403
|
+
* refused by the automatic resume path. `recoverableSessions` skips both, so the
|
|
404
|
+
* status has to report them from the raw entries or they would be invisible.
|
|
405
|
+
*
|
|
406
|
+
* @param {object} state
|
|
407
|
+
* @param {{ limit?: number }} [options]
|
|
408
|
+
* @returns {{sessionId: string, handedOffTo: string|null, handoffAt: number|null, carriedChars: number|null, handedOff: boolean, overContextLimit: boolean, contextOverflow: object|null, lastRecoveryAction: string|null, lastRecoveryDetail: string|null}[]}
|
|
409
|
+
*/
|
|
410
|
+
function diagnosticSessions(state, { limit = 50 } = {}) {
|
|
411
|
+
const out = []
|
|
412
|
+
try {
|
|
413
|
+
const sessions = state && typeof state === 'object' && state.sessions && typeof state.sessions === 'object'
|
|
414
|
+
? state.sessions
|
|
415
|
+
: {}
|
|
416
|
+
for (const [sessionId, entry] of Object.entries(sessions)) {
|
|
417
|
+
if (!entry || typeof entry !== 'object') continue
|
|
418
|
+
const handoff = entry.handoff && typeof entry.handoff === 'object' ? entry.handoff : null
|
|
419
|
+
const overflow = entry.contextOverflow && typeof entry.contextOverflow === 'object'
|
|
420
|
+
&& entry.contextOverflow.over === true
|
|
421
|
+
? entry.contextOverflow
|
|
422
|
+
: null
|
|
423
|
+
if (handoff === null && overflow === null) continue
|
|
424
|
+
out.push({
|
|
425
|
+
sessionId: typeof entry.sessionId === 'string' && entry.sessionId ? entry.sessionId : sessionId,
|
|
426
|
+
handedOff: handoff !== null,
|
|
427
|
+
handedOffTo: handoff && typeof handoff.handedOffTo === 'string' ? handoff.handedOffTo : null,
|
|
428
|
+
handoffAt: handoff && Number.isFinite(handoff.handoffAt) ? handoff.handoffAt : null,
|
|
429
|
+
carriedChars: handoff && Number.isFinite(handoff.carriedChars) ? handoff.carriedChars : null,
|
|
430
|
+
overContextLimit: overflow !== null,
|
|
431
|
+
contextOverflow: overflow === null ? null : {
|
|
432
|
+
inputTokens: Number.isFinite(overflow.inputTokens) ? overflow.inputTokens : null,
|
|
433
|
+
contextWindow: Number.isFinite(overflow.contextWindow) ? overflow.contextWindow : null,
|
|
434
|
+
detail: typeof overflow.detail === 'string' ? overflow.detail : null,
|
|
435
|
+
detectedAt: Number.isFinite(overflow.detectedAt) ? overflow.detectedAt : null,
|
|
436
|
+
},
|
|
437
|
+
lastRecoveryAction: entry.lastRecoveryAction != null ? String(entry.lastRecoveryAction) : null,
|
|
438
|
+
lastRecoveryDetail: entry.lastRecoveryDetail != null ? String(entry.lastRecoveryDetail) : null,
|
|
439
|
+
})
|
|
440
|
+
if (out.length >= limit) break
|
|
441
|
+
}
|
|
442
|
+
} catch {}
|
|
443
|
+
return out
|
|
444
|
+
}
|
|
445
|
+
|
|
237
446
|
/** Resolve the durable checkpoint path. */
|
|
238
447
|
function resolveStateFile(config) {
|
|
239
448
|
if (typeof config.stateFile === 'string' && config.stateFile.trim() !== '') {
|
|
@@ -309,6 +518,11 @@ function createRuntime(ctx, config) {
|
|
|
309
518
|
let recoverTimer
|
|
310
519
|
let disposed = false
|
|
311
520
|
let lastPersistError
|
|
521
|
+
/**
|
|
522
|
+
* The tool registrations that failed. A failed registration is recorded and
|
|
523
|
+
* logged, never thrown: the plugin still loads and the other tools still work.
|
|
524
|
+
*/
|
|
525
|
+
const toolErrors = []
|
|
312
526
|
|
|
313
527
|
const persist = () => {
|
|
314
528
|
try {
|
|
@@ -387,6 +601,331 @@ function createRuntime(ctx, config) {
|
|
|
387
601
|
return out
|
|
388
602
|
}
|
|
389
603
|
|
|
604
|
+
/**
|
|
605
|
+
* The disarmed-goal records of the checkpoint: one entry per session whose
|
|
606
|
+
* goal this plugin disarmed and has not re-armed yet.
|
|
607
|
+
*/
|
|
608
|
+
const disarmedGoalRecords = () => (
|
|
609
|
+
state.disarmedGoals && typeof state.disarmedGoals === 'object' ? state.disarmedGoals : {}
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* The LEGACY (pause-based) parked-goal records of the checkpoint. They are
|
|
614
|
+
* still read so a goal the previous build paused can be resumed once.
|
|
615
|
+
*/
|
|
616
|
+
const parkedGoalRecords = () => (
|
|
617
|
+
state.parkedGoals && typeof state.parkedGoals === 'object' ? state.parkedGoals : {}
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
/** The live agent of a session, or undefined when it is not attached. */
|
|
621
|
+
const liveAgent = (sessionId) => {
|
|
622
|
+
if (!sessionId) return undefined
|
|
623
|
+
try {
|
|
624
|
+
if (ctx.agents && typeof ctx.agents.get === 'function') {
|
|
625
|
+
const agent = ctx.agents.get(sessionId)
|
|
626
|
+
if (agent) return agent
|
|
627
|
+
}
|
|
628
|
+
} catch {}
|
|
629
|
+
return undefined
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** The live goal of a session, or undefined when it has none. */
|
|
633
|
+
const goalFor = (sessionId, agent) => {
|
|
634
|
+
const target = agent || liveAgent(sessionId)
|
|
635
|
+
if (!target) return undefined
|
|
636
|
+
try {
|
|
637
|
+
return ctx.goals.get(target)
|
|
638
|
+
} catch {
|
|
639
|
+
return undefined
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Disarm the goal of one session so a pending restart can proceed.
|
|
645
|
+
*
|
|
646
|
+
* `ctx.goals.disarm(agent)` removes the process-local continuation authority
|
|
647
|
+
* WITHOUT touching the durable goal: the goal stays `active`, the round that
|
|
648
|
+
* is running right now still finishes normally (no turn is cancelled and
|
|
649
|
+
* nothing is lost), but the goal round driver will not start another round.
|
|
650
|
+
* That is what lets the session go idle so the restart can proceed; after the
|
|
651
|
+
* boot the goal is re-armed through `ctx.goals.resume(...)`.
|
|
652
|
+
*
|
|
653
|
+
* Never throws: a failed disarm is reported instead of propagated, so it can
|
|
654
|
+
* never break the restart gate.
|
|
655
|
+
*/
|
|
656
|
+
const disarmGoal = (sessionId, agent) => {
|
|
657
|
+
const target = agent || liveAgent(sessionId)
|
|
658
|
+
if (!target) {
|
|
659
|
+
return { sessionId, disarmed: false, reason: GOAL_BLOCK_REASONS.NO_AGENT, error: 'session-not-live' }
|
|
660
|
+
}
|
|
661
|
+
const goal = goalFor(sessionId, target)
|
|
662
|
+
if (!goal || typeof goal !== 'object') return { sessionId, disarmed: false, reason: 'no-goal' }
|
|
663
|
+
const goalId = goal.id == null ? null : String(goal.id)
|
|
664
|
+
const { disarms, reported } = selectGoalDisarms([{ sessionId }], {
|
|
665
|
+
getGoal: () => goal,
|
|
666
|
+
disarmedGoals: disarmedGoalRecords(),
|
|
667
|
+
parkedGoals: parkedGoalRecords(),
|
|
668
|
+
})
|
|
669
|
+
if (disarms.length === 0) {
|
|
670
|
+
const reason = reported.length > 0 ? reported[0].reason : 'goal-not-disarmable'
|
|
671
|
+
return { sessionId, disarmed: false, goalId, reason }
|
|
672
|
+
}
|
|
673
|
+
const goals = ctx.goals
|
|
674
|
+
if (!goals || typeof goals.disarm !== 'function') {
|
|
675
|
+
return { sessionId, disarmed: false, goalId, reason: 'disarm-unavailable', error: 'goals.disarm is not available' }
|
|
676
|
+
}
|
|
677
|
+
let outcome
|
|
678
|
+
try {
|
|
679
|
+
outcome = goals.disarm(target)
|
|
680
|
+
} catch (error) {
|
|
681
|
+
return { sessionId, disarmed: false, goalId, reason: 'disarm-failed', error: describeError(error) }
|
|
682
|
+
}
|
|
683
|
+
const onDisarmed = () => {
|
|
684
|
+
setDisarmedGoal(state, sessionId, { goalId, disarmedAt: Date.now() })
|
|
685
|
+
persist()
|
|
686
|
+
log.info(`disarmed goal ${goalId} of session ${sessionId}; the running round still finishes and the goal is re-armed after the restart`)
|
|
687
|
+
}
|
|
688
|
+
if (outcome && typeof outcome.then === 'function') {
|
|
689
|
+
outcome.then(onDisarmed, (error) => {
|
|
690
|
+
log.warn(`could not disarm goal ${goalId} of session ${sessionId}:`, describeError(error))
|
|
691
|
+
})
|
|
692
|
+
return { sessionId, disarmed: true, goalId, reason: GOAL_BLOCK_REASONS.ACTIVE, pending: true }
|
|
693
|
+
}
|
|
694
|
+
onDisarmed()
|
|
695
|
+
return { sessionId, disarmed: true, goalId, reason: GOAL_BLOCK_REASONS.ACTIVE }
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* The live SESSION of a session id: the readable conversation surface
|
|
700
|
+
* (`deriveMessages` / `requestHeader` / `requestContext` / `snapshotEvents`).
|
|
701
|
+
* Falls back to the live agent's session when the sessions service is absent.
|
|
702
|
+
*/
|
|
703
|
+
const liveSession = (sessionId) => {
|
|
704
|
+
if (!sessionId) return undefined
|
|
705
|
+
try {
|
|
706
|
+
const sessions = ctx.get('sessions')
|
|
707
|
+
if (sessions && typeof sessions.get === 'function') {
|
|
708
|
+
const session = sessions.get(String(sessionId))
|
|
709
|
+
if (session) return session
|
|
710
|
+
}
|
|
711
|
+
} catch {}
|
|
712
|
+
const agent = liveAgent(sessionId)
|
|
713
|
+
return agent && agent.session ? agent.session : undefined
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** Read the readable surface of one live session. Never throws. */
|
|
717
|
+
const readSession = (sessionId) => {
|
|
718
|
+
const session = liveSession(sessionId)
|
|
719
|
+
if (!session) return { session: undefined, messages: undefined, header: undefined, requestContext: undefined, events: undefined }
|
|
720
|
+
const read = (fn) => {
|
|
721
|
+
try {
|
|
722
|
+
return typeof session[fn] === 'function' ? session[fn]() : undefined
|
|
723
|
+
} catch {
|
|
724
|
+
return undefined
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
return {
|
|
728
|
+
session,
|
|
729
|
+
messages: read('deriveMessages'),
|
|
730
|
+
header: read('requestHeader'),
|
|
731
|
+
requestContext: read('requestContext'),
|
|
732
|
+
events: read('snapshotEvents'),
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* The context-overflow verdict of a session, from its LIVE surface only.
|
|
738
|
+
*
|
|
739
|
+
* Evidence: the newest assistant message `usage.inputTokens` versus the
|
|
740
|
+
* session's `contextWindow`, or a recent turn / event that failed with a
|
|
741
|
+
* context-window error. Unknown numbers never mean overflow, so an unknown
|
|
742
|
+
* session keeps the normal resume path. Never throws.
|
|
743
|
+
*/
|
|
744
|
+
const overflowFor = (sessionId) => {
|
|
745
|
+
const unknown = { over: false, inputTokens: null, contextWindow: null, detail: null }
|
|
746
|
+
try {
|
|
747
|
+
const read = readSession(sessionId)
|
|
748
|
+
if (!read.session) return unknown
|
|
749
|
+
const info = read.requestContext
|
|
750
|
+
const contextWindow = info && typeof info === 'object' && Number.isFinite(info.contextWindow) ? info.contextWindow : null
|
|
751
|
+
return contextOverflow({ messages: read.messages, contextWindow, events: read.events })
|
|
752
|
+
} catch {
|
|
753
|
+
return unknown
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Hand an over-limit session off to a fresh, usable session.
|
|
759
|
+
*
|
|
760
|
+
* A session whose conversation outgrew the model window can neither continue
|
|
761
|
+
* nor be compacted: every request fails with "context window exceeds limit",
|
|
762
|
+
* and the compaction request needs the same oversized context. The handoff
|
|
763
|
+
* carries what still fits — the goal objective, the checkpoint reasons and a
|
|
764
|
+
* bounded tail of the conversation — into a NEW live agent/session, then marks
|
|
765
|
+
* the old session and disarms its goal so it stops issuing doomed requests.
|
|
766
|
+
*
|
|
767
|
+
* Nothing is created when the source cannot be read.
|
|
768
|
+
*
|
|
769
|
+
* @param {{ sessionId?: string, maxChars?: number, callerAgent?: object }} [options]
|
|
770
|
+
* @returns {Promise<object>} `{ ok, sessionId, carriedChars, instruction }`
|
|
771
|
+
*/
|
|
772
|
+
const handoff = async ({ sessionId, maxChars, callerAgent } = {}) => {
|
|
773
|
+
const requested = typeof sessionId === 'string' && sessionId.trim() !== '' ? sessionId.trim() : null
|
|
774
|
+
const sourceId = requested !== null ? requested : (callerAgent && callerAgent.id ? String(callerAgent.id) : null)
|
|
775
|
+
if (sourceId === null) {
|
|
776
|
+
return { ok: false, error: 'no-session', instruction: 'No source session: pass sessionId, or call this tool from the session that should be handed off.' }
|
|
777
|
+
}
|
|
778
|
+
const read = readSession(sourceId)
|
|
779
|
+
if (!read.session) {
|
|
780
|
+
return {
|
|
781
|
+
ok: false,
|
|
782
|
+
error: 'source-session-not-live',
|
|
783
|
+
sourceSessionId: sourceId,
|
|
784
|
+
instruction: `Session ${sourceId} is not live in this process, so its conversation cannot be read and no handoff was created.`,
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
if (read.messages === undefined) {
|
|
788
|
+
return {
|
|
789
|
+
ok: false,
|
|
790
|
+
error: 'source-unreadable',
|
|
791
|
+
sourceSessionId: sourceId,
|
|
792
|
+
instruction: `Session ${sourceId} did not expose its messages, so no handoff was created.`,
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
const header = read.header
|
|
796
|
+
const cwd = header && typeof header.cwd === 'string' && path.isAbsolute(header.cwd) ? header.cwd : null
|
|
797
|
+
if (cwd === null) {
|
|
798
|
+
return {
|
|
799
|
+
ok: false,
|
|
800
|
+
error: 'source-cwd-unknown',
|
|
801
|
+
sourceSessionId: sourceId,
|
|
802
|
+
instruction: `Session ${sourceId} has no absolute working directory in its header, so a new session could not be created for the handoff.`,
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const goal = goalFor(sourceId)
|
|
807
|
+
const objective = goal && typeof goal === 'object'
|
|
808
|
+
? [goal.objective, goal.title, goal.description].find((value) => typeof value === 'string' && value.trim() !== '') ?? null
|
|
809
|
+
: null
|
|
810
|
+
const entry = state.sessions && state.sessions[sourceId] ? state.sessions[sourceId] : null
|
|
811
|
+
const goalLabel = entry && entry.goal && entry.goal.goalId != null
|
|
812
|
+
? String(entry.goal.goalId)
|
|
813
|
+
: (goal && goal.id != null ? String(goal.id) : null)
|
|
814
|
+
const budget = Number.isFinite(maxChars) && maxChars > 0 ? Math.trunc(maxChars) : HANDOFF_DEFAULT_MAX_CHARS
|
|
815
|
+
const composed = composeHandoff({
|
|
816
|
+
sessionId: sourceId,
|
|
817
|
+
objective,
|
|
818
|
+
goalLabel,
|
|
819
|
+
reasons: entry && Array.isArray(entry.reasons) ? entry.reasons : [],
|
|
820
|
+
messages: read.messages,
|
|
821
|
+
maxChars: budget,
|
|
822
|
+
})
|
|
823
|
+
|
|
824
|
+
const newSessionId = 'session-' + crypto.randomUUID()
|
|
825
|
+
let created
|
|
826
|
+
try {
|
|
827
|
+
created = await ctx.agents.create({
|
|
828
|
+
sessionId: newSessionId,
|
|
829
|
+
meta: { cwd, parentSession: sourceId, createdAt: Date.now() },
|
|
830
|
+
})
|
|
831
|
+
} catch (error) {
|
|
832
|
+
const detail = describeError(error)
|
|
833
|
+
log.warn(`could not create the handoff session for ${sourceId}:`, detail)
|
|
834
|
+
return {
|
|
835
|
+
ok: false,
|
|
836
|
+
error: 'create-failed',
|
|
837
|
+
sourceSessionId: sourceId,
|
|
838
|
+
detail,
|
|
839
|
+
instruction: `The new session could not be created (${detail}); session ${sourceId} is unchanged.`,
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
let newAgent = created && typeof created === 'object' ? (created.agent ?? created.handle ?? created) : undefined
|
|
843
|
+
if (!newAgent || typeof newAgent.followup !== 'function' || newAgent.id === undefined) newAgent = liveAgent(newSessionId)
|
|
844
|
+
if (!newAgent || typeof newAgent.followup !== 'function') {
|
|
845
|
+
return {
|
|
846
|
+
ok: false,
|
|
847
|
+
error: 'handoff-agent-unavailable',
|
|
848
|
+
sourceSessionId: sourceId,
|
|
849
|
+
sessionId: newSessionId,
|
|
850
|
+
instruction: `The new session ${newSessionId} exposes no agent to deliver the handoff text to; session ${sourceId} is unchanged.`,
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
const continueId = newAgent.id === undefined || newAgent.id === null ? newSessionId : String(newAgent.id)
|
|
854
|
+
try {
|
|
855
|
+
newAgent.followup(createContinuationMessage(composed.text, {
|
|
856
|
+
summary: 'handoff of a session that exceeded the model context window',
|
|
857
|
+
}))
|
|
858
|
+
} catch (error) {
|
|
859
|
+
const detail = describeError(error)
|
|
860
|
+
log.warn(`the handoff text was rejected for ${continueId}:`, detail)
|
|
861
|
+
return {
|
|
862
|
+
ok: false,
|
|
863
|
+
error: 'deliver-failed',
|
|
864
|
+
sourceSessionId: sourceId,
|
|
865
|
+
sessionId: continueId,
|
|
866
|
+
detail,
|
|
867
|
+
instruction: `The handoff text could not be delivered to ${continueId} (${detail}); session ${sourceId} is unchanged.`,
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
const at = Date.now()
|
|
872
|
+
setHandoff(state, sourceId, { handedOffTo: continueId, handoffAt: at, carriedChars: composed.carriedChars })
|
|
873
|
+
setRecoveryOutcome(state, sourceId, { action: 'handed-off', detail: `handed off to ${continueId}`, now: at })
|
|
874
|
+
persist()
|
|
875
|
+
|
|
876
|
+
// (e) the over-limit session must stop issuing doomed requests.
|
|
877
|
+
let disarmedGoal = false
|
|
878
|
+
try {
|
|
879
|
+
const result = disarmGoal(sourceId, requested === null ? callerAgent : undefined)
|
|
880
|
+
disarmedGoal = result.disarmed === true
|
|
881
|
+
} catch (error) {
|
|
882
|
+
log.warn(`could not disarm the goal of the handed-off session ${sourceId}:`, describeError(error))
|
|
883
|
+
}
|
|
884
|
+
log.info(`handed session ${sourceId} off to ${continueId} (${composed.carriedChars} characters carried)`)
|
|
885
|
+
return {
|
|
886
|
+
ok: true,
|
|
887
|
+
sourceSessionId: sourceId,
|
|
888
|
+
sessionId: continueId,
|
|
889
|
+
carriedChars: composed.carriedChars,
|
|
890
|
+
carriedTailChars: composed.tailChars,
|
|
891
|
+
disarmedGoal,
|
|
892
|
+
instruction: `Continue the work in the new session ${continueId}. The old session ${sourceId} is kept and marked as handed off: its goal is disarmed and it is never auto-resumed again.`,
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* Disarm the goals of the sessions that block the pending restart.
|
|
898
|
+
*
|
|
899
|
+
* A goal-driven session keeps its own agent busy (the goal round driver
|
|
900
|
+
* starts the next round within milliseconds), so the restart gate would never
|
|
901
|
+
* open. Disarming removes that authority: the CURRENT round finishes
|
|
902
|
+
* normally, no new round starts, the session goes idle and the next poll sees
|
|
903
|
+
* a quiet host. Goals are never paused and no turn is ever cancelled.
|
|
904
|
+
*/
|
|
905
|
+
const disarmBlockingGoals = (busy) => {
|
|
906
|
+
const restart = state.restart
|
|
907
|
+
if (!restart) return { disarmed: [], reported: [] }
|
|
908
|
+
if (restart.parkGoals === false) return { disarmed: [], reported: [], skipped: 'park-goals-disabled' }
|
|
909
|
+
const list = Array.isArray(busy) ? busy : []
|
|
910
|
+
if (list.length === 0) return { disarmed: [], reported: [] }
|
|
911
|
+
const { disarms, reported } = selectGoalDisarms(list, {
|
|
912
|
+
getGoal: (sessionId) => goalFor(sessionId),
|
|
913
|
+
disarmedGoals: disarmedGoalRecords(),
|
|
914
|
+
parkedGoals: parkedGoalRecords(),
|
|
915
|
+
})
|
|
916
|
+
const disarmed = []
|
|
917
|
+
for (const item of disarms) {
|
|
918
|
+
try {
|
|
919
|
+
const result = disarmGoal(item.sessionId)
|
|
920
|
+
if (result.disarmed) disarmed.push(result)
|
|
921
|
+
else log.warn(`could not disarm the goal of session ${item.sessionId}: ${result.reason}${result.error ? ` (${result.error})` : ''}`)
|
|
922
|
+
} catch (error) {
|
|
923
|
+
log.warn(`disarming the goal of session ${item.sessionId} failed:`, describeError(error))
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
return { disarmed, reported }
|
|
927
|
+
}
|
|
928
|
+
|
|
390
929
|
const anyAgentRunning = () => busyAgents().length > 0
|
|
391
930
|
|
|
392
931
|
/** The two-layer restart gate: no busy conversation AND a quiet settle window. */
|
|
@@ -578,6 +1117,13 @@ function createRuntime(ctx, config) {
|
|
|
578
1117
|
// This re-check also protects the ungated fallback (>20 gate aborts) and the
|
|
579
1118
|
// forceRestartWhenBusy path: even when the detached helper cannot re-check the
|
|
580
1119
|
// gate itself, a busy conversation is never killed here.
|
|
1120
|
+
// Last chance to park a goal-driven session: without it the detached
|
|
1121
|
+
// helper would refuse to kill the process and the restart would wait.
|
|
1122
|
+
try {
|
|
1123
|
+
disarmBlockingGoals(busyAgents())
|
|
1124
|
+
} catch (error) {
|
|
1125
|
+
log.warn('could not disarm the goals of the blocking sessions:', String(error))
|
|
1126
|
+
}
|
|
581
1127
|
const verdict = gate()
|
|
582
1128
|
if (!verdict.allow) {
|
|
583
1129
|
log.info('restart deferred: a conversation is still active or the host is settling', {
|
|
@@ -639,6 +1185,15 @@ function createRuntime(ctx, config) {
|
|
|
639
1185
|
}
|
|
640
1186
|
const now = Date.now()
|
|
641
1187
|
const busy = busyAgents()
|
|
1188
|
+
// A goal-driven session blocks the restart forever: park its goal BEFORE the
|
|
1189
|
+
// gate decides that it is blocked, so the next poll sees an idle session.
|
|
1190
|
+
if (busy.length > 0) {
|
|
1191
|
+
try {
|
|
1192
|
+
disarmBlockingGoals(busy)
|
|
1193
|
+
} catch (error) {
|
|
1194
|
+
log.warn('could not disarm the goals of the blocking sessions:', String(error))
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
642
1197
|
const decision = restartDecision(state, {
|
|
643
1198
|
anyAgentRunning: busy.length > 0,
|
|
644
1199
|
busyAgents: busy,
|
|
@@ -693,6 +1248,7 @@ function createRuntime(ctx, config) {
|
|
|
693
1248
|
stateFile,
|
|
694
1249
|
log,
|
|
695
1250
|
persist,
|
|
1251
|
+
toolErrors,
|
|
696
1252
|
startPolling,
|
|
697
1253
|
stopPolling,
|
|
698
1254
|
tick,
|
|
@@ -707,7 +1263,70 @@ function createRuntime(ctx, config) {
|
|
|
707
1263
|
busyAgents,
|
|
708
1264
|
gate,
|
|
709
1265
|
sessionTitle,
|
|
1266
|
+
disarmGoal,
|
|
1267
|
+
disarmBlockingGoals,
|
|
1268
|
+
disarmedGoalRecords,
|
|
1269
|
+
parkedGoalRecords,
|
|
1270
|
+
goalFor,
|
|
1271
|
+
handoffRecord: (sessionId) => handoffRecord(state, sessionId),
|
|
1272
|
+
overflowFor,
|
|
1273
|
+
readSession,
|
|
1274
|
+
handoff,
|
|
710
1275
|
boot,
|
|
1276
|
+
/**
|
|
1277
|
+
* Re-run the boot recovery for the still-pending sessions, right now.
|
|
1278
|
+
*
|
|
1279
|
+
* This is the retry path behind `continuum_recover_now` and the POST
|
|
1280
|
+
* `/recover` route: a session whose agent was not live yet, or whose
|
|
1281
|
+
* `goals.resume` failed, can be retried without waiting for the next boot.
|
|
1282
|
+
* Every attempt reports its own action/detail, exactly like the boot pass.
|
|
1283
|
+
*
|
|
1284
|
+
* @param {string} [sessionId] one session, or every pending session
|
|
1285
|
+
* @param {{ force?: boolean }} [options] `force` re-opens one explicit session
|
|
1286
|
+
* (clearing a stale over-limit record and the attempt counter) before retrying
|
|
1287
|
+
* @returns {Promise<object[]>} one outcome per attempted session
|
|
1288
|
+
*/
|
|
1289
|
+
async recoverNow(sessionId, { force = false } = {}) {
|
|
1290
|
+
const target = typeof sessionId === 'string' && sessionId.trim() !== '' ? sessionId.trim() : null
|
|
1291
|
+
if (force === true && target !== null) {
|
|
1292
|
+
const entry = state.sessions && state.sessions[target] ? state.sessions[target] : null
|
|
1293
|
+
if (entry) {
|
|
1294
|
+
// An explicit retry re-checks the live evidence, so a stale record must
|
|
1295
|
+
// not keep the session out of recovery, and the attempt counter resets.
|
|
1296
|
+
entry.pendingResume = true
|
|
1297
|
+
entry.interrupted = true
|
|
1298
|
+
entry.pendingSince = Date.now()
|
|
1299
|
+
entry.resumeAttempts = 0
|
|
1300
|
+
entry.lastResumeError = null
|
|
1301
|
+
setContextOverflow(state, target, { over: false })
|
|
1302
|
+
// Clear the durable "never resume again" marker directly: calling the
|
|
1303
|
+
// handoff writer here would CREATE one (and clear pendingResume again).
|
|
1304
|
+
entry.handoff = null
|
|
1305
|
+
runtime.persist()
|
|
1306
|
+
log.info(`forced recovery for ${target}: cleared stale over-limit/handoff marks and the attempt counter`)
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
const pending = recoverableSessions(state, {}).filter((entry) => (
|
|
1310
|
+
target === null ? true : String(entry.sessionId) === target
|
|
1311
|
+
))
|
|
1312
|
+
if (pending.length === 0 && target !== null) {
|
|
1313
|
+
return [{ sessionId: target, action: 'nothing', detail: 'no-pending-recovery' }]
|
|
1314
|
+
}
|
|
1315
|
+
const results = []
|
|
1316
|
+
for (const entry of pending) {
|
|
1317
|
+
try {
|
|
1318
|
+
results.push(await recoverSession(ctx, runtime, entry, config))
|
|
1319
|
+
} catch (error) {
|
|
1320
|
+
const detail = describeError(error)
|
|
1321
|
+
setRecoveryOutcome(state, entry.sessionId, { action: 'prompt-failed', detail })
|
|
1322
|
+
results.push({ sessionId: entry.sessionId, action: 'prompt-failed', detail })
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
try {
|
|
1326
|
+
runtime.persist()
|
|
1327
|
+
} catch {}
|
|
1328
|
+
return results
|
|
1329
|
+
},
|
|
711
1330
|
}
|
|
712
1331
|
return runtime
|
|
713
1332
|
}
|
|
@@ -721,9 +1340,10 @@ function createRuntime(ctx, config) {
|
|
|
721
1340
|
* message: it is NOT human authority.
|
|
722
1341
|
*
|
|
723
1342
|
* @param {string} text
|
|
1343
|
+
* @param {{ summary?: string }} [options] one-line label of what this notice is
|
|
724
1344
|
* @returns {object} a frozen user message
|
|
725
1345
|
*/
|
|
726
|
-
function createContinuationMessage(text) {
|
|
1346
|
+
function createContinuationMessage(text, { summary } = {}) {
|
|
727
1347
|
return Object.freeze({
|
|
728
1348
|
id: crypto.randomUUID(),
|
|
729
1349
|
role: 'user',
|
|
@@ -731,7 +1351,9 @@ function createContinuationMessage(text) {
|
|
|
731
1351
|
source: Object.freeze({
|
|
732
1352
|
kind: 'continuum',
|
|
733
1353
|
form: 'notice',
|
|
734
|
-
summary:
|
|
1354
|
+
summary: typeof summary === 'string' && summary.trim() !== ''
|
|
1355
|
+
? summary.trim()
|
|
1356
|
+
: 'automatic continuation after a DeepSeek Harness restart',
|
|
735
1357
|
}),
|
|
736
1358
|
})
|
|
737
1359
|
}
|
|
@@ -759,6 +1381,24 @@ function describeError(value) {
|
|
|
759
1381
|
}
|
|
760
1382
|
}
|
|
761
1383
|
|
|
1384
|
+
/**
|
|
1385
|
+
* Record the self-reported outcome of one recovery attempt for a session.
|
|
1386
|
+
*
|
|
1387
|
+
* Every return path of `recoverSession` calls this: a recovery that did nothing
|
|
1388
|
+
* must say WHY it did nothing, otherwise a stuck session is invisible in
|
|
1389
|
+
* `continuum_status` and in the `?state=1` route.
|
|
1390
|
+
*
|
|
1391
|
+
* @param {object} state
|
|
1392
|
+
* @param {string} sessionId
|
|
1393
|
+
* @param {string} action short action label (`resume-disarmed-goal`, …)
|
|
1394
|
+
* @param {string|null} [detail] short human-readable reason
|
|
1395
|
+
*/
|
|
1396
|
+
function noteRecovery(state, sessionId, action, detail = null) {
|
|
1397
|
+
try {
|
|
1398
|
+
setRecoveryOutcome(state, sessionId, { action, detail: detail == null ? null : String(detail) })
|
|
1399
|
+
} catch {}
|
|
1400
|
+
}
|
|
1401
|
+
|
|
762
1402
|
/**
|
|
763
1403
|
* Decide what a session needs after a restart and do exactly that.
|
|
764
1404
|
* At-most-once per boot: the resume attempt is persisted before any side effect.
|
|
@@ -775,9 +1415,77 @@ async function recoverSession(ctx, runtime, entry, config) {
|
|
|
775
1415
|
entry.pendingResume = false
|
|
776
1416
|
entry.interrupted = false
|
|
777
1417
|
entry.lastResumeError = entry.lastResumeError || 'resume attempts exhausted'
|
|
1418
|
+
noteRecovery(state, sessionId, 'nothing', 'resume-attempts-exhausted')
|
|
778
1419
|
runtime.persist()
|
|
779
1420
|
log.info(`giving up on session ${sessionId}: resume attempts exhausted`, { lastResumeError: entry.lastResumeError })
|
|
780
|
-
return { sessionId, action: '
|
|
1421
|
+
return { sessionId, action: 'nothing', detail: 'resume-attempts-exhausted', error: entry.lastResumeError }
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
// CONTEXT-OVERFLOW GUARD. A session whose conversation outgrew the model window
|
|
1425
|
+
// can neither continue nor be compacted (the compaction request needs the same
|
|
1426
|
+
// oversized context): every request to it fails with "context window exceeds
|
|
1427
|
+
// limit". Re-arming its goal or queueing a continuation message into it can
|
|
1428
|
+
// therefore only fail, so the guard runs BEFORE the goal re-arm and before the
|
|
1429
|
+
// disarm decision. Evidence: a verdict an earlier pass already recorded, or the
|
|
1430
|
+
// LIVE session (the newest assistant message's `usage.inputTokens` versus the
|
|
1431
|
+
// session's `contextWindow`, or a recent context-window failure). Unknown
|
|
1432
|
+
// numbers never block the normal path.
|
|
1433
|
+
const recordedOverflow = entry.contextOverflow && entry.contextOverflow.over === true
|
|
1434
|
+
? entry.contextOverflow
|
|
1435
|
+
: null
|
|
1436
|
+
// The LIVE session is authoritative. A record is only a fallback for a session
|
|
1437
|
+
// this process cannot read at all; a record whose evidence no longer holds (for
|
|
1438
|
+
// example a false positive from an older detector) is cleared instead of
|
|
1439
|
+
// keeping the session out of recovery forever.
|
|
1440
|
+
let liveReadable = false
|
|
1441
|
+
try {
|
|
1442
|
+
const read = runtime.readSession(sessionId)
|
|
1443
|
+
liveReadable = Boolean(read && read.session)
|
|
1444
|
+
} catch {
|
|
1445
|
+
liveReadable = false
|
|
1446
|
+
}
|
|
1447
|
+
let overflow = null
|
|
1448
|
+
if (liveReadable) {
|
|
1449
|
+
try {
|
|
1450
|
+
overflow = runtime.overflowFor(sessionId)
|
|
1451
|
+
} catch {
|
|
1452
|
+
overflow = null
|
|
1453
|
+
}
|
|
1454
|
+
if ((!overflow || overflow.over !== true) && recordedOverflow !== null) {
|
|
1455
|
+
setContextOverflow(state, sessionId, { over: false })
|
|
1456
|
+
runtime.persist()
|
|
1457
|
+
log.info(`cleared a stale over-limit record for ${sessionId}: the live session is not over the window`)
|
|
1458
|
+
}
|
|
1459
|
+
} else if (recordedOverflow !== null) {
|
|
1460
|
+
overflow = {
|
|
1461
|
+
over: true,
|
|
1462
|
+
inputTokens: Number.isFinite(recordedOverflow.inputTokens) ? recordedOverflow.inputTokens : null,
|
|
1463
|
+
contextWindow: Number.isFinite(recordedOverflow.contextWindow) ? recordedOverflow.contextWindow : null,
|
|
1464
|
+
detail: typeof recordedOverflow.detail === 'string' && recordedOverflow.detail !== ''
|
|
1465
|
+
? recordedOverflow.detail
|
|
1466
|
+
: 'the session is over the model context window (recorded by an earlier recovery)',
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
if (overflow && overflow.over === true) {
|
|
1470
|
+
const detail = typeof overflow.detail === 'string' && overflow.detail !== ''
|
|
1471
|
+
? overflow.detail
|
|
1472
|
+
: 'the session is over the model context window'
|
|
1473
|
+
setContextOverflow(state, sessionId, {
|
|
1474
|
+
over: true,
|
|
1475
|
+
inputTokens: overflow.inputTokens,
|
|
1476
|
+
contextWindow: overflow.contextWindow,
|
|
1477
|
+
detail,
|
|
1478
|
+
detectedAt: Date.now(),
|
|
1479
|
+
})
|
|
1480
|
+
// Out of the automatic resume path for good: the goal is NOT re-armed, no
|
|
1481
|
+
// continuation message is queued, and `recoverableSessions` skips an
|
|
1482
|
+
// over-limit entry from now on. The recovery attempt is still reported.
|
|
1483
|
+
entry.pendingResume = false
|
|
1484
|
+
entry.interrupted = false
|
|
1485
|
+
noteRecovery(state, sessionId, 'context-overflow', detail)
|
|
1486
|
+
runtime.persist()
|
|
1487
|
+
log.warn(`session ${sessionId} is over the model context window and is NOT re-armed: hand it off with continuum_handoff`, { detail })
|
|
1488
|
+
return { sessionId, action: 'context-overflow', detail, overContextLimit: true }
|
|
781
1489
|
}
|
|
782
1490
|
|
|
783
1491
|
// Persist the attempt BEFORE any side effect, so a crash cannot double-resume and
|
|
@@ -794,10 +1502,67 @@ async function recoverSession(ctx, runtime, entry, config) {
|
|
|
794
1502
|
}
|
|
795
1503
|
|
|
796
1504
|
if (agent === undefined) {
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
1505
|
+
// Resolve the controller defensively: the declared dependency first, then the
|
|
1506
|
+
// optional lookup (a late-provided service still resumes this way).
|
|
1507
|
+
let controller = null
|
|
1508
|
+
let controllerPath = 'none'
|
|
1509
|
+
try {
|
|
1510
|
+
if (ctx.sessionController && typeof ctx.sessionController === 'object') {
|
|
1511
|
+
controller = ctx.sessionController
|
|
1512
|
+
controllerPath = 'inject'
|
|
1513
|
+
}
|
|
1514
|
+
} catch {}
|
|
1515
|
+
if (controller === null) {
|
|
1516
|
+
try {
|
|
1517
|
+
const viaGet = ctx.get('sessionController')
|
|
1518
|
+
if (viaGet && typeof viaGet === 'object') {
|
|
1519
|
+
controller = viaGet
|
|
1520
|
+
controllerPath = 'get'
|
|
1521
|
+
}
|
|
1522
|
+
} catch {}
|
|
1523
|
+
}
|
|
1524
|
+
const canResolve = Boolean(controller) && typeof controller.resolveAgent === 'function'
|
|
1525
|
+
const canPrompt = Boolean(controller) && typeof controller.prompt === 'function'
|
|
1526
|
+
|
|
1527
|
+
// Fallback: the controller prompt RESUMES a cold session and admits the
|
|
1528
|
+
// message in one step, so it can replace resolveAgent + followup entirely.
|
|
1529
|
+
if (!canResolve) {
|
|
1530
|
+
if (canPrompt) {
|
|
1531
|
+
const text = continuationPrompt(entry, {
|
|
1532
|
+
reason: (Array.isArray(entry.reasons) && entry.reasons[entry.reasons.length - 1]) || 'host restart',
|
|
1533
|
+
override: config.continuationText,
|
|
1534
|
+
})
|
|
1535
|
+
try {
|
|
1536
|
+
await controller.prompt({
|
|
1537
|
+
requestId: crypto.randomUUID(),
|
|
1538
|
+
sessionId,
|
|
1539
|
+
mode: 'queue',
|
|
1540
|
+
content: [{ type: 'text', text }],
|
|
1541
|
+
})
|
|
1542
|
+
entry.pendingResume = false
|
|
1543
|
+
entry.running = false
|
|
1544
|
+
entry.interrupted = false
|
|
1545
|
+
entry.lastResumeError = null
|
|
1546
|
+
noteRecovery(state, sessionId, 'resume-via-prompt', `sessionController.prompt (${controllerPath})`)
|
|
1547
|
+
runtime.persist()
|
|
1548
|
+
log.info(`resumed ${sessionId} through sessionController.prompt (${controllerPath})`)
|
|
1549
|
+
return { sessionId, action: 'resume-via-prompt', detail: `sessionController.prompt (${controllerPath})`, controllerPath }
|
|
1550
|
+
} catch (error) {
|
|
1551
|
+
const detail = describeError(error)
|
|
1552
|
+
entry.lastResumeError = detail
|
|
1553
|
+
noteRecovery(state, sessionId, 'prompt-failed', detail)
|
|
1554
|
+
runtime.persist()
|
|
1555
|
+
log.warn(`sessionController.prompt failed for ${sessionId}:`, detail)
|
|
1556
|
+
return { sessionId, action: 'prompt-failed', detail, error: detail, controllerPath }
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
const detail = controller === null
|
|
1560
|
+
? 'the session controller is not reachable (neither the declared inject nor ctx.get returned it)'
|
|
1561
|
+
: 'the session controller exposes neither resolveAgent nor prompt'
|
|
1562
|
+
log.warn('sessionController is unavailable; cannot resume', sessionId, detail)
|
|
1563
|
+
noteRecovery(state, sessionId, 'session-controller-unavailable', detail)
|
|
1564
|
+
runtime.persist()
|
|
1565
|
+
return { sessionId, action: 'session-controller-unavailable', detail, controllerPath }
|
|
801
1566
|
}
|
|
802
1567
|
// A freshly reloaded profile can expose the live agent a moment after the
|
|
803
1568
|
// state file was written, so a THROWN error is retried inside the same boot
|
|
@@ -827,51 +1592,116 @@ async function recoverSession(ctx, runtime, entry, config) {
|
|
|
827
1592
|
}
|
|
828
1593
|
}
|
|
829
1594
|
if (lastError) {
|
|
830
|
-
// Keep pendingResume: the session stays recoverable on a later boot
|
|
831
|
-
// failure is
|
|
1595
|
+
// Keep pendingResume: the session stays recoverable on a later boot (or a
|
|
1596
|
+
// later continuum_recover_now retry), but the failure is visible instead of
|
|
1597
|
+
// vanishing.
|
|
832
1598
|
entry.lastResumeError = lastError
|
|
1599
|
+
noteRecovery(state, sessionId, 'agent-not-live', lastError)
|
|
833
1600
|
runtime.persist()
|
|
834
1601
|
log.warn(`could not resume ${sessionId}:`, lastError)
|
|
835
|
-
return { sessionId, action: '
|
|
1602
|
+
return { sessionId, action: 'agent-not-live', detail: lastError, error: lastError }
|
|
836
1603
|
}
|
|
837
1604
|
agent = result && result.agent
|
|
1605
|
+
if (agent) {
|
|
1606
|
+
noteRecovery(state, sessionId, 'resume-via-resolve-agent', `sessionController.resolveAgent (${controllerPath})`)
|
|
1607
|
+
runtime.persist()
|
|
1608
|
+
}
|
|
838
1609
|
}
|
|
839
1610
|
if (!agent) {
|
|
840
1611
|
entry.lastResumeError = 'resolveAgent returned no agent'
|
|
1612
|
+
noteRecovery(state, sessionId, 'agent-not-live', entry.lastResumeError)
|
|
841
1613
|
runtime.persist()
|
|
842
|
-
return { sessionId, action: '
|
|
1614
|
+
return { sessionId, action: 'agent-not-live', detail: entry.lastResumeError, error: entry.lastResumeError }
|
|
843
1615
|
}
|
|
844
1616
|
|
|
845
1617
|
let goal
|
|
1618
|
+
let goalLookupError = null
|
|
846
1619
|
try {
|
|
847
1620
|
goal = ctx.goals.get(agent)
|
|
848
1621
|
} catch (error) {
|
|
849
|
-
|
|
1622
|
+
goalLookupError = describeError(error)
|
|
1623
|
+
log.warn(`goals.get failed for ${sessionId}:`, goalLookupError)
|
|
1624
|
+
}
|
|
1625
|
+
if (!goal || typeof goal !== 'object') {
|
|
1626
|
+
goal = undefined
|
|
1627
|
+
if (goalLookupError === null) goalLookupError = 'goals.get returned no goal'
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
const disarmed = disarmedRecord(state, sessionId)
|
|
1631
|
+
const legacyParked = parkedRecord(state, sessionId)
|
|
1632
|
+
let resumeError = null
|
|
1633
|
+
let resumeAction = null
|
|
1634
|
+
|
|
1635
|
+
// (1) The goal THIS plugin disarmed before the restart: it is still durably
|
|
1636
|
+
// `active` and only its activation is `disarmed`, so re-arming it with its
|
|
1637
|
+
// current revision lets the goal round driver continue the work.
|
|
1638
|
+
if (goal && disarmed && goal.phase === 'active' && goal.activation === 'disarmed' && disarmed.goalId === goal.id) {
|
|
1639
|
+
resumeAction = 'resume-disarmed-goal'
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
// (2) The goal the PREVIOUS (pause-based) build PAUSED: we paused it, so we may
|
|
1643
|
+
// resume it. A goal the USER paused has no record and must stay paused.
|
|
1644
|
+
if (resumeAction === null && goal && legacyParked && goal.phase === 'paused' && legacyParked.goalId === goal.id) {
|
|
1645
|
+
resumeAction = 'resume-paused-goal'
|
|
850
1646
|
}
|
|
851
1647
|
|
|
1648
|
+
// (3) The already proven boot path: an active goal whose continuation authority
|
|
1649
|
+
// is gone is re-armed (the host may have disarmed it on shutdown).
|
|
852
1650
|
if (
|
|
853
|
-
|
|
1651
|
+
resumeAction === null
|
|
1652
|
+
&& goal
|
|
854
1653
|
&& goal.phase === 'active'
|
|
855
1654
|
&& goal.activation === 'disarmed'
|
|
856
1655
|
&& goal.roundsStarted < goal.maxGoalRounds
|
|
857
1656
|
) {
|
|
1657
|
+
resumeAction = 'rearm-goal'
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
if (resumeAction !== null) {
|
|
858
1661
|
try {
|
|
859
1662
|
ctx.goals.resume(agent, { id: goal.id, revision: goal.revision })
|
|
1663
|
+
// The goal is armed again: stamp the record(s) of this session with the
|
|
1664
|
+
// resume timestamp, so the status shows the goal as resumed (the record is
|
|
1665
|
+
// reporting only — it never decides whether a goal may be disarmed).
|
|
1666
|
+
stampResumedGoal(state, sessionId, { resumedAt: Date.now() })
|
|
860
1667
|
entry.pendingResume = false
|
|
861
1668
|
entry.running = false
|
|
862
1669
|
entry.interrupted = false
|
|
863
1670
|
entry.lastResumeError = null
|
|
864
1671
|
entry.goal = { goalId: goal.id, goalRevision: goal.revision + 1, phase: 'active' }
|
|
1672
|
+
noteRecovery(state, sessionId, resumeAction, `goal ${goal.id} is armed again`)
|
|
865
1673
|
runtime.persist()
|
|
866
|
-
log.info(
|
|
867
|
-
return { sessionId, action:
|
|
1674
|
+
log.info(`${resumeAction}: goal ${goal.id} of session ${sessionId} is armed again; the goal round driver continues the work`)
|
|
1675
|
+
return { sessionId, action: resumeAction, goalId: goal.id, detail: `goal ${goal.id} is armed again` }
|
|
868
1676
|
} catch (error) {
|
|
869
|
-
|
|
1677
|
+
// Stale revision or an exhausted round budget: fall back to the
|
|
1678
|
+
// continuation message and KEEP the record, so a later boot (or a manual
|
|
1679
|
+
// continuum_recover_now retry) can try to resume the goal again.
|
|
1680
|
+
resumeError = describeError(error)
|
|
1681
|
+
entry.lastResumeError = resumeError
|
|
1682
|
+
log.warn(`could not resume goal ${goal.id} of ${sessionId} (${resumeAction}):`, resumeError)
|
|
870
1683
|
}
|
|
871
1684
|
}
|
|
872
1685
|
|
|
873
1686
|
if (!entry.pendingResume && !entry.interrupted) {
|
|
874
|
-
|
|
1687
|
+
if (resumeError) {
|
|
1688
|
+
entry.lastResumeError = resumeError
|
|
1689
|
+
noteRecovery(state, sessionId, resumeAction || 'nothing', `resume failed: ${resumeError}`)
|
|
1690
|
+
runtime.persist()
|
|
1691
|
+
return { sessionId, action: resumeAction || 'nothing', detail: `resume failed: ${resumeError}`, error: resumeError }
|
|
1692
|
+
}
|
|
1693
|
+
noteRecovery(state, sessionId, resumeAction || 'nothing', resumeAction ? 'goal handled, session not pending' : 'session-not-pending')
|
|
1694
|
+
runtime.persist()
|
|
1695
|
+
return { sessionId, action: resumeAction || 'nothing', detail: 'session-not-pending' }
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
// A failed goal lookup is not a reason to skip the session: the continuation
|
|
1699
|
+
// message still makes progress, and the reason is recorded for the operator.
|
|
1700
|
+
let pendingAction = 'prompt-continuation'
|
|
1701
|
+
let pendingDetail = resumeError ? `goal resume failed: ${resumeError}` : 'continuation message queued'
|
|
1702
|
+
if (goal === undefined) {
|
|
1703
|
+
pendingAction = 'goal-lookup-failed'
|
|
1704
|
+
pendingDetail = `${goalLookupError || 'goals.get returned no goal'}; continuation message queued`
|
|
875
1705
|
}
|
|
876
1706
|
|
|
877
1707
|
// Inject exactly one continuation message through the live agent's inbox. The
|
|
@@ -879,7 +1709,9 @@ async function recoverSession(ctx, runtime, entry, config) {
|
|
|
879
1709
|
// signature is `prompt(request, signal)` and it hardcodes a human `user` source.
|
|
880
1710
|
if (typeof agent.followup !== 'function') {
|
|
881
1711
|
log.warn('the resumed agent exposes no followup(); cannot inject the continuation prompt')
|
|
882
|
-
|
|
1712
|
+
noteRecovery(state, sessionId, 'prompt-failed', 'the agent exposes no followup()')
|
|
1713
|
+
runtime.persist()
|
|
1714
|
+
return { sessionId, action: 'prompt-failed', detail: 'the agent exposes no followup()' }
|
|
883
1715
|
}
|
|
884
1716
|
const text = continuationPrompt(entry, {
|
|
885
1717
|
reason: (Array.isArray(entry.reasons) && entry.reasons[entry.reasons.length - 1]) || 'host restart',
|
|
@@ -888,16 +1720,20 @@ async function recoverSession(ctx, runtime, entry, config) {
|
|
|
888
1720
|
try {
|
|
889
1721
|
agent.followup(createContinuationMessage(text))
|
|
890
1722
|
} catch (error) {
|
|
891
|
-
|
|
892
|
-
|
|
1723
|
+
const detail = describeError(error)
|
|
1724
|
+
log.warn(`continuation message rejected for ${sessionId}:`, detail)
|
|
1725
|
+
noteRecovery(state, sessionId, 'prompt-failed', detail)
|
|
1726
|
+
runtime.persist()
|
|
1727
|
+
return { sessionId, action: 'prompt-failed', detail }
|
|
893
1728
|
}
|
|
894
1729
|
entry.pendingResume = false
|
|
895
1730
|
entry.running = false
|
|
896
1731
|
entry.interrupted = false
|
|
897
|
-
entry.lastResumeError =
|
|
1732
|
+
entry.lastResumeError = resumeError
|
|
1733
|
+
noteRecovery(state, sessionId, pendingAction, pendingDetail)
|
|
898
1734
|
runtime.persist()
|
|
899
1735
|
log.info(`queued a continuation message for session ${sessionId}`)
|
|
900
|
-
return { sessionId, action:
|
|
1736
|
+
return { sessionId, action: pendingAction, detail: pendingDetail }
|
|
901
1737
|
}
|
|
902
1738
|
|
|
903
1739
|
/** The boot recovery pass. Runs once per host boot, bounded and idempotent. */
|
|
@@ -997,16 +1833,46 @@ function persistSafely(runtime) {
|
|
|
997
1833
|
function registerTools(ctx, runtime, config) {
|
|
998
1834
|
const log = runtime.log
|
|
999
1835
|
|
|
1836
|
+
/**
|
|
1837
|
+
* Register one tool.
|
|
1838
|
+
*
|
|
1839
|
+
* The disposer returned by `ctx.tools.register` is handed to `ctx.effect`, so
|
|
1840
|
+
* the HOST owns it: when the plugin row is disabled (or the plugin is
|
|
1841
|
+
* hot-reloaded) the fiber disposes the effect and the tool is unregistered
|
|
1842
|
+
* together with the HTTP routes. Registering it bare would leave the tools of
|
|
1843
|
+
* the OLD instance registered (and the new instance's registration would fail
|
|
1844
|
+
* as a duplicate), so the host could serve the tools of an instance whose
|
|
1845
|
+
* routes are already gone.
|
|
1846
|
+
*
|
|
1847
|
+
* A failed registration is recorded in `runtime.toolErrors` and logged; it is
|
|
1848
|
+
* never thrown, so one broken tool can never stop the plugin from loading.
|
|
1849
|
+
*/
|
|
1000
1850
|
const tool = (definition) => {
|
|
1851
|
+
const name = definition && definition.name ? String(definition.name) : null
|
|
1001
1852
|
try {
|
|
1002
|
-
ctx.
|
|
1853
|
+
ctx.effect(
|
|
1854
|
+
() => ctx.tools.register(definition),
|
|
1855
|
+
`dsh-autorestart: tool ${name ?? 'unknown'}`,
|
|
1856
|
+
)
|
|
1003
1857
|
} catch (error) {
|
|
1004
|
-
|
|
1858
|
+
runtime.toolErrors.push({ tool: name, error: describeError(error) })
|
|
1859
|
+
log.warn(`could not register tool ${name}:`, String(error))
|
|
1005
1860
|
}
|
|
1006
1861
|
}
|
|
1007
1862
|
|
|
1008
1863
|
const textOutput = (schema, render) => ({ schema, render })
|
|
1009
1864
|
|
|
1865
|
+
/**
|
|
1866
|
+
* The blocking sessions with the reason each one blocks: a session that is
|
|
1867
|
+
* held by an armed goal reports `goal-active`, a goal whose round budget is
|
|
1868
|
+
* used up reports `goal-exhausted`, and the original busy reason is kept as
|
|
1869
|
+
* `busyReason`.
|
|
1870
|
+
*/
|
|
1871
|
+
const blockingDetails = (blockedBy) => goalAwareBlockingDetails(runtime, blockedBy)
|
|
1872
|
+
|
|
1873
|
+
/** The goals this plugin parked, with the outcome of the resume. */
|
|
1874
|
+
const parkedGoalDetails = () => parkedGoalList(runtime)
|
|
1875
|
+
|
|
1010
1876
|
tool({
|
|
1011
1877
|
name: 'continuum_restart',
|
|
1012
1878
|
description:
|
|
@@ -1016,7 +1882,12 @@ function registerTools(ctx, runtime, config) {
|
|
|
1016
1882
|
+ 'forceRestartWhenBusy is enabled, otherwise the restart keeps waiting for the other conversations. '
|
|
1017
1883
|
+ 'Other conversations are never killed mid-turn: the detached helper re-checks this idle gate '
|
|
1018
1884
|
+ 'over loopback immediately before it kills the process. After the restart this '
|
|
1019
|
-
+ 'plugin resumes this session and continues the work automatically.
|
|
1885
|
+
+ 'plugin resumes this session and continues the work automatically. A goal-driven session is '
|
|
1886
|
+
+ 'DISARMED before the restart (pass parkGoals:false to turn that off): disarming only removes the '
|
|
1887
|
+
+ 'process-local continuation authority, so the round that is running right now still finishes normally '
|
|
1888
|
+
+ '(no turn is cancelled and nothing is lost), no new round starts, and after the restart the goal is '
|
|
1889
|
+
+ 're-armed and continues. A goal whose round budget is spent cannot be continued and is reported '
|
|
1890
|
+
+ 'instead. Write your final message '
|
|
1020
1891
|
+ 'in this turn, then stop calling tools — the restart fires as soon as the turn goes idle. '
|
|
1021
1892
|
+ 'Use it only when a restart is genuinely required (for example to load an installed plugin update).',
|
|
1022
1893
|
parameters: {
|
|
@@ -1026,6 +1897,7 @@ function registerTools(ctx, runtime, config) {
|
|
|
1026
1897
|
reason: { type: 'string', description: 'Why the restart is needed; recorded in the checkpoint and shown in the log.' },
|
|
1027
1898
|
resume: { type: 'boolean', description: 'Resume this session after the restart (default true).' },
|
|
1028
1899
|
defer_seconds: { type: 'number', description: 'Optional extra seconds to wait before the first idle check.' },
|
|
1900
|
+
parkGoals: { type: 'boolean', description: 'Disarm the goal of a goal-driven session before the restart (default true). Disarming keeps the durable goal active and lets the current round finish; after the restart the goal is re-armed and continues. With false no goal is disarmed, so a goal-driven session may keep the restart waiting.' },
|
|
1029
1901
|
},
|
|
1030
1902
|
},
|
|
1031
1903
|
output: textOutput(
|
|
@@ -1049,10 +1921,12 @@ function registerTools(ctx, runtime, config) {
|
|
|
1049
1921
|
const agent = exec && exec.agent
|
|
1050
1922
|
const sessionId = agent && agent.id ? String(agent.id) : undefined
|
|
1051
1923
|
const resume = args.resume !== false
|
|
1924
|
+
const parkGoals = args.parkGoals !== false
|
|
1052
1925
|
requestRestart(state, {
|
|
1053
1926
|
reason: typeof args.reason === 'string' && args.reason.trim() !== '' ? args.reason.trim() : 'agent requested restart',
|
|
1054
1927
|
bySessionId: sessionId,
|
|
1055
1928
|
resume,
|
|
1929
|
+
parkGoals,
|
|
1056
1930
|
now,
|
|
1057
1931
|
})
|
|
1058
1932
|
if (sessionId && resume) {
|
|
@@ -1068,13 +1942,15 @@ function registerTools(ctx, runtime, config) {
|
|
|
1068
1942
|
}
|
|
1069
1943
|
runtime.persist()
|
|
1070
1944
|
runtime.startPolling()
|
|
1071
|
-
log.info('restart requested', { generation: state.generation, sessionId, resume })
|
|
1945
|
+
log.info('restart requested', { generation: state.generation, sessionId, resume, parkGoals })
|
|
1072
1946
|
return {
|
|
1073
1947
|
accepted: true,
|
|
1074
1948
|
generation: state.generation,
|
|
1075
1949
|
phase: state.phase,
|
|
1076
1950
|
willResume: resume && Boolean(sessionId),
|
|
1077
|
-
note:
|
|
1951
|
+
note: parkGoals
|
|
1952
|
+
? 'The restart runs at the next safe point (all agents idle). Keep this reply short; the session resumes afterwards.'
|
|
1953
|
+
: 'The restart runs at the next safe point (all agents idle), but no goal is parked: a goal-driven session keeps its own agent busy and may therefore keep the restart waiting.',
|
|
1078
1954
|
}
|
|
1079
1955
|
},
|
|
1080
1956
|
})
|
|
@@ -1083,8 +1959,12 @@ function registerTools(ctx, runtime, config) {
|
|
|
1083
1959
|
name: 'continuum_status',
|
|
1084
1960
|
description:
|
|
1085
1961
|
'Report the dsh-autorestart lifecycle: whether a restart is pending, which sessions were '
|
|
1086
|
-
+ 'checkpointed as interrupted or continuation-pending,
|
|
1087
|
-
+ '
|
|
1962
|
+
+ 'checkpointed as interrupted or continuation-pending, why each blocking session blocks '
|
|
1963
|
+
+ '("goal-active" = an armed goal keeps it busy, "goal-exhausted" = its goal can no longer be '
|
|
1964
|
+
+ 'resumed), the goals this plugin disarmed, the last recovery action/detail per session, '
|
|
1965
|
+
+ 'whether a session was handed off to a fresh session (target session id and time) or is over '
|
|
1966
|
+
+ 'the model context window (such a session is never auto-resumed), the '
|
|
1967
|
+
+ 'resolved state-file path, and the detected restart environment. Read-only.',
|
|
1088
1968
|
parameters: {
|
|
1089
1969
|
type: 'object',
|
|
1090
1970
|
additionalProperties: false,
|
|
@@ -1107,6 +1987,9 @@ function registerTools(ctx, runtime, config) {
|
|
|
1107
1987
|
gateAllow: { type: 'boolean' },
|
|
1108
1988
|
quietForMs: { type: 'number' },
|
|
1109
1989
|
blockedBy: { type: 'array', items: { type: 'object' } },
|
|
1990
|
+
parkedGoals: { type: 'array', items: { type: 'object' } },
|
|
1991
|
+
recovery: { type: 'array', items: { type: 'object' } },
|
|
1992
|
+
handoffs: { type: 'array', items: { type: 'object' } },
|
|
1110
1993
|
environment: { type: 'object' },
|
|
1111
1994
|
sessions: { type: 'array', items: { type: 'object' } },
|
|
1112
1995
|
},
|
|
@@ -1126,7 +2009,10 @@ function registerTools(ctx, runtime, config) {
|
|
|
1126
2009
|
pendingRestart: state.restart !== null,
|
|
1127
2010
|
gateAllow: verdict.allow,
|
|
1128
2011
|
quietForMs: Math.round(verdict.quietForMs),
|
|
1129
|
-
blockedBy: verdict.blockedBy,
|
|
2012
|
+
blockedBy: blockingDetails(verdict.blockedBy),
|
|
2013
|
+
parkedGoals: parkedGoalDetails(),
|
|
2014
|
+
recovery: recoveryList(state),
|
|
2015
|
+
handoffs: diagnosticSessions(state),
|
|
1130
2016
|
environment: {
|
|
1131
2017
|
platform: runtime.env.platform,
|
|
1132
2018
|
isDesktopApp: runtime.env.isDesktopApp,
|
|
@@ -1137,17 +2023,169 @@ function registerTools(ctx, runtime, config) {
|
|
|
1137
2023
|
},
|
|
1138
2024
|
...(includeSessions
|
|
1139
2025
|
? {
|
|
1140
|
-
sessions:
|
|
1141
|
-
...entry
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
2026
|
+
sessions: [
|
|
2027
|
+
...recoverableSessions(state, { limit: 50 }).map((entry) => ({
|
|
2028
|
+
...entry,
|
|
2029
|
+
// Diagnostics: the last resolveAgent failure for this session (null when healthy).
|
|
2030
|
+
lastResumeError: entry.lastResumeError ?? null,
|
|
2031
|
+
handedOff: false,
|
|
2032
|
+
overContextLimit: false,
|
|
2033
|
+
})),
|
|
2034
|
+
// A handed-off or over-limit session is out of the automatic resume
|
|
2035
|
+
// path, so it is listed here instead of vanishing from the report.
|
|
2036
|
+
...diagnosticSessions(state, { limit: 50 }),
|
|
2037
|
+
],
|
|
1145
2038
|
}
|
|
1146
2039
|
: {}),
|
|
1147
2040
|
}
|
|
1148
2041
|
},
|
|
1149
2042
|
})
|
|
1150
2043
|
|
|
2044
|
+
tool({
|
|
2045
|
+
name: 'continuum_park_goal',
|
|
2046
|
+
description:
|
|
2047
|
+
'Disarm the durable goal of a session so a pending restart can proceed: a goal-driven '
|
|
2048
|
+
+ 'session keeps its own agent busy forever (the goal round driver starts the next round '
|
|
2049
|
+
+ 'immediately), so the restart would wait forever behind it. Disarming only removes the '
|
|
2050
|
+
+ 'process-local continuation authority: the goal stays active, the round that is running '
|
|
2051
|
+
+ 'right now still finishes normally (no turn is cancelled and nothing is lost), and no new '
|
|
2052
|
+
+ 'round starts. After the next restart the goal is re-armed automatically and continues. '
|
|
2053
|
+
+ 'Without a sessionId the goal of the calling session is disarmed. A goal whose round budget '
|
|
2054
|
+
+ 'is spent can never be continued, so it is reported instead of disarmed.',
|
|
2055
|
+
parameters: {
|
|
2056
|
+
type: 'object',
|
|
2057
|
+
additionalProperties: false,
|
|
2058
|
+
properties: {
|
|
2059
|
+
sessionId: { type: 'string', description: 'The session whose goal should be disarmed (default: the calling session).' },
|
|
2060
|
+
},
|
|
2061
|
+
},
|
|
2062
|
+
output: textOutput(
|
|
2063
|
+
{
|
|
2064
|
+
type: 'object',
|
|
2065
|
+
additionalProperties: false,
|
|
2066
|
+
required: ['ok', 'disarmed'],
|
|
2067
|
+
properties: {
|
|
2068
|
+
ok: { type: 'boolean' },
|
|
2069
|
+
disarmed: { type: 'boolean' },
|
|
2070
|
+
sessionId: { type: 'string' },
|
|
2071
|
+
goalId: { type: 'string' },
|
|
2072
|
+
reason: { type: 'string' },
|
|
2073
|
+
error: { type: 'string' },
|
|
2074
|
+
},
|
|
2075
|
+
},
|
|
2076
|
+
(_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
|
|
2077
|
+
),
|
|
2078
|
+
execute(args = {}, exec) {
|
|
2079
|
+
const agent = exec && exec.agent
|
|
2080
|
+
const callerId = agent && agent.id ? String(agent.id) : undefined
|
|
2081
|
+
const requested = typeof args.sessionId === 'string' ? args.sessionId.trim() : ''
|
|
2082
|
+
const sessionId = requested !== '' ? requested : callerId
|
|
2083
|
+
if (!sessionId) {
|
|
2084
|
+
return { ok: false, disarmed: false, reason: 'no-session' }
|
|
2085
|
+
}
|
|
2086
|
+
const result = runtime.disarmGoal(sessionId, sessionId === callerId ? agent : undefined)
|
|
2087
|
+
const payload = {
|
|
2088
|
+
ok: true,
|
|
2089
|
+
disarmed: result.disarmed === true,
|
|
2090
|
+
sessionId,
|
|
2091
|
+
reason: result.reason,
|
|
2092
|
+
}
|
|
2093
|
+
if (result.goalId !== undefined && result.goalId !== null) payload.goalId = String(result.goalId)
|
|
2094
|
+
if (result.error) payload.error = String(result.error)
|
|
2095
|
+
return payload
|
|
2096
|
+
},
|
|
2097
|
+
})
|
|
2098
|
+
|
|
2099
|
+
tool({
|
|
2100
|
+
name: 'continuum_recover_now',
|
|
2101
|
+
description:
|
|
2102
|
+
'Retry the post-restart recovery RIGHT NOW instead of waiting for the next restart. Use it when '
|
|
2103
|
+
+ 'a session is still stuck after a restart: its goal has not been re-armed yet, or its agent was '
|
|
2104
|
+
+ 'not live when the boot recovery ran. Optional sessionId retries one session; without one every '
|
|
2105
|
+
+ 'pending session is retried. Returns, per session, the action that was taken (for example '
|
|
2106
|
+
+ '"resume-disarmed-goal", "resume-paused-goal", "rearm-goal", "prompt-continuation", '
|
|
2107
|
+
+ '"agent-not-live", "goal-lookup-failed", "nothing") and a short detail.',
|
|
2108
|
+
parameters: {
|
|
2109
|
+
type: 'object',
|
|
2110
|
+
additionalProperties: false,
|
|
2111
|
+
properties: {
|
|
2112
|
+
sessionId: { type: 'string', description: 'Retry only this session (default: every pending session).' },
|
|
2113
|
+
force: {
|
|
2114
|
+
type: 'boolean',
|
|
2115
|
+
description: 'Re-open this explicit session before retrying: clears a stale over-limit / handoff mark and resets the attempt counter.',
|
|
2116
|
+
},
|
|
2117
|
+
},
|
|
2118
|
+
},
|
|
2119
|
+
output: textOutput(
|
|
2120
|
+
{
|
|
2121
|
+
type: 'object',
|
|
2122
|
+
additionalProperties: false,
|
|
2123
|
+
required: ['ok', 'results'],
|
|
2124
|
+
properties: {
|
|
2125
|
+
ok: { type: 'boolean' },
|
|
2126
|
+
results: { type: 'array', items: { type: 'object' } },
|
|
2127
|
+
},
|
|
2128
|
+
},
|
|
2129
|
+
(_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
2130
|
+
),
|
|
2131
|
+
async execute(args = {}) {
|
|
2132
|
+
const requested = typeof args.sessionId === 'string' ? args.sessionId.trim() : ''
|
|
2133
|
+
const results = await runtime.recoverNow(requested !== '' ? requested : undefined, { force: args.force === true })
|
|
2134
|
+
return { ok: true, results }
|
|
2135
|
+
},
|
|
2136
|
+
})
|
|
2137
|
+
|
|
2138
|
+
tool({
|
|
2139
|
+
name: 'continuum_handoff',
|
|
2140
|
+
description:
|
|
2141
|
+
'Hand a session whose conversation exceeded the model context window off to a FRESH session. '
|
|
2142
|
+
+ 'Such a session is dead: every request to it fails with "context window exceeds limit", and '
|
|
2143
|
+
+ 'compacting it needs the same oversized context. The handoff carries the goal objective, the '
|
|
2144
|
+
+ 'recorded checkpoint reasons and a bounded tail of the conversation (default 20000 characters, '
|
|
2145
|
+
+ '`maxChars`) into a new live session in the same working directory, marks the old session as '
|
|
2146
|
+
+ 'handed off (so it is never auto-resumed again) and disarms its goal. The old session and its '
|
|
2147
|
+
+ 'conversation are kept, untouched. Without `sessionId` the calling session is handed off. It '
|
|
2148
|
+
+ 'reports the new session id, how many characters were carried, and what the human should do.',
|
|
2149
|
+
parameters: {
|
|
2150
|
+
type: 'object',
|
|
2151
|
+
additionalProperties: false,
|
|
2152
|
+
properties: {
|
|
2153
|
+
sessionId: { type: 'string', description: 'The over-limit session to hand off (default: the calling session).' },
|
|
2154
|
+
maxChars: { type: 'number', description: 'Character budget of the carried conversation tail (default 20000).' },
|
|
2155
|
+
},
|
|
2156
|
+
},
|
|
2157
|
+
output: textOutput(
|
|
2158
|
+
{
|
|
2159
|
+
type: 'object',
|
|
2160
|
+
additionalProperties: false,
|
|
2161
|
+
required: ['ok'],
|
|
2162
|
+
properties: {
|
|
2163
|
+
ok: { type: 'boolean' },
|
|
2164
|
+
sessionId: { type: 'string' },
|
|
2165
|
+
sourceSessionId: { type: 'string' },
|
|
2166
|
+
carriedChars: { type: 'number' },
|
|
2167
|
+
carriedTailChars: { type: 'number' },
|
|
2168
|
+
disarmedGoal: { type: 'boolean' },
|
|
2169
|
+
error: { type: 'string' },
|
|
2170
|
+
detail: { type: 'string' },
|
|
2171
|
+
instruction: { type: 'string' },
|
|
2172
|
+
},
|
|
2173
|
+
},
|
|
2174
|
+
(_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
2175
|
+
),
|
|
2176
|
+
async execute(args = {}, exec) {
|
|
2177
|
+
const agent = exec && exec.agent
|
|
2178
|
+
const requested = typeof args.sessionId === 'string' ? args.sessionId.trim() : ''
|
|
2179
|
+
return runtime.handoff({
|
|
2180
|
+
sessionId: requested !== '' ? requested : undefined,
|
|
2181
|
+
maxChars: typeof args.maxChars === 'number' && Number.isFinite(args.maxChars) && args.maxChars > 0
|
|
2182
|
+
? args.maxChars
|
|
2183
|
+
: undefined,
|
|
2184
|
+
callerAgent: agent,
|
|
2185
|
+
})
|
|
2186
|
+
},
|
|
2187
|
+
})
|
|
2188
|
+
|
|
1151
2189
|
tool({
|
|
1152
2190
|
name: 'continuum_cancel_restart',
|
|
1153
2191
|
description: 'Cancel a restart previously requested through continuum_restart. Does nothing when no restart is pending.',
|
|
@@ -1219,9 +2257,11 @@ function injectPageHeartbeat(html, bootId, routePath) {
|
|
|
1219
2257
|
const AUTORESTART_PROMPT = [
|
|
1220
2258
|
'## Restart policy (dsh-autorestart)',
|
|
1221
2259
|
'- 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`).',
|
|
1222
|
-
'- Call the `continuum_restart` tool instead (optional arguments: `reason`, `resume`, `defer_seconds`). It waits until every conversation is idle (no running turn, no queued inbox work) AND the host has been quiet for `settleMs` (default 2000 ms); the detached helper re-checks that gate immediately before killing, so other conversations are never killed mid-turn. It then relaunches the app, and after boot this plugin resumes the session and continues the work automatically.',
|
|
2260
|
+
'- Call the `continuum_restart` tool instead (optional arguments: `reason`, `resume`, `defer_seconds`, `parkGoals`). It waits until every conversation is idle (no running turn, no queued inbox work) AND the host has been quiet for `settleMs` (default 2000 ms); the detached helper re-checks that gate immediately before killing, so other conversations are never killed mid-turn. It then relaunches the app, and after boot this plugin resumes the session and continues the work automatically.',
|
|
2261
|
+
'- 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`.',
|
|
1223
2262
|
'- Persist anything the next turn needs before restarting (workspace files, `todo_write`, goal state); do not rely on in-memory state.',
|
|
1224
2263
|
'- Inspect the lifecycle with `continuum_status`; cancel a pending restart with `continuum_cancel_restart`.',
|
|
2264
|
+
'- A session whose context exceeds the model window can be handed off to a fresh session with `continuum_handoff` (it carries the goal objective, the recorded reasons and a bounded tail of the conversation); this plugin refuses to auto-resume such a session, so hand it off instead of retrying it.',
|
|
1225
2265
|
'- For multi-turn objectives prefer a durable goal (`create_goal`): after a restart the plugin re-arms it and the goal round driver keeps producing rounds. No goal is required for restart continuation — without one the plugin injects a single continuation message that continues the work in a fresh turn.',
|
|
1226
2266
|
].join('\n')
|
|
1227
2267
|
|
|
@@ -1354,13 +2394,18 @@ function registerManualRestartGuard(ctx, runtime, config) {
|
|
|
1354
2394
|
return
|
|
1355
2395
|
}
|
|
1356
2396
|
try {
|
|
1357
|
-
tools
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
2397
|
+
// The disposer is owned by the fiber (like the tools and the routes), so a
|
|
2398
|
+
// re-applied plugin never leaves a guard of an old instance behind.
|
|
2399
|
+
ctx.effect(
|
|
2400
|
+
() => tools.guard((exec) => {
|
|
2401
|
+
const hit = detectManualRestart(exec, { webPort: runtime.webPort ?? 0 })
|
|
2402
|
+
if (hit === null) return undefined
|
|
2403
|
+
runtime.manualRestartBlocks += 1
|
|
2404
|
+
runtime.log.warn(`blocked a manual DSH restart attempt (${hit}) through tool "${exec.name}"; steering the agent to continuum_restart`)
|
|
2405
|
+
return MANUAL_RESTART_DENIAL
|
|
2406
|
+
}),
|
|
2407
|
+
'dsh-autorestart: manual-restart guard',
|
|
2408
|
+
)
|
|
1364
2409
|
} catch (error) {
|
|
1365
2410
|
runtime.log.warn('could not register the manual-restart guard:', String(error))
|
|
1366
2411
|
}
|
|
@@ -1404,6 +2449,273 @@ function admittedByConnection(ctx, req) {
|
|
|
1404
2449
|
}
|
|
1405
2450
|
}
|
|
1406
2451
|
|
|
2452
|
+
/**
|
|
2453
|
+
* Read a small JSON request body. Never throws: an unreadable or malformed
|
|
2454
|
+
* body simply becomes an empty object.
|
|
2455
|
+
*
|
|
2456
|
+
* @param {any} req
|
|
2457
|
+
* @param {number} [maxBytes]
|
|
2458
|
+
* @returns {Promise<object>}
|
|
2459
|
+
*/
|
|
2460
|
+
function readJsonBody(req, maxBytes = 64 * 1024) {
|
|
2461
|
+
return new Promise((resolve) => {
|
|
2462
|
+
if (!req || typeof req.on !== 'function') {
|
|
2463
|
+
resolve({})
|
|
2464
|
+
return
|
|
2465
|
+
}
|
|
2466
|
+
let size = 0
|
|
2467
|
+
let settled = false
|
|
2468
|
+
const chunks = []
|
|
2469
|
+
const finish = (text) => {
|
|
2470
|
+
if (settled) return
|
|
2471
|
+
settled = true
|
|
2472
|
+
try {
|
|
2473
|
+
const raw = String(text || '').trim()
|
|
2474
|
+
if (raw === '') {
|
|
2475
|
+
resolve({})
|
|
2476
|
+
return
|
|
2477
|
+
}
|
|
2478
|
+
const parsed = JSON.parse(raw)
|
|
2479
|
+
resolve(parsed && typeof parsed === 'object' ? parsed : {})
|
|
2480
|
+
} catch {
|
|
2481
|
+
resolve({})
|
|
2482
|
+
}
|
|
2483
|
+
}
|
|
2484
|
+
req.on('data', (chunk) => {
|
|
2485
|
+
size += typeof chunk === 'string' ? chunk.length : (chunk && chunk.length ? chunk.length : 0)
|
|
2486
|
+
if (size > maxBytes) {
|
|
2487
|
+
finish('')
|
|
2488
|
+
return
|
|
2489
|
+
}
|
|
2490
|
+
chunks.push(typeof chunk === 'string' ? chunk : String(chunk))
|
|
2491
|
+
})
|
|
2492
|
+
req.on('end', () => finish(chunks.join('')))
|
|
2493
|
+
req.on('error', () => finish(''))
|
|
2494
|
+
})
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
/**
|
|
2498
|
+
* The ids of the sessions that currently block a pending restart.
|
|
2499
|
+
*
|
|
2500
|
+
* @param {object} runtime
|
|
2501
|
+
* @returns {string[]}
|
|
2502
|
+
*/
|
|
2503
|
+
function blockingSessionIds(runtime) {
|
|
2504
|
+
const ids = []
|
|
2505
|
+
try {
|
|
2506
|
+
const verdict = runtime.gate()
|
|
2507
|
+
const blockedBy = Array.isArray(verdict.blockedBy) ? verdict.blockedBy : []
|
|
2508
|
+
for (const entry of blockedBy) {
|
|
2509
|
+
if (!entry || typeof entry !== 'object') continue
|
|
2510
|
+
const id = entry.sessionId == null ? '' : String(entry.sessionId)
|
|
2511
|
+
if (id && !ids.includes(id)) ids.push(id)
|
|
2512
|
+
}
|
|
2513
|
+
} catch {}
|
|
2514
|
+
return ids
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
/**
|
|
2518
|
+
* Build the POST route that disarms the goal of a session.
|
|
2519
|
+
*
|
|
2520
|
+
* It uses the same authentication rule as the health route (loopback OR a
|
|
2521
|
+
* request the connection service admits) and answers JSON only.
|
|
2522
|
+
*/
|
|
2523
|
+
function buildParkRoute(ctx, runtime, config) {
|
|
2524
|
+
const routePath = resolveParkPath(config)
|
|
2525
|
+
return {
|
|
2526
|
+
kind: 'exact',
|
|
2527
|
+
path: routePath,
|
|
2528
|
+
handler: (req, res) => {
|
|
2529
|
+
const remote = req && req.socket ? req.socket.remoteAddress : undefined
|
|
2530
|
+
const loopback = remote === undefined
|
|
2531
|
+
|| remote === '127.0.0.1'
|
|
2532
|
+
|| remote === '::1'
|
|
2533
|
+
|| remote === '::ffff:127.0.0.1'
|
|
2534
|
+
if (!loopback && !admittedByConnection(ctx, req)) {
|
|
2535
|
+
res.statusCode = 403
|
|
2536
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
2537
|
+
res.end('{"ok":false,"error":"loopback-only"}')
|
|
2538
|
+
return
|
|
2539
|
+
}
|
|
2540
|
+
if (!req || req.method !== 'POST') {
|
|
2541
|
+
res.statusCode = 405
|
|
2542
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
2543
|
+
res.setHeader('allow', 'POST')
|
|
2544
|
+
res.end('{"ok":false,"error":"method-not-allowed"}')
|
|
2545
|
+
return
|
|
2546
|
+
}
|
|
2547
|
+
void readJsonBody(req).then((payload) => {
|
|
2548
|
+
const body = payload && typeof payload === 'object' ? payload : {}
|
|
2549
|
+
const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : ''
|
|
2550
|
+
const restart = body.restart === true
|
|
2551
|
+
// Without a session id every session that blocks a pending restart is
|
|
2552
|
+
// parked, so the restart can proceed.
|
|
2553
|
+
const targets = sessionId !== '' ? [sessionId] : blockingSessionIds(runtime)
|
|
2554
|
+
const disarmed = []
|
|
2555
|
+
const reported = []
|
|
2556
|
+
for (const target of targets) {
|
|
2557
|
+
try {
|
|
2558
|
+
const result = runtime.disarmGoal(target)
|
|
2559
|
+
if (result.disarmed) disarmed.push(result)
|
|
2560
|
+
else reported.push(result)
|
|
2561
|
+
} catch (error) {
|
|
2562
|
+
reported.push({ sessionId: target, disarmed: false, reason: 'disarm-failed', error: describeError(error) })
|
|
2563
|
+
}
|
|
2564
|
+
}
|
|
2565
|
+
if (restart && sessionId !== '') {
|
|
2566
|
+
// The session becomes a requester of the pending restart: the restart
|
|
2567
|
+
// proceeds and the session continues afterwards.
|
|
2568
|
+
requestRestart(runtime.state, {
|
|
2569
|
+
reason: 'disarm route requested a restart',
|
|
2570
|
+
bySessionId: sessionId,
|
|
2571
|
+
resume: true,
|
|
2572
|
+
now: Date.now(),
|
|
2573
|
+
})
|
|
2574
|
+
runtime.persist()
|
|
2575
|
+
runtime.startPolling()
|
|
2576
|
+
}
|
|
2577
|
+
res.statusCode = 200
|
|
2578
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
2579
|
+
res.setHeader('cache-control', 'no-store')
|
|
2580
|
+
res.end(JSON.stringify({
|
|
2581
|
+
ok: true,
|
|
2582
|
+
disarmed,
|
|
2583
|
+
reported,
|
|
2584
|
+
pendingRestart: runtime.state.restart !== null,
|
|
2585
|
+
restartRequested: restart === true && sessionId !== '',
|
|
2586
|
+
}))
|
|
2587
|
+
}).catch((error) => {
|
|
2588
|
+
res.statusCode = 400
|
|
2589
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
2590
|
+
res.end(JSON.stringify({ ok: false, error: describeError(error) }))
|
|
2591
|
+
})
|
|
2592
|
+
},
|
|
2593
|
+
}
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2596
|
+
/**
|
|
2597
|
+
* Build the POST `/recover` route: retry the post-restart recovery for the
|
|
2598
|
+
* still-pending sessions right now.
|
|
2599
|
+
*
|
|
2600
|
+
* It is the sibling of the `/park` route next to the health route and uses the
|
|
2601
|
+
* same authentication rule (loopback OR a request the connection service
|
|
2602
|
+
* admits). Body: `{ "sessionId"?: string }`; without one every pending session
|
|
2603
|
+
* is retried. The answer reports, per session, the recovery action and detail.
|
|
2604
|
+
*/
|
|
2605
|
+
function buildRecoverRoute(ctx, runtime, config) {
|
|
2606
|
+
const routePath = resolveRecoverPath(config)
|
|
2607
|
+
return {
|
|
2608
|
+
kind: 'exact',
|
|
2609
|
+
path: routePath,
|
|
2610
|
+
handler: (req, res) => {
|
|
2611
|
+
const remote = req && req.socket ? req.socket.remoteAddress : undefined
|
|
2612
|
+
const loopback = remote === undefined
|
|
2613
|
+
|| remote === '127.0.0.1'
|
|
2614
|
+
|| remote === '::1'
|
|
2615
|
+
|| remote === '::ffff:127.0.0.1'
|
|
2616
|
+
if (!loopback && !admittedByConnection(ctx, req)) {
|
|
2617
|
+
res.statusCode = 403
|
|
2618
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
2619
|
+
res.end('{"ok":false,"error":"loopback-only"}')
|
|
2620
|
+
return
|
|
2621
|
+
}
|
|
2622
|
+
if (!req || req.method !== 'POST') {
|
|
2623
|
+
res.statusCode = 405
|
|
2624
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
2625
|
+
res.setHeader('allow', 'POST')
|
|
2626
|
+
res.end('{"ok":false,"error":"method-not-allowed"}')
|
|
2627
|
+
return
|
|
2628
|
+
}
|
|
2629
|
+
void readJsonBody(req).then(async (payload) => {
|
|
2630
|
+
const body = payload && typeof payload === 'object' ? payload : {}
|
|
2631
|
+
const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : ''
|
|
2632
|
+
const results = await runtime.recoverNow(sessionId !== '' ? sessionId : undefined, { force: body.force === true })
|
|
2633
|
+
res.statusCode = 200
|
|
2634
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
2635
|
+
res.setHeader('cache-control', 'no-store')
|
|
2636
|
+
res.end(JSON.stringify({ ok: true, results }))
|
|
2637
|
+
}).catch((error) => {
|
|
2638
|
+
res.statusCode = 400
|
|
2639
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
2640
|
+
res.end(JSON.stringify({ ok: false, error: describeError(error) }))
|
|
2641
|
+
})
|
|
2642
|
+
},
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
/**
|
|
2647
|
+
* Build the POST `/handoff` route: hand an over-limit session off to a fresh
|
|
2648
|
+
* session (the same operation as the `continuum_handoff` tool).
|
|
2649
|
+
*
|
|
2650
|
+
* It sits next to the health route and uses the same authentication rule
|
|
2651
|
+
* (loopback OR a request the connection service admits). Body:
|
|
2652
|
+
* `{ "sessionId"?: string, "maxChars"?: number }`; without a session id the
|
|
2653
|
+
* request is refused, because a route call has no calling session to default to.
|
|
2654
|
+
* The answer reports the new session id and the number of carried characters.
|
|
2655
|
+
*/
|
|
2656
|
+
function buildHandoffRoute(ctx, runtime, config) {
|
|
2657
|
+
const routePath = resolveHandoffPath(config)
|
|
2658
|
+
return {
|
|
2659
|
+
kind: 'exact',
|
|
2660
|
+
path: routePath,
|
|
2661
|
+
handler: (req, res) => {
|
|
2662
|
+
const remote = req && req.socket ? req.socket.remoteAddress : undefined
|
|
2663
|
+
const loopback = remote === undefined
|
|
2664
|
+
|| remote === '127.0.0.1'
|
|
2665
|
+
|| remote === '::1'
|
|
2666
|
+
|| remote === '::ffff:127.0.0.1'
|
|
2667
|
+
if (!loopback && !admittedByConnection(ctx, req)) {
|
|
2668
|
+
res.statusCode = 403
|
|
2669
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
2670
|
+
res.end('{"ok":false,"error":"loopback-only"}')
|
|
2671
|
+
return
|
|
2672
|
+
}
|
|
2673
|
+
if (!req || req.method !== 'POST') {
|
|
2674
|
+
res.statusCode = 405
|
|
2675
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
2676
|
+
res.setHeader('allow', 'POST')
|
|
2677
|
+
res.end('{"ok":false,"error":"method-not-allowed"}')
|
|
2678
|
+
return
|
|
2679
|
+
}
|
|
2680
|
+
void readJsonBody(req).then(async (payload) => {
|
|
2681
|
+
const body = payload && typeof payload === 'object' ? payload : {}
|
|
2682
|
+
const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : ''
|
|
2683
|
+
if (sessionId === '') {
|
|
2684
|
+
res.statusCode = 400
|
|
2685
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
2686
|
+
res.end(JSON.stringify({
|
|
2687
|
+
ok: false,
|
|
2688
|
+
error: 'no-session',
|
|
2689
|
+
instruction: 'Pass { "sessionId": "<id>" }: this route has no calling session to default to.',
|
|
2690
|
+
}))
|
|
2691
|
+
return
|
|
2692
|
+
}
|
|
2693
|
+
const result = await runtime.handoff({
|
|
2694
|
+
sessionId,
|
|
2695
|
+
maxChars: typeof body.maxChars === 'number' && Number.isFinite(body.maxChars) && body.maxChars > 0
|
|
2696
|
+
? body.maxChars
|
|
2697
|
+
: undefined,
|
|
2698
|
+
})
|
|
2699
|
+
res.statusCode = result && result.ok === true ? 200 : 400
|
|
2700
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
2701
|
+
res.setHeader('cache-control', 'no-store')
|
|
2702
|
+
res.end(JSON.stringify({
|
|
2703
|
+
ok: result && result.ok === true,
|
|
2704
|
+
sessionId: result ? result.sessionId : undefined,
|
|
2705
|
+
sourceSessionId: result ? result.sourceSessionId : sessionId,
|
|
2706
|
+
carriedChars: result ? result.carriedChars : undefined,
|
|
2707
|
+
error: result ? result.error : 'handoff-failed',
|
|
2708
|
+
instruction: result ? result.instruction : undefined,
|
|
2709
|
+
}))
|
|
2710
|
+
}).catch((error) => {
|
|
2711
|
+
res.statusCode = 400
|
|
2712
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
2713
|
+
res.end(JSON.stringify({ ok: false, error: describeError(error) }))
|
|
2714
|
+
})
|
|
2715
|
+
},
|
|
2716
|
+
}
|
|
2717
|
+
}
|
|
2718
|
+
|
|
1407
2719
|
/** Expose a tiny read-only health route for humans and verification scripts. */
|
|
1408
2720
|
function registerHealthRoute(ctx, runtime, config) {
|
|
1409
2721
|
const routePath = typeof config.healthPath === 'string' && config.healthPath.startsWith('/')
|
|
@@ -1490,8 +2802,10 @@ function registerHealthRoute(ctx, runtime, config) {
|
|
|
1490
2802
|
const verdict = runtime.gate()
|
|
1491
2803
|
// Best effort: label the blocking sessions with their titles when the
|
|
1492
2804
|
// session controller knows them. The title is optional, so an entry
|
|
1493
|
-
// without one is sent exactly as the gate produced it.
|
|
1494
|
-
|
|
2805
|
+
// without one is sent exactly as the gate produced it. The goal
|
|
2806
|
+
// reason (`goal-active` / `goal-exhausted`) is attached as well, so
|
|
2807
|
+
// the client can tell a goal-driven blocker from a busy session.
|
|
2808
|
+
const blockedBy = goalAwareBlockingDetails(runtime, verdict.blockedBy).map((entry) => {
|
|
1495
2809
|
if (!entry || typeof entry !== 'object') return entry
|
|
1496
2810
|
const title = runtime.sessionTitle(entry.sessionId)
|
|
1497
2811
|
return title ? { ...entry, title } : entry
|
|
@@ -1507,6 +2821,13 @@ function registerHealthRoute(ctx, runtime, config) {
|
|
|
1507
2821
|
quietForMs: typeof verdict.quietForMs === 'number' ? verdict.quietForMs : 0,
|
|
1508
2822
|
settleMs: config.settleMs,
|
|
1509
2823
|
updatedAt: live.updatedAt,
|
|
2824
|
+
// The park route, so the client never has to guess the prefix, and
|
|
2825
|
+
// the goals this plugin already disarmed (they are re-armed after
|
|
2826
|
+
// the restart).
|
|
2827
|
+
parkUrl: resolveParkPath(config),
|
|
2828
|
+
recoverUrl: resolveRecoverPath(config),
|
|
2829
|
+
parkedGoals: parkedGoalList(runtime),
|
|
2830
|
+
recovery: recoveryList(live),
|
|
1510
2831
|
})
|
|
1511
2832
|
} catch {
|
|
1512
2833
|
// The banner must never break the health route: fall back to "no restart".
|
|
@@ -1517,6 +2838,10 @@ function registerHealthRoute(ctx, runtime, config) {
|
|
|
1517
2838
|
blockedBy: [],
|
|
1518
2839
|
allowRestart: false,
|
|
1519
2840
|
quietForMs: 0,
|
|
2841
|
+
parkUrl: resolveParkPath(config),
|
|
2842
|
+
recoverUrl: resolveRecoverPath(config),
|
|
2843
|
+
parkedGoals: [],
|
|
2844
|
+
recovery: [],
|
|
1520
2845
|
})
|
|
1521
2846
|
}
|
|
1522
2847
|
res.statusCode = 200
|
|
@@ -1568,6 +2893,9 @@ function registerHealthRoute(ctx, runtime, config) {
|
|
|
1568
2893
|
return false
|
|
1569
2894
|
}
|
|
1570
2895
|
if (!webServer || typeof webServer.register !== 'function') return false
|
|
2896
|
+
const parkRoute = buildParkRoute(ctx, runtime, config)
|
|
2897
|
+
const recoverRoute = buildRecoverRoute(ctx, runtime, config)
|
|
2898
|
+
const handoffRoute = buildHandoffRoute(ctx, runtime, config)
|
|
1571
2899
|
try {
|
|
1572
2900
|
const origin = typeof webServer.getOrigin === 'function' ? String(webServer.getOrigin()) : ''
|
|
1573
2901
|
const portMatch = /:(\d+)\/?$/.exec(origin)
|
|
@@ -1579,6 +2907,21 @@ function registerHealthRoute(ctx, runtime, config) {
|
|
|
1579
2907
|
// A duplicate registration means another load of this plugin already owns it.
|
|
1580
2908
|
runtime.log.warn('could not register the health route:', String(error))
|
|
1581
2909
|
}
|
|
2910
|
+
try {
|
|
2911
|
+
target.effect(() => webServer.register(parkRoute), 'dsh-autorestart: park route')
|
|
2912
|
+
} catch (error) {
|
|
2913
|
+
runtime.log.warn('could not register the park route:', String(error))
|
|
2914
|
+
}
|
|
2915
|
+
try {
|
|
2916
|
+
target.effect(() => webServer.register(recoverRoute), 'dsh-autorestart: recover route')
|
|
2917
|
+
} catch (error) {
|
|
2918
|
+
runtime.log.warn('could not register the recover route:', String(error))
|
|
2919
|
+
}
|
|
2920
|
+
try {
|
|
2921
|
+
target.effect(() => webServer.register(handoffRoute), 'dsh-autorestart: handoff route')
|
|
2922
|
+
} catch (error) {
|
|
2923
|
+
runtime.log.warn('could not register the handoff route:', String(error))
|
|
2924
|
+
}
|
|
1582
2925
|
if (config.autoReloadPage !== false && typeof webServer.tapIndex === 'function') {
|
|
1583
2926
|
try {
|
|
1584
2927
|
target.effect(
|