dsh-session-guard 0.4.0 → 3.0.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.
@@ -1,129 +1,129 @@
1
- /**
2
- * dsh-session-guard — 「暂停会话 / 继续会话」按钮(`conversation.input.right`,order 20)。
3
- *
4
- * 交互(v0.2.0 修订):
5
- * - 未暂停 → 「暂停会话」,**可点**:点击 POST `{action:'stepPause'}`,在**下一次 step 边界**暂停
6
- * (不打断当前 step;step 1 也拦,不受峰谷 / provider 限制);
7
- * - 已暂停(高峰自动拉门 **或** 手动请求已登记)→ 「继续会话」,点击 POST `{action:'stepResume'}`;
8
- * - 状态更新双通道:① SSE `/session-guard/events?session=<id>` 即时推送;② 10s 轮询兜底
9
- * (SSE 不可用 / 断线时仍能收敛)。全部 fail-open,绝不抛错。
10
- *
11
- * 与 input-traffic 的「❄ 冻结追加 / 恢复追加」并列(order 30 在其右侧),互不取代:
12
- * 本按钮控 step 门,冻结按钮控回合级冻结 + 队列摘除。
13
- */
14
- import { useEffect, useState } from 'react'
15
- import {
16
- pauseButtonLabel,
17
- pauseButtonTitle,
18
- readPauseState,
19
- readStepSnapshot,
20
- type PauseButtonState,
21
- } from './pause-button-text'
22
- import { injectClientCss } from './styles'
23
-
24
- /** slot 运行时注入的会话级 props。 */
25
- export interface PauseButtonProps {
26
- sessionId?: string
27
- }
28
-
29
- /** SSE 不可用时的兜底轮询间隔(正常路径由事件驱动,几乎不触发)。 */
30
- const POLL_MS = 10_000
31
-
32
- const IDLE: PauseButtonState = { paused: false, heldSince: null, manual: false }
33
-
34
- /** 暂停 / 继续会话按钮。 */
35
- export function PauseButton({ sessionId }: PauseButtonProps) {
36
- const [state, setState] = useState<PauseButtonState>(IDLE)
37
- const [busy, setBusy] = useState(false)
38
-
39
- injectClientCss()
40
-
41
- useEffect(() => {
42
- if (sessionId === undefined || sessionId === '') return
43
- let cancelled = false
44
-
45
- const poll = async (): Promise<void> => {
46
- try {
47
- const res = await fetch(`/session-guard/state?session=${encodeURIComponent(sessionId)}`)
48
- const body = await res.json().catch(() => null)
49
- if (cancelled) return
50
- setState(readPauseState(body))
51
- } catch {
52
- // fail-open:路由不存在 / 网络失败 → 保持上一次状态。
53
- }
54
- }
55
- void poll()
56
- const timer = setInterval(() => {
57
- void poll()
58
- }, POLL_MS)
59
-
60
- let source: EventSource | undefined
61
- try {
62
- source = new EventSource(`/session-guard/events?session=${encodeURIComponent(sessionId)}`)
63
- source.onmessage = (ev: MessageEvent<string>) => {
64
- try {
65
- const msg = JSON.parse(ev.data) as { type?: string; state?: unknown }
66
- if (cancelled || msg?.type !== 'step') return
67
- setState(readStepSnapshot(msg.state))
68
- } catch {
69
- /* 非法负载:忽略,等下一次推送或轮询 */
70
- }
71
- }
72
- // onerror:EventSource 会自行重连;轮询同时兜底。
73
- source.onerror = () => undefined
74
- } catch {
75
- // 浏览器不支持 EventSource → 只靠轮询。
76
- }
77
-
78
- return () => {
79
- cancelled = true
80
- clearInterval(timer)
81
- try {
82
- source?.close()
83
- } catch {
84
- /* ignore */
85
- }
86
- }
87
- }, [sessionId])
88
-
89
- if (sessionId === undefined || sessionId === '') return null
90
-
91
- const paused = state.paused === true
92
- const onClick = async (): Promise<void> => {
93
- if (busy) return
94
- setBusy(true)
95
- try {
96
- const action = paused ? 'stepResume' : 'stepPause'
97
- const res = await fetch('/session-guard/rpc', {
98
- method: 'POST',
99
- headers: { 'content-type': 'application/json' },
100
- body: JSON.stringify({ sessionId, action }),
101
- })
102
- const body = await res.json().catch(() => null)
103
- if (body?.ok === true) {
104
- // 立即乐观更新;SSE / 轮询随后校正。
105
- setState(paused ? IDLE : { paused: true, heldSince: null, manual: true })
106
- }
107
- } catch {
108
- // fail-open:下一轮轮询会纠正显示。
109
- } finally {
110
- setBusy(false)
111
- }
112
- }
113
-
114
- return (
115
- <button
116
- type="button"
117
- className="sg-pause"
118
- disabled={busy}
119
- aria-pressed={paused || undefined}
120
- title={pauseButtonTitle(state)}
121
- data-sg-step-paused={paused ? 'on' : 'off'}
122
- onClick={() => {
123
- void onClick()
124
- }}
125
- >
126
- {pauseButtonLabel(state)}
127
- </button>
128
- )
129
- }
1
+ /**
2
+ * dsh-session-guard — 「暂停会话 / 继续会话」按钮(`conversation.input.right`,order 20)。
3
+ *
4
+ * 交互(v0.2.0 修订):
5
+ * - 未暂停 → 「暂停会话」,**可点**:点击 POST `{action:'stepPause'}`,在**下一次 step 边界**暂停
6
+ * (不打断当前 step;step 1 也拦,不受峰谷 / provider 限制);
7
+ * - 已暂停(高峰自动拉门 **或** 手动请求已登记)→ 「继续会话」,点击 POST `{action:'stepResume'}`;
8
+ * - 状态更新双通道:① SSE `/session-guard/events?session=<id>` 即时推送;② 10s 轮询兜底
9
+ * (SSE 不可用 / 断线时仍能收敛)。全部 fail-open,绝不抛错。
10
+ *
11
+ * 与 input-traffic 的「❄ 冻结追加 / 恢复追加」并列(order 30 在其右侧),互不取代:
12
+ * 本按钮控 step 门,冻结按钮控回合级冻结 + 队列摘除。
13
+ */
14
+ import { useEffect, useState } from 'react'
15
+ import {
16
+ pauseButtonLabel,
17
+ pauseButtonTitle,
18
+ readPauseState,
19
+ readStepSnapshot,
20
+ type PauseButtonState,
21
+ } from './pause-button-text'
22
+ import { injectClientCss } from './styles'
23
+
24
+ /** slot 运行时注入的会话级 props。 */
25
+ export interface PauseButtonProps {
26
+ sessionId?: string
27
+ }
28
+
29
+ /** SSE 不可用时的兜底轮询间隔(正常路径由事件驱动,几乎不触发)。 */
30
+ const POLL_MS = 10_000
31
+
32
+ const IDLE: PauseButtonState = { paused: false, heldSince: null, manual: false }
33
+
34
+ /** 暂停 / 继续会话按钮。 */
35
+ export function PauseButton({ sessionId }: PauseButtonProps) {
36
+ const [state, setState] = useState<PauseButtonState>(IDLE)
37
+ const [busy, setBusy] = useState(false)
38
+
39
+ injectClientCss()
40
+
41
+ useEffect(() => {
42
+ if (sessionId === undefined || sessionId === '') return
43
+ let cancelled = false
44
+
45
+ const poll = async (): Promise<void> => {
46
+ try {
47
+ const res = await fetch(`/session-guard/state?session=${encodeURIComponent(sessionId)}`)
48
+ const body = await res.json().catch(() => null)
49
+ if (cancelled) return
50
+ setState(readPauseState(body))
51
+ } catch {
52
+ // fail-open:路由不存在 / 网络失败 → 保持上一次状态。
53
+ }
54
+ }
55
+ void poll()
56
+ const timer = setInterval(() => {
57
+ void poll()
58
+ }, POLL_MS)
59
+
60
+ let source: EventSource | undefined
61
+ try {
62
+ source = new EventSource(`/session-guard/events?session=${encodeURIComponent(sessionId)}`)
63
+ source.onmessage = (ev: MessageEvent<string>) => {
64
+ try {
65
+ const msg = JSON.parse(ev.data) as { type?: string; state?: unknown }
66
+ if (cancelled || msg?.type !== 'step') return
67
+ setState(readStepSnapshot(msg.state))
68
+ } catch {
69
+ /* 非法负载:忽略,等下一次推送或轮询 */
70
+ }
71
+ }
72
+ // onerror:EventSource 会自行重连;轮询同时兜底。
73
+ source.onerror = () => undefined
74
+ } catch {
75
+ // 浏览器不支持 EventSource → 只靠轮询。
76
+ }
77
+
78
+ return () => {
79
+ cancelled = true
80
+ clearInterval(timer)
81
+ try {
82
+ source?.close()
83
+ } catch {
84
+ /* ignore */
85
+ }
86
+ }
87
+ }, [sessionId])
88
+
89
+ if (sessionId === undefined || sessionId === '') return null
90
+
91
+ const paused = state.paused === true
92
+ const onClick = async (): Promise<void> => {
93
+ if (busy) return
94
+ setBusy(true)
95
+ try {
96
+ const action = paused ? 'stepResume' : 'stepPause'
97
+ const res = await fetch('/session-guard/rpc', {
98
+ method: 'POST',
99
+ headers: { 'content-type': 'application/json' },
100
+ body: JSON.stringify({ sessionId, action }),
101
+ })
102
+ const body = await res.json().catch(() => null)
103
+ if (body?.ok === true) {
104
+ // 立即乐观更新;SSE / 轮询随后校正。
105
+ setState(paused ? IDLE : { paused: true, heldSince: null, manual: true })
106
+ }
107
+ } catch {
108
+ // fail-open:下一轮轮询会纠正显示。
109
+ } finally {
110
+ setBusy(false)
111
+ }
112
+ }
113
+
114
+ return (
115
+ <button
116
+ type="button"
117
+ className="sg-pause"
118
+ disabled={busy}
119
+ aria-pressed={paused || undefined}
120
+ title={pauseButtonTitle(state)}
121
+ data-sg-step-paused={paused ? 'on' : 'off'}
122
+ onClick={() => {
123
+ void onClick()
124
+ }}
125
+ >
126
+ {pauseButtonLabel(state)}
127
+ </button>
128
+ )
129
+ }
@@ -1,30 +1,30 @@
1
- /**
2
- * dsh-session-guard — 客户端样式(与 composer 右侧 input-traffic 冻结按钮同一视觉语言)。
3
- *
4
- * 为什么注入 `<style>` 而不是 CSS Modules:本插件的 tsdown 配置没有 CSS Modules 管线
5
- * (input-traffic 有),而 settings-card 已经用 `<style data-plugin-css>` 的既有约定。
6
- * 这里只做一件事:把「暂停会话 / 继续会话」按钮与状态徽标对齐到同一行的其它控件
7
- * (高度 24px、圆角 6px、12px 字号、同样的 border / hover / pressed 令牌)。
8
- */
9
-
10
- /** 与 `dsh-input-traffic/src/client/freeze-button.module.css` 对齐的控件外观。 */
11
- export const CLIENT_CSS = `
12
- .sg-pause{display:inline-flex;align-items:center;gap:4px;height:24px;padding:0 8px;border:1px solid var(--dsw-alias-border-l3,rgba(0,0,0,.12));border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary,#6b7280);font-size:12px;line-height:1;cursor:pointer;white-space:nowrap;flex:none}
13
- .sg-pause:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.04))}
14
- .sg-pause:disabled{opacity:.55;cursor:default}
15
- .sg-pause[aria-pressed='true']{border-color:var(--dsw-alias-state-warning-primary,#d97706);color:var(--dsw-alias-state-warning-primary,#d97706);background:color-mix(in srgb,var(--dsw-alias-state-warning-primary,#d97706) 8%,transparent)}
16
- .sg-status{display:inline-flex;align-items:center;height:24px;padding:0 8px;border:1px solid var(--dsw-alias-border-l3,rgba(0,0,0,.12));border-radius:6px;font-size:12px;line-height:1;color:var(--dsw-alias-label-secondary,#6b7280);white-space:nowrap;flex:none}
17
- .sg-status.sg-peak{border-color:var(--dsw-alias-state-warning-primary,#d97706);color:var(--dsw-alias-state-warning-primary,#d97706)}
18
- .sg-status.sg-weekend{border-color:var(--dsw-alias-state-success-primary,#30a46c);color:var(--dsw-alias-state-success-primary,#30a46c)}
19
- `
20
-
21
- /** 注入一次(幂等;无 document 时静默跳过)。 */
22
- export function injectClientCss(): void {
23
- if (typeof document === 'undefined') return
24
- if (document.querySelector('style[data-plugin-css="session-guard-client"]') !== null) return
25
- const tag = document.createElement('style')
26
- tag.dataset.plugin = 'dsh-session-guard'
27
- tag.dataset.pluginCss = 'session-guard-client'
28
- tag.textContent = CLIENT_CSS
29
- document.head.appendChild(tag)
30
- }
1
+ /**
2
+ * dsh-session-guard — 客户端样式(与 composer 右侧 input-traffic 冻结按钮同一视觉语言)。
3
+ *
4
+ * 为什么注入 `<style>` 而不是 CSS Modules:本插件的 tsdown 配置没有 CSS Modules 管线
5
+ * (input-traffic 有),而 settings-card 已经用 `<style data-plugin-css>` 的既有约定。
6
+ * 这里只做一件事:把「暂停会话 / 继续会话」按钮与状态徽标对齐到同一行的其它控件
7
+ * (高度 24px、圆角 6px、12px 字号、同样的 border / hover / pressed 令牌)。
8
+ */
9
+
10
+ /** 与 `dsh-input-traffic/src/client/freeze-button.module.css` 对齐的控件外观。 */
11
+ export const CLIENT_CSS = `
12
+ .sg-pause{display:inline-flex;align-items:center;gap:4px;height:24px;padding:0 8px;border:1px solid var(--dsw-alias-border-l3,rgba(0,0,0,.12));border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary,#6b7280);font-size:12px;line-height:1;cursor:pointer;white-space:nowrap;flex:none}
13
+ .sg-pause:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.04))}
14
+ .sg-pause:disabled{opacity:.55;cursor:default}
15
+ .sg-pause[aria-pressed='true']{border-color:var(--dsw-alias-state-warning-primary,#d97706);color:var(--dsw-alias-state-warning-primary,#d97706);background:color-mix(in srgb,var(--dsw-alias-state-warning-primary,#d97706) 8%,transparent)}
16
+ .sg-status{display:inline-flex;align-items:center;height:24px;padding:0 8px;border:1px solid var(--dsw-alias-border-l3,rgba(0,0,0,.12));border-radius:6px;font-size:12px;line-height:1;color:var(--dsw-alias-label-secondary,#6b7280);white-space:nowrap;flex:none}
17
+ .sg-status.sg-peak{border-color:var(--dsw-alias-state-warning-primary,#d97706);color:var(--dsw-alias-state-warning-primary,#d97706)}
18
+ .sg-status.sg-weekend{border-color:var(--dsw-alias-state-success-primary,#30a46c);color:var(--dsw-alias-state-success-primary,#30a46c)}
19
+ `
20
+
21
+ /** 注入一次(幂等;无 document 时静默跳过)。 */
22
+ export function injectClientCss(): void {
23
+ if (typeof document === 'undefined') return
24
+ if (document.querySelector('style[data-plugin-css="session-guard-client"]') !== null) return
25
+ const tag = document.createElement('style')
26
+ tag.dataset.plugin = 'dsh-session-guard'
27
+ tag.dataset.pluginCss = 'session-guard-client'
28
+ tag.textContent = CLIENT_CSS
29
+ document.head.appendChild(tag)
30
+ }
package/src/detect.js CHANGED
@@ -1,27 +1,27 @@
1
- /**
2
- * dsh-session-guard — 自动检测(D7,诚实边界)。
3
- *
4
- * - host 可自动探测:`ctx.get('taskControl')`(会话门)、`ctx.get('sessionGuard')`。
5
- * - client 可自动探测:input-traffic 补的「最小桥」标记 `window.__DSH_SESSION_GUARD_BRIDGE__`
6
- * (方案 A 中 input-traffic 冻结按钮触发时会调 sessionGuard.stopNextTurn)。
7
- * 探测不到 → 本插件客户端使用自带回退冻结(fallback),fail-open 不报错(D8)。
8
- */
9
-
10
- /** host:会话门(dsh-task-control)是否可用。 */
11
- export function detectTaskControl(ctx) {
12
- return !!(ctx && typeof ctx.get === 'function' && ctx.get('taskControl'))
13
- }
14
-
15
- /** host:sessionGuard 服务是否已提供(其他插件探测用)。 */
16
- export function detectSessionGuard(ctx) {
17
- return !!(ctx && typeof ctx.get === 'function' && ctx.get('sessionGuard'))
18
- }
19
-
20
- /**
21
- * client:input-traffic 最小桥是否已注册。
22
- * @param {object} [g] 显式传入全局对象(便于测试);默认 globalThis
23
- */
24
- export function detectInputTrafficBridge(g) {
25
- const root = g || (typeof globalThis !== 'undefined' ? globalThis : {})
26
- return !!(root && root.__DSH_SESSION_GUARD_BRIDGE__ && typeof root.__DSH_SESSION_GUARD_BRIDGE__.stopNextTurn === 'function')
27
- }
1
+ /**
2
+ * dsh-session-guard — 自动检测(D7,诚实边界)。
3
+ *
4
+ * - host 可自动探测:`ctx.get('taskControl')`(会话门)、`ctx.get('sessionGuard')`。
5
+ * - client 可自动探测:input-traffic 补的「最小桥」标记 `window.__DSH_SESSION_GUARD_BRIDGE__`
6
+ * (方案 A 中 input-traffic 冻结按钮触发时会调 sessionGuard.stopNextTurn)。
7
+ * 探测不到 → 本插件客户端使用自带回退冻结(fallback),fail-open 不报错(D8)。
8
+ */
9
+
10
+ /** host:会话门(dsh-task-control)是否可用。 */
11
+ export function detectTaskControl(ctx) {
12
+ return !!(ctx && typeof ctx.get === 'function' && ctx.get('taskControl'))
13
+ }
14
+
15
+ /** host:sessionGuard 服务是否已提供(其他插件探测用)。 */
16
+ export function detectSessionGuard(ctx) {
17
+ return !!(ctx && typeof ctx.get === 'function' && ctx.get('sessionGuard'))
18
+ }
19
+
20
+ /**
21
+ * client:input-traffic 最小桥是否已注册。
22
+ * @param {object} [g] 显式传入全局对象(便于测试);默认 globalThis
23
+ */
24
+ export function detectInputTrafficBridge(g) {
25
+ const root = g || (typeof globalThis !== 'undefined' ? globalThis : {})
26
+ return !!(root && root.__DSH_SESSION_GUARD_BRIDGE__ && typeof root.__DSH_SESSION_GUARD_BRIDGE__.stopNextTurn === 'function')
27
+ }
package/src/gate.js CHANGED
@@ -1,93 +1,93 @@
1
- /**
2
- * dsh-session-guard — 会话门驱动(D3/D5/D6,自研真实锁定)。
3
- *
4
- * **主路径**(替换 dsh-task-control):经自研 `pauseGate` 真暂停 session 推进——
5
- * `pauseGate.pause(id,{mode,reason})` 调用 agent.cancel / goals.pause / session/event
6
- * 安全边界落地(见 src/pause-gate.js),不再依赖 `ctx.get('taskControl')`,也不再退化成
7
- * 只写 `queueLocked` 标记。
8
- *
9
- * **降级路径**(fail-open,D8):仅当自研暂停因 agent 不可用而失败时,按 `queueFallback`
10
- * 回退为只锁等待队列(`queueLocked` 持久化标记,供前端/重试让路)。
11
- *
12
- * resume 对称:自研 resume(从暂停点续跑)或清 queueLock。
13
- */
14
- import { idleState } from './store.js'
15
-
16
- /**
17
- * @param {object} deps
18
- * @param {()=>object|null} deps.getCtx 取当前 host ctx(懒,便于测试)
19
- * @param {()=>object} deps.getSettings 读实时配置(pauseMode/pauseReason)
20
- * @param {ReturnType<import('./store.js').createStore>} deps.store 持久化队列锁
21
- * @param {ReturnType<import('./pause-gate.js').createPauseGate>} [deps.pauseGate] 自研会话门引擎
22
- * @param {(event:string, sessionId:string, payload?:object)=>void} [deps.emit] 状态变化通知
23
- */
24
- export function createGate({ getCtx: _getCtx, getSettings, store, pauseGate, emit }) {
25
- function lockQueue(sessionId, reason) {
26
- const cur = store.get(sessionId)
27
- const next = { ...(cur || idleState(sessionId)), queueLocked: true, lockReason: reason, updatedAt: Date.now() }
28
- store.set(sessionId, next)
29
- if (emit) emit('queue-lock', sessionId, { reason })
30
- return next
31
- }
32
-
33
- function unlockQueue(sessionId) {
34
- const cur = store.get(sessionId)
35
- const next = { ...(cur || idleState(sessionId)), queueLocked: false, lockReason: null, updatedAt: Date.now() }
36
- store.set(sessionId, next)
37
- if (emit) emit('queue-unlock', sessionId)
38
- return next
39
- }
40
-
41
- /**
42
- * 停掉 session 的下一回合(真实锁定推进)。
43
- * ① 自研 pauseGate.pause(真暂停);② 自研失败/agent 不可用 → 按 queueFallback 降级锁队列。
44
- * @param {string} sessionId
45
- * @param {object} [opts] { mode, reason }
46
- */
47
- async function stopNextTurn(sessionId, opts = {}) {
48
- const cfg = getSettings()
49
- if (pauseGate) {
50
- try {
51
- const mode = opts.mode ?? cfg.pauseMode ?? 'safe'
52
- const reason = opts.reason ?? cfg.pauseReason ?? 'wait'
53
- const r = pauseGate.pause(sessionId, { mode, reason })
54
- if (r && r.kind === 'success') return { ok: true, via: 'pauseGate', result: r }
55
- // kind === 'error'(如 no live agent)→ 落到降级,语义如实上报。
56
- } catch {
57
- // 自研内部异常 → fail-open 降级,绝不中断冻结动作。
58
- }
59
- }
60
- // 无自研门或自研失败 → 回退只锁等待队列(自带实现,D5)。
61
- if (cfg.queueFallback === false) {
62
- return { ok: false, via: 'queueLock', error: 'queueFallback disabled' }
63
- }
64
- lockQueue(sessionId, 'peak')
65
- return { ok: true, via: 'queueLock' }
66
- }
67
-
68
- /**
69
- * 恢复 session 推进(从暂停点续跑)。
70
- * ① 自研 resume(confirm + choice);② 降级清 queueLock。
71
- */
72
- async function resume(sessionId, opts = {}) {
73
- if (pauseGate) {
74
- try {
75
- const r = pauseGate.resume(sessionId, { confirm: true, choice: opts.choice ?? 'rerun' })
76
- if (r && r.kind === 'success') return { ok: true, via: 'pauseGate', result: r }
77
- } catch {
78
- // 降级清队列锁。
79
- }
80
- }
81
- unlockQueue(sessionId)
82
- return { ok: true, via: 'queueLock' }
83
- }
84
-
85
- return {
86
- stopNextTurn,
87
- resume,
88
- lockQueue,
89
- unlockQueue,
90
- /** 自研会话门是否可用(自研后恒视为可用;供 detect/UI 展示)。 */
91
- taskControlAvailable: () => (pauseGate ? pauseGate.taskControlAvailable() : false),
92
- }
93
- }
1
+ /**
2
+ * dsh-session-guard — 会话门驱动(D3/D5/D6,自研真实锁定)。
3
+ *
4
+ * **主路径**(替换 dsh-task-control):经自研 `pauseGate` 真暂停 session 推进——
5
+ * `pauseGate.pause(id,{mode,reason})` 调用 agent.cancel / goals.pause / session/event
6
+ * 安全边界落地(见 src/pause-gate.js),不再依赖 `ctx.get('taskControl')`,也不再退化成
7
+ * 只写 `queueLocked` 标记。
8
+ *
9
+ * **降级路径**(fail-open,D8):仅当自研暂停因 agent 不可用而失败时,按 `queueFallback`
10
+ * 回退为只锁等待队列(`queueLocked` 持久化标记,供前端/重试让路)。
11
+ *
12
+ * resume 对称:自研 resume(从暂停点续跑)或清 queueLock。
13
+ */
14
+ import { idleState } from './store.js'
15
+
16
+ /**
17
+ * @param {object} deps
18
+ * @param {()=>object|null} deps.getCtx 取当前 host ctx(懒,便于测试)
19
+ * @param {()=>object} deps.getSettings 读实时配置(pauseMode/pauseReason)
20
+ * @param {ReturnType<import('./store.js').createStore>} deps.store 持久化队列锁
21
+ * @param {ReturnType<import('./pause-gate.js').createPauseGate>} [deps.pauseGate] 自研会话门引擎
22
+ * @param {(event:string, sessionId:string, payload?:object)=>void} [deps.emit] 状态变化通知
23
+ */
24
+ export function createGate({ getCtx: _getCtx, getSettings, store, pauseGate, emit }) {
25
+ function lockQueue(sessionId, reason) {
26
+ const cur = store.get(sessionId)
27
+ const next = { ...(cur || idleState(sessionId)), queueLocked: true, lockReason: reason, updatedAt: Date.now() }
28
+ store.set(sessionId, next)
29
+ if (emit) emit('queue-lock', sessionId, { reason })
30
+ return next
31
+ }
32
+
33
+ function unlockQueue(sessionId) {
34
+ const cur = store.get(sessionId)
35
+ const next = { ...(cur || idleState(sessionId)), queueLocked: false, lockReason: null, updatedAt: Date.now() }
36
+ store.set(sessionId, next)
37
+ if (emit) emit('queue-unlock', sessionId)
38
+ return next
39
+ }
40
+
41
+ /**
42
+ * 停掉 session 的下一回合(真实锁定推进)。
43
+ * ① 自研 pauseGate.pause(真暂停);② 自研失败/agent 不可用 → 按 queueFallback 降级锁队列。
44
+ * @param {string} sessionId
45
+ * @param {object} [opts] { mode, reason }
46
+ */
47
+ async function stopNextTurn(sessionId, opts = {}) {
48
+ const cfg = getSettings()
49
+ if (pauseGate) {
50
+ try {
51
+ const mode = opts.mode ?? cfg.pauseMode ?? 'safe'
52
+ const reason = opts.reason ?? cfg.pauseReason ?? 'wait'
53
+ const r = pauseGate.pause(sessionId, { mode, reason })
54
+ if (r && r.kind === 'success') return { ok: true, via: 'pauseGate', result: r }
55
+ // kind === 'error'(如 no live agent)→ 落到降级,语义如实上报。
56
+ } catch {
57
+ // 自研内部异常 → fail-open 降级,绝不中断冻结动作。
58
+ }
59
+ }
60
+ // 无自研门或自研失败 → 回退只锁等待队列(自带实现,D5)。
61
+ if (cfg.queueFallback === false) {
62
+ return { ok: false, via: 'queueLock', error: 'queueFallback disabled' }
63
+ }
64
+ lockQueue(sessionId, 'peak')
65
+ return { ok: true, via: 'queueLock' }
66
+ }
67
+
68
+ /**
69
+ * 恢复 session 推进(从暂停点续跑)。
70
+ * ① 自研 resume(confirm + choice);② 降级清 queueLock。
71
+ */
72
+ async function resume(sessionId, opts = {}) {
73
+ if (pauseGate) {
74
+ try {
75
+ const r = pauseGate.resume(sessionId, { confirm: true, choice: opts.choice ?? 'rerun' })
76
+ if (r && r.kind === 'success') return { ok: true, via: 'pauseGate', result: r }
77
+ } catch {
78
+ // 降级清队列锁。
79
+ }
80
+ }
81
+ unlockQueue(sessionId)
82
+ return { ok: true, via: 'queueLock' }
83
+ }
84
+
85
+ return {
86
+ stopNextTurn,
87
+ resume,
88
+ lockQueue,
89
+ unlockQueue,
90
+ /** 自研会话门是否可用(自研后恒视为可用;供 detect/UI 展示)。 */
91
+ taskControlAvailable: () => (pauseGate ? pauseGate.taskControlAvailable() : false),
92
+ }
93
+ }