dsh-autorestart 0.6.1 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +129 -30
- package/client.js +805 -133
- package/cordis.patch.yml +18 -8
- package/docs/README.zh.md +87 -19
- package/index.js +381 -59
- package/lib/activity.js +124 -0
- package/lib/lifecycle.js +17 -4
- package/lib/queued.js +146 -0
- package/locale/en.json +10 -6
- package/locale/zh.json +10 -6
- package/package.json +1 -1
package/lib/activity.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-autorestart — what counts as REAL activity for the quiet window.
|
|
3
|
+
*
|
|
4
|
+
* THE DEFECT THIS MODULE FIXES
|
|
5
|
+
*
|
|
6
|
+
* The settle window ("the host must stay quiet for N ms before a restart may
|
|
7
|
+
* fire") used to be reset by EVERY session event. The host emits session
|
|
8
|
+
* events continuously while the interface polls and synchronises (measured on
|
|
9
|
+
* the real machine: one every 2-3 seconds), so a window longer than the event
|
|
10
|
+
* interval could never fill: the restart never fired, no matter how long the
|
|
11
|
+
* user waited.
|
|
12
|
+
*
|
|
13
|
+
* THE POLICY
|
|
14
|
+
*
|
|
15
|
+
* A session event counts as activity only when it says that WORK happened: a
|
|
16
|
+
* message was written, a tool ran, a turn started or ended. Everything else
|
|
17
|
+
* (a title, a projection update, a context request, a job that settled, a
|
|
18
|
+
* permission preset) is routine and must NOT reset the window.
|
|
19
|
+
*
|
|
20
|
+
* The rule is deliberately one-sided: an event whose type cannot be read is
|
|
21
|
+
* NOT activity. When in doubt the plugin would rather restart a little early
|
|
22
|
+
* than never restart at all.
|
|
23
|
+
*
|
|
24
|
+
* The hard guarantee does not live here: a conversation that is really
|
|
25
|
+
* running blocks the restart through `busyAgents()` (the `running` reason),
|
|
26
|
+
* and that rule is never relaxed. This module only decides what the QUIET
|
|
27
|
+
* window measures.
|
|
28
|
+
*
|
|
29
|
+
* ESM only, Node builtins only. No DSH / npm imports.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The session event types that mean "work happened".
|
|
34
|
+
*
|
|
35
|
+
* The vocabulary is the one of the DSH session service (its known event
|
|
36
|
+
* types). Only the events that describe a message, a tool, a turn or a
|
|
37
|
+
* command are listed; the rest (titles, projections, jobs, presets, modes,
|
|
38
|
+
* schedules, telemetry) are routine.
|
|
39
|
+
*/
|
|
40
|
+
export const WORK_SESSION_EVENT_TYPES = Object.freeze([
|
|
41
|
+
// A message was written into a conversation.
|
|
42
|
+
'user/message',
|
|
43
|
+
'developer/message',
|
|
44
|
+
'system/message',
|
|
45
|
+
'assistant/message',
|
|
46
|
+
'assistant/attempt',
|
|
47
|
+
// A tool ran.
|
|
48
|
+
'tool/call',
|
|
49
|
+
'tool/result',
|
|
50
|
+
'tool/ptc-dispatch',
|
|
51
|
+
'tool/ptc-dispatch-start',
|
|
52
|
+
// A turn or a step started or ended.
|
|
53
|
+
'turn/start',
|
|
54
|
+
'turn/end',
|
|
55
|
+
'step/start',
|
|
56
|
+
'step/end',
|
|
57
|
+
// A command ran.
|
|
58
|
+
'command/run',
|
|
59
|
+
'command/done',
|
|
60
|
+
// Work was handed around: an inbox splice, a team message, a workflow run.
|
|
61
|
+
'agent/inbox/spliced',
|
|
62
|
+
'team/message/queued',
|
|
63
|
+
'team/message/delivered',
|
|
64
|
+
'team/task',
|
|
65
|
+
'team/member',
|
|
66
|
+
'tool-workflow/run-start',
|
|
67
|
+
'tool-workflow/run-end',
|
|
68
|
+
'tool-workflow/agent-start',
|
|
69
|
+
'tool-workflow/agent-end',
|
|
70
|
+
// A hook ran, a compaction ran, the model retried, a goal changed.
|
|
71
|
+
'hook/invoked',
|
|
72
|
+
'hook/result',
|
|
73
|
+
'compaction/start',
|
|
74
|
+
'compaction/end',
|
|
75
|
+
'compaction/prune',
|
|
76
|
+
'compaction/summary',
|
|
77
|
+
'llm/retry',
|
|
78
|
+
'llm/retry-started',
|
|
79
|
+
'goal/change',
|
|
80
|
+
'approval/asked',
|
|
81
|
+
])
|
|
82
|
+
|
|
83
|
+
/** The work types as a Set, for a cheap lookup. */
|
|
84
|
+
const WORK_TYPES = new Set(WORK_SESSION_EVENT_TYPES)
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The type of one session event, or null when it cannot be read.
|
|
88
|
+
*
|
|
89
|
+
* The payload shape differs between the hosts that call this plugin, so the
|
|
90
|
+
* type is looked for in the places it can be: the payload itself, the nested
|
|
91
|
+
* event, or the data envelope.
|
|
92
|
+
*
|
|
93
|
+
* @param {any} payload the event payload the host delivered
|
|
94
|
+
* @returns {string|null}
|
|
95
|
+
*/
|
|
96
|
+
export function sessionEventType(payload) {
|
|
97
|
+
if (payload === null || typeof payload !== 'object') return null
|
|
98
|
+
const candidates = [
|
|
99
|
+
payload.type,
|
|
100
|
+
payload.eventType,
|
|
101
|
+
payload.event && typeof payload.event === 'object' ? payload.event.type : undefined,
|
|
102
|
+
payload.data && typeof payload.data === 'object' ? payload.data.type : undefined,
|
|
103
|
+
payload.data && typeof payload.data === 'object' && payload.data.event && typeof payload.data.event === 'object'
|
|
104
|
+
? payload.data.event.type
|
|
105
|
+
: undefined,
|
|
106
|
+
]
|
|
107
|
+
for (const candidate of candidates) {
|
|
108
|
+
if (typeof candidate === 'string' && candidate.trim() !== '') return candidate.trim()
|
|
109
|
+
}
|
|
110
|
+
return null
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Whether one session event means that real work happened.
|
|
115
|
+
*
|
|
116
|
+
* @param {any} payload the event payload the host delivered
|
|
117
|
+
* @returns {boolean} false for a routine event AND for an event whose type
|
|
118
|
+
* cannot be read (the one-sided rule: when in doubt, do not wait)
|
|
119
|
+
*/
|
|
120
|
+
export function isWorkSessionEvent(payload) {
|
|
121
|
+
const type = sessionEventType(payload)
|
|
122
|
+
if (type === null) return false
|
|
123
|
+
return WORK_TYPES.has(type)
|
|
124
|
+
}
|
package/lib/lifecycle.js
CHANGED
|
@@ -262,10 +262,12 @@ export function selectGoalDisarms(busyAgents, { getGoal, disarmedGoals, parkedGo
|
|
|
262
262
|
* forceRestartWhenBusy?: boolean,
|
|
263
263
|
* }} [options]
|
|
264
264
|
* @returns {{
|
|
265
|
-
* action: 'none'|'wait'|'warn'|'restart'|'force-restart'|'give-up',
|
|
265
|
+
* action: 'none'|'wait'|'warn'|'long-wait'|'restart'|'force-restart'|'give-up',
|
|
266
266
|
* reason: string,
|
|
267
267
|
* log?: string,
|
|
268
268
|
* markSoftWarned?: boolean,
|
|
269
|
+
* blockedBy?: { sessionId: string|null, reason: string }[],
|
|
270
|
+
* waitingSince?: number,
|
|
269
271
|
* }}
|
|
270
272
|
*/
|
|
271
273
|
export function restartDecision(
|
|
@@ -325,11 +327,19 @@ export function restartDecision(
|
|
|
325
327
|
}
|
|
326
328
|
|
|
327
329
|
// Hard deadline reached while a conversation is still running.
|
|
330
|
+
//
|
|
331
|
+
// From here on the plugin does NOT pretend that "everything is fine, we are
|
|
332
|
+
// just waiting a little longer": the wait has become a LONG wait, and the
|
|
333
|
+
// caller (index.js) has to make it VISIBLE — in the log and in the state
|
|
334
|
+
// payload — while KEEPING the user's restart request alive. The request is
|
|
335
|
+
// never silently dropped.
|
|
328
336
|
if (policy === 'wait') {
|
|
329
337
|
return {
|
|
330
|
-
action: '
|
|
338
|
+
action: 'long-wait',
|
|
331
339
|
reason: 'hard-deadline-policy-wait',
|
|
332
340
|
log: 'defer policy wait: still busy after hard deadline',
|
|
341
|
+
blockedBy: busy,
|
|
342
|
+
waitingSince: requestedAt,
|
|
333
343
|
};
|
|
334
344
|
}
|
|
335
345
|
// The hard deadline only forces a restart when the operator explicitly opted in
|
|
@@ -340,11 +350,14 @@ export function restartDecision(
|
|
|
340
350
|
return { action: 'force-restart', reason: 'hard-deadline-force-restart' };
|
|
341
351
|
}
|
|
342
352
|
// forceRestartWhenBusy is disabled (the default): the restart keeps waiting
|
|
343
|
-
// until the idle gate is quiet again, and it never gives up.
|
|
353
|
+
// until the idle gate is quiet again, and it never gives up. The result says
|
|
354
|
+
// so explicitly — a long wait, with the sessions that hold it.
|
|
344
355
|
return {
|
|
345
|
-
action: '
|
|
356
|
+
action: 'long-wait',
|
|
346
357
|
reason: 'hard-deadline-waiting-for-conversations',
|
|
347
358
|
log: 'dsh-autorestart: hard deadline reached but conversations are still running; forceRestartWhenBusy is disabled, so the restart keeps waiting',
|
|
359
|
+
blockedBy: busy,
|
|
360
|
+
waitingSince: requestedAt,
|
|
348
361
|
};
|
|
349
362
|
}
|
|
350
363
|
|
package/lib/queued.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-autorestart — the upper bound of a QUEUED inbox message.
|
|
3
|
+
*
|
|
4
|
+
* THE DEFECT THIS MODULE FIXES
|
|
5
|
+
*
|
|
6
|
+
* A queued message (an inbox entry that nobody has claimed yet) used to block
|
|
7
|
+
* the restart FOR EVER. The idle gate counted "this session has a queued
|
|
8
|
+
* message" as "this session is busy", and the hard deadline only WARNED, so a
|
|
9
|
+
* session that never ran its turn (the real case: a fresh, empty conversation
|
|
10
|
+
* in the UI with one unclaimed message in its inbox) kept the pending restart
|
|
11
|
+
* pending until the user gave up.
|
|
12
|
+
*
|
|
13
|
+
* THE POLICY
|
|
14
|
+
*
|
|
15
|
+
* - a message that is claimed inside the grace window keeps blocking the
|
|
16
|
+
* restart (that is the normal case: the agent picks it up in a second);
|
|
17
|
+
* - a message that is still unclaimed after the grace window is STUCK: it
|
|
18
|
+
* stops blocking the restart and is named in the log, so the user can see
|
|
19
|
+
* WHICH session holds the queue;
|
|
20
|
+
* - a message that is being worked on (the session is `running`) always
|
|
21
|
+
* blocks, under EVERY grace setting. This module only ever judges queued
|
|
22
|
+
* work — it never relaxes the rule that a live turn is never interrupted.
|
|
23
|
+
*
|
|
24
|
+
* The tracker knows nothing about the host: it is a map of session id to the
|
|
25
|
+
* moment the session was FIRST seen with a queued message, so it can be unit
|
|
26
|
+
* tested with a fake clock.
|
|
27
|
+
*
|
|
28
|
+
* ESM only, Node builtins only. No DSH / npm imports.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** The default upper bound of a queued message: one minute. */
|
|
32
|
+
export const DEFAULT_QUEUED_GRACE_MS = 60000
|
|
33
|
+
|
|
34
|
+
/** @param {unknown} value */
|
|
35
|
+
function isFiniteNumber(value) {
|
|
36
|
+
return typeof value === 'number' && Number.isFinite(value)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Normalise a grace window.
|
|
41
|
+
*
|
|
42
|
+
* A negative or non-numeric value falls back to the default; `0` is a LEGAL
|
|
43
|
+
* value — it switches the protection off and restores the old behaviour (a
|
|
44
|
+
* queued message blocks the restart for ever).
|
|
45
|
+
*
|
|
46
|
+
* @param {unknown} value
|
|
47
|
+
* @returns {number} milliseconds, never negative
|
|
48
|
+
*/
|
|
49
|
+
export function normalizeQueuedGraceMs(value) {
|
|
50
|
+
if (!isFiniteNumber(value) || value < 0) return DEFAULT_QUEUED_GRACE_MS
|
|
51
|
+
return Math.trunc(value)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Create the tracker.
|
|
56
|
+
*
|
|
57
|
+
* @param {{ now?: () => number }} [options] the clock (injectable for tests)
|
|
58
|
+
* @returns {{
|
|
59
|
+
* observe: (agentId: string, queued: boolean, graceMs?: number) => { blocking: boolean, firstSeenAt: number|null, ageMs: number, becameStale: boolean },
|
|
60
|
+
* forget: (agentId: string) => boolean,
|
|
61
|
+
* size: () => number,
|
|
62
|
+
* ids: () => string[],
|
|
63
|
+
* }}
|
|
64
|
+
*/
|
|
65
|
+
export function createQueuedTracker({ now = () => Date.now() } = {}) {
|
|
66
|
+
/** @type {Map<string, { firstSeenAt: number, stale: boolean }>} */
|
|
67
|
+
const records = new Map()
|
|
68
|
+
|
|
69
|
+
/** The current time of the injected clock (never throws). */
|
|
70
|
+
const clock = () => {
|
|
71
|
+
try {
|
|
72
|
+
const value = now()
|
|
73
|
+
return isFiniteNumber(value) ? value : 0
|
|
74
|
+
} catch {
|
|
75
|
+
return 0
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Observe one session.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} agentId the session id
|
|
83
|
+
* @param {boolean} queued does the session have an unclaimed message?
|
|
84
|
+
* @param {number} [graceMs] the upper bound (0 = no upper bound)
|
|
85
|
+
* @returns {{ blocking: boolean, firstSeenAt: number|null, ageMs: number, becameStale: boolean }}
|
|
86
|
+
*/
|
|
87
|
+
const observe = (agentId, queued, graceMs) => {
|
|
88
|
+
const id = agentId == null ? '' : String(agentId)
|
|
89
|
+
if (id === '') return { blocking: false, firstSeenAt: null, ageMs: 0, becameStale: false }
|
|
90
|
+
|
|
91
|
+
// The session has no queued work any more: its record is dropped, so a
|
|
92
|
+
// NEW queue starts counting from zero.
|
|
93
|
+
if (queued !== true) {
|
|
94
|
+
records.delete(id)
|
|
95
|
+
return { blocking: false, firstSeenAt: null, ageMs: 0, becameStale: false }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const at = clock()
|
|
99
|
+
let record = records.get(id)
|
|
100
|
+
if (record === undefined) {
|
|
101
|
+
record = { firstSeenAt: at, stale: false }
|
|
102
|
+
records.set(id, record)
|
|
103
|
+
}
|
|
104
|
+
const ageMs = Math.max(0, at - record.firstSeenAt)
|
|
105
|
+
const grace = normalizeQueuedGraceMs(graceMs)
|
|
106
|
+
|
|
107
|
+
// The protection is off (grace 0): a queued message blocks the restart for
|
|
108
|
+
// ever — exactly the behaviour before this module existed.
|
|
109
|
+
if (grace === 0) {
|
|
110
|
+
return { blocking: true, firstSeenAt: record.firstSeenAt, ageMs, becameStale: false }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Still inside the grace window: the agent may claim the message any
|
|
114
|
+
// moment, so the restart keeps waiting for it.
|
|
115
|
+
if (ageMs < grace) {
|
|
116
|
+
return { blocking: true, firstSeenAt: record.firstSeenAt, ageMs, becameStale: false }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// The message is STUCK: it stops blocking the restart. `becameStale` is
|
|
120
|
+
// true only on the tick where it turned stale, so the caller can log it
|
|
121
|
+
// exactly once.
|
|
122
|
+
const becameStale = record.stale !== true
|
|
123
|
+
record.stale = true
|
|
124
|
+
return { blocking: false, firstSeenAt: record.firstSeenAt, ageMs, becameStale }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Drop the record of one session (it is running, or it is gone).
|
|
129
|
+
*
|
|
130
|
+
* @param {string} agentId
|
|
131
|
+
* @returns {boolean} whether a record was dropped
|
|
132
|
+
*/
|
|
133
|
+
const forget = (agentId) => {
|
|
134
|
+
const id = agentId == null ? '' : String(agentId)
|
|
135
|
+
if (id === '') return false
|
|
136
|
+
return records.delete(id)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** How many sessions are tracked right now. */
|
|
140
|
+
const size = () => records.size
|
|
141
|
+
|
|
142
|
+
/** The ids of the tracked sessions. */
|
|
143
|
+
const ids = () => Array.from(records.keys())
|
|
144
|
+
|
|
145
|
+
return { observe, forget, size, ids }
|
|
146
|
+
}
|
package/locale/en.json
CHANGED
|
@@ -18,17 +18,21 @@
|
|
|
18
18
|
},
|
|
19
19
|
"settings": {
|
|
20
20
|
"nav": "Auto-restart & shut down",
|
|
21
|
+
"groups": {
|
|
22
|
+
"restart": "Restart settings",
|
|
23
|
+
"shutdown": "Shutdown settings"
|
|
24
|
+
},
|
|
21
25
|
"seconds": {
|
|
22
|
-
"label": "
|
|
26
|
+
"label": "Shutdown countdown seconds",
|
|
23
27
|
"immediate": "No countdown (switch off right away)",
|
|
24
28
|
"value": "{n} seconds",
|
|
25
|
-
"hint": "
|
|
29
|
+
"hint": "Once every conversation is done, this countdown is shown on screen (pick \"Never mind, keep it on\" to cancel); the machine goes off when it reaches zero. Raise it when you want more time to write."
|
|
26
30
|
},
|
|
27
|
-
"
|
|
28
|
-
"label": "Wait
|
|
29
|
-
"immediate": "Do not wait (restart
|
|
31
|
+
"restartGrace": {
|
|
32
|
+
"label": "Wait before the restart",
|
|
33
|
+
"immediate": "Do not wait (restart right away)",
|
|
30
34
|
"value": "{n} seconds",
|
|
31
|
-
"hint": "
|
|
35
|
+
"hint": "A restart has no countdown: the host restarts as soon as the conversations are done. Raise this if you rely on a memory or summary plugin that writes its notes after the conversation ended (default 0 seconds)."
|
|
32
36
|
},
|
|
33
37
|
"backup": {
|
|
34
38
|
"label": "Backup before a restart",
|
package/locale/zh.json
CHANGED
|
@@ -18,17 +18,21 @@
|
|
|
18
18
|
},
|
|
19
19
|
"settings": {
|
|
20
20
|
"nav": "自动重启与关机",
|
|
21
|
+
"groups": {
|
|
22
|
+
"restart": "重启相关设置",
|
|
23
|
+
"shutdown": "关机相关设置"
|
|
24
|
+
},
|
|
21
25
|
"seconds": {
|
|
22
|
-
"label": "
|
|
26
|
+
"label": "关机倒数秒数",
|
|
23
27
|
"immediate": "不倒数(立即关机)",
|
|
24
28
|
"value": "{n} 秒",
|
|
25
|
-
"hint": "
|
|
29
|
+
"hint": "所有会话收工后,屏幕上会显示这段倒数(可点「不关了」取消),数完才关机;想要更多落盘时间就把倒数调大。"
|
|
26
30
|
},
|
|
27
|
-
"
|
|
28
|
-
"label": "
|
|
29
|
-
"immediate": "
|
|
31
|
+
"restartGrace": {
|
|
32
|
+
"label": "重启前等待",
|
|
33
|
+
"immediate": "不等(立即重启)",
|
|
30
34
|
"value": "{n} 秒",
|
|
31
|
-
"hint": "
|
|
35
|
+
"hint": "重启没有倒数环节,会话一收工就会立刻重启;如果你装了“会话结束后才写”的记忆/总结类插件,把这里调大(默认 0 秒)。"
|
|
32
36
|
},
|
|
33
37
|
"backup": {
|
|
34
38
|
"label": "重启前备份",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-autorestart",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.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",
|