dsh-session-guard 0.4.0 → 3.0.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/CHANGELOG.ja.md +19 -0
- package/CHANGELOG.ko.md +19 -0
- package/CHANGELOG.md +19 -0
- package/INSTALL.ja.md +14 -3
- package/INSTALL.ko.md +14 -3
- package/INSTALL.md +17 -4
- package/INSTALL.zh.md +12 -3
- package/LICENSE +21 -21
- package/README.en.md +14 -2
- package/README.ja.md +37 -1
- package/README.ko.md +37 -1
- package/README.md +34 -10
- package/cordis.patch.yml +10 -10
- package/lib/client.js +656 -656
- package/package.json +1 -1
- package/src/bridge.js +136 -136
- package/src/client/index.ts +66 -66
- package/src/client/pause-button-text.ts +60 -60
- package/src/client/pause-button.tsx +129 -129
- package/src/client/styles.ts +30 -30
- package/src/detect.js +27 -27
- package/src/gate.js +93 -93
- package/src/pause-store.js +101 -101
- package/src/scheduler.js +42 -42
- package/src/step-gate.js +399 -399
- package/src/store.js +77 -77
package/src/step-gate.js
CHANGED
|
@@ -1,399 +1,399 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-session-guard — step 级门控(v0.2.0,`agent/pre-step` waterfall)。
|
|
3
|
-
*
|
|
4
|
-
* 为什么需要它:turn 级暂停(pause-gate.js)只在安全边界停下整个回合,
|
|
5
|
-
* 高峰切入时已经花掉当前 step 的 token。step 门控挂在 `agent/pre-step`,
|
|
6
|
-
* 在**下一个 step 的模型请求发生之前**把回合挂起,退峰/手动继续后原地续跑。
|
|
7
|
-
*
|
|
8
|
-
* 关键语义(见计划 findings F2/F3/F6/F7/F8):
|
|
9
|
-
* - `agent/pre-step` 是 waterfall:监听器 `await` 任意 Promise 后再 `return next()` 合法。
|
|
10
|
-
* **不要**返回 `{kind:'reject'}`——loop 把 reject 映射为 `turnEnds={kind:'blocked'}` 并结束回合。
|
|
11
|
-
* - abort:`await` 期间 signal abort 必须自己 resolve(否则 Promise 泄漏);resolve 后
|
|
12
|
-
* loop 的 `signal.throwIfAborted()` 会抛出并结束回合,因此 agent.cancel 天然解锁。
|
|
13
|
-
* - 超时(默认 5min):不是放行,而是**升级为 turn 级 force 暂停**(F8),
|
|
14
|
-
* 否则会在高峰形成「每 5 分钟一个 step」的 token drip。
|
|
15
|
-
* - 互斥:请求级 hold(deferrals)已挂起该会话时绝不拉门(F7)。
|
|
16
|
-
* - 手动「继续」置 bypass,本峰内不再拦该会话(否则下一个 pre-step 立刻再拉门)。
|
|
17
|
-
*
|
|
18
|
-
* 状态仅存内存:挂起的是 Promise,进程重启必然失效,落盘只会产生幽灵状态。
|
|
19
|
-
* 零 `@deepseek-ai/*` 值导入;所有判定异常 fail-open 放行。
|
|
20
|
-
*/
|
|
21
|
-
import { shouldPause } from './time.js'
|
|
22
|
-
|
|
23
|
-
/** 默认 step 门控超时(防死锁,与 PRD §2.2 的 5min 一致)。 */
|
|
24
|
-
export const DEFAULT_STEP_TIMEOUT_MS = 300_000
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* 纯判定:该 step 是否应拉门。
|
|
28
|
-
*
|
|
29
|
-
* 顺序(全部满足才拉门,任一步抛错由调用方 fail-open):
|
|
30
|
-
* 1. enabled 2. stepLevelPause 3. step > 1
|
|
31
|
-
* 4. 未被 bypass 5. root / guardSubagents
|
|
32
|
-
* 6. pauseVerdict.pause(含周末 / 峰谷) 7. shouldHoldSession(providerGuard 二维判定)
|
|
33
|
-
* 8. 未被请求级 hold 9. 该会话当前没有已挂起的门
|
|
34
|
-
*
|
|
35
|
-
* @param {object} input
|
|
36
|
-
* @param {object} input.cfg 实时设置
|
|
37
|
-
* @param {number} input.step 提议的 step 号(1 基)
|
|
38
|
-
* @param {boolean} [input.isRoot] 是否 root agent
|
|
39
|
-
* @param {{pause:boolean,reason:string}} input.pauseVerdict time.shouldPause 结果
|
|
40
|
-
* @param {(cfg:object,sessionId:string)=>boolean} [input.shouldHoldSession] 目标 provider 判定
|
|
41
|
-
* @param {boolean} [input.isHeldByDeferrals] 请求级守卫是否已挂起该会话
|
|
42
|
-
* @param {boolean} [input.bypassed] 本峰内是否被用户手动跳过
|
|
43
|
-
* @param {boolean} [input.alreadyHeld] 该会话是否已有挂起的门
|
|
44
|
-
* @param {boolean} [input.manual] 用户手动点了「暂停会话」(优先级最高,不受峰谷/step/provider 限制)
|
|
45
|
-
* @returns {{hold:boolean, why:string}}
|
|
46
|
-
*/
|
|
47
|
-
export function decideStepHold({
|
|
48
|
-
cfg,
|
|
49
|
-
step,
|
|
50
|
-
isRoot = true,
|
|
51
|
-
pauseVerdict,
|
|
52
|
-
shouldHoldSession,
|
|
53
|
-
isHeldByDeferrals = false,
|
|
54
|
-
bypassed = false,
|
|
55
|
-
alreadyHeld = false,
|
|
56
|
-
manual = false,
|
|
57
|
-
} = {}) {
|
|
58
|
-
// 手动暂停:用户显式意图,绕过 enabled / stepLevelPause / step>1 / 峰谷 / provider 判定,
|
|
59
|
-
// 仅保留互斥铁律(请求已 hold)与防双门。
|
|
60
|
-
if (manual === true) {
|
|
61
|
-
if (isHeldByDeferrals === true) return { hold: false, why: 'request-held' }
|
|
62
|
-
if (alreadyHeld === true) return { hold: false, why: 'already-held' }
|
|
63
|
-
return { hold: true, why: 'manual' }
|
|
64
|
-
}
|
|
65
|
-
if (!cfg || cfg.enabled !== true) return { hold: false, why: 'disabled' }
|
|
66
|
-
if (cfg.stepLevelPause !== true) return { hold: false, why: 'step-level-off' }
|
|
67
|
-
if (!(Number(step) > 1)) return { hold: false, why: 'first-step' }
|
|
68
|
-
if (bypassed === true) return { hold: false, why: 'bypassed' }
|
|
69
|
-
if (isRoot === false && cfg.guardSubagents === false) return { hold: false, why: 'subagent' }
|
|
70
|
-
if (!pauseVerdict || pauseVerdict.pause !== true) {
|
|
71
|
-
return { hold: false, why: (pauseVerdict && pauseVerdict.reason) || 'not-peak' }
|
|
72
|
-
}
|
|
73
|
-
if (typeof shouldHoldSession === 'function' && shouldHoldSession(cfg) !== true) {
|
|
74
|
-
return { hold: false, why: 'non-official' }
|
|
75
|
-
}
|
|
76
|
-
if (isHeldByDeferrals === true) return { hold: false, why: 'request-held' }
|
|
77
|
-
if (alreadyHeld === true) return { hold: false, why: 'already-held' }
|
|
78
|
-
return { hold: true, why: 'peak' }
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* step 门控引擎。
|
|
83
|
-
*
|
|
84
|
-
* @param {object} deps
|
|
85
|
-
* @param {()=>object} deps.getSettings 读实时设置
|
|
86
|
-
* @param {(cfg:object,sessionId:string)=>boolean} [deps.shouldHoldSession] 复用 wiring.shouldPauseSession
|
|
87
|
-
* @param {(sessionId:string)=>boolean} [deps.isHeldByDeferrals] 请求级 hold 查询
|
|
88
|
-
* @param {(agent:object)=>boolean} [deps.isRootAgent] root 判定(默认恒 true)
|
|
89
|
-
* @param {{pause:(id:string,opts:object)=>unknown}} [deps.pauseGate] 超时升级用的 turn 级门
|
|
90
|
-
* @param {object} [deps.logger]
|
|
91
|
-
* @param {()=>number} [deps.now]
|
|
92
|
-
* @param {typeof setTimeout} [deps.setTimer]
|
|
93
|
-
* @param {typeof clearTimeout} [deps.clearTimer]
|
|
94
|
-
* @param {boolean} [deps.unrefTimers]
|
|
95
|
-
* @param {(sessionId:string, state:object)=>void} [deps.onChange] 门控状态变化回调(SSE 推送用)
|
|
96
|
-
*/
|
|
97
|
-
export function createStepGate({
|
|
98
|
-
getSettings,
|
|
99
|
-
shouldHoldSession,
|
|
100
|
-
isHeldByDeferrals = () => false,
|
|
101
|
-
isRootAgent = () => true,
|
|
102
|
-
pauseGate,
|
|
103
|
-
logger,
|
|
104
|
-
now = () => Date.now(),
|
|
105
|
-
setTimer = setTimeout,
|
|
106
|
-
clearTimer = clearTimeout,
|
|
107
|
-
unrefTimers = true,
|
|
108
|
-
onChange,
|
|
109
|
-
} = {}) {
|
|
110
|
-
/** sessionId -> { sessionId, step, turn, since, resolve, timer, signal, onAbort } */
|
|
111
|
-
const holds = new Map()
|
|
112
|
-
/** 本峰内被用户手动跳过的会话 */
|
|
113
|
-
const bypass = new Set()
|
|
114
|
-
/** 用户手动点「暂停会话」但还没到 pre-step 边界的会话 */
|
|
115
|
-
const manualPause = new Set()
|
|
116
|
-
|
|
117
|
-
const warn = (m) => {
|
|
118
|
-
try {
|
|
119
|
-
logger?.warn?.(`[session-guard] ${m}`)
|
|
120
|
-
} catch {
|
|
121
|
-
/* 日志失败不影响门控 */
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
const info = (m) => {
|
|
125
|
-
try {
|
|
126
|
-
logger?.info?.(`[session-guard] ${m}`)
|
|
127
|
-
} catch {
|
|
128
|
-
/* ignore */
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function readCfg() {
|
|
133
|
-
try {
|
|
134
|
-
const v = getSettings()
|
|
135
|
-
return v && typeof v === 'object' ? v : {}
|
|
136
|
-
} catch {
|
|
137
|
-
return {}
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/** 通知状态变化(SSE 推送;异常不得影响门控)。 */
|
|
142
|
-
function emitChange(sessionId) {
|
|
143
|
-
if (typeof onChange !== 'function') return
|
|
144
|
-
try {
|
|
145
|
-
onChange(String(sessionId), state(sessionId))
|
|
146
|
-
} catch (e) {
|
|
147
|
-
warn(`step gate onChange failed: ${String(e && e.message || e)}`)
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** 摘掉一条挂起记录(不 resolve),幂等。 */
|
|
152
|
-
function detach(entry) {
|
|
153
|
-
if (!entry) return false
|
|
154
|
-
if (holds.get(entry.sessionId) === entry) holds.delete(entry.sessionId)
|
|
155
|
-
if (entry.timer !== null) {
|
|
156
|
-
try {
|
|
157
|
-
clearTimer(entry.timer)
|
|
158
|
-
} catch {
|
|
159
|
-
/* ignore */
|
|
160
|
-
}
|
|
161
|
-
entry.timer = null
|
|
162
|
-
}
|
|
163
|
-
if (entry.signal && entry.onAbort) {
|
|
164
|
-
try {
|
|
165
|
-
entry.signal.removeEventListener('abort', entry.onAbort)
|
|
166
|
-
} catch {
|
|
167
|
-
/* ignore */
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
entry.onAbort = null
|
|
171
|
-
return true
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* 释放一会话的挂起门。幂等:未挂起 → `{released:false}`。
|
|
176
|
-
* @param {string} sessionId
|
|
177
|
-
* @param {string} [reason] manual | off-peak | abort | timeout | pause | cancel | command | disposed
|
|
178
|
-
*/
|
|
179
|
-
function release(sessionId, reason = 'manual') {
|
|
180
|
-
const id = String(sessionId)
|
|
181
|
-
const entry = holds.get(id)
|
|
182
|
-
if (entry === undefined) {
|
|
183
|
-
// 没挂起,但可能有未落地的手动暂停请求 → 一并撤销
|
|
184
|
-
if (manualPause.delete(id)) emitChange(id)
|
|
185
|
-
return { released: false, reason }
|
|
186
|
-
}
|
|
187
|
-
detach(entry)
|
|
188
|
-
manualPause.delete(id)
|
|
189
|
-
info(`step gate released (${reason}) for ${id} (step ${entry.step})`)
|
|
190
|
-
try {
|
|
191
|
-
entry.resolve({ released: true, reason })
|
|
192
|
-
} catch (e) {
|
|
193
|
-
warn(`step gate resolve failed: ${String(e && e.message || e)}`)
|
|
194
|
-
}
|
|
195
|
-
emitChange(id)
|
|
196
|
-
return { released: true, reason }
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/** 释放全部挂起门(退峰 / 卸载)。返回被释放的会话 id 列表。 */
|
|
200
|
-
function releaseAll(reason = 'releaseAll') {
|
|
201
|
-
const ids = [...holds.keys()]
|
|
202
|
-
for (const id of ids) release(id, reason)
|
|
203
|
-
for (const id of [...manualPause]) {
|
|
204
|
-
manualPause.delete(id)
|
|
205
|
-
emitChange(id)
|
|
206
|
-
}
|
|
207
|
-
return ids
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
/**
|
|
211
|
-
* 用户手动请求暂停(「暂停会话」按钮 / `stepPause` RPC / 服务端口)。
|
|
212
|
-
* 不打断当前 step:在下一次 `agent/pre-step` 边界拉门(step 1 也拦)。
|
|
213
|
-
* 已挂起时幂等;同时撤销本峰 bypass(显式暂停优先)。
|
|
214
|
-
* @returns {{requested:boolean, held:boolean}}
|
|
215
|
-
*/
|
|
216
|
-
function requestPause(sessionId) {
|
|
217
|
-
const id = String(sessionId)
|
|
218
|
-
bypass.delete(id)
|
|
219
|
-
if (holds.has(id)) return { requested: true, held: true }
|
|
220
|
-
manualPause.add(id)
|
|
221
|
-
info(`step gate manual pause requested for ${id}`)
|
|
222
|
-
emitChange(id)
|
|
223
|
-
return { requested: true, held: false }
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/** 超时:释放 + 升级为 turn 级 force 暂停(F8)。 */
|
|
227
|
-
function escalate(sessionId) {
|
|
228
|
-
const id = String(sessionId)
|
|
229
|
-
release(id, 'timeout')
|
|
230
|
-
if (!pauseGate || typeof pauseGate.pause !== 'function') return
|
|
231
|
-
try {
|
|
232
|
-
const r = pauseGate.pause(id, { mode: 'force', reason: 'stop' })
|
|
233
|
-
if (r && typeof r.then === 'function') {
|
|
234
|
-
r.then(undefined, (e) => warn(`step gate escalation pause failed for ${id}: ${String(e && e.message || e)}`))
|
|
235
|
-
}
|
|
236
|
-
} catch (e) {
|
|
237
|
-
warn(`step gate escalation pause threw for ${id}: ${String(e && e.message || e)}`)
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/** 本峰内跳过该会话(用户手动继续)。 */
|
|
242
|
-
function markBypass(sessionId) {
|
|
243
|
-
const id = String(sessionId)
|
|
244
|
-
manualPause.delete(id)
|
|
245
|
-
bypass.add(id)
|
|
246
|
-
emitChange(id)
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
function clearBypass(sessionId) {
|
|
250
|
-
const id = String(sessionId)
|
|
251
|
-
if (bypass.delete(id)) emitChange(id)
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
function clearAllBypass() {
|
|
255
|
-
bypass.clear()
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/** 该会话的 step 门控状态。 */
|
|
259
|
-
function state(sessionId) {
|
|
260
|
-
const id = String(sessionId)
|
|
261
|
-
const entry = holds.get(id)
|
|
262
|
-
return {
|
|
263
|
-
held: entry !== undefined,
|
|
264
|
-
step: entry ? entry.step : null,
|
|
265
|
-
since: entry ? entry.since : null,
|
|
266
|
-
bypass: bypass.has(id),
|
|
267
|
-
/** 用户已请求暂停但还没到边界(按钮已应显示「继续会话」) */
|
|
268
|
-
manual: manualPause.has(id) || (entry !== undefined && entry.manual === true),
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
function heldIds() {
|
|
273
|
-
return [...holds.keys()]
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
/**
|
|
277
|
-
* `agent/pre-step` waterfall 主体:判定 → 拉门 → 释放后放行本 step。
|
|
278
|
-
* @param {{agent?:object, step?:number, turn?:number, signal?:AbortSignal, messages?:unknown[]}} payload
|
|
279
|
-
* @param {()=>Promise<unknown>} next
|
|
280
|
-
*/
|
|
281
|
-
async function hold(payload, next) {
|
|
282
|
-
const pass = () => (typeof next === 'function' ? next() : Promise.resolve({ kind: 'enter', messages: [] }))
|
|
283
|
-
try {
|
|
284
|
-
const agent = payload && payload.agent
|
|
285
|
-
const id = String((agent && agent.id) ?? '')
|
|
286
|
-
if (id === '') return pass()
|
|
287
|
-
|
|
288
|
-
const cfg = readCfg()
|
|
289
|
-
let verdict
|
|
290
|
-
try {
|
|
291
|
-
verdict = shouldPause(cfg, new Date(now()))
|
|
292
|
-
} catch (e) {
|
|
293
|
-
warn(`step gate peak verdict failed for ${id}: ${String(e && e.message || e)}`)
|
|
294
|
-
verdict = { pause: false, reason: 'fail-open' }
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
let targetOk = true
|
|
298
|
-
if (typeof shouldHoldSession === 'function') {
|
|
299
|
-
try {
|
|
300
|
-
targetOk = shouldHoldSession(cfg, id) === true
|
|
301
|
-
} catch (e) {
|
|
302
|
-
warn(`step gate target verdict failed for ${id}: ${String(e && e.message || e)}`)
|
|
303
|
-
targetOk = false // fail-open 放行
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
let held = false
|
|
308
|
-
try {
|
|
309
|
-
held = isHeldByDeferrals(id) === true
|
|
310
|
-
} catch {
|
|
311
|
-
held = false
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
let rootFlag = true
|
|
315
|
-
try {
|
|
316
|
-
rootFlag = isRootAgent(agent) !== false
|
|
317
|
-
} catch {
|
|
318
|
-
rootFlag = true
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
const manual = manualPause.has(id)
|
|
322
|
-
const decision = decideStepHold({
|
|
323
|
-
cfg,
|
|
324
|
-
step: payload && payload.step,
|
|
325
|
-
isRoot: rootFlag,
|
|
326
|
-
pauseVerdict: verdict,
|
|
327
|
-
shouldHoldSession: () => targetOk,
|
|
328
|
-
isHeldByDeferrals: held,
|
|
329
|
-
bypassed: bypass.has(id),
|
|
330
|
-
alreadyHeld: holds.has(id),
|
|
331
|
-
manual,
|
|
332
|
-
})
|
|
333
|
-
if (!decision.hold) return pass()
|
|
334
|
-
|
|
335
|
-
const entry = {
|
|
336
|
-
sessionId: id,
|
|
337
|
-
step: Number(payload && payload.step),
|
|
338
|
-
turn: Number(payload && payload.turn),
|
|
339
|
-
since: now(),
|
|
340
|
-
resolve: null,
|
|
341
|
-
timer: null,
|
|
342
|
-
signal: (payload && payload.signal) || null,
|
|
343
|
-
onAbort: null,
|
|
344
|
-
manual,
|
|
345
|
-
}
|
|
346
|
-
const promise = new Promise((resolve) => {
|
|
347
|
-
entry.resolve = resolve
|
|
348
|
-
})
|
|
349
|
-
holds.set(id, entry)
|
|
350
|
-
emitChange(id)
|
|
351
|
-
|
|
352
|
-
// abort(agent.cancel / 冻结 / /pause / /cancel)→ 释放,让 loop 的 throwIfAborted 收尾
|
|
353
|
-
if (entry.signal && typeof entry.signal.addEventListener === 'function') {
|
|
354
|
-
entry.onAbort = () => release(id, 'abort')
|
|
355
|
-
try {
|
|
356
|
-
entry.signal.addEventListener('abort', entry.onAbort, { once: true })
|
|
357
|
-
} catch {
|
|
358
|
-
entry.onAbort = null
|
|
359
|
-
}
|
|
360
|
-
if (entry.signal.aborted === true) release(id, 'abort')
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
const rawTimeout = Number(cfg.stepGateTimeoutMs)
|
|
364
|
-
const timeoutMs = Number.isFinite(rawTimeout) && rawTimeout > 0 ? rawTimeout : DEFAULT_STEP_TIMEOUT_MS
|
|
365
|
-
entry.timer = setTimer(() => {
|
|
366
|
-
entry.timer = null
|
|
367
|
-
escalate(id)
|
|
368
|
-
}, timeoutMs)
|
|
369
|
-
if (unrefTimers && entry.timer && typeof entry.timer.unref === 'function') entry.timer.unref()
|
|
370
|
-
|
|
371
|
-
info(`step gate held for ${id} at step ${entry.step} (timeout ${timeoutMs}ms)`)
|
|
372
|
-
await promise
|
|
373
|
-
return pass()
|
|
374
|
-
} catch (e) {
|
|
375
|
-
// 门控自身异常 → fail-open 放行,绝不阻断回合
|
|
376
|
-
warn(`step gate failed open: ${String(e && e.message || e)}`)
|
|
377
|
-
return pass()
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
return {
|
|
382
|
-
hold,
|
|
383
|
-
release,
|
|
384
|
-
releaseAll,
|
|
385
|
-
requestPause,
|
|
386
|
-
escalate,
|
|
387
|
-
markBypass,
|
|
388
|
-
clearBypass,
|
|
389
|
-
clearAllBypass,
|
|
390
|
-
state,
|
|
391
|
-
heldIds,
|
|
392
|
-
/** 当前挂起门数(测试 / diag 用)。 */
|
|
393
|
-
_count: () => holds.size,
|
|
394
|
-
/** 本峰 bypass 集合(测试 / diag 用)。 */
|
|
395
|
-
_bypassed: () => [...bypass],
|
|
396
|
-
/** 待落地的手动暂停请求(测试 / diag 用)。 */
|
|
397
|
-
_manualPending: () => [...manualPause],
|
|
398
|
-
}
|
|
399
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* dsh-session-guard — step 级门控(v0.2.0,`agent/pre-step` waterfall)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要它:turn 级暂停(pause-gate.js)只在安全边界停下整个回合,
|
|
5
|
+
* 高峰切入时已经花掉当前 step 的 token。step 门控挂在 `agent/pre-step`,
|
|
6
|
+
* 在**下一个 step 的模型请求发生之前**把回合挂起,退峰/手动继续后原地续跑。
|
|
7
|
+
*
|
|
8
|
+
* 关键语义(见计划 findings F2/F3/F6/F7/F8):
|
|
9
|
+
* - `agent/pre-step` 是 waterfall:监听器 `await` 任意 Promise 后再 `return next()` 合法。
|
|
10
|
+
* **不要**返回 `{kind:'reject'}`——loop 把 reject 映射为 `turnEnds={kind:'blocked'}` 并结束回合。
|
|
11
|
+
* - abort:`await` 期间 signal abort 必须自己 resolve(否则 Promise 泄漏);resolve 后
|
|
12
|
+
* loop 的 `signal.throwIfAborted()` 会抛出并结束回合,因此 agent.cancel 天然解锁。
|
|
13
|
+
* - 超时(默认 5min):不是放行,而是**升级为 turn 级 force 暂停**(F8),
|
|
14
|
+
* 否则会在高峰形成「每 5 分钟一个 step」的 token drip。
|
|
15
|
+
* - 互斥:请求级 hold(deferrals)已挂起该会话时绝不拉门(F7)。
|
|
16
|
+
* - 手动「继续」置 bypass,本峰内不再拦该会话(否则下一个 pre-step 立刻再拉门)。
|
|
17
|
+
*
|
|
18
|
+
* 状态仅存内存:挂起的是 Promise,进程重启必然失效,落盘只会产生幽灵状态。
|
|
19
|
+
* 零 `@deepseek-ai/*` 值导入;所有判定异常 fail-open 放行。
|
|
20
|
+
*/
|
|
21
|
+
import { shouldPause } from './time.js'
|
|
22
|
+
|
|
23
|
+
/** 默认 step 门控超时(防死锁,与 PRD §2.2 的 5min 一致)。 */
|
|
24
|
+
export const DEFAULT_STEP_TIMEOUT_MS = 300_000
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 纯判定:该 step 是否应拉门。
|
|
28
|
+
*
|
|
29
|
+
* 顺序(全部满足才拉门,任一步抛错由调用方 fail-open):
|
|
30
|
+
* 1. enabled 2. stepLevelPause 3. step > 1
|
|
31
|
+
* 4. 未被 bypass 5. root / guardSubagents
|
|
32
|
+
* 6. pauseVerdict.pause(含周末 / 峰谷) 7. shouldHoldSession(providerGuard 二维判定)
|
|
33
|
+
* 8. 未被请求级 hold 9. 该会话当前没有已挂起的门
|
|
34
|
+
*
|
|
35
|
+
* @param {object} input
|
|
36
|
+
* @param {object} input.cfg 实时设置
|
|
37
|
+
* @param {number} input.step 提议的 step 号(1 基)
|
|
38
|
+
* @param {boolean} [input.isRoot] 是否 root agent
|
|
39
|
+
* @param {{pause:boolean,reason:string}} input.pauseVerdict time.shouldPause 结果
|
|
40
|
+
* @param {(cfg:object,sessionId:string)=>boolean} [input.shouldHoldSession] 目标 provider 判定
|
|
41
|
+
* @param {boolean} [input.isHeldByDeferrals] 请求级守卫是否已挂起该会话
|
|
42
|
+
* @param {boolean} [input.bypassed] 本峰内是否被用户手动跳过
|
|
43
|
+
* @param {boolean} [input.alreadyHeld] 该会话是否已有挂起的门
|
|
44
|
+
* @param {boolean} [input.manual] 用户手动点了「暂停会话」(优先级最高,不受峰谷/step/provider 限制)
|
|
45
|
+
* @returns {{hold:boolean, why:string}}
|
|
46
|
+
*/
|
|
47
|
+
export function decideStepHold({
|
|
48
|
+
cfg,
|
|
49
|
+
step,
|
|
50
|
+
isRoot = true,
|
|
51
|
+
pauseVerdict,
|
|
52
|
+
shouldHoldSession,
|
|
53
|
+
isHeldByDeferrals = false,
|
|
54
|
+
bypassed = false,
|
|
55
|
+
alreadyHeld = false,
|
|
56
|
+
manual = false,
|
|
57
|
+
} = {}) {
|
|
58
|
+
// 手动暂停:用户显式意图,绕过 enabled / stepLevelPause / step>1 / 峰谷 / provider 判定,
|
|
59
|
+
// 仅保留互斥铁律(请求已 hold)与防双门。
|
|
60
|
+
if (manual === true) {
|
|
61
|
+
if (isHeldByDeferrals === true) return { hold: false, why: 'request-held' }
|
|
62
|
+
if (alreadyHeld === true) return { hold: false, why: 'already-held' }
|
|
63
|
+
return { hold: true, why: 'manual' }
|
|
64
|
+
}
|
|
65
|
+
if (!cfg || cfg.enabled !== true) return { hold: false, why: 'disabled' }
|
|
66
|
+
if (cfg.stepLevelPause !== true) return { hold: false, why: 'step-level-off' }
|
|
67
|
+
if (!(Number(step) > 1)) return { hold: false, why: 'first-step' }
|
|
68
|
+
if (bypassed === true) return { hold: false, why: 'bypassed' }
|
|
69
|
+
if (isRoot === false && cfg.guardSubagents === false) return { hold: false, why: 'subagent' }
|
|
70
|
+
if (!pauseVerdict || pauseVerdict.pause !== true) {
|
|
71
|
+
return { hold: false, why: (pauseVerdict && pauseVerdict.reason) || 'not-peak' }
|
|
72
|
+
}
|
|
73
|
+
if (typeof shouldHoldSession === 'function' && shouldHoldSession(cfg) !== true) {
|
|
74
|
+
return { hold: false, why: 'non-official' }
|
|
75
|
+
}
|
|
76
|
+
if (isHeldByDeferrals === true) return { hold: false, why: 'request-held' }
|
|
77
|
+
if (alreadyHeld === true) return { hold: false, why: 'already-held' }
|
|
78
|
+
return { hold: true, why: 'peak' }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* step 门控引擎。
|
|
83
|
+
*
|
|
84
|
+
* @param {object} deps
|
|
85
|
+
* @param {()=>object} deps.getSettings 读实时设置
|
|
86
|
+
* @param {(cfg:object,sessionId:string)=>boolean} [deps.shouldHoldSession] 复用 wiring.shouldPauseSession
|
|
87
|
+
* @param {(sessionId:string)=>boolean} [deps.isHeldByDeferrals] 请求级 hold 查询
|
|
88
|
+
* @param {(agent:object)=>boolean} [deps.isRootAgent] root 判定(默认恒 true)
|
|
89
|
+
* @param {{pause:(id:string,opts:object)=>unknown}} [deps.pauseGate] 超时升级用的 turn 级门
|
|
90
|
+
* @param {object} [deps.logger]
|
|
91
|
+
* @param {()=>number} [deps.now]
|
|
92
|
+
* @param {typeof setTimeout} [deps.setTimer]
|
|
93
|
+
* @param {typeof clearTimeout} [deps.clearTimer]
|
|
94
|
+
* @param {boolean} [deps.unrefTimers]
|
|
95
|
+
* @param {(sessionId:string, state:object)=>void} [deps.onChange] 门控状态变化回调(SSE 推送用)
|
|
96
|
+
*/
|
|
97
|
+
export function createStepGate({
|
|
98
|
+
getSettings,
|
|
99
|
+
shouldHoldSession,
|
|
100
|
+
isHeldByDeferrals = () => false,
|
|
101
|
+
isRootAgent = () => true,
|
|
102
|
+
pauseGate,
|
|
103
|
+
logger,
|
|
104
|
+
now = () => Date.now(),
|
|
105
|
+
setTimer = setTimeout,
|
|
106
|
+
clearTimer = clearTimeout,
|
|
107
|
+
unrefTimers = true,
|
|
108
|
+
onChange,
|
|
109
|
+
} = {}) {
|
|
110
|
+
/** sessionId -> { sessionId, step, turn, since, resolve, timer, signal, onAbort } */
|
|
111
|
+
const holds = new Map()
|
|
112
|
+
/** 本峰内被用户手动跳过的会话 */
|
|
113
|
+
const bypass = new Set()
|
|
114
|
+
/** 用户手动点「暂停会话」但还没到 pre-step 边界的会话 */
|
|
115
|
+
const manualPause = new Set()
|
|
116
|
+
|
|
117
|
+
const warn = (m) => {
|
|
118
|
+
try {
|
|
119
|
+
logger?.warn?.(`[session-guard] ${m}`)
|
|
120
|
+
} catch {
|
|
121
|
+
/* 日志失败不影响门控 */
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const info = (m) => {
|
|
125
|
+
try {
|
|
126
|
+
logger?.info?.(`[session-guard] ${m}`)
|
|
127
|
+
} catch {
|
|
128
|
+
/* ignore */
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function readCfg() {
|
|
133
|
+
try {
|
|
134
|
+
const v = getSettings()
|
|
135
|
+
return v && typeof v === 'object' ? v : {}
|
|
136
|
+
} catch {
|
|
137
|
+
return {}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** 通知状态变化(SSE 推送;异常不得影响门控)。 */
|
|
142
|
+
function emitChange(sessionId) {
|
|
143
|
+
if (typeof onChange !== 'function') return
|
|
144
|
+
try {
|
|
145
|
+
onChange(String(sessionId), state(sessionId))
|
|
146
|
+
} catch (e) {
|
|
147
|
+
warn(`step gate onChange failed: ${String(e && e.message || e)}`)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 摘掉一条挂起记录(不 resolve),幂等。 */
|
|
152
|
+
function detach(entry) {
|
|
153
|
+
if (!entry) return false
|
|
154
|
+
if (holds.get(entry.sessionId) === entry) holds.delete(entry.sessionId)
|
|
155
|
+
if (entry.timer !== null) {
|
|
156
|
+
try {
|
|
157
|
+
clearTimer(entry.timer)
|
|
158
|
+
} catch {
|
|
159
|
+
/* ignore */
|
|
160
|
+
}
|
|
161
|
+
entry.timer = null
|
|
162
|
+
}
|
|
163
|
+
if (entry.signal && entry.onAbort) {
|
|
164
|
+
try {
|
|
165
|
+
entry.signal.removeEventListener('abort', entry.onAbort)
|
|
166
|
+
} catch {
|
|
167
|
+
/* ignore */
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
entry.onAbort = null
|
|
171
|
+
return true
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 释放一会话的挂起门。幂等:未挂起 → `{released:false}`。
|
|
176
|
+
* @param {string} sessionId
|
|
177
|
+
* @param {string} [reason] manual | off-peak | abort | timeout | pause | cancel | command | disposed
|
|
178
|
+
*/
|
|
179
|
+
function release(sessionId, reason = 'manual') {
|
|
180
|
+
const id = String(sessionId)
|
|
181
|
+
const entry = holds.get(id)
|
|
182
|
+
if (entry === undefined) {
|
|
183
|
+
// 没挂起,但可能有未落地的手动暂停请求 → 一并撤销
|
|
184
|
+
if (manualPause.delete(id)) emitChange(id)
|
|
185
|
+
return { released: false, reason }
|
|
186
|
+
}
|
|
187
|
+
detach(entry)
|
|
188
|
+
manualPause.delete(id)
|
|
189
|
+
info(`step gate released (${reason}) for ${id} (step ${entry.step})`)
|
|
190
|
+
try {
|
|
191
|
+
entry.resolve({ released: true, reason })
|
|
192
|
+
} catch (e) {
|
|
193
|
+
warn(`step gate resolve failed: ${String(e && e.message || e)}`)
|
|
194
|
+
}
|
|
195
|
+
emitChange(id)
|
|
196
|
+
return { released: true, reason }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** 释放全部挂起门(退峰 / 卸载)。返回被释放的会话 id 列表。 */
|
|
200
|
+
function releaseAll(reason = 'releaseAll') {
|
|
201
|
+
const ids = [...holds.keys()]
|
|
202
|
+
for (const id of ids) release(id, reason)
|
|
203
|
+
for (const id of [...manualPause]) {
|
|
204
|
+
manualPause.delete(id)
|
|
205
|
+
emitChange(id)
|
|
206
|
+
}
|
|
207
|
+
return ids
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* 用户手动请求暂停(「暂停会话」按钮 / `stepPause` RPC / 服务端口)。
|
|
212
|
+
* 不打断当前 step:在下一次 `agent/pre-step` 边界拉门(step 1 也拦)。
|
|
213
|
+
* 已挂起时幂等;同时撤销本峰 bypass(显式暂停优先)。
|
|
214
|
+
* @returns {{requested:boolean, held:boolean}}
|
|
215
|
+
*/
|
|
216
|
+
function requestPause(sessionId) {
|
|
217
|
+
const id = String(sessionId)
|
|
218
|
+
bypass.delete(id)
|
|
219
|
+
if (holds.has(id)) return { requested: true, held: true }
|
|
220
|
+
manualPause.add(id)
|
|
221
|
+
info(`step gate manual pause requested for ${id}`)
|
|
222
|
+
emitChange(id)
|
|
223
|
+
return { requested: true, held: false }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** 超时:释放 + 升级为 turn 级 force 暂停(F8)。 */
|
|
227
|
+
function escalate(sessionId) {
|
|
228
|
+
const id = String(sessionId)
|
|
229
|
+
release(id, 'timeout')
|
|
230
|
+
if (!pauseGate || typeof pauseGate.pause !== 'function') return
|
|
231
|
+
try {
|
|
232
|
+
const r = pauseGate.pause(id, { mode: 'force', reason: 'stop' })
|
|
233
|
+
if (r && typeof r.then === 'function') {
|
|
234
|
+
r.then(undefined, (e) => warn(`step gate escalation pause failed for ${id}: ${String(e && e.message || e)}`))
|
|
235
|
+
}
|
|
236
|
+
} catch (e) {
|
|
237
|
+
warn(`step gate escalation pause threw for ${id}: ${String(e && e.message || e)}`)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** 本峰内跳过该会话(用户手动继续)。 */
|
|
242
|
+
function markBypass(sessionId) {
|
|
243
|
+
const id = String(sessionId)
|
|
244
|
+
manualPause.delete(id)
|
|
245
|
+
bypass.add(id)
|
|
246
|
+
emitChange(id)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function clearBypass(sessionId) {
|
|
250
|
+
const id = String(sessionId)
|
|
251
|
+
if (bypass.delete(id)) emitChange(id)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function clearAllBypass() {
|
|
255
|
+
bypass.clear()
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** 该会话的 step 门控状态。 */
|
|
259
|
+
function state(sessionId) {
|
|
260
|
+
const id = String(sessionId)
|
|
261
|
+
const entry = holds.get(id)
|
|
262
|
+
return {
|
|
263
|
+
held: entry !== undefined,
|
|
264
|
+
step: entry ? entry.step : null,
|
|
265
|
+
since: entry ? entry.since : null,
|
|
266
|
+
bypass: bypass.has(id),
|
|
267
|
+
/** 用户已请求暂停但还没到边界(按钮已应显示「继续会话」) */
|
|
268
|
+
manual: manualPause.has(id) || (entry !== undefined && entry.manual === true),
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function heldIds() {
|
|
273
|
+
return [...holds.keys()]
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* `agent/pre-step` waterfall 主体:判定 → 拉门 → 释放后放行本 step。
|
|
278
|
+
* @param {{agent?:object, step?:number, turn?:number, signal?:AbortSignal, messages?:unknown[]}} payload
|
|
279
|
+
* @param {()=>Promise<unknown>} next
|
|
280
|
+
*/
|
|
281
|
+
async function hold(payload, next) {
|
|
282
|
+
const pass = () => (typeof next === 'function' ? next() : Promise.resolve({ kind: 'enter', messages: [] }))
|
|
283
|
+
try {
|
|
284
|
+
const agent = payload && payload.agent
|
|
285
|
+
const id = String((agent && agent.id) ?? '')
|
|
286
|
+
if (id === '') return pass()
|
|
287
|
+
|
|
288
|
+
const cfg = readCfg()
|
|
289
|
+
let verdict
|
|
290
|
+
try {
|
|
291
|
+
verdict = shouldPause(cfg, new Date(now()))
|
|
292
|
+
} catch (e) {
|
|
293
|
+
warn(`step gate peak verdict failed for ${id}: ${String(e && e.message || e)}`)
|
|
294
|
+
verdict = { pause: false, reason: 'fail-open' }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
let targetOk = true
|
|
298
|
+
if (typeof shouldHoldSession === 'function') {
|
|
299
|
+
try {
|
|
300
|
+
targetOk = shouldHoldSession(cfg, id) === true
|
|
301
|
+
} catch (e) {
|
|
302
|
+
warn(`step gate target verdict failed for ${id}: ${String(e && e.message || e)}`)
|
|
303
|
+
targetOk = false // fail-open 放行
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
let held = false
|
|
308
|
+
try {
|
|
309
|
+
held = isHeldByDeferrals(id) === true
|
|
310
|
+
} catch {
|
|
311
|
+
held = false
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
let rootFlag = true
|
|
315
|
+
try {
|
|
316
|
+
rootFlag = isRootAgent(agent) !== false
|
|
317
|
+
} catch {
|
|
318
|
+
rootFlag = true
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const manual = manualPause.has(id)
|
|
322
|
+
const decision = decideStepHold({
|
|
323
|
+
cfg,
|
|
324
|
+
step: payload && payload.step,
|
|
325
|
+
isRoot: rootFlag,
|
|
326
|
+
pauseVerdict: verdict,
|
|
327
|
+
shouldHoldSession: () => targetOk,
|
|
328
|
+
isHeldByDeferrals: held,
|
|
329
|
+
bypassed: bypass.has(id),
|
|
330
|
+
alreadyHeld: holds.has(id),
|
|
331
|
+
manual,
|
|
332
|
+
})
|
|
333
|
+
if (!decision.hold) return pass()
|
|
334
|
+
|
|
335
|
+
const entry = {
|
|
336
|
+
sessionId: id,
|
|
337
|
+
step: Number(payload && payload.step),
|
|
338
|
+
turn: Number(payload && payload.turn),
|
|
339
|
+
since: now(),
|
|
340
|
+
resolve: null,
|
|
341
|
+
timer: null,
|
|
342
|
+
signal: (payload && payload.signal) || null,
|
|
343
|
+
onAbort: null,
|
|
344
|
+
manual,
|
|
345
|
+
}
|
|
346
|
+
const promise = new Promise((resolve) => {
|
|
347
|
+
entry.resolve = resolve
|
|
348
|
+
})
|
|
349
|
+
holds.set(id, entry)
|
|
350
|
+
emitChange(id)
|
|
351
|
+
|
|
352
|
+
// abort(agent.cancel / 冻结 / /pause / /cancel)→ 释放,让 loop 的 throwIfAborted 收尾
|
|
353
|
+
if (entry.signal && typeof entry.signal.addEventListener === 'function') {
|
|
354
|
+
entry.onAbort = () => release(id, 'abort')
|
|
355
|
+
try {
|
|
356
|
+
entry.signal.addEventListener('abort', entry.onAbort, { once: true })
|
|
357
|
+
} catch {
|
|
358
|
+
entry.onAbort = null
|
|
359
|
+
}
|
|
360
|
+
if (entry.signal.aborted === true) release(id, 'abort')
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const rawTimeout = Number(cfg.stepGateTimeoutMs)
|
|
364
|
+
const timeoutMs = Number.isFinite(rawTimeout) && rawTimeout > 0 ? rawTimeout : DEFAULT_STEP_TIMEOUT_MS
|
|
365
|
+
entry.timer = setTimer(() => {
|
|
366
|
+
entry.timer = null
|
|
367
|
+
escalate(id)
|
|
368
|
+
}, timeoutMs)
|
|
369
|
+
if (unrefTimers && entry.timer && typeof entry.timer.unref === 'function') entry.timer.unref()
|
|
370
|
+
|
|
371
|
+
info(`step gate held for ${id} at step ${entry.step} (timeout ${timeoutMs}ms)`)
|
|
372
|
+
await promise
|
|
373
|
+
return pass()
|
|
374
|
+
} catch (e) {
|
|
375
|
+
// 门控自身异常 → fail-open 放行,绝不阻断回合
|
|
376
|
+
warn(`step gate failed open: ${String(e && e.message || e)}`)
|
|
377
|
+
return pass()
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return {
|
|
382
|
+
hold,
|
|
383
|
+
release,
|
|
384
|
+
releaseAll,
|
|
385
|
+
requestPause,
|
|
386
|
+
escalate,
|
|
387
|
+
markBypass,
|
|
388
|
+
clearBypass,
|
|
389
|
+
clearAllBypass,
|
|
390
|
+
state,
|
|
391
|
+
heldIds,
|
|
392
|
+
/** 当前挂起门数(测试 / diag 用)。 */
|
|
393
|
+
_count: () => holds.size,
|
|
394
|
+
/** 本峰 bypass 集合(测试 / diag 用)。 */
|
|
395
|
+
_bypassed: () => [...bypass],
|
|
396
|
+
/** 待落地的手动暂停请求(测试 / diag 用)。 */
|
|
397
|
+
_manualPending: () => [...manualPause],
|
|
398
|
+
}
|
|
399
|
+
}
|