dsh-turn-recovery 0.2.3 → 0.2.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/CHANGELOG.md +102 -0
- package/README.md +27 -8
- package/lib/index.js +414 -84
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,108 @@
|
|
|
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.5] - 2026-11-12
|
|
11
|
+
|
|
12
|
+
**宿主契约修正版**。三专家评审(架构 / 安全 / 验收)对着宿主 0.2.2-rc.1 的
|
|
13
|
+
`app.asar` 源码与 8 天生产日志复核 0.2.4,查出一个「处理器在真实宿主上永远空转」
|
|
14
|
+
的契约漂移、一条会把模型输出发给第三方的越权路径,以及若干状态文件与路径问题。
|
|
15
|
+
|
|
16
|
+
### 修复(缺陷)
|
|
17
|
+
|
|
18
|
+
- **`agent/inbox/discarded` 载荷漂移(P1)**:宿主 0.2.2-rc.1 只发
|
|
19
|
+
`{ message }`(`dsh-agent-loop/lib/index.js:199`,已从 asar 逐行核实),
|
|
20
|
+
而 0.2.4 读的是 `payload.agent` → 处理器永远在第一行 `return`:用户点「停止」
|
|
21
|
+
后待发补写不会被撤销,仍会多跑一个模型回合,且 `DISCARDED by host` 日志永不出现。
|
|
22
|
+
改为按 `message.id` 反查 `stateById` 中的在途补写,不再依赖载荷里的 agent/session。
|
|
23
|
+
单测夹具同步改成宿主真实形状(旧夹具自造 `{agent, message}`,所以 65 例全绿也测不到)。
|
|
24
|
+
- **来源闸门(P1)**:新增 `nudgeSources`。`turn/end` 会检查本回合最后一条
|
|
25
|
+
`user/message` 的 `source.kind`,只有落在白名单(默认 = 前台白名单)才补写。
|
|
26
|
+
社交桥(`wechat-social-bridge` / `form: relay`)默认被排除:桥接器会把模型输出
|
|
27
|
+
发回给真人联系人,补写等于**替你对外发言**。
|
|
28
|
+
- **伪造认领不得释放在途标记(P2)**:`clearNudgeInFlight` 原来在「id 不匹配即忽略」
|
|
29
|
+
之前无条件执行,同进程任一插件 append 一条同 kind、随意 id 的消息就能让守卫
|
|
30
|
+
多唤醒一次模型。现在只在真的对上 id 时清;过期由 120s 认领超时兜底。
|
|
31
|
+
- **header 缺失时 fail-closed(P2)**:新增 `headerlessScope`,默认 `'out'`(不判定)。
|
|
32
|
+
旧版按 id 形态把 `session-*` 当根会话,可能给「无 header 的子代理」补写,
|
|
33
|
+
破坏委派语义。老宿主兼容可显式设 `'root'`。
|
|
34
|
+
- **`sawTurnStart` 不再被误清(P2)**:`turn/start` 缺数值 `turn` 时,
|
|
35
|
+
后续 `assistant/message` 触发的重置会把 `sawTurnStart` 一并清成 false,
|
|
36
|
+
整个回合被判成「中途加载」而跳过判定。现在只补记 turn 号、保留 `sawTurnStart`。
|
|
37
|
+
- **`logFile` / `budgetFile` 路径校验(P2)**:自定义路径必须 `.log` 结尾,否则回落默认并记
|
|
38
|
+
config issue;轮转前检查文件头是否为本插件日志(首字节 `[`),不是就拒绝 rename/truncate ——
|
|
39
|
+
避免一次配置手滑不可逆地毁掉无关文件。
|
|
40
|
+
- **状态文件压缩(P2)**:`guard-ids.log` 此前只增不减,超 1 MiB 后读侧按字节截断会把
|
|
41
|
+
TTL 内的旧记录丢掉(晚到认领认不出 → 连环补写)。现在与预算文件同法按 TTL 原子压缩。
|
|
42
|
+
预算/guard 压缩时若文件里有内容却一条都解析不出来,判定为「不是我们的文件」并拒绝重写。
|
|
43
|
+
|
|
44
|
+
### 加固
|
|
45
|
+
|
|
46
|
+
- `stateById` 改为 LRU(命中刷新插入序),避免长跑 >500 会话时把活跃会话按插入序淘汰。
|
|
47
|
+
- 日志压平扩展为 `\r \n U+2028 U+2029 ESC`(部分查看器把前两者当换行、把 ESC 当终端着色)。
|
|
48
|
+
- 状态文件压缩、路径拒绝、来源拦截都会留一行日志,便于现场判定「为什么没补写」。
|
|
49
|
+
|
|
50
|
+
### 测试
|
|
51
|
+
|
|
52
|
+
- 65 → 76 例。新增:真实形状的丢弃事件(含陌生 id)、来源闸门(默认拦 / 显式放开)、
|
|
53
|
+
headerlessScope 两种取值、`sawTurnStart` 补记、guard-ids 压缩、`logFile` 回落、控制字符压平。
|
|
54
|
+
- 测试夹具默认带根会话 header(0.2.5 起无 header 视为范围外),需要测无 header 时传 `__noHeader`。
|
|
55
|
+
|
|
56
|
+
### 已知遗留
|
|
57
|
+
|
|
58
|
+
- 「`agent/inbox/discarded` 是否真的广播到插件根 `ctx`」仍**未验证**;
|
|
59
|
+
收不到时 900ms 停止窗口依然存在(README 已如实标注)。
|
|
60
|
+
- 补写投递 → 认领 → 跨重启去重整条链在真实宿主上仍**零生产证据**
|
|
61
|
+
(`delivered=0 claimed=0`),需按 README 的 dryRun 流程取证。
|
|
62
|
+
|
|
63
|
+
## [0.2.4] - 2026-11-09
|
|
64
|
+
|
|
65
|
+
**运行期复核版**。0.2.3 之后按 8 天生产日志(645 个 `turn/end`)回头复查代码,
|
|
66
|
+
修掉一处「装了但可能静默不工作」的状态索引隐患,并清掉每回合都要付的同步与
|
|
67
|
+
日志开销;顺带把「守卫究竟为什么没触发」变成一行可读的聚合统计。
|
|
68
|
+
|
|
69
|
+
### 修复
|
|
70
|
+
|
|
71
|
+
- **会话状态只按 session 对象身份索引**(`P1`):`state` 是 WeakMap,而宿主在
|
|
72
|
+
不同代码路径上可能给出不同的 session 实例(同文件的 `agentOf` 早就为此加了
|
|
73
|
+
`agentsById`)。一旦身份漂移,`turn/start` 落在对象 A、`assistant/message` 与
|
|
74
|
+
`turn/end` 落在对象 B,B 的状态里 `sawTurnStart` 恒为 false,判定链整体早退,
|
|
75
|
+
只留一行 `turn/start not observed` —— 与 0.2.1 修过的「orchestrator-* 被判成
|
|
76
|
+
子代理、19 次静默跳过」是同一类故障。现在加有界的 `stateById`(500 条)双索引,
|
|
77
|
+
`agent/disposed` 时两个索引同时清理。
|
|
78
|
+
- **布尔配置拼错不会被发现**:`enabled: "flase"` 被 `enabledOr` 当成 `true` 且
|
|
79
|
+
无人提示。布尔键现在只承认 `true/false/1/0/on/off/yes/no`,其余记一条
|
|
80
|
+
`config issue: "enabled" looks like a typo`。
|
|
81
|
+
- **`foregroundKinds` 里的非字符串元素被静默丢弃**:现在记一条 config issue。
|
|
82
|
+
- **预算文件压缩失败会留下 `.tmp-<pid>`**:`rename` 失败(Windows 上 EPERM/EBUSY)
|
|
83
|
+
时先 unlink 再抛,不再往 state 目录里积垃圾。
|
|
84
|
+
|
|
85
|
+
### 性能 / 日志
|
|
86
|
+
|
|
87
|
+
- **每行日志都同步 `mkdirSync`**:目录改为只建一次(写失败复位、下次自愈),
|
|
88
|
+
少掉宿主事件循环上每行一次的同步系统调用。
|
|
89
|
+
- **正常回合的 `turn/end` 行占日志一半**(实测 645/1286 行):新增
|
|
90
|
+
`logTurns: 'empty' | 'all'`,默认 `empty` 只在空回合/跳过判定时写行,排查期切
|
|
91
|
+
`all` 恢复逐回合记录。
|
|
92
|
+
- **每条 assistant 消息两遍遍历 + 一次 `Array.from()`**:合并成一次遍历的
|
|
93
|
+
`inspectMessage()`,字符数用 `for...of` 累加(仍给精确总数,日志的
|
|
94
|
+
`visibleChars` 不退化成「至少 1」)。
|
|
95
|
+
- **`GUARD_IDS_MAX` 与字面量 `8` 脱钩**:投递时的截断改用常量。
|
|
96
|
+
- **一小时预算谓词写了 4 遍**:抽成 `withinWindow(at, now)` 与
|
|
97
|
+
`budgetExhausted(s, now)`,窗口与时钟容忍度以后只改一处。
|
|
98
|
+
|
|
99
|
+
### 可观测性
|
|
100
|
+
|
|
101
|
+
- **新增 `stats:` 聚合行**(每 50 个回合 + 卸载时 `(final)`):
|
|
102
|
+
`turns / emptyCompleted / emptyOther / delivered / claimed`。生产经验是
|
|
103
|
+
`completed` 空回合 8 天为 0、空回合全是 `steps=0` 的 `error`/`aborted`;有了
|
|
104
|
+
这行就能区分「守卫没触发是因为目标场景没发生」和「守卫坏了」。
|
|
105
|
+
|
|
106
|
+
### 文档
|
|
107
|
+
|
|
108
|
+
- 更正 README「已知限制」里的实测数据:空回合不是「`aborted` / `blocked`」,
|
|
109
|
+
而是 `error` 162 / `aborted` 20、`completed` 0;并补上 `logTurns`、
|
|
110
|
+
`stats` 与状态双索引的说明。
|
|
111
|
+
|
|
10
112
|
## [0.2.3] - 2026-11-05
|
|
11
113
|
|
|
12
114
|
**三方复核(架构 / 安全 / 验收)后的加固版**。0.2.2 的判定链没有 P0 缺陷,但复核在
|
package/README.md
CHANGED
|
@@ -64,13 +64,18 @@
|
|
|
64
64
|
maxNudgesPerHour: 3 # 每会话每小时的补写上限(0 = 关闭补写)
|
|
65
65
|
sessionScope: root # root = 只处理根会话(按 SessionHeader 判定);all = 连子代理会话一起
|
|
66
66
|
foregroundKinds: [] # 追加「会取消待发补写」的来源 kind(默认 user / agent-message)
|
|
67
|
+
nudgeSources: [] # 允许被补写的「回合发起来源」;空 = 沿用 foregroundKinds
|
|
68
|
+
# 社交桥(wechat-social-bridge 等)默认**不在**其中:模型输出
|
|
69
|
+
# 可能被转发给第三方联系人,补写等于替你对外发言
|
|
70
|
+
headerlessScope: out # 会话无 SessionHeader 时的判定:out = 不碰(默认);root = 沿用旧版 id 形态猜
|
|
67
71
|
budgetFile: '' # 空 = <DSH_HOME>/state/turn-recovery/nudges.log(跨重启限流)
|
|
68
|
-
#
|
|
72
|
+
# 必须 .log 结尾;同目录还会写 guard-ids.log(跨重启认出晚到认领的补写 id)
|
|
69
73
|
logFile: '' # 空 = <DSH_HOME>/logs/turn-recovery.log
|
|
74
|
+
logTurns: empty # empty = 只记空回合/被跳过判定的回合;all = 连正常回合也记
|
|
70
75
|
dryRun: false # true = 判定/限流/日志全部照走,但不真的投递(验证用)
|
|
71
76
|
```
|
|
72
77
|
|
|
73
|
-
配置做严格解析:`maxNudgesPerHour: 0` 是**关闭补写**,不会被默认值吞成 3;`delayMs: "abc"` 这类非法值回落到默认值;`enabled: "false"`(字符串)与 `false`
|
|
78
|
+
配置做严格解析:`maxNudgesPerHour: 0` 是**关闭补写**,不会被默认值吞成 3;`delayMs: "abc"` 这类非法值回落到默认值;`enabled: "false"`(字符串)与 `false` 等价。布尔键只承认 `true/false/1/0/on/off/yes/no`,`enabled: "flase"` 这类拼错会被记成 config issue(行为仍按真值走,所以要留意日志)。
|
|
74
79
|
|
|
75
80
|
宿主**不校验**插件配置(本包不导出 `Config` schema,原因见「实现约束」),所以键名写错不会被谁拦下来。插件改为在加载期自查:未知键、类型不符、`sessionScope` 取值不对等,逐条写进日志(`config issue: "minNudgesPerHour" is unknown -> ignored`),插件照常启用。改完配置先看这几行,比猜「为什么没生效」划算。
|
|
76
81
|
|
|
@@ -102,13 +107,25 @@
|
|
|
102
107
|
[2026-10-25T02:03:45.004Z] guard message claimed turn=5 matched=true [5f884825]
|
|
103
108
|
```
|
|
104
109
|
|
|
105
|
-
|
|
110
|
+
正常回合默认**不写** `turn/end` 行(`logTurns: empty`):实测这类行占日志的一半,而它只在「某个回合为什么没被判空」时才有人看。要看逐回合记录就把 `logTurns` 切成 `all`,此时格式同上。空回合的 `empty=true` 行在任何粒度下都会留下。
|
|
111
|
+
|
|
112
|
+
每 50 个回合会多一行聚合统计,卸载/重载时再打一行 `(final)`:
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
[…Z] stats: turns=50 emptyCompleted=0 emptyOther=37 delivered=0 claimed=0
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`emptyCompleted` 是守卫真正要救的那一类(`completed` 却没有正文);`emptyOther` 是 `error` / `aborted` 等按设计不补写的空回合。长期出现 `emptyCompleted=0` 而 `emptyOther>0`,说明「守卫没触发」是因为目标场景本身没发生,而不是插件坏了;只有 `delivered` 与 `claimed` 的差值才说明投递链路是否真的接通。
|
|
119
|
+
|
|
120
|
+
出现 `empty=true` 时应紧跟 `nudge outcome turn=N: scheduled` 与 `nudge DELIVERED`,再往后出现 `guard message claimed ... matched=true` 才说明补写回合真的起来了。
|
|
121
|
+
|
|
122
|
+
`[...]` 是会话 id(剥掉 `session-` / `orchestrator-` / `worker-` 前缀)前 8 位,`wasGuard` 表示该回合是不是补写回合,`sawStart` 表示插件是否看到过该回合的 `turn/start`(中途热加载时为 false,此时不判定)。
|
|
106
123
|
|
|
107
124
|
只想验证判定链、不想真的多跑一轮模型时,把 `dryRun` 打开,日志会写 `nudge DRY-RUN: would deliver turn=N` 并停在那里(既不投递,也不占小时预算)。
|
|
108
125
|
|
|
109
126
|
`version=` 是进程里真正加载的版本号(从包自身 `package.json` 读)——**升级后先看这一行**,不要靠「某个日志字段有没有」推断跑的是哪一版(0.1.4 与 0.1.5 都打过 `sawStart`,这个推断会错)。
|
|
110
127
|
|
|
111
|
-
排查常用短语:`config issue`(配置写错,插件仍启用)、`skip scope`(被 `sessionScope` 过滤掉的会话,括号里写明是 `header says subagent` 还是 `
|
|
128
|
+
排查常用短语:`config issue`(配置写错,插件仍启用)、`skip scope`(被 `sessionScope` 过滤掉的会话,括号里写明是 `header says subagent` 还是 `no header (headerlessScope=out)`)、`scope fallback`(`headerlessScope: 'root'` 时靠 id 形态放行的会话,首次记一行)、`guard NOT armed (fail-closed`(同名 prompt 段落已被占用,插件整体不启用,不再谎报 armed)、`inbox already holds a pending turn-recovery nudge`(队列里已有一笔,未重复投递)、`turn/start not observed`(热加载中途,不判定)、`NOT ours`(同来源但 id 对不上,已忽略;0.2.5 起不再因此清在途标记)、`LATE, from guardIds`(认领超时后才到,仍按补写回合处理)、`DISCARDED by host`(用户停止,补写被丢弃)、`is not an in-flight nudge`(丢弃事件里的 id 不是我们的在途补写)、`origin=… not in nudge sources`(来源闸门拦下的空回合,例如社交桥)、`a nudge is already awaiting claim`(上一笔还没被认领)、`skipped:in-flight`(本回合没排上,因为上一笔仍在途 —— 不算「已补过」)、`turn N already handled`(该回合确实排过或确实被限流)、`DROPPED: turn moved on`(定时器到点时会话已进入下一回合)、`stop chaining`(补写回合又空,按设计不再连环)、`budget exhausted`(本小时额度用完)、`guard ids compacted` / `budget compact skipped`(状态文件压缩结果)、`log rotate skipped: file does not look like`(`logFile` 配到了别的文件,拒绝轮转它)。
|
|
112
129
|
|
|
113
130
|
## 实现约束
|
|
114
131
|
|
|
@@ -125,16 +142,18 @@
|
|
|
125
142
|
|
|
126
143
|
## 已知限制
|
|
127
144
|
|
|
145
|
+
- **会话状态是双索引**:`state` 是 WeakMap(按 session 对象身份,快路径),另有有界(500 条)的 `stateById`(按 `session.id`,兜底)。宿主在某些代码路径上会给出不同的 session 实例;只按对象身份索引时,`turn/start` 与 `turn/end` 会落在两份状态上,`sawTurnStart` 恒为 false、判定链整体静默早退(只留一行 `turn/start not observed`)。现在两个索引同时写,`agent/disposed` 时同时清。
|
|
146
|
+
- **补写只在「人类发起」的回合里发生(0.2.5 来源闸门)**:`turn/end` 会检查本回合最后一条 `user/message` 的 `source.kind`,只有落在 `nudgeSources`(默认等于前台白名单 `user` / `agent-message`)里才补写。原因是社交桥(`source.kind='wechat-social-bridge'`, `form='relay'`)这类会话里,桥接器会把模型输出发回给真实的微信/QQ 联系人 —— 插件往这种会话塞一句"请直接输出纯文本",等于**替你对外发言**。需要给桥接对话也保留保护时,把该 kind 写进 `nudgeSources` 显式开启。本回合没有 `user/message`(拿不到来源)时按"无法判定"放行,日志里能看到判定过程。
|
|
128
147
|
- **补写消息以 `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
|
-
- **用户点停止的窗口**:宿主 `cancel()` 只清 inbox + abort,不 append 任何 session
|
|
130
|
-
- **`sessionScope: 'root'` 的判定依据是宿主的 `Session.header`**:`origin === 'subagent'` 或 `delegationDepth > 0` 才算子代理会话,`orchestrator-*` 这类顶层会话会被正常处理(0.2.0 用 id 前缀判断,把它们全部判成子代理,实测日志里有 19
|
|
148
|
+
- **用户点停止的窗口**:宿主 `cancel()` 只清 inbox + abort,不 append 任何 session 事件。插件订阅宿主的 `agent/inbox/discarded` 来撤掉自己那条补写。0.2.4 及更早读的是 `payload.agent`,而宿主 0.2.2-rc.1 实际只发 `{ message }`(`dsh-agent-loop/lib/index.js:199`),于是处理器永远在第一行 return —— 0.2.5 改为按 `message.id` 反查在途补写,真实形状下才会真的撤掉定时器。**仍未验证**的是:该事件是否向插件的根 `ctx` 广播(宿主在自己的 `ownerCtx` 上监听 `agent/disposed`,说明 agent 作用域事件存在)。收不到时,「用户已点停止、插件仍在 `delayMs` 后补写」这个窗口依然存在(默认 900ms,可通过 `delayMs` 调小)。真正发出去的那一回合仍受宿主 abort 语义约束。
|
|
149
|
+
- **`sessionScope: 'root'` 的判定依据是宿主的 `Session.header`**:`origin === 'subagent'` 或 `delegationDepth > 0` 才算子代理会话,`orchestrator-*` 这类顶层会话会被正常处理(0.2.0 用 id 前缀判断,把它们全部判成子代理,实测日志里有 19 次静默跳过)。**0.2.5 起**:拿不到 header 时默认**不判定**(`headerlessScope: 'out'`,fail-closed)—— 旧版按 id 形态把 `session-*` / `orchestrator-*` 当根会话,于是「无 header 的子代理」也会被补写,往子代理里塞"不要调用工具"会破坏委派语义。确有老宿主需要兼容时显式设 `headerlessScope: 'root'`(首次放行会记一行 `scope fallback`)。被过滤的会话每个 id 只记一条 `skip scope` 日志,不会静默消失。
|
|
131
150
|
- **可见字符是「按回合累计」而不是「末条消息」**:一个回合里先写了结论、后来又被工具调用打断的,累计口径下不算空(这是刻意的:用户此前已经看到了文字)。反过来,`aborted` / `interrupted` 等非 `completed` 回合一律不补写,即使它确实没留下正文 —— 那些回合多半是用户自己喊停的,再补一轮会变成「已取消还继续跑」。
|
|
132
|
-
- **崩溃遗留**:补写已入队但宿主在认领前崩溃时,该消息可能在下次启动被重放。插件只认 id 匹配的认领(`NOT ours`),因此不会被这种消息骗成「补写回合」,但它仍可能作为一条插件消息出现在转录里。为覆盖「120s 认领超时后才被认领」与「重启后才被认领」两种情形,最近 8 条投递 id 会同时留在内存与 `<budgetFile 同目录>/guard-ids.log` 里(`LATE, from guardIds
|
|
151
|
+
- **崩溃遗留**:补写已入队但宿主在认领前崩溃时,该消息可能在下次启动被重放。插件只认 id 匹配的认领(`NOT ours`),因此不会被这种消息骗成「补写回合」,但它仍可能作为一条插件消息出现在转录里。为覆盖「120s 认领超时后才被认领」与「重启后才被认领」两种情形,最近 8 条投递 id 会同时留在内存与 `<budgetFile 同目录>/guard-ids.log` 里(`LATE, from guardIds`),重启后仍能认出它们,不会把补写回合再当成真空回合补第二笔。**0.2.5 起**该文件超过 1 MiB 会按 TTL 原子压缩(`guard ids compacted: lines=N`)—— 旧版只增不减,而且读侧按字节截断会把 TTL 内的旧记录丢掉,反而让「晚到认领」认不出。
|
|
133
152
|
- **预算文件是不可信输入**:解析时会丢弃「晚于当前时钟 +5 分钟」的时间戳(否则 `now - t` 为负、该会话被永久判成预算耗尽),并且整份文件只在 1 MiB 以内整读、超过就只读尾部(避免配置写错指向几百 MB 的文件时同步阻塞宿主)。写入用 JSON 行(会话 id 含空格也不会被截断),压缩是「重读磁盘 + 临时文件原子替换」,不会覆盖另一个进程刚追加的行。
|
|
134
153
|
- **多进程(桌面宿主 + 独立的 web/headless 宿主)**:各自一套内存态,`agent.inbox.nextTurn` 只对本进程可见。预算文件与补写 id 文件是共享的,因此限流大致成立,但「同一会话同时被两个宿主打开」仍可能各补一笔。同一会话请只用一个宿主。
|
|
135
154
|
- **`dryRun` 不写预算文件**:判定链、日志、限流检查照走,但不投递、不记账,所以它只能证明「判定会发生」,不能用来验证限流是否生效(日志里的 `budgetUsed=n/max` 是当时的已用额度)。
|
|
136
155
|
- **多宿主共用同一日志文件**:两个进程同时写同一路径时轮转可能失败,此时回退 `truncate(0)` 并记一条 `log rotate fell back to truncate`。
|
|
137
|
-
-
|
|
156
|
+
- **生产环境仍未观测到真实补写(截至 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
157
|
|
|
139
158
|
## License
|
|
140
159
|
|
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,8 +190,8 @@ function enabledOr(value, fallback) {
|
|
|
180
190
|
/** 配置项清单:只用于在加载期挑出「写错的键」,不作为校验依据。 */
|
|
181
191
|
const CONFIG_KEYS = new Set([
|
|
182
192
|
'enabled', 'injectInstruction', 'dryRun', 'instructionText',
|
|
183
|
-
'minVisibleChars', 'delayMs', 'maxNudgesPerHour', 'logFile',
|
|
184
|
-
'sessionScope', 'foregroundKinds', 'budgetFile',
|
|
193
|
+
'minVisibleChars', 'delayMs', 'maxNudgesPerHour', 'logFile', 'logTurns',
|
|
194
|
+
'sessionScope', 'foregroundKinds', 'nudgeSources', 'headerlessScope', 'budgetFile',
|
|
185
195
|
]);
|
|
186
196
|
const NUMERIC_KEYS = ['minVisibleChars', 'delayMs', 'maxNudgesPerHour'];
|
|
187
197
|
const STRING_KEYS = ['instructionText', 'logFile', 'budgetFile'];
|
|
@@ -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,24 @@ 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
|
-
|
|
242
|
-
|
|
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.nudgeSources !== undefined && !Array.isArray(raw.nudgeSources)) {
|
|
264
|
+
issues.push('"nudgeSources" expects an array, got ' + brief(raw.nudgeSources) + ' -> ignored');
|
|
265
|
+
} else if (Array.isArray(raw.nudgeSources)) {
|
|
266
|
+
const bad = raw.nudgeSources.filter((k) => typeof k !== 'string' || k.trim() === '').length;
|
|
267
|
+
if (bad > 0) issues.push(bad + ' non-string/empty entry in "nudgeSources" -> dropped');
|
|
247
268
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
269
|
+
if (raw.headerlessScope !== undefined && raw.headerlessScope !== 'out' && raw.headerlessScope !== 'root') {
|
|
270
|
+
issues.push('"headerlessScope" expects "out" | "root", got ' + brief(raw.headerlessScope) + ' -> "out"');
|
|
271
|
+
}
|
|
272
|
+
if (raw.logTurns !== undefined && raw.logTurns !== 'empty' && raw.logTurns !== 'all') {
|
|
273
|
+
issues.push('"logTurns" expects "empty" | "all", got ' + brief(raw.logTurns) + ' -> "empty"');
|
|
274
|
+
}
|
|
275
|
+
return issues;
|
|
254
276
|
}
|
|
255
277
|
|
|
256
278
|
/**
|
|
@@ -260,12 +282,32 @@ function codepointLength(text) {
|
|
|
260
282
|
*/
|
|
261
283
|
const VISIBLE_NON_TEXT = new Set(['image', 'file', 'attachment', 'audio', 'video']);
|
|
262
284
|
|
|
263
|
-
|
|
285
|
+
/**
|
|
286
|
+
* 一条 assistant 消息的可见性画像:可见字符数(按码位)+ 是否有可见非文本块。
|
|
287
|
+
*
|
|
288
|
+
* 0.2.4 之前这里是「两遍遍历 + 一次 Array.from()」:visibleText 先拼字符串,
|
|
289
|
+
* hasVisibleNonText 再扫一遍,最后 codepointLength 用 Array.from 建出一整个
|
|
290
|
+
* 码位数组。assistant 消息可能很长,而一个回合有几十步,这笔分配纯粹是浪费。
|
|
291
|
+
* 现在一次遍历同时得出两个结论,字符数用 for...of 累加(代理对算 1 个)。
|
|
292
|
+
* 仍然给精确总数而不是「到阈值就停」:日志里的 visibleChars 是有用的诊断值,
|
|
293
|
+
* 为了省几次迭代把它退化成「至少 1」不划算。
|
|
294
|
+
*/
|
|
295
|
+
function inspectMessage(message) {
|
|
264
296
|
const content = message && Array.isArray(message.content) ? message.content : [];
|
|
297
|
+
let text = '';
|
|
298
|
+
let hasNonText = false;
|
|
265
299
|
for (const part of content) {
|
|
266
|
-
if (part
|
|
300
|
+
if (!part || typeof part.type !== 'string') continue;
|
|
301
|
+
if (part.type === 'text') {
|
|
302
|
+
if (typeof part.text === 'string') text += part.text;
|
|
303
|
+
} else if (VISIBLE_NON_TEXT.has(part.type)) {
|
|
304
|
+
hasNonText = true;
|
|
305
|
+
}
|
|
267
306
|
}
|
|
268
|
-
|
|
307
|
+
const visible = text.replace(ZERO_WIDTH, '').trim();
|
|
308
|
+
let chars = 0;
|
|
309
|
+
for (const _ignored of visible) chars += 1;
|
|
310
|
+
return { chars, hasNonText };
|
|
269
311
|
}
|
|
270
312
|
|
|
271
313
|
export function apply(ctx, config) {
|
|
@@ -283,6 +325,11 @@ export function apply(ctx, config) {
|
|
|
283
325
|
// 0 表示关闭补写(而不是被 || 吞成 3)。
|
|
284
326
|
maxNudgesPerHour: intOr(raw.maxNudgesPerHour, 3, 0, 1000),
|
|
285
327
|
logFile: typeof raw.logFile === 'string' ? raw.logFile.trim() : '',
|
|
328
|
+
// 回合日志粒度:'empty'(默认)只在「空回合 / 判定被跳过」时写一行,
|
|
329
|
+
// 'all' 连正常回合的 turn/end 也写。实测 1286 行日志里 645 行是正常回合
|
|
330
|
+
// 的 turn/end —— 它们只在「某回合为什么没被判空」时才有人看,默认不值得
|
|
331
|
+
// 每回合付一次同步写。排查期把这里切 'all'。
|
|
332
|
+
logTurns: raw.logTurns === 'all' ? 'all' : 'empty',
|
|
286
333
|
// 'root'(默认)只处理根会话(header.delegationDepth 为 0 或缺省);'all' 连
|
|
287
334
|
// 子代理会话一起处理。默认不碰子会话:子代理「只调工具、不回正文」是正常收尾,
|
|
288
335
|
// 补写会浪费 token 甚至改变委派行为。
|
|
@@ -294,6 +341,31 @@ export function apply(ctx, config) {
|
|
|
294
341
|
const extra = list.filter((k) => typeof k === 'string' && k.trim() !== '').map((k) => k.trim());
|
|
295
342
|
return new Set([...DEFAULT_FOREGROUND_KINDS, ...extra]);
|
|
296
343
|
})(),
|
|
344
|
+
/**
|
|
345
|
+
* 允许被补写的「回合发起来源」(0.2.5)。
|
|
346
|
+
*
|
|
347
|
+
* 决定的是「这个会话里该不该由插件插一句话」,与 foregroundKinds 的
|
|
348
|
+
* 「谁的消息能取消待发补写」是两件事:
|
|
349
|
+
* - 社交桥(source.kind='wechat-social-bridge', form='relay')把真人微信
|
|
350
|
+
* 消息送进会话,桥接器又会把模型输出发回给联系人。它不在前台白名单里,
|
|
351
|
+
* 于是 0.2.4 既不会被它取消补写、也不会因此排除该会话 —— 一旦那里出现
|
|
352
|
+
* completed 空回合,插件就会往一段真人对话里塞"请直接输出纯文本",
|
|
353
|
+
* 模型输出经桥接成为对外发文。这是未授权的外部副作用,必须默认关掉。
|
|
354
|
+
* - 显式列在这个白名单里的来源才允许补写;空数组 = 沿用 foregroundKinds。
|
|
355
|
+
* 想给桥接对话也保留保护的用户,把 kind 显式写进来即可(自担对外发言的语义)。
|
|
356
|
+
*/
|
|
357
|
+
nudgeSources: (() => {
|
|
358
|
+
const list = Array.isArray(raw.nudgeSources) ? raw.nudgeSources : [];
|
|
359
|
+
const extra = list.filter((k) => typeof k === 'string' && k.trim() !== '').map((k) => k.trim());
|
|
360
|
+
return extra.length > 0 ? new Set(extra) : null;
|
|
361
|
+
})(),
|
|
362
|
+
/**
|
|
363
|
+
* 会话 header 缺失时怎么判定(0.2.5)。宿主的 SessionHeader 是根/子代理的
|
|
364
|
+
* 唯一可信判据;拿不到时旧版按 id 形态猜(session-* 当根会话),于是
|
|
365
|
+
* 「无 header 且 id 形如 session-* 的子代理」会被补写,破坏委派语义。
|
|
366
|
+
* 默认 'out':无法判定就不碰;确有老宿主需要时显式设成 'root'。
|
|
367
|
+
*/
|
|
368
|
+
headerlessScope: raw.headerlessScope === 'root' ? 'root' : 'out',
|
|
297
369
|
// 补写预算的持久化文件(跨重启限流);默认 $DSH_HOME/state/turn-recovery/nudges.log。
|
|
298
370
|
budgetFile: typeof raw.budgetFile === 'string' ? raw.budgetFile.trim() : '',
|
|
299
371
|
// 干跑:判定、限流、日志全部照常走,唯独不真的把补写投进队列。
|
|
@@ -310,7 +382,12 @@ export function apply(ctx, config) {
|
|
|
310
382
|
const dshHome = typeof process.env.DSH_HOME === 'string' && process.env.DSH_HOME.trim() !== ''
|
|
311
383
|
? process.env.DSH_HOME.trim()
|
|
312
384
|
: path.join(os.homedir(), '.dsh');
|
|
313
|
-
|
|
385
|
+
// 0.2.5:自定义路径必须像日志文件(.log 结尾)。否则一次配置手滑就可能让
|
|
386
|
+
// 轮转/压缩逻辑去 rename 或 truncate 一个无关文件 —— 那是不可逆的破坏。
|
|
387
|
+
const customLogRejected = cfg.logFile !== '' && !/\.log$/i.test(cfg.logFile);
|
|
388
|
+
const logPath = (cfg.logFile !== '' && !customLogRejected)
|
|
389
|
+
? cfg.logFile
|
|
390
|
+
: path.join(dshHome, 'logs', 'turn-recovery.log');
|
|
314
391
|
const fallbackLogPath = path.join(os.tmpdir(), 'turn-recovery.log');
|
|
315
392
|
const startedAt = Date.now();
|
|
316
393
|
let lastRotateCheck = 0;
|
|
@@ -321,9 +398,33 @@ export function apply(ctx, config) {
|
|
|
321
398
|
* 先试 rename,失败就退回 truncate(0) —— 宁可丢历史,也不能让日志无限长。
|
|
322
399
|
*/
|
|
323
400
|
let rotateWarned = false;
|
|
401
|
+
let foreignLogWarned = false;
|
|
402
|
+
/**
|
|
403
|
+
* 文件头是否像本插件写的日志(首字节 '[',即 ISO 时间戳的开头)。
|
|
404
|
+
* 轮转/截断前必须过这一关:`logFile` 配错时,旧版会直接 rename 或 truncate
|
|
405
|
+
* 用户的任意文件;现在宁可让日志继续长,也不动一个不认识的文件。
|
|
406
|
+
*/
|
|
407
|
+
const looksLikeOurLog = (file) => {
|
|
408
|
+
try {
|
|
409
|
+
const fd = fs.openSync(file, 'r');
|
|
410
|
+
try {
|
|
411
|
+
const buf = Buffer.alloc(1);
|
|
412
|
+
const read = fs.readSync(fd, buf, 0, 1, 0);
|
|
413
|
+
if (read === 0) return true; // 空文件:轮转/截断都无害
|
|
414
|
+
return buf[0] === 0x5B; // '['
|
|
415
|
+
} finally { fs.closeSync(fd); }
|
|
416
|
+
} catch { return false; }
|
|
417
|
+
};
|
|
324
418
|
const rotateIfNeeded = (file) => {
|
|
325
419
|
try {
|
|
326
420
|
if (fs.statSync(file).size <= LOG_MAX_BYTES) return;
|
|
421
|
+
if (!looksLikeOurLog(file)) {
|
|
422
|
+
if (!foreignLogWarned) {
|
|
423
|
+
foreignLogWarned = true;
|
|
424
|
+
log('log rotate skipped: file does not look like a turn-recovery log (' + file + ')');
|
|
425
|
+
}
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
327
428
|
const backup = file + '.1';
|
|
328
429
|
try {
|
|
329
430
|
if (fs.existsSync(backup)) fs.unlinkSync(backup);
|
|
@@ -351,17 +452,28 @@ export function apply(ctx, config) {
|
|
|
351
452
|
|
|
352
453
|
// 主日志写在 $DSH_HOME/logs;万一主路径不可写(受限环境/目录被锁),
|
|
353
454
|
// 再退到系统临时目录,避免「插件其实活着但一行日志都没有」的误判。
|
|
455
|
+
//
|
|
456
|
+
// 目录只建一次:旧版每写一行都跑一次同步 mkdirSync,而宿主事件循环上的
|
|
457
|
+
// 每一次同步系统调用都是白给的延迟(turn/end、补写、判空都会写日志)。
|
|
458
|
+
// 写失败时把标志复位,下一次重新 mkdir —— 目录被删/权限变化仍能自愈。
|
|
459
|
+
let logDirReady = false;
|
|
354
460
|
const log = (line) => {
|
|
355
461
|
// 日志行内不允许出现换行:会话 id、错误文本都可能带 \n,拼进去就能在日志里
|
|
356
462
|
// 伪造出一行看似插件自己写的记录(伪造 turn/end 或 guard armed 会误导排障)。
|
|
357
|
-
|
|
463
|
+
// 0.2.5 起连 U+2028/U+2029(部分查看器当换行)与 ESC(终端着色)一起压平:
|
|
464
|
+
// 会话 id 由宿主/桥接插件产生,现网已出现 social-*、bili-whale 这类非 UUID 形态。
|
|
465
|
+
const safe = String(line).replace(/[\r\n\u2028\u2029\x1b]+/g, ' ');
|
|
358
466
|
const record = '[' + new Date().toISOString() + '] ' + safe + '\n';
|
|
359
467
|
maybeRotate();
|
|
360
468
|
try {
|
|
361
|
-
|
|
469
|
+
if (!logDirReady) {
|
|
470
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
471
|
+
logDirReady = true;
|
|
472
|
+
}
|
|
362
473
|
fs.appendFileSync(logPath, record);
|
|
363
474
|
return;
|
|
364
475
|
} catch {
|
|
476
|
+
logDirReady = false;
|
|
365
477
|
/* 落到下面的兜底 */
|
|
366
478
|
}
|
|
367
479
|
try {
|
|
@@ -384,9 +496,12 @@ export function apply(ctx, config) {
|
|
|
384
496
|
* 也不能让限流文件的 IO 问题拖垮宿主回合)。多进程并发追加时可能丢几行,
|
|
385
497
|
* 这是可接受的——它只是预算,不是账本。
|
|
386
498
|
*/
|
|
387
|
-
const
|
|
499
|
+
const customBudgetRejected = cfg.budgetFile !== '' && !/\.log$/i.test(cfg.budgetFile);
|
|
500
|
+
const budgetPath = (cfg.budgetFile !== '' && !customBudgetRejected)
|
|
388
501
|
? cfg.budgetFile
|
|
389
502
|
: path.join(dshHome, 'state', 'turn-recovery', 'nudges.log');
|
|
503
|
+
if (customLogRejected) configIssues.push('"logFile" must end with .log -> default used');
|
|
504
|
+
if (customBudgetRejected) configIssues.push('"budgetFile" must end with .log -> default used');
|
|
390
505
|
const BUDGET_MAX_BYTES = 1024 * 1024;
|
|
391
506
|
/** 已投递补写 id 的持久化文件(跨重启识别晚到认领用),与预算文件同目录。 */
|
|
392
507
|
const guardPath = path.join(path.dirname(budgetPath), 'guard-ids.log');
|
|
@@ -425,7 +540,7 @@ export function apply(ctx, config) {
|
|
|
425
540
|
* 0.2.2 及更早写的是 "id at" 纯文本,两者都认。
|
|
426
541
|
* 时间戳晚于当前时钟 + 容忍窗口的丢弃。
|
|
427
542
|
*/
|
|
428
|
-
const parseBudgetLine = (line, now
|
|
543
|
+
const parseBudgetLine = (line, now) => {
|
|
429
544
|
const text = line.trim();
|
|
430
545
|
if (text === '') return null;
|
|
431
546
|
let id = '';
|
|
@@ -443,8 +558,7 @@ export function apply(ctx, config) {
|
|
|
443
558
|
at = Number(text.slice(sp + 1).trim());
|
|
444
559
|
}
|
|
445
560
|
if (id === '' || !Number.isFinite(at)) return null;
|
|
446
|
-
if (at
|
|
447
|
-
if (at < cutoff) return null;
|
|
561
|
+
if (!withinWindow(at, now)) return null;
|
|
448
562
|
return { id, at };
|
|
449
563
|
};
|
|
450
564
|
|
|
@@ -454,9 +568,8 @@ export function apply(ctx, config) {
|
|
|
454
568
|
try {
|
|
455
569
|
const rawText = readTail(budgetPath, BUDGET_MAX_BYTES);
|
|
456
570
|
const now = Date.now();
|
|
457
|
-
const cutoff = now - HOUR_MS;
|
|
458
571
|
for (const line of rawText.split('\n')) {
|
|
459
|
-
const rec = parseBudgetLine(line, now
|
|
572
|
+
const rec = parseBudgetLine(line, now);
|
|
460
573
|
if (!rec) continue;
|
|
461
574
|
const list = budgetHistory.get(rec.id);
|
|
462
575
|
if (list) list.push(rec.at);
|
|
@@ -503,6 +616,64 @@ export function apply(ctx, config) {
|
|
|
503
616
|
return list.slice(-GUARD_IDS_MAX);
|
|
504
617
|
};
|
|
505
618
|
|
|
619
|
+
/**
|
|
620
|
+
* guard-ids 文件压缩(0.2.5)。这个文件此前是唯一「只增不减」的持久文件:
|
|
621
|
+
* 每投递一笔写一行,永不轮转;读侧只取尾部 1 MiB,于是超过 1 MiB 后 TTL 内
|
|
622
|
+
* 的旧记录会被按字节截断丢掉 —— 重启后的晚到认领认不出,反而触发连环补写
|
|
623
|
+
* (正是这个文件想解决的场景)。现在与预算文件同法:重读磁盘 → 只留 TTL 内
|
|
624
|
+
* 且每会话至多 GUARD_IDS_MAX 条 → 原子替换。
|
|
625
|
+
* 若文件内容一条都解析不出来(被别的程序写成了别的东西),拒绝重写。
|
|
626
|
+
*/
|
|
627
|
+
let guardCompactWarned = false;
|
|
628
|
+
const compactGuardIds = (now) => {
|
|
629
|
+
try {
|
|
630
|
+
const rawText = readTail(guardPath, BUDGET_MAX_BYTES);
|
|
631
|
+
const floor = now - GUARD_ID_TTL_MS;
|
|
632
|
+
const keep = new Map(); // sid -> [{ id, at }]
|
|
633
|
+
let parsed = 0;
|
|
634
|
+
for (const line of rawText.split('\n')) {
|
|
635
|
+
const text = line.trim();
|
|
636
|
+
if (text === '') continue;
|
|
637
|
+
let rec;
|
|
638
|
+
try { rec = JSON.parse(text); } catch { continue; }
|
|
639
|
+
if (!rec || typeof rec.id !== 'string' || typeof rec.sid !== 'string') continue;
|
|
640
|
+
const at = Number(rec.at);
|
|
641
|
+
if (!Number.isFinite(at) || at < floor || at > now + BUDGET_FUTURE_SKEW_MS) continue;
|
|
642
|
+
parsed += 1;
|
|
643
|
+
const list = keep.get(rec.sid) || [];
|
|
644
|
+
const seen = list.find((x) => x.id === rec.id);
|
|
645
|
+
if (seen) seen.at = Math.max(seen.at, at);
|
|
646
|
+
else list.push({ id: rec.id, at });
|
|
647
|
+
keep.set(rec.sid, list);
|
|
648
|
+
}
|
|
649
|
+
if (parsed === 0 && rawText.trim() !== '') {
|
|
650
|
+
if (!guardCompactWarned) {
|
|
651
|
+
guardCompactWarned = true;
|
|
652
|
+
log('guard ids compact skipped: file does not look like turn-recovery state ('
|
|
653
|
+
+ guardPath + ')');
|
|
654
|
+
}
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
const lines = [];
|
|
658
|
+
for (const [sid, entries] of keep) {
|
|
659
|
+
for (const entry of entries.slice(-GUARD_IDS_MAX)) {
|
|
660
|
+
lines.push(JSON.stringify({ sid, id: entry.id, at: entry.at }));
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
const tmp = guardPath + '.tmp-' + process.pid;
|
|
664
|
+
fs.writeFileSync(tmp, lines.length > 0 ? lines.join('\n') + '\n' : '');
|
|
665
|
+
try {
|
|
666
|
+
fs.renameSync(tmp, guardPath);
|
|
667
|
+
} catch (err) {
|
|
668
|
+
try { fs.unlinkSync(tmp); } catch { /* ignore */ }
|
|
669
|
+
throw err;
|
|
670
|
+
}
|
|
671
|
+
log('guard ids compacted: lines=' + lines.length);
|
|
672
|
+
} catch (err) {
|
|
673
|
+
log('guard ids compact FAILED: ' + errText(err));
|
|
674
|
+
}
|
|
675
|
+
};
|
|
676
|
+
|
|
506
677
|
const recordGuardId = (session, id, at) => {
|
|
507
678
|
const sid = session && typeof session.id === 'string' ? session.id : '';
|
|
508
679
|
if (sid === '' || typeof id !== 'string' || id === '') return;
|
|
@@ -513,6 +684,9 @@ export function apply(ctx, config) {
|
|
|
513
684
|
while (list.length > GUARD_IDS_MAX) list.shift();
|
|
514
685
|
fs.mkdirSync(path.dirname(guardPath), { recursive: true });
|
|
515
686
|
fs.appendFileSync(guardPath, JSON.stringify({ sid, id, at }) + '\n');
|
|
687
|
+
let size = 0;
|
|
688
|
+
try { size = fs.statSync(guardPath).size; } catch { /* 首次写入 */ }
|
|
689
|
+
if (size > BUDGET_MAX_BYTES) compactGuardIds(Date.now());
|
|
516
690
|
} catch { /* 只服务于去重,写失败不影响补写本身 */ }
|
|
517
691
|
};
|
|
518
692
|
|
|
@@ -533,9 +707,8 @@ export function apply(ctx, config) {
|
|
|
533
707
|
let recordTicks = 0;
|
|
534
708
|
|
|
535
709
|
const pruneBudgetHistory = (now) => {
|
|
536
|
-
const floor = now - HOUR_MS;
|
|
537
710
|
for (const [sid, times] of budgetHistory) {
|
|
538
|
-
const kept = times.filter((t) => t
|
|
711
|
+
const kept = times.filter((t) => withinWindow(t, now));
|
|
539
712
|
if (kept.length === 0) budgetHistory.delete(sid);
|
|
540
713
|
else if (kept.length !== times.length) budgetHistory.set(sid, kept);
|
|
541
714
|
}
|
|
@@ -546,18 +719,31 @@ export function apply(ctx, config) {
|
|
|
546
719
|
* 旧版直接用内存 map 覆盖写整份文件 —— 多进程(桌面宿主 + 独立 web/headless)
|
|
547
720
|
* 同时运行时,会把对方刚追加的行整段抹掉,预算就少算、于是多补。
|
|
548
721
|
*/
|
|
722
|
+
let budgetCompactWarned = false;
|
|
549
723
|
const compactBudget = (now) => {
|
|
724
|
+
let parsed = 0;
|
|
725
|
+
let rawText = '';
|
|
550
726
|
try {
|
|
551
|
-
|
|
552
|
-
const cutoff = now - HOUR_MS;
|
|
727
|
+
rawText = readTail(budgetPath, BUDGET_MAX_BYTES);
|
|
553
728
|
for (const line of rawText.split('\n')) {
|
|
554
|
-
const rec = parseBudgetLine(line, now
|
|
729
|
+
const rec = parseBudgetLine(line, now);
|
|
555
730
|
if (!rec) continue;
|
|
731
|
+
parsed += 1;
|
|
556
732
|
const list = budgetHistory.get(rec.id);
|
|
557
733
|
if (!list) budgetHistory.set(rec.id, [rec.at]);
|
|
558
734
|
else if (list.indexOf(rec.at) < 0) list.push(rec.at);
|
|
559
735
|
}
|
|
560
736
|
} catch { /* 读不到就只用内存态 */ }
|
|
737
|
+
// 文件里有内容却一条都解析不出来:这不是我们的预算文件(配置指错了,或被
|
|
738
|
+
// 别的程序写成了别的东西)。重写它会毁掉别人的数据,宁可不压缩、只留一行。
|
|
739
|
+
if (parsed === 0 && rawText.trim() !== '') {
|
|
740
|
+
if (!budgetCompactWarned) {
|
|
741
|
+
budgetCompactWarned = true;
|
|
742
|
+
log('budget compact skipped: file does not look like turn-recovery state ('
|
|
743
|
+
+ budgetPath + ')');
|
|
744
|
+
}
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
561
747
|
pruneBudgetHistory(now);
|
|
562
748
|
const lines = [];
|
|
563
749
|
for (const [sid, times] of budgetHistory) {
|
|
@@ -565,7 +751,14 @@ export function apply(ctx, config) {
|
|
|
565
751
|
}
|
|
566
752
|
const tmp = budgetPath + '.tmp-' + process.pid;
|
|
567
753
|
fs.writeFileSync(tmp, lines.length > 0 ? lines.join('\n') + '\n' : '');
|
|
568
|
-
|
|
754
|
+
try {
|
|
755
|
+
fs.renameSync(tmp, budgetPath);
|
|
756
|
+
} catch (err) {
|
|
757
|
+
// 替换失败(Windows 上目标被另一进程占用时是 EPERM/EBUSY)不能把
|
|
758
|
+
// .tmp-<pid> 留在 state 目录里:长期跑会积一堆同名的垃圾文件。
|
|
759
|
+
try { fs.unlinkSync(tmp); } catch { /* ignore */ }
|
|
760
|
+
throw err;
|
|
761
|
+
}
|
|
569
762
|
};
|
|
570
763
|
|
|
571
764
|
const recordNudge = (session, at) => {
|
|
@@ -589,14 +782,18 @@ export function apply(ctx, config) {
|
|
|
589
782
|
* 日志里的会话短标签。旧版直接 id.slice(0,8),而根会话 id 形如
|
|
590
783
|
* session-<uuid>,前 8 位恒为 "session-",多会话根本分不开。
|
|
591
784
|
* 现在先剥掉 session- / orchestrator- / worker- 前缀再取前 8 位;剥完为空则退回原 id 前 8 位。
|
|
785
|
+
* 只拿到 id(没有 session 对象)的路径——例如宿主只发 { message } 的
|
|
786
|
+
* agent/inbox/discarded——直接复用 shortId。
|
|
592
787
|
*/
|
|
788
|
+
const shortId = (rawId) => {
|
|
789
|
+
const id = typeof rawId === 'string' ? rawId : '';
|
|
790
|
+
if (id === '') return 'no-session';
|
|
791
|
+
const stripped = id.replace(/^(session-|orchestrator-|worker-)/, '');
|
|
792
|
+
return stripped ? stripped.slice(0, 8) : id.slice(0, 8);
|
|
793
|
+
};
|
|
794
|
+
|
|
593
795
|
const tag = (session) => {
|
|
594
|
-
try {
|
|
595
|
-
const id = session && typeof session.id === 'string' ? session.id : '';
|
|
596
|
-
if (!id) return 'no-session';
|
|
597
|
-
const stripped = id.replace(/^(session-|orchestrator-|worker-)/, '');
|
|
598
|
-
return stripped ? stripped.slice(0, 8) : id.slice(0, 8);
|
|
599
|
-
} catch { return 'no-session'; }
|
|
796
|
+
try { return shortId(session && session.id); } catch { return 'no-session'; }
|
|
600
797
|
};
|
|
601
798
|
|
|
602
799
|
/**
|
|
@@ -618,6 +815,8 @@ export function apply(ctx, config) {
|
|
|
618
815
|
*/
|
|
619
816
|
const scopeSeen = new Set();
|
|
620
817
|
let scopeSeenCapped = false;
|
|
818
|
+
/** headerlessScope='root' 时,每个「靠 id 形态放行」的会话只记一条日志。 */
|
|
819
|
+
const headerlessSeen = new Set();
|
|
621
820
|
|
|
622
821
|
/** header 判定根会话;返回 null 表示无法判定(没有 header)。 */
|
|
623
822
|
const rootByHeader = (session) => {
|
|
@@ -652,9 +851,19 @@ export function apply(ctx, config) {
|
|
|
652
851
|
return false;
|
|
653
852
|
}
|
|
654
853
|
const id = session && typeof session.id === 'string' ? session.id : '';
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
854
|
+
// 0.2.5:拿不到 header 时默认**不碰**(fail-closed)。旧版按 id 形态把
|
|
855
|
+
// session-* / orchestrator-* 当根会话,于是一个「无 header 且 id 形如
|
|
856
|
+
// session-*」的子代理会话也会被补写 —— 往子代理里塞"不要调用工具"会破坏
|
|
857
|
+
// 委派语义。确有老宿主需要兼容时,显式设 headerlessScope: 'root'。
|
|
858
|
+
if (cfg.headerlessScope === 'root' && /^(session-|orchestrator-)/.test(id)) {
|
|
859
|
+
if (!headerlessSeen.has(id) && headerlessSeen.size < SCOPE_SEEN_MAX) {
|
|
860
|
+
headerlessSeen.add(id);
|
|
861
|
+
log('scope fallback: no header, id shape=root -> in scope (headerlessScope=root) ['
|
|
862
|
+
+ shortId(id) + ']');
|
|
863
|
+
}
|
|
864
|
+
return true;
|
|
865
|
+
}
|
|
866
|
+
logScopeSkip(session, id === '' ? 'no header and no id' : 'no header (headerlessScope=out)');
|
|
658
867
|
return false;
|
|
659
868
|
};
|
|
660
869
|
|
|
@@ -709,11 +918,48 @@ export function apply(ctx, config) {
|
|
|
709
918
|
let armed = false;
|
|
710
919
|
const agents = new Map(); // session -> agent
|
|
711
920
|
const agentsById = new Map(); // session.id -> agent(session 对象身份不稳定时兜底)
|
|
921
|
+
/**
|
|
922
|
+
* 会话状态双索引:WeakMap 按对象身份(快路径),Map 按 session.id(兜底)。
|
|
923
|
+
*
|
|
924
|
+
* 只按对象身份索引是原实现的一个静默失效点:宿主在不同代码路径上可能给出
|
|
925
|
+
* 不同的 session 实例(agentOf 早就为此加了 agentsById,注释也写明了这个
|
|
926
|
+
* 风险)。一旦身份漂移,turn/start 落在对象 A、assistant/message 与 turn/end
|
|
927
|
+
* 落在对象 B,B 的状态里 sawTurnStart 恒为 false,判定链整体早退 ——
|
|
928
|
+
* 插件不报错、只留下 "turn/start not observed" 一行,表现为「装了但没用」。
|
|
929
|
+
* 这与 0.2.1 修过的「orchestrator-* 被判成子代理、19 次静默跳过」是同一类故障。
|
|
930
|
+
*/
|
|
712
931
|
const state = new WeakMap(); // session -> per-session guard state
|
|
932
|
+
const stateById = new Map(); // session.id -> per-session guard state(有界)
|
|
933
|
+
const STATE_BY_ID_MAX = 500;
|
|
713
934
|
const timers = new Set(); // 所有在跑的定时器,便于 dispose 时一次性清掉
|
|
714
935
|
|
|
936
|
+
/**
|
|
937
|
+
* 运行期聚合统计。
|
|
938
|
+
* 生产日志实测(2026-11-01 → 11-09,645 个回合):completed 空回合为 0,
|
|
939
|
+
* 182 个空回合全是 steps=0 的 error / aborted。逐行日志回答不了
|
|
940
|
+
* 「守卫没触发是目标场景没发生,还是它坏了」—— 每 STATS_EVERY_TURNS 个回合
|
|
941
|
+
* 打一行汇总,两个问题一眼可辨;卸载时再打一行 final。
|
|
942
|
+
*/
|
|
943
|
+
const stats = { turns: 0, emptyCompleted: 0, emptyOther: 0, delivered: 0, claimed: 0 };
|
|
944
|
+
const STATS_EVERY_TURNS = 50;
|
|
945
|
+
const statsLine = (suffix) => 'stats: turns=' + stats.turns
|
|
946
|
+
+ ' emptyCompleted=' + stats.emptyCompleted
|
|
947
|
+
+ ' emptyOther=' + stats.emptyOther
|
|
948
|
+
+ ' delivered=' + stats.delivered
|
|
949
|
+
+ ' claimed=' + stats.claimed
|
|
950
|
+
+ (suffix ? ' (' + suffix + ')' : '');
|
|
951
|
+
|
|
952
|
+
/** 只读查找(不创建状态):先对象身份、再 id。丢弃事件等旁路用它。 */
|
|
953
|
+
const stateLookup = (session) => {
|
|
954
|
+
if (!session || typeof session !== 'object') return undefined;
|
|
955
|
+
const direct = state.get(session);
|
|
956
|
+
if (direct) return direct;
|
|
957
|
+
const id = typeof session.id === 'string' ? session.id : '';
|
|
958
|
+
return id !== '' ? stateById.get(id) : undefined;
|
|
959
|
+
};
|
|
960
|
+
|
|
715
961
|
const stateOf = (session) => {
|
|
716
|
-
let s =
|
|
962
|
+
let s = stateLookup(session);
|
|
717
963
|
if (!s) {
|
|
718
964
|
s = {
|
|
719
965
|
turn: null,
|
|
@@ -728,6 +974,12 @@ export function apply(ctx, config) {
|
|
|
728
974
|
* 拿去补写会对着「本来有正文的回合」发假提醒。
|
|
729
975
|
*/
|
|
730
976
|
sawTurnStart: false,
|
|
977
|
+
/**
|
|
978
|
+
* 本回合最近一条非补写 user/message 的来源 kind('' = 本回合没见到)。
|
|
979
|
+
* 0.2.5 的来源闸门用它:只有来源在 nudgeSources(默认前台白名单)里
|
|
980
|
+
* 才允许补写,避免往社交桥这类「模型输出会被转发给第三方」的会话里插话。
|
|
981
|
+
*/
|
|
982
|
+
turnOrigin: '',
|
|
731
983
|
/**
|
|
732
984
|
* 本回合已经「处理过」:要么排上了补写,要么在排补写前就被限流/去重挡下。
|
|
733
985
|
* 只在 turn/end 的判定走完之后置位 —— 旧版在调用 scheduleNudge 之前就置位,
|
|
@@ -763,7 +1015,25 @@ export function apply(ctx, config) {
|
|
|
763
1015
|
nudgeTimes: historyOf(session),
|
|
764
1016
|
timer: null,
|
|
765
1017
|
};
|
|
766
|
-
|
|
1018
|
+
}
|
|
1019
|
+
// 两个索引都写:对象身份是最快路径,id 是宿主换 session 实例时的兜底。
|
|
1020
|
+
if (session && typeof session === 'object') state.set(session, s);
|
|
1021
|
+
const sid = session && typeof session.id === 'string' ? session.id : '';
|
|
1022
|
+
if (sid !== '') {
|
|
1023
|
+
const existing = stateById.get(sid);
|
|
1024
|
+
if (existing === s) {
|
|
1025
|
+
// LRU:命中即刷新插入顺序(Map 保序)。旧版按纯插入序淘汰,长跑 >500 个
|
|
1026
|
+
// 会话时会把仍在活跃的会话踢掉,它的 sawTurnStart 随即复位 → 静默漏补。
|
|
1027
|
+
stateById.delete(sid);
|
|
1028
|
+
stateById.set(sid, s);
|
|
1029
|
+
} else {
|
|
1030
|
+
if (stateById.size >= STATE_BY_ID_MAX && !stateById.has(sid)) {
|
|
1031
|
+
// Map 保序,第一个就是最久未使用的(见上面的命中刷新)。
|
|
1032
|
+
const oldest = stateById.keys().next();
|
|
1033
|
+
if (!oldest.done) stateById.delete(oldest.value);
|
|
1034
|
+
}
|
|
1035
|
+
stateById.set(sid, s);
|
|
1036
|
+
}
|
|
767
1037
|
}
|
|
768
1038
|
return s;
|
|
769
1039
|
};
|
|
@@ -880,7 +1150,7 @@ export function apply(ctx, config) {
|
|
|
880
1150
|
const agent = payload && payload.agent ? payload.agent : payload;
|
|
881
1151
|
const session = agent && agent.session;
|
|
882
1152
|
if (!session) return;
|
|
883
|
-
const s =
|
|
1153
|
+
const s = stateLookup(session);
|
|
884
1154
|
if (s) {
|
|
885
1155
|
clearTimer(s, null);
|
|
886
1156
|
clearNudgeInFlight(s, null);
|
|
@@ -889,7 +1159,12 @@ export function apply(ctx, config) {
|
|
|
889
1159
|
s.guardIds = [];
|
|
890
1160
|
}
|
|
891
1161
|
agents.delete(session);
|
|
892
|
-
if (typeof session.id === 'string' && session.id !== '')
|
|
1162
|
+
if (typeof session.id === 'string' && session.id !== '') {
|
|
1163
|
+
agentsById.delete(session.id);
|
|
1164
|
+
// 两个状态索引都要清:否则 id 兜底会一直把已销毁会话的状态留在表里。
|
|
1165
|
+
stateById.delete(session.id);
|
|
1166
|
+
state.delete(session);
|
|
1167
|
+
}
|
|
893
1168
|
} catch (err) {
|
|
894
1169
|
log('agent/disposed handler error: ' + errText(err));
|
|
895
1170
|
}
|
|
@@ -899,26 +1174,33 @@ export function apply(ctx, config) {
|
|
|
899
1174
|
}
|
|
900
1175
|
|
|
901
1176
|
/**
|
|
902
|
-
* P2-C
|
|
903
|
-
* session 事件,插件单靠 session/event 无从得知那条补写已被丢弃,
|
|
904
|
-
*
|
|
905
|
-
*
|
|
906
|
-
*
|
|
907
|
-
*
|
|
1177
|
+
* P2-C 缓解(0.2.5 修):用户点「停止」时宿主 cancel() 只清 inbox + abort,
|
|
1178
|
+
* 不 append 任何 session 事件,插件单靠 session/event 无从得知那条补写已被丢弃,
|
|
1179
|
+
* delayMs 后照发,于是「用户已经喊停」还会多跑一轮模型。
|
|
1180
|
+
*
|
|
1181
|
+
* 宿主 0.2.2-rc.1 的真实载荷(app.asar 内 dsh-agent-loop/lib/index.js:199):
|
|
1182
|
+
* this.dispatch.emit("agent/inbox/discarded", { message })
|
|
1183
|
+
* —— 只有 message,没有 agent、也没有 session。0.2.4 及更早读的是 payload.agent,
|
|
1184
|
+
* 于是 handler 在真实宿主上永远在第一行 return:定时器不撤、日志不写。
|
|
1185
|
+
* 单测又用自造的 { agent, message } 形状喂它,所以 65 例全绿也覆盖不到这个失效。
|
|
1186
|
+
* 现在按 message.id 反查持有该在途 id 的会话状态,不再依赖载荷里的 agent/session。
|
|
908
1187
|
*/
|
|
909
1188
|
const onInboxDiscarded = (payload) => {
|
|
910
1189
|
try {
|
|
911
|
-
const
|
|
912
|
-
const session = agent && agent.session;
|
|
913
|
-
if (!session) return;
|
|
914
|
-
const s = state.get(session);
|
|
915
|
-
if (!s) return;
|
|
916
|
-
const message = payload && payload.message;
|
|
1190
|
+
const message = payload && payload.message ? payload.message : payload;
|
|
917
1191
|
const id = message && typeof message.id === 'string' ? message.id : '';
|
|
918
|
-
if (id === ''
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
1192
|
+
if (id === '') return;
|
|
1193
|
+
let hit = 0;
|
|
1194
|
+
for (const [sid, s] of stateById) {
|
|
1195
|
+
if (s.nudgeMessageId !== id) continue;
|
|
1196
|
+
clearTimer(s, 'nudge discarded by host');
|
|
1197
|
+
clearNudgeInFlight(s, 'discarded by host');
|
|
1198
|
+
log('nudge DISCARDED by host before claim [' + shortId(sid) + ']');
|
|
1199
|
+
hit += 1;
|
|
1200
|
+
}
|
|
1201
|
+
if (hit === 0) {
|
|
1202
|
+
log('inbox discarded: message ' + id.slice(0, 8) + ' is not an in-flight nudge -> ignored');
|
|
1203
|
+
}
|
|
922
1204
|
} catch (err) {
|
|
923
1205
|
log('agent/inbox/discarded handler error: ' + errText(err));
|
|
924
1206
|
}
|
|
@@ -1013,6 +1295,7 @@ export function apply(ctx, config) {
|
|
|
1013
1295
|
const fn = attempts[i][1];
|
|
1014
1296
|
try {
|
|
1015
1297
|
fn();
|
|
1298
|
+
stats.delivered += 1;
|
|
1016
1299
|
log('nudge DELIVERED via ' + label + ' [' + tag(session) + ']');
|
|
1017
1300
|
return message;
|
|
1018
1301
|
} catch (err) {
|
|
@@ -1024,16 +1307,23 @@ export function apply(ctx, config) {
|
|
|
1024
1307
|
};
|
|
1025
1308
|
|
|
1026
1309
|
/**
|
|
1027
|
-
*
|
|
1028
|
-
*
|
|
1029
|
-
* `< HOUR_MS` 恒成立,只按上半句过滤就会把它永久留在预算里。
|
|
1310
|
+
* 修剪本会话最近一小时的投递时刻,返回窗口内还剩几次。
|
|
1311
|
+
* 未来时间戳的丢弃规则统一走 withinWindow(见该函数的注释)。
|
|
1030
1312
|
*/
|
|
1031
1313
|
const pruneBudget = (s, now) => {
|
|
1032
|
-
s.nudgeTimes = s.nudgeTimes.filter(
|
|
1033
|
-
(t) => now - t < HOUR_MS && t <= now + BUDGET_FUTURE_SKEW_MS);
|
|
1314
|
+
s.nudgeTimes = s.nudgeTimes.filter((t) => withinWindow(t, now));
|
|
1034
1315
|
return s.nudgeTimes.length;
|
|
1035
1316
|
};
|
|
1036
1317
|
|
|
1318
|
+
/**
|
|
1319
|
+
* 本会话窗口内的补写次数是否已达上限。
|
|
1320
|
+
* 「定时器到点时」与「turn/end 判定时」各查一次(两者相隔 delayMs),
|
|
1321
|
+
* 两处必须用同一套修剪+比较;写成分散表达式时,一处改了窗口、另一处没改
|
|
1322
|
+
* 就会从某条路径漏限流。
|
|
1323
|
+
*/
|
|
1324
|
+
const budgetExhausted = (s, now = Date.now()) =>
|
|
1325
|
+
pruneBudget(s, now) >= cfg.maxNudgesPerHour;
|
|
1326
|
+
|
|
1037
1327
|
/**
|
|
1038
1328
|
* 排一笔补写。返回结果字符串,供调用侧决定是否把本回合标记为「已处理」:
|
|
1039
1329
|
* 'scheduled' | 'dry-run' | 'skipped:<原因>' | 'error'
|
|
@@ -1086,7 +1376,7 @@ export function apply(ctx, config) {
|
|
|
1086
1376
|
return;
|
|
1087
1377
|
}
|
|
1088
1378
|
// 预算按「真正投递成功」计,取消/失败不占额度。
|
|
1089
|
-
if (
|
|
1379
|
+
if (budgetExhausted(s)) {
|
|
1090
1380
|
log('hourly nudge budget exhausted (' + s.nudgeTimes.length + ') -> no nudge');
|
|
1091
1381
|
return;
|
|
1092
1382
|
}
|
|
@@ -1109,7 +1399,7 @@ export function apply(ctx, config) {
|
|
|
1109
1399
|
s.nudgeTimes.push(Date.now());
|
|
1110
1400
|
if (typeof message.id === 'string' && message.id !== '') {
|
|
1111
1401
|
s.guardIds.push(message.id);
|
|
1112
|
-
while (s.guardIds.length >
|
|
1402
|
+
while (s.guardIds.length > GUARD_IDS_MAX) s.guardIds.shift();
|
|
1113
1403
|
}
|
|
1114
1404
|
// 跨重启限流:把投递时刻落盘,重启后的新进程回读最近一小时。
|
|
1115
1405
|
recordNudge(session, s.nudgeDeliveredAt);
|
|
@@ -1165,20 +1455,23 @@ export function apply(ctx, config) {
|
|
|
1165
1455
|
// 仍可能认领那笔「已超时」的消息 —— 它进转录后开出的照样是补写回合。
|
|
1166
1456
|
// 不认它,下一个空回合就会被当成真空回合再补一笔(连环补写)。
|
|
1167
1457
|
const late = !matched && typeof data.id === 'string' && s.guardIds.indexOf(data.id) >= 0;
|
|
1168
|
-
//
|
|
1169
|
-
// 对不上说明队列已经往前走了(上一进程遗留/重放),这笔补写也不会再被认领。
|
|
1170
|
-
// 只有晚到认领例外 —— 它可能是旧 id,不该顺手抹掉新一笔的在途状态。
|
|
1171
|
-
if (!late) clearNudgeInFlight(s, null);
|
|
1172
|
-
// 关键:只有真的对上 id 才算「补写回合」。
|
|
1458
|
+
// 关键:只有真的对上 id(或晚到认领)才算「补写回合」。
|
|
1173
1459
|
// 旧版把 matched 只写进日志、然后无条件置 awaitingGuardTurn,于是任何
|
|
1174
1460
|
// 同来源但 id 不符的消息(上次进程遗留、重放、另一实例写入)都会被误认,
|
|
1175
1461
|
// 导致下一个真正的空回合被判成「连环补写」而静默跳过。
|
|
1176
1462
|
if (!matched && !late) {
|
|
1463
|
+
// 0.2.5 修:这里**不再清在途标记**。旧版在早退之前无条件 clear,
|
|
1464
|
+
// 于是同进程任一插件只要 append 一条 source.kind=plugin:turn-recovery
|
|
1465
|
+
// 且 id 随意的消息,就能把在途标记抹掉 → 下一个空回合多补一笔
|
|
1466
|
+
// (多一轮模型唤醒与费用)。真正「队列已前进」由 120s 认领超时兜底。
|
|
1177
1467
|
log('guard-kind message seen but NOT ours (id mismatch/expired) -> ignored ['
|
|
1178
1468
|
+ tag(session) + ']');
|
|
1179
1469
|
return;
|
|
1180
1470
|
}
|
|
1471
|
+
// 只有对上 id 时它才算被消费;晚到认领是旧 id,不该抹掉新一笔的在途状态。
|
|
1472
|
+
if (matched) clearNudgeInFlight(s, null);
|
|
1181
1473
|
s.awaitingGuardTurn = true;
|
|
1474
|
+
stats.claimed += 1;
|
|
1182
1475
|
log('guard message claimed' + (late ? ' (LATE, from guardIds)' : '')
|
|
1183
1476
|
+ ' turn=' + (typeof data.turn === 'number' ? data.turn : '?')
|
|
1184
1477
|
+ ' matched=true [' + tag(session) + ']');
|
|
@@ -1192,6 +1485,10 @@ export function apply(ctx, config) {
|
|
|
1192
1485
|
// 0.2.0 用「后台黑名单 + 未知即前台」,宿主每加一个通知来源就可能让补写
|
|
1193
1486
|
// 被静默取消;白名单把默认值改成「不认识就当背景」,漏取消由 turn/start 兜底。
|
|
1194
1487
|
const form = source && typeof source.form === 'string' ? source.form : '';
|
|
1488
|
+
// 记下本回合是谁发起的:turn/end 的来源闸门读它(见 nudgeSources)。
|
|
1489
|
+
// form:'notice' 是宿主的通知形态,不算「有人说话」,但它的 kind 仍可能
|
|
1490
|
+
// 恰好是 user —— 那种情况不覆盖 turnOrigin,避免把通知当成人类回合。
|
|
1491
|
+
if (form !== 'notice') s.turnOrigin = kind;
|
|
1195
1492
|
// form:'notice' 是宿主的通知形态(tool-jobs / action-cards / plan-mode /
|
|
1196
1493
|
// tool-goal 都用它):无论 kind 叫什么,它都不是人在说话。
|
|
1197
1494
|
if (form !== 'notice' && cfg.foregroundKinds.has(kind)) {
|
|
@@ -1203,10 +1500,18 @@ export function apply(ctx, config) {
|
|
|
1203
1500
|
if (event.type === 'assistant/message') {
|
|
1204
1501
|
const message = data.message;
|
|
1205
1502
|
if (!message || message.role !== 'assistant') return;
|
|
1206
|
-
if (typeof data.turn === 'number' && s.turn !== data.turn)
|
|
1503
|
+
if (typeof data.turn === 'number' && s.turn !== data.turn) {
|
|
1504
|
+
// 0.2.5:turn/start 见到过、但它的 turn 不是数值时,s.turn 仍是 null;
|
|
1505
|
+
// 这里若走 resetTurnCounters 会把 sawTurnStart 一并清成 false,于是整个
|
|
1506
|
+
// 回合被误判成「中途加载」而跳过判定(日志与热加载同形,排障会跑偏)。
|
|
1507
|
+
// 只补记 turn 号、保留 sawTurnStart。
|
|
1508
|
+
if (s.sawTurnStart && s.turn === null) s.turn = data.turn;
|
|
1509
|
+
else resetTurnCounters(s, data.turn);
|
|
1510
|
+
}
|
|
1511
|
+
const seen = inspectMessage(message);
|
|
1207
1512
|
s.assistantMessages += 1;
|
|
1208
|
-
s.visibleChars +=
|
|
1209
|
-
if (
|
|
1513
|
+
s.visibleChars += seen.chars;
|
|
1514
|
+
if (seen.hasNonText) s.visibleNonText = true;
|
|
1210
1515
|
return;
|
|
1211
1516
|
}
|
|
1212
1517
|
|
|
@@ -1236,9 +1541,22 @@ export function apply(ctx, config) {
|
|
|
1236
1541
|
|
|
1237
1542
|
const sawStart = s.sawTurnStart === true;
|
|
1238
1543
|
const empty = !s.visibleNonText && s.visibleChars < cfg.minVisibleChars;
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1544
|
+
// 回合已结束:来源标记取一次就清,避免跨回合残留把上一回合的来源当成这一次的。
|
|
1545
|
+
const turnOrigin = s.turnOrigin;
|
|
1546
|
+
s.turnOrigin = '';
|
|
1547
|
+
stats.turns += 1;
|
|
1548
|
+
if (empty) {
|
|
1549
|
+
if (kind === 'completed') stats.emptyCompleted += 1;
|
|
1550
|
+
else stats.emptyOther += 1;
|
|
1551
|
+
}
|
|
1552
|
+
// 正常回合默认不写行:实测这类行占日志的一半,而它只在「某回合为什么
|
|
1553
|
+
// 没被判空」时才有人看。排查期把 logTurns 切成 'all' 即可恢复逐回合记录。
|
|
1554
|
+
if (empty || cfg.logTurns === 'all') {
|
|
1555
|
+
log('turn/end turn=' + turn + ' kind=' + kind + ' steps=' + s.assistantMessages
|
|
1556
|
+
+ ' visibleChars=' + s.visibleChars + ' empty=' + empty + ' wasGuard=' + wasGuard
|
|
1557
|
+
+ ' sawStart=' + sawStart + ' [' + tag(session) + ']');
|
|
1558
|
+
}
|
|
1559
|
+
if (stats.turns % STATS_EVERY_TURNS === 0) log(statsLine(null));
|
|
1242
1560
|
|
|
1243
1561
|
if (!sawStart) {
|
|
1244
1562
|
// 插件在回合中途才加载/热重载:看不到 turn/start,也就漏掉了该回合早前的
|
|
@@ -1251,6 +1569,15 @@ export function apply(ctx, config) {
|
|
|
1251
1569
|
log('empty turn but kind=' + kind + ' -> no nudge');
|
|
1252
1570
|
return;
|
|
1253
1571
|
}
|
|
1572
|
+
// 来源闸门(0.2.5):只给「人类发起」的回合补写。社交桥等来源的会话
|
|
1573
|
+
// 一旦被补写,模型输出可能被桥接器转发给第三方联系人 —— 那是用户没有
|
|
1574
|
+
// 授权的对外发言,必须默认拒绝;需要时用 nudgeSources 显式放开。
|
|
1575
|
+
const allowedOrigins = cfg.nudgeSources || cfg.foregroundKinds;
|
|
1576
|
+
if (turnOrigin !== '' && !allowedOrigins.has(turnOrigin)) {
|
|
1577
|
+
log('empty turn but origin=' + turnOrigin + ' not in nudge sources -> no nudge ['
|
|
1578
|
+
+ tag(session) + ']');
|
|
1579
|
+
return;
|
|
1580
|
+
}
|
|
1254
1581
|
if (wasGuard) {
|
|
1255
1582
|
log('previous turn was already a guard turn and still empty -> stop chaining');
|
|
1256
1583
|
return;
|
|
@@ -1264,7 +1591,7 @@ export function apply(ctx, config) {
|
|
|
1264
1591
|
return;
|
|
1265
1592
|
}
|
|
1266
1593
|
const now = Date.now();
|
|
1267
|
-
if (
|
|
1594
|
+
if (budgetExhausted(s, now)) {
|
|
1268
1595
|
log('hourly nudge budget exhausted (' + s.nudgeTimes.length + ') -> no nudge');
|
|
1269
1596
|
return;
|
|
1270
1597
|
}
|
|
@@ -1291,6 +1618,9 @@ export function apply(ctx, config) {
|
|
|
1291
1618
|
try { clearTimeout(timer); } catch { /* ignore */ }
|
|
1292
1619
|
}
|
|
1293
1620
|
timers.clear();
|
|
1621
|
+
// 卸载时打一行 final 统计:短命进程(频繁重启的宿主)也留得下
|
|
1622
|
+
// 「这段时间到底判了多少回合、补了几笔」的证据,不必等满 50 个回合。
|
|
1623
|
+
try { log(statsLine('final')); } catch { /* 日志失败不影响卸载 */ }
|
|
1294
1624
|
});
|
|
1295
1625
|
} catch { /* effect 不可用时忽略 */ }
|
|
1296
1626
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-turn-recovery",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
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",
|