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/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
- const pauseGate = createPauseGate({ ctx, pauseStore })
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: bridge.state(sessionId),
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),
@@ -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
+ }