dsh-turn-recovery 0.2.3 → 0.2.4
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.md +49 -0
- package/README.md +17 -3
- package/lib/index.js +176 -49
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,55 @@
|
|
|
7
7
|
> `@nlflora/dsh-empty-turn-guard`)冲突,自 0.2.0 起更名为 `dsh-turn-recovery`。0.1.x 及更早的条目保留旧名原文,
|
|
8
8
|
> 其提到的 `plugin:empty-turn-guard`、`empty-turn-guard.log` 等标识在 0.2.0 起分别改为 `plugin:turn-recovery`、`turn-recovery.log`。
|
|
9
9
|
|
|
10
|
+
## [0.2.4] - 2026-11-09
|
|
11
|
+
|
|
12
|
+
**运行期复核版**。0.2.3 之后按 8 天生产日志(645 个 `turn/end`)回头复查代码,
|
|
13
|
+
修掉一处「装了但可能静默不工作」的状态索引隐患,并清掉每回合都要付的同步与
|
|
14
|
+
日志开销;顺带把「守卫究竟为什么没触发」变成一行可读的聚合统计。
|
|
15
|
+
|
|
16
|
+
### 修复
|
|
17
|
+
|
|
18
|
+
- **会话状态只按 session 对象身份索引**(`P1`):`state` 是 WeakMap,而宿主在
|
|
19
|
+
不同代码路径上可能给出不同的 session 实例(同文件的 `agentOf` 早就为此加了
|
|
20
|
+
`agentsById`)。一旦身份漂移,`turn/start` 落在对象 A、`assistant/message` 与
|
|
21
|
+
`turn/end` 落在对象 B,B 的状态里 `sawTurnStart` 恒为 false,判定链整体早退,
|
|
22
|
+
只留一行 `turn/start not observed` —— 与 0.2.1 修过的「orchestrator-* 被判成
|
|
23
|
+
子代理、19 次静默跳过」是同一类故障。现在加有界的 `stateById`(500 条)双索引,
|
|
24
|
+
`agent/disposed` 时两个索引同时清理。
|
|
25
|
+
- **布尔配置拼错不会被发现**:`enabled: "flase"` 被 `enabledOr` 当成 `true` 且
|
|
26
|
+
无人提示。布尔键现在只承认 `true/false/1/0/on/off/yes/no`,其余记一条
|
|
27
|
+
`config issue: "enabled" looks like a typo`。
|
|
28
|
+
- **`foregroundKinds` 里的非字符串元素被静默丢弃**:现在记一条 config issue。
|
|
29
|
+
- **预算文件压缩失败会留下 `.tmp-<pid>`**:`rename` 失败(Windows 上 EPERM/EBUSY)
|
|
30
|
+
时先 unlink 再抛,不再往 state 目录里积垃圾。
|
|
31
|
+
|
|
32
|
+
### 性能 / 日志
|
|
33
|
+
|
|
34
|
+
- **每行日志都同步 `mkdirSync`**:目录改为只建一次(写失败复位、下次自愈),
|
|
35
|
+
少掉宿主事件循环上每行一次的同步系统调用。
|
|
36
|
+
- **正常回合的 `turn/end` 行占日志一半**(实测 645/1286 行):新增
|
|
37
|
+
`logTurns: 'empty' | 'all'`,默认 `empty` 只在空回合/跳过判定时写行,排查期切
|
|
38
|
+
`all` 恢复逐回合记录。
|
|
39
|
+
- **每条 assistant 消息两遍遍历 + 一次 `Array.from()`**:合并成一次遍历的
|
|
40
|
+
`inspectMessage()`,字符数用 `for...of` 累加(仍给精确总数,日志的
|
|
41
|
+
`visibleChars` 不退化成「至少 1」)。
|
|
42
|
+
- **`GUARD_IDS_MAX` 与字面量 `8` 脱钩**:投递时的截断改用常量。
|
|
43
|
+
- **一小时预算谓词写了 4 遍**:抽成 `withinWindow(at, now)` 与
|
|
44
|
+
`budgetExhausted(s, now)`,窗口与时钟容忍度以后只改一处。
|
|
45
|
+
|
|
46
|
+
### 可观测性
|
|
47
|
+
|
|
48
|
+
- **新增 `stats:` 聚合行**(每 50 个回合 + 卸载时 `(final)`):
|
|
49
|
+
`turns / emptyCompleted / emptyOther / delivered / claimed`。生产经验是
|
|
50
|
+
`completed` 空回合 8 天为 0、空回合全是 `steps=0` 的 `error`/`aborted`;有了
|
|
51
|
+
这行就能区分「守卫没触发是因为目标场景没发生」和「守卫坏了」。
|
|
52
|
+
|
|
53
|
+
### 文档
|
|
54
|
+
|
|
55
|
+
- 更正 README「已知限制」里的实测数据:空回合不是「`aborted` / `blocked`」,
|
|
56
|
+
而是 `error` 162 / `aborted` 20、`completed` 0;并补上 `logTurns`、
|
|
57
|
+
`stats` 与状态双索引的说明。
|
|
58
|
+
|
|
10
59
|
## [0.2.3] - 2026-11-05
|
|
11
60
|
|
|
12
61
|
**三方复核(架构 / 安全 / 验收)后的加固版**。0.2.2 的判定链没有 P0 缺陷,但复核在
|
package/README.md
CHANGED
|
@@ -67,10 +67,11 @@
|
|
|
67
67
|
budgetFile: '' # 空 = <DSH_HOME>/state/turn-recovery/nudges.log(跨重启限流)
|
|
68
68
|
# 同目录还会写 guard-ids.log(跨重启认出晚到认领的补写 id)
|
|
69
69
|
logFile: '' # 空 = <DSH_HOME>/logs/turn-recovery.log
|
|
70
|
+
logTurns: empty # empty = 只记空回合/被跳过判定的回合;all = 连正常回合也记
|
|
70
71
|
dryRun: false # true = 判定/限流/日志全部照走,但不真的投递(验证用)
|
|
71
72
|
```
|
|
72
73
|
|
|
73
|
-
配置做严格解析:`maxNudgesPerHour: 0` 是**关闭补写**,不会被默认值吞成 3;`delayMs: "abc"` 这类非法值回落到默认值;`enabled: "false"`(字符串)与 `false`
|
|
74
|
+
配置做严格解析:`maxNudgesPerHour: 0` 是**关闭补写**,不会被默认值吞成 3;`delayMs: "abc"` 这类非法值回落到默认值;`enabled: "false"`(字符串)与 `false` 等价。布尔键只承认 `true/false/1/0/on/off/yes/no`,`enabled: "flase"` 这类拼错会被记成 config issue(行为仍按真值走,所以要留意日志)。
|
|
74
75
|
|
|
75
76
|
宿主**不校验**插件配置(本包不导出 `Config` schema,原因见「实现约束」),所以键名写错不会被谁拦下来。插件改为在加载期自查:未知键、类型不符、`sessionScope` 取值不对等,逐条写进日志(`config issue: "minNudgesPerHour" is unknown -> ignored`),插件照常启用。改完配置先看这几行,比猜「为什么没生效」划算。
|
|
76
77
|
|
|
@@ -102,7 +103,19 @@
|
|
|
102
103
|
[2026-10-25T02:03:45.004Z] guard message claimed turn=5 matched=true [5f884825]
|
|
103
104
|
```
|
|
104
105
|
|
|
105
|
-
|
|
106
|
+
正常回合默认**不写** `turn/end` 行(`logTurns: empty`):实测这类行占日志的一半,而它只在「某个回合为什么没被判空」时才有人看。要看逐回合记录就把 `logTurns` 切成 `all`,此时格式同上。空回合的 `empty=true` 行在任何粒度下都会留下。
|
|
107
|
+
|
|
108
|
+
每 50 个回合会多一行聚合统计,卸载/重载时再打一行 `(final)`:
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
[…Z] stats: turns=50 emptyCompleted=0 emptyOther=37 delivered=0 claimed=0
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`emptyCompleted` 是守卫真正要救的那一类(`completed` 却没有正文);`emptyOther` 是 `error` / `aborted` 等按设计不补写的空回合。长期出现 `emptyCompleted=0` 而 `emptyOther>0`,说明「守卫没触发」是因为目标场景本身没发生,而不是插件坏了;只有 `delivered` 与 `claimed` 的差值才说明投递链路是否真的接通。
|
|
115
|
+
|
|
116
|
+
出现 `empty=true` 时应紧跟 `nudge outcome turn=N: scheduled` 与 `nudge DELIVERED`,再往后出现 `guard message claimed ... matched=true` 才说明补写回合真的起来了。
|
|
117
|
+
|
|
118
|
+
`[...]` 是会话 id(剥掉 `session-` / `orchestrator-` / `worker-` 前缀)前 8 位,`wasGuard` 表示该回合是不是补写回合,`sawStart` 表示插件是否看到过该回合的 `turn/start`(中途热加载时为 false,此时不判定)。
|
|
106
119
|
|
|
107
120
|
只想验证判定链、不想真的多跑一轮模型时,把 `dryRun` 打开,日志会写 `nudge DRY-RUN: would deliver turn=N` 并停在那里(既不投递,也不占小时预算)。
|
|
108
121
|
|
|
@@ -125,6 +138,7 @@
|
|
|
125
138
|
|
|
126
139
|
## 已知限制
|
|
127
140
|
|
|
141
|
+
- **会话状态是双索引**:`state` 是 WeakMap(按 session 对象身份,快路径),另有有界(500 条)的 `stateById`(按 `session.id`,兜底)。宿主在某些代码路径上会给出不同的 session 实例;只按对象身份索引时,`turn/start` 与 `turn/end` 会落在两份状态上,`sawTurnStart` 恒为 false、判定链整体静默早退(只留一行 `turn/start not observed`)。现在两个索引同时写,`agent/disposed` 时同时清。
|
|
128
142
|
- **补写消息以 `role: 'user'` 落盘**(`source.kind = 'plugin:turn-recovery'`):宿主 session 层对 `source.kind` 不做枚举校验(只要求非空字符串),插件侧也改不了 role。桌面前端按 `source.kind !== 'user'` 渲染成上下文卡片(不是用户气泡),mnemon 只收录 `source.kind === 'user'`,所以界面与记忆都不受污染;但**会话转录文件里它确实是一条 user 消息**,审计时要看 `source.kind` 区分。另外 `plugin:*` 这个前缀不在宿主 `MessageSourceMap` 的类型联合里 —— 运行时不受影响(前端对未知 kind 走 `default` 分支,显示为「请求」卡片),但下游按类型联合写的消费者会漏掉它。
|
|
129
143
|
- **用户点停止的窗口**:宿主 `cancel()` 只清 inbox + abort,不 append 任何 session 事件。插件订阅了宿主 dispatch 的 `agent/inbox/discarded` 来撤掉自己那条补写,但该事件是否向插件广播取决于宿主的 dispatch 链路;收不到时,「用户已点停止、插件仍在 `delayMs` 后补写」这个窗口依然存在(默认 900ms,可通过 `delayMs` 调小)。真正发出去的那一回合仍受宿主 abort 语义约束。
|
|
130
144
|
- **`sessionScope: 'root'` 的判定依据是宿主的 `Session.header`**:`origin === 'subagent'` 或 `delegationDepth > 0` 才算子代理会话,`orchestrator-*` 这类顶层会话会被正常处理(0.2.0 用 id 前缀判断,把它们全部判成子代理,实测日志里有 19 次静默跳过)。宿主没有 header 的场合(老宿主、测试桩)退回 id 形态:`session-*` / `orchestrator-*` 算根,纯 UUID / `social-*` 不算。被过滤的会话每个 id 只记一条 `skip scope` 日志,不会静默消失。
|
|
@@ -134,7 +148,7 @@
|
|
|
134
148
|
- **多进程(桌面宿主 + 独立的 web/headless 宿主)**:各自一套内存态,`agent.inbox.nextTurn` 只对本进程可见。预算文件与补写 id 文件是共享的,因此限流大致成立,但「同一会话同时被两个宿主打开」仍可能各补一笔。同一会话请只用一个宿主。
|
|
135
149
|
- **`dryRun` 不写预算文件**:判定链、日志、限流检查照走,但不投递、不记账,所以它只能证明「判定会发生」,不能用来验证限流是否生效(日志里的 `budgetUsed=n/max` 是当时的已用额度)。
|
|
136
150
|
- **多宿主共用同一日志文件**:两个进程同时写同一路径时轮转可能失败,此时回退 `truncate(0)` 并记一条 `log rotate fell back to truncate`。
|
|
137
|
-
-
|
|
151
|
+
- **生产环境仍未观测到真实补写(截至 2026-11-09 复核)**:本机 8 天日志里共 645 个 `turn/end`(`completed` 455 / `error` 165 / `aborted` 25),空回合 182 个 —— 其中 `error` 162、`aborted` 20、`completed` **0**,`nudge SCHEDULED` / `DELIVERED` 也仍是 0。也就是说:唯一要救的那一类(`completed` 却只剩零宽占位符)在本机一次都没发生,出现的全是 `steps=0` 的调用失败(162 次集中在 11-06 / 11-07 两天,属于上游故障;此时补写只会反复撞同一堵墙,所以按设计不补)。所有结论仍来自单测与 mock 复现。要闭合这一项,请在真机上临时设置 `dryRun: true`,等一次 `completed` 空回合,确认日志出现 `nudge DRY-RUN: would deliver`(判定链在生产成立),再关掉 `dryRun` 等真实投递 —— 这比直接开补写更安全:干跑不会多跑一轮模型。看 `stats:` 聚合行可以一眼判断当前处在哪种状态。
|
|
138
152
|
|
|
139
153
|
## License
|
|
140
154
|
|
package/lib/index.js
CHANGED
|
@@ -99,6 +99,16 @@ const NUDGE_CLAIM_TIMEOUT_MS = 120000;
|
|
|
99
99
|
* 记录一律丢弃(不报错、不中断,只丢那一条)。
|
|
100
100
|
*/
|
|
101
101
|
const BUDGET_FUTURE_SKEW_MS = 5 * 60 * 1000;
|
|
102
|
+
/**
|
|
103
|
+
* 一条投递时刻是否落在「最近一小时」的预算窗口内。
|
|
104
|
+
* 未来时间戳(> now + 容忍窗口)必须一起丢掉:`now - t` 对它是负数,
|
|
105
|
+
* `< HOUR_MS` 恒成立,只按上半句过滤就会把它永久留在预算里、让该会话永久
|
|
106
|
+
* 判成「本小时已用尽」。这个谓词此前在 4 处各写一遍(会话级修剪、全局修剪、
|
|
107
|
+
* 预算文件解析、两处上限比较),改窗口或容忍度时很容易漏改其中一处。
|
|
108
|
+
*/
|
|
109
|
+
function withinWindow(at, now) {
|
|
110
|
+
return now - at < HOUR_MS && at <= now + BUDGET_FUTURE_SKEW_MS;
|
|
111
|
+
}
|
|
102
112
|
/** 已投递补写 id 的持久化保留时长:覆盖宿主重启/插件热重载后的晚到认领。 */
|
|
103
113
|
const GUARD_ID_TTL_MS = 24 * 60 * 60 * 1000;
|
|
104
114
|
/** 每个会话保留的补写 id 上限(与内存态 guardIds 一致)。 */
|
|
@@ -180,7 +190,7 @@ function enabledOr(value, fallback) {
|
|
|
180
190
|
/** 配置项清单:只用于在加载期挑出「写错的键」,不作为校验依据。 */
|
|
181
191
|
const CONFIG_KEYS = new Set([
|
|
182
192
|
'enabled', 'injectInstruction', 'dryRun', 'instructionText',
|
|
183
|
-
'minVisibleChars', 'delayMs', 'maxNudgesPerHour', 'logFile',
|
|
193
|
+
'minVisibleChars', 'delayMs', 'maxNudgesPerHour', 'logFile', 'logTurns',
|
|
184
194
|
'sessionScope', 'foregroundKinds', 'budgetFile',
|
|
185
195
|
]);
|
|
186
196
|
const NUMERIC_KEYS = ['minVisibleChars', 'delayMs', 'maxNudgesPerHour'];
|
|
@@ -221,7 +231,18 @@ function describeConfigIssues(raw) {
|
|
|
221
231
|
for (const key of BOOLEAN_KEYS) {
|
|
222
232
|
const v = raw[key];
|
|
223
233
|
if (v === undefined || typeof v === 'boolean') continue;
|
|
224
|
-
if (typeof v === '
|
|
234
|
+
if (typeof v === 'number') continue;
|
|
235
|
+
if (typeof v === 'string') {
|
|
236
|
+
// 旧版对布尔键接受任意字符串,于是 enabled: "flase" 这类拼错会被
|
|
237
|
+
// enabledOr 静默当成 true,用户以为关掉了、其实一直开着。这里只承认
|
|
238
|
+
// 明确的真/假写法,其余按拼错报出来(行为仍是 enabledOr 的结果)。
|
|
239
|
+
const word = v.trim().toLowerCase();
|
|
240
|
+
if (word === '' || word === 'true' || word === 'false' || word === '1' || word === '0'
|
|
241
|
+
|| word === 'on' || word === 'off' || word === 'yes' || word === 'no') continue;
|
|
242
|
+
issues.push('"' + key + '" looks like a typo (' + brief(v) + ') -> treated as '
|
|
243
|
+
+ (enabledOr(v, false) ? 'true' : 'false'));
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
225
246
|
issues.push('"' + key + '" expects a boolean, got ' + brief(v) + ' -> ' + (enabledOr(v, false) ? 'true' : 'false'));
|
|
226
247
|
}
|
|
227
248
|
for (const key of STRING_KEYS) {
|
|
@@ -234,23 +255,15 @@ function describeConfigIssues(raw) {
|
|
|
234
255
|
}
|
|
235
256
|
if (raw.foregroundKinds !== undefined && !Array.isArray(raw.foregroundKinds)) {
|
|
236
257
|
issues.push('"foregroundKinds" expects an array, got ' + brief(raw.foregroundKinds) + ' -> ignored');
|
|
258
|
+
} else if (Array.isArray(raw.foregroundKinds)) {
|
|
259
|
+
// 非字符串元素会在构建 cfg 时被静默过滤掉,配置看着生效、实际没加进去。
|
|
260
|
+
const bad = raw.foregroundKinds.filter((k) => typeof k !== 'string' || k.trim() === '').length;
|
|
261
|
+
if (bad > 0) issues.push(bad + ' non-string/empty entry in "foregroundKinds" -> dropped');
|
|
237
262
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
/** 一条消息里所有 text 块拼起来,剥掉不可见字符和首尾空白后的可见文本。 */
|
|
242
|
-
function visibleText(message) {
|
|
243
|
-
const content = message && Array.isArray(message.content) ? message.content : [];
|
|
244
|
-
let acc = '';
|
|
245
|
-
for (const part of content) {
|
|
246
|
-
if (part && part.type === 'text' && typeof part.text === 'string') acc += part.text;
|
|
263
|
+
if (raw.logTurns !== undefined && raw.logTurns !== 'empty' && raw.logTurns !== 'all') {
|
|
264
|
+
issues.push('"logTurns" expects "empty" | "all", got ' + brief(raw.logTurns) + ' -> "empty"');
|
|
247
265
|
}
|
|
248
|
-
return
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
/** 按码位计数,避免代理对被算成 2 个字符。 */
|
|
252
|
-
function codepointLength(text) {
|
|
253
|
-
return Array.from(text).length;
|
|
266
|
+
return issues;
|
|
254
267
|
}
|
|
255
268
|
|
|
256
269
|
/**
|
|
@@ -260,12 +273,32 @@ function codepointLength(text) {
|
|
|
260
273
|
*/
|
|
261
274
|
const VISIBLE_NON_TEXT = new Set(['image', 'file', 'attachment', 'audio', 'video']);
|
|
262
275
|
|
|
263
|
-
|
|
276
|
+
/**
|
|
277
|
+
* 一条 assistant 消息的可见性画像:可见字符数(按码位)+ 是否有可见非文本块。
|
|
278
|
+
*
|
|
279
|
+
* 0.2.4 之前这里是「两遍遍历 + 一次 Array.from()」:visibleText 先拼字符串,
|
|
280
|
+
* hasVisibleNonText 再扫一遍,最后 codepointLength 用 Array.from 建出一整个
|
|
281
|
+
* 码位数组。assistant 消息可能很长,而一个回合有几十步,这笔分配纯粹是浪费。
|
|
282
|
+
* 现在一次遍历同时得出两个结论,字符数用 for...of 累加(代理对算 1 个)。
|
|
283
|
+
* 仍然给精确总数而不是「到阈值就停」:日志里的 visibleChars 是有用的诊断值,
|
|
284
|
+
* 为了省几次迭代把它退化成「至少 1」不划算。
|
|
285
|
+
*/
|
|
286
|
+
function inspectMessage(message) {
|
|
264
287
|
const content = message && Array.isArray(message.content) ? message.content : [];
|
|
288
|
+
let text = '';
|
|
289
|
+
let hasNonText = false;
|
|
265
290
|
for (const part of content) {
|
|
266
|
-
if (part
|
|
291
|
+
if (!part || typeof part.type !== 'string') continue;
|
|
292
|
+
if (part.type === 'text') {
|
|
293
|
+
if (typeof part.text === 'string') text += part.text;
|
|
294
|
+
} else if (VISIBLE_NON_TEXT.has(part.type)) {
|
|
295
|
+
hasNonText = true;
|
|
296
|
+
}
|
|
267
297
|
}
|
|
268
|
-
|
|
298
|
+
const visible = text.replace(ZERO_WIDTH, '').trim();
|
|
299
|
+
let chars = 0;
|
|
300
|
+
for (const _ignored of visible) chars += 1;
|
|
301
|
+
return { chars, hasNonText };
|
|
269
302
|
}
|
|
270
303
|
|
|
271
304
|
export function apply(ctx, config) {
|
|
@@ -283,6 +316,11 @@ export function apply(ctx, config) {
|
|
|
283
316
|
// 0 表示关闭补写(而不是被 || 吞成 3)。
|
|
284
317
|
maxNudgesPerHour: intOr(raw.maxNudgesPerHour, 3, 0, 1000),
|
|
285
318
|
logFile: typeof raw.logFile === 'string' ? raw.logFile.trim() : '',
|
|
319
|
+
// 回合日志粒度:'empty'(默认)只在「空回合 / 判定被跳过」时写一行,
|
|
320
|
+
// 'all' 连正常回合的 turn/end 也写。实测 1286 行日志里 645 行是正常回合
|
|
321
|
+
// 的 turn/end —— 它们只在「某回合为什么没被判空」时才有人看,默认不值得
|
|
322
|
+
// 每回合付一次同步写。排查期把这里切 'all'。
|
|
323
|
+
logTurns: raw.logTurns === 'all' ? 'all' : 'empty',
|
|
286
324
|
// 'root'(默认)只处理根会话(header.delegationDepth 为 0 或缺省);'all' 连
|
|
287
325
|
// 子代理会话一起处理。默认不碰子会话:子代理「只调工具、不回正文」是正常收尾,
|
|
288
326
|
// 补写会浪费 token 甚至改变委派行为。
|
|
@@ -351,6 +389,11 @@ export function apply(ctx, config) {
|
|
|
351
389
|
|
|
352
390
|
// 主日志写在 $DSH_HOME/logs;万一主路径不可写(受限环境/目录被锁),
|
|
353
391
|
// 再退到系统临时目录,避免「插件其实活着但一行日志都没有」的误判。
|
|
392
|
+
//
|
|
393
|
+
// 目录只建一次:旧版每写一行都跑一次同步 mkdirSync,而宿主事件循环上的
|
|
394
|
+
// 每一次同步系统调用都是白给的延迟(turn/end、补写、判空都会写日志)。
|
|
395
|
+
// 写失败时把标志复位,下一次重新 mkdir —— 目录被删/权限变化仍能自愈。
|
|
396
|
+
let logDirReady = false;
|
|
354
397
|
const log = (line) => {
|
|
355
398
|
// 日志行内不允许出现换行:会话 id、错误文本都可能带 \n,拼进去就能在日志里
|
|
356
399
|
// 伪造出一行看似插件自己写的记录(伪造 turn/end 或 guard armed 会误导排障)。
|
|
@@ -358,10 +401,14 @@ export function apply(ctx, config) {
|
|
|
358
401
|
const record = '[' + new Date().toISOString() + '] ' + safe + '\n';
|
|
359
402
|
maybeRotate();
|
|
360
403
|
try {
|
|
361
|
-
|
|
404
|
+
if (!logDirReady) {
|
|
405
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
406
|
+
logDirReady = true;
|
|
407
|
+
}
|
|
362
408
|
fs.appendFileSync(logPath, record);
|
|
363
409
|
return;
|
|
364
410
|
} catch {
|
|
411
|
+
logDirReady = false;
|
|
365
412
|
/* 落到下面的兜底 */
|
|
366
413
|
}
|
|
367
414
|
try {
|
|
@@ -425,7 +472,7 @@ export function apply(ctx, config) {
|
|
|
425
472
|
* 0.2.2 及更早写的是 "id at" 纯文本,两者都认。
|
|
426
473
|
* 时间戳晚于当前时钟 + 容忍窗口的丢弃。
|
|
427
474
|
*/
|
|
428
|
-
const parseBudgetLine = (line, now
|
|
475
|
+
const parseBudgetLine = (line, now) => {
|
|
429
476
|
const text = line.trim();
|
|
430
477
|
if (text === '') return null;
|
|
431
478
|
let id = '';
|
|
@@ -443,8 +490,7 @@ export function apply(ctx, config) {
|
|
|
443
490
|
at = Number(text.slice(sp + 1).trim());
|
|
444
491
|
}
|
|
445
492
|
if (id === '' || !Number.isFinite(at)) return null;
|
|
446
|
-
if (at
|
|
447
|
-
if (at < cutoff) return null;
|
|
493
|
+
if (!withinWindow(at, now)) return null;
|
|
448
494
|
return { id, at };
|
|
449
495
|
};
|
|
450
496
|
|
|
@@ -454,9 +500,8 @@ export function apply(ctx, config) {
|
|
|
454
500
|
try {
|
|
455
501
|
const rawText = readTail(budgetPath, BUDGET_MAX_BYTES);
|
|
456
502
|
const now = Date.now();
|
|
457
|
-
const cutoff = now - HOUR_MS;
|
|
458
503
|
for (const line of rawText.split('\n')) {
|
|
459
|
-
const rec = parseBudgetLine(line, now
|
|
504
|
+
const rec = parseBudgetLine(line, now);
|
|
460
505
|
if (!rec) continue;
|
|
461
506
|
const list = budgetHistory.get(rec.id);
|
|
462
507
|
if (list) list.push(rec.at);
|
|
@@ -533,9 +578,8 @@ export function apply(ctx, config) {
|
|
|
533
578
|
let recordTicks = 0;
|
|
534
579
|
|
|
535
580
|
const pruneBudgetHistory = (now) => {
|
|
536
|
-
const floor = now - HOUR_MS;
|
|
537
581
|
for (const [sid, times] of budgetHistory) {
|
|
538
|
-
const kept = times.filter((t) => t
|
|
582
|
+
const kept = times.filter((t) => withinWindow(t, now));
|
|
539
583
|
if (kept.length === 0) budgetHistory.delete(sid);
|
|
540
584
|
else if (kept.length !== times.length) budgetHistory.set(sid, kept);
|
|
541
585
|
}
|
|
@@ -549,9 +593,8 @@ export function apply(ctx, config) {
|
|
|
549
593
|
const compactBudget = (now) => {
|
|
550
594
|
try {
|
|
551
595
|
const rawText = readTail(budgetPath, BUDGET_MAX_BYTES);
|
|
552
|
-
const cutoff = now - HOUR_MS;
|
|
553
596
|
for (const line of rawText.split('\n')) {
|
|
554
|
-
const rec = parseBudgetLine(line, now
|
|
597
|
+
const rec = parseBudgetLine(line, now);
|
|
555
598
|
if (!rec) continue;
|
|
556
599
|
const list = budgetHistory.get(rec.id);
|
|
557
600
|
if (!list) budgetHistory.set(rec.id, [rec.at]);
|
|
@@ -565,7 +608,14 @@ export function apply(ctx, config) {
|
|
|
565
608
|
}
|
|
566
609
|
const tmp = budgetPath + '.tmp-' + process.pid;
|
|
567
610
|
fs.writeFileSync(tmp, lines.length > 0 ? lines.join('\n') + '\n' : '');
|
|
568
|
-
|
|
611
|
+
try {
|
|
612
|
+
fs.renameSync(tmp, budgetPath);
|
|
613
|
+
} catch (err) {
|
|
614
|
+
// 替换失败(Windows 上目标被另一进程占用时是 EPERM/EBUSY)不能把
|
|
615
|
+
// .tmp-<pid> 留在 state 目录里:长期跑会积一堆同名的垃圾文件。
|
|
616
|
+
try { fs.unlinkSync(tmp); } catch { /* ignore */ }
|
|
617
|
+
throw err;
|
|
618
|
+
}
|
|
569
619
|
};
|
|
570
620
|
|
|
571
621
|
const recordNudge = (session, at) => {
|
|
@@ -709,11 +759,48 @@ export function apply(ctx, config) {
|
|
|
709
759
|
let armed = false;
|
|
710
760
|
const agents = new Map(); // session -> agent
|
|
711
761
|
const agentsById = new Map(); // session.id -> agent(session 对象身份不稳定时兜底)
|
|
762
|
+
/**
|
|
763
|
+
* 会话状态双索引:WeakMap 按对象身份(快路径),Map 按 session.id(兜底)。
|
|
764
|
+
*
|
|
765
|
+
* 只按对象身份索引是原实现的一个静默失效点:宿主在不同代码路径上可能给出
|
|
766
|
+
* 不同的 session 实例(agentOf 早就为此加了 agentsById,注释也写明了这个
|
|
767
|
+
* 风险)。一旦身份漂移,turn/start 落在对象 A、assistant/message 与 turn/end
|
|
768
|
+
* 落在对象 B,B 的状态里 sawTurnStart 恒为 false,判定链整体早退 ——
|
|
769
|
+
* 插件不报错、只留下 "turn/start not observed" 一行,表现为「装了但没用」。
|
|
770
|
+
* 这与 0.2.1 修过的「orchestrator-* 被判成子代理、19 次静默跳过」是同一类故障。
|
|
771
|
+
*/
|
|
712
772
|
const state = new WeakMap(); // session -> per-session guard state
|
|
773
|
+
const stateById = new Map(); // session.id -> per-session guard state(有界)
|
|
774
|
+
const STATE_BY_ID_MAX = 500;
|
|
713
775
|
const timers = new Set(); // 所有在跑的定时器,便于 dispose 时一次性清掉
|
|
714
776
|
|
|
777
|
+
/**
|
|
778
|
+
* 运行期聚合统计。
|
|
779
|
+
* 生产日志实测(2026-11-01 → 11-09,645 个回合):completed 空回合为 0,
|
|
780
|
+
* 182 个空回合全是 steps=0 的 error / aborted。逐行日志回答不了
|
|
781
|
+
* 「守卫没触发是目标场景没发生,还是它坏了」—— 每 STATS_EVERY_TURNS 个回合
|
|
782
|
+
* 打一行汇总,两个问题一眼可辨;卸载时再打一行 final。
|
|
783
|
+
*/
|
|
784
|
+
const stats = { turns: 0, emptyCompleted: 0, emptyOther: 0, delivered: 0, claimed: 0 };
|
|
785
|
+
const STATS_EVERY_TURNS = 50;
|
|
786
|
+
const statsLine = (suffix) => 'stats: turns=' + stats.turns
|
|
787
|
+
+ ' emptyCompleted=' + stats.emptyCompleted
|
|
788
|
+
+ ' emptyOther=' + stats.emptyOther
|
|
789
|
+
+ ' delivered=' + stats.delivered
|
|
790
|
+
+ ' claimed=' + stats.claimed
|
|
791
|
+
+ (suffix ? ' (' + suffix + ')' : '');
|
|
792
|
+
|
|
793
|
+
/** 只读查找(不创建状态):先对象身份、再 id。丢弃事件等旁路用它。 */
|
|
794
|
+
const stateLookup = (session) => {
|
|
795
|
+
if (!session || typeof session !== 'object') return undefined;
|
|
796
|
+
const direct = state.get(session);
|
|
797
|
+
if (direct) return direct;
|
|
798
|
+
const id = typeof session.id === 'string' ? session.id : '';
|
|
799
|
+
return id !== '' ? stateById.get(id) : undefined;
|
|
800
|
+
};
|
|
801
|
+
|
|
715
802
|
const stateOf = (session) => {
|
|
716
|
-
let s =
|
|
803
|
+
let s = stateLookup(session);
|
|
717
804
|
if (!s) {
|
|
718
805
|
s = {
|
|
719
806
|
turn: null,
|
|
@@ -763,7 +850,19 @@ export function apply(ctx, config) {
|
|
|
763
850
|
nudgeTimes: historyOf(session),
|
|
764
851
|
timer: null,
|
|
765
852
|
};
|
|
766
|
-
|
|
853
|
+
}
|
|
854
|
+
// 两个索引都写:对象身份是最快路径,id 是宿主换 session 实例时的兜底。
|
|
855
|
+
if (session && typeof session === 'object') state.set(session, s);
|
|
856
|
+
const sid = session && typeof session.id === 'string' ? session.id : '';
|
|
857
|
+
if (sid !== '' && stateById.get(sid) !== s) {
|
|
858
|
+
if (stateById.size >= STATE_BY_ID_MAX && !stateById.has(sid)) {
|
|
859
|
+
// Map 保序,第一个就是最早插入的一条。淘汰是安全的:该会话的下一次
|
|
860
|
+
// 事件会拿到一个新状态(字段齐全,只是当回合累计值从零开始),
|
|
861
|
+
// 而状态本身是缓存,淘汰不等同于丢判定能力。
|
|
862
|
+
const oldest = stateById.keys().next();
|
|
863
|
+
if (!oldest.done) stateById.delete(oldest.value);
|
|
864
|
+
}
|
|
865
|
+
stateById.set(sid, s);
|
|
767
866
|
}
|
|
768
867
|
return s;
|
|
769
868
|
};
|
|
@@ -880,7 +979,7 @@ export function apply(ctx, config) {
|
|
|
880
979
|
const agent = payload && payload.agent ? payload.agent : payload;
|
|
881
980
|
const session = agent && agent.session;
|
|
882
981
|
if (!session) return;
|
|
883
|
-
const s =
|
|
982
|
+
const s = stateLookup(session);
|
|
884
983
|
if (s) {
|
|
885
984
|
clearTimer(s, null);
|
|
886
985
|
clearNudgeInFlight(s, null);
|
|
@@ -889,7 +988,12 @@ export function apply(ctx, config) {
|
|
|
889
988
|
s.guardIds = [];
|
|
890
989
|
}
|
|
891
990
|
agents.delete(session);
|
|
892
|
-
if (typeof session.id === 'string' && session.id !== '')
|
|
991
|
+
if (typeof session.id === 'string' && session.id !== '') {
|
|
992
|
+
agentsById.delete(session.id);
|
|
993
|
+
// 两个状态索引都要清:否则 id 兜底会一直把已销毁会话的状态留在表里。
|
|
994
|
+
stateById.delete(session.id);
|
|
995
|
+
state.delete(session);
|
|
996
|
+
}
|
|
893
997
|
} catch (err) {
|
|
894
998
|
log('agent/disposed handler error: ' + errText(err));
|
|
895
999
|
}
|
|
@@ -911,7 +1015,7 @@ export function apply(ctx, config) {
|
|
|
911
1015
|
const agent = (payload && payload.agent) || payload;
|
|
912
1016
|
const session = agent && agent.session;
|
|
913
1017
|
if (!session) return;
|
|
914
|
-
const s =
|
|
1018
|
+
const s = stateLookup(session);
|
|
915
1019
|
if (!s) return;
|
|
916
1020
|
const message = payload && payload.message;
|
|
917
1021
|
const id = message && typeof message.id === 'string' ? message.id : '';
|
|
@@ -1013,6 +1117,7 @@ export function apply(ctx, config) {
|
|
|
1013
1117
|
const fn = attempts[i][1];
|
|
1014
1118
|
try {
|
|
1015
1119
|
fn();
|
|
1120
|
+
stats.delivered += 1;
|
|
1016
1121
|
log('nudge DELIVERED via ' + label + ' [' + tag(session) + ']');
|
|
1017
1122
|
return message;
|
|
1018
1123
|
} catch (err) {
|
|
@@ -1024,16 +1129,23 @@ export function apply(ctx, config) {
|
|
|
1024
1129
|
};
|
|
1025
1130
|
|
|
1026
1131
|
/**
|
|
1027
|
-
*
|
|
1028
|
-
*
|
|
1029
|
-
* `< HOUR_MS` 恒成立,只按上半句过滤就会把它永久留在预算里。
|
|
1132
|
+
* 修剪本会话最近一小时的投递时刻,返回窗口内还剩几次。
|
|
1133
|
+
* 未来时间戳的丢弃规则统一走 withinWindow(见该函数的注释)。
|
|
1030
1134
|
*/
|
|
1031
1135
|
const pruneBudget = (s, now) => {
|
|
1032
|
-
s.nudgeTimes = s.nudgeTimes.filter(
|
|
1033
|
-
(t) => now - t < HOUR_MS && t <= now + BUDGET_FUTURE_SKEW_MS);
|
|
1136
|
+
s.nudgeTimes = s.nudgeTimes.filter((t) => withinWindow(t, now));
|
|
1034
1137
|
return s.nudgeTimes.length;
|
|
1035
1138
|
};
|
|
1036
1139
|
|
|
1140
|
+
/**
|
|
1141
|
+
* 本会话窗口内的补写次数是否已达上限。
|
|
1142
|
+
* 「定时器到点时」与「turn/end 判定时」各查一次(两者相隔 delayMs),
|
|
1143
|
+
* 两处必须用同一套修剪+比较;写成分散表达式时,一处改了窗口、另一处没改
|
|
1144
|
+
* 就会从某条路径漏限流。
|
|
1145
|
+
*/
|
|
1146
|
+
const budgetExhausted = (s, now = Date.now()) =>
|
|
1147
|
+
pruneBudget(s, now) >= cfg.maxNudgesPerHour;
|
|
1148
|
+
|
|
1037
1149
|
/**
|
|
1038
1150
|
* 排一笔补写。返回结果字符串,供调用侧决定是否把本回合标记为「已处理」:
|
|
1039
1151
|
* 'scheduled' | 'dry-run' | 'skipped:<原因>' | 'error'
|
|
@@ -1086,7 +1198,7 @@ export function apply(ctx, config) {
|
|
|
1086
1198
|
return;
|
|
1087
1199
|
}
|
|
1088
1200
|
// 预算按「真正投递成功」计,取消/失败不占额度。
|
|
1089
|
-
if (
|
|
1201
|
+
if (budgetExhausted(s)) {
|
|
1090
1202
|
log('hourly nudge budget exhausted (' + s.nudgeTimes.length + ') -> no nudge');
|
|
1091
1203
|
return;
|
|
1092
1204
|
}
|
|
@@ -1109,7 +1221,7 @@ export function apply(ctx, config) {
|
|
|
1109
1221
|
s.nudgeTimes.push(Date.now());
|
|
1110
1222
|
if (typeof message.id === 'string' && message.id !== '') {
|
|
1111
1223
|
s.guardIds.push(message.id);
|
|
1112
|
-
while (s.guardIds.length >
|
|
1224
|
+
while (s.guardIds.length > GUARD_IDS_MAX) s.guardIds.shift();
|
|
1113
1225
|
}
|
|
1114
1226
|
// 跨重启限流:把投递时刻落盘,重启后的新进程回读最近一小时。
|
|
1115
1227
|
recordNudge(session, s.nudgeDeliveredAt);
|
|
@@ -1179,6 +1291,7 @@ export function apply(ctx, config) {
|
|
|
1179
1291
|
return;
|
|
1180
1292
|
}
|
|
1181
1293
|
s.awaitingGuardTurn = true;
|
|
1294
|
+
stats.claimed += 1;
|
|
1182
1295
|
log('guard message claimed' + (late ? ' (LATE, from guardIds)' : '')
|
|
1183
1296
|
+ ' turn=' + (typeof data.turn === 'number' ? data.turn : '?')
|
|
1184
1297
|
+ ' matched=true [' + tag(session) + ']');
|
|
@@ -1204,9 +1317,10 @@ export function apply(ctx, config) {
|
|
|
1204
1317
|
const message = data.message;
|
|
1205
1318
|
if (!message || message.role !== 'assistant') return;
|
|
1206
1319
|
if (typeof data.turn === 'number' && s.turn !== data.turn) resetTurnCounters(s, data.turn);
|
|
1320
|
+
const seen = inspectMessage(message);
|
|
1207
1321
|
s.assistantMessages += 1;
|
|
1208
|
-
s.visibleChars +=
|
|
1209
|
-
if (
|
|
1322
|
+
s.visibleChars += seen.chars;
|
|
1323
|
+
if (seen.hasNonText) s.visibleNonText = true;
|
|
1210
1324
|
return;
|
|
1211
1325
|
}
|
|
1212
1326
|
|
|
@@ -1236,9 +1350,19 @@ export function apply(ctx, config) {
|
|
|
1236
1350
|
|
|
1237
1351
|
const sawStart = s.sawTurnStart === true;
|
|
1238
1352
|
const empty = !s.visibleNonText && s.visibleChars < cfg.minVisibleChars;
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1353
|
+
stats.turns += 1;
|
|
1354
|
+
if (empty) {
|
|
1355
|
+
if (kind === 'completed') stats.emptyCompleted += 1;
|
|
1356
|
+
else stats.emptyOther += 1;
|
|
1357
|
+
}
|
|
1358
|
+
// 正常回合默认不写行:实测这类行占日志的一半,而它只在「某回合为什么
|
|
1359
|
+
// 没被判空」时才有人看。排查期把 logTurns 切成 'all' 即可恢复逐回合记录。
|
|
1360
|
+
if (empty || cfg.logTurns === 'all') {
|
|
1361
|
+
log('turn/end turn=' + turn + ' kind=' + kind + ' steps=' + s.assistantMessages
|
|
1362
|
+
+ ' visibleChars=' + s.visibleChars + ' empty=' + empty + ' wasGuard=' + wasGuard
|
|
1363
|
+
+ ' sawStart=' + sawStart + ' [' + tag(session) + ']');
|
|
1364
|
+
}
|
|
1365
|
+
if (stats.turns % STATS_EVERY_TURNS === 0) log(statsLine(null));
|
|
1242
1366
|
|
|
1243
1367
|
if (!sawStart) {
|
|
1244
1368
|
// 插件在回合中途才加载/热重载:看不到 turn/start,也就漏掉了该回合早前的
|
|
@@ -1264,7 +1388,7 @@ export function apply(ctx, config) {
|
|
|
1264
1388
|
return;
|
|
1265
1389
|
}
|
|
1266
1390
|
const now = Date.now();
|
|
1267
|
-
if (
|
|
1391
|
+
if (budgetExhausted(s, now)) {
|
|
1268
1392
|
log('hourly nudge budget exhausted (' + s.nudgeTimes.length + ') -> no nudge');
|
|
1269
1393
|
return;
|
|
1270
1394
|
}
|
|
@@ -1291,6 +1415,9 @@ export function apply(ctx, config) {
|
|
|
1291
1415
|
try { clearTimeout(timer); } catch { /* ignore */ }
|
|
1292
1416
|
}
|
|
1293
1417
|
timers.clear();
|
|
1418
|
+
// 卸载时打一行 final 统计:短命进程(频繁重启的宿主)也留得下
|
|
1419
|
+
// 「这段时间到底判了多少回合、补了几笔」的证据,不必等满 50 个回合。
|
|
1420
|
+
try { log(statsLine('final')); } catch { /* 日志失败不影响卸载 */ }
|
|
1294
1421
|
});
|
|
1295
1422
|
} catch { /* effect 不可用时忽略 */ }
|
|
1296
1423
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-turn-recovery",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "DSH plugin: detect assistant turns that end with no visible text (zero-width placeholder) and re-prompt the model to write the answer; also injects a visible-closing-answer system prompt rule.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "HERO476",
|