dsh-session-guard 4.0.2 → 4.1.5
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/LICENSE +21 -21
- package/README.de.md +375 -375
- package/README.es.md +374 -374
- package/README.fr.md +373 -373
- package/README.it.md +374 -374
- package/README.ru.md +374 -374
- package/cordis.patch.yml +10 -10
- package/dsh.plugin.json +16 -16
- package/lib/client.js +179 -37
- package/lib/client.js.map +1 -1
- package/package.json +9 -11
- package/src/bridge.js +136 -136
- package/src/client/index.ts +125 -47
- 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/shell-overlay.tsx +69 -0
- package/src/client/status-badge.tsx +17 -0
- package/src/client/styles.ts +30 -30
- package/src/detect.js +27 -27
- package/src/gate.js +93 -93
- package/src/index.js +36 -1
- package/src/pause-gate.js +544 -544
- package/src/pause-store.js +101 -101
- package/src/retry.js +227 -227
- package/src/scheduler.js +42 -42
- package/src/step-gate.js +399 -399
- package/src/store.js +77 -77
- package/src/wiring.js +305 -305
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
|
@@ -1,227 +1,227 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-session-guard — 后端自动重试(host side,D9)。
|
|
3
|
-
*
|
|
4
|
-
* 监听 session/event:turn/end 以 error / interrupted / max-tokens 结束时,
|
|
5
|
-
* 分类瞬时/永久失败,自适应退避后自动 `agent.followup(retryText)` 续跑。
|
|
6
|
-
*
|
|
7
|
-
* 关键纪律:
|
|
8
|
-
* - **冻结让路**:会话被本插件门控(高峰暂停 / 队列锁)时不触发重试——
|
|
9
|
-
* 与 input-traffic「freeze 是一等公民」一致,自动重试不得绕过会话门。
|
|
10
|
-
* - 用户手动介入(user/message)与成功回合重置连续计数。
|
|
11
|
-
* - 子代理会话不重试(由父代理处理)。
|
|
12
|
-
* - 永久失败(鉴权/余额/模型不存在/上下文超限等)重试无益 → 停止并告警。
|
|
13
|
-
*
|
|
14
|
-
* 纯决策逻辑(classifyTurnEnd / isTransientFailure / effectiveCooldown /
|
|
15
|
-
* shouldRetry)零依赖可单测;事件接线在 createRetry。
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
/** 默认重试配置。 */
|
|
19
|
-
export const DEFAULT_RETRY = Object.freeze({
|
|
20
|
-
retryEnabled: false, // ← 自动重试开关(默认关,保守)
|
|
21
|
-
retryText: '继续(自动重试)',
|
|
22
|
-
retryGraceMs: 3000, // 失败后等待多久再发
|
|
23
|
-
retryCooldownMs: 20000, // 同一会话两次重试最小间隔
|
|
24
|
-
retryBackoffFactor: 2,
|
|
25
|
-
retryBackoffMaxMs: 300000,
|
|
26
|
-
retryMaxConsecutive: 3, // 连续重试上限,超过停止
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
/** 本插件自己的延后失败哨兵(与 deferrals.js 的 PEAK_DEFERRED_CODE 同一个值)。 */
|
|
30
|
-
export const PEAK_DEFERRED_CODE = 'PEAK_DEFERRED'
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* 是否本插件的「高峰延后」失败(**精确匹配,唯一短路**)。
|
|
34
|
-
*
|
|
35
|
-
* 两条路径都必须认:
|
|
36
|
-
* - `code === 'PEAK_DEFERRED'`:结构化路径(若上层保留了 failure.code);
|
|
37
|
-
* - `message` 以 `PEAK_DEFERRED:` 开头:DSH 回合循环只对 `error instanceof LlmError`
|
|
38
|
-
* 保留结构化 failure,其它一律压成 `{ message: errorChain(error), code: 'UNKNOWN' }`
|
|
39
|
-
* (见 findings 8.3),本插件不能 value-import `@deepseek-ai/dsh-llm`。
|
|
40
|
-
*
|
|
41
|
-
* **严禁**在这里加 `高峰` / `已延后` / `拦截` 之类宽泛关键词——那会误伤真实错误,
|
|
42
|
-
* 也会破坏 429 / 传输层的重试语义。
|
|
43
|
-
*/
|
|
44
|
-
export function isPeakDeferredFailure({ code, message } = {}) {
|
|
45
|
-
if (code === PEAK_DEFERRED_CODE) return true
|
|
46
|
-
return typeof message === 'string' && message.startsWith(`${PEAK_DEFERRED_CODE}:`)
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** 瞬时 vs 永久失败分类:瞬时值得重试,永久重试无益。 */
|
|
50
|
-
export function isTransientFailure({ code, message, status } = {}) {
|
|
51
|
-
// 本插件自己的延后失败是「永久」——重试只会再被拦一次,形成放大。
|
|
52
|
-
if (isPeakDeferredFailure({ code, message })) return false
|
|
53
|
-
const haystack = `${code ?? ''} ${message ?? ''}`.toLowerCase()
|
|
54
|
-
if (status !== undefined && (status === 401 || status === 403)) return false
|
|
55
|
-
const permanent =
|
|
56
|
-
/auth|unauthor|forbidden|credential|api\s*[_-]?\s*key|permission/i.test(haystack) ||
|
|
57
|
-
/insufficient.*(balance|quota)|billing|payment/i.test(haystack) ||
|
|
58
|
-
/model[^a-z]*not[^a-z]*found|unknown[_-]?model|not.*support.*model/i.test(haystack) ||
|
|
59
|
-
/context.*(length|limit|overflow|exceed)|token.*limit|max.*context/i.test(haystack) ||
|
|
60
|
-
/invalid[_-]?request|bad[_-]?request/i.test(haystack)
|
|
61
|
-
return !permanent
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* turn/end reason → 是否可自动重试。
|
|
66
|
-
* - completed / aborted(用户停)/ blocked(策略拒)→ 否
|
|
67
|
-
* - error → 先按精确码短路 `PEAK_DEFERRED`(永久),再按 isTransientFailure 分类
|
|
68
|
-
* - interrupted(崩溃修复)→ 可重试
|
|
69
|
-
* - max-tokens → 可重试
|
|
70
|
-
*/
|
|
71
|
-
export function classifyTurnEnd(reason, failure) {
|
|
72
|
-
const kind = reason && reason.kind
|
|
73
|
-
if (kind === 'completed' || kind === 'aborted' || kind === 'blocked') return false
|
|
74
|
-
if (kind === 'error') {
|
|
75
|
-
if (isPeakDeferredFailure(failure)) return false
|
|
76
|
-
return isTransientFailure(failure)
|
|
77
|
-
}
|
|
78
|
-
if (kind === 'interrupted' || kind === 'max-tokens') return true
|
|
79
|
-
return false
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** 自适应退避:consecutive 次连续后 cooldown * factor^n,封顶 max。 */
|
|
83
|
-
export function effectiveCooldown(consecutive, base, factor, max) {
|
|
84
|
-
const mult = Math.pow(factor, Math.max(0, consecutive))
|
|
85
|
-
return Math.min(Math.max(base, base * mult), Math.max(base, max))
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* 纯决策:此刻是否应触发重试。
|
|
90
|
-
* @param {object} s 会话状态 { consecutive, lastAttemptAt, pending }
|
|
91
|
-
* @param {object} cfg 重试配置
|
|
92
|
-
* @param {boolean} frozen 会话是否被门控(高峰暂停/队列锁)
|
|
93
|
-
* @param {number} now
|
|
94
|
-
*/
|
|
95
|
-
export function shouldRetry(s, cfg, frozen, now = Date.now()) {
|
|
96
|
-
if (!cfg.retryEnabled) return false
|
|
97
|
-
if (frozen) return false // 冻结让路(D9)
|
|
98
|
-
if (s.pending) return false // 已有排队重试
|
|
99
|
-
if (s.consecutive >= cfg.retryMaxConsecutive) return false
|
|
100
|
-
if (now - s.lastAttemptAt < effectiveCooldown(s.consecutive, cfg.retryCooldownMs, cfg.retryBackoffFactor, cfg.retryBackoffMaxMs)) return false
|
|
101
|
-
return true
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/** 会话状态工厂。 */
|
|
105
|
-
export function freshRetryState() {
|
|
106
|
-
return { consecutive: 0, lastAttemptAt: 0, pending: false }
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* 事件接线(host):
|
|
111
|
-
* @param {object} deps
|
|
112
|
-
* @param {object} deps.ctx host context
|
|
113
|
-
* @param {()=>object} deps.getSettings 读实时配置
|
|
114
|
-
* @param {(sessionId:string)=>boolean} deps.isFrozen 会话是否被门控
|
|
115
|
-
* @param {(sessionId:string, text:string)=>void} [deps.send] 发送函数(默认 agent.followup)
|
|
116
|
-
*/
|
|
117
|
-
export function createRetry({ ctx, getSettings, isFrozen, send }) {
|
|
118
|
-
const states = new Map()
|
|
119
|
-
|
|
120
|
-
function state(sessionId) {
|
|
121
|
-
let s = states.get(sessionId)
|
|
122
|
-
if (!s) {
|
|
123
|
-
s = freshRetryState()
|
|
124
|
-
states.set(sessionId, s)
|
|
125
|
-
}
|
|
126
|
-
return s
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/** 从 turn/end reason 提取失败事实。 */
|
|
130
|
-
function failureFacts(reason) {
|
|
131
|
-
const error = reason && reason.error
|
|
132
|
-
return {
|
|
133
|
-
code: error && typeof error.code === 'string' ? error.code : 'UNKNOWN',
|
|
134
|
-
message: error && typeof error.message === 'string' ? error.message : '',
|
|
135
|
-
status: error && typeof error.status === 'number' ? error.status : undefined,
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function schedule(sessionId, reason) {
|
|
140
|
-
const s = state(sessionId)
|
|
141
|
-
const cfg = getSettings()
|
|
142
|
-
if (s.pending) return
|
|
143
|
-
const frozen = isFrozen(sessionId)
|
|
144
|
-
if (!shouldRetry({ ...s, pending: true }, cfg, frozen)) return
|
|
145
|
-
s.pending = true
|
|
146
|
-
const grace = cfg.retryGraceMs ?? DEFAULT_RETRY.retryGraceMs
|
|
147
|
-
const timer = setTimeout(() => {
|
|
148
|
-
s.pending = false
|
|
149
|
-
// 到点再复核:门控/上限变化后放弃。
|
|
150
|
-
if (!shouldRetry(s, cfg, isFrozen(sessionId))) return
|
|
151
|
-
const agent = ctx.agents.get(sessionId)
|
|
152
|
-
if (!agent || agent.status !== 'idle') return
|
|
153
|
-
const text = cfg.retryText ?? DEFAULT_RETRY.retryText
|
|
154
|
-
try {
|
|
155
|
-
if (send) {
|
|
156
|
-
send(sessionId, text)
|
|
157
|
-
} else {
|
|
158
|
-
// 与 autoresume 同构:直接构造 plugin-source 消息,避免运行时依赖。
|
|
159
|
-
agent.followup({
|
|
160
|
-
id: `session-guard-retry-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
161
|
-
role: 'user',
|
|
162
|
-
content: [{ type: 'text', text }],
|
|
163
|
-
source: { kind: 'plugin:session-guard', form: 'notice' },
|
|
164
|
-
})
|
|
165
|
-
}
|
|
166
|
-
s.lastAttemptAt = Date.now()
|
|
167
|
-
s.consecutive += 1
|
|
168
|
-
ctx.logger?.info?.(`[session-guard] auto-retry ${sessionId} (${String(reason && reason.kind)}), #${s.consecutive}`)
|
|
169
|
-
} catch (e) {
|
|
170
|
-
ctx.logger?.warn?.(`[session-guard] auto-retry send failed: ${String(e && e.message || e)}`)
|
|
171
|
-
}
|
|
172
|
-
}, grace)
|
|
173
|
-
// 会话对象上的清理钩子(若宿主提供)。
|
|
174
|
-
const dispose = () => clearTimeout(timer)
|
|
175
|
-
return dispose
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function onEvent(session, event) {
|
|
179
|
-
const sessionId = session && session.id
|
|
180
|
-
if (typeof sessionId !== 'string') return
|
|
181
|
-
const s = state(sessionId)
|
|
182
|
-
switch (event.type) {
|
|
183
|
-
case 'turn/end': {
|
|
184
|
-
const reason = event.data && event.data.reason
|
|
185
|
-
if (reason && reason.kind === 'completed') {
|
|
186
|
-
s.consecutive = 0 // 成功回合重置
|
|
187
|
-
s.pending = false
|
|
188
|
-
return
|
|
189
|
-
}
|
|
190
|
-
if (reason && reason.kind === 'aborted') {
|
|
191
|
-
// 用户主动停止:不重试,重置。
|
|
192
|
-
s.consecutive = 0
|
|
193
|
-
s.pending = false
|
|
194
|
-
return
|
|
195
|
-
}
|
|
196
|
-
if (reason && classifyTurnEnd(reason, failureFacts(reason))) {
|
|
197
|
-
schedule(sessionId, reason)
|
|
198
|
-
}
|
|
199
|
-
return
|
|
200
|
-
}
|
|
201
|
-
case 'user/message': {
|
|
202
|
-
// 用户手动介入:重置(无论是否我们的回显都保守重置)。
|
|
203
|
-
if (event.data && event.data.source && event.data.source.kind === 'user') {
|
|
204
|
-
s.consecutive = 0
|
|
205
|
-
s.pending = false
|
|
206
|
-
}
|
|
207
|
-
return
|
|
208
|
-
}
|
|
209
|
-
default:
|
|
210
|
-
return
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
ctx.on('session/event', (session, event) => {
|
|
215
|
-
try {
|
|
216
|
-
onEvent(session, event)
|
|
217
|
-
} catch (e) {
|
|
218
|
-
ctx.logger?.warn?.(`[session-guard] retry event failed: ${String(e && e.message || e)}`)
|
|
219
|
-
}
|
|
220
|
-
})
|
|
221
|
-
|
|
222
|
-
return {
|
|
223
|
-
onEvent,
|
|
224
|
-
state,
|
|
225
|
-
states,
|
|
226
|
-
}
|
|
227
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* dsh-session-guard — 后端自动重试(host side,D9)。
|
|
3
|
+
*
|
|
4
|
+
* 监听 session/event:turn/end 以 error / interrupted / max-tokens 结束时,
|
|
5
|
+
* 分类瞬时/永久失败,自适应退避后自动 `agent.followup(retryText)` 续跑。
|
|
6
|
+
*
|
|
7
|
+
* 关键纪律:
|
|
8
|
+
* - **冻结让路**:会话被本插件门控(高峰暂停 / 队列锁)时不触发重试——
|
|
9
|
+
* 与 input-traffic「freeze 是一等公民」一致,自动重试不得绕过会话门。
|
|
10
|
+
* - 用户手动介入(user/message)与成功回合重置连续计数。
|
|
11
|
+
* - 子代理会话不重试(由父代理处理)。
|
|
12
|
+
* - 永久失败(鉴权/余额/模型不存在/上下文超限等)重试无益 → 停止并告警。
|
|
13
|
+
*
|
|
14
|
+
* 纯决策逻辑(classifyTurnEnd / isTransientFailure / effectiveCooldown /
|
|
15
|
+
* shouldRetry)零依赖可单测;事件接线在 createRetry。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** 默认重试配置。 */
|
|
19
|
+
export const DEFAULT_RETRY = Object.freeze({
|
|
20
|
+
retryEnabled: false, // ← 自动重试开关(默认关,保守)
|
|
21
|
+
retryText: '继续(自动重试)',
|
|
22
|
+
retryGraceMs: 3000, // 失败后等待多久再发
|
|
23
|
+
retryCooldownMs: 20000, // 同一会话两次重试最小间隔
|
|
24
|
+
retryBackoffFactor: 2,
|
|
25
|
+
retryBackoffMaxMs: 300000,
|
|
26
|
+
retryMaxConsecutive: 3, // 连续重试上限,超过停止
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
/** 本插件自己的延后失败哨兵(与 deferrals.js 的 PEAK_DEFERRED_CODE 同一个值)。 */
|
|
30
|
+
export const PEAK_DEFERRED_CODE = 'PEAK_DEFERRED'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 是否本插件的「高峰延后」失败(**精确匹配,唯一短路**)。
|
|
34
|
+
*
|
|
35
|
+
* 两条路径都必须认:
|
|
36
|
+
* - `code === 'PEAK_DEFERRED'`:结构化路径(若上层保留了 failure.code);
|
|
37
|
+
* - `message` 以 `PEAK_DEFERRED:` 开头:DSH 回合循环只对 `error instanceof LlmError`
|
|
38
|
+
* 保留结构化 failure,其它一律压成 `{ message: errorChain(error), code: 'UNKNOWN' }`
|
|
39
|
+
* (见 findings 8.3),本插件不能 value-import `@deepseek-ai/dsh-llm`。
|
|
40
|
+
*
|
|
41
|
+
* **严禁**在这里加 `高峰` / `已延后` / `拦截` 之类宽泛关键词——那会误伤真实错误,
|
|
42
|
+
* 也会破坏 429 / 传输层的重试语义。
|
|
43
|
+
*/
|
|
44
|
+
export function isPeakDeferredFailure({ code, message } = {}) {
|
|
45
|
+
if (code === PEAK_DEFERRED_CODE) return true
|
|
46
|
+
return typeof message === 'string' && message.startsWith(`${PEAK_DEFERRED_CODE}:`)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 瞬时 vs 永久失败分类:瞬时值得重试,永久重试无益。 */
|
|
50
|
+
export function isTransientFailure({ code, message, status } = {}) {
|
|
51
|
+
// 本插件自己的延后失败是「永久」——重试只会再被拦一次,形成放大。
|
|
52
|
+
if (isPeakDeferredFailure({ code, message })) return false
|
|
53
|
+
const haystack = `${code ?? ''} ${message ?? ''}`.toLowerCase()
|
|
54
|
+
if (status !== undefined && (status === 401 || status === 403)) return false
|
|
55
|
+
const permanent =
|
|
56
|
+
/auth|unauthor|forbidden|credential|api\s*[_-]?\s*key|permission/i.test(haystack) ||
|
|
57
|
+
/insufficient.*(balance|quota)|billing|payment/i.test(haystack) ||
|
|
58
|
+
/model[^a-z]*not[^a-z]*found|unknown[_-]?model|not.*support.*model/i.test(haystack) ||
|
|
59
|
+
/context.*(length|limit|overflow|exceed)|token.*limit|max.*context/i.test(haystack) ||
|
|
60
|
+
/invalid[_-]?request|bad[_-]?request/i.test(haystack)
|
|
61
|
+
return !permanent
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* turn/end reason → 是否可自动重试。
|
|
66
|
+
* - completed / aborted(用户停)/ blocked(策略拒)→ 否
|
|
67
|
+
* - error → 先按精确码短路 `PEAK_DEFERRED`(永久),再按 isTransientFailure 分类
|
|
68
|
+
* - interrupted(崩溃修复)→ 可重试
|
|
69
|
+
* - max-tokens → 可重试
|
|
70
|
+
*/
|
|
71
|
+
export function classifyTurnEnd(reason, failure) {
|
|
72
|
+
const kind = reason && reason.kind
|
|
73
|
+
if (kind === 'completed' || kind === 'aborted' || kind === 'blocked') return false
|
|
74
|
+
if (kind === 'error') {
|
|
75
|
+
if (isPeakDeferredFailure(failure)) return false
|
|
76
|
+
return isTransientFailure(failure)
|
|
77
|
+
}
|
|
78
|
+
if (kind === 'interrupted' || kind === 'max-tokens') return true
|
|
79
|
+
return false
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 自适应退避:consecutive 次连续后 cooldown * factor^n,封顶 max。 */
|
|
83
|
+
export function effectiveCooldown(consecutive, base, factor, max) {
|
|
84
|
+
const mult = Math.pow(factor, Math.max(0, consecutive))
|
|
85
|
+
return Math.min(Math.max(base, base * mult), Math.max(base, max))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 纯决策:此刻是否应触发重试。
|
|
90
|
+
* @param {object} s 会话状态 { consecutive, lastAttemptAt, pending }
|
|
91
|
+
* @param {object} cfg 重试配置
|
|
92
|
+
* @param {boolean} frozen 会话是否被门控(高峰暂停/队列锁)
|
|
93
|
+
* @param {number} now
|
|
94
|
+
*/
|
|
95
|
+
export function shouldRetry(s, cfg, frozen, now = Date.now()) {
|
|
96
|
+
if (!cfg.retryEnabled) return false
|
|
97
|
+
if (frozen) return false // 冻结让路(D9)
|
|
98
|
+
if (s.pending) return false // 已有排队重试
|
|
99
|
+
if (s.consecutive >= cfg.retryMaxConsecutive) return false
|
|
100
|
+
if (now - s.lastAttemptAt < effectiveCooldown(s.consecutive, cfg.retryCooldownMs, cfg.retryBackoffFactor, cfg.retryBackoffMaxMs)) return false
|
|
101
|
+
return true
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 会话状态工厂。 */
|
|
105
|
+
export function freshRetryState() {
|
|
106
|
+
return { consecutive: 0, lastAttemptAt: 0, pending: false }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 事件接线(host):
|
|
111
|
+
* @param {object} deps
|
|
112
|
+
* @param {object} deps.ctx host context
|
|
113
|
+
* @param {()=>object} deps.getSettings 读实时配置
|
|
114
|
+
* @param {(sessionId:string)=>boolean} deps.isFrozen 会话是否被门控
|
|
115
|
+
* @param {(sessionId:string, text:string)=>void} [deps.send] 发送函数(默认 agent.followup)
|
|
116
|
+
*/
|
|
117
|
+
export function createRetry({ ctx, getSettings, isFrozen, send }) {
|
|
118
|
+
const states = new Map()
|
|
119
|
+
|
|
120
|
+
function state(sessionId) {
|
|
121
|
+
let s = states.get(sessionId)
|
|
122
|
+
if (!s) {
|
|
123
|
+
s = freshRetryState()
|
|
124
|
+
states.set(sessionId, s)
|
|
125
|
+
}
|
|
126
|
+
return s
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 从 turn/end reason 提取失败事实。 */
|
|
130
|
+
function failureFacts(reason) {
|
|
131
|
+
const error = reason && reason.error
|
|
132
|
+
return {
|
|
133
|
+
code: error && typeof error.code === 'string' ? error.code : 'UNKNOWN',
|
|
134
|
+
message: error && typeof error.message === 'string' ? error.message : '',
|
|
135
|
+
status: error && typeof error.status === 'number' ? error.status : undefined,
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function schedule(sessionId, reason) {
|
|
140
|
+
const s = state(sessionId)
|
|
141
|
+
const cfg = getSettings()
|
|
142
|
+
if (s.pending) return
|
|
143
|
+
const frozen = isFrozen(sessionId)
|
|
144
|
+
if (!shouldRetry({ ...s, pending: true }, cfg, frozen)) return
|
|
145
|
+
s.pending = true
|
|
146
|
+
const grace = cfg.retryGraceMs ?? DEFAULT_RETRY.retryGraceMs
|
|
147
|
+
const timer = setTimeout(() => {
|
|
148
|
+
s.pending = false
|
|
149
|
+
// 到点再复核:门控/上限变化后放弃。
|
|
150
|
+
if (!shouldRetry(s, cfg, isFrozen(sessionId))) return
|
|
151
|
+
const agent = ctx.agents.get(sessionId)
|
|
152
|
+
if (!agent || agent.status !== 'idle') return
|
|
153
|
+
const text = cfg.retryText ?? DEFAULT_RETRY.retryText
|
|
154
|
+
try {
|
|
155
|
+
if (send) {
|
|
156
|
+
send(sessionId, text)
|
|
157
|
+
} else {
|
|
158
|
+
// 与 autoresume 同构:直接构造 plugin-source 消息,避免运行时依赖。
|
|
159
|
+
agent.followup({
|
|
160
|
+
id: `session-guard-retry-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
161
|
+
role: 'user',
|
|
162
|
+
content: [{ type: 'text', text }],
|
|
163
|
+
source: { kind: 'plugin:session-guard', form: 'notice' },
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
s.lastAttemptAt = Date.now()
|
|
167
|
+
s.consecutive += 1
|
|
168
|
+
ctx.logger?.info?.(`[session-guard] auto-retry ${sessionId} (${String(reason && reason.kind)}), #${s.consecutive}`)
|
|
169
|
+
} catch (e) {
|
|
170
|
+
ctx.logger?.warn?.(`[session-guard] auto-retry send failed: ${String(e && e.message || e)}`)
|
|
171
|
+
}
|
|
172
|
+
}, grace)
|
|
173
|
+
// 会话对象上的清理钩子(若宿主提供)。
|
|
174
|
+
const dispose = () => clearTimeout(timer)
|
|
175
|
+
return dispose
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function onEvent(session, event) {
|
|
179
|
+
const sessionId = session && session.id
|
|
180
|
+
if (typeof sessionId !== 'string') return
|
|
181
|
+
const s = state(sessionId)
|
|
182
|
+
switch (event.type) {
|
|
183
|
+
case 'turn/end': {
|
|
184
|
+
const reason = event.data && event.data.reason
|
|
185
|
+
if (reason && reason.kind === 'completed') {
|
|
186
|
+
s.consecutive = 0 // 成功回合重置
|
|
187
|
+
s.pending = false
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
if (reason && reason.kind === 'aborted') {
|
|
191
|
+
// 用户主动停止:不重试,重置。
|
|
192
|
+
s.consecutive = 0
|
|
193
|
+
s.pending = false
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
if (reason && classifyTurnEnd(reason, failureFacts(reason))) {
|
|
197
|
+
schedule(sessionId, reason)
|
|
198
|
+
}
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
case 'user/message': {
|
|
202
|
+
// 用户手动介入:重置(无论是否我们的回显都保守重置)。
|
|
203
|
+
if (event.data && event.data.source && event.data.source.kind === 'user') {
|
|
204
|
+
s.consecutive = 0
|
|
205
|
+
s.pending = false
|
|
206
|
+
}
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
default:
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
ctx.on('session/event', (session, event) => {
|
|
215
|
+
try {
|
|
216
|
+
onEvent(session, event)
|
|
217
|
+
} catch (e) {
|
|
218
|
+
ctx.logger?.warn?.(`[session-guard] retry event failed: ${String(e && e.message || e)}`)
|
|
219
|
+
}
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
onEvent,
|
|
224
|
+
state,
|
|
225
|
+
states,
|
|
226
|
+
}
|
|
227
|
+
}
|