dsh-session-guard 0.1.5-beta.1 → 0.3.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 +23 -0
- package/CHANGELOG.ko.md +23 -0
- package/CHANGELOG.md +52 -0
- package/README.en.md +75 -13
- package/README.ja.md +34 -6
- package/README.ko.md +34 -6
- package/README.md +78 -16
- package/dsh.plugin.json +2 -2
- package/lib/client.js +656 -462
- package/lib/client.js.map +1 -1
- package/package.json +6 -1
- package/src/bridge.js +61 -1
- package/src/client/badge-text.ts +6 -1
- package/src/client/index.ts +11 -1
- package/src/client/pause-button-text.ts +60 -0
- package/src/client/pause-button.tsx +129 -0
- package/src/client/settings-card.tsx +21 -0
- package/src/client/status-badge.tsx +4 -0
- package/src/client/styles.ts +30 -0
- package/src/index.js +106 -4
- package/src/pause-gate.js +22 -1
- package/src/settings.js +4 -0
- package/src/step-gate.js +399 -0
- package/src/wiring.js +46 -4
package/src/index.js
CHANGED
|
@@ -19,7 +19,9 @@ import { createBridge } from './bridge.js'
|
|
|
19
19
|
import { createRetry } from './retry.js'
|
|
20
20
|
import { createPauseStore } from './pause-store.js'
|
|
21
21
|
import { createPauseGate } from './pause-gate.js'
|
|
22
|
+
import { createStepGate } from './step-gate.js'
|
|
22
23
|
import { detectTaskControl } from './detect.js'
|
|
24
|
+
import { makeIsRoot } from './request-guard.js'
|
|
23
25
|
import { createWiring } from './wiring.js'
|
|
24
26
|
import { NS, DEFAULT_SETTINGS, SettingsSchema, registerSettings } from './settings.js'
|
|
25
27
|
|
|
@@ -42,17 +44,52 @@ export function apply(ctx) {
|
|
|
42
44
|
}
|
|
43
45
|
}
|
|
44
46
|
|
|
47
|
+
// ── SSE 推送(v0.2.0):step 门状态变化时立刻推给对应会话页面 ──
|
|
48
|
+
// 客户端「暂停会话 / 继续会话」按钮据此更新,无需等轮询。
|
|
49
|
+
const sseClients = new Set()
|
|
50
|
+
|
|
51
|
+
function sseSend(client, payload) {
|
|
52
|
+
try {
|
|
53
|
+
client.res.write(`data: ${JSON.stringify(payload)}\n\n`)
|
|
54
|
+
} catch {
|
|
55
|
+
/* 连接已断开:交给 close 事件清理 */
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function broadcastStep(sessionId, stepState) {
|
|
60
|
+
const id = String(sessionId)
|
|
61
|
+
for (const client of [...sseClients]) {
|
|
62
|
+
if (client.sessionId !== '' && client.sessionId !== id) continue
|
|
63
|
+
sseSend(client, { type: 'step', sessionId: id, state: stepState })
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
45
67
|
// ── 自研会话门(脱离 dsh-task-control,真暂停)──
|
|
46
68
|
// 状态持久化 + 引擎;经 gate.stopNextTurn/resume 主路径接入;/pause /resume /cancel 命令。
|
|
69
|
+
// v0.2.0 追加 step 级门控(src/step-gate.js):stepGate 与 pauseGate/wiring 互相需要,
|
|
70
|
+
// 用 late-bound 引用装配,避免构造期循环依赖。
|
|
47
71
|
const pauseStore = createPauseStore()
|
|
48
|
-
|
|
72
|
+
let pauseGateRef = null
|
|
73
|
+
let wiringRef = null
|
|
74
|
+
const stepGate = createStepGate({
|
|
75
|
+
getSettings: readCfg,
|
|
76
|
+
shouldHoldSession: (cfg, id) => (wiringRef ? wiringRef.shouldPauseSession(cfg, id) : true),
|
|
77
|
+
isHeldByDeferrals: (id) => (wiringRef ? wiringRef.deferrals.has(id) : false),
|
|
78
|
+
isRootAgent: makeIsRoot(ctx),
|
|
79
|
+
pauseGate: { pause: (id, opts) => pauseGateRef.pause(id, opts) },
|
|
80
|
+
logger: ctx.logger,
|
|
81
|
+
onChange: (id, snapshot) => broadcastStep(id, snapshot),
|
|
82
|
+
})
|
|
83
|
+
const pauseGate = createPauseGate({ ctx, pauseStore, stepGate })
|
|
84
|
+
pauseGateRef = pauseGate
|
|
49
85
|
const gate = createGate({ getCtx: () => ctx, getSettings: readCfg, store, pauseGate })
|
|
50
|
-
const bridge = createBridge(ctx, gate, store, pauseGate)
|
|
86
|
+
const bridge = createBridge(ctx, gate, store, pauseGate, stepGate)
|
|
51
87
|
|
|
52
88
|
// ── 官方 provider 二维判定接线(目标追踪 / 请求级守卫 / 入峰过滤 / 退峰释放)──
|
|
53
89
|
// 纯逻辑见 provider.js / provider-directory.js / deferrals.js / request-guard.js / targets.js,
|
|
54
90
|
// 接线与编排见 wiring.js;这里只负责装配与生命周期。
|
|
55
|
-
const wiring = createWiring({ ctx, getSettings: readCfg, gate, logger: ctx.logger })
|
|
91
|
+
const wiring = createWiring({ ctx, getSettings: readCfg, gate, stepGate, logger: ctx.logger })
|
|
92
|
+
wiringRef = wiring
|
|
56
93
|
|
|
57
94
|
// ── 冗余端口:input-traffic 冻结按钮透传接入(D5/D6/D8)──
|
|
58
95
|
ctx.provide('sessionGuard', bridge)
|
|
@@ -74,6 +111,11 @@ export function apply(ctx) {
|
|
|
74
111
|
// ── 请求级守卫(agent/request waterfall)──
|
|
75
112
|
// 覆盖缺口:tick 只在状态跳变时处理 running 会话,入峰后新启动 / 中途切官方都会漏。
|
|
76
113
|
ctx.effect(() => wiring.installGuard(), 'session-guard: request guard')
|
|
114
|
+
|
|
115
|
+
// ── step 级门控(agent/pre-step waterfall,v0.2.0)──
|
|
116
|
+
// 高峰 + 非周末 + 目标官方 → 在下一个 step 的模型请求前拉门;退峰原地续跑。
|
|
117
|
+
ctx.effect(() => wiring.installStepGuard(), 'session-guard: step guard')
|
|
118
|
+
|
|
77
119
|
ctx.effect(() => () => wiring.dispose(), 'session-guard: wiring dispose')
|
|
78
120
|
|
|
79
121
|
// ── 手动会话门命令(/pause /resume /cancel,全量移植)──
|
|
@@ -102,6 +144,8 @@ export function apply(ctx) {
|
|
|
102
144
|
const sid = String(invocation?.agent?.id ?? '')
|
|
103
145
|
if (!sid) return { kind: 'error', text: 'no session for this command' }
|
|
104
146
|
const tokens = tokensOf(invocation.rawInput)
|
|
147
|
+
// v0.2.0:step 门挂起时,「恢复」= 放行该 step 且本峰内不再拦(用户显式继续)。
|
|
148
|
+
bridge.stepResume(sid, { bypass: true, reason: 'command' })
|
|
105
149
|
return pauseGate.resume(sid, {
|
|
106
150
|
confirm: tokens.includes('confirm'),
|
|
107
151
|
choice: tokens.includes('skip') ? 'skip' : 'rerun',
|
|
@@ -114,6 +158,7 @@ export function apply(ctx) {
|
|
|
114
158
|
handler: (invocation) => {
|
|
115
159
|
const sid = String(invocation?.agent?.id ?? '')
|
|
116
160
|
if (!sid) return { kind: 'error', text: 'no session for this command' }
|
|
161
|
+
bridge.stepResume(sid, { bypass: false, reason: 'command' })
|
|
117
162
|
return pauseGate.cancel(sid)
|
|
118
163
|
},
|
|
119
164
|
}))
|
|
@@ -127,6 +172,7 @@ export function apply(ctx) {
|
|
|
127
172
|
const st = bridge.state(sessionId)
|
|
128
173
|
if (st.queueLocked) return true
|
|
129
174
|
if (st.paused) return true // 自研会话门真暂停
|
|
175
|
+
if (st.pausedStep) return true // v0.2.0:step 门挂起同样让路,绝不绕过会话门
|
|
130
176
|
if (st.taskControl && st.taskControl.paused) return true
|
|
131
177
|
return false
|
|
132
178
|
},
|
|
@@ -190,14 +236,52 @@ export function apply(ctx) {
|
|
|
190
236
|
if (method === 'GET' && url.pathname === '/session-guard/state') {
|
|
191
237
|
const sessionId = url.searchParams.get('session') ?? ''
|
|
192
238
|
if (!sessionId) return json(400, { ok: false, error: 'missing session' })
|
|
239
|
+
const st = bridge.state(sessionId)
|
|
193
240
|
return json(200, {
|
|
194
241
|
ok: true,
|
|
195
|
-
state:
|
|
242
|
+
state: st,
|
|
243
|
+
// v0.2.0:PRD §6.2 的 `paused: { step, turn }` 形状(顶层,兼容既有 state.paused 布尔)
|
|
244
|
+
// step = 已拉门;manual = 已请求但还没到边界(按钮同样显示「继续会话」)
|
|
245
|
+
paused: { step: st.pausedStep === true, turn: st.paused === true, manual: st.stepManual === true },
|
|
246
|
+
stepGate: {
|
|
247
|
+
held: st.pausedStep === true,
|
|
248
|
+
manual: st.stepManual === true,
|
|
249
|
+
since: st.stepHeldSince,
|
|
250
|
+
bypass: st.stepBypass === true,
|
|
251
|
+
},
|
|
196
252
|
target: wiring.targets.get(sessionId),
|
|
197
253
|
held: wiring.deferrals.has(sessionId),
|
|
198
254
|
deferred: wiring.deferrals.isDeferred(sessionId),
|
|
199
255
|
})
|
|
200
256
|
}
|
|
257
|
+
// GET /session-guard/events?session=<id> —— SSE:step 门状态变化即时推送
|
|
258
|
+
if (method === 'GET' && url.pathname === '/session-guard/events') {
|
|
259
|
+
const sessionId = url.searchParams.get('session') ?? ''
|
|
260
|
+
res.writeHead(200, {
|
|
261
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
262
|
+
'cache-control': 'no-cache, no-transform',
|
|
263
|
+
connection: 'keep-alive',
|
|
264
|
+
})
|
|
265
|
+
res.write(': connected\n\n')
|
|
266
|
+
const client = { sessionId, res }
|
|
267
|
+
sseClients.add(client)
|
|
268
|
+
sseSend(client, { type: 'step', sessionId, state: stepGate.state(sessionId) })
|
|
269
|
+
const keepAlive = setInterval(() => {
|
|
270
|
+
try {
|
|
271
|
+
res.write(': ping\n\n')
|
|
272
|
+
} catch {
|
|
273
|
+
/* 连接已断开 */
|
|
274
|
+
}
|
|
275
|
+
}, 25_000)
|
|
276
|
+
if (typeof keepAlive.unref === 'function') keepAlive.unref()
|
|
277
|
+
const cleanup = () => {
|
|
278
|
+
clearInterval(keepAlive)
|
|
279
|
+
sseClients.delete(client)
|
|
280
|
+
}
|
|
281
|
+
req.on?.('close', cleanup)
|
|
282
|
+
res.on?.('close', cleanup)
|
|
283
|
+
return undefined
|
|
284
|
+
}
|
|
201
285
|
// GET /session-guard/provider?provider=<id> —— 官方判定诊断(排查误判用)
|
|
202
286
|
if (method === 'GET' && url.pathname === '/session-guard/provider') {
|
|
203
287
|
const provider = url.searchParams.get('provider') ?? ''
|
|
@@ -229,8 +313,10 @@ export function apply(ctx) {
|
|
|
229
313
|
enabled: cfg.enabled,
|
|
230
314
|
weekendMode: cfg.weekendMode,
|
|
231
315
|
providerGuard: cfg.providerGuard === true,
|
|
316
|
+
stepLevelPause: cfg.stepLevelPause === true,
|
|
232
317
|
held: wiring.deferrals.size(),
|
|
233
318
|
deferred: wiring.deferrals.deferredSize(),
|
|
319
|
+
stepHeld: stepGate.heldIds().length,
|
|
234
320
|
timezone: cfg.timezone,
|
|
235
321
|
billingTimezone: BILLING_TIMEZONE,
|
|
236
322
|
now: now.toISOString(),
|
|
@@ -267,6 +353,12 @@ export function apply(ctx) {
|
|
|
267
353
|
configurableProviders: wiring.directory.entries().length,
|
|
268
354
|
held: wiring.deferrals.size(),
|
|
269
355
|
deferred: wiring.deferrals.deferredSize(),
|
|
356
|
+
stepGate: {
|
|
357
|
+
held: stepGate.heldIds(),
|
|
358
|
+
bypass: stepGate._bypassed(),
|
|
359
|
+
timeoutMs: readCfg().stepGateTimeoutMs,
|
|
360
|
+
enabled: readCfg().stepLevelPause === true,
|
|
361
|
+
},
|
|
270
362
|
},
|
|
271
363
|
})
|
|
272
364
|
}
|
|
@@ -288,6 +380,16 @@ export function apply(ctx) {
|
|
|
288
380
|
if (action === 'resume') return json(200, { ok: true, result: await bridge.resume(sessionId, parsed) })
|
|
289
381
|
if (action === 'lockQueue') return json(200, { ok: true, result: bridge.lockQueue(sessionId, parsed.reason) })
|
|
290
382
|
if (action === 'unlockQueue') return json(200, { ok: true, result: bridge.unlockQueue(sessionId) })
|
|
383
|
+
if (action === 'stepResume') {
|
|
384
|
+
return json(200, {
|
|
385
|
+
ok: true,
|
|
386
|
+
result: bridge.stepResume(sessionId, {
|
|
387
|
+
bypass: parsed.bypass !== false,
|
|
388
|
+
reason: typeof parsed.reason === 'string' && parsed.reason !== '' ? parsed.reason : 'rpc',
|
|
389
|
+
}),
|
|
390
|
+
})
|
|
391
|
+
}
|
|
392
|
+
if (action === 'stepPause') return json(200, { ok: true, result: bridge.stepPause(sessionId) })
|
|
291
393
|
if (action === 'state') return json(200, { ok: true, state: bridge.state(sessionId) })
|
|
292
394
|
return json(400, { ok: false, error: `unknown action ${action}` })
|
|
293
395
|
}
|
package/src/pause-gate.js
CHANGED
|
@@ -52,11 +52,26 @@ export function createPluginUserMessage({ content, source }) {
|
|
|
52
52
|
* @param {ReturnType<import('./pause-store.js').createPauseStore>} deps.pauseStore 暂停状态存储
|
|
53
53
|
* @param {string} [deps.pluginId] followup 消息的 source.plugin(默认 'session-guard')
|
|
54
54
|
* @param {(input: {content: object, source: object}) => unknown} [deps.makeFollowupMessage] 恢复消息构造(默认本地构造;测试注入)
|
|
55
|
+
* @param {ReturnType<import('./step-gate.js').createStepGate>} [deps.stepGate] step 级门控(v0.2.0)
|
|
56
|
+
*
|
|
57
|
+
* **为什么 pause/resume/cancel 必须先释放 step gate(F6 死锁)**:step 门控挂起在
|
|
58
|
+
* `agent/pre-step`,此刻既不会产生 `assistant/message` 也不会产生 `tool/result`;
|
|
59
|
+
* 而 safe+wait / safe+stop 都靠这两个事件落地暂停 → 两边互等,会话永久卡死。
|
|
55
60
|
*/
|
|
56
|
-
export function createPauseGate({ ctx, pauseStore, pluginId = 'session-guard', makeFollowupMessage = createPluginUserMessage }) {
|
|
61
|
+
export function createPauseGate({ ctx, pauseStore, pluginId = 'session-guard', makeFollowupMessage = createPluginUserMessage, stepGate }) {
|
|
57
62
|
const state = { inFlight: new Map(), pendingPause: new Map() }
|
|
58
63
|
const getAgent = (sessionId) => (ctx && typeof ctx.agents?.get === 'function' ? ctx.agents.get(sessionId) : undefined)
|
|
59
64
|
|
|
65
|
+
/** 先解开 step 门(若存在),再走 turn 级逻辑;任何异常都不阻断暂停动作。 */
|
|
66
|
+
function releaseStepGate(sessionId, reason) {
|
|
67
|
+
if (!stepGate || typeof stepGate.release !== 'function') return
|
|
68
|
+
try {
|
|
69
|
+
stepGate.release(sessionId, reason)
|
|
70
|
+
} catch (e) {
|
|
71
|
+
ctx?.logger?.warn?.('[session-guard] step gate release failed: ' + String(e))
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
60
75
|
/** 会话门(dsh-task-control)当前是否可用(兼容探测;自研后始终视为可用)。 */
|
|
61
76
|
const taskControlAvailable = () => true
|
|
62
77
|
|
|
@@ -218,6 +233,8 @@ export function createPauseGate({ ctx, pauseStore, pluginId = 'session-guard', m
|
|
|
218
233
|
* 默认 safe+wait(对齐 DEFAULT_SETTINGS)。返回 { kind, text, needConfirmation? }。
|
|
219
234
|
*/
|
|
220
235
|
function pauseTask(sessionId, opts = {}) {
|
|
236
|
+
// F6:先解开 step 门,否则 safe 模式的 pendingPause 永远等不到安全边界事件。
|
|
237
|
+
releaseStepGate(sessionId, 'pause')
|
|
221
238
|
const agent = getAgent(sessionId)
|
|
222
239
|
if (agent === undefined) return { kind: 'error', text: 'no live agent for this session — nothing to pause' }
|
|
223
240
|
const current = currentPause(sessionId)
|
|
@@ -262,6 +279,8 @@ export function createPauseGate({ ctx, pauseStore, pluginId = 'session-guard', m
|
|
|
262
279
|
* opts: { confirm:boolean, choice:'rerun'|'skip' }。
|
|
263
280
|
*/
|
|
264
281
|
function resumeTask(sessionId, opts = {}) {
|
|
282
|
+
// F6:step 门挂起时「恢复」等价于放行该 step(回合本来就还开着)。
|
|
283
|
+
releaseStepGate(sessionId, 'resume')
|
|
265
284
|
const agent = getAgent(sessionId)
|
|
266
285
|
if (agent === undefined) return { kind: 'error', text: 'no live agent for this session' }
|
|
267
286
|
state.pendingPause.delete(sessionId)
|
|
@@ -336,6 +355,8 @@ export function createPauseGate({ ctx, pauseStore, pluginId = 'session-guard', m
|
|
|
336
355
|
|
|
337
356
|
/** 立即终止当前回合,回报被中断工具的目的/副作用风险。 */
|
|
338
357
|
function cancelTask(sessionId) {
|
|
358
|
+
// F6:取消 = 释放 step 门 + 中断回合(顺序无所谓,release 幂等)。
|
|
359
|
+
releaseStepGate(sessionId, 'cancel')
|
|
339
360
|
const agent = getAgent(sessionId)
|
|
340
361
|
if (agent === undefined) return { kind: 'error', text: 'no live agent for this session' }
|
|
341
362
|
const current = currentPause(sessionId)
|
package/src/settings.js
CHANGED
|
@@ -28,6 +28,8 @@ export const DEFAULT_SETTINGS = Object.freeze({
|
|
|
28
28
|
],
|
|
29
29
|
pauseMode: 'safe', // 透传 taskControl.pause mode
|
|
30
30
|
pauseReason: 'wait', // 透传 taskControl.pause reason
|
|
31
|
+
stepLevelPause: true, // step 级门控(agent/pre-step):高峰在下一个 step 前拉门
|
|
32
|
+
stepGateTimeoutMs: 300_000, // step 门控超时(防死锁;到期升级为 turn 级 force 暂停)
|
|
31
33
|
queueFallback: true, // 无会话门时回退锁等待队列(简单开关)
|
|
32
34
|
retryEnabled: false, // 自动重试开关(后端,D9;默认关,保守)
|
|
33
35
|
retryText: DEFAULT_RETRY.retryText,
|
|
@@ -61,6 +63,8 @@ export const SettingsSchema = z.object({
|
|
|
61
63
|
.default(DEFAULT_SETTINGS.peakWindows),
|
|
62
64
|
pauseMode: z.union([z.const('safe'), z.const('force')]).default(DEFAULT_SETTINGS.pauseMode),
|
|
63
65
|
pauseReason: z.union([z.const('wait'), z.const('stop')]).default(DEFAULT_SETTINGS.pauseReason),
|
|
66
|
+
stepLevelPause: z.boolean().default(DEFAULT_SETTINGS.stepLevelPause),
|
|
67
|
+
stepGateTimeoutMs: z.number().min(0).default(DEFAULT_SETTINGS.stepGateTimeoutMs),
|
|
64
68
|
queueFallback: z.boolean().default(DEFAULT_SETTINGS.queueFallback),
|
|
65
69
|
retryEnabled: z.boolean().default(DEFAULT_SETTINGS.retryEnabled),
|
|
66
70
|
retryText: z.string().default(DEFAULT_SETTINGS.retryText),
|
package/src/step-gate.js
ADDED
|
@@ -0,0 +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
|
+
}
|