dsh-autorestart 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/index.js +121 -19
- package/lib/lifecycle.js +42 -20
- package/package.json +2 -2
- /package/{README.zh.md → docs/README.zh.md} +0 -0
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ Restart DeepSeek Harness without losing the work that is currently running.
|
|
|
7
7
|
[](https://github.com/topics/dsh-plugin)
|
|
8
8
|
[](https://github.com/Eason-22/dsh-autorestart)
|
|
9
9
|
|
|
10
|
-
Chinese version: [README.zh.md](README.zh.md)
|
|
10
|
+
Chinese version: [README.zh.md](docs/README.zh.md)
|
|
11
11
|
|
|
12
12
|
## What it does
|
|
13
13
|
|
package/index.js
CHANGED
|
@@ -150,11 +150,13 @@ function delay(ms) {
|
|
|
150
150
|
}
|
|
151
151
|
|
|
152
152
|
/**
|
|
153
|
-
* Hard dependencies. `sessionController` is
|
|
154
|
-
*
|
|
155
|
-
*
|
|
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 (
|
|
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
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
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
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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: `
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.3.2",
|
|
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",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"assets/*.png",
|
|
38
38
|
"screenshots.json",
|
|
39
39
|
"README.md",
|
|
40
|
-
"
|
|
40
|
+
"docs/*.md"
|
|
41
41
|
],
|
|
42
42
|
"dsh": {
|
|
43
43
|
"bundle": {
|
|
File without changes
|