dsh-autorestart 0.3.0 → 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.
Files changed (3) hide show
  1. package/index.js +121 -19
  2. package/lib/lifecycle.js +42 -20
  3. package/package.json +2 -2
package/index.js CHANGED
@@ -150,11 +150,13 @@ function delay(ms) {
150
150
  }
151
151
 
152
152
  /**
153
- * Hard dependencies. `sessionController` is read optionally (`ctx.get`) so the plugin
154
- * also loads in compositions without the web session controller; it simply cannot
155
- * resume agents there.
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.
156
158
  */
157
- export const inject = ['agents', 'goals', 'tools', 'timer', 'systemPrompt']
159
+ export const inject = ['agents', 'goals', 'tools', 'timer', 'systemPrompt', 'sessionController']
158
160
 
159
161
  /**
160
162
  * Validated loader row config. Exported so the Plugin Manager / config editor can show
@@ -1268,6 +1270,7 @@ function createRuntime(ctx, config) {
1268
1270
  goalFor,
1269
1271
  handoffRecord: (sessionId) => handoffRecord(state, sessionId),
1270
1272
  overflowFor,
1273
+ readSession,
1271
1274
  handoff,
1272
1275
  boot,
1273
1276
  /**
@@ -1279,10 +1282,30 @@ function createRuntime(ctx, config) {
1279
1282
  * Every attempt reports its own action/detail, exactly like the boot pass.
1280
1283
  *
1281
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
1282
1287
  * @returns {Promise<object[]>} one outcome per attempted session
1283
1288
  */
1284
- async recoverNow(sessionId) {
1289
+ async recoverNow(sessionId, { force = false } = {}) {
1285
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
+ }
1286
1309
  const pending = recoverableSessions(state, {}).filter((entry) => (
1287
1310
  target === null ? true : String(entry.sessionId) === target
1288
1311
  ))
@@ -1410,8 +1433,30 @@ async function recoverSession(ctx, runtime, entry, config) {
1410
1433
  const recordedOverflow = entry.contextOverflow && entry.contextOverflow.over === true
1411
1434
  ? entry.contextOverflow
1412
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
+ }
1413
1447
  let overflow = null
1414
- if (recordedOverflow !== 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) {
1415
1460
  overflow = {
1416
1461
  over: true,
1417
1462
  inputTokens: Number.isFinite(recordedOverflow.inputTokens) ? recordedOverflow.inputTokens : null,
@@ -1420,12 +1465,6 @@ async function recoverSession(ctx, runtime, entry, config) {
1420
1465
  ? recordedOverflow.detail
1421
1466
  : 'the session is over the model context window (recorded by an earlier recovery)',
1422
1467
  }
1423
- } else {
1424
- try {
1425
- overflow = runtime.overflowFor(sessionId)
1426
- } catch {
1427
- overflow = null
1428
- }
1429
1468
  }
1430
1469
  if (overflow && overflow.over === true) {
1431
1470
  const detail = typeof overflow.detail === 'string' && overflow.detail !== ''
@@ -1463,12 +1502,67 @@ async function recoverSession(ctx, runtime, entry, config) {
1463
1502
  }
1464
1503
 
1465
1504
  if (agent === undefined) {
1466
- const controller = ctx.get('sessionController')
1467
- if (!controller || typeof controller.resolveAgent !== 'function') {
1468
- log.warn('sessionController is unavailable; cannot resume', sessionId)
1469
- noteRecovery(state, sessionId, 'session-controller-unavailable', 'sessionController.resolveAgent is unavailable')
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)
1470
1564
  runtime.persist()
1471
- return { sessionId, action: 'session-controller-unavailable', detail: 'sessionController.resolveAgent is unavailable' }
1565
+ return { sessionId, action: 'session-controller-unavailable', detail, controllerPath }
1472
1566
  }
1473
1567
  // A freshly reloaded profile can expose the live agent a moment after the
1474
1568
  // state file was written, so a THROWN error is retried inside the same boot
@@ -1508,6 +1602,10 @@ async function recoverSession(ctx, runtime, entry, config) {
1508
1602
  return { sessionId, action: 'agent-not-live', detail: lastError, error: lastError }
1509
1603
  }
1510
1604
  agent = result && result.agent
1605
+ if (agent) {
1606
+ noteRecovery(state, sessionId, 'resume-via-resolve-agent', `sessionController.resolveAgent (${controllerPath})`)
1607
+ runtime.persist()
1608
+ }
1511
1609
  }
1512
1610
  if (!agent) {
1513
1611
  entry.lastResumeError = 'resolveAgent returned no agent'
@@ -2012,6 +2110,10 @@ function registerTools(ctx, runtime, config) {
2012
2110
  additionalProperties: false,
2013
2111
  properties: {
2014
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
+ },
2015
2117
  },
2016
2118
  },
2017
2119
  output: textOutput(
@@ -2028,7 +2130,7 @@ function registerTools(ctx, runtime, config) {
2028
2130
  ),
2029
2131
  async execute(args = {}) {
2030
2132
  const requested = typeof args.sessionId === 'string' ? args.sessionId.trim() : ''
2031
- const results = await runtime.recoverNow(requested !== '' ? requested : undefined)
2133
+ const results = await runtime.recoverNow(requested !== '' ? requested : undefined, { force: args.force === true })
2032
2134
  return { ok: true, results }
2033
2135
  },
2034
2136
  })
@@ -2527,7 +2629,7 @@ function buildRecoverRoute(ctx, runtime, config) {
2527
2629
  void readJsonBody(req).then(async (payload) => {
2528
2630
  const body = payload && typeof payload === 'object' ? payload : {}
2529
2631
  const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : ''
2530
- const results = await runtime.recoverNow(sessionId !== '' ? sessionId : undefined)
2632
+ const results = await runtime.recoverNow(sessionId !== '' ? sessionId : undefined, { force: body.force === true })
2531
2633
  res.statusCode = 200
2532
2634
  res.setHeader('content-type', 'application/json; charset=utf-8')
2533
2635
  res.setHeader('cache-control', 'no-store')
package/lib/lifecycle.js CHANGED
@@ -33,8 +33,12 @@ function isDoneEntry(entry) {
33
33
  // with the same "context window exceeds limit" error.
34
34
  if (isObject(entry.handoff)) return true;
35
35
  if (isObject(entry.contextOverflow) && entry.contextOverflow.over === true) return true;
36
- const phase = isObject(entry.goal) && typeof entry.goal.phase === 'string' ? entry.goal.phase : null;
37
- return phase === 'done' || phase === 'complete' || phase === 'completed';
36
+ // A COMPLETE / paused / blocked GOAL does not make the SESSION done: a turn that
37
+ // was interrupted still deserves its continuation message. Only the explicit
38
+ // `done` flag and the durable handoff / over-limit markers remove a session
39
+ // from the automatic resume path. (Treating a completed goal as done used to
40
+ // silently drop every restart continuation for those sessions.)
41
+ return false;
38
42
  }
39
43
 
40
44
  /**
@@ -654,37 +658,55 @@ export function contextOverflow({ messages = [], contextWindow = null, events =
654
658
  }
655
659
  const window = isFiniteNumber(contextWindow) && contextWindow > 0 ? contextWindow : null;
656
660
 
657
- let failure = '';
658
- const scan = (text) => {
659
- if (failure !== '' || typeof text !== 'string' || text === '') return;
660
- if (CONTEXT_WINDOW_PATTERN.test(text) && EXCEED_PATTERN.test(text)) failure = flattenText(text).slice(0, 200);
661
- };
662
- for (let i = list.length - 1; i >= 0 && failure === ''; i -= 1) scan(messageTextOf(list[i]));
663
- const eventList = Array.isArray(events) ? events.slice(-20) : [];
664
- for (let i = eventList.length - 1; i >= 0 && failure === ''; i -= 1) scan(eventTextOf(eventList[i]));
661
+ // Numbers are authoritative: when the last request's token usage and the model
662
+ // window are both known, they decide, and a mere MENTION of the phrase in a
663
+ // tool result or in a pasted log can never flip the verdict.
664
+ if (inputTokens !== null && window !== null) {
665
+ if (inputTokens > window) {
666
+ return {
667
+ over: true,
668
+ inputTokens,
669
+ contextWindow: window,
670
+ detail: `the last request used ${inputTokens} tokens, which exceeds the model context window of ${window}`,
671
+ };
672
+ }
673
+ return {
674
+ over: false,
675
+ inputTokens,
676
+ contextWindow: window,
677
+ detail: `used ${inputTokens} of ${window} tokens`,
678
+ };
679
+ }
665
680
 
666
- const numbers = inputTokens !== null && window !== null
667
- ? ` (used ${inputTokens} of ${window} tokens)`
668
- : inputTokens !== null
669
- ? ` (used ${inputTokens} tokens; window unknown)`
670
- : '';
681
+ // Without numbers, only a STRUCTURED turn failure counts: a `turn/end` event
682
+ // whose reason is an error whose failure message says the context window was
683
+ // exceeded. Free text in ordinary messages is never evidence.
684
+ let failure = '';
685
+ for (const event of (Array.isArray(events) ? events : [])) {
686
+ if (!isObject(event) || event.type !== 'turn/end') continue;
687
+ const reason = event.data && event.data.reason;
688
+ if (!isObject(reason) || reason.kind !== 'error') continue;
689
+ const error = reason.error;
690
+ const message = error && typeof error.message === 'string' ? error.message : '';
691
+ if (message === '') continue;
692
+ if (CONTEXT_WINDOW_PATTERN.test(message) && EXCEED_PATTERN.test(message)) {
693
+ failure = flattenText(message).slice(0, 200);
694
+ }
695
+ }
671
696
 
672
697
  if (failure !== '') {
673
- return { over: true, inputTokens, contextWindow: window, detail: `a recent turn failed with a context-window error${numbers}: ${failure}` };
674
- }
675
- if (inputTokens !== null && window !== null && inputTokens > window) {
676
698
  return {
677
699
  over: true,
678
700
  inputTokens,
679
701
  contextWindow: window,
680
- detail: `the last request used ${inputTokens} tokens, which exceeds the model context window of ${window}`,
702
+ detail: `a turn failed with a context-window error: ${failure}`,
681
703
  };
682
704
  }
683
705
  return {
684
706
  over: false,
685
707
  inputTokens,
686
708
  contextWindow: window,
687
- detail: inputTokens !== null && window !== null ? `used ${inputTokens} of ${window} tokens` : null,
709
+ detail: inputTokens !== null ? `used ${inputTokens} tokens (window unknown)` : null,
688
710
  };
689
711
  }
690
712
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
- {
1
+ {
2
2
  "name": "dsh-autorestart",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "description": "DeepSeek Harness plugin: restart without losing work. Waits until every conversation is idle, restarts the host, then automatically resumes the interrupted sessions. AI-generated, not human-reviewed: use with caution.",
6
6
  "license": "MIT",