dsh-turn-recovery 0.2.4 → 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 CHANGED
@@ -7,6 +7,59 @@
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
+
10
63
  ## [0.2.4] - 2026-11-09
11
64
 
12
65
  **运行期复核版**。0.2.3 之后按 8 天生产日志(645 个 `turn/end`)回头复查代码,
package/README.md CHANGED
@@ -64,8 +64,12 @@
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
- # 同目录还会写 guard-ids.log(跨重启认出晚到认领的补写 id)
72
+ # 必须 .log 结尾;同目录还会写 guard-ids.log(跨重启认出晚到认领的补写 id)
69
73
  logFile: '' # 空 = <DSH_HOME>/logs/turn-recovery.log
70
74
  logTurns: empty # empty = 只记空回合/被跳过判定的回合;all = 连正常回合也记
71
75
  dryRun: false # true = 判定/限流/日志全部照走,但不真的投递(验证用)
@@ -121,7 +125,7 @@
121
125
 
122
126
  `version=` 是进程里真正加载的版本号(从包自身 `package.json` 读)——**升级后先看这一行**,不要靠「某个日志字段有没有」推断跑的是哪一版(0.1.4 与 0.1.5 都打过 `sawStart`,这个推断会错)。
123
127
 
124
- 排查常用短语:`config issue`(配置写错,插件仍启用)、`skip scope`(被 `sessionScope` 过滤掉的会话,括号里写明是 `header says subagent` 还是 `id is not a root shape`)、`guard NOT armed (fail-closed`(同名 prompt 段落已被占用,插件整体不启用,不再谎报 armed)、`inbox already holds a pending turn-recovery nudge`(队列里已有一笔,未重复投递)、`turn/start not observed`(热加载中途,不判定)、`NOT ours`(同来源但 id 对不上,已忽略)、`LATE, from guardIds`(认领超时后才到,仍按补写回合处理)、`DISCARDED by host`(用户停止,补写被丢弃)、`a nudge is already awaiting claim`(上一笔还没被认领)、`skipped:in-flight`(本回合没排上,因为上一笔仍在途 —— 不算「已补过」)、`turn N already handled`(该回合确实排过或确实被限流)、`DROPPED: turn moved on`(定时器到点时会话已进入下一回合)、`stop chaining`(补写回合又空,按设计不再连环)、`budget exhausted`(本小时额度用完)。
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` 配到了别的文件,拒绝轮转它)。
125
129
 
126
130
  ## 实现约束
127
131
 
@@ -139,11 +143,12 @@
139
143
  ## 已知限制
140
144
 
141
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`(拿不到来源)时按"无法判定"放行,日志里能看到判定过程。
142
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` 分支,显示为「请求」卡片),但下游按类型联合写的消费者会漏掉它。
143
- - **用户点停止的窗口**:宿主 `cancel()` 只清 inbox + abort,不 append 任何 session 事件。插件订阅了宿主 dispatch 的 `agent/inbox/discarded` 来撤掉自己那条补写,但该事件是否向插件广播取决于宿主的 dispatch 链路;收不到时,「用户已点停止、插件仍在 `delayMs` 后补写」这个窗口依然存在(默认 900ms,可通过 `delayMs` 调小)。真正发出去的那一回合仍受宿主 abort 语义约束。
144
- - **`sessionScope: 'root'` 的判定依据是宿主的 `Session.header`**:`origin === 'subagent'` 或 `delegationDepth > 0` 才算子代理会话,`orchestrator-*` 这类顶层会话会被正常处理(0.2.0 用 id 前缀判断,把它们全部判成子代理,实测日志里有 19 次静默跳过)。宿主没有 header 的场合(老宿主、测试桩)退回 id 形态:`session-*` / `orchestrator-*` 算根,纯 UUID / `social-*` 不算。被过滤的会话每个 id 只记一条 `skip scope` 日志,不会静默消失。
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` 日志,不会静默消失。
145
150
  - **可见字符是「按回合累计」而不是「末条消息」**:一个回合里先写了结论、后来又被工具调用打断的,累计口径下不算空(这是刻意的:用户此前已经看到了文字)。反过来,`aborted` / `interrupted` 等非 `completed` 回合一律不补写,即使它确实没留下正文 —— 那些回合多半是用户自己喊停的,再补一轮会变成「已取消还继续跑」。
146
- - **崩溃遗留**:补写已入队但宿主在认领前崩溃时,该消息可能在下次启动被重放。插件只认 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 内的旧记录丢掉,反而让「晚到认领」认不出。
147
152
  - **预算文件是不可信输入**:解析时会丢弃「晚于当前时钟 +5 分钟」的时间戳(否则 `now - t` 为负、该会话被永久判成预算耗尽),并且整份文件只在 1 MiB 以内整读、超过就只读尾部(避免配置写错指向几百 MB 的文件时同步阻塞宿主)。写入用 JSON 行(会话 id 含空格也不会被截断),压缩是「重读磁盘 + 临时文件原子替换」,不会覆盖另一个进程刚追加的行。
148
153
  - **多进程(桌面宿主 + 独立的 web/headless 宿主)**:各自一套内存态,`agent.inbox.nextTurn` 只对本进程可见。预算文件与补写 id 文件是共享的,因此限流大致成立,但「同一会话同时被两个宿主打开」仍可能各补一笔。同一会话请只用一个宿主。
149
154
  - **`dryRun` 不写预算文件**:判定链、日志、限流检查照走,但不投递、不记账,所以它只能证明「判定会发生」,不能用来验证限流是否生效(日志里的 `budgetUsed=n/max` 是当时的已用额度)。
package/lib/index.js CHANGED
@@ -191,7 +191,7 @@ function enabledOr(value, fallback) {
191
191
  const CONFIG_KEYS = new Set([
192
192
  'enabled', 'injectInstruction', 'dryRun', 'instructionText',
193
193
  'minVisibleChars', 'delayMs', 'maxNudgesPerHour', 'logFile', 'logTurns',
194
- 'sessionScope', 'foregroundKinds', 'budgetFile',
194
+ 'sessionScope', 'foregroundKinds', 'nudgeSources', 'headerlessScope', 'budgetFile',
195
195
  ]);
196
196
  const NUMERIC_KEYS = ['minVisibleChars', 'delayMs', 'maxNudgesPerHour'];
197
197
  const STRING_KEYS = ['instructionText', 'logFile', 'budgetFile'];
@@ -260,6 +260,15 @@ function describeConfigIssues(raw) {
260
260
  const bad = raw.foregroundKinds.filter((k) => typeof k !== 'string' || k.trim() === '').length;
261
261
  if (bad > 0) issues.push(bad + ' non-string/empty entry in "foregroundKinds" -> dropped');
262
262
  }
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');
268
+ }
269
+ if (raw.headerlessScope !== undefined && raw.headerlessScope !== 'out' && raw.headerlessScope !== 'root') {
270
+ issues.push('"headerlessScope" expects "out" | "root", got ' + brief(raw.headerlessScope) + ' -> "out"');
271
+ }
263
272
  if (raw.logTurns !== undefined && raw.logTurns !== 'empty' && raw.logTurns !== 'all') {
264
273
  issues.push('"logTurns" expects "empty" | "all", got ' + brief(raw.logTurns) + ' -> "empty"');
265
274
  }
@@ -332,6 +341,31 @@ export function apply(ctx, config) {
332
341
  const extra = list.filter((k) => typeof k === 'string' && k.trim() !== '').map((k) => k.trim());
333
342
  return new Set([...DEFAULT_FOREGROUND_KINDS, ...extra]);
334
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',
335
369
  // 补写预算的持久化文件(跨重启限流);默认 $DSH_HOME/state/turn-recovery/nudges.log。
336
370
  budgetFile: typeof raw.budgetFile === 'string' ? raw.budgetFile.trim() : '',
337
371
  // 干跑:判定、限流、日志全部照常走,唯独不真的把补写投进队列。
@@ -348,7 +382,12 @@ export function apply(ctx, config) {
348
382
  const dshHome = typeof process.env.DSH_HOME === 'string' && process.env.DSH_HOME.trim() !== ''
349
383
  ? process.env.DSH_HOME.trim()
350
384
  : path.join(os.homedir(), '.dsh');
351
- const logPath = cfg.logFile !== '' ? cfg.logFile : path.join(dshHome, 'logs', 'turn-recovery.log');
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');
352
391
  const fallbackLogPath = path.join(os.tmpdir(), 'turn-recovery.log');
353
392
  const startedAt = Date.now();
354
393
  let lastRotateCheck = 0;
@@ -359,9 +398,33 @@ export function apply(ctx, config) {
359
398
  * 先试 rename,失败就退回 truncate(0) —— 宁可丢历史,也不能让日志无限长。
360
399
  */
361
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
+ };
362
418
  const rotateIfNeeded = (file) => {
363
419
  try {
364
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
+ }
365
428
  const backup = file + '.1';
366
429
  try {
367
430
  if (fs.existsSync(backup)) fs.unlinkSync(backup);
@@ -397,7 +460,9 @@ export function apply(ctx, config) {
397
460
  const log = (line) => {
398
461
  // 日志行内不允许出现换行:会话 id、错误文本都可能带 \n,拼进去就能在日志里
399
462
  // 伪造出一行看似插件自己写的记录(伪造 turn/end 或 guard armed 会误导排障)。
400
- const safe = String(line).replace(/[\r\n]+/g, ' ');
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, ' ');
401
466
  const record = '[' + new Date().toISOString() + '] ' + safe + '\n';
402
467
  maybeRotate();
403
468
  try {
@@ -431,9 +496,12 @@ export function apply(ctx, config) {
431
496
  * 也不能让限流文件的 IO 问题拖垮宿主回合)。多进程并发追加时可能丢几行,
432
497
  * 这是可接受的——它只是预算,不是账本。
433
498
  */
434
- const budgetPath = cfg.budgetFile !== ''
499
+ const customBudgetRejected = cfg.budgetFile !== '' && !/\.log$/i.test(cfg.budgetFile);
500
+ const budgetPath = (cfg.budgetFile !== '' && !customBudgetRejected)
435
501
  ? cfg.budgetFile
436
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');
437
505
  const BUDGET_MAX_BYTES = 1024 * 1024;
438
506
  /** 已投递补写 id 的持久化文件(跨重启识别晚到认领用),与预算文件同目录。 */
439
507
  const guardPath = path.join(path.dirname(budgetPath), 'guard-ids.log');
@@ -548,6 +616,64 @@ export function apply(ctx, config) {
548
616
  return list.slice(-GUARD_IDS_MAX);
549
617
  };
550
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
+
551
677
  const recordGuardId = (session, id, at) => {
552
678
  const sid = session && typeof session.id === 'string' ? session.id : '';
553
679
  if (sid === '' || typeof id !== 'string' || id === '') return;
@@ -558,6 +684,9 @@ export function apply(ctx, config) {
558
684
  while (list.length > GUARD_IDS_MAX) list.shift();
559
685
  fs.mkdirSync(path.dirname(guardPath), { recursive: true });
560
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());
561
690
  } catch { /* 只服务于去重,写失败不影响补写本身 */ }
562
691
  };
563
692
 
@@ -590,17 +719,31 @@ export function apply(ctx, config) {
590
719
  * 旧版直接用内存 map 覆盖写整份文件 —— 多进程(桌面宿主 + 独立 web/headless)
591
720
  * 同时运行时,会把对方刚追加的行整段抹掉,预算就少算、于是多补。
592
721
  */
722
+ let budgetCompactWarned = false;
593
723
  const compactBudget = (now) => {
724
+ let parsed = 0;
725
+ let rawText = '';
594
726
  try {
595
- const rawText = readTail(budgetPath, BUDGET_MAX_BYTES);
727
+ rawText = readTail(budgetPath, BUDGET_MAX_BYTES);
596
728
  for (const line of rawText.split('\n')) {
597
729
  const rec = parseBudgetLine(line, now);
598
730
  if (!rec) continue;
731
+ parsed += 1;
599
732
  const list = budgetHistory.get(rec.id);
600
733
  if (!list) budgetHistory.set(rec.id, [rec.at]);
601
734
  else if (list.indexOf(rec.at) < 0) list.push(rec.at);
602
735
  }
603
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
+ }
604
747
  pruneBudgetHistory(now);
605
748
  const lines = [];
606
749
  for (const [sid, times] of budgetHistory) {
@@ -639,14 +782,18 @@ export function apply(ctx, config) {
639
782
  * 日志里的会话短标签。旧版直接 id.slice(0,8),而根会话 id 形如
640
783
  * session-<uuid>,前 8 位恒为 "session-",多会话根本分不开。
641
784
  * 现在先剥掉 session- / orchestrator- / worker- 前缀再取前 8 位;剥完为空则退回原 id 前 8 位。
785
+ * 只拿到 id(没有 session 对象)的路径——例如宿主只发 { message } 的
786
+ * agent/inbox/discarded——直接复用 shortId。
642
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
+
643
795
  const tag = (session) => {
644
- try {
645
- const id = session && typeof session.id === 'string' ? session.id : '';
646
- if (!id) return 'no-session';
647
- const stripped = id.replace(/^(session-|orchestrator-|worker-)/, '');
648
- return stripped ? stripped.slice(0, 8) : id.slice(0, 8);
649
- } catch { return 'no-session'; }
796
+ try { return shortId(session && session.id); } catch { return 'no-session'; }
650
797
  };
651
798
 
652
799
  /**
@@ -668,6 +815,8 @@ export function apply(ctx, config) {
668
815
  */
669
816
  const scopeSeen = new Set();
670
817
  let scopeSeenCapped = false;
818
+ /** headerlessScope='root' 时,每个「靠 id 形态放行」的会话只记一条日志。 */
819
+ const headerlessSeen = new Set();
671
820
 
672
821
  /** header 判定根会话;返回 null 表示无法判定(没有 header)。 */
673
822
  const rootByHeader = (session) => {
@@ -702,9 +851,19 @@ export function apply(ctx, config) {
702
851
  return false;
703
852
  }
704
853
  const id = session && typeof session.id === 'string' ? session.id : '';
705
- if (/^(session-|orchestrator-)/.test(id)) return true;
706
- if (id === '') return false;
707
- logScopeSkip(session, 'id is not a root shape and no header');
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)');
708
867
  return false;
709
868
  };
710
869
 
@@ -815,6 +974,12 @@ export function apply(ctx, config) {
815
974
  * 拿去补写会对着「本来有正文的回合」发假提醒。
816
975
  */
817
976
  sawTurnStart: false,
977
+ /**
978
+ * 本回合最近一条非补写 user/message 的来源 kind('' = 本回合没见到)。
979
+ * 0.2.5 的来源闸门用它:只有来源在 nudgeSources(默认前台白名单)里
980
+ * 才允许补写,避免往社交桥这类「模型输出会被转发给第三方」的会话里插话。
981
+ */
982
+ turnOrigin: '',
818
983
  /**
819
984
  * 本回合已经「处理过」:要么排上了补写,要么在排补写前就被限流/去重挡下。
820
985
  * 只在 turn/end 的判定走完之后置位 —— 旧版在调用 scheduleNudge 之前就置位,
@@ -854,15 +1019,21 @@ export function apply(ctx, config) {
854
1019
  // 两个索引都写:对象身份是最快路径,id 是宿主换 session 实例时的兜底。
855
1020
  if (session && typeof session === 'object') state.set(session, s);
856
1021
  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);
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);
864
1036
  }
865
- stateById.set(sid, s);
866
1037
  }
867
1038
  return s;
868
1039
  };
@@ -1003,26 +1174,33 @@ export function apply(ctx, config) {
1003
1174
  }
1004
1175
 
1005
1176
  /**
1006
- * P2-C 缓解:用户点「停止」时宿主 cancel() 只清 inbox + abort,不 append 任何
1007
- * session 事件,插件单靠 session/event 无从得知那条补写已被丢弃,900ms 后照发,
1008
- * 于是「用户已经喊停」还会多跑一轮模型。
1009
- * 宿主(rc.5 bundle 内确认存在)会 dispatch 'agent/inbox/discarded'。这里尽力订阅,
1010
- * 但只在「被丢弃消息的 id 恰好是我们的补写」时才撤定时器:id 不匹配、或宿主根本
1011
- * 不向插件广播该事件时,行为退回旧版(不会误伤别的消息、也不会乱取消)。
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。
1012
1187
  */
1013
1188
  const onInboxDiscarded = (payload) => {
1014
1189
  try {
1015
- const agent = (payload && payload.agent) || payload;
1016
- const session = agent && agent.session;
1017
- if (!session) return;
1018
- const s = stateLookup(session);
1019
- if (!s) return;
1020
- const message = payload && payload.message;
1190
+ const message = payload && payload.message ? payload.message : payload;
1021
1191
  const id = message && typeof message.id === 'string' ? message.id : '';
1022
- if (id === '' || s.nudgeMessageId === null || id !== s.nudgeMessageId) return;
1023
- clearTimer(s, 'nudge discarded by host');
1024
- clearNudgeInFlight(s, 'discarded by host');
1025
- log('nudge DISCARDED by host before claim [' + tag(session) + ']');
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
+ }
1026
1204
  } catch (err) {
1027
1205
  log('agent/inbox/discarded handler error: ' + errText(err));
1028
1206
  }
@@ -1277,19 +1455,21 @@ export function apply(ctx, config) {
1277
1455
  // 仍可能认领那笔「已超时」的消息 —— 它进转录后开出的照样是补写回合。
1278
1456
  // 不认它,下一个空回合就会被当成真空回合再补一笔(连环补写)。
1279
1457
  const late = !matched && typeof data.id === 'string' && s.guardIds.indexOf(data.id) >= 0;
1280
- // 非「晚到」的情形都清在途标记(与旧版一致):对上 id 说明它已被消费;
1281
- // 对不上说明队列已经往前走了(上一进程遗留/重放),这笔补写也不会再被认领。
1282
- // 只有晚到认领例外 —— 它可能是旧 id,不该顺手抹掉新一笔的在途状态。
1283
- if (!late) clearNudgeInFlight(s, null);
1284
- // 关键:只有真的对上 id 才算「补写回合」。
1458
+ // 关键:只有真的对上 id(或晚到认领)才算「补写回合」。
1285
1459
  // 旧版把 matched 只写进日志、然后无条件置 awaitingGuardTurn,于是任何
1286
1460
  // 同来源但 id 不符的消息(上次进程遗留、重放、另一实例写入)都会被误认,
1287
1461
  // 导致下一个真正的空回合被判成「连环补写」而静默跳过。
1288
1462
  if (!matched && !late) {
1463
+ // 0.2.5 修:这里**不再清在途标记**。旧版在早退之前无条件 clear,
1464
+ // 于是同进程任一插件只要 append 一条 source.kind=plugin:turn-recovery
1465
+ // 且 id 随意的消息,就能把在途标记抹掉 → 下一个空回合多补一笔
1466
+ // (多一轮模型唤醒与费用)。真正「队列已前进」由 120s 认领超时兜底。
1289
1467
  log('guard-kind message seen but NOT ours (id mismatch/expired) -> ignored ['
1290
1468
  + tag(session) + ']');
1291
1469
  return;
1292
1470
  }
1471
+ // 只有对上 id 时它才算被消费;晚到认领是旧 id,不该抹掉新一笔的在途状态。
1472
+ if (matched) clearNudgeInFlight(s, null);
1293
1473
  s.awaitingGuardTurn = true;
1294
1474
  stats.claimed += 1;
1295
1475
  log('guard message claimed' + (late ? ' (LATE, from guardIds)' : '')
@@ -1305,6 +1485,10 @@ export function apply(ctx, config) {
1305
1485
  // 0.2.0 用「后台黑名单 + 未知即前台」,宿主每加一个通知来源就可能让补写
1306
1486
  // 被静默取消;白名单把默认值改成「不认识就当背景」,漏取消由 turn/start 兜底。
1307
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;
1308
1492
  // form:'notice' 是宿主的通知形态(tool-jobs / action-cards / plan-mode /
1309
1493
  // tool-goal 都用它):无论 kind 叫什么,它都不是人在说话。
1310
1494
  if (form !== 'notice' && cfg.foregroundKinds.has(kind)) {
@@ -1316,7 +1500,14 @@ export function apply(ctx, config) {
1316
1500
  if (event.type === 'assistant/message') {
1317
1501
  const message = data.message;
1318
1502
  if (!message || message.role !== 'assistant') return;
1319
- if (typeof data.turn === 'number' && s.turn !== data.turn) resetTurnCounters(s, 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
+ }
1320
1511
  const seen = inspectMessage(message);
1321
1512
  s.assistantMessages += 1;
1322
1513
  s.visibleChars += seen.chars;
@@ -1350,6 +1541,9 @@ export function apply(ctx, config) {
1350
1541
 
1351
1542
  const sawStart = s.sawTurnStart === true;
1352
1543
  const empty = !s.visibleNonText && s.visibleChars < cfg.minVisibleChars;
1544
+ // 回合已结束:来源标记取一次就清,避免跨回合残留把上一回合的来源当成这一次的。
1545
+ const turnOrigin = s.turnOrigin;
1546
+ s.turnOrigin = '';
1353
1547
  stats.turns += 1;
1354
1548
  if (empty) {
1355
1549
  if (kind === 'completed') stats.emptyCompleted += 1;
@@ -1375,6 +1569,15 @@ export function apply(ctx, config) {
1375
1569
  log('empty turn but kind=' + kind + ' -> no nudge');
1376
1570
  return;
1377
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
+ }
1378
1581
  if (wasGuard) {
1379
1582
  log('previous turn was already a guard turn and still empty -> stop chaining');
1380
1583
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-turn-recovery",
3
- "version": "0.2.4",
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",