dsh-session-guard 0.1.2 → 0.2.0-beta.1

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/wiring.js ADDED
@@ -0,0 +1,305 @@
1
+ /**
2
+ * dsh-session-guard — 接线层(host):把纯逻辑模块装到 cordis 事件/服务上。
3
+ *
4
+ * 职责:
5
+ * - 目标追踪:`session/event` 的 `request/header`(两版本)+ `model/selection`(0.1.2+)
6
+ * - 请求级守卫:`agent/request` waterfall(覆盖入峰后新会话 / 中途切官方的缺口)
7
+ * - 入峰:只暂停「最近目标为官方或 unknown」的 running 会话;**已 hold 的跳过**(互斥铁律)
8
+ * - 退峰:先 `releaseAll` 放行挂起的请求,再按 `deferredResume` / `offPeakAutoResume` 决定续跑
9
+ * - 精确释放:`msUntilOffPeak` 定时器在退峰瞬间放行(30s tick 只是兜底)
10
+ *
11
+ * 本模块不做任何 `@deepseek-ai/*` 值导入;所有上游服务缺失都 fail-open 降级。
12
+ */
13
+ import { createProviderDirectory } from './provider-directory.js'
14
+ import { createDeferrals } from './deferrals.js'
15
+ import { createRequestGuard } from './request-guard.js'
16
+ import { createTargets, UNKNOWN } from './targets.js'
17
+ import { msUntilOffPeak, shouldPause } from './time.js'
18
+
19
+ /**
20
+ * @param {object} deps
21
+ * @param {object} deps.ctx host context
22
+ * @param {()=>object} deps.getSettings 读实时配置
23
+ * @param {{stopNextTurn:Function, resume:Function}} deps.gate 会话门
24
+ * @param {ReturnType<import('./step-gate.js').createStepGate>} [deps.stepGate] step 级门控(v0.2.0)
25
+ * @param {object} [deps.logger]
26
+ * @param {()=>Date} [deps.clock]
27
+ * @param {typeof setTimeout} [deps.setTimer]
28
+ * @param {typeof clearTimeout} [deps.clearTimer]
29
+ * @param {boolean} [deps.unrefTimers]
30
+ */
31
+ export function createWiring({
32
+ ctx,
33
+ getSettings,
34
+ gate,
35
+ stepGate,
36
+ logger,
37
+ clock = () => new Date(),
38
+ setTimer = setTimeout,
39
+ clearTimer = clearTimeout,
40
+ unrefTimers = true,
41
+ }) {
42
+ const warn = (m) => logger?.warn?.(`[session-guard] ${m}`)
43
+ const targets = createTargets()
44
+ const directory = createProviderDirectory({ ctx, getSettings, warn })
45
+ const deferrals = createDeferrals({
46
+ maxHoldMs: () => {
47
+ const cfg = safeCfg()
48
+ return typeof cfg.deferredMaxHoldMs === 'number' ? cfg.deferredMaxHoldMs : 6 * 60 * 60 * 1000
49
+ },
50
+ unrefTimers,
51
+ })
52
+
53
+ function safeCfg() {
54
+ try {
55
+ const v = getSettings()
56
+ return v && typeof v === 'object' ? v : {}
57
+ } catch {
58
+ return {}
59
+ }
60
+ }
61
+
62
+ let releaseTimer = null
63
+ /** 本插件因入峰而暂停的会话(只自动恢复这些,绝不碰用户手动暂停的会话)。 */
64
+ const pausedByPeak = new Set()
65
+
66
+ /** 安排一次「退峰瞬间」的释放检查(幂等:重复调用只保留最新一个定时器)。 */
67
+ function scheduleRelease() {
68
+ const cfg = safeCfg()
69
+ const ms = msUntilOffPeak(cfg, clock())
70
+ if (!(ms > 0)) return 0
71
+ if (releaseTimer !== null) clearTimer(releaseTimer)
72
+ releaseTimer = setTimer(() => {
73
+ releaseTimer = null
74
+ try {
75
+ const nowCfg = safeCfg()
76
+ if (shouldPause(nowCfg, clock()).pause) {
77
+ // 设置热改导致仍是高峰 → 重新排
78
+ scheduleRelease()
79
+ return
80
+ }
81
+ void onLeavePeak(nowCfg)
82
+ } catch (e) {
83
+ warn(`release timer failed: ${String(e && e.message || e)}`)
84
+ }
85
+ }, ms)
86
+ if (unrefTimers && releaseTimer && typeof releaseTimer.unref === 'function') releaseTimer.unref()
87
+ return ms
88
+ }
89
+
90
+ const guard = createRequestGuard({
91
+ ctx,
92
+ getSettings,
93
+ directory,
94
+ deferrals,
95
+ gate,
96
+ logger,
97
+ clock,
98
+ scheduleRelease,
99
+ })
100
+
101
+ /** `session/event` → 目标追踪(形状异常降级 unknown,不抛出)+ 目标转非官方时自动恢复。 */
102
+ function onSessionEvent(session, event) {
103
+ try {
104
+ const id = session && session.id
105
+ if (typeof id !== 'string' || id === '') return
106
+ const rec = targets.update(id, event)
107
+ if (rec !== null) maybeAutoResume(id, rec)
108
+ } catch (e) {
109
+ warn(`target tracking failed: ${String(e && e.message || e)}`)
110
+ }
111
+ }
112
+
113
+ /**
114
+ * 高峰期目标从官方变为非官方 → 恢复本插件因入峰而暂停的会话(spec 需求 5)。
115
+ * v0.2.0:先释放 step 门控(幂等,无门则 no-op);turn 级只对 `pausedByPeak` 里的
116
+ * 会话生效(不碰用户手动暂停的);受 `deferredResume` 约束;
117
+ * 用 queueMicrotask 跳出当前事件派发,避免 resume→followup→事件 的重入。
118
+ */
119
+ function maybeAutoResume(sessionId, rec) {
120
+ const cfg = safeCfg()
121
+ if (cfg.providerGuard !== true || cfg.deferredResume === false) return
122
+ if (!shouldPause(cfg, clock()).pause) return
123
+ const cls = directory.classify(rec.provider)
124
+ if (cls.official) return
125
+ // step 门控:目标转非官方 → 放行被挂起的 step(step gate 自身按目标判定,无需 pausedByPeak)
126
+ if (stepGate) {
127
+ const r = stepGate.release(sessionId, 'non-official')
128
+ if (r && r.released === true) {
129
+ logger?.info?.(`[session-guard] target switched to non-official "${rec.provider}" (matchedBy=${cls.matchedBy}) — releasing step gate for ${sessionId}`)
130
+ }
131
+ }
132
+ if (!pausedByPeak.has(sessionId)) return
133
+ pausedByPeak.delete(sessionId)
134
+ logger?.info?.(`[session-guard] target switched to non-official "${rec.provider}" (matchedBy=${cls.matchedBy}) — auto-resuming ${sessionId}`)
135
+ queueMicrotask(() => {
136
+ Promise.resolve(gate.resume(sessionId, { choice: 'rerun' })).catch((e) => {
137
+ warn(`auto-resume failed for ${sessionId}: ${String(e && e.message || e)}`)
138
+ })
139
+ })
140
+ }
141
+
142
+ /** 该会话是否应因高峰被暂停(官方 / unknown 才停;已知非官方放行)。 */
143
+ function shouldPauseSession(cfg, sessionId) {
144
+ if (cfg.providerGuard !== true) return true // 退回现有纯时间判定:全部暂停
145
+ const provider = targets.providerOf(sessionId)
146
+ if (provider === UNKNOWN) return true // 未知保守处理
147
+ const cls = directory.classify(provider)
148
+ return cls.official
149
+ }
150
+
151
+ /** 入峰:暂停 running 会话(跳过已 hold / 已暂停 / 目标非官方)。 */
152
+ async function onEnterPeak(cfg) {
153
+ const agents = ctx && ctx.agents
154
+ if (!agents) return { paused: [], skipped: [] }
155
+ const roots = typeof agents.roots === 'function' ? agents.roots() : typeof agents.list === 'function' ? agents.list() : []
156
+ // Q1-A:step 级门控开启时,入峰不再 turn 级暂停——会话跑到下一个 pre-step 边界自动拉门。
157
+ const stepLevel = cfg.stepLevelPause === true && !!stepGate
158
+ const paused = []
159
+ const skipped = []
160
+ for (const agent of Array.isArray(roots) ? roots : []) {
161
+ const id = String((agent && agent.id) ?? '')
162
+ if (id === '' || !agent || agent.status !== 'running') continue
163
+ // 互斥铁律:请求已被 hold 的会话,请求级挂起本身就是暂停,绝不再调 pauseGate
164
+ if (deferrals.has(id)) {
165
+ skipped.push({ sessionId: id, why: 'held' })
166
+ continue
167
+ }
168
+ if (!shouldPauseSession(cfg, id)) {
169
+ skipped.push({ sessionId: id, why: 'non-official', provider: targets.providerOf(id) })
170
+ continue
171
+ }
172
+ if (stepLevel) {
173
+ // 新一轮高峰:清掉上一轮的 bypass,让 step 门重新生效。
174
+ stepGate.clearBypass(id)
175
+ paused.push({ sessionId: id, via: 'stepGate', ok: true })
176
+ continue
177
+ }
178
+ try {
179
+ const r = await gate.stopNextTurn(id, { mode: cfg.pauseMode, reason: cfg.pauseReason })
180
+ if (!r || r.ok !== false) pausedByPeak.add(id)
181
+ paused.push({ sessionId: id, via: r && r.via, ok: r && r.ok })
182
+ } catch (e) {
183
+ warn(`stopNextTurn failed for ${id}: ${String(e && e.message || e)}`)
184
+ }
185
+ }
186
+ logger?.info?.(`[session-guard] peak entered — ${stepLevel ? 'step-level gate armed for' : 'paused'} ${paused.length} session(s): ${JSON.stringify(paused)}; skipped ${JSON.stringify(skipped)}`)
187
+ return { paused, skipped }
188
+ }
189
+
190
+ /** 给一条延后记录发续跑消息(与 retry.js 同构:plugin-source 消息,零运行时依赖)。 */
191
+ function sendResume(sessionId, text) {
192
+ const agents = ctx && ctx.agents
193
+ const agent = agents && typeof agents.get === 'function' ? agents.get(sessionId) : undefined
194
+ if (!agent || agent.status !== 'idle') return false
195
+ try {
196
+ agent.followup({
197
+ id: `session-guard-deferred-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
198
+ role: 'user',
199
+ content: [{ type: 'text', text }],
200
+ source: { kind: 'plugin', plugin: 'session-guard', form: 'notice' },
201
+ })
202
+ return true
203
+ } catch (e) {
204
+ warn(`deferred resume send failed for ${sessionId}: ${String(e && e.message || e)}`)
205
+ return false
206
+ }
207
+ }
208
+
209
+ /** 退峰:先释放 step 门(回合原地续跑),再放行挂起的请求、恢复暂停的会话。 */
210
+ async function onLeavePeak(cfg) {
211
+ if (releaseTimer !== null) {
212
+ clearTimer(releaseTimer)
213
+ releaseTimer = null
214
+ }
215
+ // 0) step 级门控:先放行被挂起的 step —— 回合本来就还开着,无需 followup。
216
+ if (stepGate) {
217
+ const releasedSteps = stepGate.releaseAll('off-peak')
218
+ stepGate.clearAllBypass()
219
+ if (releasedSteps.length > 0) logger?.info?.(`[session-guard] peak left — released ${releasedSteps.length} step gate(s): ${JSON.stringify(releasedSteps)}`)
220
+ }
221
+ // 1) 先取延后记录快照(releaseAll 会清空),再放行全部挂起。
222
+ const deferredSnapshot = deferrals.deferredList()
223
+ const { released } = deferrals.releaseAll('off-peak')
224
+
225
+ // 2) error 模式的续跑:deferredResume=false 时不自动继续(用户手动 /resume 或再发消息)
226
+ const resumed = []
227
+ if (cfg.deferredResume !== false) {
228
+ const text = typeof cfg.deferredResumeText === 'string' && cfg.deferredResumeText !== ''
229
+ ? cfg.deferredResumeText
230
+ : '继续(高峰已过,自动继续)'
231
+ for (const rec of deferredSnapshot) {
232
+ if (sendResume(rec.sessionId, text)) resumed.push(rec.sessionId)
233
+ }
234
+ } else {
235
+ logger?.info?.('[session-guard] deferredResume disabled — deferred sessions stay parked')
236
+ }
237
+
238
+ // 3) 低谷自动恢复:关掉则保持暂停,需手动恢复。
239
+ if (cfg.offPeakAutoResume === false) {
240
+ logger?.info?.(`[session-guard] off-peak auto-resume disabled — released ${released.length} held request(s), sessions stay paused`)
241
+ return { released, resumed }
242
+ }
243
+ const agents = ctx && ctx.agents
244
+ const roots = agents ? (typeof agents.roots === 'function' ? agents.roots() : typeof agents.list === 'function' ? agents.list() : []) : []
245
+ for (const agent of Array.isArray(roots) ? roots : []) {
246
+ const id = String((agent && agent.id) ?? '')
247
+ if (id === '') continue
248
+ try {
249
+ await gate.resume(id, { choice: 'rerun' })
250
+ } catch (e) {
251
+ warn(`resume failed for ${id}: ${String(e && e.message || e)}`)
252
+ }
253
+ }
254
+ pausedByPeak.clear()
255
+ logger?.info?.(`[session-guard] peak left — released ${released.length} held request(s), resumed ${resumed.length} deferred session(s)`)
256
+ return { released, resumed }
257
+ }
258
+
259
+ /** 安装请求级守卫(返回 disposer,交给 ctx.effect)。 */
260
+ function installGuard() {
261
+ return guard.install()
262
+ }
263
+
264
+ /**
265
+ * 安装 step 级门控(`agent/pre-step` waterfall,v0.2.0)。
266
+ * disposer 释放全部挂起门,避免卸载后 Promise 悬挂。
267
+ */
268
+ function installStepGuard() {
269
+ if (!stepGate || !ctx || typeof ctx.on !== 'function') return () => {}
270
+ const off = ctx.on('agent/pre-step', (payload, next) => stepGate.hold(payload, next))
271
+ return () => {
272
+ if (typeof off === 'function') off()
273
+ stepGate.releaseAll('disposed')
274
+ }
275
+ }
276
+
277
+ /** 插件卸载:清定时器 + 拒绝全部挂起(不泄漏 promise)。 */
278
+ function dispose() {
279
+ if (releaseTimer !== null) {
280
+ clearTimer(releaseTimer)
281
+ releaseTimer = null
282
+ }
283
+ pausedByPeak.clear()
284
+ if (stepGate) {
285
+ stepGate.releaseAll('disposed')
286
+ stepGate.clearAllBypass()
287
+ }
288
+ deferrals.rejectAll('disposed')
289
+ }
290
+
291
+ return {
292
+ targets,
293
+ directory,
294
+ deferrals,
295
+ guard,
296
+ onSessionEvent,
297
+ onEnterPeak,
298
+ onLeavePeak,
299
+ scheduleRelease,
300
+ shouldPauseSession,
301
+ installGuard,
302
+ installStepGuard,
303
+ dispose,
304
+ }
305
+ }