dsh-autorestart 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +0 -0
- package/README.md +204 -295
- 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 +1284 -43
- package/lib/checkpoint.js +516 -2
- package/lib/lifecycle.js +387 -0
- 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,
|
|
@@ -234,6 +254,193 @@ function resolveHealthPath(config) {
|
|
|
234
254
|
: '/__dsh_autorestart/health'
|
|
235
255
|
}
|
|
236
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Resolve the park-route path: a sibling of the health route, under the same
|
|
259
|
+
* prefix (the health path without its last segment).
|
|
260
|
+
*
|
|
261
|
+
* @param {object} config
|
|
262
|
+
* @returns {string}
|
|
263
|
+
*/
|
|
264
|
+
function resolveParkPath(config) {
|
|
265
|
+
const healthPath = resolveHealthPath(config)
|
|
266
|
+
const index = healthPath.lastIndexOf('/')
|
|
267
|
+
const prefix = index > 0 ? healthPath.slice(0, index) : '/__dsh_autorestart'
|
|
268
|
+
return `${prefix}/park`
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Resolve the recovery-route path: a sibling of the health route, under the same
|
|
273
|
+
* prefix (`POST /recover` retries the boot recovery for the pending sessions).
|
|
274
|
+
*
|
|
275
|
+
* @param {object} config
|
|
276
|
+
* @returns {string}
|
|
277
|
+
*/
|
|
278
|
+
function resolveRecoverPath(config) {
|
|
279
|
+
const healthPath = resolveHealthPath(config)
|
|
280
|
+
const index = healthPath.lastIndexOf('/')
|
|
281
|
+
const prefix = index > 0 ? healthPath.slice(0, index) : '/__dsh_autorestart'
|
|
282
|
+
return `${prefix}/recover`
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Resolve the handoff-route path: a sibling of the health route, under the same
|
|
287
|
+
* prefix (`POST /handoff` hands an over-limit session off to a fresh session).
|
|
288
|
+
*
|
|
289
|
+
* @param {object} config
|
|
290
|
+
* @returns {string}
|
|
291
|
+
*/
|
|
292
|
+
function resolveHandoffPath(config) {
|
|
293
|
+
const healthPath = resolveHealthPath(config)
|
|
294
|
+
const index = healthPath.lastIndexOf('/')
|
|
295
|
+
const prefix = index > 0 ? healthPath.slice(0, index) : '/__dsh_autorestart'
|
|
296
|
+
return `${prefix}/handoff`
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* The blocking sessions with the reason each one blocks: a session that is
|
|
301
|
+
* held by an armed goal reports `goal-active` (that goal can be disarmed), a
|
|
302
|
+
* goal whose round budget is spent reports `goal-exhausted`, and the original
|
|
303
|
+
* busy reason is kept as `busyReason`.
|
|
304
|
+
*
|
|
305
|
+
* Never throws: a goal lookup must never break a status report.
|
|
306
|
+
*
|
|
307
|
+
* @param {object} runtime
|
|
308
|
+
* @param {any} blockedBy
|
|
309
|
+
* @returns {any[]}
|
|
310
|
+
*/
|
|
311
|
+
function goalAwareBlockingDetails(runtime, blockedBy) {
|
|
312
|
+
const list = Array.isArray(blockedBy) ? blockedBy : []
|
|
313
|
+
const reasons = new Map()
|
|
314
|
+
try {
|
|
315
|
+
const { disarms, reported } = selectGoalDisarms(list, {
|
|
316
|
+
getGoal: (sessionId) => runtime.goalFor(sessionId),
|
|
317
|
+
disarmedGoals: runtime.disarmedGoalRecords(),
|
|
318
|
+
parkedGoals: runtime.parkedGoalRecords(),
|
|
319
|
+
})
|
|
320
|
+
for (const item of disarms) reasons.set(item.sessionId, GOAL_BLOCK_REASONS.ACTIVE)
|
|
321
|
+
for (const item of reported) reasons.set(item.sessionId, item.reason)
|
|
322
|
+
} catch {
|
|
323
|
+
// A goal lookup must never break the status report.
|
|
324
|
+
}
|
|
325
|
+
return list.map((entry) => {
|
|
326
|
+
if (!entry || typeof entry !== 'object') return entry
|
|
327
|
+
const reason = reasons.get(entry.sessionId)
|
|
328
|
+
if (!reason) return entry
|
|
329
|
+
return { ...entry, busyReason: entry.reason, reason }
|
|
330
|
+
})
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* The goal records this plugin keeps, in both shapes: the current disarmed-goal
|
|
335
|
+
* records (`goalId` / `disarmedAt` / `resumedAt`, written by
|
|
336
|
+
* `ctx.goals.disarm`) and the LEGACY parked-goal records written by the
|
|
337
|
+
* previous pause-based build (`goalId` / `parkedAt` / `resumedAt`).
|
|
338
|
+
*
|
|
339
|
+
* The status field is still called `parkedGoals` so an older client keeps
|
|
340
|
+
* working; `kind` tells the two shapes apart.
|
|
341
|
+
*
|
|
342
|
+
* @param {object} runtime
|
|
343
|
+
* @returns {{sessionId: string, kind: 'disarmed'|'parked', goalId: string|null, disarmedAt: number|null, parkedAt: number|null, resumedAt: number|null, resumed: boolean}[]}
|
|
344
|
+
*/
|
|
345
|
+
function parkedGoalList(runtime) {
|
|
346
|
+
const out = []
|
|
347
|
+
const collect = (records, kind, atKey) => {
|
|
348
|
+
for (const [sessionId, record] of Object.entries(records || {})) {
|
|
349
|
+
if (!record || typeof record !== 'object') continue
|
|
350
|
+
out.push({
|
|
351
|
+
sessionId,
|
|
352
|
+
kind,
|
|
353
|
+
goalId: record.goalId != null ? String(record.goalId) : null,
|
|
354
|
+
disarmedAt: kind === 'disarmed' && Number.isFinite(record.disarmedAt) ? record.disarmedAt : null,
|
|
355
|
+
parkedAt: kind === 'parked' && Number.isFinite(record[atKey]) ? record[atKey] : null,
|
|
356
|
+
resumedAt: record.resumedAt != null ? record.resumedAt : null,
|
|
357
|
+
resumed: record.resumedAt != null,
|
|
358
|
+
})
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
collect(runtime.disarmedGoalRecords(), 'disarmed', 'disarmedAt')
|
|
362
|
+
collect(runtime.parkedGoalRecords(), 'parked', 'parkedAt')
|
|
363
|
+
return out
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* The self-reported outcome of the last recovery attempt, per session, plus the
|
|
368
|
+
* sessions that can still be recovered. Used by `continuum_status` and by the
|
|
369
|
+
* `?state=1` route so a stuck recovery is visible without reading the log.
|
|
370
|
+
*
|
|
371
|
+
* @param {object} state
|
|
372
|
+
* @returns {{sessionId: string, action: string|null, detail: string|null, pending: boolean, attempts: number, error: string|null}[]}
|
|
373
|
+
*/
|
|
374
|
+
function recoveryList(state) {
|
|
375
|
+
const out = []
|
|
376
|
+
try {
|
|
377
|
+
const sessions = state && typeof state === 'object' && state.sessions && typeof state.sessions === 'object'
|
|
378
|
+
? state.sessions
|
|
379
|
+
: {}
|
|
380
|
+
for (const [sessionId, entry] of Object.entries(sessions)) {
|
|
381
|
+
if (!entry || typeof entry !== 'object') continue
|
|
382
|
+
if (entry.lastRecoveryAction == null && entry.pendingResume !== true) continue
|
|
383
|
+
out.push({
|
|
384
|
+
sessionId: typeof entry.sessionId === 'string' && entry.sessionId ? entry.sessionId : sessionId,
|
|
385
|
+
action: entry.lastRecoveryAction != null ? String(entry.lastRecoveryAction) : null,
|
|
386
|
+
detail: entry.lastRecoveryDetail != null ? String(entry.lastRecoveryDetail) : null,
|
|
387
|
+
pending: entry.pendingResume === true,
|
|
388
|
+
attempts: Number.isFinite(entry.resumeAttempts) ? entry.resumeAttempts : 0,
|
|
389
|
+
error: entry.lastResumeError != null ? String(entry.lastResumeError) : null,
|
|
390
|
+
})
|
|
391
|
+
}
|
|
392
|
+
} catch {}
|
|
393
|
+
return out
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* The per-session handoff / context-overflow diagnostics of the checkpoint.
|
|
398
|
+
*
|
|
399
|
+
* Both records are the durable "this session outgrew the model window" markers:
|
|
400
|
+
* a handed-off session was replaced by a fresh one, an over-limit session is
|
|
401
|
+
* refused by the automatic resume path. `recoverableSessions` skips both, so the
|
|
402
|
+
* status has to report them from the raw entries or they would be invisible.
|
|
403
|
+
*
|
|
404
|
+
* @param {object} state
|
|
405
|
+
* @param {{ limit?: number }} [options]
|
|
406
|
+
* @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}[]}
|
|
407
|
+
*/
|
|
408
|
+
function diagnosticSessions(state, { limit = 50 } = {}) {
|
|
409
|
+
const out = []
|
|
410
|
+
try {
|
|
411
|
+
const sessions = state && typeof state === 'object' && state.sessions && typeof state.sessions === 'object'
|
|
412
|
+
? state.sessions
|
|
413
|
+
: {}
|
|
414
|
+
for (const [sessionId, entry] of Object.entries(sessions)) {
|
|
415
|
+
if (!entry || typeof entry !== 'object') continue
|
|
416
|
+
const handoff = entry.handoff && typeof entry.handoff === 'object' ? entry.handoff : null
|
|
417
|
+
const overflow = entry.contextOverflow && typeof entry.contextOverflow === 'object'
|
|
418
|
+
&& entry.contextOverflow.over === true
|
|
419
|
+
? entry.contextOverflow
|
|
420
|
+
: null
|
|
421
|
+
if (handoff === null && overflow === null) continue
|
|
422
|
+
out.push({
|
|
423
|
+
sessionId: typeof entry.sessionId === 'string' && entry.sessionId ? entry.sessionId : sessionId,
|
|
424
|
+
handedOff: handoff !== null,
|
|
425
|
+
handedOffTo: handoff && typeof handoff.handedOffTo === 'string' ? handoff.handedOffTo : null,
|
|
426
|
+
handoffAt: handoff && Number.isFinite(handoff.handoffAt) ? handoff.handoffAt : null,
|
|
427
|
+
carriedChars: handoff && Number.isFinite(handoff.carriedChars) ? handoff.carriedChars : null,
|
|
428
|
+
overContextLimit: overflow !== null,
|
|
429
|
+
contextOverflow: overflow === null ? null : {
|
|
430
|
+
inputTokens: Number.isFinite(overflow.inputTokens) ? overflow.inputTokens : null,
|
|
431
|
+
contextWindow: Number.isFinite(overflow.contextWindow) ? overflow.contextWindow : null,
|
|
432
|
+
detail: typeof overflow.detail === 'string' ? overflow.detail : null,
|
|
433
|
+
detectedAt: Number.isFinite(overflow.detectedAt) ? overflow.detectedAt : null,
|
|
434
|
+
},
|
|
435
|
+
lastRecoveryAction: entry.lastRecoveryAction != null ? String(entry.lastRecoveryAction) : null,
|
|
436
|
+
lastRecoveryDetail: entry.lastRecoveryDetail != null ? String(entry.lastRecoveryDetail) : null,
|
|
437
|
+
})
|
|
438
|
+
if (out.length >= limit) break
|
|
439
|
+
}
|
|
440
|
+
} catch {}
|
|
441
|
+
return out
|
|
442
|
+
}
|
|
443
|
+
|
|
237
444
|
/** Resolve the durable checkpoint path. */
|
|
238
445
|
function resolveStateFile(config) {
|
|
239
446
|
if (typeof config.stateFile === 'string' && config.stateFile.trim() !== '') {
|
|
@@ -309,6 +516,11 @@ function createRuntime(ctx, config) {
|
|
|
309
516
|
let recoverTimer
|
|
310
517
|
let disposed = false
|
|
311
518
|
let lastPersistError
|
|
519
|
+
/**
|
|
520
|
+
* The tool registrations that failed. A failed registration is recorded and
|
|
521
|
+
* logged, never thrown: the plugin still loads and the other tools still work.
|
|
522
|
+
*/
|
|
523
|
+
const toolErrors = []
|
|
312
524
|
|
|
313
525
|
const persist = () => {
|
|
314
526
|
try {
|
|
@@ -387,6 +599,331 @@ function createRuntime(ctx, config) {
|
|
|
387
599
|
return out
|
|
388
600
|
}
|
|
389
601
|
|
|
602
|
+
/**
|
|
603
|
+
* The disarmed-goal records of the checkpoint: one entry per session whose
|
|
604
|
+
* goal this plugin disarmed and has not re-armed yet.
|
|
605
|
+
*/
|
|
606
|
+
const disarmedGoalRecords = () => (
|
|
607
|
+
state.disarmedGoals && typeof state.disarmedGoals === 'object' ? state.disarmedGoals : {}
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* The LEGACY (pause-based) parked-goal records of the checkpoint. They are
|
|
612
|
+
* still read so a goal the previous build paused can be resumed once.
|
|
613
|
+
*/
|
|
614
|
+
const parkedGoalRecords = () => (
|
|
615
|
+
state.parkedGoals && typeof state.parkedGoals === 'object' ? state.parkedGoals : {}
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
/** The live agent of a session, or undefined when it is not attached. */
|
|
619
|
+
const liveAgent = (sessionId) => {
|
|
620
|
+
if (!sessionId) return undefined
|
|
621
|
+
try {
|
|
622
|
+
if (ctx.agents && typeof ctx.agents.get === 'function') {
|
|
623
|
+
const agent = ctx.agents.get(sessionId)
|
|
624
|
+
if (agent) return agent
|
|
625
|
+
}
|
|
626
|
+
} catch {}
|
|
627
|
+
return undefined
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** The live goal of a session, or undefined when it has none. */
|
|
631
|
+
const goalFor = (sessionId, agent) => {
|
|
632
|
+
const target = agent || liveAgent(sessionId)
|
|
633
|
+
if (!target) return undefined
|
|
634
|
+
try {
|
|
635
|
+
return ctx.goals.get(target)
|
|
636
|
+
} catch {
|
|
637
|
+
return undefined
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Disarm the goal of one session so a pending restart can proceed.
|
|
643
|
+
*
|
|
644
|
+
* `ctx.goals.disarm(agent)` removes the process-local continuation authority
|
|
645
|
+
* WITHOUT touching the durable goal: the goal stays `active`, the round that
|
|
646
|
+
* is running right now still finishes normally (no turn is cancelled and
|
|
647
|
+
* nothing is lost), but the goal round driver will not start another round.
|
|
648
|
+
* That is what lets the session go idle so the restart can proceed; after the
|
|
649
|
+
* boot the goal is re-armed through `ctx.goals.resume(...)`.
|
|
650
|
+
*
|
|
651
|
+
* Never throws: a failed disarm is reported instead of propagated, so it can
|
|
652
|
+
* never break the restart gate.
|
|
653
|
+
*/
|
|
654
|
+
const disarmGoal = (sessionId, agent) => {
|
|
655
|
+
const target = agent || liveAgent(sessionId)
|
|
656
|
+
if (!target) {
|
|
657
|
+
return { sessionId, disarmed: false, reason: GOAL_BLOCK_REASONS.NO_AGENT, error: 'session-not-live' }
|
|
658
|
+
}
|
|
659
|
+
const goal = goalFor(sessionId, target)
|
|
660
|
+
if (!goal || typeof goal !== 'object') return { sessionId, disarmed: false, reason: 'no-goal' }
|
|
661
|
+
const goalId = goal.id == null ? null : String(goal.id)
|
|
662
|
+
const { disarms, reported } = selectGoalDisarms([{ sessionId }], {
|
|
663
|
+
getGoal: () => goal,
|
|
664
|
+
disarmedGoals: disarmedGoalRecords(),
|
|
665
|
+
parkedGoals: parkedGoalRecords(),
|
|
666
|
+
})
|
|
667
|
+
if (disarms.length === 0) {
|
|
668
|
+
const reason = reported.length > 0 ? reported[0].reason : 'goal-not-disarmable'
|
|
669
|
+
return { sessionId, disarmed: false, goalId, reason }
|
|
670
|
+
}
|
|
671
|
+
const goals = ctx.goals
|
|
672
|
+
if (!goals || typeof goals.disarm !== 'function') {
|
|
673
|
+
return { sessionId, disarmed: false, goalId, reason: 'disarm-unavailable', error: 'goals.disarm is not available' }
|
|
674
|
+
}
|
|
675
|
+
let outcome
|
|
676
|
+
try {
|
|
677
|
+
outcome = goals.disarm(target)
|
|
678
|
+
} catch (error) {
|
|
679
|
+
return { sessionId, disarmed: false, goalId, reason: 'disarm-failed', error: describeError(error) }
|
|
680
|
+
}
|
|
681
|
+
const onDisarmed = () => {
|
|
682
|
+
setDisarmedGoal(state, sessionId, { goalId, disarmedAt: Date.now() })
|
|
683
|
+
persist()
|
|
684
|
+
log.info(`disarmed goal ${goalId} of session ${sessionId}; the running round still finishes and the goal is re-armed after the restart`)
|
|
685
|
+
}
|
|
686
|
+
if (outcome && typeof outcome.then === 'function') {
|
|
687
|
+
outcome.then(onDisarmed, (error) => {
|
|
688
|
+
log.warn(`could not disarm goal ${goalId} of session ${sessionId}:`, describeError(error))
|
|
689
|
+
})
|
|
690
|
+
return { sessionId, disarmed: true, goalId, reason: GOAL_BLOCK_REASONS.ACTIVE, pending: true }
|
|
691
|
+
}
|
|
692
|
+
onDisarmed()
|
|
693
|
+
return { sessionId, disarmed: true, goalId, reason: GOAL_BLOCK_REASONS.ACTIVE }
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* The live SESSION of a session id: the readable conversation surface
|
|
698
|
+
* (`deriveMessages` / `requestHeader` / `requestContext` / `snapshotEvents`).
|
|
699
|
+
* Falls back to the live agent's session when the sessions service is absent.
|
|
700
|
+
*/
|
|
701
|
+
const liveSession = (sessionId) => {
|
|
702
|
+
if (!sessionId) return undefined
|
|
703
|
+
try {
|
|
704
|
+
const sessions = ctx.get('sessions')
|
|
705
|
+
if (sessions && typeof sessions.get === 'function') {
|
|
706
|
+
const session = sessions.get(String(sessionId))
|
|
707
|
+
if (session) return session
|
|
708
|
+
}
|
|
709
|
+
} catch {}
|
|
710
|
+
const agent = liveAgent(sessionId)
|
|
711
|
+
return agent && agent.session ? agent.session : undefined
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** Read the readable surface of one live session. Never throws. */
|
|
715
|
+
const readSession = (sessionId) => {
|
|
716
|
+
const session = liveSession(sessionId)
|
|
717
|
+
if (!session) return { session: undefined, messages: undefined, header: undefined, requestContext: undefined, events: undefined }
|
|
718
|
+
const read = (fn) => {
|
|
719
|
+
try {
|
|
720
|
+
return typeof session[fn] === 'function' ? session[fn]() : undefined
|
|
721
|
+
} catch {
|
|
722
|
+
return undefined
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
return {
|
|
726
|
+
session,
|
|
727
|
+
messages: read('deriveMessages'),
|
|
728
|
+
header: read('requestHeader'),
|
|
729
|
+
requestContext: read('requestContext'),
|
|
730
|
+
events: read('snapshotEvents'),
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* The context-overflow verdict of a session, from its LIVE surface only.
|
|
736
|
+
*
|
|
737
|
+
* Evidence: the newest assistant message `usage.inputTokens` versus the
|
|
738
|
+
* session's `contextWindow`, or a recent turn / event that failed with a
|
|
739
|
+
* context-window error. Unknown numbers never mean overflow, so an unknown
|
|
740
|
+
* session keeps the normal resume path. Never throws.
|
|
741
|
+
*/
|
|
742
|
+
const overflowFor = (sessionId) => {
|
|
743
|
+
const unknown = { over: false, inputTokens: null, contextWindow: null, detail: null }
|
|
744
|
+
try {
|
|
745
|
+
const read = readSession(sessionId)
|
|
746
|
+
if (!read.session) return unknown
|
|
747
|
+
const info = read.requestContext
|
|
748
|
+
const contextWindow = info && typeof info === 'object' && Number.isFinite(info.contextWindow) ? info.contextWindow : null
|
|
749
|
+
return contextOverflow({ messages: read.messages, contextWindow, events: read.events })
|
|
750
|
+
} catch {
|
|
751
|
+
return unknown
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Hand an over-limit session off to a fresh, usable session.
|
|
757
|
+
*
|
|
758
|
+
* A session whose conversation outgrew the model window can neither continue
|
|
759
|
+
* nor be compacted: every request fails with "context window exceeds limit",
|
|
760
|
+
* and the compaction request needs the same oversized context. The handoff
|
|
761
|
+
* carries what still fits — the goal objective, the checkpoint reasons and a
|
|
762
|
+
* bounded tail of the conversation — into a NEW live agent/session, then marks
|
|
763
|
+
* the old session and disarms its goal so it stops issuing doomed requests.
|
|
764
|
+
*
|
|
765
|
+
* Nothing is created when the source cannot be read.
|
|
766
|
+
*
|
|
767
|
+
* @param {{ sessionId?: string, maxChars?: number, callerAgent?: object }} [options]
|
|
768
|
+
* @returns {Promise<object>} `{ ok, sessionId, carriedChars, instruction }`
|
|
769
|
+
*/
|
|
770
|
+
const handoff = async ({ sessionId, maxChars, callerAgent } = {}) => {
|
|
771
|
+
const requested = typeof sessionId === 'string' && sessionId.trim() !== '' ? sessionId.trim() : null
|
|
772
|
+
const sourceId = requested !== null ? requested : (callerAgent && callerAgent.id ? String(callerAgent.id) : null)
|
|
773
|
+
if (sourceId === null) {
|
|
774
|
+
return { ok: false, error: 'no-session', instruction: 'No source session: pass sessionId, or call this tool from the session that should be handed off.' }
|
|
775
|
+
}
|
|
776
|
+
const read = readSession(sourceId)
|
|
777
|
+
if (!read.session) {
|
|
778
|
+
return {
|
|
779
|
+
ok: false,
|
|
780
|
+
error: 'source-session-not-live',
|
|
781
|
+
sourceSessionId: sourceId,
|
|
782
|
+
instruction: `Session ${sourceId} is not live in this process, so its conversation cannot be read and no handoff was created.`,
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
if (read.messages === undefined) {
|
|
786
|
+
return {
|
|
787
|
+
ok: false,
|
|
788
|
+
error: 'source-unreadable',
|
|
789
|
+
sourceSessionId: sourceId,
|
|
790
|
+
instruction: `Session ${sourceId} did not expose its messages, so no handoff was created.`,
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
const header = read.header
|
|
794
|
+
const cwd = header && typeof header.cwd === 'string' && path.isAbsolute(header.cwd) ? header.cwd : null
|
|
795
|
+
if (cwd === null) {
|
|
796
|
+
return {
|
|
797
|
+
ok: false,
|
|
798
|
+
error: 'source-cwd-unknown',
|
|
799
|
+
sourceSessionId: sourceId,
|
|
800
|
+
instruction: `Session ${sourceId} has no absolute working directory in its header, so a new session could not be created for the handoff.`,
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const goal = goalFor(sourceId)
|
|
805
|
+
const objective = goal && typeof goal === 'object'
|
|
806
|
+
? [goal.objective, goal.title, goal.description].find((value) => typeof value === 'string' && value.trim() !== '') ?? null
|
|
807
|
+
: null
|
|
808
|
+
const entry = state.sessions && state.sessions[sourceId] ? state.sessions[sourceId] : null
|
|
809
|
+
const goalLabel = entry && entry.goal && entry.goal.goalId != null
|
|
810
|
+
? String(entry.goal.goalId)
|
|
811
|
+
: (goal && goal.id != null ? String(goal.id) : null)
|
|
812
|
+
const budget = Number.isFinite(maxChars) && maxChars > 0 ? Math.trunc(maxChars) : HANDOFF_DEFAULT_MAX_CHARS
|
|
813
|
+
const composed = composeHandoff({
|
|
814
|
+
sessionId: sourceId,
|
|
815
|
+
objective,
|
|
816
|
+
goalLabel,
|
|
817
|
+
reasons: entry && Array.isArray(entry.reasons) ? entry.reasons : [],
|
|
818
|
+
messages: read.messages,
|
|
819
|
+
maxChars: budget,
|
|
820
|
+
})
|
|
821
|
+
|
|
822
|
+
const newSessionId = 'session-' + crypto.randomUUID()
|
|
823
|
+
let created
|
|
824
|
+
try {
|
|
825
|
+
created = await ctx.agents.create({
|
|
826
|
+
sessionId: newSessionId,
|
|
827
|
+
meta: { cwd, parentSession: sourceId, createdAt: Date.now() },
|
|
828
|
+
})
|
|
829
|
+
} catch (error) {
|
|
830
|
+
const detail = describeError(error)
|
|
831
|
+
log.warn(`could not create the handoff session for ${sourceId}:`, detail)
|
|
832
|
+
return {
|
|
833
|
+
ok: false,
|
|
834
|
+
error: 'create-failed',
|
|
835
|
+
sourceSessionId: sourceId,
|
|
836
|
+
detail,
|
|
837
|
+
instruction: `The new session could not be created (${detail}); session ${sourceId} is unchanged.`,
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
let newAgent = created && typeof created === 'object' ? (created.agent ?? created.handle ?? created) : undefined
|
|
841
|
+
if (!newAgent || typeof newAgent.followup !== 'function' || newAgent.id === undefined) newAgent = liveAgent(newSessionId)
|
|
842
|
+
if (!newAgent || typeof newAgent.followup !== 'function') {
|
|
843
|
+
return {
|
|
844
|
+
ok: false,
|
|
845
|
+
error: 'handoff-agent-unavailable',
|
|
846
|
+
sourceSessionId: sourceId,
|
|
847
|
+
sessionId: newSessionId,
|
|
848
|
+
instruction: `The new session ${newSessionId} exposes no agent to deliver the handoff text to; session ${sourceId} is unchanged.`,
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
const continueId = newAgent.id === undefined || newAgent.id === null ? newSessionId : String(newAgent.id)
|
|
852
|
+
try {
|
|
853
|
+
newAgent.followup(createContinuationMessage(composed.text, {
|
|
854
|
+
summary: 'handoff of a session that exceeded the model context window',
|
|
855
|
+
}))
|
|
856
|
+
} catch (error) {
|
|
857
|
+
const detail = describeError(error)
|
|
858
|
+
log.warn(`the handoff text was rejected for ${continueId}:`, detail)
|
|
859
|
+
return {
|
|
860
|
+
ok: false,
|
|
861
|
+
error: 'deliver-failed',
|
|
862
|
+
sourceSessionId: sourceId,
|
|
863
|
+
sessionId: continueId,
|
|
864
|
+
detail,
|
|
865
|
+
instruction: `The handoff text could not be delivered to ${continueId} (${detail}); session ${sourceId} is unchanged.`,
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
const at = Date.now()
|
|
870
|
+
setHandoff(state, sourceId, { handedOffTo: continueId, handoffAt: at, carriedChars: composed.carriedChars })
|
|
871
|
+
setRecoveryOutcome(state, sourceId, { action: 'handed-off', detail: `handed off to ${continueId}`, now: at })
|
|
872
|
+
persist()
|
|
873
|
+
|
|
874
|
+
// (e) the over-limit session must stop issuing doomed requests.
|
|
875
|
+
let disarmedGoal = false
|
|
876
|
+
try {
|
|
877
|
+
const result = disarmGoal(sourceId, requested === null ? callerAgent : undefined)
|
|
878
|
+
disarmedGoal = result.disarmed === true
|
|
879
|
+
} catch (error) {
|
|
880
|
+
log.warn(`could not disarm the goal of the handed-off session ${sourceId}:`, describeError(error))
|
|
881
|
+
}
|
|
882
|
+
log.info(`handed session ${sourceId} off to ${continueId} (${composed.carriedChars} characters carried)`)
|
|
883
|
+
return {
|
|
884
|
+
ok: true,
|
|
885
|
+
sourceSessionId: sourceId,
|
|
886
|
+
sessionId: continueId,
|
|
887
|
+
carriedChars: composed.carriedChars,
|
|
888
|
+
carriedTailChars: composed.tailChars,
|
|
889
|
+
disarmedGoal,
|
|
890
|
+
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.`,
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* Disarm the goals of the sessions that block the pending restart.
|
|
896
|
+
*
|
|
897
|
+
* A goal-driven session keeps its own agent busy (the goal round driver
|
|
898
|
+
* starts the next round within milliseconds), so the restart gate would never
|
|
899
|
+
* open. Disarming removes that authority: the CURRENT round finishes
|
|
900
|
+
* normally, no new round starts, the session goes idle and the next poll sees
|
|
901
|
+
* a quiet host. Goals are never paused and no turn is ever cancelled.
|
|
902
|
+
*/
|
|
903
|
+
const disarmBlockingGoals = (busy) => {
|
|
904
|
+
const restart = state.restart
|
|
905
|
+
if (!restart) return { disarmed: [], reported: [] }
|
|
906
|
+
if (restart.parkGoals === false) return { disarmed: [], reported: [], skipped: 'park-goals-disabled' }
|
|
907
|
+
const list = Array.isArray(busy) ? busy : []
|
|
908
|
+
if (list.length === 0) return { disarmed: [], reported: [] }
|
|
909
|
+
const { disarms, reported } = selectGoalDisarms(list, {
|
|
910
|
+
getGoal: (sessionId) => goalFor(sessionId),
|
|
911
|
+
disarmedGoals: disarmedGoalRecords(),
|
|
912
|
+
parkedGoals: parkedGoalRecords(),
|
|
913
|
+
})
|
|
914
|
+
const disarmed = []
|
|
915
|
+
for (const item of disarms) {
|
|
916
|
+
try {
|
|
917
|
+
const result = disarmGoal(item.sessionId)
|
|
918
|
+
if (result.disarmed) disarmed.push(result)
|
|
919
|
+
else log.warn(`could not disarm the goal of session ${item.sessionId}: ${result.reason}${result.error ? ` (${result.error})` : ''}`)
|
|
920
|
+
} catch (error) {
|
|
921
|
+
log.warn(`disarming the goal of session ${item.sessionId} failed:`, describeError(error))
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
return { disarmed, reported }
|
|
925
|
+
}
|
|
926
|
+
|
|
390
927
|
const anyAgentRunning = () => busyAgents().length > 0
|
|
391
928
|
|
|
392
929
|
/** The two-layer restart gate: no busy conversation AND a quiet settle window. */
|
|
@@ -578,6 +1115,13 @@ function createRuntime(ctx, config) {
|
|
|
578
1115
|
// This re-check also protects the ungated fallback (>20 gate aborts) and the
|
|
579
1116
|
// forceRestartWhenBusy path: even when the detached helper cannot re-check the
|
|
580
1117
|
// gate itself, a busy conversation is never killed here.
|
|
1118
|
+
// Last chance to park a goal-driven session: without it the detached
|
|
1119
|
+
// helper would refuse to kill the process and the restart would wait.
|
|
1120
|
+
try {
|
|
1121
|
+
disarmBlockingGoals(busyAgents())
|
|
1122
|
+
} catch (error) {
|
|
1123
|
+
log.warn('could not disarm the goals of the blocking sessions:', String(error))
|
|
1124
|
+
}
|
|
581
1125
|
const verdict = gate()
|
|
582
1126
|
if (!verdict.allow) {
|
|
583
1127
|
log.info('restart deferred: a conversation is still active or the host is settling', {
|
|
@@ -639,6 +1183,15 @@ function createRuntime(ctx, config) {
|
|
|
639
1183
|
}
|
|
640
1184
|
const now = Date.now()
|
|
641
1185
|
const busy = busyAgents()
|
|
1186
|
+
// A goal-driven session blocks the restart forever: park its goal BEFORE the
|
|
1187
|
+
// gate decides that it is blocked, so the next poll sees an idle session.
|
|
1188
|
+
if (busy.length > 0) {
|
|
1189
|
+
try {
|
|
1190
|
+
disarmBlockingGoals(busy)
|
|
1191
|
+
} catch (error) {
|
|
1192
|
+
log.warn('could not disarm the goals of the blocking sessions:', String(error))
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
642
1195
|
const decision = restartDecision(state, {
|
|
643
1196
|
anyAgentRunning: busy.length > 0,
|
|
644
1197
|
busyAgents: busy,
|
|
@@ -693,6 +1246,7 @@ function createRuntime(ctx, config) {
|
|
|
693
1246
|
stateFile,
|
|
694
1247
|
log,
|
|
695
1248
|
persist,
|
|
1249
|
+
toolErrors,
|
|
696
1250
|
startPolling,
|
|
697
1251
|
stopPolling,
|
|
698
1252
|
tick,
|
|
@@ -707,7 +1261,49 @@ function createRuntime(ctx, config) {
|
|
|
707
1261
|
busyAgents,
|
|
708
1262
|
gate,
|
|
709
1263
|
sessionTitle,
|
|
1264
|
+
disarmGoal,
|
|
1265
|
+
disarmBlockingGoals,
|
|
1266
|
+
disarmedGoalRecords,
|
|
1267
|
+
parkedGoalRecords,
|
|
1268
|
+
goalFor,
|
|
1269
|
+
handoffRecord: (sessionId) => handoffRecord(state, sessionId),
|
|
1270
|
+
overflowFor,
|
|
1271
|
+
handoff,
|
|
710
1272
|
boot,
|
|
1273
|
+
/**
|
|
1274
|
+
* Re-run the boot recovery for the still-pending sessions, right now.
|
|
1275
|
+
*
|
|
1276
|
+
* This is the retry path behind `continuum_recover_now` and the POST
|
|
1277
|
+
* `/recover` route: a session whose agent was not live yet, or whose
|
|
1278
|
+
* `goals.resume` failed, can be retried without waiting for the next boot.
|
|
1279
|
+
* Every attempt reports its own action/detail, exactly like the boot pass.
|
|
1280
|
+
*
|
|
1281
|
+
* @param {string} [sessionId] one session, or every pending session
|
|
1282
|
+
* @returns {Promise<object[]>} one outcome per attempted session
|
|
1283
|
+
*/
|
|
1284
|
+
async recoverNow(sessionId) {
|
|
1285
|
+
const target = typeof sessionId === 'string' && sessionId.trim() !== '' ? sessionId.trim() : null
|
|
1286
|
+
const pending = recoverableSessions(state, {}).filter((entry) => (
|
|
1287
|
+
target === null ? true : String(entry.sessionId) === target
|
|
1288
|
+
))
|
|
1289
|
+
if (pending.length === 0 && target !== null) {
|
|
1290
|
+
return [{ sessionId: target, action: 'nothing', detail: 'no-pending-recovery' }]
|
|
1291
|
+
}
|
|
1292
|
+
const results = []
|
|
1293
|
+
for (const entry of pending) {
|
|
1294
|
+
try {
|
|
1295
|
+
results.push(await recoverSession(ctx, runtime, entry, config))
|
|
1296
|
+
} catch (error) {
|
|
1297
|
+
const detail = describeError(error)
|
|
1298
|
+
setRecoveryOutcome(state, entry.sessionId, { action: 'prompt-failed', detail })
|
|
1299
|
+
results.push({ sessionId: entry.sessionId, action: 'prompt-failed', detail })
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
try {
|
|
1303
|
+
runtime.persist()
|
|
1304
|
+
} catch {}
|
|
1305
|
+
return results
|
|
1306
|
+
},
|
|
711
1307
|
}
|
|
712
1308
|
return runtime
|
|
713
1309
|
}
|
|
@@ -721,9 +1317,10 @@ function createRuntime(ctx, config) {
|
|
|
721
1317
|
* message: it is NOT human authority.
|
|
722
1318
|
*
|
|
723
1319
|
* @param {string} text
|
|
1320
|
+
* @param {{ summary?: string }} [options] one-line label of what this notice is
|
|
724
1321
|
* @returns {object} a frozen user message
|
|
725
1322
|
*/
|
|
726
|
-
function createContinuationMessage(text) {
|
|
1323
|
+
function createContinuationMessage(text, { summary } = {}) {
|
|
727
1324
|
return Object.freeze({
|
|
728
1325
|
id: crypto.randomUUID(),
|
|
729
1326
|
role: 'user',
|
|
@@ -731,7 +1328,9 @@ function createContinuationMessage(text) {
|
|
|
731
1328
|
source: Object.freeze({
|
|
732
1329
|
kind: 'continuum',
|
|
733
1330
|
form: 'notice',
|
|
734
|
-
summary:
|
|
1331
|
+
summary: typeof summary === 'string' && summary.trim() !== ''
|
|
1332
|
+
? summary.trim()
|
|
1333
|
+
: 'automatic continuation after a DeepSeek Harness restart',
|
|
735
1334
|
}),
|
|
736
1335
|
})
|
|
737
1336
|
}
|
|
@@ -759,6 +1358,24 @@ function describeError(value) {
|
|
|
759
1358
|
}
|
|
760
1359
|
}
|
|
761
1360
|
|
|
1361
|
+
/**
|
|
1362
|
+
* Record the self-reported outcome of one recovery attempt for a session.
|
|
1363
|
+
*
|
|
1364
|
+
* Every return path of `recoverSession` calls this: a recovery that did nothing
|
|
1365
|
+
* must say WHY it did nothing, otherwise a stuck session is invisible in
|
|
1366
|
+
* `continuum_status` and in the `?state=1` route.
|
|
1367
|
+
*
|
|
1368
|
+
* @param {object} state
|
|
1369
|
+
* @param {string} sessionId
|
|
1370
|
+
* @param {string} action short action label (`resume-disarmed-goal`, …)
|
|
1371
|
+
* @param {string|null} [detail] short human-readable reason
|
|
1372
|
+
*/
|
|
1373
|
+
function noteRecovery(state, sessionId, action, detail = null) {
|
|
1374
|
+
try {
|
|
1375
|
+
setRecoveryOutcome(state, sessionId, { action, detail: detail == null ? null : String(detail) })
|
|
1376
|
+
} catch {}
|
|
1377
|
+
}
|
|
1378
|
+
|
|
762
1379
|
/**
|
|
763
1380
|
* Decide what a session needs after a restart and do exactly that.
|
|
764
1381
|
* At-most-once per boot: the resume attempt is persisted before any side effect.
|
|
@@ -775,9 +1392,61 @@ async function recoverSession(ctx, runtime, entry, config) {
|
|
|
775
1392
|
entry.pendingResume = false
|
|
776
1393
|
entry.interrupted = false
|
|
777
1394
|
entry.lastResumeError = entry.lastResumeError || 'resume attempts exhausted'
|
|
1395
|
+
noteRecovery(state, sessionId, 'nothing', 'resume-attempts-exhausted')
|
|
778
1396
|
runtime.persist()
|
|
779
1397
|
log.info(`giving up on session ${sessionId}: resume attempts exhausted`, { lastResumeError: entry.lastResumeError })
|
|
780
|
-
return { sessionId, action: '
|
|
1398
|
+
return { sessionId, action: 'nothing', detail: 'resume-attempts-exhausted', error: entry.lastResumeError }
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
// CONTEXT-OVERFLOW GUARD. A session whose conversation outgrew the model window
|
|
1402
|
+
// can neither continue nor be compacted (the compaction request needs the same
|
|
1403
|
+
// oversized context): every request to it fails with "context window exceeds
|
|
1404
|
+
// limit". Re-arming its goal or queueing a continuation message into it can
|
|
1405
|
+
// therefore only fail, so the guard runs BEFORE the goal re-arm and before the
|
|
1406
|
+
// disarm decision. Evidence: a verdict an earlier pass already recorded, or the
|
|
1407
|
+
// LIVE session (the newest assistant message's `usage.inputTokens` versus the
|
|
1408
|
+
// session's `contextWindow`, or a recent context-window failure). Unknown
|
|
1409
|
+
// numbers never block the normal path.
|
|
1410
|
+
const recordedOverflow = entry.contextOverflow && entry.contextOverflow.over === true
|
|
1411
|
+
? entry.contextOverflow
|
|
1412
|
+
: null
|
|
1413
|
+
let overflow = null
|
|
1414
|
+
if (recordedOverflow !== null) {
|
|
1415
|
+
overflow = {
|
|
1416
|
+
over: true,
|
|
1417
|
+
inputTokens: Number.isFinite(recordedOverflow.inputTokens) ? recordedOverflow.inputTokens : null,
|
|
1418
|
+
contextWindow: Number.isFinite(recordedOverflow.contextWindow) ? recordedOverflow.contextWindow : null,
|
|
1419
|
+
detail: typeof recordedOverflow.detail === 'string' && recordedOverflow.detail !== ''
|
|
1420
|
+
? recordedOverflow.detail
|
|
1421
|
+
: 'the session is over the model context window (recorded by an earlier recovery)',
|
|
1422
|
+
}
|
|
1423
|
+
} else {
|
|
1424
|
+
try {
|
|
1425
|
+
overflow = runtime.overflowFor(sessionId)
|
|
1426
|
+
} catch {
|
|
1427
|
+
overflow = null
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
if (overflow && overflow.over === true) {
|
|
1431
|
+
const detail = typeof overflow.detail === 'string' && overflow.detail !== ''
|
|
1432
|
+
? overflow.detail
|
|
1433
|
+
: 'the session is over the model context window'
|
|
1434
|
+
setContextOverflow(state, sessionId, {
|
|
1435
|
+
over: true,
|
|
1436
|
+
inputTokens: overflow.inputTokens,
|
|
1437
|
+
contextWindow: overflow.contextWindow,
|
|
1438
|
+
detail,
|
|
1439
|
+
detectedAt: Date.now(),
|
|
1440
|
+
})
|
|
1441
|
+
// Out of the automatic resume path for good: the goal is NOT re-armed, no
|
|
1442
|
+
// continuation message is queued, and `recoverableSessions` skips an
|
|
1443
|
+
// over-limit entry from now on. The recovery attempt is still reported.
|
|
1444
|
+
entry.pendingResume = false
|
|
1445
|
+
entry.interrupted = false
|
|
1446
|
+
noteRecovery(state, sessionId, 'context-overflow', detail)
|
|
1447
|
+
runtime.persist()
|
|
1448
|
+
log.warn(`session ${sessionId} is over the model context window and is NOT re-armed: hand it off with continuum_handoff`, { detail })
|
|
1449
|
+
return { sessionId, action: 'context-overflow', detail, overContextLimit: true }
|
|
781
1450
|
}
|
|
782
1451
|
|
|
783
1452
|
// Persist the attempt BEFORE any side effect, so a crash cannot double-resume and
|
|
@@ -797,7 +1466,9 @@ async function recoverSession(ctx, runtime, entry, config) {
|
|
|
797
1466
|
const controller = ctx.get('sessionController')
|
|
798
1467
|
if (!controller || typeof controller.resolveAgent !== 'function') {
|
|
799
1468
|
log.warn('sessionController is unavailable; cannot resume', sessionId)
|
|
800
|
-
|
|
1469
|
+
noteRecovery(state, sessionId, 'session-controller-unavailable', 'sessionController.resolveAgent is unavailable')
|
|
1470
|
+
runtime.persist()
|
|
1471
|
+
return { sessionId, action: 'session-controller-unavailable', detail: 'sessionController.resolveAgent is unavailable' }
|
|
801
1472
|
}
|
|
802
1473
|
// A freshly reloaded profile can expose the live agent a moment after the
|
|
803
1474
|
// state file was written, so a THROWN error is retried inside the same boot
|
|
@@ -827,51 +1498,112 @@ async function recoverSession(ctx, runtime, entry, config) {
|
|
|
827
1498
|
}
|
|
828
1499
|
}
|
|
829
1500
|
if (lastError) {
|
|
830
|
-
// Keep pendingResume: the session stays recoverable on a later boot
|
|
831
|
-
// failure is
|
|
1501
|
+
// Keep pendingResume: the session stays recoverable on a later boot (or a
|
|
1502
|
+
// later continuum_recover_now retry), but the failure is visible instead of
|
|
1503
|
+
// vanishing.
|
|
832
1504
|
entry.lastResumeError = lastError
|
|
1505
|
+
noteRecovery(state, sessionId, 'agent-not-live', lastError)
|
|
833
1506
|
runtime.persist()
|
|
834
1507
|
log.warn(`could not resume ${sessionId}:`, lastError)
|
|
835
|
-
return { sessionId, action: '
|
|
1508
|
+
return { sessionId, action: 'agent-not-live', detail: lastError, error: lastError }
|
|
836
1509
|
}
|
|
837
1510
|
agent = result && result.agent
|
|
838
1511
|
}
|
|
839
1512
|
if (!agent) {
|
|
840
1513
|
entry.lastResumeError = 'resolveAgent returned no agent'
|
|
1514
|
+
noteRecovery(state, sessionId, 'agent-not-live', entry.lastResumeError)
|
|
841
1515
|
runtime.persist()
|
|
842
|
-
return { sessionId, action: '
|
|
1516
|
+
return { sessionId, action: 'agent-not-live', detail: entry.lastResumeError, error: entry.lastResumeError }
|
|
843
1517
|
}
|
|
844
1518
|
|
|
845
1519
|
let goal
|
|
1520
|
+
let goalLookupError = null
|
|
846
1521
|
try {
|
|
847
1522
|
goal = ctx.goals.get(agent)
|
|
848
1523
|
} catch (error) {
|
|
849
|
-
|
|
1524
|
+
goalLookupError = describeError(error)
|
|
1525
|
+
log.warn(`goals.get failed for ${sessionId}:`, goalLookupError)
|
|
850
1526
|
}
|
|
1527
|
+
if (!goal || typeof goal !== 'object') {
|
|
1528
|
+
goal = undefined
|
|
1529
|
+
if (goalLookupError === null) goalLookupError = 'goals.get returned no goal'
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
const disarmed = disarmedRecord(state, sessionId)
|
|
1533
|
+
const legacyParked = parkedRecord(state, sessionId)
|
|
1534
|
+
let resumeError = null
|
|
1535
|
+
let resumeAction = null
|
|
851
1536
|
|
|
1537
|
+
// (1) The goal THIS plugin disarmed before the restart: it is still durably
|
|
1538
|
+
// `active` and only its activation is `disarmed`, so re-arming it with its
|
|
1539
|
+
// current revision lets the goal round driver continue the work.
|
|
1540
|
+
if (goal && disarmed && goal.phase === 'active' && goal.activation === 'disarmed' && disarmed.goalId === goal.id) {
|
|
1541
|
+
resumeAction = 'resume-disarmed-goal'
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
// (2) The goal the PREVIOUS (pause-based) build PAUSED: we paused it, so we may
|
|
1545
|
+
// resume it. A goal the USER paused has no record and must stay paused.
|
|
1546
|
+
if (resumeAction === null && goal && legacyParked && goal.phase === 'paused' && legacyParked.goalId === goal.id) {
|
|
1547
|
+
resumeAction = 'resume-paused-goal'
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
// (3) The already proven boot path: an active goal whose continuation authority
|
|
1551
|
+
// is gone is re-armed (the host may have disarmed it on shutdown).
|
|
852
1552
|
if (
|
|
853
|
-
|
|
1553
|
+
resumeAction === null
|
|
1554
|
+
&& goal
|
|
854
1555
|
&& goal.phase === 'active'
|
|
855
1556
|
&& goal.activation === 'disarmed'
|
|
856
1557
|
&& goal.roundsStarted < goal.maxGoalRounds
|
|
857
1558
|
) {
|
|
1559
|
+
resumeAction = 'rearm-goal'
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
if (resumeAction !== null) {
|
|
858
1563
|
try {
|
|
859
1564
|
ctx.goals.resume(agent, { id: goal.id, revision: goal.revision })
|
|
1565
|
+
// The goal is armed again: stamp the record(s) of this session with the
|
|
1566
|
+
// resume timestamp, so the status shows the goal as resumed (the record is
|
|
1567
|
+
// reporting only — it never decides whether a goal may be disarmed).
|
|
1568
|
+
stampResumedGoal(state, sessionId, { resumedAt: Date.now() })
|
|
860
1569
|
entry.pendingResume = false
|
|
861
1570
|
entry.running = false
|
|
862
1571
|
entry.interrupted = false
|
|
863
1572
|
entry.lastResumeError = null
|
|
864
1573
|
entry.goal = { goalId: goal.id, goalRevision: goal.revision + 1, phase: 'active' }
|
|
1574
|
+
noteRecovery(state, sessionId, resumeAction, `goal ${goal.id} is armed again`)
|
|
865
1575
|
runtime.persist()
|
|
866
|
-
log.info(
|
|
867
|
-
return { sessionId, action:
|
|
1576
|
+
log.info(`${resumeAction}: goal ${goal.id} of session ${sessionId} is armed again; the goal round driver continues the work`)
|
|
1577
|
+
return { sessionId, action: resumeAction, goalId: goal.id, detail: `goal ${goal.id} is armed again` }
|
|
868
1578
|
} catch (error) {
|
|
869
|
-
|
|
1579
|
+
// Stale revision or an exhausted round budget: fall back to the
|
|
1580
|
+
// continuation message and KEEP the record, so a later boot (or a manual
|
|
1581
|
+
// continuum_recover_now retry) can try to resume the goal again.
|
|
1582
|
+
resumeError = describeError(error)
|
|
1583
|
+
entry.lastResumeError = resumeError
|
|
1584
|
+
log.warn(`could not resume goal ${goal.id} of ${sessionId} (${resumeAction}):`, resumeError)
|
|
870
1585
|
}
|
|
871
1586
|
}
|
|
872
1587
|
|
|
873
1588
|
if (!entry.pendingResume && !entry.interrupted) {
|
|
874
|
-
|
|
1589
|
+
if (resumeError) {
|
|
1590
|
+
entry.lastResumeError = resumeError
|
|
1591
|
+
noteRecovery(state, sessionId, resumeAction || 'nothing', `resume failed: ${resumeError}`)
|
|
1592
|
+
runtime.persist()
|
|
1593
|
+
return { sessionId, action: resumeAction || 'nothing', detail: `resume failed: ${resumeError}`, error: resumeError }
|
|
1594
|
+
}
|
|
1595
|
+
noteRecovery(state, sessionId, resumeAction || 'nothing', resumeAction ? 'goal handled, session not pending' : 'session-not-pending')
|
|
1596
|
+
runtime.persist()
|
|
1597
|
+
return { sessionId, action: resumeAction || 'nothing', detail: 'session-not-pending' }
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
// A failed goal lookup is not a reason to skip the session: the continuation
|
|
1601
|
+
// message still makes progress, and the reason is recorded for the operator.
|
|
1602
|
+
let pendingAction = 'prompt-continuation'
|
|
1603
|
+
let pendingDetail = resumeError ? `goal resume failed: ${resumeError}` : 'continuation message queued'
|
|
1604
|
+
if (goal === undefined) {
|
|
1605
|
+
pendingAction = 'goal-lookup-failed'
|
|
1606
|
+
pendingDetail = `${goalLookupError || 'goals.get returned no goal'}; continuation message queued`
|
|
875
1607
|
}
|
|
876
1608
|
|
|
877
1609
|
// Inject exactly one continuation message through the live agent's inbox. The
|
|
@@ -879,7 +1611,9 @@ async function recoverSession(ctx, runtime, entry, config) {
|
|
|
879
1611
|
// signature is `prompt(request, signal)` and it hardcodes a human `user` source.
|
|
880
1612
|
if (typeof agent.followup !== 'function') {
|
|
881
1613
|
log.warn('the resumed agent exposes no followup(); cannot inject the continuation prompt')
|
|
882
|
-
|
|
1614
|
+
noteRecovery(state, sessionId, 'prompt-failed', 'the agent exposes no followup()')
|
|
1615
|
+
runtime.persist()
|
|
1616
|
+
return { sessionId, action: 'prompt-failed', detail: 'the agent exposes no followup()' }
|
|
883
1617
|
}
|
|
884
1618
|
const text = continuationPrompt(entry, {
|
|
885
1619
|
reason: (Array.isArray(entry.reasons) && entry.reasons[entry.reasons.length - 1]) || 'host restart',
|
|
@@ -888,16 +1622,20 @@ async function recoverSession(ctx, runtime, entry, config) {
|
|
|
888
1622
|
try {
|
|
889
1623
|
agent.followup(createContinuationMessage(text))
|
|
890
1624
|
} catch (error) {
|
|
891
|
-
|
|
892
|
-
|
|
1625
|
+
const detail = describeError(error)
|
|
1626
|
+
log.warn(`continuation message rejected for ${sessionId}:`, detail)
|
|
1627
|
+
noteRecovery(state, sessionId, 'prompt-failed', detail)
|
|
1628
|
+
runtime.persist()
|
|
1629
|
+
return { sessionId, action: 'prompt-failed', detail }
|
|
893
1630
|
}
|
|
894
1631
|
entry.pendingResume = false
|
|
895
1632
|
entry.running = false
|
|
896
1633
|
entry.interrupted = false
|
|
897
|
-
entry.lastResumeError =
|
|
1634
|
+
entry.lastResumeError = resumeError
|
|
1635
|
+
noteRecovery(state, sessionId, pendingAction, pendingDetail)
|
|
898
1636
|
runtime.persist()
|
|
899
1637
|
log.info(`queued a continuation message for session ${sessionId}`)
|
|
900
|
-
return { sessionId, action:
|
|
1638
|
+
return { sessionId, action: pendingAction, detail: pendingDetail }
|
|
901
1639
|
}
|
|
902
1640
|
|
|
903
1641
|
/** The boot recovery pass. Runs once per host boot, bounded and idempotent. */
|
|
@@ -997,16 +1735,46 @@ function persistSafely(runtime) {
|
|
|
997
1735
|
function registerTools(ctx, runtime, config) {
|
|
998
1736
|
const log = runtime.log
|
|
999
1737
|
|
|
1738
|
+
/**
|
|
1739
|
+
* Register one tool.
|
|
1740
|
+
*
|
|
1741
|
+
* The disposer returned by `ctx.tools.register` is handed to `ctx.effect`, so
|
|
1742
|
+
* the HOST owns it: when the plugin row is disabled (or the plugin is
|
|
1743
|
+
* hot-reloaded) the fiber disposes the effect and the tool is unregistered
|
|
1744
|
+
* together with the HTTP routes. Registering it bare would leave the tools of
|
|
1745
|
+
* the OLD instance registered (and the new instance's registration would fail
|
|
1746
|
+
* as a duplicate), so the host could serve the tools of an instance whose
|
|
1747
|
+
* routes are already gone.
|
|
1748
|
+
*
|
|
1749
|
+
* A failed registration is recorded in `runtime.toolErrors` and logged; it is
|
|
1750
|
+
* never thrown, so one broken tool can never stop the plugin from loading.
|
|
1751
|
+
*/
|
|
1000
1752
|
const tool = (definition) => {
|
|
1753
|
+
const name = definition && definition.name ? String(definition.name) : null
|
|
1001
1754
|
try {
|
|
1002
|
-
ctx.
|
|
1755
|
+
ctx.effect(
|
|
1756
|
+
() => ctx.tools.register(definition),
|
|
1757
|
+
`dsh-autorestart: tool ${name ?? 'unknown'}`,
|
|
1758
|
+
)
|
|
1003
1759
|
} catch (error) {
|
|
1004
|
-
|
|
1760
|
+
runtime.toolErrors.push({ tool: name, error: describeError(error) })
|
|
1761
|
+
log.warn(`could not register tool ${name}:`, String(error))
|
|
1005
1762
|
}
|
|
1006
1763
|
}
|
|
1007
1764
|
|
|
1008
1765
|
const textOutput = (schema, render) => ({ schema, render })
|
|
1009
1766
|
|
|
1767
|
+
/**
|
|
1768
|
+
* The blocking sessions with the reason each one blocks: a session that is
|
|
1769
|
+
* held by an armed goal reports `goal-active`, a goal whose round budget is
|
|
1770
|
+
* used up reports `goal-exhausted`, and the original busy reason is kept as
|
|
1771
|
+
* `busyReason`.
|
|
1772
|
+
*/
|
|
1773
|
+
const blockingDetails = (blockedBy) => goalAwareBlockingDetails(runtime, blockedBy)
|
|
1774
|
+
|
|
1775
|
+
/** The goals this plugin parked, with the outcome of the resume. */
|
|
1776
|
+
const parkedGoalDetails = () => parkedGoalList(runtime)
|
|
1777
|
+
|
|
1010
1778
|
tool({
|
|
1011
1779
|
name: 'continuum_restart',
|
|
1012
1780
|
description:
|
|
@@ -1016,7 +1784,12 @@ function registerTools(ctx, runtime, config) {
|
|
|
1016
1784
|
+ 'forceRestartWhenBusy is enabled, otherwise the restart keeps waiting for the other conversations. '
|
|
1017
1785
|
+ 'Other conversations are never killed mid-turn: the detached helper re-checks this idle gate '
|
|
1018
1786
|
+ 'over loopback immediately before it kills the process. After the restart this '
|
|
1019
|
-
+ 'plugin resumes this session and continues the work automatically.
|
|
1787
|
+
+ 'plugin resumes this session and continues the work automatically. A goal-driven session is '
|
|
1788
|
+
+ 'DISARMED before the restart (pass parkGoals:false to turn that off): disarming only removes the '
|
|
1789
|
+
+ 'process-local continuation authority, so the round that is running right now still finishes normally '
|
|
1790
|
+
+ '(no turn is cancelled and nothing is lost), no new round starts, and after the restart the goal is '
|
|
1791
|
+
+ 're-armed and continues. A goal whose round budget is spent cannot be continued and is reported '
|
|
1792
|
+
+ 'instead. Write your final message '
|
|
1020
1793
|
+ 'in this turn, then stop calling tools — the restart fires as soon as the turn goes idle. '
|
|
1021
1794
|
+ 'Use it only when a restart is genuinely required (for example to load an installed plugin update).',
|
|
1022
1795
|
parameters: {
|
|
@@ -1026,6 +1799,7 @@ function registerTools(ctx, runtime, config) {
|
|
|
1026
1799
|
reason: { type: 'string', description: 'Why the restart is needed; recorded in the checkpoint and shown in the log.' },
|
|
1027
1800
|
resume: { type: 'boolean', description: 'Resume this session after the restart (default true).' },
|
|
1028
1801
|
defer_seconds: { type: 'number', description: 'Optional extra seconds to wait before the first idle check.' },
|
|
1802
|
+
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
1803
|
},
|
|
1030
1804
|
},
|
|
1031
1805
|
output: textOutput(
|
|
@@ -1049,10 +1823,12 @@ function registerTools(ctx, runtime, config) {
|
|
|
1049
1823
|
const agent = exec && exec.agent
|
|
1050
1824
|
const sessionId = agent && agent.id ? String(agent.id) : undefined
|
|
1051
1825
|
const resume = args.resume !== false
|
|
1826
|
+
const parkGoals = args.parkGoals !== false
|
|
1052
1827
|
requestRestart(state, {
|
|
1053
1828
|
reason: typeof args.reason === 'string' && args.reason.trim() !== '' ? args.reason.trim() : 'agent requested restart',
|
|
1054
1829
|
bySessionId: sessionId,
|
|
1055
1830
|
resume,
|
|
1831
|
+
parkGoals,
|
|
1056
1832
|
now,
|
|
1057
1833
|
})
|
|
1058
1834
|
if (sessionId && resume) {
|
|
@@ -1068,13 +1844,15 @@ function registerTools(ctx, runtime, config) {
|
|
|
1068
1844
|
}
|
|
1069
1845
|
runtime.persist()
|
|
1070
1846
|
runtime.startPolling()
|
|
1071
|
-
log.info('restart requested', { generation: state.generation, sessionId, resume })
|
|
1847
|
+
log.info('restart requested', { generation: state.generation, sessionId, resume, parkGoals })
|
|
1072
1848
|
return {
|
|
1073
1849
|
accepted: true,
|
|
1074
1850
|
generation: state.generation,
|
|
1075
1851
|
phase: state.phase,
|
|
1076
1852
|
willResume: resume && Boolean(sessionId),
|
|
1077
|
-
note:
|
|
1853
|
+
note: parkGoals
|
|
1854
|
+
? 'The restart runs at the next safe point (all agents idle). Keep this reply short; the session resumes afterwards.'
|
|
1855
|
+
: '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
1856
|
}
|
|
1079
1857
|
},
|
|
1080
1858
|
})
|
|
@@ -1083,8 +1861,12 @@ function registerTools(ctx, runtime, config) {
|
|
|
1083
1861
|
name: 'continuum_status',
|
|
1084
1862
|
description:
|
|
1085
1863
|
'Report the dsh-autorestart lifecycle: whether a restart is pending, which sessions were '
|
|
1086
|
-
+ 'checkpointed as interrupted or continuation-pending,
|
|
1087
|
-
+ '
|
|
1864
|
+
+ 'checkpointed as interrupted or continuation-pending, why each blocking session blocks '
|
|
1865
|
+
+ '("goal-active" = an armed goal keeps it busy, "goal-exhausted" = its goal can no longer be '
|
|
1866
|
+
+ 'resumed), the goals this plugin disarmed, the last recovery action/detail per session, '
|
|
1867
|
+
+ 'whether a session was handed off to a fresh session (target session id and time) or is over '
|
|
1868
|
+
+ 'the model context window (such a session is never auto-resumed), the '
|
|
1869
|
+
+ 'resolved state-file path, and the detected restart environment. Read-only.',
|
|
1088
1870
|
parameters: {
|
|
1089
1871
|
type: 'object',
|
|
1090
1872
|
additionalProperties: false,
|
|
@@ -1107,6 +1889,9 @@ function registerTools(ctx, runtime, config) {
|
|
|
1107
1889
|
gateAllow: { type: 'boolean' },
|
|
1108
1890
|
quietForMs: { type: 'number' },
|
|
1109
1891
|
blockedBy: { type: 'array', items: { type: 'object' } },
|
|
1892
|
+
parkedGoals: { type: 'array', items: { type: 'object' } },
|
|
1893
|
+
recovery: { type: 'array', items: { type: 'object' } },
|
|
1894
|
+
handoffs: { type: 'array', items: { type: 'object' } },
|
|
1110
1895
|
environment: { type: 'object' },
|
|
1111
1896
|
sessions: { type: 'array', items: { type: 'object' } },
|
|
1112
1897
|
},
|
|
@@ -1126,7 +1911,10 @@ function registerTools(ctx, runtime, config) {
|
|
|
1126
1911
|
pendingRestart: state.restart !== null,
|
|
1127
1912
|
gateAllow: verdict.allow,
|
|
1128
1913
|
quietForMs: Math.round(verdict.quietForMs),
|
|
1129
|
-
blockedBy: verdict.blockedBy,
|
|
1914
|
+
blockedBy: blockingDetails(verdict.blockedBy),
|
|
1915
|
+
parkedGoals: parkedGoalDetails(),
|
|
1916
|
+
recovery: recoveryList(state),
|
|
1917
|
+
handoffs: diagnosticSessions(state),
|
|
1130
1918
|
environment: {
|
|
1131
1919
|
platform: runtime.env.platform,
|
|
1132
1920
|
isDesktopApp: runtime.env.isDesktopApp,
|
|
@@ -1137,17 +1925,165 @@ function registerTools(ctx, runtime, config) {
|
|
|
1137
1925
|
},
|
|
1138
1926
|
...(includeSessions
|
|
1139
1927
|
? {
|
|
1140
|
-
sessions:
|
|
1141
|
-
...entry
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1928
|
+
sessions: [
|
|
1929
|
+
...recoverableSessions(state, { limit: 50 }).map((entry) => ({
|
|
1930
|
+
...entry,
|
|
1931
|
+
// Diagnostics: the last resolveAgent failure for this session (null when healthy).
|
|
1932
|
+
lastResumeError: entry.lastResumeError ?? null,
|
|
1933
|
+
handedOff: false,
|
|
1934
|
+
overContextLimit: false,
|
|
1935
|
+
})),
|
|
1936
|
+
// A handed-off or over-limit session is out of the automatic resume
|
|
1937
|
+
// path, so it is listed here instead of vanishing from the report.
|
|
1938
|
+
...diagnosticSessions(state, { limit: 50 }),
|
|
1939
|
+
],
|
|
1145
1940
|
}
|
|
1146
1941
|
: {}),
|
|
1147
1942
|
}
|
|
1148
1943
|
},
|
|
1149
1944
|
})
|
|
1150
1945
|
|
|
1946
|
+
tool({
|
|
1947
|
+
name: 'continuum_park_goal',
|
|
1948
|
+
description:
|
|
1949
|
+
'Disarm the durable goal of a session so a pending restart can proceed: a goal-driven '
|
|
1950
|
+
+ 'session keeps its own agent busy forever (the goal round driver starts the next round '
|
|
1951
|
+
+ 'immediately), so the restart would wait forever behind it. Disarming only removes the '
|
|
1952
|
+
+ 'process-local continuation authority: the goal stays active, the round that is running '
|
|
1953
|
+
+ 'right now still finishes normally (no turn is cancelled and nothing is lost), and no new '
|
|
1954
|
+
+ 'round starts. After the next restart the goal is re-armed automatically and continues. '
|
|
1955
|
+
+ 'Without a sessionId the goal of the calling session is disarmed. A goal whose round budget '
|
|
1956
|
+
+ 'is spent can never be continued, so it is reported instead of disarmed.',
|
|
1957
|
+
parameters: {
|
|
1958
|
+
type: 'object',
|
|
1959
|
+
additionalProperties: false,
|
|
1960
|
+
properties: {
|
|
1961
|
+
sessionId: { type: 'string', description: 'The session whose goal should be disarmed (default: the calling session).' },
|
|
1962
|
+
},
|
|
1963
|
+
},
|
|
1964
|
+
output: textOutput(
|
|
1965
|
+
{
|
|
1966
|
+
type: 'object',
|
|
1967
|
+
additionalProperties: false,
|
|
1968
|
+
required: ['ok', 'disarmed'],
|
|
1969
|
+
properties: {
|
|
1970
|
+
ok: { type: 'boolean' },
|
|
1971
|
+
disarmed: { type: 'boolean' },
|
|
1972
|
+
sessionId: { type: 'string' },
|
|
1973
|
+
goalId: { type: 'string' },
|
|
1974
|
+
reason: { type: 'string' },
|
|
1975
|
+
error: { type: 'string' },
|
|
1976
|
+
},
|
|
1977
|
+
},
|
|
1978
|
+
(_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
|
|
1979
|
+
),
|
|
1980
|
+
execute(args = {}, exec) {
|
|
1981
|
+
const agent = exec && exec.agent
|
|
1982
|
+
const callerId = agent && agent.id ? String(agent.id) : undefined
|
|
1983
|
+
const requested = typeof args.sessionId === 'string' ? args.sessionId.trim() : ''
|
|
1984
|
+
const sessionId = requested !== '' ? requested : callerId
|
|
1985
|
+
if (!sessionId) {
|
|
1986
|
+
return { ok: false, disarmed: false, reason: 'no-session' }
|
|
1987
|
+
}
|
|
1988
|
+
const result = runtime.disarmGoal(sessionId, sessionId === callerId ? agent : undefined)
|
|
1989
|
+
const payload = {
|
|
1990
|
+
ok: true,
|
|
1991
|
+
disarmed: result.disarmed === true,
|
|
1992
|
+
sessionId,
|
|
1993
|
+
reason: result.reason,
|
|
1994
|
+
}
|
|
1995
|
+
if (result.goalId !== undefined && result.goalId !== null) payload.goalId = String(result.goalId)
|
|
1996
|
+
if (result.error) payload.error = String(result.error)
|
|
1997
|
+
return payload
|
|
1998
|
+
},
|
|
1999
|
+
})
|
|
2000
|
+
|
|
2001
|
+
tool({
|
|
2002
|
+
name: 'continuum_recover_now',
|
|
2003
|
+
description:
|
|
2004
|
+
'Retry the post-restart recovery RIGHT NOW instead of waiting for the next restart. Use it when '
|
|
2005
|
+
+ 'a session is still stuck after a restart: its goal has not been re-armed yet, or its agent was '
|
|
2006
|
+
+ 'not live when the boot recovery ran. Optional sessionId retries one session; without one every '
|
|
2007
|
+
+ 'pending session is retried. Returns, per session, the action that was taken (for example '
|
|
2008
|
+
+ '"resume-disarmed-goal", "resume-paused-goal", "rearm-goal", "prompt-continuation", '
|
|
2009
|
+
+ '"agent-not-live", "goal-lookup-failed", "nothing") and a short detail.',
|
|
2010
|
+
parameters: {
|
|
2011
|
+
type: 'object',
|
|
2012
|
+
additionalProperties: false,
|
|
2013
|
+
properties: {
|
|
2014
|
+
sessionId: { type: 'string', description: 'Retry only this session (default: every pending session).' },
|
|
2015
|
+
},
|
|
2016
|
+
},
|
|
2017
|
+
output: textOutput(
|
|
2018
|
+
{
|
|
2019
|
+
type: 'object',
|
|
2020
|
+
additionalProperties: false,
|
|
2021
|
+
required: ['ok', 'results'],
|
|
2022
|
+
properties: {
|
|
2023
|
+
ok: { type: 'boolean' },
|
|
2024
|
+
results: { type: 'array', items: { type: 'object' } },
|
|
2025
|
+
},
|
|
2026
|
+
},
|
|
2027
|
+
(_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
2028
|
+
),
|
|
2029
|
+
async execute(args = {}) {
|
|
2030
|
+
const requested = typeof args.sessionId === 'string' ? args.sessionId.trim() : ''
|
|
2031
|
+
const results = await runtime.recoverNow(requested !== '' ? requested : undefined)
|
|
2032
|
+
return { ok: true, results }
|
|
2033
|
+
},
|
|
2034
|
+
})
|
|
2035
|
+
|
|
2036
|
+
tool({
|
|
2037
|
+
name: 'continuum_handoff',
|
|
2038
|
+
description:
|
|
2039
|
+
'Hand a session whose conversation exceeded the model context window off to a FRESH session. '
|
|
2040
|
+
+ 'Such a session is dead: every request to it fails with "context window exceeds limit", and '
|
|
2041
|
+
+ 'compacting it needs the same oversized context. The handoff carries the goal objective, the '
|
|
2042
|
+
+ 'recorded checkpoint reasons and a bounded tail of the conversation (default 20000 characters, '
|
|
2043
|
+
+ '`maxChars`) into a new live session in the same working directory, marks the old session as '
|
|
2044
|
+
+ 'handed off (so it is never auto-resumed again) and disarms its goal. The old session and its '
|
|
2045
|
+
+ 'conversation are kept, untouched. Without `sessionId` the calling session is handed off. It '
|
|
2046
|
+
+ 'reports the new session id, how many characters were carried, and what the human should do.',
|
|
2047
|
+
parameters: {
|
|
2048
|
+
type: 'object',
|
|
2049
|
+
additionalProperties: false,
|
|
2050
|
+
properties: {
|
|
2051
|
+
sessionId: { type: 'string', description: 'The over-limit session to hand off (default: the calling session).' },
|
|
2052
|
+
maxChars: { type: 'number', description: 'Character budget of the carried conversation tail (default 20000).' },
|
|
2053
|
+
},
|
|
2054
|
+
},
|
|
2055
|
+
output: textOutput(
|
|
2056
|
+
{
|
|
2057
|
+
type: 'object',
|
|
2058
|
+
additionalProperties: false,
|
|
2059
|
+
required: ['ok'],
|
|
2060
|
+
properties: {
|
|
2061
|
+
ok: { type: 'boolean' },
|
|
2062
|
+
sessionId: { type: 'string' },
|
|
2063
|
+
sourceSessionId: { type: 'string' },
|
|
2064
|
+
carriedChars: { type: 'number' },
|
|
2065
|
+
carriedTailChars: { type: 'number' },
|
|
2066
|
+
disarmedGoal: { type: 'boolean' },
|
|
2067
|
+
error: { type: 'string' },
|
|
2068
|
+
detail: { type: 'string' },
|
|
2069
|
+
instruction: { type: 'string' },
|
|
2070
|
+
},
|
|
2071
|
+
},
|
|
2072
|
+
(_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
2073
|
+
),
|
|
2074
|
+
async execute(args = {}, exec) {
|
|
2075
|
+
const agent = exec && exec.agent
|
|
2076
|
+
const requested = typeof args.sessionId === 'string' ? args.sessionId.trim() : ''
|
|
2077
|
+
return runtime.handoff({
|
|
2078
|
+
sessionId: requested !== '' ? requested : undefined,
|
|
2079
|
+
maxChars: typeof args.maxChars === 'number' && Number.isFinite(args.maxChars) && args.maxChars > 0
|
|
2080
|
+
? args.maxChars
|
|
2081
|
+
: undefined,
|
|
2082
|
+
callerAgent: agent,
|
|
2083
|
+
})
|
|
2084
|
+
},
|
|
2085
|
+
})
|
|
2086
|
+
|
|
1151
2087
|
tool({
|
|
1152
2088
|
name: 'continuum_cancel_restart',
|
|
1153
2089
|
description: 'Cancel a restart previously requested through continuum_restart. Does nothing when no restart is pending.',
|
|
@@ -1219,9 +2155,11 @@ function injectPageHeartbeat(html, bootId, routePath) {
|
|
|
1219
2155
|
const AUTORESTART_PROMPT = [
|
|
1220
2156
|
'## Restart policy (dsh-autorestart)',
|
|
1221
2157
|
'- 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.',
|
|
2158
|
+
'- 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.',
|
|
2159
|
+
'- 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
2160
|
'- Persist anything the next turn needs before restarting (workspace files, `todo_write`, goal state); do not rely on in-memory state.',
|
|
1224
2161
|
'- Inspect the lifecycle with `continuum_status`; cancel a pending restart with `continuum_cancel_restart`.',
|
|
2162
|
+
'- 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
2163
|
'- 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
2164
|
].join('\n')
|
|
1227
2165
|
|
|
@@ -1354,13 +2292,18 @@ function registerManualRestartGuard(ctx, runtime, config) {
|
|
|
1354
2292
|
return
|
|
1355
2293
|
}
|
|
1356
2294
|
try {
|
|
1357
|
-
tools
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
2295
|
+
// The disposer is owned by the fiber (like the tools and the routes), so a
|
|
2296
|
+
// re-applied plugin never leaves a guard of an old instance behind.
|
|
2297
|
+
ctx.effect(
|
|
2298
|
+
() => tools.guard((exec) => {
|
|
2299
|
+
const hit = detectManualRestart(exec, { webPort: runtime.webPort ?? 0 })
|
|
2300
|
+
if (hit === null) return undefined
|
|
2301
|
+
runtime.manualRestartBlocks += 1
|
|
2302
|
+
runtime.log.warn(`blocked a manual DSH restart attempt (${hit}) through tool "${exec.name}"; steering the agent to continuum_restart`)
|
|
2303
|
+
return MANUAL_RESTART_DENIAL
|
|
2304
|
+
}),
|
|
2305
|
+
'dsh-autorestart: manual-restart guard',
|
|
2306
|
+
)
|
|
1364
2307
|
} catch (error) {
|
|
1365
2308
|
runtime.log.warn('could not register the manual-restart guard:', String(error))
|
|
1366
2309
|
}
|
|
@@ -1404,6 +2347,273 @@ function admittedByConnection(ctx, req) {
|
|
|
1404
2347
|
}
|
|
1405
2348
|
}
|
|
1406
2349
|
|
|
2350
|
+
/**
|
|
2351
|
+
* Read a small JSON request body. Never throws: an unreadable or malformed
|
|
2352
|
+
* body simply becomes an empty object.
|
|
2353
|
+
*
|
|
2354
|
+
* @param {any} req
|
|
2355
|
+
* @param {number} [maxBytes]
|
|
2356
|
+
* @returns {Promise<object>}
|
|
2357
|
+
*/
|
|
2358
|
+
function readJsonBody(req, maxBytes = 64 * 1024) {
|
|
2359
|
+
return new Promise((resolve) => {
|
|
2360
|
+
if (!req || typeof req.on !== 'function') {
|
|
2361
|
+
resolve({})
|
|
2362
|
+
return
|
|
2363
|
+
}
|
|
2364
|
+
let size = 0
|
|
2365
|
+
let settled = false
|
|
2366
|
+
const chunks = []
|
|
2367
|
+
const finish = (text) => {
|
|
2368
|
+
if (settled) return
|
|
2369
|
+
settled = true
|
|
2370
|
+
try {
|
|
2371
|
+
const raw = String(text || '').trim()
|
|
2372
|
+
if (raw === '') {
|
|
2373
|
+
resolve({})
|
|
2374
|
+
return
|
|
2375
|
+
}
|
|
2376
|
+
const parsed = JSON.parse(raw)
|
|
2377
|
+
resolve(parsed && typeof parsed === 'object' ? parsed : {})
|
|
2378
|
+
} catch {
|
|
2379
|
+
resolve({})
|
|
2380
|
+
}
|
|
2381
|
+
}
|
|
2382
|
+
req.on('data', (chunk) => {
|
|
2383
|
+
size += typeof chunk === 'string' ? chunk.length : (chunk && chunk.length ? chunk.length : 0)
|
|
2384
|
+
if (size > maxBytes) {
|
|
2385
|
+
finish('')
|
|
2386
|
+
return
|
|
2387
|
+
}
|
|
2388
|
+
chunks.push(typeof chunk === 'string' ? chunk : String(chunk))
|
|
2389
|
+
})
|
|
2390
|
+
req.on('end', () => finish(chunks.join('')))
|
|
2391
|
+
req.on('error', () => finish(''))
|
|
2392
|
+
})
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
/**
|
|
2396
|
+
* The ids of the sessions that currently block a pending restart.
|
|
2397
|
+
*
|
|
2398
|
+
* @param {object} runtime
|
|
2399
|
+
* @returns {string[]}
|
|
2400
|
+
*/
|
|
2401
|
+
function blockingSessionIds(runtime) {
|
|
2402
|
+
const ids = []
|
|
2403
|
+
try {
|
|
2404
|
+
const verdict = runtime.gate()
|
|
2405
|
+
const blockedBy = Array.isArray(verdict.blockedBy) ? verdict.blockedBy : []
|
|
2406
|
+
for (const entry of blockedBy) {
|
|
2407
|
+
if (!entry || typeof entry !== 'object') continue
|
|
2408
|
+
const id = entry.sessionId == null ? '' : String(entry.sessionId)
|
|
2409
|
+
if (id && !ids.includes(id)) ids.push(id)
|
|
2410
|
+
}
|
|
2411
|
+
} catch {}
|
|
2412
|
+
return ids
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
/**
|
|
2416
|
+
* Build the POST route that disarms the goal of a session.
|
|
2417
|
+
*
|
|
2418
|
+
* It uses the same authentication rule as the health route (loopback OR a
|
|
2419
|
+
* request the connection service admits) and answers JSON only.
|
|
2420
|
+
*/
|
|
2421
|
+
function buildParkRoute(ctx, runtime, config) {
|
|
2422
|
+
const routePath = resolveParkPath(config)
|
|
2423
|
+
return {
|
|
2424
|
+
kind: 'exact',
|
|
2425
|
+
path: routePath,
|
|
2426
|
+
handler: (req, res) => {
|
|
2427
|
+
const remote = req && req.socket ? req.socket.remoteAddress : undefined
|
|
2428
|
+
const loopback = remote === undefined
|
|
2429
|
+
|| remote === '127.0.0.1'
|
|
2430
|
+
|| remote === '::1'
|
|
2431
|
+
|| remote === '::ffff:127.0.0.1'
|
|
2432
|
+
if (!loopback && !admittedByConnection(ctx, req)) {
|
|
2433
|
+
res.statusCode = 403
|
|
2434
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
2435
|
+
res.end('{"ok":false,"error":"loopback-only"}')
|
|
2436
|
+
return
|
|
2437
|
+
}
|
|
2438
|
+
if (!req || req.method !== 'POST') {
|
|
2439
|
+
res.statusCode = 405
|
|
2440
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
2441
|
+
res.setHeader('allow', 'POST')
|
|
2442
|
+
res.end('{"ok":false,"error":"method-not-allowed"}')
|
|
2443
|
+
return
|
|
2444
|
+
}
|
|
2445
|
+
void readJsonBody(req).then((payload) => {
|
|
2446
|
+
const body = payload && typeof payload === 'object' ? payload : {}
|
|
2447
|
+
const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : ''
|
|
2448
|
+
const restart = body.restart === true
|
|
2449
|
+
// Without a session id every session that blocks a pending restart is
|
|
2450
|
+
// parked, so the restart can proceed.
|
|
2451
|
+
const targets = sessionId !== '' ? [sessionId] : blockingSessionIds(runtime)
|
|
2452
|
+
const disarmed = []
|
|
2453
|
+
const reported = []
|
|
2454
|
+
for (const target of targets) {
|
|
2455
|
+
try {
|
|
2456
|
+
const result = runtime.disarmGoal(target)
|
|
2457
|
+
if (result.disarmed) disarmed.push(result)
|
|
2458
|
+
else reported.push(result)
|
|
2459
|
+
} catch (error) {
|
|
2460
|
+
reported.push({ sessionId: target, disarmed: false, reason: 'disarm-failed', error: describeError(error) })
|
|
2461
|
+
}
|
|
2462
|
+
}
|
|
2463
|
+
if (restart && sessionId !== '') {
|
|
2464
|
+
// The session becomes a requester of the pending restart: the restart
|
|
2465
|
+
// proceeds and the session continues afterwards.
|
|
2466
|
+
requestRestart(runtime.state, {
|
|
2467
|
+
reason: 'disarm route requested a restart',
|
|
2468
|
+
bySessionId: sessionId,
|
|
2469
|
+
resume: true,
|
|
2470
|
+
now: Date.now(),
|
|
2471
|
+
})
|
|
2472
|
+
runtime.persist()
|
|
2473
|
+
runtime.startPolling()
|
|
2474
|
+
}
|
|
2475
|
+
res.statusCode = 200
|
|
2476
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
2477
|
+
res.setHeader('cache-control', 'no-store')
|
|
2478
|
+
res.end(JSON.stringify({
|
|
2479
|
+
ok: true,
|
|
2480
|
+
disarmed,
|
|
2481
|
+
reported,
|
|
2482
|
+
pendingRestart: runtime.state.restart !== null,
|
|
2483
|
+
restartRequested: restart === true && sessionId !== '',
|
|
2484
|
+
}))
|
|
2485
|
+
}).catch((error) => {
|
|
2486
|
+
res.statusCode = 400
|
|
2487
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
2488
|
+
res.end(JSON.stringify({ ok: false, error: describeError(error) }))
|
|
2489
|
+
})
|
|
2490
|
+
},
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
|
|
2494
|
+
/**
|
|
2495
|
+
* Build the POST `/recover` route: retry the post-restart recovery for the
|
|
2496
|
+
* still-pending sessions right now.
|
|
2497
|
+
*
|
|
2498
|
+
* It is the sibling of the `/park` route next to the health route and uses the
|
|
2499
|
+
* same authentication rule (loopback OR a request the connection service
|
|
2500
|
+
* admits). Body: `{ "sessionId"?: string }`; without one every pending session
|
|
2501
|
+
* is retried. The answer reports, per session, the recovery action and detail.
|
|
2502
|
+
*/
|
|
2503
|
+
function buildRecoverRoute(ctx, runtime, config) {
|
|
2504
|
+
const routePath = resolveRecoverPath(config)
|
|
2505
|
+
return {
|
|
2506
|
+
kind: 'exact',
|
|
2507
|
+
path: routePath,
|
|
2508
|
+
handler: (req, res) => {
|
|
2509
|
+
const remote = req && req.socket ? req.socket.remoteAddress : undefined
|
|
2510
|
+
const loopback = remote === undefined
|
|
2511
|
+
|| remote === '127.0.0.1'
|
|
2512
|
+
|| remote === '::1'
|
|
2513
|
+
|| remote === '::ffff:127.0.0.1'
|
|
2514
|
+
if (!loopback && !admittedByConnection(ctx, req)) {
|
|
2515
|
+
res.statusCode = 403
|
|
2516
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
2517
|
+
res.end('{"ok":false,"error":"loopback-only"}')
|
|
2518
|
+
return
|
|
2519
|
+
}
|
|
2520
|
+
if (!req || req.method !== 'POST') {
|
|
2521
|
+
res.statusCode = 405
|
|
2522
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
2523
|
+
res.setHeader('allow', 'POST')
|
|
2524
|
+
res.end('{"ok":false,"error":"method-not-allowed"}')
|
|
2525
|
+
return
|
|
2526
|
+
}
|
|
2527
|
+
void readJsonBody(req).then(async (payload) => {
|
|
2528
|
+
const body = payload && typeof payload === 'object' ? payload : {}
|
|
2529
|
+
const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : ''
|
|
2530
|
+
const results = await runtime.recoverNow(sessionId !== '' ? sessionId : undefined)
|
|
2531
|
+
res.statusCode = 200
|
|
2532
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
2533
|
+
res.setHeader('cache-control', 'no-store')
|
|
2534
|
+
res.end(JSON.stringify({ ok: true, results }))
|
|
2535
|
+
}).catch((error) => {
|
|
2536
|
+
res.statusCode = 400
|
|
2537
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
2538
|
+
res.end(JSON.stringify({ ok: false, error: describeError(error) }))
|
|
2539
|
+
})
|
|
2540
|
+
},
|
|
2541
|
+
}
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
/**
|
|
2545
|
+
* Build the POST `/handoff` route: hand an over-limit session off to a fresh
|
|
2546
|
+
* session (the same operation as the `continuum_handoff` tool).
|
|
2547
|
+
*
|
|
2548
|
+
* It sits next to the health route and uses the same authentication rule
|
|
2549
|
+
* (loopback OR a request the connection service admits). Body:
|
|
2550
|
+
* `{ "sessionId"?: string, "maxChars"?: number }`; without a session id the
|
|
2551
|
+
* request is refused, because a route call has no calling session to default to.
|
|
2552
|
+
* The answer reports the new session id and the number of carried characters.
|
|
2553
|
+
*/
|
|
2554
|
+
function buildHandoffRoute(ctx, runtime, config) {
|
|
2555
|
+
const routePath = resolveHandoffPath(config)
|
|
2556
|
+
return {
|
|
2557
|
+
kind: 'exact',
|
|
2558
|
+
path: routePath,
|
|
2559
|
+
handler: (req, res) => {
|
|
2560
|
+
const remote = req && req.socket ? req.socket.remoteAddress : undefined
|
|
2561
|
+
const loopback = remote === undefined
|
|
2562
|
+
|| remote === '127.0.0.1'
|
|
2563
|
+
|| remote === '::1'
|
|
2564
|
+
|| remote === '::ffff:127.0.0.1'
|
|
2565
|
+
if (!loopback && !admittedByConnection(ctx, req)) {
|
|
2566
|
+
res.statusCode = 403
|
|
2567
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
2568
|
+
res.end('{"ok":false,"error":"loopback-only"}')
|
|
2569
|
+
return
|
|
2570
|
+
}
|
|
2571
|
+
if (!req || req.method !== 'POST') {
|
|
2572
|
+
res.statusCode = 405
|
|
2573
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
2574
|
+
res.setHeader('allow', 'POST')
|
|
2575
|
+
res.end('{"ok":false,"error":"method-not-allowed"}')
|
|
2576
|
+
return
|
|
2577
|
+
}
|
|
2578
|
+
void readJsonBody(req).then(async (payload) => {
|
|
2579
|
+
const body = payload && typeof payload === 'object' ? payload : {}
|
|
2580
|
+
const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : ''
|
|
2581
|
+
if (sessionId === '') {
|
|
2582
|
+
res.statusCode = 400
|
|
2583
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
2584
|
+
res.end(JSON.stringify({
|
|
2585
|
+
ok: false,
|
|
2586
|
+
error: 'no-session',
|
|
2587
|
+
instruction: 'Pass { "sessionId": "<id>" }: this route has no calling session to default to.',
|
|
2588
|
+
}))
|
|
2589
|
+
return
|
|
2590
|
+
}
|
|
2591
|
+
const result = await runtime.handoff({
|
|
2592
|
+
sessionId,
|
|
2593
|
+
maxChars: typeof body.maxChars === 'number' && Number.isFinite(body.maxChars) && body.maxChars > 0
|
|
2594
|
+
? body.maxChars
|
|
2595
|
+
: undefined,
|
|
2596
|
+
})
|
|
2597
|
+
res.statusCode = result && result.ok === true ? 200 : 400
|
|
2598
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
2599
|
+
res.setHeader('cache-control', 'no-store')
|
|
2600
|
+
res.end(JSON.stringify({
|
|
2601
|
+
ok: result && result.ok === true,
|
|
2602
|
+
sessionId: result ? result.sessionId : undefined,
|
|
2603
|
+
sourceSessionId: result ? result.sourceSessionId : sessionId,
|
|
2604
|
+
carriedChars: result ? result.carriedChars : undefined,
|
|
2605
|
+
error: result ? result.error : 'handoff-failed',
|
|
2606
|
+
instruction: result ? result.instruction : undefined,
|
|
2607
|
+
}))
|
|
2608
|
+
}).catch((error) => {
|
|
2609
|
+
res.statusCode = 400
|
|
2610
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
2611
|
+
res.end(JSON.stringify({ ok: false, error: describeError(error) }))
|
|
2612
|
+
})
|
|
2613
|
+
},
|
|
2614
|
+
}
|
|
2615
|
+
}
|
|
2616
|
+
|
|
1407
2617
|
/** Expose a tiny read-only health route for humans and verification scripts. */
|
|
1408
2618
|
function registerHealthRoute(ctx, runtime, config) {
|
|
1409
2619
|
const routePath = typeof config.healthPath === 'string' && config.healthPath.startsWith('/')
|
|
@@ -1490,8 +2700,10 @@ function registerHealthRoute(ctx, runtime, config) {
|
|
|
1490
2700
|
const verdict = runtime.gate()
|
|
1491
2701
|
// Best effort: label the blocking sessions with their titles when the
|
|
1492
2702
|
// session controller knows them. The title is optional, so an entry
|
|
1493
|
-
// without one is sent exactly as the gate produced it.
|
|
1494
|
-
|
|
2703
|
+
// without one is sent exactly as the gate produced it. The goal
|
|
2704
|
+
// reason (`goal-active` / `goal-exhausted`) is attached as well, so
|
|
2705
|
+
// the client can tell a goal-driven blocker from a busy session.
|
|
2706
|
+
const blockedBy = goalAwareBlockingDetails(runtime, verdict.blockedBy).map((entry) => {
|
|
1495
2707
|
if (!entry || typeof entry !== 'object') return entry
|
|
1496
2708
|
const title = runtime.sessionTitle(entry.sessionId)
|
|
1497
2709
|
return title ? { ...entry, title } : entry
|
|
@@ -1507,6 +2719,13 @@ function registerHealthRoute(ctx, runtime, config) {
|
|
|
1507
2719
|
quietForMs: typeof verdict.quietForMs === 'number' ? verdict.quietForMs : 0,
|
|
1508
2720
|
settleMs: config.settleMs,
|
|
1509
2721
|
updatedAt: live.updatedAt,
|
|
2722
|
+
// The park route, so the client never has to guess the prefix, and
|
|
2723
|
+
// the goals this plugin already disarmed (they are re-armed after
|
|
2724
|
+
// the restart).
|
|
2725
|
+
parkUrl: resolveParkPath(config),
|
|
2726
|
+
recoverUrl: resolveRecoverPath(config),
|
|
2727
|
+
parkedGoals: parkedGoalList(runtime),
|
|
2728
|
+
recovery: recoveryList(live),
|
|
1510
2729
|
})
|
|
1511
2730
|
} catch {
|
|
1512
2731
|
// The banner must never break the health route: fall back to "no restart".
|
|
@@ -1517,6 +2736,10 @@ function registerHealthRoute(ctx, runtime, config) {
|
|
|
1517
2736
|
blockedBy: [],
|
|
1518
2737
|
allowRestart: false,
|
|
1519
2738
|
quietForMs: 0,
|
|
2739
|
+
parkUrl: resolveParkPath(config),
|
|
2740
|
+
recoverUrl: resolveRecoverPath(config),
|
|
2741
|
+
parkedGoals: [],
|
|
2742
|
+
recovery: [],
|
|
1520
2743
|
})
|
|
1521
2744
|
}
|
|
1522
2745
|
res.statusCode = 200
|
|
@@ -1568,6 +2791,9 @@ function registerHealthRoute(ctx, runtime, config) {
|
|
|
1568
2791
|
return false
|
|
1569
2792
|
}
|
|
1570
2793
|
if (!webServer || typeof webServer.register !== 'function') return false
|
|
2794
|
+
const parkRoute = buildParkRoute(ctx, runtime, config)
|
|
2795
|
+
const recoverRoute = buildRecoverRoute(ctx, runtime, config)
|
|
2796
|
+
const handoffRoute = buildHandoffRoute(ctx, runtime, config)
|
|
1571
2797
|
try {
|
|
1572
2798
|
const origin = typeof webServer.getOrigin === 'function' ? String(webServer.getOrigin()) : ''
|
|
1573
2799
|
const portMatch = /:(\d+)\/?$/.exec(origin)
|
|
@@ -1579,6 +2805,21 @@ function registerHealthRoute(ctx, runtime, config) {
|
|
|
1579
2805
|
// A duplicate registration means another load of this plugin already owns it.
|
|
1580
2806
|
runtime.log.warn('could not register the health route:', String(error))
|
|
1581
2807
|
}
|
|
2808
|
+
try {
|
|
2809
|
+
target.effect(() => webServer.register(parkRoute), 'dsh-autorestart: park route')
|
|
2810
|
+
} catch (error) {
|
|
2811
|
+
runtime.log.warn('could not register the park route:', String(error))
|
|
2812
|
+
}
|
|
2813
|
+
try {
|
|
2814
|
+
target.effect(() => webServer.register(recoverRoute), 'dsh-autorestart: recover route')
|
|
2815
|
+
} catch (error) {
|
|
2816
|
+
runtime.log.warn('could not register the recover route:', String(error))
|
|
2817
|
+
}
|
|
2818
|
+
try {
|
|
2819
|
+
target.effect(() => webServer.register(handoffRoute), 'dsh-autorestart: handoff route')
|
|
2820
|
+
} catch (error) {
|
|
2821
|
+
runtime.log.warn('could not register the handoff route:', String(error))
|
|
2822
|
+
}
|
|
1582
2823
|
if (config.autoReloadPage !== false && typeof webServer.tapIndex === 'function') {
|
|
1583
2824
|
try {
|
|
1584
2825
|
target.effect(
|