dsh-session-guard 0.1.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 +21 -0
- package/README.md +128 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +271 -0
- package/lib/client.js.map +1 -0
- package/package.json +67 -0
- package/src/bridge.js +76 -0
- package/src/client/index.ts +56 -0
- package/src/client/settings-card.tsx +191 -0
- package/src/client/status-badge.tsx +66 -0
- package/src/detect.js +27 -0
- package/src/gate.js +93 -0
- package/src/index.js +289 -0
- package/src/pause-gate.js +416 -0
- package/src/pause-store.js +101 -0
- package/src/retry.js +202 -0
- package/src/scheduler.js +42 -0
- package/src/settings.js +97 -0
- package/src/store.js +77 -0
- package/src/time.js +88 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-session-guard — 自研会话门引擎(脱离 dsh-task-control,全量移植)。
|
|
3
|
+
*
|
|
4
|
+
* 真实锁定「session 推进」:复用 dsh runtime 自身的原语,不依赖任何外部会话门:
|
|
5
|
+
* - `agent.cancel({ kind:'user' }, { keepInbox:true })` —— 停住当前回合(中断推理/在途工具)。
|
|
6
|
+
* - `goals.pause(agent, { id, revision })` —— 停住同会话 goal,防 goal-round driver 再排。
|
|
7
|
+
* - `ctx.on('session/event')` —— 安全边界:tool/call 记 in-flight,tool/result 落地,
|
|
8
|
+
* assistant/message 记录 deferredTools,再落地延迟暂停(queueMicrotask)。
|
|
9
|
+
* - `agent.followup(createUserMessage({ content, source:{kind:'plugin',plugin} }))` —— 恢复续跑指令。
|
|
10
|
+
* - 暂停状态持久化(src/pause-store.js),不写 session log。
|
|
11
|
+
*
|
|
12
|
+
* 三粒度(对齐 task-control):
|
|
13
|
+
* force 立即中断推理+在途工具,记 interruptedTool,resume 需 confirm 选 rerun/skip
|
|
14
|
+
* safe + stop 在途工具跑完后再暂停(不中断推理则工具完成后落地)
|
|
15
|
+
* safe + wait 不中断推理,assistant/message 后记 deferredTools 再落地(默认)
|
|
16
|
+
*
|
|
17
|
+
* 设计为目标可单测:ctx / pauseStore / createUserMessage 全部依赖注入,
|
|
18
|
+
* 停/续/取消判定不依赖真实 dsh runtime;`getAgent(sessionId)` 经 `ctx.agents.get` 懒取。
|
|
19
|
+
*/
|
|
20
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {object} deps
|
|
24
|
+
* @param {object} deps.ctx host context(含 agents.get / get / logger,可选 goals)
|
|
25
|
+
* @param {ReturnType<import('./pause-store.js').createPauseStore>} deps.pauseStore 暂停状态存储
|
|
26
|
+
* @param {string} [deps.pluginId] followup 消息的 source.plugin(默认 'session-guard')
|
|
27
|
+
* @param {(content: object) => unknown} [deps.makeFollowupMessage] 恢复消息构造(默认 dsh-llm createUserMessage;测试注入)
|
|
28
|
+
*/
|
|
29
|
+
export function createPauseGate({ ctx, pauseStore, pluginId = 'session-guard', makeFollowupMessage = createUserMessage }) {
|
|
30
|
+
const state = { inFlight: new Map(), pendingPause: new Map() }
|
|
31
|
+
const getAgent = (sessionId) => (ctx && typeof ctx.agents?.get === 'function' ? ctx.agents.get(sessionId) : undefined)
|
|
32
|
+
|
|
33
|
+
/** 会话门(dsh-task-control)当前是否可用(兼容探测;自研后始终视为可用)。 */
|
|
34
|
+
const taskControlAvailable = () => true
|
|
35
|
+
|
|
36
|
+
// ── in-flight 工具跟踪 ──────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
function inflightOf(sessionId) {
|
|
39
|
+
let map = state.inFlight.get(sessionId)
|
|
40
|
+
if (map === undefined) {
|
|
41
|
+
map = new Map()
|
|
42
|
+
state.inFlight.set(sessionId, map)
|
|
43
|
+
}
|
|
44
|
+
return map
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function latestInflight(sessionId) {
|
|
48
|
+
const map = inflightOf(sessionId)
|
|
49
|
+
const entries = [...map.values()]
|
|
50
|
+
return entries.length > 0 ? entries[entries.length - 1] : null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function clearInflight(sessionId) {
|
|
54
|
+
state.inFlight.delete(sessionId)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── 工具描述 / 结果查阅(resume 断点决策) ────────────────────────────
|
|
58
|
+
|
|
59
|
+
/** 描述工具目的:优先 model 写的 description,再 bash command,再 compact args。 */
|
|
60
|
+
function describeToolPurpose(info) {
|
|
61
|
+
if (info === undefined || info === null) return '未知工具'
|
|
62
|
+
const raw = info.arguments
|
|
63
|
+
const parseArgs = (text) => {
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(text)
|
|
66
|
+
} catch {
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const args = typeof raw === 'string' ? parseArgs(raw) : raw
|
|
71
|
+
if (args !== null && typeof args.description === 'string' && args.description.length > 0) return args.description
|
|
72
|
+
if (info.name === 'bash' && args !== null && typeof args.command === 'string') return `运行命令:${args.command}`
|
|
73
|
+
if (args !== null) {
|
|
74
|
+
try {
|
|
75
|
+
return JSON.stringify(args).slice(0, 120)
|
|
76
|
+
} catch {
|
|
77
|
+
/* ignore */
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return `工具 ${info.name}`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function describeTool(info) {
|
|
84
|
+
return `${info?.name ?? '未知工具'}(${describeToolPurpose(info)})`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 在 session log 查一个中断工具的实际结果(kernel 会 drain 已启动工具到 tool/result)。 */
|
|
88
|
+
function findToolOutcome(agent, callId) {
|
|
89
|
+
if (!agent?.session?.events) return null
|
|
90
|
+
let outcome = null
|
|
91
|
+
for (const event of agent.session.events) {
|
|
92
|
+
if (event.type === 'tool/result') {
|
|
93
|
+
const message = event.data?.message ?? {}
|
|
94
|
+
const block = (Array.isArray(message.content) ? message.content : []).find((b) => b?.type === 'tool-result')
|
|
95
|
+
const id = block?.toolCallId ?? message.source?.callId
|
|
96
|
+
if (id === callId) {
|
|
97
|
+
outcome = {
|
|
98
|
+
hasResult: true,
|
|
99
|
+
isError: block?.isError === true,
|
|
100
|
+
abortedBeforeDispatch: event.data?.error?.code === 'ABORTED_BEFORE_DISPATCH',
|
|
101
|
+
content: block?.content ?? [],
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
} else if (event.type === 'user/message') {
|
|
105
|
+
const content = Array.isArray(event.data?.content) ? event.data.content : []
|
|
106
|
+
for (const block of content) {
|
|
107
|
+
if (block?.type === 'tool-result' && block.toolCallId === callId) {
|
|
108
|
+
outcome = { hasResult: true, isError: block.isError === true, abortedBeforeDispatch: false, content: block.content ?? [] }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return outcome
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function lastUserPrompt(agent) {
|
|
117
|
+
if (!agent?.session?.events) return null
|
|
118
|
+
for (let index = agent.session.events.length - 1; index >= 0; index -= 1) {
|
|
119
|
+
const event = agent.session.events[index]
|
|
120
|
+
if (event.type !== 'user/message') continue
|
|
121
|
+
if (event.data?.source?.kind !== 'user') continue
|
|
122
|
+
return event.data.content ?? null
|
|
123
|
+
}
|
|
124
|
+
return null
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── 暂停状态读写 ────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
function currentPause(id) {
|
|
130
|
+
return pauseStore.current(id)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function markPaused(id, resumeContent, forcedContext, deferredTools) {
|
|
134
|
+
const snapshot = {
|
|
135
|
+
sessionId: String(id),
|
|
136
|
+
paused: true,
|
|
137
|
+
resumeContent: resumeContent ?? null,
|
|
138
|
+
forced: forcedContext?.forced === true,
|
|
139
|
+
interruptedTool: forcedContext?.interruptedTool ?? null,
|
|
140
|
+
deferredTools: deferredTools ?? null,
|
|
141
|
+
updatedAt: Date.now(),
|
|
142
|
+
}
|
|
143
|
+
pauseStore.set(id, snapshot)
|
|
144
|
+
return snapshot
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function clearPaused(id) {
|
|
148
|
+
pauseStore.clear(id)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── goal 暂停(防 goal-round driver 再排同会话轮次) ──────────────────
|
|
152
|
+
|
|
153
|
+
function pauseSessionGoal(agent) {
|
|
154
|
+
try {
|
|
155
|
+
const goals = ctx && typeof ctx.get === 'function' ? ctx.get('goals') : undefined
|
|
156
|
+
if (goals === undefined) return
|
|
157
|
+
const goal = goals.get(agent)
|
|
158
|
+
if (goal !== undefined && goal.phase === 'active') {
|
|
159
|
+
goals.pause(agent, { id: goal.id, revision: goal.revision })
|
|
160
|
+
}
|
|
161
|
+
} catch (e) {
|
|
162
|
+
ctx?.logger?.warn?.('[session-guard] goal pause failed: ' + String(e))
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── 立即落地暂停 ────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
/** 立即停止运行回合(agent.cancel 保 inbox)+ 停 goal + 持久化快照。 */
|
|
169
|
+
function applyPauseNow(id, resumeContent, forcedContext, deferredTools) {
|
|
170
|
+
state.pendingPause.delete(id)
|
|
171
|
+
const current = currentPause(id)
|
|
172
|
+
if (current.paused) return { kind: 'success', text: 'task is already paused' }
|
|
173
|
+
const agent = getAgent(id)
|
|
174
|
+
if (agent !== undefined && agent.status === 'running') agent.cancel({ kind: 'user' }, { keepInbox: true })
|
|
175
|
+
clearInflight(id)
|
|
176
|
+
if (agent !== undefined) pauseSessionGoal(agent)
|
|
177
|
+
markPaused(id, resumeContent, forcedContext, deferredTools)
|
|
178
|
+
return { kind: 'success', text: agent !== undefined && agent.status === 'running' ? 'task paused — the running turn was stopped' : 'task paused' }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── 暂停主逻辑(三粒度) ─────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* 暂停一会话。opts: { mode:'safe'|'force', reason:'stop'|'wait' }。
|
|
185
|
+
* 默认 safe+wait(对齐 DEFAULT_SETTINGS)。返回 { kind, text, needConfirmation? }。
|
|
186
|
+
*/
|
|
187
|
+
function pauseTask(sessionId, opts = {}) {
|
|
188
|
+
const agent = getAgent(sessionId)
|
|
189
|
+
if (agent === undefined) return { kind: 'error', text: 'no live agent for this session — nothing to pause' }
|
|
190
|
+
const current = currentPause(sessionId)
|
|
191
|
+
if (current.paused) return { kind: 'success', text: 'task is already paused' }
|
|
192
|
+
const mode = opts.mode ?? 'safe'
|
|
193
|
+
const reason = opts.reason ?? 'wait'
|
|
194
|
+
const resumeContent = agent.status === 'running' ? lastUserPrompt(agent) : null
|
|
195
|
+
|
|
196
|
+
if (mode === 'force') {
|
|
197
|
+
const interruptedTool = latestInflight(sessionId)
|
|
198
|
+
if (agent.status === 'running') agent.cancel({ kind: 'user' }, { keepInbox: true })
|
|
199
|
+
clearInflight(sessionId)
|
|
200
|
+
pauseSessionGoal(agent)
|
|
201
|
+
markPaused(sessionId, resumeContent, {
|
|
202
|
+
forced: true,
|
|
203
|
+
interruptedTool: interruptedTool ? { name: interruptedTool.name, arguments: interruptedTool.arguments, callId: interruptedTool.callId } : null,
|
|
204
|
+
})
|
|
205
|
+
return {
|
|
206
|
+
kind: 'success',
|
|
207
|
+
text: interruptedTool !== null
|
|
208
|
+
? `task force-paused — interrupted tool ${interruptedTool.name}(预期目的:${describeToolPurpose(interruptedTool)}),可能已部分执行`
|
|
209
|
+
: 'task force-paused — interrupted the running turn',
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// safe mode —— 延迟到安全边界
|
|
214
|
+
if (agent.status === 'running' && inflightOf(sessionId).size > 0) {
|
|
215
|
+
state.pendingPause.set(sessionId, { resumeContent, mode: 'safe', reason })
|
|
216
|
+
return { kind: 'success', text: 'task pausing — waiting for the running tool to finish (safe boundary), trace keeps recording until then' }
|
|
217
|
+
}
|
|
218
|
+
if (agent.status === 'running' && reason === 'wait') {
|
|
219
|
+
state.pendingPause.set(sessionId, { resumeContent, mode: 'safe', reason: 'wait' })
|
|
220
|
+
return { kind: 'success', text: 'task pausing — waiting for the current reasoning to complete before pausing' }
|
|
221
|
+
}
|
|
222
|
+
return applyPauseNow(sessionId, resumeContent, { forced: false, interruptedTool: null })
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── 恢复主逻辑 ───────────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* 恢复一会话(从暂停点继续,session log 即 trace,不整体重发)。
|
|
229
|
+
* opts: { confirm:boolean, choice:'rerun'|'skip' }。
|
|
230
|
+
*/
|
|
231
|
+
function resumeTask(sessionId, opts = {}) {
|
|
232
|
+
const agent = getAgent(sessionId)
|
|
233
|
+
if (agent === undefined) return { kind: 'error', text: 'no live agent for this session' }
|
|
234
|
+
state.pendingPause.delete(sessionId)
|
|
235
|
+
const current = currentPause(sessionId)
|
|
236
|
+
if (!current.paused) return { kind: 'success', text: 'no paused task to resume' }
|
|
237
|
+
const followup = (blocks) => agent.followup(makeFollowupMessage({ content: blocks, source: { kind: 'plugin', plugin: pluginId } }))
|
|
238
|
+
|
|
239
|
+
if (current.forced) {
|
|
240
|
+
const tool = current.interruptedTool
|
|
241
|
+
if (tool !== null) {
|
|
242
|
+
if (opts?.confirm !== true) {
|
|
243
|
+
return { kind: 'error', needConfirmation: true, text: `需要确认:上次暂停时工具 ${describeTool(tool)} 没有执行完成,将重新执行。请选择:重新执行该工具 / 跳过该工具 / 保持暂停。` }
|
|
244
|
+
}
|
|
245
|
+
const outcome = findToolOutcome(agent, tool.callId)
|
|
246
|
+
clearPaused(sessionId)
|
|
247
|
+
if (outcome !== null && !outcome.isError) {
|
|
248
|
+
followup([{ type: 'text', text: `任务已恢复。上次暂停时工具 ${describeTool(tool)} 实际已执行完成(结果见上方上下文)。请基于该结果继续执行,不要重复执行该工具。` }])
|
|
249
|
+
return { kind: 'success', text: `task resumed — tool ${tool.name} had actually completed, continuing` }
|
|
250
|
+
}
|
|
251
|
+
if (outcome !== null && outcome.abortedBeforeDispatch) {
|
|
252
|
+
if (opts?.choice === 'skip') {
|
|
253
|
+
followup([{ type: 'text', text: `任务已恢复。上次暂停时工具 ${describeTool(tool)} 未及执行(无副作用),你选择跳过。请直接继续后续工作。` }])
|
|
254
|
+
return { kind: 'success', text: `task resumed — skipped tool ${tool.name}` }
|
|
255
|
+
}
|
|
256
|
+
followup([{ type: 'text', text: `任务已恢复。上次暂停时工具 ${describeTool(tool)} 未及执行(无副作用)。请执行该工具调用,然后继续任务。` }])
|
|
257
|
+
return { kind: 'success', text: `task resumed — re-executing tool ${tool.name}` }
|
|
258
|
+
}
|
|
259
|
+
if (opts?.choice === 'skip') {
|
|
260
|
+
followup([{ type: 'text', text: `任务已恢复。上次暂停时工具 ${describeTool(tool)} 没有执行完成(可能已部分执行,状态未知)。你选择跳过该工具:请基于已有上下文继续任务,不再执行该工具。` }])
|
|
261
|
+
return { kind: 'success', text: `task resumed — skipped tool ${tool.name}` }
|
|
262
|
+
}
|
|
263
|
+
followup([{ type: 'text', text: `任务已恢复。上次暂停时工具 ${describeTool(tool)} 没有执行完成(可能已部分执行并产生副作用,状态未知)。你选择重新执行:请先评估/清理该工具可能产生的部分副作用,再重新执行该工具调用,然后继续任务。` }])
|
|
264
|
+
return { kind: 'success', text: `task resumed — re-executing interrupted tool ${tool.name}` }
|
|
265
|
+
}
|
|
266
|
+
clearPaused(sessionId)
|
|
267
|
+
followup([{ type: 'text', text: '任务已恢复。请基于以上上下文(暂停点之前的完整执行记录)继续执行任务。' }])
|
|
268
|
+
return { kind: 'success', text: 'task resumed — continuing from the latest trace' }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// safe pause
|
|
272
|
+
const deferred = Array.isArray(current.deferredTools) ? current.deferredTools : []
|
|
273
|
+
if (deferred.length > 0) {
|
|
274
|
+
if (opts?.confirm !== true) {
|
|
275
|
+
return { kind: 'error', needConfirmation: true, text: `需要确认:上次暂停发生在推理完成后、工具执行前,以下工具未及执行(无副作用):${deferred.map(describeTool).join('、')}。请选择:重新执行 / 跳过 / 保持暂停。` }
|
|
276
|
+
}
|
|
277
|
+
clearPaused(sessionId)
|
|
278
|
+
const rerun = deferred.filter((tool) => {
|
|
279
|
+
const outcome = findToolOutcome(agent, tool.callId)
|
|
280
|
+
return outcome === null || outcome.abortedBeforeDispatch === true
|
|
281
|
+
})
|
|
282
|
+
if (rerun.length === 0) {
|
|
283
|
+
followup([{ type: 'text', text: '任务已恢复。上次暂停时待执行的工具均已实际执行完成,请基于以上结果继续执行,不要重复执行。' }])
|
|
284
|
+
return { kind: 'success', text: 'task resumed — deferred tools had actually completed' }
|
|
285
|
+
}
|
|
286
|
+
if (opts?.choice === 'skip') {
|
|
287
|
+
followup([{ type: 'text', text: `任务已恢复。上次暂停时以下工具未及执行(无副作用),你选择跳过:${rerun.map(describeTool).join('、')}。请直接继续后续工作。` }])
|
|
288
|
+
return { kind: 'success', text: 'task resumed — skipped deferred tools' }
|
|
289
|
+
}
|
|
290
|
+
followup([{ type: 'text', text: `任务已恢复。上次暂停时以下工具未及执行(无副作用),请执行这些工具调用,然后继续任务:${rerun.map(describeTool).join('、')}。` }])
|
|
291
|
+
return { kind: 'success', text: 'task resumed — re-executing deferred tools' }
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
clearPaused(sessionId)
|
|
295
|
+
if (agent.status === 'running') {
|
|
296
|
+
return { kind: 'success', text: 'task resumed — the task was still actually running; execution results keep flowing' }
|
|
297
|
+
}
|
|
298
|
+
followup([{ type: 'text', text: '任务已恢复。请基于以上上下文(暂停点之前的完整执行记录)从暂停点继续执行,不要重复已完成的工作。' }])
|
|
299
|
+
return { kind: 'success', text: 'task resumed — continuing from the pause point' }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ── 取消 ─────────────────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
/** 立即终止当前回合,回报被中断工具的目的/副作用风险。 */
|
|
305
|
+
function cancelTask(sessionId) {
|
|
306
|
+
const agent = getAgent(sessionId)
|
|
307
|
+
if (agent === undefined) return { kind: 'error', text: 'no live agent for this session' }
|
|
308
|
+
const current = currentPause(sessionId)
|
|
309
|
+
if (current.paused) clearPaused(sessionId)
|
|
310
|
+
state.pendingPause.delete(sessionId)
|
|
311
|
+
let interrupted = null
|
|
312
|
+
if (agent.status === 'running') {
|
|
313
|
+
interrupted = latestInflight(sessionId)
|
|
314
|
+
agent.cancel({ kind: 'user' }, { keepInbox: true })
|
|
315
|
+
clearInflight(sessionId)
|
|
316
|
+
}
|
|
317
|
+
if (interrupted !== null) {
|
|
318
|
+
return { kind: 'success', text: `task cancelled — 已立即终止正在执行的工具 ${interrupted.name}。其预期目的:${describeToolPurpose(interrupted)}。请检查该操作是否产生了副作用(文件修改、进程、网络等)。` }
|
|
319
|
+
}
|
|
320
|
+
return { kind: 'success', text: 'task cancelled' }
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ── 安全边界:session/event 监听落点 ─────────────────────────────────
|
|
324
|
+
|
|
325
|
+
function scheduleDeferredPause(sessionId, resumeContent, deferredTools) {
|
|
326
|
+
queueMicrotask(() => {
|
|
327
|
+
try {
|
|
328
|
+
const agent = getAgent(sessionId)
|
|
329
|
+
if (agent === undefined) {
|
|
330
|
+
state.pendingPause.delete(sessionId)
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
applyPauseNow(sessionId, resumeContent, { forced: false, interruptedTool: null }, deferredTools)
|
|
334
|
+
} catch (e) {
|
|
335
|
+
ctx?.logger?.warn?.('[session-guard] deferred pause failed: ' + String(e))
|
|
336
|
+
state.pendingPause.delete(sessionId)
|
|
337
|
+
}
|
|
338
|
+
})
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function tryApplyPending(sessionId) {
|
|
342
|
+
const pending = state.pendingPause.get(sessionId)
|
|
343
|
+
if (pending === undefined) return
|
|
344
|
+
if (pending.mode !== 'safe') return
|
|
345
|
+
if (inflightOf(sessionId).size > 0) return
|
|
346
|
+
scheduleDeferredPause(sessionId, pending.resumeContent ?? null, pending.deferredTools ?? null)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** 会话事件监听:跟踪在途工具 + 在安全边界落地延迟暂停。 */
|
|
350
|
+
function handleEvent(session, event) {
|
|
351
|
+
const sessionId = session?.id
|
|
352
|
+
if (typeof sessionId !== 'string') return
|
|
353
|
+
if (event.type === 'tool/call') {
|
|
354
|
+
const info = { name: event.data?.name ?? 'tool', arguments: event.data?.arguments ?? null, callId: event.data?.callId ?? 'unknown' }
|
|
355
|
+
if (typeof event.data?.callId === 'string') inflightOf(sessionId).set(event.data.callId, info)
|
|
356
|
+
return
|
|
357
|
+
}
|
|
358
|
+
if (event.type === 'tool/result') {
|
|
359
|
+
const callId = event.data?.message?.source?.callId ?? event.data?.message?.content?.[0]?.toolCallId
|
|
360
|
+
if (typeof callId === 'string') inflightOf(sessionId).delete(callId)
|
|
361
|
+
tryApplyPending(sessionId)
|
|
362
|
+
return
|
|
363
|
+
}
|
|
364
|
+
if (event.type === 'assistant/message') {
|
|
365
|
+
const pending = state.pendingPause.get(sessionId)
|
|
366
|
+
if (pending !== undefined && pending.mode === 'safe' && pending.reason === 'wait') {
|
|
367
|
+
const content = Array.isArray(event.data?.message?.content) ? event.data.message.content : []
|
|
368
|
+
const calls = content.filter((block) => block?.type === 'tool-call').map((block) => ({
|
|
369
|
+
name: block.name ?? 'tool',
|
|
370
|
+
arguments: block.arguments ?? null,
|
|
371
|
+
callId: block.id ?? 'unknown',
|
|
372
|
+
}))
|
|
373
|
+
if (calls.length > 0) pending.deferredTools = calls
|
|
374
|
+
}
|
|
375
|
+
tryApplyPending(sessionId)
|
|
376
|
+
return
|
|
377
|
+
}
|
|
378
|
+
if (event.type === 'user/message') {
|
|
379
|
+
const content = Array.isArray(event.data?.content) ? event.data.content : []
|
|
380
|
+
for (const block of content) {
|
|
381
|
+
if (block?.type === 'tool-result' && typeof block.toolCallId === 'string') inflightOf(sessionId).delete(block.toolCallId)
|
|
382
|
+
}
|
|
383
|
+
tryApplyPending(sessionId)
|
|
384
|
+
return
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ── 状态读取 ─────────────────────────────────────────────────────────
|
|
389
|
+
|
|
390
|
+
function sessionState(sessionId) {
|
|
391
|
+
const agent = getAgent(sessionId)
|
|
392
|
+
const current = currentPause(sessionId)
|
|
393
|
+
return {
|
|
394
|
+
sessionId: String(sessionId),
|
|
395
|
+
status: agent === undefined ? 'offline' : agent.status,
|
|
396
|
+
paused: current.paused === true,
|
|
397
|
+
forced: current.forced === true,
|
|
398
|
+
interruptedTool: current.interruptedTool ?? null,
|
|
399
|
+
deferredTools: current.deferredTools ?? null,
|
|
400
|
+
resumeContent: current.resumeContent ?? null,
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return {
|
|
405
|
+
pause: pauseTask,
|
|
406
|
+
resume: resumeTask,
|
|
407
|
+
cancel: cancelTask,
|
|
408
|
+
state: sessionState,
|
|
409
|
+
handleEvent,
|
|
410
|
+
taskControlAvailable,
|
|
411
|
+
/** 当前 in-flight 工具数(测试用)。 */
|
|
412
|
+
_inflightCount: (id) => inflightOf(id).size,
|
|
413
|
+
/** 是否挂起了延迟暂停(测试用)。 */
|
|
414
|
+
_pendingCount: () => state.pendingPause.size,
|
|
415
|
+
}
|
|
416
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-session-guard — 自研会话门暂停状态持久化(脱离 dsh-task-control)。
|
|
3
|
+
*
|
|
4
|
+
* 与 queueLock(src/store.js,队列锁)分离:这里是「真暂停」的持久化快照,
|
|
5
|
+
* 仿 task-control 的状态模型,但独立子目录 /pause 避免与队列锁文件互相污染。
|
|
6
|
+
*
|
|
7
|
+
* 暂停/锁定状态仍**不写 session log**(harness 持久化 reader 只有已知事件集,
|
|
8
|
+
* 自定义 `session-guard/*` 事件会导致重启后会话无法加载),走插件自有 JSON 文件:
|
|
9
|
+
* `$DSH_HOME/.dsh/session-guard/pause/<sessionId>.json`(`DSH_SESSION_GUARD_PAUSE_DIR`
|
|
10
|
+
* 可覆盖根目录)。原子写:tmp + rename。
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
13
|
+
import { dirname, join } from 'node:path'
|
|
14
|
+
import { storeRoot } from './store.js'
|
|
15
|
+
|
|
16
|
+
/** 会话 id → 安全文件名(与队列锁同规则)。 */
|
|
17
|
+
export function encodeSessionId(id) {
|
|
18
|
+
return String(id).replace(/[^a-zA-Z0-9._-]/g, '_')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 自研暂停状态根目录(独立于队列锁)。 */
|
|
22
|
+
export function pauseStateRoot() {
|
|
23
|
+
return (
|
|
24
|
+
process.env.DSH_SESSION_GUARD_PAUSE_DIR ||
|
|
25
|
+
join(storeRoot(), 'pause')
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 每会话暂停状态文件路径。 */
|
|
30
|
+
export function pauseFilePath(id) {
|
|
31
|
+
return join(pauseStateRoot(), `${encodeSessionId(id)}.json`)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 未暂停基线快照。 */
|
|
35
|
+
export function idlePauseState(id) {
|
|
36
|
+
return {
|
|
37
|
+
sessionId: String(id),
|
|
38
|
+
paused: false,
|
|
39
|
+
resumeContent: null,
|
|
40
|
+
forced: false,
|
|
41
|
+
interruptedTool: null,
|
|
42
|
+
deferredTools: null,
|
|
43
|
+
updatedAt: null,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 创建暂停状态存储(内存缓存 + 磁盘持久化,原子写)。
|
|
49
|
+
* 独立于队列锁 store,字段形态仿 task-control(paused/forced/interruptedTool/deferredTools)。
|
|
50
|
+
*/
|
|
51
|
+
export function createPauseStore() {
|
|
52
|
+
const cache = new Map()
|
|
53
|
+
|
|
54
|
+
function read(id) {
|
|
55
|
+
if (cache.has(id)) return cache.get(id)
|
|
56
|
+
try {
|
|
57
|
+
const f = pauseFilePath(id)
|
|
58
|
+
if (!existsSync(f)) return null
|
|
59
|
+
const v = JSON.parse(readFileSync(f, 'utf8'))
|
|
60
|
+
cache.set(id, v)
|
|
61
|
+
return v
|
|
62
|
+
} catch {
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
/** 读一会话暂停态;无记录返回 null(调用方 fallback idlePauseState)。 */
|
|
69
|
+
get(id) {
|
|
70
|
+
return read(id)
|
|
71
|
+
},
|
|
72
|
+
/** 原子写。 */
|
|
73
|
+
set(id, value) {
|
|
74
|
+
cache.set(id, value)
|
|
75
|
+
try {
|
|
76
|
+
const f = pauseFilePath(id)
|
|
77
|
+
mkdirSync(dirname(f), { recursive: true })
|
|
78
|
+
const tmp = `${f}.tmp`
|
|
79
|
+
writeFileSync(tmp, JSON.stringify(value, null, 2))
|
|
80
|
+
renameSync(tmp, f)
|
|
81
|
+
} catch (e) {
|
|
82
|
+
// 落盘失败仅影响重启恢复,内存态仍可用。
|
|
83
|
+
console.error(`[session-guard] pause store write failed: ${String(e && e.message || e)}`)
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
/** 清除(resume/cancel 后)。 */
|
|
87
|
+
clear(id) {
|
|
88
|
+
cache.delete(id)
|
|
89
|
+
try {
|
|
90
|
+
rmSync(pauseFilePath(id), { force: true })
|
|
91
|
+
} catch {
|
|
92
|
+
/* ignore */
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
/** 当前有效快照(无记录时 idle 基线)。 */
|
|
96
|
+
current(id) {
|
|
97
|
+
const v = read(id)
|
|
98
|
+
return v !== null && typeof v === 'object' ? v : idlePauseState(id)
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
}
|