dsh-turn-recovery 0.2.1 → 0.2.3

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,98 @@
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.3] - 2026-11-05
11
+
12
+ **三方复核(架构 / 安全 / 验收)后的加固版**。0.2.2 的判定链没有 P0 缺陷,但复核在
13
+ 持久化文件与部署侧查出实质问题:预算文件可被本地输入拖垮或投毒、跨重启的补写 id 记忆
14
+ 不落盘、段落注册一次冲突就让插件永久停摆、inbox 去重依赖未公开结构却没有探测。
15
+ 本版逐项修掉,并补齐 README 里对「桌面端 vs 网页端」的错误期待。
16
+
17
+ ### 修复
18
+
19
+ - **预算文件同步整读会阻塞宿主**(安全复核实测 64 MB ≈ 544 ms):`hydrateBudget` 改为先
20
+ `stat`,超过 1 MiB 只读尾部并丢掉截断的首行,日志留一行
21
+ `persisted state read: file is …B, tail …B only`。
22
+ - **预算文件可被未来时间戳投毒**:一条 `at > now` 的记录会让 `now - t < HOUR_MS` 恒成立、
23
+ 永久留在预算里,该会话从此被判成「本小时预算用尽」而静默不补写。解析与修剪统一加
24
+ `+5min` 时钟容忍,超出即丢弃。
25
+ - **预算记录格式改为 JSON 行**:旧格式 `id at` 在会话 id 含空格时会被截断成错账;
26
+ 新格式兼容读旧文件(两种都认)。
27
+ - **压缩预算文件会覆盖别的进程刚追加的行**:改为「重读磁盘 → 合并 → 临时文件 → `rename`
28
+ 原子替换」,并把写入统一成 append-only。
29
+ - **跨重启的补写 id 不落盘**:新增 `<budgetFile 同目录>/guard-ids.log`(JSON 行,
30
+ 24 小时 TTL、每会话 8 条)。宿主重启后认领「重启前投递」的那条消息时仍记为
31
+ `LATE, from guardIds`,补写回合再空不会连环补第二笔。
32
+ - **段落注册一次失败就永久停摆**:改为最多重试 3 次(150 ms 间隔),只有注册成功才
33
+ `armed`;重试耗尽仍 fail-closed(`guard NOT armed`),期间所有判定链不工作
34
+ (`session/event` 与 `deliver` 都先看 `armed`)。
35
+ - **`agent.inbox.nextTurn` 去重依赖未公开结构**:加能力探测,缺失时记一行
36
+ `inbox dedupe UNAVAILABLE …` 并降级(认领 id 匹配仍在),不再静默失效。
37
+ - **`skip scope` 日志上限**:200 → 2000,且触顶时明确记一行,不再无声无息。
38
+
39
+ ### 变更
40
+
41
+ - `nudge DRY-RUN` 日志补 `budgetUsed=n/max`,并注明干跑不落预算 —— 旧文案容易让人以为
42
+ `dryRun` 能验证限流。
43
+ - README 新增「桌面端与网页端」章节:内嵌 Web UI 与桌面端同进程同实例;独立
44
+ `dsh web` 在 `profiles/web` 不存在时走出厂模板,插件静默不加载。
45
+ - 回归用例 49 → 58:预算尾部读取、未来时间戳、JSON 落盘、跨重启认领、inbox 探测降级、
46
+ 段落注册重试成功,以及 `enabled:false` / `injectInstruction:false` 三条开关语义。
47
+
48
+ ### 未变
49
+
50
+ - 补写消息仍是 `role: 'user'` + `source.kind = 'plugin:turn-recovery'`(宿主不允许插件改 role)。
51
+ - 真实宿主上的端到端投递仍未观测到(生产日志 `nudge DELIVERED = 0`)。本版只加固了
52
+ 与「能不能安全地投递」有关的边角,闭合这一项仍需要在真机上跑一次 `dryRun` + 一次真实投递。
53
+
54
+ ## [0.2.2] - 2026-11-04
55
+
56
+ **代码审查修复 + 可验证性补强**。针对 0.2.1 的复审(宿主 `0.2.2-alpha.3` 源码取证、
57
+ `~/.dsh/logs/turn-recovery.log` 28 次激活 0 次 `nudge DELIVERED` 的实证)逐项修复,
58
+ 不含行为方向的改变,但对「补写到底有没有发生」的日志与状态记账做了实质纠正。
59
+
60
+ ### 新增
61
+
62
+ - **`dryRun` 配置项**:判定链、限流、日志全部照走,唯独不把补写交给宿主。
63
+ 这是给「生产环境从未观测到真实补写」准备的验证通道 —— 在真机上把 `dryRun: true`,
64
+ 等一次空回合,日志里出现 `nudge DRY-RUN: would deliver` 就说明整条判定链在生产可用,
65
+ 而且不会因此多跑一轮模型、不占小时预算。
66
+
67
+ ### 修复
68
+
69
+ - **`guardSentForTurn` 先置位、后调度**(P3-1):`scheduleNudge` 可能在内部早退
70
+ (已有在途补写 / 宿主队列里已有一笔 / 干跑),旧版在此之前就把回合记成「已补过」,
71
+ 日志写 `already nudged` 而实际一笔没发,同一回合再触发一次 `turn/end` 也就永久放弃。
72
+ 现在 `scheduleNudge` 返回 `scheduled` / `dry-run` / `skipped:<原因>`,只有真的接了活才记账,
73
+ 并额外打一行 `nudge outcome turn=N: <结果>`。字段同时更名为 `nudgeHandledTurn`(语义更准)。
74
+ - **`turn/end` 提前清空 `pendingTurn`**(P3-2):那道「回合已前进就丢弃定时器」的检查靠它做判据,
75
+ 而 `turn/end` 在调度之后又把它清零,于是一个没有 `turn/start` 的第二个 `turn/end` 之后
76
+ 检查恒不生效,补写可能插进下一个回合。现在 `turn/end` 不再碰它(更名 `scheduledTurn`),
77
+ 清理职责只落在定时器自身与 `clearTimer` 上。
78
+ - **认领超时可重排第二笔**(P3-3):120s 超时后 `nudgeMessageId` 被清掉并允许重排,
79
+ 但宿主仍可能认领那笔「已超时」的消息,旧版会把它判成 `NOT ours`,导致后面的空回合
80
+ 又被补一笔。现在保留最近 8 条投递 id(`guardIds`),晚到的认领照样算作补写回合
81
+ (日志 `guard message claimed (LATE, from guardIds)`)。同时把「清在途标记」挪到
82
+ 宿主队列去重之后,避免清掉标记后重复排期。
83
+ - **`budgetHistory` 只增不减**(P2-2):跨重启限流的内存 map 生命周期等于进程,旧版只在
84
+ 预算文件 >1 MB 时重建,重建后 map 里仍留着过期条目。现在重建时按一小时窗口同时修剪 map,
85
+ 并每 256 次投递做一次纯内存修剪(清过期条目与空会话)。
86
+ - **配置写错无人报警**(P3-4):宿主不校验插件配置(本包不导出 `Config` schema,原因见 README),
87
+ `maxNudgesPerHr`、`minVisibleChars: "abc"` 这类手滑会被静默兜底成默认值,用户以为改生效了。
88
+ 现在加载期检查未知键、数字/布尔/字符串类型、`sessionScope` 取值、`foregroundKinds` 形态,
89
+ 逐条写 `config issue: ...` 与汇总行。只报告、不抛错 —— 一个笔误不该让整个插件拒绝加载。
90
+ - **日志 `tag()` 对 `worker-*` 只截前 8 位**(P3-6):前缀剥离正则补上 `worker-`,
91
+ 多 worker 会话不再全挤成 `[worker-a]`。
92
+
93
+ ### 说明
94
+
95
+ - **`source.kind = 'plugin:turn-recovery'` 不在宿主 `MessageSourceMap` 的类型联合内**(P3-5):
96
+ 宿主运行时只校验 `source.kind` 是非空字符串(`dsh-session` 的 `assertMessageEventShape`),
97
+ 类型联合的缺项只影响本仓库以外的 TS 读者,前端对未知 kind 有 `default` 分支。
98
+ 保持现状并写进 README「已知限制」,不改协议用法。
99
+ - 回归用例 43 → 49:新增 P3-1(早退不记账)、P3-2(回合前进丢弃)、`dryRun`、
100
+ P3-3(晚到认领)、配置写错告警、`worker-` tag。
101
+
10
102
  ## [0.2.1] - 2026-11-03
11
103
 
12
104
  **适配性修复**:针对 DSH `0.2.2-alpha.3` 实测(宿主 `dsh-session` / `dsh-agent-loop` /
package/README.md CHANGED
@@ -31,7 +31,7 @@
31
31
  // <DSH_HOME>/profiles/<你的 profile>/package.json
32
32
  {
33
33
  "dependencies": {
34
- "dsh-turn-recovery": "^0.2.1"
34
+ "dsh-turn-recovery": "^0.2.3"
35
35
  },
36
36
  "dsh": {
37
37
  "profile": {
@@ -65,36 +65,59 @@
65
65
  sessionScope: root # root = 只处理根会话(按 SessionHeader 判定);all = 连子代理会话一起
66
66
  foregroundKinds: [] # 追加「会取消待发补写」的来源 kind(默认 user / agent-message)
67
67
  budgetFile: '' # 空 = <DSH_HOME>/state/turn-recovery/nudges.log(跨重启限流)
68
+ # 同目录还会写 guard-ids.log(跨重启认出晚到认领的补写 id)
68
69
  logFile: '' # 空 = <DSH_HOME>/logs/turn-recovery.log
70
+ dryRun: false # true = 判定/限流/日志全部照走,但不真的投递(验证用)
69
71
  ```
70
72
 
71
73
  配置做严格解析:`maxNudgesPerHour: 0` 是**关闭补写**,不会被默认值吞成 3;`delayMs: "abc"` 这类非法值回落到默认值;`enabled: "false"`(字符串)与 `false` 等价。
72
74
 
75
+ 宿主**不校验**插件配置(本包不导出 `Config` schema,原因见「实现约束」),所以键名写错不会被谁拦下来。插件改为在加载期自查:未知键、类型不符、`sessionScope` 取值不对等,逐条写进日志(`config issue: "minNudgesPerHour" is unknown -> ignored`),插件照常启用。改完配置先看这几行,比猜「为什么没生效」划算。
76
+
77
+ ## 桌面端与网页端
78
+
79
+ 两者不是两条独立的加载路径,别按「装一份、两边都生效」理解:
80
+
81
+ - **桌面应用里内嵌的 Web UI(浏览器打开的那个端口)与桌面端是同一个宿主进程、同一个 profile、同一个插件实例**。插件对它是生效的,不需要额外注册。
82
+ - **独立运行 `dsh web`(等价于 `--profile web`)** 时,如果 `<DSH_HOME>/profiles/web` 不存在,宿主会用出厂模板初始化该 profile(只含 `dsh-base` + `dsh-web-app`)。**本插件不在其中,网页端会静默无保护** —— 既不加载、也不报错,连一条 `skip scope` 都不会有。
83
+ - `headless` profile 同理(模板只含 base + headless)。
84
+
85
+ 需要独立 web/headless 宿主时,显式注册三处(与 desktop profile 一样):`profiles/<name>/package.json` 的 `dependencies` 加上本包、`dsh.profile.bundles` 加上 `dsh-turn-recovery`、`profiles/<name>/cordis.patch.yml` 加 `- id: turn-recovery` 的 config;装完用 `dsh --profile web --dump-config | grep dsh-turn-recovery` 确认条目真的在。
86
+
87
+ > 2026-11-05 本机实测:`<DSH_HOME>/profiles/` 只有 `desktop` 与 `headless`,没有 `web`。当时浏览器里那个端口由桌面宿主进程提供服务(端口 owner 与日志里 `plugin activated` 的 pid 相同)。
88
+
73
89
  ## 日志
74
90
 
75
91
  默认写到 `<DSH_HOME>/logs/turn-recovery.log`(未设 `DSH_HOME` 时是 `~/.dsh/logs/…`;主路径不可写时兜底到系统临时目录;超过 5 MB 自动轮转为 `.1`):
76
92
 
77
93
  ```
78
- [2026-10-25T01:49:13.941Z] plugin activated: pid=17520 enabled=true instruction=true dshHome=C:\Users\me\.dsh
94
+ [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
95
  [2026-10-25T01:49:13.942Z] prompt section registered: order=960 chars=222
80
96
  [2026-10-25T01:49:13.942Z] guard armed (instruction=registered, uptime=1ms)
81
97
  [2026-10-25T01:58:02.114Z] turn/end turn=3 kind=completed steps=4 visibleChars=812 empty=false wasGuard=false sawStart=true [5f884825]
82
98
  [2026-10-25T02:03:41.507Z] turn/end turn=4 kind=completed steps=6 visibleChars=0 empty=true wasGuard=false sawStart=true [5f884825]
83
99
  [2026-10-25T02:03:41.508Z] nudge SCHEDULED turn=4 delay=900ms [5f884825] (empty completed turn)
100
+ [2026-10-25T02:03:41.509Z] nudge outcome turn=4: scheduled [5f884825]
84
101
  [2026-10-25T02:03:42.561Z] nudge DELIVERED via send(next-turn,wakeup) [5f884825]
85
102
  [2026-10-25T02:03:45.004Z] guard message claimed turn=5 matched=true [5f884825]
86
103
  ```
87
104
 
88
- 正常回合只需看 `empty=false`;出现 `empty=true` 就应紧跟一条 `nudge DELIVERED`,再往后出现 `guard message claimed ... matched=true` 才说明补写回合真的起来了。`[...]` 是会话 id(剥掉 `session-` 前缀)前 8 位,`wasGuard` 表示该回合是不是补写回合,`sawStart` 表示插件是否看到过该回合的 `turn/start`(中途热加载时为 false,此时不判定)。
105
+ 正常回合只需看 `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,此时不判定)。
106
+
107
+ 只想验证判定链、不想真的多跑一轮模型时,把 `dryRun` 打开,日志会写 `nudge DRY-RUN: would deliver turn=N` 并停在那里(既不投递,也不占小时预算)。
89
108
 
90
109
  `version=` 是进程里真正加载的版本号(从包自身 `package.json` 读)——**升级后先看这一行**,不要靠「某个日志字段有没有」推断跑的是哪一版(0.1.4 与 0.1.5 都打过 `sawStart`,这个推断会错)。
91
110
 
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`(本小时额度用完)。
111
+ 排查常用短语:`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
112
 
94
113
  ## 实现约束
95
114
 
96
115
  - 只 import Node 内置模块(`node:fs` / `node:os` / `node:path` / `node:crypto`)。插件被装进 profile 的 `node_modules` 后,裸名 `@deepseek-ai/*` 在插件目录下 Node 解析不到,少一个依赖就少一个「插件整个加载失败」的风险面。
97
- - 不导出 `Config` schema(宿主允许无 schema 的 entry),配置在 `apply(ctx, config)` 内部做防御性默认值合并。
116
+ - **不导出 `Config` schema**,配置在 `apply(ctx, config)` 内部做防御性默认值合并 + 加载期自查。这不是偷懒,是三个约束撞在一起的结果:
117
+ 1. cordis 的配置解析走 Standard Schema v1(`runtime.Config["~standard"].validate(config)`),只要导出一个带该方法的对象就能校验;
118
+ 2. 但宿主另有一条 schemastery 专用路径 —— `isSchemastery()`(`cordis-plugin-loader`)与 `isNativeConfigSchema()`(要求 `value[Symbol.for('schemastery')] === true`)都按 schemastery 判别,非 schemastery 的 schema 会被当成「没有 schema」;
119
+ 3. 而 `dsh-settings` 投影表单时**直接读** `schema.type` / `schema.dict` / `schema.meta.volatile`,没有上面那层判别保护 —— 一个形状不对的 `Config` 有可能让设置页报错。
120
+ 本包零依赖,装进 profile 后也解析不到 `schemastery`(更不能 import `@deepseek-ai/*`)。与其导出一个「在 A 路径像 schema、在 B 路径像垃圾」的对象,不如不导出,把校验放在加载期日志里:错了能看见,也不会让设置页崩。
98
121
  - 所有逻辑包在 try/catch 里:任何异常只写日志,不向上抛,不会拖垮宿主回合。
99
122
  - 宿主门槛 `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
123
  - **升级宿主前请先看门槛**:`<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 +125,16 @@
102
125
 
103
126
  ## 已知限制
104
127
 
105
- - **补写消息以 `role: 'user'` 落盘**(`source.kind = 'plugin:turn-recovery'`):宿主 session 层对 `source.kind` 不做枚举校验,插件侧也改不了 role。桌面前端按 `source.kind !== 'user'` 渲染成上下文卡片(不是用户气泡),mnemon 只收录 `source.kind === 'user'`,所以界面与记忆都不受污染;但**会话转录文件里它确实是一条 user 消息**,审计时要看 `source.kind` 区分。
128
+ - **补写消息以 `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
129
  - **用户点停止的窗口**:宿主 `cancel()` 只清 inbox + abort,不 append 任何 session 事件。插件订阅了宿主 dispatch 的 `agent/inbox/discarded` 来撤掉自己那条补写,但该事件是否向插件广播取决于宿主的 dispatch 链路;收不到时,「用户已点停止、插件仍在 `delayMs` 后补写」这个窗口依然存在(默认 900ms,可通过 `delayMs` 调小)。真正发出去的那一回合仍受宿主 abort 语义约束。
107
130
  - **`sessionScope: 'root'` 的判定依据是宿主的 `Session.header`**:`origin === 'subagent'` 或 `delegationDepth > 0` 才算子代理会话,`orchestrator-*` 这类顶层会话会被正常处理(0.2.0 用 id 前缀判断,把它们全部判成子代理,实测日志里有 19 次静默跳过)。宿主没有 header 的场合(老宿主、测试桩)退回 id 形态:`session-*` / `orchestrator-*` 算根,纯 UUID / `social-*` 不算。被过滤的会话每个 id 只记一条 `skip scope` 日志,不会静默消失。
108
131
  - **可见字符是「按回合累计」而不是「末条消息」**:一个回合里先写了结论、后来又被工具调用打断的,累计口径下不算空(这是刻意的:用户此前已经看到了文字)。反过来,`aborted` / `interrupted` 等非 `completed` 回合一律不补写,即使它确实没留下正文 —— 那些回合多半是用户自己喊停的,再补一轮会变成「已取消还继续跑」。
109
- - **崩溃遗留**:补写已入队但宿主在认领前崩溃时,该消息可能在下次启动被重放。插件只认 id 匹配的认领(`NOT ours`),因此不会被这种消息骗成「补写回合」,但它仍可能作为一条插件消息出现在转录里。
132
+ - **崩溃遗留**:补写已入队但宿主在认领前崩溃时,该消息可能在下次启动被重放。插件只认 id 匹配的认领(`NOT ours`),因此不会被这种消息骗成「补写回合」,但它仍可能作为一条插件消息出现在转录里。为覆盖「120s 认领超时后才被认领」与「重启后才被认领」两种情形,最近 8 条投递 id 会同时留在内存与 `<budgetFile 同目录>/guard-ids.log` 里(`LATE, from guardIds`),重启后仍能认出它们,不会把补写回合再当成真空回合补第二笔。
133
+ - **预算文件是不可信输入**:解析时会丢弃「晚于当前时钟 +5 分钟」的时间戳(否则 `now - t` 为负、该会话被永久判成预算耗尽),并且整份文件只在 1 MiB 以内整读、超过就只读尾部(避免配置写错指向几百 MB 的文件时同步阻塞宿主)。写入用 JSON 行(会话 id 含空格也不会被截断),压缩是「重读磁盘 + 临时文件原子替换」,不会覆盖另一个进程刚追加的行。
134
+ - **多进程(桌面宿主 + 独立的 web/headless 宿主)**:各自一套内存态,`agent.inbox.nextTurn` 只对本进程可见。预算文件与补写 id 文件是共享的,因此限流大致成立,但「同一会话同时被两个宿主打开」仍可能各补一笔。同一会话请只用一个宿主。
135
+ - **`dryRun` 不写预算文件**:判定链、日志、限流检查照走,但不投递、不记账,所以它只能证明「判定会发生」,不能用来验证限流是否生效(日志里的 `budgetUsed=n/max` 是当时的已用额度)。
110
136
  - **多宿主共用同一日志文件**:两个进程同时写同一路径时轮转可能失败,此时回退 `truncate(0)` 并记一条 `log rotate fell back to truncate`。
111
- - **生产环境尚未观测到真实补写**:截至 2026-10-30,本机生产日志里 `nudge DELIVERED` 仍为 0(空回合全部是 `aborted`),所有结论来自单测与 mock 复现。真机端到端(含「补写回合真的开起来」)仍是未验证项。
137
+ - **生产环境仍未观测到真实补写**:截至 2026-11-04,本机生产日志里 `nudge DELIVERED` 仍为 0(空回合全部是 `aborted` / `blocked`),所有结论来自单测与 mock 复现。要闭合这一项,请在真机上临时设置 `dryRun: true`,等一次 `completed` 空回合,确认日志出现 `nudge DRY-RUN: would deliver`(判定链在生产成立),再关掉 `dryRun` 等真实投递 —— 这比直接开补写更安全:干跑不会多跑一轮模型。
112
138
 
113
139
  ## License
114
140
 
package/lib/index.js CHANGED
@@ -91,6 +91,23 @@ const HOUR_MS = 3600000;
91
91
  * 避免一个丢失的补写永久占住 nudgeMessageId。
92
92
  */
93
93
  const NUDGE_CLAIM_TIMEOUT_MS = 120000;
94
+ /**
95
+ * 持久化时间戳的时钟偏移容忍(毫秒)。
96
+ * 预算文件与补写 id 文件都是本地可写文件,里面的时间戳不能当可信输入:
97
+ * 一条明显晚于当前时钟的记录会让 `now - t` 变成负数,`< HOUR_MS` 恒成立,
98
+ * 于是该会话被永久判成本小时预算耗尽、守卫静默失效。晚于 now + 容忍窗口的
99
+ * 记录一律丢弃(不报错、不中断,只丢那一条)。
100
+ */
101
+ const BUDGET_FUTURE_SKEW_MS = 5 * 60 * 1000;
102
+ /** 已投递补写 id 的持久化保留时长:覆盖宿主重启/插件热重载后的晚到认领。 */
103
+ const GUARD_ID_TTL_MS = 24 * 60 * 60 * 1000;
104
+ /** 每个会话保留的补写 id 上限(与内存态 guardIds 一致)。 */
105
+ const GUARD_IDS_MAX = 8;
106
+ /** prompt 段落注册失败后的重试次数与间隔(热重载时旧段落可能还没 dispose)。 */
107
+ const SECTION_RETRY_MAX = 3;
108
+ const SECTION_RETRY_DELAY_MS = 150;
109
+ /** 每个会话只记一条 skip scope 日志;上限给足,避免长跑后新会话不再留痕。 */
110
+ const SCOPE_SEEN_MAX = 2000;
94
111
  /** 单个日志文件上限,超过就轮转成 .1(appendFileSync 没有上限,长期跑会无限长)。 */
95
112
  const LOG_MAX_BYTES = 5 * 1024 * 1024;
96
113
  const LOG_ROTATE_CHECK_MS = 60000;
@@ -160,6 +177,67 @@ function enabledOr(value, fallback) {
160
177
  return true;
161
178
  }
162
179
 
180
+ /** 配置项清单:只用于在加载期挑出「写错的键」,不作为校验依据。 */
181
+ const CONFIG_KEYS = new Set([
182
+ 'enabled', 'injectInstruction', 'dryRun', 'instructionText',
183
+ 'minVisibleChars', 'delayMs', 'maxNudgesPerHour', 'logFile',
184
+ 'sessionScope', 'foregroundKinds', 'budgetFile',
185
+ ]);
186
+ const NUMERIC_KEYS = ['minVisibleChars', 'delayMs', 'maxNudgesPerHour'];
187
+ const STRING_KEYS = ['instructionText', 'logFile', 'budgetFile'];
188
+ const BOOLEAN_KEYS = ['enabled', 'injectInstruction', 'dryRun'];
189
+
190
+ /** 安全地把任意值转成一行短文本(循环引用/抛异常的取值都不会炸)。 */
191
+ function brief(value) {
192
+ if (typeof value === 'string') return value.length > 40 ? JSON.stringify(value.slice(0, 40) + '…') : JSON.stringify(value);
193
+ if (typeof value === 'number' || typeof value === 'boolean' || value === null) return String(value);
194
+ if (Array.isArray(value)) return 'array(' + value.length + ')';
195
+ return typeof value;
196
+ }
197
+
198
+ /**
199
+ * 列出「看起来写错了」的配置项。只报告、不抛错:
200
+ * 一个手滑的键名不该让整个插件拒绝加载,但也不能像以前那样静默吞掉。
201
+ */
202
+ function describeConfigIssues(raw) {
203
+ const issues = [];
204
+ if (raw === undefined || raw === null) return issues;
205
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
206
+ issues.push('config must be an object, got ' + brief(raw));
207
+ return issues;
208
+ }
209
+ for (const key of Object.keys(raw)) {
210
+ // 双下划线前缀是测试/内部保留位(测试 rig 用它注入 __sessionId 之类的开关),不算写错的键。
211
+ if (key.startsWith('__')) continue;
212
+ if (!CONFIG_KEYS.has(key)) issues.push('unknown key "' + key + '" ignored');
213
+ }
214
+ for (const key of NUMERIC_KEYS) {
215
+ const v = raw[key];
216
+ if (v === undefined) continue;
217
+ if (typeof v === 'number' && Number.isFinite(v)) continue;
218
+ if (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))) continue;
219
+ issues.push('"' + key + '" expects a number, got ' + brief(v) + ' -> default used');
220
+ }
221
+ for (const key of BOOLEAN_KEYS) {
222
+ const v = raw[key];
223
+ if (v === undefined || typeof v === 'boolean') continue;
224
+ if (typeof v === 'string' || typeof v === 'number') continue;
225
+ issues.push('"' + key + '" expects a boolean, got ' + brief(v) + ' -> ' + (enabledOr(v, false) ? 'true' : 'false'));
226
+ }
227
+ for (const key of STRING_KEYS) {
228
+ const v = raw[key];
229
+ if (v === undefined || typeof v === 'string') continue;
230
+ issues.push('"' + key + '" expects a string, got ' + brief(v) + ' -> ignored');
231
+ }
232
+ if (raw.sessionScope !== undefined && raw.sessionScope !== 'root' && raw.sessionScope !== 'all') {
233
+ issues.push('"sessionScope" expects "root" | "all", got ' + brief(raw.sessionScope) + ' -> "root"');
234
+ }
235
+ if (raw.foregroundKinds !== undefined && !Array.isArray(raw.foregroundKinds)) {
236
+ issues.push('"foregroundKinds" expects an array, got ' + brief(raw.foregroundKinds) + ' -> ignored');
237
+ }
238
+ return issues;
239
+ }
240
+
163
241
  /** 一条消息里所有 text 块拼起来,剥掉不可见字符和首尾空白后的可见文本。 */
164
242
  function visibleText(message) {
165
243
  const content = message && Array.isArray(message.content) ? message.content : [];
@@ -218,8 +296,16 @@ export function apply(ctx, config) {
218
296
  })(),
219
297
  // 补写预算的持久化文件(跨重启限流);默认 $DSH_HOME/state/turn-recovery/nudges.log。
220
298
  budgetFile: typeof raw.budgetFile === 'string' ? raw.budgetFile.trim() : '',
299
+ // 干跑:判定、限流、日志全部照常走,唯独不真的把补写投进队列。
300
+ // 用它在真实宿主里验证「空回合链」是否成立,而不产生任何额外模型回合。
301
+ dryRun: enabledOr(raw.dryRun, false),
221
302
  };
222
303
 
304
+ // 宿主不校验插件配置(本包不导出 Config schema,见 README「为什么没有 Config」),
305
+ // 于是写错键名/类型只会在运行期被静默兜底成默认值 —— 用户以为改生效了,其实没有。
306
+ // 这里在加载期把可疑配置显式挑出来写进日志,成本是几行字符串拼接。
307
+ const configIssues = describeConfigIssues(raw);
308
+
223
309
  // 日志位置优先跟随 DSH_HOME(多 profile / 自定义家目录时才不会写错地方)。
224
310
  const dshHome = typeof process.env.DSH_HOME === 'string' && process.env.DSH_HOME.trim() !== ''
225
311
  ? process.env.DSH_HOME.trim()
@@ -302,29 +388,134 @@ export function apply(ctx, config) {
302
388
  ? cfg.budgetFile
303
389
  : path.join(dshHome, 'state', 'turn-recovery', 'nudges.log');
304
390
  const BUDGET_MAX_BYTES = 1024 * 1024;
391
+ /** 已投递补写 id 的持久化文件(跨重启识别晚到认领用),与预算文件同目录。 */
392
+ const guardPath = path.join(path.dirname(budgetPath), 'guard-ids.log');
305
393
  let budgetHydrated = false;
306
394
  /** session.id -> 最近一小时内的投递时刻(只在本进程内累计)。 */
307
395
  const budgetHistory = new Map();
396
+ /** session.id -> 已投递补写 id(跨重启),只用于认出晚到认领,不参与判定。 */
397
+ const guardIdHistory = new Map();
398
+ let guardHydrated = false;
399
+
400
+ /**
401
+ * 只读文件尾部 maxBytes。
402
+ * 旧版直接 readFileSync 整个 budgetFile:配置写错或文件被替换成几百 MB 时,
403
+ * 这一行会同步卡住宿主的事件循环(实测 64 MB ≈ 544 ms,线性放大)。
404
+ * 现在先 stat,超上限只读尾部;截断点落在行中间时丢掉首个不完整行。
405
+ */
406
+ const readTail = (file, maxBytes) => {
407
+ const st = fs.statSync(file);
408
+ if (!st.isFile()) return '';
409
+ if (st.size <= maxBytes) return fs.readFileSync(file, 'utf8');
410
+ const fd = fs.openSync(file, 'r');
411
+ try {
412
+ const buf = Buffer.allocUnsafe(maxBytes);
413
+ const read = fs.readSync(fd, buf, 0, maxBytes, st.size - maxBytes);
414
+ let text = buf.subarray(0, read).toString('utf8');
415
+ const nl = text.indexOf('\n');
416
+ if (nl >= 0) text = text.slice(nl + 1);
417
+ log('persisted state read: file is ' + st.size + 'B, tail ' + maxBytes + 'B only ('
418
+ + file + ')');
419
+ return text;
420
+ } finally { fs.closeSync(fd); }
421
+ };
422
+
423
+ /**
424
+ * 解析一行预算记录。新格式是 JSON(id 里有空格也不会被截断),
425
+ * 0.2.2 及更早写的是 "id at" 纯文本,两者都认。
426
+ * 时间戳晚于当前时钟 + 容忍窗口的丢弃。
427
+ */
428
+ const parseBudgetLine = (line, now, cutoff) => {
429
+ const text = line.trim();
430
+ if (text === '') return null;
431
+ let id = '';
432
+ let at = NaN;
433
+ if (text.charCodeAt(0) === 123 /* '{' */) {
434
+ let rec;
435
+ try { rec = JSON.parse(text); } catch { return null; }
436
+ if (!rec || typeof rec.id !== 'string') return null;
437
+ id = rec.id;
438
+ at = Number(rec.at);
439
+ } else {
440
+ const sp = text.indexOf(' ');
441
+ if (sp <= 0) return null;
442
+ id = text.slice(0, sp);
443
+ at = Number(text.slice(sp + 1).trim());
444
+ }
445
+ if (id === '' || !Number.isFinite(at)) return null;
446
+ if (at > now + BUDGET_FUTURE_SKEW_MS) return null;
447
+ if (at < cutoff) return null;
448
+ return { id, at };
449
+ };
308
450
 
309
451
  const hydrateBudget = () => {
310
452
  if (budgetHydrated) return;
311
453
  budgetHydrated = true;
312
454
  try {
313
- const rawText = fs.readFileSync(budgetPath, 'utf8');
314
- const cutoff = Date.now() - HOUR_MS;
455
+ const rawText = readTail(budgetPath, BUDGET_MAX_BYTES);
456
+ const now = Date.now();
457
+ const cutoff = now - HOUR_MS;
315
458
  for (const line of rawText.split('\n')) {
316
- const sp = line.indexOf(' ');
317
- if (sp <= 0) continue;
318
- const id = line.slice(0, sp);
319
- const at = Number(line.slice(sp + 1).trim());
320
- if (id === '' || !Number.isFinite(at) || at < cutoff) continue;
321
- const list = budgetHistory.get(id);
322
- if (list) list.push(at);
323
- else budgetHistory.set(id, [at]);
459
+ const rec = parseBudgetLine(line, now, cutoff);
460
+ if (!rec) continue;
461
+ const list = budgetHistory.get(rec.id);
462
+ if (list) list.push(rec.at);
463
+ else budgetHistory.set(rec.id, [rec.at]);
324
464
  }
325
465
  } catch { /* 文件不存在或读不了:当作没有历史 */ }
326
466
  };
327
467
 
468
+ /**
469
+ * 已投递补写 id 的持久化。
470
+ * 宿主重启/插件热重载后内存态 guardIds 是空的,于是「重启前投递、重启后认领」
471
+ * 的那条消息会被判成 NOT ours,紧接着那个补写回合结束时空正文依然为空,
472
+ * 插件就把它当成真空回合再补一笔(跨重启的连环补写)。
473
+ * 这里只解决「认得出晚到认领」,不参与是否补写的判定。
474
+ */
475
+ const hydrateGuardIds = () => {
476
+ if (guardHydrated) return;
477
+ guardHydrated = true;
478
+ try {
479
+ const rawText = readTail(guardPath, BUDGET_MAX_BYTES);
480
+ const now = Date.now();
481
+ const floor = now - GUARD_ID_TTL_MS;
482
+ for (const line of rawText.split('\n')) {
483
+ const text = line.trim();
484
+ if (text === '') continue;
485
+ let rec;
486
+ try { rec = JSON.parse(text); } catch { continue; }
487
+ if (!rec || typeof rec.id !== 'string' || typeof rec.sid !== 'string') continue;
488
+ const at = Number(rec.at);
489
+ if (!Number.isFinite(at) || at < floor || at > now + BUDGET_FUTURE_SKEW_MS) continue;
490
+ const list = guardIdHistory.get(rec.sid);
491
+ if (!list) guardIdHistory.set(rec.sid, [rec.id]);
492
+ else if (list.indexOf(rec.id) < 0) list.push(rec.id);
493
+ }
494
+ } catch { /* 无文件:当作没有历史 */ }
495
+ };
496
+
497
+ /** 该会话跨重启保留的补写 id(最多 GUARD_IDS_MAX 条)。 */
498
+ const persistedGuardIds = (session) => {
499
+ hydrateGuardIds();
500
+ const id = session && typeof session.id === 'string' ? session.id : '';
501
+ const list = id !== '' ? guardIdHistory.get(id) : undefined;
502
+ if (!list || list.length === 0) return [];
503
+ return list.slice(-GUARD_IDS_MAX);
504
+ };
505
+
506
+ const recordGuardId = (session, id, at) => {
507
+ const sid = session && typeof session.id === 'string' ? session.id : '';
508
+ if (sid === '' || typeof id !== 'string' || id === '') return;
509
+ try {
510
+ let list = guardIdHistory.get(sid);
511
+ if (!list) { list = []; guardIdHistory.set(sid, list); }
512
+ if (list.indexOf(id) < 0) list.push(id);
513
+ while (list.length > GUARD_IDS_MAX) list.shift();
514
+ fs.mkdirSync(path.dirname(guardPath), { recursive: true });
515
+ fs.appendFileSync(guardPath, JSON.stringify({ sid, id, at }) + '\n');
516
+ } catch { /* 只服务于去重,写失败不影响补写本身 */ }
517
+ };
518
+
328
519
  /** 该会话最近一小时的投递时刻(合并持久化记录与本次进程内的记录)。 */
329
520
  const historyOf = (session) => {
330
521
  hydrateBudget();
@@ -333,39 +524,77 @@ export function apply(ctx, config) {
333
524
  return persisted ? persisted.slice() : [];
334
525
  };
335
526
 
336
- /** 记录一次真正投递成功的补写;文件过大时先按一小时窗口重写。 */
527
+ /**
528
+ * 记录一次真正投递成功的补写;文件过大时先按一小时窗口重写。
529
+ * 顺带修剪内存里的 budgetHistory:这张 map 的寿命等于进程,
530
+ * 旧版只在文件 >1MB 时重建,而且重建后 map 里仍留着过期条目,
531
+ * 长时间运行(每会话每小时若干条)会只增不减(P2-2)。
532
+ */
533
+ let recordTicks = 0;
534
+
535
+ const pruneBudgetHistory = (now) => {
536
+ const floor = now - HOUR_MS;
537
+ for (const [sid, times] of budgetHistory) {
538
+ const kept = times.filter((t) => t >= floor && t <= now + BUDGET_FUTURE_SKEW_MS);
539
+ if (kept.length === 0) budgetHistory.delete(sid);
540
+ else if (kept.length !== times.length) budgetHistory.set(sid, kept);
541
+ }
542
+ };
543
+
544
+ /**
545
+ * 预算文件压缩:先重读磁盘,再原子替换。
546
+ * 旧版直接用内存 map 覆盖写整份文件 —— 多进程(桌面宿主 + 独立 web/headless)
547
+ * 同时运行时,会把对方刚追加的行整段抹掉,预算就少算、于是多补。
548
+ */
549
+ const compactBudget = (now) => {
550
+ try {
551
+ const rawText = readTail(budgetPath, BUDGET_MAX_BYTES);
552
+ const cutoff = now - HOUR_MS;
553
+ for (const line of rawText.split('\n')) {
554
+ const rec = parseBudgetLine(line, now, cutoff);
555
+ if (!rec) continue;
556
+ const list = budgetHistory.get(rec.id);
557
+ if (!list) budgetHistory.set(rec.id, [rec.at]);
558
+ else if (list.indexOf(rec.at) < 0) list.push(rec.at);
559
+ }
560
+ } catch { /* 读不到就只用内存态 */ }
561
+ pruneBudgetHistory(now);
562
+ const lines = [];
563
+ for (const [sid, times] of budgetHistory) {
564
+ for (const t of times) lines.push(JSON.stringify({ id: sid, at: t }));
565
+ }
566
+ const tmp = budgetPath + '.tmp-' + process.pid;
567
+ fs.writeFileSync(tmp, lines.length > 0 ? lines.join('\n') + '\n' : '');
568
+ fs.renameSync(tmp, budgetPath);
569
+ };
570
+
337
571
  const recordNudge = (session, at) => {
338
572
  const id = session && typeof session.id === 'string' ? session.id : '';
339
573
  if (id === '') return;
340
574
  try {
341
575
  if (!budgetHistory.has(id)) budgetHistory.set(id, []);
342
576
  budgetHistory.get(id).push(at);
577
+ recordTicks += 1;
343
578
  fs.mkdirSync(path.dirname(budgetPath), { recursive: true });
579
+ // 只追加、不改写:改写是 read-modify-write,多进程下会覆盖别人刚写的行。
580
+ fs.appendFileSync(budgetPath, JSON.stringify({ id, at }) + '\n');
344
581
  let size = 0;
345
582
  try { size = fs.statSync(budgetPath).size; } catch { /* 首次写入 */ }
346
- if (size > BUDGET_MAX_BYTES) {
347
- const cutoff = Date.now() - HOUR_MS;
348
- const lines = [];
349
- for (const [sid, times] of budgetHistory) {
350
- for (const t of times) if (t >= cutoff) lines.push(sid + ' ' + t);
351
- }
352
- fs.writeFileSync(budgetPath, lines.join('\n') + (lines.length > 0 ? '\n' : ''));
353
- } else {
354
- fs.appendFileSync(budgetPath, id + ' ' + at + '\n');
355
- }
583
+ if (size > BUDGET_MAX_BYTES) compactBudget(Date.now());
584
+ else if (recordTicks % 256 === 0) pruneBudgetHistory(Date.now());
356
585
  } catch { /* 限流退化到内存态,不影响补写本身 */ }
357
586
  };
358
587
 
359
588
  /**
360
589
  * 日志里的会话短标签。旧版直接 id.slice(0,8),而根会话 id 形如
361
590
  * session-<uuid>,前 8 位恒为 "session-",多会话根本分不开。
362
- * 现在先剥掉 session- / orchestrator- 前缀再取前 8 位;剥完为空则退回原 id 前 8 位。
591
+ * 现在先剥掉 session- / orchestrator- / worker- 前缀再取前 8 位;剥完为空则退回原 id 前 8 位。
363
592
  */
364
593
  const tag = (session) => {
365
594
  try {
366
595
  const id = session && typeof session.id === 'string' ? session.id : '';
367
596
  if (!id) return 'no-session';
368
- const stripped = id.replace(/^(session-|orchestrator-)/, '');
597
+ const stripped = id.replace(/^(session-|orchestrator-|worker-)/, '');
369
598
  return stripped ? stripped.slice(0, 8) : id.slice(0, 8);
370
599
  } catch { return 'no-session'; }
371
600
  };
@@ -388,6 +617,7 @@ export function apply(ctx, config) {
388
617
  * 「被 scope 静默过滤」在日志里长得一模一样,排障全靠猜。
389
618
  */
390
619
  const scopeSeen = new Set();
620
+ let scopeSeenCapped = false;
391
621
 
392
622
  /** header 判定根会话;返回 null 表示无法判定(没有 header)。 */
393
623
  const rootByHeader = (session) => {
@@ -404,7 +634,12 @@ export function apply(ctx, config) {
404
634
  const logScopeSkip = (session, why) => {
405
635
  const id = session && typeof session.id === 'string' ? session.id : '';
406
636
  if (id === '' || scopeSeen.has(id)) return;
407
- if (scopeSeen.size < 200) scopeSeen.add(id);
637
+ if (scopeSeen.size < SCOPE_SEEN_MAX) {
638
+ scopeSeen.add(id);
639
+ } else if (!scopeSeenCapped) {
640
+ scopeSeenCapped = true;
641
+ log('skip scope log capped at ' + SCOPE_SEEN_MAX + ' sessions; further skips are silent');
642
+ }
408
643
  log('skip scope: non-root session ' + id.slice(0, 12) + ' (sessionScope=root, ' + why + ')');
409
644
  };
410
645
 
@@ -424,42 +659,54 @@ export function apply(ctx, config) {
424
659
  };
425
660
 
426
661
  log('plugin activated: pid=' + process.pid + ' version=' + VERSION
427
- + ' enabled=' + cfg.enabled + ' instruction=' + cfg.injectInstruction + ' dshHome=' + dshHome);
662
+ + ' enabled=' + cfg.enabled + ' instruction=' + cfg.injectInstruction
663
+ + ' dryRun=' + cfg.dryRun + ' dshHome=' + dshHome);
664
+ if (configIssues.length > 0) {
665
+ for (const issue of configIssues) log('config issue: ' + issue);
666
+ log('config issues: ' + configIssues.length + ' (defaults used where noted)');
667
+ }
428
668
 
429
669
  // ---- A) system prompt 段落 ----
430
- if (cfg.injectInstruction) {
431
- try {
432
- const text = cfg.instructionText.trim() !== '' ? cfg.instructionText : DEFAULT_INSTRUCTION;
433
- const dispose = ctx.systemPrompt.section({
434
- name: SECTION_NAME,
435
- order: SECTION_ORDER,
436
- text,
437
- interpolate: false,
438
- });
439
- if (typeof dispose === 'function') {
440
- try {
441
- ctx.effect(() => () => { try { dispose(); } catch { /* ignore */ } });
442
- } catch { /* effect 不可用时忽略 */ }
443
- }
444
- log('prompt section registered: order=' + SECTION_ORDER + ' chars=' + text.length);
445
- } catch (err) {
446
- // fail-closed:A 段装不上时不再启用补写,也不再写「guard armed」。
447
- // 宿主对同名 section 重复注册是直接 throw(dsh-system-prompt 的 section
448
- // 注册会拒绝重名),最常见的触发原因是同一插件被两个 bundle 各装了一次。
449
- // 此时若照常 arm:两个实例各发一条补写;日志却写着「一切正常」,
450
- // 而「222 字符的收尾指令其实没进 prompt」这个事实在日志里看不出来。
451
- log('prompt section registration FAILED: ' + errText(err));
452
- log('guard NOT armed (fail-closed: prompt section registration failed)');
453
- return;
670
+ /**
671
+ * 注册收尾指令段落。宿主对同名 section 重复注册会直接 throw,
672
+ * 最常见的触发是「同一插件被两个 bundle 各装一次」,其次才是热重载时
673
+ * 旧段落还没 dispose。所以这里只负责「试一次」,重试与 fail-closed 在下面 arm 处。
674
+ */
675
+ const registerPromptSection = () => {
676
+ const text = cfg.instructionText.trim() !== '' ? cfg.instructionText : DEFAULT_INSTRUCTION;
677
+ const dispose = ctx.systemPrompt.section({
678
+ name: SECTION_NAME,
679
+ order: SECTION_ORDER,
680
+ text,
681
+ interpolate: false,
682
+ });
683
+ if (typeof dispose === 'function') {
684
+ try {
685
+ ctx.effect(() => () => { try { dispose(); } catch { /* ignore */ } });
686
+ } catch { /* effect 不可用时忽略 */ }
454
687
  }
455
- }
688
+ log('prompt section registered: order=' + SECTION_ORDER + ' chars=' + text.length);
689
+ };
456
690
 
457
691
  if (!cfg.enabled) {
458
- log('guard disabled by config; only instruction part active');
692
+ if (cfg.injectInstruction) {
693
+ try {
694
+ registerPromptSection();
695
+ } catch (err) {
696
+ log('prompt section registration FAILED: ' + errText(err));
697
+ log('guard NOT armed (fail-closed: prompt section registration failed)');
698
+ return;
699
+ }
700
+ }
701
+ log(cfg.injectInstruction
702
+ ? 'guard disabled by config; only instruction part active'
703
+ : 'guard disabled by config and injectInstruction=false; nothing active');
459
704
  return;
460
705
  }
461
706
 
462
707
  // ---- C) 空回合保护 ----
708
+ /** 段落注册成功才置 true;置位前所有判定链都不工作(fail-closed)。 */
709
+ let armed = false;
463
710
  const agents = new Map(); // session -> agent
464
711
  const agentsById = new Map(); // session.id -> agent(session 对象身份不稳定时兜底)
465
712
  const state = new WeakMap(); // session -> per-session guard state
@@ -481,7 +728,14 @@ export function apply(ctx, config) {
481
728
  * 拿去补写会对着「本来有正文的回合」发假提醒。
482
729
  */
483
730
  sawTurnStart: false,
484
- guardSentForTurn: null,
731
+ /**
732
+ * 本回合已经「处理过」:要么排上了补写,要么在排补写前就被限流/去重挡下。
733
+ * 只在 turn/end 的判定走完之后置位 —— 旧版在调用 scheduleNudge 之前就置位,
734
+ * 于是 scheduleNudge 内部早退(已有在途补写 / 队列里已有 / 预算耗尽)时,
735
+ * 日志会写着「already nudged」而其实一笔都没发,同一回合再触发一次
736
+ * turn/end 也就永久放弃了这次补写机会。
737
+ */
738
+ nudgeHandledTurn: null,
485
739
  /**
486
740
  * 补写消息已经被宿主认领、写进转录,且它对应的回合还没结束。
487
741
  * 只在看到 source.kind === SOURCE_KIND 的 user/message 时置位;
@@ -492,8 +746,19 @@ export function apply(ctx, config) {
492
746
  nudgeMessageId: null,
493
747
  /** 上述补写的投递时间,用于认领超时。 */
494
748
  nudgeDeliveredAt: 0,
495
- /** 定时器是为哪个回合排的;回合变了就该丢弃。 */
496
- pendingTurn: null,
749
+ /**
750
+ * 最近投递过的补写 id(有界,最多 8 条)。
751
+ * 认领超时后 nudgeMessageId 会被清掉并允许重排第二笔,但宿主仍可能认领
752
+ * 那笔「超时」的消息;靠这个列表把晚到的认领仍算作补写回合,
753
+ * 否则第二个空回合会被当成真空回合再补一笔。
754
+ */
755
+ guardIds: persistedGuardIds(session),
756
+ /**
757
+ * 定时器是为哪个回合排的;回合变了就该丢弃。
758
+ * 与 turn/end 的清零无关:turn/end 先清 s.turn 之外的字段不该顺手把它清空,
759
+ * 否则「回合已前进」这道检查在无 turn/start 的第二个 turn/end 之后会失效。
760
+ */
761
+ scheduledTurn: null,
497
762
  /** 本小时已投递的补写时刻:启动时用持久化记录补齐(跨重启限流)。 */
498
763
  nudgeTimes: historyOf(session),
499
764
  timer: null,
@@ -508,7 +773,7 @@ export function apply(ctx, config) {
508
773
  try { clearTimeout(s.timer); } catch { /* ignore */ }
509
774
  timers.delete(s.timer);
510
775
  s.timer = null;
511
- s.pendingTurn = null;
776
+ s.scheduledTurn = null;
512
777
  if (why) log('pending nudge CANCELLED (' + why + ')');
513
778
  };
514
779
 
@@ -530,7 +795,7 @@ export function apply(ctx, config) {
530
795
  s.visibleChars = 0;
531
796
  s.visibleNonText = false;
532
797
  s.sawTurnStart = false;
533
- s.guardSentForTurn = null;
798
+ s.nudgeHandledTurn = null;
534
799
  };
535
800
 
536
801
  /**
@@ -620,7 +885,8 @@ export function apply(ctx, config) {
620
885
  clearTimer(s, null);
621
886
  clearNudgeInFlight(s, null);
622
887
  s.awaitingGuardTurn = false;
623
- s.pendingTurn = null;
888
+ s.scheduledTurn = null;
889
+ s.guardIds = [];
624
890
  }
625
891
  agents.delete(session);
626
892
  if (typeof session.id === 'string' && session.id !== '') agentsById.delete(session.id);
@@ -679,16 +945,52 @@ export function apply(ctx, config) {
679
945
  * 各装一次时它都是空的,于是会对同一个空回合各投一笔,模型连着被唤醒两次。
680
946
  * 队列本身是宿主维护的真相(agent.inbox.nextTurn),以它为准去重。
681
947
  */
682
- const pendingNudgeInInbox = (agent) => {
948
+ /**
949
+ * 宿主「下一回合队列」的能力探测。
950
+ * `agent.inbox.nextTurn` 是宿主当前实现里的公开字段,但插件不该假设它永远在:
951
+ * 宿主把它改私有/改名/换实现时,旧写法会安静地恒返回 false —— 去重失效、
952
+ * 重复补写,日志里却没有任何线索。这里探测一次并留下明确的一行。
953
+ */
954
+ let inboxProbe = 'unknown';
955
+
956
+ const nextTurnQueue = (agent) => {
957
+ if (!agent) return null;
683
958
  try {
684
- const inbox = agent && agent.inbox;
959
+ const inbox = agent.inbox;
685
960
  const queue = inbox && Array.isArray(inbox.nextTurn) ? inbox.nextTurn : null;
686
- if (!queue) return false;
687
- return queue.some((m) => m && m.source && m.source.kind === SOURCE_KIND);
688
- } catch { return false; }
961
+ if (!queue) {
962
+ if (inboxProbe !== 'unavailable') {
963
+ inboxProbe = 'unavailable';
964
+ log('inbox dedupe UNAVAILABLE: host no longer exposes agent.inbox.nextTurn'
965
+ + ' -> cross-instance dedupe degraded (claim-id matching still applies)');
966
+ }
967
+ return null;
968
+ }
969
+ if (inboxProbe !== 'available') {
970
+ inboxProbe = 'available';
971
+ log('inbox dedupe available (agent.inbox.nextTurn)');
972
+ }
973
+ return queue;
974
+ } catch (err) {
975
+ if (inboxProbe !== 'unavailable') {
976
+ inboxProbe = 'unavailable';
977
+ log('inbox dedupe UNAVAILABLE: probe threw ' + errText(err));
978
+ }
979
+ return null;
980
+ }
981
+ };
982
+
983
+ const pendingNudgeInInbox = (agent) => {
984
+ const queue = nextTurnQueue(agent);
985
+ if (!queue) return false;
986
+ return queue.some((m) => m && m.source && m.source.kind === SOURCE_KIND);
689
987
  };
690
988
 
691
989
  const deliver = (session, text) => {
990
+ if (!armed) {
991
+ log('nudge SKIPPED [' + tag(session) + ']: guard not armed (prompt section missing)');
992
+ return null;
993
+ }
692
994
  const agent = agentOf(session);
693
995
  if (!agent) {
694
996
  log('nudge SKIPPED [' + tag(session) + ']: no live agent bound to session');
@@ -721,22 +1023,33 @@ export function apply(ctx, config) {
721
1023
  return null;
722
1024
  };
723
1025
 
1026
+ /**
1027
+ * 修剪本会话最近一小时的投递时刻。
1028
+ * 未来时间戳(> now + 容忍窗口)必须一起丢掉:`now - t` 对它是负数,
1029
+ * `< HOUR_MS` 恒成立,只按上半句过滤就会把它永久留在预算里。
1030
+ */
724
1031
  const pruneBudget = (s, now) => {
725
- s.nudgeTimes = s.nudgeTimes.filter((t) => now - t < HOUR_MS);
1032
+ s.nudgeTimes = s.nudgeTimes.filter(
1033
+ (t) => now - t < HOUR_MS && t <= now + BUDGET_FUTURE_SKEW_MS);
726
1034
  return s.nudgeTimes.length;
727
1035
  };
728
1036
 
1037
+ /**
1038
+ * 排一笔补写。返回结果字符串,供调用侧决定是否把本回合标记为「已处理」:
1039
+ * 'scheduled' | 'dry-run' | 'skipped:<原因>' | 'error'
1040
+ * 旧版返回 undefined 且由调用侧提前置位 guardSentForTurn,于是这里早退时
1041
+ * 那个回合已经被记账成「补过」,日志与实际投递对不上。
1042
+ */
729
1043
  const scheduleNudge = (session, s, turn, info) => {
730
1044
  try {
731
1045
  // 上一笔补写还没被认领时不要再排第二笔:
732
1046
  // 宿主按队列顺序逐条认领,两条补写只会变成两个多余的回合。
733
1047
  if (nudgePending(s)) {
734
1048
  log('nudge SKIPPED: a nudge is already awaiting claim [' + tag(session) + ']');
735
- return;
1049
+ return 'skipped:in-flight';
736
1050
  }
737
- if (s.nudgeMessageId !== null) clearNudgeInFlight(s, 'claim timeout');
738
1051
  if (s.timer) clearTimer(s, 'rescheduled');
739
- s.pendingTurn = turn;
1052
+ s.scheduledTurn = turn;
740
1053
  const delay = cfg.delayMs;
741
1054
  log('nudge SCHEDULED turn=' + turn + ' delay=' + delay + 'ms [' + tag(session) + '] (' + info + ')');
742
1055
  const timer = setTimeout(() => {
@@ -745,12 +1058,12 @@ export function apply(ctx, config) {
745
1058
  s.timer = null;
746
1059
  try {
747
1060
  // 定时器跑起来时回合可能已经往前走:丢弃过期补写,避免插进别人的回合。
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) + ']');
1061
+ if (s.scheduledTurn !== null && s.turn !== s.scheduledTurn) {
1062
+ s.scheduledTurn = null;
1063
+ log('nudge DROPPED: turn moved on (scheduled=' + turn + ' current=' + s.turn + ') [' + tag(session) + ']');
751
1064
  return;
752
1065
  }
753
- s.pendingTurn = null;
1066
+ s.scheduledTurn = null;
754
1067
  if (s.awaitingGuardTurn) {
755
1068
  log('nudge SKIPPED: a guard turn is already in flight [' + tag(session) + ']');
756
1069
  return;
@@ -760,12 +1073,14 @@ export function apply(ctx, config) {
760
1073
  log('nudge SKIPPED: a nudge is already awaiting claim [' + tag(session) + ']');
761
1074
  return;
762
1075
  }
763
- if (s.nudgeMessageId !== null) clearNudgeInFlight(s, 'claim timeout');
1076
+ // 先去重、再清「超时」的在途标记:反过来的话,队列里其实还躺着上一笔
1077
+ // 时标记已被清掉,下一轮判定就看不到它,可能再补一笔(P3-3)。
764
1078
  if (pendingNudgeInInbox(agentOf(session))) {
765
1079
  log('nudge SKIPPED: inbox already holds a pending turn-recovery nudge ['
766
1080
  + tag(session) + ']');
767
1081
  return;
768
1082
  }
1083
+ if (s.nudgeMessageId !== null) clearNudgeInFlight(s, 'claim timeout');
769
1084
  if (cfg.maxNudgesPerHour === 0) {
770
1085
  log('nudge SKIPPED: maxNudgesPerHour=0 [' + tag(session) + ']');
771
1086
  return;
@@ -775,6 +1090,16 @@ export function apply(ctx, config) {
775
1090
  log('hourly nudge budget exhausted (' + s.nudgeTimes.length + ') -> no nudge');
776
1091
  return;
777
1092
  }
1093
+ if (cfg.dryRun) {
1094
+ // 干跑:判定链、限流、日志全部照走,只是不把消息交给宿主。
1095
+ // 这样可以在真实宿主里验证「会判空 → 会想补」而不产生任何模型回合。
1096
+ // 但 dryRun 只做预算检查、不记账也不落盘,所以它验证不了「限流真的生效」;
1097
+ // 日志里带上 budgetUsed,至少能读出当前窗口已经用掉多少。
1098
+ log('nudge DRY-RUN: would deliver turn=' + turn + ' delay=' + delay
1099
+ + 'ms budgetUsed=' + s.nudgeTimes.length + '/' + cfg.maxNudgesPerHour
1100
+ + ' (no delivery, no budget persist) [' + tag(session) + ']');
1101
+ return;
1102
+ }
778
1103
  const message = deliver(session, NUDGE_TEXT);
779
1104
  if (message) {
780
1105
  // 只记「在途」,不置 awaitingGuardTurn:投递 ≠ 认领。
@@ -782,8 +1107,14 @@ export function apply(ctx, config) {
782
1107
  s.nudgeMessageId = message.id;
783
1108
  s.nudgeDeliveredAt = Date.now();
784
1109
  s.nudgeTimes.push(Date.now());
1110
+ if (typeof message.id === 'string' && message.id !== '') {
1111
+ s.guardIds.push(message.id);
1112
+ while (s.guardIds.length > 8) s.guardIds.shift();
1113
+ }
785
1114
  // 跨重启限流:把投递时刻落盘,重启后的新进程回读最近一小时。
786
1115
  recordNudge(session, s.nudgeDeliveredAt);
1116
+ // 跨重启认领:把这笔 id 落盘,重启后宿主认领它时仍能认出是补写回合。
1117
+ recordGuardId(session, message.id, s.nudgeDeliveredAt);
787
1118
  log('nudge awaiting claim id=' + String(message.id).slice(0, 8) + ' [' + tag(session) + ']');
788
1119
  } else {
789
1120
  clearNudgeInFlight(s, null);
@@ -797,14 +1128,19 @@ export function apply(ctx, config) {
797
1128
  if (timer && typeof timer.unref === 'function') timer.unref();
798
1129
  s.timer = timer;
799
1130
  timers.add(timer);
1131
+ return cfg.dryRun ? 'dry-run' : 'scheduled';
800
1132
  } catch (err) {
801
1133
  log('scheduleNudge error: ' + errText(err));
1134
+ return 'error';
802
1135
  }
803
1136
  };
804
1137
 
805
1138
  try {
806
1139
  ctx.on('session/event', (session, event) => {
807
1140
  try {
1141
+ // arm 之前(段落还没注册成功)不判定、不补写:宁可少补,也不能在
1142
+ // 「收尾指令没进 prompt」的状态下往会话里塞自动消息。
1143
+ if (!armed) return;
808
1144
  if (!session || !event || typeof event.type !== 'string') return;
809
1145
  if (!sessionInScope(session)) return;
810
1146
  const data = event.data || {};
@@ -825,18 +1161,26 @@ export function apply(ctx, config) {
825
1161
  // 补写消息被宿主从 next-turn 队列认领并写进转录,这一刻它才真正生效。
826
1162
  // 投递成功只代表「已入队」,消息可能还躺在队列里,也可能被取消。
827
1163
  const matched = s.nudgeMessageId !== null && data.id === s.nudgeMessageId;
828
- clearNudgeInFlight(s, null);
1164
+ // 认领超时(120s)后 nudgeMessageId 会被清掉并允许改排第二笔,但宿主
1165
+ // 仍可能认领那笔「已超时」的消息 —— 它进转录后开出的照样是补写回合。
1166
+ // 不认它,下一个空回合就会被当成真空回合再补一笔(连环补写)。
1167
+ const late = !matched && typeof data.id === 'string' && s.guardIds.indexOf(data.id) >= 0;
1168
+ // 非「晚到」的情形都清在途标记(与旧版一致):对上 id 说明它已被消费;
1169
+ // 对不上说明队列已经往前走了(上一进程遗留/重放),这笔补写也不会再被认领。
1170
+ // 只有晚到认领例外 —— 它可能是旧 id,不该顺手抹掉新一笔的在途状态。
1171
+ if (!late) clearNudgeInFlight(s, null);
829
1172
  // 关键:只有真的对上 id 才算「补写回合」。
830
1173
  // 旧版把 matched 只写进日志、然后无条件置 awaitingGuardTurn,于是任何
831
1174
  // 同来源但 id 不符的消息(上次进程遗留、重放、另一实例写入)都会被误认,
832
1175
  // 导致下一个真正的空回合被判成「连环补写」而静默跳过。
833
- if (!matched) {
1176
+ if (!matched && !late) {
834
1177
  log('guard-kind message seen but NOT ours (id mismatch/expired) -> ignored ['
835
1178
  + tag(session) + ']');
836
1179
  return;
837
1180
  }
838
1181
  s.awaitingGuardTurn = true;
839
- log('guard message claimed turn=' + (typeof data.turn === 'number' ? data.turn : '?')
1182
+ log('guard message claimed' + (late ? ' (LATE, from guardIds)' : '')
1183
+ + ' turn=' + (typeof data.turn === 'number' ? data.turn : '?')
840
1184
  + ' matched=true [' + tag(session) + ']');
841
1185
  return;
842
1186
  }
@@ -869,7 +1213,7 @@ export function apply(ctx, config) {
869
1213
  if (event.type !== 'turn/end') return;
870
1214
 
871
1215
  // turn 缺失时无法做「本回合是否已补过」的去重,也不能拿 s.turn 兜底
872
- // (两者都为 null 会命中 guardSentForTurn===turn 而静默跳过)。
1216
+ // (两者都为 null 会命中 nudgeHandledTurn===turn 而静默跳过)。
873
1217
  // 但清零必须发生在早退之前:旧版先 return 后清零,缺 turn 的 turn/end
874
1218
  // 会把 awaitingGuardTurn 永久留在 true,下一个真空回合成「连环补写」被吞。
875
1219
  const turn = typeof data.turn === 'number' ? data.turn : null;
@@ -878,7 +1222,10 @@ export function apply(ctx, config) {
878
1222
  // 早退路径(缺 turn / 非 completed / 已补过 / 预算耗尽)过去会把标志留在 true。
879
1223
  const wasGuard = s.awaitingGuardTurn === true;
880
1224
  s.awaitingGuardTurn = false;
881
- s.pendingTurn = null;
1225
+ // 这里**故意不清** s.scheduledTurn:定时器里那道「回合已前进就丢弃」的检查
1226
+ // 全靠它。旧版在此处清空,于是一个没有 turn/start 的第二个 turn/end 之后,
1227
+ // 定时器就再也看不出回合变了,可能把补写插进下一个回合(P3-2)。
1228
+ // 清空职责只落在定时器自己(丢弃/投递前)和 clearTimer 上。
882
1229
  // 补写回合结束:无论消息 id 是否来得及匹配,都不再保留在途标记。
883
1230
  if (wasGuard) clearNudgeInFlight(s, null);
884
1231
 
@@ -908,8 +1255,8 @@ export function apply(ctx, config) {
908
1255
  log('previous turn was already a guard turn and still empty -> stop chaining');
909
1256
  return;
910
1257
  }
911
- if (s.guardSentForTurn === turn) {
912
- log('already nudged for turn ' + turn + ' -> no nudge');
1258
+ if (s.nudgeHandledTurn === turn) {
1259
+ log('turn ' + turn + ' already handled (scheduled or throttled) -> no nudge');
913
1260
  return;
914
1261
  }
915
1262
  if (cfg.maxNudgesPerHour === 0) {
@@ -921,8 +1268,13 @@ export function apply(ctx, config) {
921
1268
  log('hourly nudge budget exhausted (' + s.nudgeTimes.length + ') -> no nudge');
922
1269
  return;
923
1270
  }
924
- s.guardSentForTurn = turn;
925
- scheduleNudge(session, s, turn, 'empty completed turn');
1271
+ // 先排、再记账:scheduleNudge 可能早退(已有在途补写 / 队列里已有 / 干跑),
1272
+ // 只有它真的接了活才把这个回合标记成已处理,否则本回合的补写机会会被白吞(P3-1)。
1273
+ const outcome = scheduleNudge(session, s, turn, 'empty completed turn');
1274
+ if (outcome === 'scheduled' || outcome === 'dry-run') {
1275
+ s.nudgeHandledTurn = turn;
1276
+ }
1277
+ log('nudge outcome turn=' + turn + ': ' + outcome + ' [' + tag(session) + ']');
926
1278
  } catch (err) {
927
1279
  log('session/event handler error: ' + errText(err));
928
1280
  }
@@ -942,8 +1294,40 @@ export function apply(ctx, config) {
942
1294
  });
943
1295
  } catch { /* effect 不可用时忽略 */ }
944
1296
 
945
- log('guard armed (instruction=' + (cfg.injectInstruction ? 'registered' : 'disabled')
946
- + ', uptime=' + (Date.now() - startedAt) + 'ms)');
1297
+ // ---- A) 段落注册 + arm ----
1298
+ // 注册放在最后:热重载时旧段落可能还没 dispose,注册会抛;这里重试几次,
1299
+ // 只有注册成功才 arm —— 「段落没进 prompt 就不补写」的 fail-closed 语义不变,
1300
+ // 但不再因为一次瞬时冲突让整个插件永久停摆。
1301
+ const armGuard = () => {
1302
+ armed = true;
1303
+ log('guard armed (instruction=' + (cfg.injectInstruction ? 'registered' : 'disabled')
1304
+ + ', uptime=' + (Date.now() - startedAt) + 'ms)');
1305
+ };
1306
+ const tryArm = (attempt) => {
1307
+ if (!cfg.injectInstruction) {
1308
+ armGuard();
1309
+ return;
1310
+ }
1311
+ try {
1312
+ registerPromptSection();
1313
+ armGuard();
1314
+ } catch (err) {
1315
+ log('prompt section registration FAILED (attempt ' + (attempt + 1) + '/'
1316
+ + (SECTION_RETRY_MAX + 1) + '): ' + errText(err));
1317
+ if (attempt < SECTION_RETRY_MAX) {
1318
+ const retry = setTimeout(() => {
1319
+ timers.delete(retry);
1320
+ tryArm(attempt + 1);
1321
+ }, SECTION_RETRY_DELAY_MS);
1322
+ if (retry && typeof retry.unref === 'function') retry.unref();
1323
+ timers.add(retry);
1324
+ return;
1325
+ }
1326
+ log('guard NOT armed (fail-closed: prompt section registration failed after '
1327
+ + (SECTION_RETRY_MAX + 1) + ' attempts)');
1328
+ }
1329
+ };
1330
+ tryArm(0);
947
1331
  }
948
1332
 
949
1333
  export default { name, inject, apply };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-turn-recovery",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
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",