dsh-turn-recovery 0.2.2 → 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 +44 -0
- package/README.md +18 -2
- package/lib/index.js +308 -69
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,50 @@
|
|
|
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
|
+
|
|
10
54
|
## [0.2.2] - 2026-11-04
|
|
11
55
|
|
|
12
56
|
**代码审查修复 + 可验证性补强**。针对 0.2.1 的复审(宿主 `0.2.2-alpha.3` 源码取证、
|
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
// <DSH_HOME>/profiles/<你的 profile>/package.json
|
|
32
32
|
{
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"dsh-turn-recovery": "^0.2.
|
|
34
|
+
"dsh-turn-recovery": "^0.2.3"
|
|
35
35
|
},
|
|
36
36
|
"dsh": {
|
|
37
37
|
"profile": {
|
|
@@ -65,6 +65,7 @@
|
|
|
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
|
|
69
70
|
dryRun: false # true = 判定/限流/日志全部照走,但不真的投递(验证用)
|
|
70
71
|
```
|
|
@@ -73,6 +74,18 @@
|
|
|
73
74
|
|
|
74
75
|
宿主**不校验**插件配置(本包不导出 `Config` schema,原因见「实现约束」),所以键名写错不会被谁拦下来。插件改为在加载期自查:未知键、类型不符、`sessionScope` 取值不对等,逐条写进日志(`config issue: "minNudgesPerHour" is unknown -> ignored`),插件照常启用。改完配置先看这几行,比猜「为什么没生效」划算。
|
|
75
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
|
+
|
|
76
89
|
## 日志
|
|
77
90
|
|
|
78
91
|
默认写到 `<DSH_HOME>/logs/turn-recovery.log`(未设 `DSH_HOME` 时是 `~/.dsh/logs/…`;主路径不可写时兜底到系统临时目录;超过 5 MB 自动轮转为 `.1`):
|
|
@@ -116,7 +129,10 @@
|
|
|
116
129
|
- **用户点停止的窗口**:宿主 `cancel()` 只清 inbox + abort,不 append 任何 session 事件。插件订阅了宿主 dispatch 的 `agent/inbox/discarded` 来撤掉自己那条补写,但该事件是否向插件广播取决于宿主的 dispatch 链路;收不到时,「用户已点停止、插件仍在 `delayMs` 后补写」这个窗口依然存在(默认 900ms,可通过 `delayMs` 调小)。真正发出去的那一回合仍受宿主 abort 语义约束。
|
|
117
130
|
- **`sessionScope: 'root'` 的判定依据是宿主的 `Session.header`**:`origin === 'subagent'` 或 `delegationDepth > 0` 才算子代理会话,`orchestrator-*` 这类顶层会话会被正常处理(0.2.0 用 id 前缀判断,把它们全部判成子代理,实测日志里有 19 次静默跳过)。宿主没有 header 的场合(老宿主、测试桩)退回 id 形态:`session-*` / `orchestrator-*` 算根,纯 UUID / `social-*` 不算。被过滤的会话每个 id 只记一条 `skip scope` 日志,不会静默消失。
|
|
118
131
|
- **可见字符是「按回合累计」而不是「末条消息」**:一个回合里先写了结论、后来又被工具调用打断的,累计口径下不算空(这是刻意的:用户此前已经看到了文字)。反过来,`aborted` / `interrupted` 等非 `completed` 回合一律不补写,即使它确实没留下正文 —— 那些回合多半是用户自己喊停的,再补一轮会变成「已取消还继续跑」。
|
|
119
|
-
- **崩溃遗留**:补写已入队但宿主在认领前崩溃时,该消息可能在下次启动被重放。插件只认 id 匹配的认领(`NOT ours`),因此不会被这种消息骗成「补写回合」,但它仍可能作为一条插件消息出现在转录里。为覆盖「120s
|
|
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` 是当时的已用额度)。
|
|
120
136
|
- **多宿主共用同一日志文件**:两个进程同时写同一路径时轮转可能失败,此时回退 `truncate(0)` 并记一条 `log rotate fell back to truncate`。
|
|
121
137
|
- **生产环境仍未观测到真实补写**:截至 2026-11-04,本机生产日志里 `nudge DELIVERED` 仍为 0(空回合全部是 `aborted` / `blocked`),所有结论来自单测与 mock 复现。要闭合这一项,请在真机上临时设置 `dryRun: true`,等一次 `completed` 空回合,确认日志出现 `nudge DRY-RUN: would deliver`(判定链在生产成立),再关掉 `dryRun` 等真实投递 —— 这比直接开补写更安全:干跑不会多跑一轮模型。
|
|
122
138
|
|
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;
|
|
@@ -371,29 +388,134 @@ export function apply(ctx, config) {
|
|
|
371
388
|
? cfg.budgetFile
|
|
372
389
|
: path.join(dshHome, 'state', 'turn-recovery', 'nudges.log');
|
|
373
390
|
const BUDGET_MAX_BYTES = 1024 * 1024;
|
|
391
|
+
/** 已投递补写 id 的持久化文件(跨重启识别晚到认领用),与预算文件同目录。 */
|
|
392
|
+
const guardPath = path.join(path.dirname(budgetPath), 'guard-ids.log');
|
|
374
393
|
let budgetHydrated = false;
|
|
375
394
|
/** session.id -> 最近一小时内的投递时刻(只在本进程内累计)。 */
|
|
376
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
|
+
};
|
|
377
450
|
|
|
378
451
|
const hydrateBudget = () => {
|
|
379
452
|
if (budgetHydrated) return;
|
|
380
453
|
budgetHydrated = true;
|
|
381
454
|
try {
|
|
382
|
-
const rawText =
|
|
383
|
-
const
|
|
455
|
+
const rawText = readTail(budgetPath, BUDGET_MAX_BYTES);
|
|
456
|
+
const now = Date.now();
|
|
457
|
+
const cutoff = now - HOUR_MS;
|
|
384
458
|
for (const line of rawText.split('\n')) {
|
|
385
|
-
const
|
|
386
|
-
if (
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
const list = budgetHistory.get(id);
|
|
391
|
-
if (list) list.push(at);
|
|
392
|
-
else budgetHistory.set(id, [at]);
|
|
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]);
|
|
393
464
|
}
|
|
394
465
|
} catch { /* 文件不存在或读不了:当作没有历史 */ }
|
|
395
466
|
};
|
|
396
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
|
+
|
|
397
519
|
/** 该会话最近一小时的投递时刻(合并持久化记录与本次进程内的记录)。 */
|
|
398
520
|
const historyOf = (session) => {
|
|
399
521
|
hydrateBudget();
|
|
@@ -409,35 +531,57 @@ export function apply(ctx, config) {
|
|
|
409
531
|
* 长时间运行(每会话每小时若干条)会只增不减(P2-2)。
|
|
410
532
|
*/
|
|
411
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
|
+
|
|
412
571
|
const recordNudge = (session, at) => {
|
|
413
572
|
const id = session && typeof session.id === 'string' ? session.id : '';
|
|
414
573
|
if (id === '') return;
|
|
415
574
|
try {
|
|
416
575
|
if (!budgetHistory.has(id)) budgetHistory.set(id, []);
|
|
417
576
|
budgetHistory.get(id).push(at);
|
|
418
|
-
const cutoff = Date.now() - HOUR_MS;
|
|
419
577
|
recordTicks += 1;
|
|
420
|
-
let rewrite = false;
|
|
421
|
-
try { rewrite = fs.statSync(budgetPath).size > BUDGET_MAX_BYTES; } catch { /* 首次写入 */ }
|
|
422
|
-
// 每 256 次投递做一次常规修剪:不写文件,只清内存里的过期条目和空会话。
|
|
423
|
-
// 投递受 maxNudgesPerHour 限制,256 次意味着至少几十个会话满额,频率够低。
|
|
424
|
-
if (rewrite || recordTicks % 256 === 0) {
|
|
425
|
-
for (const [sid, times] of budgetHistory) {
|
|
426
|
-
const kept = times.filter((t) => t >= cutoff);
|
|
427
|
-
if (kept.length === 0) budgetHistory.delete(sid);
|
|
428
|
-
else if (kept.length !== times.length) budgetHistory.set(sid, kept);
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
578
|
fs.mkdirSync(path.dirname(budgetPath), { recursive: true });
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
} else {
|
|
439
|
-
fs.appendFileSync(budgetPath, id + ' ' + at + '\n');
|
|
440
|
-
}
|
|
579
|
+
// 只追加、不改写:改写是 read-modify-write,多进程下会覆盖别人刚写的行。
|
|
580
|
+
fs.appendFileSync(budgetPath, JSON.stringify({ id, at }) + '\n');
|
|
581
|
+
let size = 0;
|
|
582
|
+
try { size = fs.statSync(budgetPath).size; } catch { /* 首次写入 */ }
|
|
583
|
+
if (size > BUDGET_MAX_BYTES) compactBudget(Date.now());
|
|
584
|
+
else if (recordTicks % 256 === 0) pruneBudgetHistory(Date.now());
|
|
441
585
|
} catch { /* 限流退化到内存态,不影响补写本身 */ }
|
|
442
586
|
};
|
|
443
587
|
|
|
@@ -473,6 +617,7 @@ export function apply(ctx, config) {
|
|
|
473
617
|
* 「被 scope 静默过滤」在日志里长得一模一样,排障全靠猜。
|
|
474
618
|
*/
|
|
475
619
|
const scopeSeen = new Set();
|
|
620
|
+
let scopeSeenCapped = false;
|
|
476
621
|
|
|
477
622
|
/** header 判定根会话;返回 null 表示无法判定(没有 header)。 */
|
|
478
623
|
const rootByHeader = (session) => {
|
|
@@ -489,7 +634,12 @@ export function apply(ctx, config) {
|
|
|
489
634
|
const logScopeSkip = (session, why) => {
|
|
490
635
|
const id = session && typeof session.id === 'string' ? session.id : '';
|
|
491
636
|
if (id === '' || scopeSeen.has(id)) return;
|
|
492
|
-
if (scopeSeen.size <
|
|
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
|
+
}
|
|
493
643
|
log('skip scope: non-root session ' + id.slice(0, 12) + ' (sessionScope=root, ' + why + ')');
|
|
494
644
|
};
|
|
495
645
|
|
|
@@ -517,39 +667,46 @@ export function apply(ctx, config) {
|
|
|
517
667
|
}
|
|
518
668
|
|
|
519
669
|
// ---- A) system prompt 段落 ----
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
// 宿主对同名 section 重复注册是直接 throw(dsh-system-prompt 的 section
|
|
538
|
-
// 注册会拒绝重名),最常见的触发原因是同一插件被两个 bundle 各装了一次。
|
|
539
|
-
// 此时若照常 arm:两个实例各发一条补写;日志却写着「一切正常」,
|
|
540
|
-
// 而「222 字符的收尾指令其实没进 prompt」这个事实在日志里看不出来。
|
|
541
|
-
log('prompt section registration FAILED: ' + errText(err));
|
|
542
|
-
log('guard NOT armed (fail-closed: prompt section registration failed)');
|
|
543
|
-
return;
|
|
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 不可用时忽略 */ }
|
|
544
687
|
}
|
|
545
|
-
|
|
688
|
+
log('prompt section registered: order=' + SECTION_ORDER + ' chars=' + text.length);
|
|
689
|
+
};
|
|
546
690
|
|
|
547
691
|
if (!cfg.enabled) {
|
|
548
|
-
|
|
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');
|
|
549
704
|
return;
|
|
550
705
|
}
|
|
551
706
|
|
|
552
707
|
// ---- C) 空回合保护 ----
|
|
708
|
+
/** 段落注册成功才置 true;置位前所有判定链都不工作(fail-closed)。 */
|
|
709
|
+
let armed = false;
|
|
553
710
|
const agents = new Map(); // session -> agent
|
|
554
711
|
const agentsById = new Map(); // session.id -> agent(session 对象身份不稳定时兜底)
|
|
555
712
|
const state = new WeakMap(); // session -> per-session guard state
|
|
@@ -595,7 +752,7 @@ export function apply(ctx, config) {
|
|
|
595
752
|
* 那笔「超时」的消息;靠这个列表把晚到的认领仍算作补写回合,
|
|
596
753
|
* 否则第二个空回合会被当成真空回合再补一笔。
|
|
597
754
|
*/
|
|
598
|
-
guardIds:
|
|
755
|
+
guardIds: persistedGuardIds(session),
|
|
599
756
|
/**
|
|
600
757
|
* 定时器是为哪个回合排的;回合变了就该丢弃。
|
|
601
758
|
* 与 turn/end 的清零无关:turn/end 先清 s.turn 之外的字段不该顺手把它清空,
|
|
@@ -788,16 +945,52 @@ export function apply(ctx, config) {
|
|
|
788
945
|
* 各装一次时它都是空的,于是会对同一个空回合各投一笔,模型连着被唤醒两次。
|
|
789
946
|
* 队列本身是宿主维护的真相(agent.inbox.nextTurn),以它为准去重。
|
|
790
947
|
*/
|
|
791
|
-
|
|
948
|
+
/**
|
|
949
|
+
* 宿主「下一回合队列」的能力探测。
|
|
950
|
+
* `agent.inbox.nextTurn` 是宿主当前实现里的公开字段,但插件不该假设它永远在:
|
|
951
|
+
* 宿主把它改私有/改名/换实现时,旧写法会安静地恒返回 false —— 去重失效、
|
|
952
|
+
* 重复补写,日志里却没有任何线索。这里探测一次并留下明确的一行。
|
|
953
|
+
*/
|
|
954
|
+
let inboxProbe = 'unknown';
|
|
955
|
+
|
|
956
|
+
const nextTurnQueue = (agent) => {
|
|
957
|
+
if (!agent) return null;
|
|
792
958
|
try {
|
|
793
|
-
const inbox = agent
|
|
959
|
+
const inbox = agent.inbox;
|
|
794
960
|
const queue = inbox && Array.isArray(inbox.nextTurn) ? inbox.nextTurn : null;
|
|
795
|
-
if (!queue)
|
|
796
|
-
|
|
797
|
-
|
|
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);
|
|
798
987
|
};
|
|
799
988
|
|
|
800
989
|
const deliver = (session, text) => {
|
|
990
|
+
if (!armed) {
|
|
991
|
+
log('nudge SKIPPED [' + tag(session) + ']: guard not armed (prompt section missing)');
|
|
992
|
+
return null;
|
|
993
|
+
}
|
|
801
994
|
const agent = agentOf(session);
|
|
802
995
|
if (!agent) {
|
|
803
996
|
log('nudge SKIPPED [' + tag(session) + ']: no live agent bound to session');
|
|
@@ -830,8 +1023,14 @@ export function apply(ctx, config) {
|
|
|
830
1023
|
return null;
|
|
831
1024
|
};
|
|
832
1025
|
|
|
1026
|
+
/**
|
|
1027
|
+
* 修剪本会话最近一小时的投递时刻。
|
|
1028
|
+
* 未来时间戳(> now + 容忍窗口)必须一起丢掉:`now - t` 对它是负数,
|
|
1029
|
+
* `< HOUR_MS` 恒成立,只按上半句过滤就会把它永久留在预算里。
|
|
1030
|
+
*/
|
|
833
1031
|
const pruneBudget = (s, now) => {
|
|
834
|
-
s.nudgeTimes = s.nudgeTimes.filter(
|
|
1032
|
+
s.nudgeTimes = s.nudgeTimes.filter(
|
|
1033
|
+
(t) => now - t < HOUR_MS && t <= now + BUDGET_FUTURE_SKEW_MS);
|
|
835
1034
|
return s.nudgeTimes.length;
|
|
836
1035
|
};
|
|
837
1036
|
|
|
@@ -894,8 +1093,11 @@ export function apply(ctx, config) {
|
|
|
894
1093
|
if (cfg.dryRun) {
|
|
895
1094
|
// 干跑:判定链、限流、日志全部照走,只是不把消息交给宿主。
|
|
896
1095
|
// 这样可以在真实宿主里验证「会判空 → 会想补」而不产生任何模型回合。
|
|
897
|
-
|
|
898
|
-
|
|
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) + ']');
|
|
899
1101
|
return;
|
|
900
1102
|
}
|
|
901
1103
|
const message = deliver(session, NUDGE_TEXT);
|
|
@@ -911,6 +1113,8 @@ export function apply(ctx, config) {
|
|
|
911
1113
|
}
|
|
912
1114
|
// 跨重启限流:把投递时刻落盘,重启后的新进程回读最近一小时。
|
|
913
1115
|
recordNudge(session, s.nudgeDeliveredAt);
|
|
1116
|
+
// 跨重启认领:把这笔 id 落盘,重启后宿主认领它时仍能认出是补写回合。
|
|
1117
|
+
recordGuardId(session, message.id, s.nudgeDeliveredAt);
|
|
914
1118
|
log('nudge awaiting claim id=' + String(message.id).slice(0, 8) + ' [' + tag(session) + ']');
|
|
915
1119
|
} else {
|
|
916
1120
|
clearNudgeInFlight(s, null);
|
|
@@ -934,6 +1138,9 @@ export function apply(ctx, config) {
|
|
|
934
1138
|
try {
|
|
935
1139
|
ctx.on('session/event', (session, event) => {
|
|
936
1140
|
try {
|
|
1141
|
+
// arm 之前(段落还没注册成功)不判定、不补写:宁可少补,也不能在
|
|
1142
|
+
// 「收尾指令没进 prompt」的状态下往会话里塞自动消息。
|
|
1143
|
+
if (!armed) return;
|
|
937
1144
|
if (!session || !event || typeof event.type !== 'string') return;
|
|
938
1145
|
if (!sessionInScope(session)) return;
|
|
939
1146
|
const data = event.data || {};
|
|
@@ -1087,8 +1294,40 @@ export function apply(ctx, config) {
|
|
|
1087
1294
|
});
|
|
1088
1295
|
} catch { /* effect 不可用时忽略 */ }
|
|
1089
1296
|
|
|
1090
|
-
|
|
1091
|
-
|
|
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);
|
|
1092
1331
|
}
|
|
1093
1332
|
|
|
1094
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.
|
|
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",
|