dsh-turn-recovery 0.2.1 → 0.2.2

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,54 @@
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.2] - 2026-11-04
11
+
12
+ **代码审查修复 + 可验证性补强**。针对 0.2.1 的复审(宿主 `0.2.2-alpha.3` 源码取证、
13
+ `~/.dsh/logs/turn-recovery.log` 28 次激活 0 次 `nudge DELIVERED` 的实证)逐项修复,
14
+ 不含行为方向的改变,但对「补写到底有没有发生」的日志与状态记账做了实质纠正。
15
+
16
+ ### 新增
17
+
18
+ - **`dryRun` 配置项**:判定链、限流、日志全部照走,唯独不把补写交给宿主。
19
+ 这是给「生产环境从未观测到真实补写」准备的验证通道 —— 在真机上把 `dryRun: true`,
20
+ 等一次空回合,日志里出现 `nudge DRY-RUN: would deliver` 就说明整条判定链在生产可用,
21
+ 而且不会因此多跑一轮模型、不占小时预算。
22
+
23
+ ### 修复
24
+
25
+ - **`guardSentForTurn` 先置位、后调度**(P3-1):`scheduleNudge` 可能在内部早退
26
+ (已有在途补写 / 宿主队列里已有一笔 / 干跑),旧版在此之前就把回合记成「已补过」,
27
+ 日志写 `already nudged` 而实际一笔没发,同一回合再触发一次 `turn/end` 也就永久放弃。
28
+ 现在 `scheduleNudge` 返回 `scheduled` / `dry-run` / `skipped:<原因>`,只有真的接了活才记账,
29
+ 并额外打一行 `nudge outcome turn=N: <结果>`。字段同时更名为 `nudgeHandledTurn`(语义更准)。
30
+ - **`turn/end` 提前清空 `pendingTurn`**(P3-2):那道「回合已前进就丢弃定时器」的检查靠它做判据,
31
+ 而 `turn/end` 在调度之后又把它清零,于是一个没有 `turn/start` 的第二个 `turn/end` 之后
32
+ 检查恒不生效,补写可能插进下一个回合。现在 `turn/end` 不再碰它(更名 `scheduledTurn`),
33
+ 清理职责只落在定时器自身与 `clearTimer` 上。
34
+ - **认领超时可重排第二笔**(P3-3):120s 超时后 `nudgeMessageId` 被清掉并允许重排,
35
+ 但宿主仍可能认领那笔「已超时」的消息,旧版会把它判成 `NOT ours`,导致后面的空回合
36
+ 又被补一笔。现在保留最近 8 条投递 id(`guardIds`),晚到的认领照样算作补写回合
37
+ (日志 `guard message claimed (LATE, from guardIds)`)。同时把「清在途标记」挪到
38
+ 宿主队列去重之后,避免清掉标记后重复排期。
39
+ - **`budgetHistory` 只增不减**(P2-2):跨重启限流的内存 map 生命周期等于进程,旧版只在
40
+ 预算文件 >1 MB 时重建,重建后 map 里仍留着过期条目。现在重建时按一小时窗口同时修剪 map,
41
+ 并每 256 次投递做一次纯内存修剪(清过期条目与空会话)。
42
+ - **配置写错无人报警**(P3-4):宿主不校验插件配置(本包不导出 `Config` schema,原因见 README),
43
+ `maxNudgesPerHr`、`minVisibleChars: "abc"` 这类手滑会被静默兜底成默认值,用户以为改生效了。
44
+ 现在加载期检查未知键、数字/布尔/字符串类型、`sessionScope` 取值、`foregroundKinds` 形态,
45
+ 逐条写 `config issue: ...` 与汇总行。只报告、不抛错 —— 一个笔误不该让整个插件拒绝加载。
46
+ - **日志 `tag()` 对 `worker-*` 只截前 8 位**(P3-6):前缀剥离正则补上 `worker-`,
47
+ 多 worker 会话不再全挤成 `[worker-a]`。
48
+
49
+ ### 说明
50
+
51
+ - **`source.kind = 'plugin:turn-recovery'` 不在宿主 `MessageSourceMap` 的类型联合内**(P3-5):
52
+ 宿主运行时只校验 `source.kind` 是非空字符串(`dsh-session` 的 `assertMessageEventShape`),
53
+ 类型联合的缺项只影响本仓库以外的 TS 读者,前端对未知 kind 有 `default` 分支。
54
+ 保持现状并写进 README「已知限制」,不改协议用法。
55
+ - 回归用例 43 → 49:新增 P3-1(早退不记账)、P3-2(回合前进丢弃)、`dryRun`、
56
+ P3-3(晚到认领)、配置写错告警、`worker-` tag。
57
+
10
58
  ## [0.2.1] - 2026-11-03
11
59
 
12
60
  **适配性修复**:针对 DSH `0.2.2-alpha.3` 实测(宿主 `dsh-session` / `dsh-agent-loop` /
package/README.md CHANGED
@@ -66,35 +66,45 @@
66
66
  foregroundKinds: [] # 追加「会取消待发补写」的来源 kind(默认 user / agent-message)
67
67
  budgetFile: '' # 空 = <DSH_HOME>/state/turn-recovery/nudges.log(跨重启限流)
68
68
  logFile: '' # 空 = <DSH_HOME>/logs/turn-recovery.log
69
+ dryRun: false # true = 判定/限流/日志全部照走,但不真的投递(验证用)
69
70
  ```
70
71
 
71
72
  配置做严格解析:`maxNudgesPerHour: 0` 是**关闭补写**,不会被默认值吞成 3;`delayMs: "abc"` 这类非法值回落到默认值;`enabled: "false"`(字符串)与 `false` 等价。
72
73
 
74
+ 宿主**不校验**插件配置(本包不导出 `Config` schema,原因见「实现约束」),所以键名写错不会被谁拦下来。插件改为在加载期自查:未知键、类型不符、`sessionScope` 取值不对等,逐条写进日志(`config issue: "minNudgesPerHour" is unknown -> ignored`),插件照常启用。改完配置先看这几行,比猜「为什么没生效」划算。
75
+
73
76
  ## 日志
74
77
 
75
78
  默认写到 `<DSH_HOME>/logs/turn-recovery.log`(未设 `DSH_HOME` 时是 `~/.dsh/logs/…`;主路径不可写时兜底到系统临时目录;超过 5 MB 自动轮转为 `.1`):
76
79
 
77
80
  ```
78
- [2026-10-25T01:49:13.941Z] plugin activated: pid=17520 enabled=true instruction=true dshHome=C:\Users\me\.dsh
81
+ [2026-10-25T01:49:13.941Z] plugin activated: pid=17520 version=0.2.2 enabled=true instruction=true dryRun=false dshHome=C:\Users\me\.dsh
79
82
  [2026-10-25T01:49:13.942Z] prompt section registered: order=960 chars=222
80
83
  [2026-10-25T01:49:13.942Z] guard armed (instruction=registered, uptime=1ms)
81
84
  [2026-10-25T01:58:02.114Z] turn/end turn=3 kind=completed steps=4 visibleChars=812 empty=false wasGuard=false sawStart=true [5f884825]
82
85
  [2026-10-25T02:03:41.507Z] turn/end turn=4 kind=completed steps=6 visibleChars=0 empty=true wasGuard=false sawStart=true [5f884825]
83
86
  [2026-10-25T02:03:41.508Z] nudge SCHEDULED turn=4 delay=900ms [5f884825] (empty completed turn)
87
+ [2026-10-25T02:03:41.509Z] nudge outcome turn=4: scheduled [5f884825]
84
88
  [2026-10-25T02:03:42.561Z] nudge DELIVERED via send(next-turn,wakeup) [5f884825]
85
89
  [2026-10-25T02:03:45.004Z] guard message claimed turn=5 matched=true [5f884825]
86
90
  ```
87
91
 
88
- 正常回合只需看 `empty=false`;出现 `empty=true` 就应紧跟一条 `nudge DELIVERED`,再往后出现 `guard message claimed ... matched=true` 才说明补写回合真的起来了。`[...]` 是会话 id(剥掉 `session-` 前缀)前 8 位,`wasGuard` 表示该回合是不是补写回合,`sawStart` 表示插件是否看到过该回合的 `turn/start`(中途热加载时为 false,此时不判定)。
92
+ 正常回合只需看 `empty=false`;出现 `empty=true` 就应紧跟 `nudge outcome turn=N: scheduled` 与 `nudge DELIVERED`,再往后出现 `guard message claimed ... matched=true` 才说明补写回合真的起来了。`[...]` 是会话 id(剥掉 `session-` / `orchestrator-` / `worker-` 前缀)前 8 位,`wasGuard` 表示该回合是不是补写回合,`sawStart` 表示插件是否看到过该回合的 `turn/start`(中途热加载时为 false,此时不判定)。
93
+
94
+ 只想验证判定链、不想真的多跑一轮模型时,把 `dryRun` 打开,日志会写 `nudge DRY-RUN: would deliver turn=N` 并停在那里(既不投递,也不占小时预算)。
89
95
 
90
96
  `version=` 是进程里真正加载的版本号(从包自身 `package.json` 读)——**升级后先看这一行**,不要靠「某个日志字段有没有」推断跑的是哪一版(0.1.4 与 0.1.5 都打过 `sawStart`,这个推断会错)。
91
97
 
92
- 排查常用短语:`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 对不上,已忽略)、`DISCARDED by host`(用户停止,补写被丢弃)、`a nudge is already awaiting claim`(上一笔还没被认领)、`stop chaining`(补写回合又空,按设计不再连环)、`budget exhausted`(本小时额度用完)。
98
+ 排查常用短语:`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`(本小时额度用完)。
93
99
 
94
100
  ## 实现约束
95
101
 
96
102
  - 只 import Node 内置模块(`node:fs` / `node:os` / `node:path` / `node:crypto`)。插件被装进 profile 的 `node_modules` 后,裸名 `@deepseek-ai/*` 在插件目录下 Node 解析不到,少一个依赖就少一个「插件整个加载失败」的风险面。
97
- - 不导出 `Config` schema(宿主允许无 schema 的 entry),配置在 `apply(ctx, config)` 内部做防御性默认值合并。
103
+ - **不导出 `Config` schema**,配置在 `apply(ctx, config)` 内部做防御性默认值合并 + 加载期自查。这不是偷懒,是三个约束撞在一起的结果:
104
+ 1. cordis 的配置解析走 Standard Schema v1(`runtime.Config["~standard"].validate(config)`),只要导出一个带该方法的对象就能校验;
105
+ 2. 但宿主另有一条 schemastery 专用路径 —— `isSchemastery()`(`cordis-plugin-loader`)与 `isNativeConfigSchema()`(要求 `value[Symbol.for('schemastery')] === true`)都按 schemastery 判别,非 schemastery 的 schema 会被当成「没有 schema」;
106
+ 3. 而 `dsh-settings` 投影表单时**直接读** `schema.type` / `schema.dict` / `schema.meta.volatile`,没有上面那层判别保护 —— 一个形状不对的 `Config` 有可能让设置页报错。
107
+ 本包零依赖,装进 profile 后也解析不到 `schemastery`(更不能 import `@deepseek-ai/*`)。与其导出一个「在 A 路径像 schema、在 B 路径像垃圾」的对象,不如不导出,把校验放在加载期日志里:错了能看见,也不会让设置页崩。
98
108
  - 所有逻辑包在 try/catch 里:任何异常只写日志,不向上抛,不会拖垮宿主回合。
99
109
  - 宿主门槛 `engines.dsh = ">=0.2.1-rc.1 <0.3.0-0"`:本插件用到的宿主 API 在 `0.2.1-rc.1` / `rc.2` / `rc.3` / `rc.4` / `rc.5` 里实现完全一致(rc.3 / rc.4 / rc.5 的 `dsh-system-prompt` / `dsh-agent` / `dsh-session` / `dsh-agent-loop` / `dsh-app-boot` 的 `lib/index.js` sha256 逐字节相同),所以门槛只设到 rc.1。**不要写成 `>=0.2.1`**——semver 里预发布版本不满足不含预发布标识的同段范围,`0.2.1-rc.3` 对 `>=0.2.1` 判为 `false`,会连 rc 版本一起挡掉。上界必须写成 `<0.3.0-0`:宿主用 `semver` 的 `includePrerelease: true` 判定门槛,写成 `<0.3.0` 时 `0.3.0-alpha.1` / `0.3.0-beta.2` 都会被判为满足(实测 `semver.satisfies('0.3.0-alpha.1', '>=0.2.1-rc.1 <0.3.0', {includePrerelease:true}) === true`),插件会带着未验证的假设加载到下一主线;`-0` 是显式的预发布截断。`scripts/check-metadata.mjs` 会强制检查这个写法。门槛不满足时宿主会把整个 bundle 静默跳过(启动只留一行 `skipping profile bundle`)。
100
110
  - **升级宿主前请先看门槛**:`<0.3.0-0` 的上界意味着宿主升到 0.3.0(含 `0.3.0-alpha*` / `0.3.0-beta*`)时本插件会**静默不加载**(`dsh-app-boot` 只往 stderr 写一行 `skipping profile bundle`)。请先看日志首行 `plugin activated: ... version=...`:没有这一行就是没加载。届时要么升级本插件并把上界往上放,要么临时把 `engines.dsh` 放宽(自担 API 漂移风险)。
@@ -102,13 +112,13 @@
102
112
 
103
113
  ## 已知限制
104
114
 
105
- - **补写消息以 `role: 'user'` 落盘**(`source.kind = 'plugin:turn-recovery'`):宿主 session 层对 `source.kind` 不做枚举校验,插件侧也改不了 role。桌面前端按 `source.kind !== 'user'` 渲染成上下文卡片(不是用户气泡),mnemon 只收录 `source.kind === 'user'`,所以界面与记忆都不受污染;但**会话转录文件里它确实是一条 user 消息**,审计时要看 `source.kind` 区分。
115
+ - **补写消息以 `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` 分支,显示为「请求」卡片),但下游按类型联合写的消费者会漏掉它。
106
116
  - **用户点停止的窗口**:宿主 `cancel()` 只清 inbox + abort,不 append 任何 session 事件。插件订阅了宿主 dispatch 的 `agent/inbox/discarded` 来撤掉自己那条补写,但该事件是否向插件广播取决于宿主的 dispatch 链路;收不到时,「用户已点停止、插件仍在 `delayMs` 后补写」这个窗口依然存在(默认 900ms,可通过 `delayMs` 调小)。真正发出去的那一回合仍受宿主 abort 语义约束。
107
117
  - **`sessionScope: 'root'` 的判定依据是宿主的 `Session.header`**:`origin === 'subagent'` 或 `delegationDepth > 0` 才算子代理会话,`orchestrator-*` 这类顶层会话会被正常处理(0.2.0 用 id 前缀判断,把它们全部判成子代理,实测日志里有 19 次静默跳过)。宿主没有 header 的场合(老宿主、测试桩)退回 id 形态:`session-*` / `orchestrator-*` 算根,纯 UUID / `social-*` 不算。被过滤的会话每个 id 只记一条 `skip scope` 日志,不会静默消失。
108
118
  - **可见字符是「按回合累计」而不是「末条消息」**:一个回合里先写了结论、后来又被工具调用打断的,累计口径下不算空(这是刻意的:用户此前已经看到了文字)。反过来,`aborted` / `interrupted` 等非 `completed` 回合一律不补写,即使它确实没留下正文 —— 那些回合多半是用户自己喊停的,再补一轮会变成「已取消还继续跑」。
109
- - **崩溃遗留**:补写已入队但宿主在认领前崩溃时,该消息可能在下次启动被重放。插件只认 id 匹配的认领(`NOT ours`),因此不会被这种消息骗成「补写回合」,但它仍可能作为一条插件消息出现在转录里。
119
+ - **崩溃遗留**:补写已入队但宿主在认领前崩溃时,该消息可能在下次启动被重放。插件只认 id 匹配的认领(`NOT ours`),因此不会被这种消息骗成「补写回合」,但它仍可能作为一条插件消息出现在转录里。为覆盖「120s 认领超时后才被认领」的情形,最近 8 条投递 id 会留在内存里(`LATE, from guardIds`),进程重启后这份记忆就没了。
110
120
  - **多宿主共用同一日志文件**:两个进程同时写同一路径时轮转可能失败,此时回退 `truncate(0)` 并记一条 `log rotate fell back to truncate`。
111
- - **生产环境尚未观测到真实补写**:截至 2026-10-30,本机生产日志里 `nudge DELIVERED` 仍为 0(空回合全部是 `aborted`),所有结论来自单测与 mock 复现。真机端到端(含「补写回合真的开起来」)仍是未验证项。
121
+ - **生产环境仍未观测到真实补写**:截至 2026-11-04,本机生产日志里 `nudge DELIVERED` 仍为 0(空回合全部是 `aborted` / `blocked`),所有结论来自单测与 mock 复现。要闭合这一项,请在真机上临时设置 `dryRun: true`,等一次 `completed` 空回合,确认日志出现 `nudge DRY-RUN: would deliver`(判定链在生产成立),再关掉 `dryRun` 等真实投递 —— 这比直接开补写更安全:干跑不会多跑一轮模型。
112
122
 
113
123
  ## License
114
124
 
package/lib/index.js CHANGED
@@ -160,6 +160,67 @@ function enabledOr(value, fallback) {
160
160
  return true;
161
161
  }
162
162
 
163
+ /** 配置项清单:只用于在加载期挑出「写错的键」,不作为校验依据。 */
164
+ const CONFIG_KEYS = new Set([
165
+ 'enabled', 'injectInstruction', 'dryRun', 'instructionText',
166
+ 'minVisibleChars', 'delayMs', 'maxNudgesPerHour', 'logFile',
167
+ 'sessionScope', 'foregroundKinds', 'budgetFile',
168
+ ]);
169
+ const NUMERIC_KEYS = ['minVisibleChars', 'delayMs', 'maxNudgesPerHour'];
170
+ const STRING_KEYS = ['instructionText', 'logFile', 'budgetFile'];
171
+ const BOOLEAN_KEYS = ['enabled', 'injectInstruction', 'dryRun'];
172
+
173
+ /** 安全地把任意值转成一行短文本(循环引用/抛异常的取值都不会炸)。 */
174
+ function brief(value) {
175
+ if (typeof value === 'string') return value.length > 40 ? JSON.stringify(value.slice(0, 40) + '…') : JSON.stringify(value);
176
+ if (typeof value === 'number' || typeof value === 'boolean' || value === null) return String(value);
177
+ if (Array.isArray(value)) return 'array(' + value.length + ')';
178
+ return typeof value;
179
+ }
180
+
181
+ /**
182
+ * 列出「看起来写错了」的配置项。只报告、不抛错:
183
+ * 一个手滑的键名不该让整个插件拒绝加载,但也不能像以前那样静默吞掉。
184
+ */
185
+ function describeConfigIssues(raw) {
186
+ const issues = [];
187
+ if (raw === undefined || raw === null) return issues;
188
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
189
+ issues.push('config must be an object, got ' + brief(raw));
190
+ return issues;
191
+ }
192
+ for (const key of Object.keys(raw)) {
193
+ // 双下划线前缀是测试/内部保留位(测试 rig 用它注入 __sessionId 之类的开关),不算写错的键。
194
+ if (key.startsWith('__')) continue;
195
+ if (!CONFIG_KEYS.has(key)) issues.push('unknown key "' + key + '" ignored');
196
+ }
197
+ for (const key of NUMERIC_KEYS) {
198
+ const v = raw[key];
199
+ if (v === undefined) continue;
200
+ if (typeof v === 'number' && Number.isFinite(v)) continue;
201
+ if (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))) continue;
202
+ issues.push('"' + key + '" expects a number, got ' + brief(v) + ' -> default used');
203
+ }
204
+ for (const key of BOOLEAN_KEYS) {
205
+ const v = raw[key];
206
+ if (v === undefined || typeof v === 'boolean') continue;
207
+ if (typeof v === 'string' || typeof v === 'number') continue;
208
+ issues.push('"' + key + '" expects a boolean, got ' + brief(v) + ' -> ' + (enabledOr(v, false) ? 'true' : 'false'));
209
+ }
210
+ for (const key of STRING_KEYS) {
211
+ const v = raw[key];
212
+ if (v === undefined || typeof v === 'string') continue;
213
+ issues.push('"' + key + '" expects a string, got ' + brief(v) + ' -> ignored');
214
+ }
215
+ if (raw.sessionScope !== undefined && raw.sessionScope !== 'root' && raw.sessionScope !== 'all') {
216
+ issues.push('"sessionScope" expects "root" | "all", got ' + brief(raw.sessionScope) + ' -> "root"');
217
+ }
218
+ if (raw.foregroundKinds !== undefined && !Array.isArray(raw.foregroundKinds)) {
219
+ issues.push('"foregroundKinds" expects an array, got ' + brief(raw.foregroundKinds) + ' -> ignored');
220
+ }
221
+ return issues;
222
+ }
223
+
163
224
  /** 一条消息里所有 text 块拼起来,剥掉不可见字符和首尾空白后的可见文本。 */
164
225
  function visibleText(message) {
165
226
  const content = message && Array.isArray(message.content) ? message.content : [];
@@ -218,8 +279,16 @@ export function apply(ctx, config) {
218
279
  })(),
219
280
  // 补写预算的持久化文件(跨重启限流);默认 $DSH_HOME/state/turn-recovery/nudges.log。
220
281
  budgetFile: typeof raw.budgetFile === 'string' ? raw.budgetFile.trim() : '',
282
+ // 干跑:判定、限流、日志全部照常走,唯独不真的把补写投进队列。
283
+ // 用它在真实宿主里验证「空回合链」是否成立,而不产生任何额外模型回合。
284
+ dryRun: enabledOr(raw.dryRun, false),
221
285
  };
222
286
 
287
+ // 宿主不校验插件配置(本包不导出 Config schema,见 README「为什么没有 Config」),
288
+ // 于是写错键名/类型只会在运行期被静默兜底成默认值 —— 用户以为改生效了,其实没有。
289
+ // 这里在加载期把可疑配置显式挑出来写进日志,成本是几行字符串拼接。
290
+ const configIssues = describeConfigIssues(raw);
291
+
223
292
  // 日志位置优先跟随 DSH_HOME(多 profile / 自定义家目录时才不会写错地方)。
224
293
  const dshHome = typeof process.env.DSH_HOME === 'string' && process.env.DSH_HOME.trim() !== ''
225
294
  ? process.env.DSH_HOME.trim()
@@ -333,21 +402,37 @@ export function apply(ctx, config) {
333
402
  return persisted ? persisted.slice() : [];
334
403
  };
335
404
 
336
- /** 记录一次真正投递成功的补写;文件过大时先按一小时窗口重写。 */
405
+ /**
406
+ * 记录一次真正投递成功的补写;文件过大时先按一小时窗口重写。
407
+ * 顺带修剪内存里的 budgetHistory:这张 map 的寿命等于进程,
408
+ * 旧版只在文件 >1MB 时重建,而且重建后 map 里仍留着过期条目,
409
+ * 长时间运行(每会话每小时若干条)会只增不减(P2-2)。
410
+ */
411
+ let recordTicks = 0;
337
412
  const recordNudge = (session, at) => {
338
413
  const id = session && typeof session.id === 'string' ? session.id : '';
339
414
  if (id === '') return;
340
415
  try {
341
416
  if (!budgetHistory.has(id)) budgetHistory.set(id, []);
342
417
  budgetHistory.get(id).push(at);
418
+ const cutoff = Date.now() - HOUR_MS;
419
+ 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
+ }
343
431
  fs.mkdirSync(path.dirname(budgetPath), { recursive: true });
344
- let size = 0;
345
- try { size = fs.statSync(budgetPath).size; } catch { /* 首次写入 */ }
346
- if (size > BUDGET_MAX_BYTES) {
347
- const cutoff = Date.now() - HOUR_MS;
432
+ if (rewrite) {
348
433
  const lines = [];
349
434
  for (const [sid, times] of budgetHistory) {
350
- for (const t of times) if (t >= cutoff) lines.push(sid + ' ' + t);
435
+ for (const t of times) lines.push(sid + ' ' + t);
351
436
  }
352
437
  fs.writeFileSync(budgetPath, lines.join('\n') + (lines.length > 0 ? '\n' : ''));
353
438
  } else {
@@ -359,13 +444,13 @@ export function apply(ctx, config) {
359
444
  /**
360
445
  * 日志里的会话短标签。旧版直接 id.slice(0,8),而根会话 id 形如
361
446
  * session-<uuid>,前 8 位恒为 "session-",多会话根本分不开。
362
- * 现在先剥掉 session- / orchestrator- 前缀再取前 8 位;剥完为空则退回原 id 前 8 位。
447
+ * 现在先剥掉 session- / orchestrator- / worker- 前缀再取前 8 位;剥完为空则退回原 id 前 8 位。
363
448
  */
364
449
  const tag = (session) => {
365
450
  try {
366
451
  const id = session && typeof session.id === 'string' ? session.id : '';
367
452
  if (!id) return 'no-session';
368
- const stripped = id.replace(/^(session-|orchestrator-)/, '');
453
+ const stripped = id.replace(/^(session-|orchestrator-|worker-)/, '');
369
454
  return stripped ? stripped.slice(0, 8) : id.slice(0, 8);
370
455
  } catch { return 'no-session'; }
371
456
  };
@@ -424,7 +509,12 @@ export function apply(ctx, config) {
424
509
  };
425
510
 
426
511
  log('plugin activated: pid=' + process.pid + ' version=' + VERSION
427
- + ' enabled=' + cfg.enabled + ' instruction=' + cfg.injectInstruction + ' dshHome=' + dshHome);
512
+ + ' enabled=' + cfg.enabled + ' instruction=' + cfg.injectInstruction
513
+ + ' dryRun=' + cfg.dryRun + ' dshHome=' + dshHome);
514
+ if (configIssues.length > 0) {
515
+ for (const issue of configIssues) log('config issue: ' + issue);
516
+ log('config issues: ' + configIssues.length + ' (defaults used where noted)');
517
+ }
428
518
 
429
519
  // ---- A) system prompt 段落 ----
430
520
  if (cfg.injectInstruction) {
@@ -481,7 +571,14 @@ export function apply(ctx, config) {
481
571
  * 拿去补写会对着「本来有正文的回合」发假提醒。
482
572
  */
483
573
  sawTurnStart: false,
484
- guardSentForTurn: null,
574
+ /**
575
+ * 本回合已经「处理过」:要么排上了补写,要么在排补写前就被限流/去重挡下。
576
+ * 只在 turn/end 的判定走完之后置位 —— 旧版在调用 scheduleNudge 之前就置位,
577
+ * 于是 scheduleNudge 内部早退(已有在途补写 / 队列里已有 / 预算耗尽)时,
578
+ * 日志会写着「already nudged」而其实一笔都没发,同一回合再触发一次
579
+ * turn/end 也就永久放弃了这次补写机会。
580
+ */
581
+ nudgeHandledTurn: null,
485
582
  /**
486
583
  * 补写消息已经被宿主认领、写进转录,且它对应的回合还没结束。
487
584
  * 只在看到 source.kind === SOURCE_KIND 的 user/message 时置位;
@@ -492,8 +589,19 @@ export function apply(ctx, config) {
492
589
  nudgeMessageId: null,
493
590
  /** 上述补写的投递时间,用于认领超时。 */
494
591
  nudgeDeliveredAt: 0,
495
- /** 定时器是为哪个回合排的;回合变了就该丢弃。 */
496
- pendingTurn: null,
592
+ /**
593
+ * 最近投递过的补写 id(有界,最多 8 条)。
594
+ * 认领超时后 nudgeMessageId 会被清掉并允许重排第二笔,但宿主仍可能认领
595
+ * 那笔「超时」的消息;靠这个列表把晚到的认领仍算作补写回合,
596
+ * 否则第二个空回合会被当成真空回合再补一笔。
597
+ */
598
+ guardIds: [],
599
+ /**
600
+ * 定时器是为哪个回合排的;回合变了就该丢弃。
601
+ * 与 turn/end 的清零无关:turn/end 先清 s.turn 之外的字段不该顺手把它清空,
602
+ * 否则「回合已前进」这道检查在无 turn/start 的第二个 turn/end 之后会失效。
603
+ */
604
+ scheduledTurn: null,
497
605
  /** 本小时已投递的补写时刻:启动时用持久化记录补齐(跨重启限流)。 */
498
606
  nudgeTimes: historyOf(session),
499
607
  timer: null,
@@ -508,7 +616,7 @@ export function apply(ctx, config) {
508
616
  try { clearTimeout(s.timer); } catch { /* ignore */ }
509
617
  timers.delete(s.timer);
510
618
  s.timer = null;
511
- s.pendingTurn = null;
619
+ s.scheduledTurn = null;
512
620
  if (why) log('pending nudge CANCELLED (' + why + ')');
513
621
  };
514
622
 
@@ -530,7 +638,7 @@ export function apply(ctx, config) {
530
638
  s.visibleChars = 0;
531
639
  s.visibleNonText = false;
532
640
  s.sawTurnStart = false;
533
- s.guardSentForTurn = null;
641
+ s.nudgeHandledTurn = null;
534
642
  };
535
643
 
536
644
  /**
@@ -620,7 +728,8 @@ export function apply(ctx, config) {
620
728
  clearTimer(s, null);
621
729
  clearNudgeInFlight(s, null);
622
730
  s.awaitingGuardTurn = false;
623
- s.pendingTurn = null;
731
+ s.scheduledTurn = null;
732
+ s.guardIds = [];
624
733
  }
625
734
  agents.delete(session);
626
735
  if (typeof session.id === 'string' && session.id !== '') agentsById.delete(session.id);
@@ -726,17 +835,22 @@ export function apply(ctx, config) {
726
835
  return s.nudgeTimes.length;
727
836
  };
728
837
 
838
+ /**
839
+ * 排一笔补写。返回结果字符串,供调用侧决定是否把本回合标记为「已处理」:
840
+ * 'scheduled' | 'dry-run' | 'skipped:<原因>' | 'error'
841
+ * 旧版返回 undefined 且由调用侧提前置位 guardSentForTurn,于是这里早退时
842
+ * 那个回合已经被记账成「补过」,日志与实际投递对不上。
843
+ */
729
844
  const scheduleNudge = (session, s, turn, info) => {
730
845
  try {
731
846
  // 上一笔补写还没被认领时不要再排第二笔:
732
847
  // 宿主按队列顺序逐条认领,两条补写只会变成两个多余的回合。
733
848
  if (nudgePending(s)) {
734
849
  log('nudge SKIPPED: a nudge is already awaiting claim [' + tag(session) + ']');
735
- return;
850
+ return 'skipped:in-flight';
736
851
  }
737
- if (s.nudgeMessageId !== null) clearNudgeInFlight(s, 'claim timeout');
738
852
  if (s.timer) clearTimer(s, 'rescheduled');
739
- s.pendingTurn = turn;
853
+ s.scheduledTurn = turn;
740
854
  const delay = cfg.delayMs;
741
855
  log('nudge SCHEDULED turn=' + turn + ' delay=' + delay + 'ms [' + tag(session) + '] (' + info + ')');
742
856
  const timer = setTimeout(() => {
@@ -745,12 +859,12 @@ export function apply(ctx, config) {
745
859
  s.timer = null;
746
860
  try {
747
861
  // 定时器跑起来时回合可能已经往前走:丢弃过期补写,避免插进别人的回合。
748
- if (s.pendingTurn !== null && s.turn !== s.pendingTurn) {
749
- s.pendingTurn = null;
750
- log('nudge DROPPED: turn moved on (pending=' + turn + ' current=' + s.turn + ') [' + tag(session) + ']');
862
+ if (s.scheduledTurn !== null && s.turn !== s.scheduledTurn) {
863
+ s.scheduledTurn = null;
864
+ log('nudge DROPPED: turn moved on (scheduled=' + turn + ' current=' + s.turn + ') [' + tag(session) + ']');
751
865
  return;
752
866
  }
753
- s.pendingTurn = null;
867
+ s.scheduledTurn = null;
754
868
  if (s.awaitingGuardTurn) {
755
869
  log('nudge SKIPPED: a guard turn is already in flight [' + tag(session) + ']');
756
870
  return;
@@ -760,12 +874,14 @@ export function apply(ctx, config) {
760
874
  log('nudge SKIPPED: a nudge is already awaiting claim [' + tag(session) + ']');
761
875
  return;
762
876
  }
763
- if (s.nudgeMessageId !== null) clearNudgeInFlight(s, 'claim timeout');
877
+ // 先去重、再清「超时」的在途标记:反过来的话,队列里其实还躺着上一笔
878
+ // 时标记已被清掉,下一轮判定就看不到它,可能再补一笔(P3-3)。
764
879
  if (pendingNudgeInInbox(agentOf(session))) {
765
880
  log('nudge SKIPPED: inbox already holds a pending turn-recovery nudge ['
766
881
  + tag(session) + ']');
767
882
  return;
768
883
  }
884
+ if (s.nudgeMessageId !== null) clearNudgeInFlight(s, 'claim timeout');
769
885
  if (cfg.maxNudgesPerHour === 0) {
770
886
  log('nudge SKIPPED: maxNudgesPerHour=0 [' + tag(session) + ']');
771
887
  return;
@@ -775,6 +891,13 @@ export function apply(ctx, config) {
775
891
  log('hourly nudge budget exhausted (' + s.nudgeTimes.length + ') -> no nudge');
776
892
  return;
777
893
  }
894
+ if (cfg.dryRun) {
895
+ // 干跑:判定链、限流、日志全部照走,只是不把消息交给宿主。
896
+ // 这样可以在真实宿主里验证「会判空 → 会想补」而不产生任何模型回合。
897
+ log('nudge DRY-RUN: would deliver turn=' + turn + ' delay=' + delay + 'ms ['
898
+ + tag(session) + ']');
899
+ return;
900
+ }
778
901
  const message = deliver(session, NUDGE_TEXT);
779
902
  if (message) {
780
903
  // 只记「在途」,不置 awaitingGuardTurn:投递 ≠ 认领。
@@ -782,6 +905,10 @@ export function apply(ctx, config) {
782
905
  s.nudgeMessageId = message.id;
783
906
  s.nudgeDeliveredAt = Date.now();
784
907
  s.nudgeTimes.push(Date.now());
908
+ if (typeof message.id === 'string' && message.id !== '') {
909
+ s.guardIds.push(message.id);
910
+ while (s.guardIds.length > 8) s.guardIds.shift();
911
+ }
785
912
  // 跨重启限流:把投递时刻落盘,重启后的新进程回读最近一小时。
786
913
  recordNudge(session, s.nudgeDeliveredAt);
787
914
  log('nudge awaiting claim id=' + String(message.id).slice(0, 8) + ' [' + tag(session) + ']');
@@ -797,8 +924,10 @@ export function apply(ctx, config) {
797
924
  if (timer && typeof timer.unref === 'function') timer.unref();
798
925
  s.timer = timer;
799
926
  timers.add(timer);
927
+ return cfg.dryRun ? 'dry-run' : 'scheduled';
800
928
  } catch (err) {
801
929
  log('scheduleNudge error: ' + errText(err));
930
+ return 'error';
802
931
  }
803
932
  };
804
933
 
@@ -825,18 +954,26 @@ export function apply(ctx, config) {
825
954
  // 补写消息被宿主从 next-turn 队列认领并写进转录,这一刻它才真正生效。
826
955
  // 投递成功只代表「已入队」,消息可能还躺在队列里,也可能被取消。
827
956
  const matched = s.nudgeMessageId !== null && data.id === s.nudgeMessageId;
828
- clearNudgeInFlight(s, null);
957
+ // 认领超时(120s)后 nudgeMessageId 会被清掉并允许改排第二笔,但宿主
958
+ // 仍可能认领那笔「已超时」的消息 —— 它进转录后开出的照样是补写回合。
959
+ // 不认它,下一个空回合就会被当成真空回合再补一笔(连环补写)。
960
+ const late = !matched && typeof data.id === 'string' && s.guardIds.indexOf(data.id) >= 0;
961
+ // 非「晚到」的情形都清在途标记(与旧版一致):对上 id 说明它已被消费;
962
+ // 对不上说明队列已经往前走了(上一进程遗留/重放),这笔补写也不会再被认领。
963
+ // 只有晚到认领例外 —— 它可能是旧 id,不该顺手抹掉新一笔的在途状态。
964
+ if (!late) clearNudgeInFlight(s, null);
829
965
  // 关键:只有真的对上 id 才算「补写回合」。
830
966
  // 旧版把 matched 只写进日志、然后无条件置 awaitingGuardTurn,于是任何
831
967
  // 同来源但 id 不符的消息(上次进程遗留、重放、另一实例写入)都会被误认,
832
968
  // 导致下一个真正的空回合被判成「连环补写」而静默跳过。
833
- if (!matched) {
969
+ if (!matched && !late) {
834
970
  log('guard-kind message seen but NOT ours (id mismatch/expired) -> ignored ['
835
971
  + tag(session) + ']');
836
972
  return;
837
973
  }
838
974
  s.awaitingGuardTurn = true;
839
- log('guard message claimed turn=' + (typeof data.turn === 'number' ? data.turn : '?')
975
+ log('guard message claimed' + (late ? ' (LATE, from guardIds)' : '')
976
+ + ' turn=' + (typeof data.turn === 'number' ? data.turn : '?')
840
977
  + ' matched=true [' + tag(session) + ']');
841
978
  return;
842
979
  }
@@ -869,7 +1006,7 @@ export function apply(ctx, config) {
869
1006
  if (event.type !== 'turn/end') return;
870
1007
 
871
1008
  // turn 缺失时无法做「本回合是否已补过」的去重,也不能拿 s.turn 兜底
872
- // (两者都为 null 会命中 guardSentForTurn===turn 而静默跳过)。
1009
+ // (两者都为 null 会命中 nudgeHandledTurn===turn 而静默跳过)。
873
1010
  // 但清零必须发生在早退之前:旧版先 return 后清零,缺 turn 的 turn/end
874
1011
  // 会把 awaitingGuardTurn 永久留在 true,下一个真空回合成「连环补写」被吞。
875
1012
  const turn = typeof data.turn === 'number' ? data.turn : null;
@@ -878,7 +1015,10 @@ export function apply(ctx, config) {
878
1015
  // 早退路径(缺 turn / 非 completed / 已补过 / 预算耗尽)过去会把标志留在 true。
879
1016
  const wasGuard = s.awaitingGuardTurn === true;
880
1017
  s.awaitingGuardTurn = false;
881
- s.pendingTurn = null;
1018
+ // 这里**故意不清** s.scheduledTurn:定时器里那道「回合已前进就丢弃」的检查
1019
+ // 全靠它。旧版在此处清空,于是一个没有 turn/start 的第二个 turn/end 之后,
1020
+ // 定时器就再也看不出回合变了,可能把补写插进下一个回合(P3-2)。
1021
+ // 清空职责只落在定时器自己(丢弃/投递前)和 clearTimer 上。
882
1022
  // 补写回合结束:无论消息 id 是否来得及匹配,都不再保留在途标记。
883
1023
  if (wasGuard) clearNudgeInFlight(s, null);
884
1024
 
@@ -908,8 +1048,8 @@ export function apply(ctx, config) {
908
1048
  log('previous turn was already a guard turn and still empty -> stop chaining');
909
1049
  return;
910
1050
  }
911
- if (s.guardSentForTurn === turn) {
912
- log('already nudged for turn ' + turn + ' -> no nudge');
1051
+ if (s.nudgeHandledTurn === turn) {
1052
+ log('turn ' + turn + ' already handled (scheduled or throttled) -> no nudge');
913
1053
  return;
914
1054
  }
915
1055
  if (cfg.maxNudgesPerHour === 0) {
@@ -921,8 +1061,13 @@ export function apply(ctx, config) {
921
1061
  log('hourly nudge budget exhausted (' + s.nudgeTimes.length + ') -> no nudge');
922
1062
  return;
923
1063
  }
924
- s.guardSentForTurn = turn;
925
- scheduleNudge(session, s, turn, 'empty completed turn');
1064
+ // 先排、再记账:scheduleNudge 可能早退(已有在途补写 / 队列里已有 / 干跑),
1065
+ // 只有它真的接了活才把这个回合标记成已处理,否则本回合的补写机会会被白吞(P3-1)。
1066
+ const outcome = scheduleNudge(session, s, turn, 'empty completed turn');
1067
+ if (outcome === 'scheduled' || outcome === 'dry-run') {
1068
+ s.nudgeHandledTurn = turn;
1069
+ }
1070
+ log('nudge outcome turn=' + turn + ': ' + outcome + ' [' + tag(session) + ']');
926
1071
  } catch (err) {
927
1072
  log('session/event handler error: ' + errText(err));
928
1073
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-turn-recovery",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
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",