dsh-session-guard 3.2.3 → 4.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 +21 -0
- package/CHANGELOG.ko.md +21 -0
- package/CHANGELOG.md +21 -0
- package/LICENSE +21 -21
- package/README.md +14 -5
- package/cordis.patch.yml +10 -10
- package/dsh.plugin.json +16 -16
- package/lib/client.js.map +1 -1
- package/package.json +8 -8
- package/src/bridge.js +136 -136
- package/src/client/family-section.tsx +46 -46
- package/src/client/index.ts +135 -135
- package/src/client/pause-button-text.ts +60 -60
- package/src/client/pause-button.tsx +129 -129
- package/src/client/scope-face.ts +20 -20
- package/src/client/settings-card.tsx +391 -391
- package/src/client/styles.ts +30 -30
- package/src/detect.js +27 -27
- package/src/gate.js +93 -93
- package/src/pause-gate.js +30 -10
- package/src/pause-store.js +101 -101
- package/src/retry.js +1 -1
- package/src/scheduler.js +42 -42
- package/src/step-gate.js +399 -399
- package/src/store.js +77 -77
- package/src/tool-call-id.js +15 -11
- package/src/wiring.js +1 -1
package/src/client/styles.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/pause-gate.js
CHANGED
|
@@ -17,9 +17,10 @@
|
|
|
17
17
|
* 设计为目标可单测:ctx / pauseStore / makeFollowupMessage 全部依赖注入,
|
|
18
18
|
* 停/续/取消判定不依赖真实 dsh runtime;`getAgent(sessionId)` 经 `ctx.agents.get` 懒取。
|
|
19
19
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* VERSION-LINE(2026-09-27 订正):本仓 engines.dsh >=0.1.7-rc.1(会话格式 v4)。
|
|
21
|
+
* 下述扩展点的 0.1.1-rc.2/0.1.2-rc.1 双版本签名核对结论为历史依据,现役契约以
|
|
22
|
+
* v4 为准(署名 producer-owned kind、tool/result 为一等 role:'tool' 消息,
|
|
23
|
+
* 见 `tool-call-id.js` 头注的三形状代际表)。
|
|
23
24
|
* 唯一需要双读的是 `tool/result` 记录的调用 id 形态(见 readToolResultCallId):
|
|
24
25
|
* 0.1.1 与 0.1.2 的回放日志都同时保留 `content[].toolCallId` 与 `source.callId` 两种形态。
|
|
25
26
|
*/
|
|
@@ -47,7 +48,7 @@ export function createPluginUserMessage({ content, source }) {
|
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
/**
|
|
50
|
-
*
|
|
51
|
+
* 恢复消息入队(双路径;双路径判定沿自 0.1.5 代的兼容实测,现役线为 v4)。
|
|
51
52
|
*
|
|
52
53
|
* 0.1.5 把 Inbox 从独立服务改为 agent-loop 只读投影(消息入队走 session 事件),
|
|
53
54
|
* `agent.followup` 是否保留无法静态确认,因此运行时检测:
|
|
@@ -157,9 +158,10 @@ export function createPauseGate({ ctx, pauseStore, pluginId = 'session-guard', m
|
|
|
157
158
|
}
|
|
158
159
|
|
|
159
160
|
/**
|
|
160
|
-
* `tool/result` 调用 id
|
|
161
|
-
*
|
|
162
|
-
*
|
|
161
|
+
* `tool/result` 调用 id 的多形态读取已抽到 `./tool-call-id.js`(零依赖、可单测),
|
|
162
|
+
* 三形状代际(详见其头注):v4 native 顶层 `message.toolCallId` 优先 →
|
|
163
|
+
* v3 wrapper `content[].toolCallId` → 遗留 `source.callId`。勿改成单读——
|
|
164
|
+
* 任一单读都会在其它代际的记录上丢 id(in-flight 工具无法落地/暂停点判定失效)。
|
|
163
165
|
*/
|
|
164
166
|
|
|
165
167
|
/**
|
|
@@ -189,6 +191,22 @@ export function createPauseGate({ ctx, pauseStore, pluginId = 'session-guard', m
|
|
|
189
191
|
for (const event of events) {
|
|
190
192
|
if (event.type === 'tool/result') {
|
|
191
193
|
const message = event.data?.message ?? {}
|
|
194
|
+
// v4 native(≥0.1.7-rc.1):一等 role:'tool' 消息,toolCallId/isError 在
|
|
195
|
+
// message 顶层(宿主 assertBlock 硬拒 `type:'tool-result'` 块)。native
|
|
196
|
+
// 分支是强制新增——漏掉它 isError 恒 false,恢复 followup 会把失败工具
|
|
197
|
+
// 误报为"已执行完成,不要重复执行"。
|
|
198
|
+
if (typeof message.toolCallId === 'string') {
|
|
199
|
+
if (message.toolCallId === callId) {
|
|
200
|
+
outcome = {
|
|
201
|
+
hasResult: true,
|
|
202
|
+
isError: message.isError === true,
|
|
203
|
+
abortedBeforeDispatch: event.data?.error?.code === 'ABORTED_BEFORE_DISPATCH',
|
|
204
|
+
content: Array.isArray(message.content) ? message.content : [],
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
// v3 wrapper(未迁移日志的历史兼容)。
|
|
192
210
|
const block = (Array.isArray(message.content) ? message.content : []).find((b) => b?.type === 'tool-result')
|
|
193
211
|
const id = readToolResultCallId(message)
|
|
194
212
|
if (id === callId) {
|
|
@@ -337,8 +355,9 @@ export function createPauseGate({ ctx, pauseStore, pluginId = 'session-guard', m
|
|
|
337
355
|
state.pendingPause.delete(sessionId)
|
|
338
356
|
const current = currentPause(sessionId)
|
|
339
357
|
if (!current.paused) return { kind: 'success', text: 'no paused task to resume' }
|
|
340
|
-
//
|
|
341
|
-
|
|
358
|
+
// form: 'instructions' —— 0.1.5 引入的 ContextFormed 语义字段(旧版本忽略未知字段);
|
|
359
|
+
// 署名 kind 已按 v4(≥0.1.7-rc.1)适配为 producer-owned `plugin:${pluginId}`
|
|
360
|
+
const followup = (blocks) => enqueueFollowup(agent, makeFollowupMessage({ content: blocks, source: { kind: `plugin:${pluginId}`, form: 'instructions' } }), ctx)
|
|
342
361
|
|
|
343
362
|
if (current.forced) {
|
|
344
363
|
const tool = current.interruptedTool
|
|
@@ -462,7 +481,8 @@ export function createPauseGate({ ctx, pauseStore, pluginId = 'session-guard', m
|
|
|
462
481
|
return
|
|
463
482
|
}
|
|
464
483
|
if (event.type === 'tool/result') {
|
|
465
|
-
//
|
|
484
|
+
// 三形状双读以上(见 readToolResultCallId):v4 native 顶层 toolCallId 优先,
|
|
485
|
+
// v3 wrapper 块上次之,旧记录再回退 source.callId。
|
|
466
486
|
const callId = readToolResultCallId(event.data?.message)
|
|
467
487
|
if (typeof callId === 'string') inflightOf(sessionId).delete(callId)
|
|
468
488
|
tryApplyPending(sessionId)
|
package/src/pause-store.js
CHANGED
|
@@ -1,101 +1,101 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-session-guard — 自研会话门暂停状态持久化(脱离 dsh-task-control)。
|
|
3
|
-
*
|
|
4
|
-
* 与 queueLock(src/store.js,队列锁)分离:这里是「真暂停」的持久化快照,
|
|
5
|
-
* 仿 task-control 的状态模型,但独立子目录 /pause 避免与队列锁文件互相污染。
|
|
6
|
-
*
|
|
7
|
-
* 暂停/锁定状态仍**不写 session log**(harness 持久化 reader 只有已知事件集,
|
|
8
|
-
* 自定义 `session-guard/*` 事件会导致重启后会话无法加载),走插件自有 JSON 文件:
|
|
9
|
-
* `$DSH_HOME/.dsh/session-guard/pause/<sessionId>.json`(`DSH_SESSION_GUARD_PAUSE_DIR`
|
|
10
|
-
* 可覆盖根目录)。原子写:tmp + rename。
|
|
11
|
-
*/
|
|
12
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
13
|
-
import { dirname, join } from 'node:path'
|
|
14
|
-
import { storeRoot } from './store.js'
|
|
15
|
-
|
|
16
|
-
/** 会话 id → 安全文件名(与队列锁同规则)。 */
|
|
17
|
-
export function encodeSessionId(id) {
|
|
18
|
-
return String(id).replace(/[^a-zA-Z0-9._-]/g, '_')
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/** 自研暂停状态根目录(独立于队列锁)。 */
|
|
22
|
-
export function pauseStateRoot() {
|
|
23
|
-
return (
|
|
24
|
-
process.env.DSH_SESSION_GUARD_PAUSE_DIR ||
|
|
25
|
-
join(storeRoot(), 'pause')
|
|
26
|
-
)
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** 每会话暂停状态文件路径。 */
|
|
30
|
-
export function pauseFilePath(id) {
|
|
31
|
-
return join(pauseStateRoot(), `${encodeSessionId(id)}.json`)
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** 未暂停基线快照。 */
|
|
35
|
-
export function idlePauseState(id) {
|
|
36
|
-
return {
|
|
37
|
-
sessionId: String(id),
|
|
38
|
-
paused: false,
|
|
39
|
-
resumeContent: null,
|
|
40
|
-
forced: false,
|
|
41
|
-
interruptedTool: null,
|
|
42
|
-
deferredTools: null,
|
|
43
|
-
updatedAt: null,
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* 创建暂停状态存储(内存缓存 + 磁盘持久化,原子写)。
|
|
49
|
-
* 独立于队列锁 store,字段形态仿 task-control(paused/forced/interruptedTool/deferredTools)。
|
|
50
|
-
*/
|
|
51
|
-
export function createPauseStore() {
|
|
52
|
-
const cache = new Map()
|
|
53
|
-
|
|
54
|
-
function read(id) {
|
|
55
|
-
if (cache.has(id)) return cache.get(id)
|
|
56
|
-
try {
|
|
57
|
-
const f = pauseFilePath(id)
|
|
58
|
-
if (!existsSync(f)) return null
|
|
59
|
-
const v = JSON.parse(readFileSync(f, 'utf8'))
|
|
60
|
-
cache.set(id, v)
|
|
61
|
-
return v
|
|
62
|
-
} catch {
|
|
63
|
-
return null
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
return {
|
|
68
|
-
/** 读一会话暂停态;无记录返回 null(调用方 fallback idlePauseState)。 */
|
|
69
|
-
get(id) {
|
|
70
|
-
return read(id)
|
|
71
|
-
},
|
|
72
|
-
/** 原子写。 */
|
|
73
|
-
set(id, value) {
|
|
74
|
-
cache.set(id, value)
|
|
75
|
-
try {
|
|
76
|
-
const f = pauseFilePath(id)
|
|
77
|
-
mkdirSync(dirname(f), { recursive: true })
|
|
78
|
-
const tmp = `${f}.tmp`
|
|
79
|
-
writeFileSync(tmp, JSON.stringify(value, null, 2))
|
|
80
|
-
renameSync(tmp, f)
|
|
81
|
-
} catch (e) {
|
|
82
|
-
// 落盘失败仅影响重启恢复,内存态仍可用。
|
|
83
|
-
console.error(`[session-guard] pause store write failed: ${String(e && e.message || e)}`)
|
|
84
|
-
}
|
|
85
|
-
},
|
|
86
|
-
/** 清除(resume/cancel 后)。 */
|
|
87
|
-
clear(id) {
|
|
88
|
-
cache.delete(id)
|
|
89
|
-
try {
|
|
90
|
-
rmSync(pauseFilePath(id), { force: true })
|
|
91
|
-
} catch {
|
|
92
|
-
/* ignore */
|
|
93
|
-
}
|
|
94
|
-
},
|
|
95
|
-
/** 当前有效快照(无记录时 idle 基线)。 */
|
|
96
|
-
current(id) {
|
|
97
|
-
const v = read(id)
|
|
98
|
-
return v !== null && typeof v === 'object' ? v : idlePauseState(id)
|
|
99
|
-
},
|
|
100
|
-
}
|
|
101
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* dsh-session-guard — 自研会话门暂停状态持久化(脱离 dsh-task-control)。
|
|
3
|
+
*
|
|
4
|
+
* 与 queueLock(src/store.js,队列锁)分离:这里是「真暂停」的持久化快照,
|
|
5
|
+
* 仿 task-control 的状态模型,但独立子目录 /pause 避免与队列锁文件互相污染。
|
|
6
|
+
*
|
|
7
|
+
* 暂停/锁定状态仍**不写 session log**(harness 持久化 reader 只有已知事件集,
|
|
8
|
+
* 自定义 `session-guard/*` 事件会导致重启后会话无法加载),走插件自有 JSON 文件:
|
|
9
|
+
* `$DSH_HOME/.dsh/session-guard/pause/<sessionId>.json`(`DSH_SESSION_GUARD_PAUSE_DIR`
|
|
10
|
+
* 可覆盖根目录)。原子写:tmp + rename。
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
13
|
+
import { dirname, join } from 'node:path'
|
|
14
|
+
import { storeRoot } from './store.js'
|
|
15
|
+
|
|
16
|
+
/** 会话 id → 安全文件名(与队列锁同规则)。 */
|
|
17
|
+
export function encodeSessionId(id) {
|
|
18
|
+
return String(id).replace(/[^a-zA-Z0-9._-]/g, '_')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 自研暂停状态根目录(独立于队列锁)。 */
|
|
22
|
+
export function pauseStateRoot() {
|
|
23
|
+
return (
|
|
24
|
+
process.env.DSH_SESSION_GUARD_PAUSE_DIR ||
|
|
25
|
+
join(storeRoot(), 'pause')
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 每会话暂停状态文件路径。 */
|
|
30
|
+
export function pauseFilePath(id) {
|
|
31
|
+
return join(pauseStateRoot(), `${encodeSessionId(id)}.json`)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 未暂停基线快照。 */
|
|
35
|
+
export function idlePauseState(id) {
|
|
36
|
+
return {
|
|
37
|
+
sessionId: String(id),
|
|
38
|
+
paused: false,
|
|
39
|
+
resumeContent: null,
|
|
40
|
+
forced: false,
|
|
41
|
+
interruptedTool: null,
|
|
42
|
+
deferredTools: null,
|
|
43
|
+
updatedAt: null,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 创建暂停状态存储(内存缓存 + 磁盘持久化,原子写)。
|
|
49
|
+
* 独立于队列锁 store,字段形态仿 task-control(paused/forced/interruptedTool/deferredTools)。
|
|
50
|
+
*/
|
|
51
|
+
export function createPauseStore() {
|
|
52
|
+
const cache = new Map()
|
|
53
|
+
|
|
54
|
+
function read(id) {
|
|
55
|
+
if (cache.has(id)) return cache.get(id)
|
|
56
|
+
try {
|
|
57
|
+
const f = pauseFilePath(id)
|
|
58
|
+
if (!existsSync(f)) return null
|
|
59
|
+
const v = JSON.parse(readFileSync(f, 'utf8'))
|
|
60
|
+
cache.set(id, v)
|
|
61
|
+
return v
|
|
62
|
+
} catch {
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
/** 读一会话暂停态;无记录返回 null(调用方 fallback idlePauseState)。 */
|
|
69
|
+
get(id) {
|
|
70
|
+
return read(id)
|
|
71
|
+
},
|
|
72
|
+
/** 原子写。 */
|
|
73
|
+
set(id, value) {
|
|
74
|
+
cache.set(id, value)
|
|
75
|
+
try {
|
|
76
|
+
const f = pauseFilePath(id)
|
|
77
|
+
mkdirSync(dirname(f), { recursive: true })
|
|
78
|
+
const tmp = `${f}.tmp`
|
|
79
|
+
writeFileSync(tmp, JSON.stringify(value, null, 2))
|
|
80
|
+
renameSync(tmp, f)
|
|
81
|
+
} catch (e) {
|
|
82
|
+
// 落盘失败仅影响重启恢复,内存态仍可用。
|
|
83
|
+
console.error(`[session-guard] pause store write failed: ${String(e && e.message || e)}`)
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
/** 清除(resume/cancel 后)。 */
|
|
87
|
+
clear(id) {
|
|
88
|
+
cache.delete(id)
|
|
89
|
+
try {
|
|
90
|
+
rmSync(pauseFilePath(id), { force: true })
|
|
91
|
+
} catch {
|
|
92
|
+
/* ignore */
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
/** 当前有效快照(无记录时 idle 基线)。 */
|
|
96
|
+
current(id) {
|
|
97
|
+
const v = read(id)
|
|
98
|
+
return v !== null && typeof v === 'object' ? v : idlePauseState(id)
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
}
|
package/src/retry.js
CHANGED
|
@@ -160,7 +160,7 @@ export function createRetry({ ctx, getSettings, isFrozen, send }) {
|
|
|
160
160
|
id: `session-guard-retry-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
161
161
|
role: 'user',
|
|
162
162
|
content: [{ type: 'text', text }],
|
|
163
|
-
source: { kind: 'plugin
|
|
163
|
+
source: { kind: 'plugin:session-guard', form: 'notice' },
|
|
164
164
|
})
|
|
165
165
|
}
|
|
166
166
|
s.lastAttemptAt = Date.now()
|