dsh-turn-recovery 0.2.2 → 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 +93 -0
- package/README.md +35 -5
- package/lib/index.js +469 -103
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,99 @@
|
|
|
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
|
+
|
|
59
|
+
## [0.2.3] - 2026-11-05
|
|
60
|
+
|
|
61
|
+
**三方复核(架构 / 安全 / 验收)后的加固版**。0.2.2 的判定链没有 P0 缺陷,但复核在
|
|
62
|
+
持久化文件与部署侧查出实质问题:预算文件可被本地输入拖垮或投毒、跨重启的补写 id 记忆
|
|
63
|
+
不落盘、段落注册一次冲突就让插件永久停摆、inbox 去重依赖未公开结构却没有探测。
|
|
64
|
+
本版逐项修掉,并补齐 README 里对「桌面端 vs 网页端」的错误期待。
|
|
65
|
+
|
|
66
|
+
### 修复
|
|
67
|
+
|
|
68
|
+
- **预算文件同步整读会阻塞宿主**(安全复核实测 64 MB ≈ 544 ms):`hydrateBudget` 改为先
|
|
69
|
+
`stat`,超过 1 MiB 只读尾部并丢掉截断的首行,日志留一行
|
|
70
|
+
`persisted state read: file is …B, tail …B only`。
|
|
71
|
+
- **预算文件可被未来时间戳投毒**:一条 `at > now` 的记录会让 `now - t < HOUR_MS` 恒成立、
|
|
72
|
+
永久留在预算里,该会话从此被判成「本小时预算用尽」而静默不补写。解析与修剪统一加
|
|
73
|
+
`+5min` 时钟容忍,超出即丢弃。
|
|
74
|
+
- **预算记录格式改为 JSON 行**:旧格式 `id at` 在会话 id 含空格时会被截断成错账;
|
|
75
|
+
新格式兼容读旧文件(两种都认)。
|
|
76
|
+
- **压缩预算文件会覆盖别的进程刚追加的行**:改为「重读磁盘 → 合并 → 临时文件 → `rename`
|
|
77
|
+
原子替换」,并把写入统一成 append-only。
|
|
78
|
+
- **跨重启的补写 id 不落盘**:新增 `<budgetFile 同目录>/guard-ids.log`(JSON 行,
|
|
79
|
+
24 小时 TTL、每会话 8 条)。宿主重启后认领「重启前投递」的那条消息时仍记为
|
|
80
|
+
`LATE, from guardIds`,补写回合再空不会连环补第二笔。
|
|
81
|
+
- **段落注册一次失败就永久停摆**:改为最多重试 3 次(150 ms 间隔),只有注册成功才
|
|
82
|
+
`armed`;重试耗尽仍 fail-closed(`guard NOT armed`),期间所有判定链不工作
|
|
83
|
+
(`session/event` 与 `deliver` 都先看 `armed`)。
|
|
84
|
+
- **`agent.inbox.nextTurn` 去重依赖未公开结构**:加能力探测,缺失时记一行
|
|
85
|
+
`inbox dedupe UNAVAILABLE …` 并降级(认领 id 匹配仍在),不再静默失效。
|
|
86
|
+
- **`skip scope` 日志上限**:200 → 2000,且触顶时明确记一行,不再无声无息。
|
|
87
|
+
|
|
88
|
+
### 变更
|
|
89
|
+
|
|
90
|
+
- `nudge DRY-RUN` 日志补 `budgetUsed=n/max`,并注明干跑不落预算 —— 旧文案容易让人以为
|
|
91
|
+
`dryRun` 能验证限流。
|
|
92
|
+
- README 新增「桌面端与网页端」章节:内嵌 Web UI 与桌面端同进程同实例;独立
|
|
93
|
+
`dsh web` 在 `profiles/web` 不存在时走出厂模板,插件静默不加载。
|
|
94
|
+
- 回归用例 49 → 58:预算尾部读取、未来时间戳、JSON 落盘、跨重启认领、inbox 探测降级、
|
|
95
|
+
段落注册重试成功,以及 `enabled:false` / `injectInstruction:false` 三条开关语义。
|
|
96
|
+
|
|
97
|
+
### 未变
|
|
98
|
+
|
|
99
|
+
- 补写消息仍是 `role: 'user'` + `source.kind = 'plugin:turn-recovery'`(宿主不允许插件改 role)。
|
|
100
|
+
- 真实宿主上的端到端投递仍未观测到(生产日志 `nudge DELIVERED = 0`)。本版只加固了
|
|
101
|
+
与「能不能安全地投递」有关的边角,闭合这一项仍需要在真机上跑一次 `dryRun` + 一次真实投递。
|
|
102
|
+
|
|
10
103
|
## [0.2.2] - 2026-11-04
|
|
11
104
|
|
|
12
105
|
**代码审查修复 + 可验证性补强**。针对 0.2.1 的复审(宿主 `0.2.2-alpha.3` 源码取证、
|
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
// <DSH_HOME>/profiles/<你的 profile>/package.json
|
|
32
32
|
{
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"dsh-turn-recovery": "^0.2.
|
|
34
|
+
"dsh-turn-recovery": "^0.2.3"
|
|
35
35
|
},
|
|
36
36
|
"dsh": {
|
|
37
37
|
"profile": {
|
|
@@ -65,14 +65,28 @@
|
|
|
65
65
|
sessionScope: root # root = 只处理根会话(按 SessionHeader 判定);all = 连子代理会话一起
|
|
66
66
|
foregroundKinds: [] # 追加「会取消待发补写」的来源 kind(默认 user / agent-message)
|
|
67
67
|
budgetFile: '' # 空 = <DSH_HOME>/state/turn-recovery/nudges.log(跨重启限流)
|
|
68
|
+
# 同目录还会写 guard-ids.log(跨重启认出晚到认领的补写 id)
|
|
68
69
|
logFile: '' # 空 = <DSH_HOME>/logs/turn-recovery.log
|
|
70
|
+
logTurns: empty # empty = 只记空回合/被跳过判定的回合;all = 连正常回合也记
|
|
69
71
|
dryRun: false # true = 判定/限流/日志全部照走,但不真的投递(验证用)
|
|
70
72
|
```
|
|
71
73
|
|
|
72
|
-
配置做严格解析:`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(行为仍按真值走,所以要留意日志)。
|
|
73
75
|
|
|
74
76
|
宿主**不校验**插件配置(本包不导出 `Config` schema,原因见「实现约束」),所以键名写错不会被谁拦下来。插件改为在加载期自查:未知键、类型不符、`sessionScope` 取值不对等,逐条写进日志(`config issue: "minNudgesPerHour" is unknown -> ignored`),插件照常启用。改完配置先看这几行,比猜「为什么没生效」划算。
|
|
75
77
|
|
|
78
|
+
## 桌面端与网页端
|
|
79
|
+
|
|
80
|
+
两者不是两条独立的加载路径,别按「装一份、两边都生效」理解:
|
|
81
|
+
|
|
82
|
+
- **桌面应用里内嵌的 Web UI(浏览器打开的那个端口)与桌面端是同一个宿主进程、同一个 profile、同一个插件实例**。插件对它是生效的,不需要额外注册。
|
|
83
|
+
- **独立运行 `dsh web`(等价于 `--profile web`)** 时,如果 `<DSH_HOME>/profiles/web` 不存在,宿主会用出厂模板初始化该 profile(只含 `dsh-base` + `dsh-web-app`)。**本插件不在其中,网页端会静默无保护** —— 既不加载、也不报错,连一条 `skip scope` 都不会有。
|
|
84
|
+
- `headless` profile 同理(模板只含 base + headless)。
|
|
85
|
+
|
|
86
|
+
需要独立 web/headless 宿主时,显式注册三处(与 desktop profile 一样):`profiles/<name>/package.json` 的 `dependencies` 加上本包、`dsh.profile.bundles` 加上 `dsh-turn-recovery`、`profiles/<name>/cordis.patch.yml` 加 `- id: turn-recovery` 的 config;装完用 `dsh --profile web --dump-config | grep dsh-turn-recovery` 确认条目真的在。
|
|
87
|
+
|
|
88
|
+
> 2026-11-05 本机实测:`<DSH_HOME>/profiles/` 只有 `desktop` 与 `headless`,没有 `web`。当时浏览器里那个端口由桌面宿主进程提供服务(端口 owner 与日志里 `plugin activated` 的 pid 相同)。
|
|
89
|
+
|
|
76
90
|
## 日志
|
|
77
91
|
|
|
78
92
|
默认写到 `<DSH_HOME>/logs/turn-recovery.log`(未设 `DSH_HOME` 时是 `~/.dsh/logs/…`;主路径不可写时兜底到系统临时目录;超过 5 MB 自动轮转为 `.1`):
|
|
@@ -89,7 +103,19 @@
|
|
|
89
103
|
[2026-10-25T02:03:45.004Z] guard message claimed turn=5 matched=true [5f884825]
|
|
90
104
|
```
|
|
91
105
|
|
|
92
|
-
|
|
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,此时不判定)。
|
|
93
119
|
|
|
94
120
|
只想验证判定链、不想真的多跑一轮模型时,把 `dryRun` 打开,日志会写 `nudge DRY-RUN: would deliver turn=N` 并停在那里(既不投递,也不占小时预算)。
|
|
95
121
|
|
|
@@ -112,13 +138,17 @@
|
|
|
112
138
|
|
|
113
139
|
## 已知限制
|
|
114
140
|
|
|
141
|
+
- **会话状态是双索引**:`state` 是 WeakMap(按 session 对象身份,快路径),另有有界(500 条)的 `stateById`(按 `session.id`,兜底)。宿主在某些代码路径上会给出不同的 session 实例;只按对象身份索引时,`turn/start` 与 `turn/end` 会落在两份状态上,`sawTurnStart` 恒为 false、判定链整体静默早退(只留一行 `turn/start not observed`)。现在两个索引同时写,`agent/disposed` 时同时清。
|
|
115
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` 分支,显示为「请求」卡片),但下游按类型联合写的消费者会漏掉它。
|
|
116
143
|
- **用户点停止的窗口**:宿主 `cancel()` 只清 inbox + abort,不 append 任何 session 事件。插件订阅了宿主 dispatch 的 `agent/inbox/discarded` 来撤掉自己那条补写,但该事件是否向插件广播取决于宿主的 dispatch 链路;收不到时,「用户已点停止、插件仍在 `delayMs` 后补写」这个窗口依然存在(默认 900ms,可通过 `delayMs` 调小)。真正发出去的那一回合仍受宿主 abort 语义约束。
|
|
117
144
|
- **`sessionScope: 'root'` 的判定依据是宿主的 `Session.header`**:`origin === 'subagent'` 或 `delegationDepth > 0` 才算子代理会话,`orchestrator-*` 这类顶层会话会被正常处理(0.2.0 用 id 前缀判断,把它们全部判成子代理,实测日志里有 19 次静默跳过)。宿主没有 header 的场合(老宿主、测试桩)退回 id 形态:`session-*` / `orchestrator-*` 算根,纯 UUID / `social-*` 不算。被过滤的会话每个 id 只记一条 `skip scope` 日志,不会静默消失。
|
|
118
145
|
- **可见字符是「按回合累计」而不是「末条消息」**:一个回合里先写了结论、后来又被工具调用打断的,累计口径下不算空(这是刻意的:用户此前已经看到了文字)。反过来,`aborted` / `interrupted` 等非 `completed` 回合一律不补写,即使它确实没留下正文 —— 那些回合多半是用户自己喊停的,再补一轮会变成「已取消还继续跑」。
|
|
119
|
-
- **崩溃遗留**:补写已入队但宿主在认领前崩溃时,该消息可能在下次启动被重放。插件只认 id 匹配的认领(`NOT ours`),因此不会被这种消息骗成「补写回合」,但它仍可能作为一条插件消息出现在转录里。为覆盖「120s
|
|
146
|
+
- **崩溃遗留**:补写已入队但宿主在认领前崩溃时,该消息可能在下次启动被重放。插件只认 id 匹配的认领(`NOT ours`),因此不会被这种消息骗成「补写回合」,但它仍可能作为一条插件消息出现在转录里。为覆盖「120s 认领超时后才被认领」与「重启后才被认领」两种情形,最近 8 条投递 id 会同时留在内存与 `<budgetFile 同目录>/guard-ids.log` 里(`LATE, from guardIds`),重启后仍能认出它们,不会把补写回合再当成真空回合补第二笔。
|
|
147
|
+
- **预算文件是不可信输入**:解析时会丢弃「晚于当前时钟 +5 分钟」的时间戳(否则 `now - t` 为负、该会话被永久判成预算耗尽),并且整份文件只在 1 MiB 以内整读、超过就只读尾部(避免配置写错指向几百 MB 的文件时同步阻塞宿主)。写入用 JSON 行(会话 id 含空格也不会被截断),压缩是「重读磁盘 + 临时文件原子替换」,不会覆盖另一个进程刚追加的行。
|
|
148
|
+
- **多进程(桌面宿主 + 独立的 web/headless 宿主)**:各自一套内存态,`agent.inbox.nextTurn` 只对本进程可见。预算文件与补写 id 文件是共享的,因此限流大致成立,但「同一会话同时被两个宿主打开」仍可能各补一笔。同一会话请只用一个宿主。
|
|
149
|
+
- **`dryRun` 不写预算文件**:判定链、日志、限流检查照走,但不投递、不记账,所以它只能证明「判定会发生」,不能用来验证限流是否生效(日志里的 `budgetUsed=n/max` 是当时的已用额度)。
|
|
120
150
|
- **多宿主共用同一日志文件**:两个进程同时写同一路径时轮转可能失败,此时回退 `truncate(0)` 并记一条 `log rotate fell back to truncate`。
|
|
121
|
-
-
|
|
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:` 聚合行可以一眼判断当前处在哪种状态。
|
|
122
152
|
|
|
123
153
|
## License
|
|
124
154
|
|
package/lib/index.js
CHANGED
|
@@ -91,6 +91,33 @@ const HOUR_MS = 3600000;
|
|
|
91
91
|
* 避免一个丢失的补写永久占住 nudgeMessageId。
|
|
92
92
|
*/
|
|
93
93
|
const NUDGE_CLAIM_TIMEOUT_MS = 120000;
|
|
94
|
+
/**
|
|
95
|
+
* 持久化时间戳的时钟偏移容忍(毫秒)。
|
|
96
|
+
* 预算文件与补写 id 文件都是本地可写文件,里面的时间戳不能当可信输入:
|
|
97
|
+
* 一条明显晚于当前时钟的记录会让 `now - t` 变成负数,`< HOUR_MS` 恒成立,
|
|
98
|
+
* 于是该会话被永久判成本小时预算耗尽、守卫静默失效。晚于 now + 容忍窗口的
|
|
99
|
+
* 记录一律丢弃(不报错、不中断,只丢那一条)。
|
|
100
|
+
*/
|
|
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
|
+
}
|
|
112
|
+
/** 已投递补写 id 的持久化保留时长:覆盖宿主重启/插件热重载后的晚到认领。 */
|
|
113
|
+
const GUARD_ID_TTL_MS = 24 * 60 * 60 * 1000;
|
|
114
|
+
/** 每个会话保留的补写 id 上限(与内存态 guardIds 一致)。 */
|
|
115
|
+
const GUARD_IDS_MAX = 8;
|
|
116
|
+
/** prompt 段落注册失败后的重试次数与间隔(热重载时旧段落可能还没 dispose)。 */
|
|
117
|
+
const SECTION_RETRY_MAX = 3;
|
|
118
|
+
const SECTION_RETRY_DELAY_MS = 150;
|
|
119
|
+
/** 每个会话只记一条 skip scope 日志;上限给足,避免长跑后新会话不再留痕。 */
|
|
120
|
+
const SCOPE_SEEN_MAX = 2000;
|
|
94
121
|
/** 单个日志文件上限,超过就轮转成 .1(appendFileSync 没有上限,长期跑会无限长)。 */
|
|
95
122
|
const LOG_MAX_BYTES = 5 * 1024 * 1024;
|
|
96
123
|
const LOG_ROTATE_CHECK_MS = 60000;
|
|
@@ -163,7 +190,7 @@ function enabledOr(value, fallback) {
|
|
|
163
190
|
/** 配置项清单:只用于在加载期挑出「写错的键」,不作为校验依据。 */
|
|
164
191
|
const CONFIG_KEYS = new Set([
|
|
165
192
|
'enabled', 'injectInstruction', 'dryRun', 'instructionText',
|
|
166
|
-
'minVisibleChars', 'delayMs', 'maxNudgesPerHour', 'logFile',
|
|
193
|
+
'minVisibleChars', 'delayMs', 'maxNudgesPerHour', 'logFile', 'logTurns',
|
|
167
194
|
'sessionScope', 'foregroundKinds', 'budgetFile',
|
|
168
195
|
]);
|
|
169
196
|
const NUMERIC_KEYS = ['minVisibleChars', 'delayMs', 'maxNudgesPerHour'];
|
|
@@ -204,7 +231,18 @@ function describeConfigIssues(raw) {
|
|
|
204
231
|
for (const key of BOOLEAN_KEYS) {
|
|
205
232
|
const v = raw[key];
|
|
206
233
|
if (v === undefined || typeof v === 'boolean') continue;
|
|
207
|
-
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
|
+
}
|
|
208
246
|
issues.push('"' + key + '" expects a boolean, got ' + brief(v) + ' -> ' + (enabledOr(v, false) ? 'true' : 'false'));
|
|
209
247
|
}
|
|
210
248
|
for (const key of STRING_KEYS) {
|
|
@@ -217,23 +255,15 @@ function describeConfigIssues(raw) {
|
|
|
217
255
|
}
|
|
218
256
|
if (raw.foregroundKinds !== undefined && !Array.isArray(raw.foregroundKinds)) {
|
|
219
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');
|
|
220
262
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
/** 一条消息里所有 text 块拼起来,剥掉不可见字符和首尾空白后的可见文本。 */
|
|
225
|
-
function visibleText(message) {
|
|
226
|
-
const content = message && Array.isArray(message.content) ? message.content : [];
|
|
227
|
-
let acc = '';
|
|
228
|
-
for (const part of content) {
|
|
229
|
-
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"');
|
|
230
265
|
}
|
|
231
|
-
return
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/** 按码位计数,避免代理对被算成 2 个字符。 */
|
|
235
|
-
function codepointLength(text) {
|
|
236
|
-
return Array.from(text).length;
|
|
266
|
+
return issues;
|
|
237
267
|
}
|
|
238
268
|
|
|
239
269
|
/**
|
|
@@ -243,12 +273,32 @@ function codepointLength(text) {
|
|
|
243
273
|
*/
|
|
244
274
|
const VISIBLE_NON_TEXT = new Set(['image', 'file', 'attachment', 'audio', 'video']);
|
|
245
275
|
|
|
246
|
-
|
|
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) {
|
|
247
287
|
const content = message && Array.isArray(message.content) ? message.content : [];
|
|
288
|
+
let text = '';
|
|
289
|
+
let hasNonText = false;
|
|
248
290
|
for (const part of content) {
|
|
249
|
-
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
|
+
}
|
|
250
297
|
}
|
|
251
|
-
|
|
298
|
+
const visible = text.replace(ZERO_WIDTH, '').trim();
|
|
299
|
+
let chars = 0;
|
|
300
|
+
for (const _ignored of visible) chars += 1;
|
|
301
|
+
return { chars, hasNonText };
|
|
252
302
|
}
|
|
253
303
|
|
|
254
304
|
export function apply(ctx, config) {
|
|
@@ -266,6 +316,11 @@ export function apply(ctx, config) {
|
|
|
266
316
|
// 0 表示关闭补写(而不是被 || 吞成 3)。
|
|
267
317
|
maxNudgesPerHour: intOr(raw.maxNudgesPerHour, 3, 0, 1000),
|
|
268
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',
|
|
269
324
|
// 'root'(默认)只处理根会话(header.delegationDepth 为 0 或缺省);'all' 连
|
|
270
325
|
// 子代理会话一起处理。默认不碰子会话:子代理「只调工具、不回正文」是正常收尾,
|
|
271
326
|
// 补写会浪费 token 甚至改变委派行为。
|
|
@@ -334,6 +389,11 @@ export function apply(ctx, config) {
|
|
|
334
389
|
|
|
335
390
|
// 主日志写在 $DSH_HOME/logs;万一主路径不可写(受限环境/目录被锁),
|
|
336
391
|
// 再退到系统临时目录,避免「插件其实活着但一行日志都没有」的误判。
|
|
392
|
+
//
|
|
393
|
+
// 目录只建一次:旧版每写一行都跑一次同步 mkdirSync,而宿主事件循环上的
|
|
394
|
+
// 每一次同步系统调用都是白给的延迟(turn/end、补写、判空都会写日志)。
|
|
395
|
+
// 写失败时把标志复位,下一次重新 mkdir —— 目录被删/权限变化仍能自愈。
|
|
396
|
+
let logDirReady = false;
|
|
337
397
|
const log = (line) => {
|
|
338
398
|
// 日志行内不允许出现换行:会话 id、错误文本都可能带 \n,拼进去就能在日志里
|
|
339
399
|
// 伪造出一行看似插件自己写的记录(伪造 turn/end 或 guard armed 会误导排障)。
|
|
@@ -341,10 +401,14 @@ export function apply(ctx, config) {
|
|
|
341
401
|
const record = '[' + new Date().toISOString() + '] ' + safe + '\n';
|
|
342
402
|
maybeRotate();
|
|
343
403
|
try {
|
|
344
|
-
|
|
404
|
+
if (!logDirReady) {
|
|
405
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
406
|
+
logDirReady = true;
|
|
407
|
+
}
|
|
345
408
|
fs.appendFileSync(logPath, record);
|
|
346
409
|
return;
|
|
347
410
|
} catch {
|
|
411
|
+
logDirReady = false;
|
|
348
412
|
/* 落到下面的兜底 */
|
|
349
413
|
}
|
|
350
414
|
try {
|
|
@@ -371,29 +435,132 @@ export function apply(ctx, config) {
|
|
|
371
435
|
? cfg.budgetFile
|
|
372
436
|
: path.join(dshHome, 'state', 'turn-recovery', 'nudges.log');
|
|
373
437
|
const BUDGET_MAX_BYTES = 1024 * 1024;
|
|
438
|
+
/** 已投递补写 id 的持久化文件(跨重启识别晚到认领用),与预算文件同目录。 */
|
|
439
|
+
const guardPath = path.join(path.dirname(budgetPath), 'guard-ids.log');
|
|
374
440
|
let budgetHydrated = false;
|
|
375
441
|
/** session.id -> 最近一小时内的投递时刻(只在本进程内累计)。 */
|
|
376
442
|
const budgetHistory = new Map();
|
|
443
|
+
/** session.id -> 已投递补写 id(跨重启),只用于认出晚到认领,不参与判定。 */
|
|
444
|
+
const guardIdHistory = new Map();
|
|
445
|
+
let guardHydrated = false;
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* 只读文件尾部 maxBytes。
|
|
449
|
+
* 旧版直接 readFileSync 整个 budgetFile:配置写错或文件被替换成几百 MB 时,
|
|
450
|
+
* 这一行会同步卡住宿主的事件循环(实测 64 MB ≈ 544 ms,线性放大)。
|
|
451
|
+
* 现在先 stat,超上限只读尾部;截断点落在行中间时丢掉首个不完整行。
|
|
452
|
+
*/
|
|
453
|
+
const readTail = (file, maxBytes) => {
|
|
454
|
+
const st = fs.statSync(file);
|
|
455
|
+
if (!st.isFile()) return '';
|
|
456
|
+
if (st.size <= maxBytes) return fs.readFileSync(file, 'utf8');
|
|
457
|
+
const fd = fs.openSync(file, 'r');
|
|
458
|
+
try {
|
|
459
|
+
const buf = Buffer.allocUnsafe(maxBytes);
|
|
460
|
+
const read = fs.readSync(fd, buf, 0, maxBytes, st.size - maxBytes);
|
|
461
|
+
let text = buf.subarray(0, read).toString('utf8');
|
|
462
|
+
const nl = text.indexOf('\n');
|
|
463
|
+
if (nl >= 0) text = text.slice(nl + 1);
|
|
464
|
+
log('persisted state read: file is ' + st.size + 'B, tail ' + maxBytes + 'B only ('
|
|
465
|
+
+ file + ')');
|
|
466
|
+
return text;
|
|
467
|
+
} finally { fs.closeSync(fd); }
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* 解析一行预算记录。新格式是 JSON(id 里有空格也不会被截断),
|
|
472
|
+
* 0.2.2 及更早写的是 "id at" 纯文本,两者都认。
|
|
473
|
+
* 时间戳晚于当前时钟 + 容忍窗口的丢弃。
|
|
474
|
+
*/
|
|
475
|
+
const parseBudgetLine = (line, now) => {
|
|
476
|
+
const text = line.trim();
|
|
477
|
+
if (text === '') return null;
|
|
478
|
+
let id = '';
|
|
479
|
+
let at = NaN;
|
|
480
|
+
if (text.charCodeAt(0) === 123 /* '{' */) {
|
|
481
|
+
let rec;
|
|
482
|
+
try { rec = JSON.parse(text); } catch { return null; }
|
|
483
|
+
if (!rec || typeof rec.id !== 'string') return null;
|
|
484
|
+
id = rec.id;
|
|
485
|
+
at = Number(rec.at);
|
|
486
|
+
} else {
|
|
487
|
+
const sp = text.indexOf(' ');
|
|
488
|
+
if (sp <= 0) return null;
|
|
489
|
+
id = text.slice(0, sp);
|
|
490
|
+
at = Number(text.slice(sp + 1).trim());
|
|
491
|
+
}
|
|
492
|
+
if (id === '' || !Number.isFinite(at)) return null;
|
|
493
|
+
if (!withinWindow(at, now)) return null;
|
|
494
|
+
return { id, at };
|
|
495
|
+
};
|
|
377
496
|
|
|
378
497
|
const hydrateBudget = () => {
|
|
379
498
|
if (budgetHydrated) return;
|
|
380
499
|
budgetHydrated = true;
|
|
381
500
|
try {
|
|
382
|
-
const rawText =
|
|
383
|
-
const
|
|
501
|
+
const rawText = readTail(budgetPath, BUDGET_MAX_BYTES);
|
|
502
|
+
const now = Date.now();
|
|
384
503
|
for (const line of rawText.split('\n')) {
|
|
385
|
-
const
|
|
386
|
-
if (
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
const list = budgetHistory.get(id);
|
|
391
|
-
if (list) list.push(at);
|
|
392
|
-
else budgetHistory.set(id, [at]);
|
|
504
|
+
const rec = parseBudgetLine(line, now);
|
|
505
|
+
if (!rec) continue;
|
|
506
|
+
const list = budgetHistory.get(rec.id);
|
|
507
|
+
if (list) list.push(rec.at);
|
|
508
|
+
else budgetHistory.set(rec.id, [rec.at]);
|
|
393
509
|
}
|
|
394
510
|
} catch { /* 文件不存在或读不了:当作没有历史 */ }
|
|
395
511
|
};
|
|
396
512
|
|
|
513
|
+
/**
|
|
514
|
+
* 已投递补写 id 的持久化。
|
|
515
|
+
* 宿主重启/插件热重载后内存态 guardIds 是空的,于是「重启前投递、重启后认领」
|
|
516
|
+
* 的那条消息会被判成 NOT ours,紧接着那个补写回合结束时空正文依然为空,
|
|
517
|
+
* 插件就把它当成真空回合再补一笔(跨重启的连环补写)。
|
|
518
|
+
* 这里只解决「认得出晚到认领」,不参与是否补写的判定。
|
|
519
|
+
*/
|
|
520
|
+
const hydrateGuardIds = () => {
|
|
521
|
+
if (guardHydrated) return;
|
|
522
|
+
guardHydrated = true;
|
|
523
|
+
try {
|
|
524
|
+
const rawText = readTail(guardPath, BUDGET_MAX_BYTES);
|
|
525
|
+
const now = Date.now();
|
|
526
|
+
const floor = now - GUARD_ID_TTL_MS;
|
|
527
|
+
for (const line of rawText.split('\n')) {
|
|
528
|
+
const text = line.trim();
|
|
529
|
+
if (text === '') continue;
|
|
530
|
+
let rec;
|
|
531
|
+
try { rec = JSON.parse(text); } catch { continue; }
|
|
532
|
+
if (!rec || typeof rec.id !== 'string' || typeof rec.sid !== 'string') continue;
|
|
533
|
+
const at = Number(rec.at);
|
|
534
|
+
if (!Number.isFinite(at) || at < floor || at > now + BUDGET_FUTURE_SKEW_MS) continue;
|
|
535
|
+
const list = guardIdHistory.get(rec.sid);
|
|
536
|
+
if (!list) guardIdHistory.set(rec.sid, [rec.id]);
|
|
537
|
+
else if (list.indexOf(rec.id) < 0) list.push(rec.id);
|
|
538
|
+
}
|
|
539
|
+
} catch { /* 无文件:当作没有历史 */ }
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
/** 该会话跨重启保留的补写 id(最多 GUARD_IDS_MAX 条)。 */
|
|
543
|
+
const persistedGuardIds = (session) => {
|
|
544
|
+
hydrateGuardIds();
|
|
545
|
+
const id = session && typeof session.id === 'string' ? session.id : '';
|
|
546
|
+
const list = id !== '' ? guardIdHistory.get(id) : undefined;
|
|
547
|
+
if (!list || list.length === 0) return [];
|
|
548
|
+
return list.slice(-GUARD_IDS_MAX);
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
const recordGuardId = (session, id, at) => {
|
|
552
|
+
const sid = session && typeof session.id === 'string' ? session.id : '';
|
|
553
|
+
if (sid === '' || typeof id !== 'string' || id === '') return;
|
|
554
|
+
try {
|
|
555
|
+
let list = guardIdHistory.get(sid);
|
|
556
|
+
if (!list) { list = []; guardIdHistory.set(sid, list); }
|
|
557
|
+
if (list.indexOf(id) < 0) list.push(id);
|
|
558
|
+
while (list.length > GUARD_IDS_MAX) list.shift();
|
|
559
|
+
fs.mkdirSync(path.dirname(guardPath), { recursive: true });
|
|
560
|
+
fs.appendFileSync(guardPath, JSON.stringify({ sid, id, at }) + '\n');
|
|
561
|
+
} catch { /* 只服务于去重,写失败不影响补写本身 */ }
|
|
562
|
+
};
|
|
563
|
+
|
|
397
564
|
/** 该会话最近一小时的投递时刻(合并持久化记录与本次进程内的记录)。 */
|
|
398
565
|
const historyOf = (session) => {
|
|
399
566
|
hydrateBudget();
|
|
@@ -409,35 +576,62 @@ export function apply(ctx, config) {
|
|
|
409
576
|
* 长时间运行(每会话每小时若干条)会只增不减(P2-2)。
|
|
410
577
|
*/
|
|
411
578
|
let recordTicks = 0;
|
|
579
|
+
|
|
580
|
+
const pruneBudgetHistory = (now) => {
|
|
581
|
+
for (const [sid, times] of budgetHistory) {
|
|
582
|
+
const kept = times.filter((t) => withinWindow(t, now));
|
|
583
|
+
if (kept.length === 0) budgetHistory.delete(sid);
|
|
584
|
+
else if (kept.length !== times.length) budgetHistory.set(sid, kept);
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* 预算文件压缩:先重读磁盘,再原子替换。
|
|
590
|
+
* 旧版直接用内存 map 覆盖写整份文件 —— 多进程(桌面宿主 + 独立 web/headless)
|
|
591
|
+
* 同时运行时,会把对方刚追加的行整段抹掉,预算就少算、于是多补。
|
|
592
|
+
*/
|
|
593
|
+
const compactBudget = (now) => {
|
|
594
|
+
try {
|
|
595
|
+
const rawText = readTail(budgetPath, BUDGET_MAX_BYTES);
|
|
596
|
+
for (const line of rawText.split('\n')) {
|
|
597
|
+
const rec = parseBudgetLine(line, now);
|
|
598
|
+
if (!rec) continue;
|
|
599
|
+
const list = budgetHistory.get(rec.id);
|
|
600
|
+
if (!list) budgetHistory.set(rec.id, [rec.at]);
|
|
601
|
+
else if (list.indexOf(rec.at) < 0) list.push(rec.at);
|
|
602
|
+
}
|
|
603
|
+
} catch { /* 读不到就只用内存态 */ }
|
|
604
|
+
pruneBudgetHistory(now);
|
|
605
|
+
const lines = [];
|
|
606
|
+
for (const [sid, times] of budgetHistory) {
|
|
607
|
+
for (const t of times) lines.push(JSON.stringify({ id: sid, at: t }));
|
|
608
|
+
}
|
|
609
|
+
const tmp = budgetPath + '.tmp-' + process.pid;
|
|
610
|
+
fs.writeFileSync(tmp, lines.length > 0 ? lines.join('\n') + '\n' : '');
|
|
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
|
+
}
|
|
619
|
+
};
|
|
620
|
+
|
|
412
621
|
const recordNudge = (session, at) => {
|
|
413
622
|
const id = session && typeof session.id === 'string' ? session.id : '';
|
|
414
623
|
if (id === '') return;
|
|
415
624
|
try {
|
|
416
625
|
if (!budgetHistory.has(id)) budgetHistory.set(id, []);
|
|
417
626
|
budgetHistory.get(id).push(at);
|
|
418
|
-
const cutoff = Date.now() - HOUR_MS;
|
|
419
627
|
recordTicks += 1;
|
|
420
|
-
let rewrite = false;
|
|
421
|
-
try { rewrite = fs.statSync(budgetPath).size > BUDGET_MAX_BYTES; } catch { /* 首次写入 */ }
|
|
422
|
-
// 每 256 次投递做一次常规修剪:不写文件,只清内存里的过期条目和空会话。
|
|
423
|
-
// 投递受 maxNudgesPerHour 限制,256 次意味着至少几十个会话满额,频率够低。
|
|
424
|
-
if (rewrite || recordTicks % 256 === 0) {
|
|
425
|
-
for (const [sid, times] of budgetHistory) {
|
|
426
|
-
const kept = times.filter((t) => t >= cutoff);
|
|
427
|
-
if (kept.length === 0) budgetHistory.delete(sid);
|
|
428
|
-
else if (kept.length !== times.length) budgetHistory.set(sid, kept);
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
628
|
fs.mkdirSync(path.dirname(budgetPath), { recursive: true });
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
} else {
|
|
439
|
-
fs.appendFileSync(budgetPath, id + ' ' + at + '\n');
|
|
440
|
-
}
|
|
629
|
+
// 只追加、不改写:改写是 read-modify-write,多进程下会覆盖别人刚写的行。
|
|
630
|
+
fs.appendFileSync(budgetPath, JSON.stringify({ id, at }) + '\n');
|
|
631
|
+
let size = 0;
|
|
632
|
+
try { size = fs.statSync(budgetPath).size; } catch { /* 首次写入 */ }
|
|
633
|
+
if (size > BUDGET_MAX_BYTES) compactBudget(Date.now());
|
|
634
|
+
else if (recordTicks % 256 === 0) pruneBudgetHistory(Date.now());
|
|
441
635
|
} catch { /* 限流退化到内存态,不影响补写本身 */ }
|
|
442
636
|
};
|
|
443
637
|
|
|
@@ -473,6 +667,7 @@ export function apply(ctx, config) {
|
|
|
473
667
|
* 「被 scope 静默过滤」在日志里长得一模一样,排障全靠猜。
|
|
474
668
|
*/
|
|
475
669
|
const scopeSeen = new Set();
|
|
670
|
+
let scopeSeenCapped = false;
|
|
476
671
|
|
|
477
672
|
/** header 判定根会话;返回 null 表示无法判定(没有 header)。 */
|
|
478
673
|
const rootByHeader = (session) => {
|
|
@@ -489,7 +684,12 @@ export function apply(ctx, config) {
|
|
|
489
684
|
const logScopeSkip = (session, why) => {
|
|
490
685
|
const id = session && typeof session.id === 'string' ? session.id : '';
|
|
491
686
|
if (id === '' || scopeSeen.has(id)) return;
|
|
492
|
-
if (scopeSeen.size <
|
|
687
|
+
if (scopeSeen.size < SCOPE_SEEN_MAX) {
|
|
688
|
+
scopeSeen.add(id);
|
|
689
|
+
} else if (!scopeSeenCapped) {
|
|
690
|
+
scopeSeenCapped = true;
|
|
691
|
+
log('skip scope log capped at ' + SCOPE_SEEN_MAX + ' sessions; further skips are silent');
|
|
692
|
+
}
|
|
493
693
|
log('skip scope: non-root session ' + id.slice(0, 12) + ' (sessionScope=root, ' + why + ')');
|
|
494
694
|
};
|
|
495
695
|
|
|
@@ -517,46 +717,90 @@ export function apply(ctx, config) {
|
|
|
517
717
|
}
|
|
518
718
|
|
|
519
719
|
// ---- A) system prompt 段落 ----
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
// 宿主对同名 section 重复注册是直接 throw(dsh-system-prompt 的 section
|
|
538
|
-
// 注册会拒绝重名),最常见的触发原因是同一插件被两个 bundle 各装了一次。
|
|
539
|
-
// 此时若照常 arm:两个实例各发一条补写;日志却写着「一切正常」,
|
|
540
|
-
// 而「222 字符的收尾指令其实没进 prompt」这个事实在日志里看不出来。
|
|
541
|
-
log('prompt section registration FAILED: ' + errText(err));
|
|
542
|
-
log('guard NOT armed (fail-closed: prompt section registration failed)');
|
|
543
|
-
return;
|
|
720
|
+
/**
|
|
721
|
+
* 注册收尾指令段落。宿主对同名 section 重复注册会直接 throw,
|
|
722
|
+
* 最常见的触发是「同一插件被两个 bundle 各装一次」,其次才是热重载时
|
|
723
|
+
* 旧段落还没 dispose。所以这里只负责「试一次」,重试与 fail-closed 在下面 arm 处。
|
|
724
|
+
*/
|
|
725
|
+
const registerPromptSection = () => {
|
|
726
|
+
const text = cfg.instructionText.trim() !== '' ? cfg.instructionText : DEFAULT_INSTRUCTION;
|
|
727
|
+
const dispose = ctx.systemPrompt.section({
|
|
728
|
+
name: SECTION_NAME,
|
|
729
|
+
order: SECTION_ORDER,
|
|
730
|
+
text,
|
|
731
|
+
interpolate: false,
|
|
732
|
+
});
|
|
733
|
+
if (typeof dispose === 'function') {
|
|
734
|
+
try {
|
|
735
|
+
ctx.effect(() => () => { try { dispose(); } catch { /* ignore */ } });
|
|
736
|
+
} catch { /* effect 不可用时忽略 */ }
|
|
544
737
|
}
|
|
545
|
-
|
|
738
|
+
log('prompt section registered: order=' + SECTION_ORDER + ' chars=' + text.length);
|
|
739
|
+
};
|
|
546
740
|
|
|
547
741
|
if (!cfg.enabled) {
|
|
548
|
-
|
|
742
|
+
if (cfg.injectInstruction) {
|
|
743
|
+
try {
|
|
744
|
+
registerPromptSection();
|
|
745
|
+
} catch (err) {
|
|
746
|
+
log('prompt section registration FAILED: ' + errText(err));
|
|
747
|
+
log('guard NOT armed (fail-closed: prompt section registration failed)');
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
log(cfg.injectInstruction
|
|
752
|
+
? 'guard disabled by config; only instruction part active'
|
|
753
|
+
: 'guard disabled by config and injectInstruction=false; nothing active');
|
|
549
754
|
return;
|
|
550
755
|
}
|
|
551
756
|
|
|
552
757
|
// ---- C) 空回合保护 ----
|
|
758
|
+
/** 段落注册成功才置 true;置位前所有判定链都不工作(fail-closed)。 */
|
|
759
|
+
let armed = false;
|
|
553
760
|
const agents = new Map(); // session -> agent
|
|
554
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
|
+
*/
|
|
555
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;
|
|
556
775
|
const timers = new Set(); // 所有在跑的定时器,便于 dispose 时一次性清掉
|
|
557
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
|
+
|
|
558
802
|
const stateOf = (session) => {
|
|
559
|
-
let s =
|
|
803
|
+
let s = stateLookup(session);
|
|
560
804
|
if (!s) {
|
|
561
805
|
s = {
|
|
562
806
|
turn: null,
|
|
@@ -595,7 +839,7 @@ export function apply(ctx, config) {
|
|
|
595
839
|
* 那笔「超时」的消息;靠这个列表把晚到的认领仍算作补写回合,
|
|
596
840
|
* 否则第二个空回合会被当成真空回合再补一笔。
|
|
597
841
|
*/
|
|
598
|
-
guardIds:
|
|
842
|
+
guardIds: persistedGuardIds(session),
|
|
599
843
|
/**
|
|
600
844
|
* 定时器是为哪个回合排的;回合变了就该丢弃。
|
|
601
845
|
* 与 turn/end 的清零无关:turn/end 先清 s.turn 之外的字段不该顺手把它清空,
|
|
@@ -606,7 +850,19 @@ export function apply(ctx, config) {
|
|
|
606
850
|
nudgeTimes: historyOf(session),
|
|
607
851
|
timer: null,
|
|
608
852
|
};
|
|
609
|
-
|
|
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);
|
|
610
866
|
}
|
|
611
867
|
return s;
|
|
612
868
|
};
|
|
@@ -723,7 +979,7 @@ export function apply(ctx, config) {
|
|
|
723
979
|
const agent = payload && payload.agent ? payload.agent : payload;
|
|
724
980
|
const session = agent && agent.session;
|
|
725
981
|
if (!session) return;
|
|
726
|
-
const s =
|
|
982
|
+
const s = stateLookup(session);
|
|
727
983
|
if (s) {
|
|
728
984
|
clearTimer(s, null);
|
|
729
985
|
clearNudgeInFlight(s, null);
|
|
@@ -732,7 +988,12 @@ export function apply(ctx, config) {
|
|
|
732
988
|
s.guardIds = [];
|
|
733
989
|
}
|
|
734
990
|
agents.delete(session);
|
|
735
|
-
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
|
+
}
|
|
736
997
|
} catch (err) {
|
|
737
998
|
log('agent/disposed handler error: ' + errText(err));
|
|
738
999
|
}
|
|
@@ -754,7 +1015,7 @@ export function apply(ctx, config) {
|
|
|
754
1015
|
const agent = (payload && payload.agent) || payload;
|
|
755
1016
|
const session = agent && agent.session;
|
|
756
1017
|
if (!session) return;
|
|
757
|
-
const s =
|
|
1018
|
+
const s = stateLookup(session);
|
|
758
1019
|
if (!s) return;
|
|
759
1020
|
const message = payload && payload.message;
|
|
760
1021
|
const id = message && typeof message.id === 'string' ? message.id : '';
|
|
@@ -788,16 +1049,52 @@ export function apply(ctx, config) {
|
|
|
788
1049
|
* 各装一次时它都是空的,于是会对同一个空回合各投一笔,模型连着被唤醒两次。
|
|
789
1050
|
* 队列本身是宿主维护的真相(agent.inbox.nextTurn),以它为准去重。
|
|
790
1051
|
*/
|
|
791
|
-
|
|
1052
|
+
/**
|
|
1053
|
+
* 宿主「下一回合队列」的能力探测。
|
|
1054
|
+
* `agent.inbox.nextTurn` 是宿主当前实现里的公开字段,但插件不该假设它永远在:
|
|
1055
|
+
* 宿主把它改私有/改名/换实现时,旧写法会安静地恒返回 false —— 去重失效、
|
|
1056
|
+
* 重复补写,日志里却没有任何线索。这里探测一次并留下明确的一行。
|
|
1057
|
+
*/
|
|
1058
|
+
let inboxProbe = 'unknown';
|
|
1059
|
+
|
|
1060
|
+
const nextTurnQueue = (agent) => {
|
|
1061
|
+
if (!agent) return null;
|
|
792
1062
|
try {
|
|
793
|
-
const inbox = agent
|
|
1063
|
+
const inbox = agent.inbox;
|
|
794
1064
|
const queue = inbox && Array.isArray(inbox.nextTurn) ? inbox.nextTurn : null;
|
|
795
|
-
if (!queue)
|
|
796
|
-
|
|
797
|
-
|
|
1065
|
+
if (!queue) {
|
|
1066
|
+
if (inboxProbe !== 'unavailable') {
|
|
1067
|
+
inboxProbe = 'unavailable';
|
|
1068
|
+
log('inbox dedupe UNAVAILABLE: host no longer exposes agent.inbox.nextTurn'
|
|
1069
|
+
+ ' -> cross-instance dedupe degraded (claim-id matching still applies)');
|
|
1070
|
+
}
|
|
1071
|
+
return null;
|
|
1072
|
+
}
|
|
1073
|
+
if (inboxProbe !== 'available') {
|
|
1074
|
+
inboxProbe = 'available';
|
|
1075
|
+
log('inbox dedupe available (agent.inbox.nextTurn)');
|
|
1076
|
+
}
|
|
1077
|
+
return queue;
|
|
1078
|
+
} catch (err) {
|
|
1079
|
+
if (inboxProbe !== 'unavailable') {
|
|
1080
|
+
inboxProbe = 'unavailable';
|
|
1081
|
+
log('inbox dedupe UNAVAILABLE: probe threw ' + errText(err));
|
|
1082
|
+
}
|
|
1083
|
+
return null;
|
|
1084
|
+
}
|
|
1085
|
+
};
|
|
1086
|
+
|
|
1087
|
+
const pendingNudgeInInbox = (agent) => {
|
|
1088
|
+
const queue = nextTurnQueue(agent);
|
|
1089
|
+
if (!queue) return false;
|
|
1090
|
+
return queue.some((m) => m && m.source && m.source.kind === SOURCE_KIND);
|
|
798
1091
|
};
|
|
799
1092
|
|
|
800
1093
|
const deliver = (session, text) => {
|
|
1094
|
+
if (!armed) {
|
|
1095
|
+
log('nudge SKIPPED [' + tag(session) + ']: guard not armed (prompt section missing)');
|
|
1096
|
+
return null;
|
|
1097
|
+
}
|
|
801
1098
|
const agent = agentOf(session);
|
|
802
1099
|
if (!agent) {
|
|
803
1100
|
log('nudge SKIPPED [' + tag(session) + ']: no live agent bound to session');
|
|
@@ -820,6 +1117,7 @@ export function apply(ctx, config) {
|
|
|
820
1117
|
const fn = attempts[i][1];
|
|
821
1118
|
try {
|
|
822
1119
|
fn();
|
|
1120
|
+
stats.delivered += 1;
|
|
823
1121
|
log('nudge DELIVERED via ' + label + ' [' + tag(session) + ']');
|
|
824
1122
|
return message;
|
|
825
1123
|
} catch (err) {
|
|
@@ -830,11 +1128,24 @@ export function apply(ctx, config) {
|
|
|
830
1128
|
return null;
|
|
831
1129
|
};
|
|
832
1130
|
|
|
1131
|
+
/**
|
|
1132
|
+
* 修剪本会话最近一小时的投递时刻,返回窗口内还剩几次。
|
|
1133
|
+
* 未来时间戳的丢弃规则统一走 withinWindow(见该函数的注释)。
|
|
1134
|
+
*/
|
|
833
1135
|
const pruneBudget = (s, now) => {
|
|
834
|
-
s.nudgeTimes = s.nudgeTimes.filter((t) =>
|
|
1136
|
+
s.nudgeTimes = s.nudgeTimes.filter((t) => withinWindow(t, now));
|
|
835
1137
|
return s.nudgeTimes.length;
|
|
836
1138
|
};
|
|
837
1139
|
|
|
1140
|
+
/**
|
|
1141
|
+
* 本会话窗口内的补写次数是否已达上限。
|
|
1142
|
+
* 「定时器到点时」与「turn/end 判定时」各查一次(两者相隔 delayMs),
|
|
1143
|
+
* 两处必须用同一套修剪+比较;写成分散表达式时,一处改了窗口、另一处没改
|
|
1144
|
+
* 就会从某条路径漏限流。
|
|
1145
|
+
*/
|
|
1146
|
+
const budgetExhausted = (s, now = Date.now()) =>
|
|
1147
|
+
pruneBudget(s, now) >= cfg.maxNudgesPerHour;
|
|
1148
|
+
|
|
838
1149
|
/**
|
|
839
1150
|
* 排一笔补写。返回结果字符串,供调用侧决定是否把本回合标记为「已处理」:
|
|
840
1151
|
* 'scheduled' | 'dry-run' | 'skipped:<原因>' | 'error'
|
|
@@ -887,15 +1198,18 @@ export function apply(ctx, config) {
|
|
|
887
1198
|
return;
|
|
888
1199
|
}
|
|
889
1200
|
// 预算按「真正投递成功」计,取消/失败不占额度。
|
|
890
|
-
if (
|
|
1201
|
+
if (budgetExhausted(s)) {
|
|
891
1202
|
log('hourly nudge budget exhausted (' + s.nudgeTimes.length + ') -> no nudge');
|
|
892
1203
|
return;
|
|
893
1204
|
}
|
|
894
1205
|
if (cfg.dryRun) {
|
|
895
1206
|
// 干跑:判定链、限流、日志全部照走,只是不把消息交给宿主。
|
|
896
1207
|
// 这样可以在真实宿主里验证「会判空 → 会想补」而不产生任何模型回合。
|
|
897
|
-
|
|
898
|
-
|
|
1208
|
+
// 但 dryRun 只做预算检查、不记账也不落盘,所以它验证不了「限流真的生效」;
|
|
1209
|
+
// 日志里带上 budgetUsed,至少能读出当前窗口已经用掉多少。
|
|
1210
|
+
log('nudge DRY-RUN: would deliver turn=' + turn + ' delay=' + delay
|
|
1211
|
+
+ 'ms budgetUsed=' + s.nudgeTimes.length + '/' + cfg.maxNudgesPerHour
|
|
1212
|
+
+ ' (no delivery, no budget persist) [' + tag(session) + ']');
|
|
899
1213
|
return;
|
|
900
1214
|
}
|
|
901
1215
|
const message = deliver(session, NUDGE_TEXT);
|
|
@@ -907,10 +1221,12 @@ export function apply(ctx, config) {
|
|
|
907
1221
|
s.nudgeTimes.push(Date.now());
|
|
908
1222
|
if (typeof message.id === 'string' && message.id !== '') {
|
|
909
1223
|
s.guardIds.push(message.id);
|
|
910
|
-
while (s.guardIds.length >
|
|
1224
|
+
while (s.guardIds.length > GUARD_IDS_MAX) s.guardIds.shift();
|
|
911
1225
|
}
|
|
912
1226
|
// 跨重启限流:把投递时刻落盘,重启后的新进程回读最近一小时。
|
|
913
1227
|
recordNudge(session, s.nudgeDeliveredAt);
|
|
1228
|
+
// 跨重启认领:把这笔 id 落盘,重启后宿主认领它时仍能认出是补写回合。
|
|
1229
|
+
recordGuardId(session, message.id, s.nudgeDeliveredAt);
|
|
914
1230
|
log('nudge awaiting claim id=' + String(message.id).slice(0, 8) + ' [' + tag(session) + ']');
|
|
915
1231
|
} else {
|
|
916
1232
|
clearNudgeInFlight(s, null);
|
|
@@ -934,6 +1250,9 @@ export function apply(ctx, config) {
|
|
|
934
1250
|
try {
|
|
935
1251
|
ctx.on('session/event', (session, event) => {
|
|
936
1252
|
try {
|
|
1253
|
+
// arm 之前(段落还没注册成功)不判定、不补写:宁可少补,也不能在
|
|
1254
|
+
// 「收尾指令没进 prompt」的状态下往会话里塞自动消息。
|
|
1255
|
+
if (!armed) return;
|
|
937
1256
|
if (!session || !event || typeof event.type !== 'string') return;
|
|
938
1257
|
if (!sessionInScope(session)) return;
|
|
939
1258
|
const data = event.data || {};
|
|
@@ -972,6 +1291,7 @@ export function apply(ctx, config) {
|
|
|
972
1291
|
return;
|
|
973
1292
|
}
|
|
974
1293
|
s.awaitingGuardTurn = true;
|
|
1294
|
+
stats.claimed += 1;
|
|
975
1295
|
log('guard message claimed' + (late ? ' (LATE, from guardIds)' : '')
|
|
976
1296
|
+ ' turn=' + (typeof data.turn === 'number' ? data.turn : '?')
|
|
977
1297
|
+ ' matched=true [' + tag(session) + ']');
|
|
@@ -997,9 +1317,10 @@ export function apply(ctx, config) {
|
|
|
997
1317
|
const message = data.message;
|
|
998
1318
|
if (!message || message.role !== 'assistant') return;
|
|
999
1319
|
if (typeof data.turn === 'number' && s.turn !== data.turn) resetTurnCounters(s, data.turn);
|
|
1320
|
+
const seen = inspectMessage(message);
|
|
1000
1321
|
s.assistantMessages += 1;
|
|
1001
|
-
s.visibleChars +=
|
|
1002
|
-
if (
|
|
1322
|
+
s.visibleChars += seen.chars;
|
|
1323
|
+
if (seen.hasNonText) s.visibleNonText = true;
|
|
1003
1324
|
return;
|
|
1004
1325
|
}
|
|
1005
1326
|
|
|
@@ -1029,9 +1350,19 @@ export function apply(ctx, config) {
|
|
|
1029
1350
|
|
|
1030
1351
|
const sawStart = s.sawTurnStart === true;
|
|
1031
1352
|
const empty = !s.visibleNonText && s.visibleChars < cfg.minVisibleChars;
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
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));
|
|
1035
1366
|
|
|
1036
1367
|
if (!sawStart) {
|
|
1037
1368
|
// 插件在回合中途才加载/热重载:看不到 turn/start,也就漏掉了该回合早前的
|
|
@@ -1057,7 +1388,7 @@ export function apply(ctx, config) {
|
|
|
1057
1388
|
return;
|
|
1058
1389
|
}
|
|
1059
1390
|
const now = Date.now();
|
|
1060
|
-
if (
|
|
1391
|
+
if (budgetExhausted(s, now)) {
|
|
1061
1392
|
log('hourly nudge budget exhausted (' + s.nudgeTimes.length + ') -> no nudge');
|
|
1062
1393
|
return;
|
|
1063
1394
|
}
|
|
@@ -1084,11 +1415,46 @@ export function apply(ctx, config) {
|
|
|
1084
1415
|
try { clearTimeout(timer); } catch { /* ignore */ }
|
|
1085
1416
|
}
|
|
1086
1417
|
timers.clear();
|
|
1418
|
+
// 卸载时打一行 final 统计:短命进程(频繁重启的宿主)也留得下
|
|
1419
|
+
// 「这段时间到底判了多少回合、补了几笔」的证据,不必等满 50 个回合。
|
|
1420
|
+
try { log(statsLine('final')); } catch { /* 日志失败不影响卸载 */ }
|
|
1087
1421
|
});
|
|
1088
1422
|
} catch { /* effect 不可用时忽略 */ }
|
|
1089
1423
|
|
|
1090
|
-
|
|
1091
|
-
|
|
1424
|
+
// ---- A) 段落注册 + arm ----
|
|
1425
|
+
// 注册放在最后:热重载时旧段落可能还没 dispose,注册会抛;这里重试几次,
|
|
1426
|
+
// 只有注册成功才 arm —— 「段落没进 prompt 就不补写」的 fail-closed 语义不变,
|
|
1427
|
+
// 但不再因为一次瞬时冲突让整个插件永久停摆。
|
|
1428
|
+
const armGuard = () => {
|
|
1429
|
+
armed = true;
|
|
1430
|
+
log('guard armed (instruction=' + (cfg.injectInstruction ? 'registered' : 'disabled')
|
|
1431
|
+
+ ', uptime=' + (Date.now() - startedAt) + 'ms)');
|
|
1432
|
+
};
|
|
1433
|
+
const tryArm = (attempt) => {
|
|
1434
|
+
if (!cfg.injectInstruction) {
|
|
1435
|
+
armGuard();
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
try {
|
|
1439
|
+
registerPromptSection();
|
|
1440
|
+
armGuard();
|
|
1441
|
+
} catch (err) {
|
|
1442
|
+
log('prompt section registration FAILED (attempt ' + (attempt + 1) + '/'
|
|
1443
|
+
+ (SECTION_RETRY_MAX + 1) + '): ' + errText(err));
|
|
1444
|
+
if (attempt < SECTION_RETRY_MAX) {
|
|
1445
|
+
const retry = setTimeout(() => {
|
|
1446
|
+
timers.delete(retry);
|
|
1447
|
+
tryArm(attempt + 1);
|
|
1448
|
+
}, SECTION_RETRY_DELAY_MS);
|
|
1449
|
+
if (retry && typeof retry.unref === 'function') retry.unref();
|
|
1450
|
+
timers.add(retry);
|
|
1451
|
+
return;
|
|
1452
|
+
}
|
|
1453
|
+
log('guard NOT armed (fail-closed: prompt section registration failed after '
|
|
1454
|
+
+ (SECTION_RETRY_MAX + 1) + ' attempts)');
|
|
1455
|
+
}
|
|
1456
|
+
};
|
|
1457
|
+
tryArm(0);
|
|
1092
1458
|
}
|
|
1093
1459
|
|
|
1094
1460
|
export default { name, inject, apply };
|
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",
|