dsh-fast-mode 1.5.33 → 1.5.35

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/BENCHMARK.md CHANGED
@@ -2392,6 +2392,27 @@ python3 bench/suite-run.py --tasks debugfix,migrate --arms def15,combo3 --pairs
2392
2392
  - **时点臂仍在 HOLD,不进默认。** `combo2` 比 `combo3` 多出来的那一截(0.669 vs 0.742)指向 `d2g`,但两个臂的样本不是同一批 pair,n = 22 vs 11 也不足以给「多 7 个百分点」定论;`d2g` 在 `codeimpl` 上的负收益(见上一节结论 2)没有被这一轮推翻,默认仍是 `disciplineAfterSteps: 4`。
2393
2393
  - **诚实边界**:各一个块、两个任务族(`debugfix` 短、`migrate` 长),`combo3` 的符号检验 p = 0.113 只算方向提示;锚点臂有 2 格(`migrate` pair 2、11)判 `leak` 被剔除,剔除后仍配对 22 对,`combo2` 24 格全绿、无一格 `leak`。想复现就重跑上面两条命令,`bench/arms/combo2.yml`、`combo3.yml` 在仓库里。
2394
2394
 
2395
+ ## 提速有多少是从「非模型时间」里挤出来的:没有,只占墙钟 5 % 且组合臂更轻
2396
+
2397
+ 外部评审担心的是「你把成本挪到插件自己身上,墙钟看着快了其实更重」。`wall`(任务下达到进程退出)与 `llm`(花在模型调用里的时间)两列本来就在每一行里,差值就是一切非模型开销:工具执行、宿主调度、插件每步的活。新脚本把这块单拉出来,成对看:
2398
+
2399
+ ```
2400
+ python3 bench/overhead.py --blocks COMBO2 --base def15 --arm combo2
2401
+ python3 bench/overhead.py --blocks COMBO3 --base def15 --arm combo3
2402
+ ```
2403
+
2404
+ | 口径 | 默认臂 | 组合臂 |
2405
+ |---|---|---|
2406
+ | `combo2`(22 对)每步非模型开销 | 0.64 s | **0.38 s** |
2407
+ | `combo2` 非模型开销占墙钟 | 5.9 % | 6.6 % |
2408
+ | `combo2` 成对开销比 | — | 中位 **0.953×**,16/22 对更轻 |
2409
+ | `combo3`(11 对)每步非模型开销 | 0.30 s | 0.39 s |
2410
+ | `combo3` 成对开销比 | — | 中位 **0.940×**,8/11 对更轻 |
2411
+
2412
+ - **提速不来自压榨工具时间。** 非模型开销本来只占墙钟 3–8 %,就算压到 0 也只有几个百分点;实测组合臂的这块绝对值反而略轻(成对中位 0.94–0.95×,各 16/22 与 8/11 对更轻),因为总步数少了、固定开销也少收几遍。占比从 5.9 % 涨到 6.6 % 只是分母(墙钟)变小,不是开销变重。
2413
+ - **每步开销没有系统性变大。** `combo2` 每步 0.38 s vs 默认(同块)0.64 s;`combo3` 是 0.39 s vs 0.30 s,方向相反但绝对值都不到 0.1 s/步,且成对中位仍是 0.94×。插件每步的活是几十毫秒级的字符串拼接与一次 `inject`,落在这两列的噪声里,解释不了 30 % 的墙钟差。
2414
+ - **读法**:这一块不能用来证明快,只能用来排除「快的代价藏在别处」。墙钟比(0.687 中位 / 几何 0.669)与 `llm` 比的落点一致,说明省下来的确实是模型回合。
2415
+
2395
2416
  ## 同一配置再跑一遍会差多少:块级差 4 %,任务级差 15–31 %(REP2,10 对)
2396
2417
 
2397
2418
  外部评审提的第一条是稳定性。这一块不新增任何开关,只把 `migrate`+`debugfix` 的同一批格子**用同一套配置再跑一遍**(`REP2`,20 次运行,顺序 ABBA 与之前的块一致),看同配置的两次独立测量互相差多少:
@@ -2440,3 +2461,75 @@ python3 bench/textshare.py --limit 5
2440
2461
  ```
2441
2462
 
2442
2463
  可见文字只占(可见文字 + 工具参数)的 1.28 %;把推理也算进来,**推理占模型全部发出字符的 99.6 %**。所以提速不来自「少说话」:省下来的是**步数**与**每步的推理量**,而提醒文案只是同时压这两者的手段。`bench/mechanism.py` 的报告里也多了 `prose / args / reasoning / emitted` 四行(168 对同题同配的结论:`reasoning` 几何比 0.75,`emitted` 0.82)。
2464
+
2465
+ ## 常驻宿主的堆:把最后 6 个无界缓存封顶,并用一个「不重启的宿主」证明它是平的(1.5.34)
2466
+
2467
+ 外部评审这一轮的建议是「先做稳定性修复,暂时不要加旋钮」。插件里能威胁稳定性的东西不多,但有一类是真的:**所有缓存都以永不重复的 id 为键**(会话 id、`callId`、`${sid}${cwd}`),而宿主的进程是活几周不重启的。1.5.32 封了 3 个,这一轮把剩下的全封了,并且补了一个黑盒浸泡脚本,让「它是平的」变成每次都能重跑的数字,而不是一句断言。
2468
+
2469
+ ### A. 审计:插件自己持有的 8 个缓存
2470
+
2471
+ | 缓存 | 键 | 之前 | 现在 |
2472
+ | --- | --- | --- | --- |
2473
+ | `compacted`(`index.js`) | 会话 id | 200 条 FIFO | 200(改用 `trim`) |
2474
+ | `mapSent`(`index.js`) | `${sid}${cwd}` | **无界** | 500 |
2475
+ | `autoBudgets`(`index.js`) | 会话 id | 200 条 FIFO | 200(改用 `trim`) |
2476
+ | `startedAt`(`index.js`) | `callId` | **无界**(结果没回来的调用不会被 `delete`) | 2000 |
2477
+ | `warnedCaps` | `callId` | 有界 | 不变 |
2478
+ | `labels`(`src/metrics.js`) | 会话 id | **无界**,且 sink 关闭时也写 | 2000,且 sink 关闭时直接不写 |
2479
+ | `steps` / `open` / `pendingChunk`(`src/metrics.js`) | 步 / 会话 | **无界** | 2000 / 2000 / 1000 |
2480
+ | `sessions`(`src/auto.js`) | 会话 id | 200(LRU) | 200(改用 `trim`) |
2481
+
2482
+ 封顶全部走同一个新工具 `src/bounded.js` 的 `trim(cache, cap)`:插入顺序即淘汰顺序,返回同一个缓存以便链式使用,`cap <= 0` 时不动。之前每个缓存各写一遍淘汰循环,写法不一致是漏掉下一个缓存的原因。被淘汰不等于写错行:`labels` 里没了的会话,它的测量行记成 `mode: 'unknown'`,而不是借用别人的档位——这一条直接写成了测试(`test/bounded.test.js`)。
2483
+
2484
+ ### B. 先修的是测量工具本身:它最初报了个假泄漏
2485
+
2486
+ 新写的 `bench/soak.mjs` 用与 `test/plugin.test.js` 相同的假 Cordis 挂载真插件,然后扮演一个从不重启的宿主:N 个会话 × 4 轮,每轮走 `step/start` → `agent/request` → 工具调用 → `step/end`。第一次跑 4000 会话报 **+63.67 MB / 16.3 KB per session / FAIL**,而且 `--off` 一模一样——说明增长不在插件逻辑里。二分两下就定位了:
2487
+
2488
+ | 控制 | 4000 会话的增长 |
2489
+ | --- | --- |
2490
+ | `--off`(插件禁用) | +63.67 MB(同样) |
2491
+ | `--no-event`(不发事件流) | +49.29 MB |
2492
+ | `--no-request`(不发 `agent/request`) | **−0.55 MB** |
2493
+ | `--one-id`(4000 个会话共用一个 id) | +49.30 MB(同样) |
2494
+
2495
+ 问题在**脚本**:`ctx.fire('agent/request', …)` 没有 `await`,16000 次调用留下 16000 个 pending 的 Promise 链,堆自然涨,而且与「会话数」无关而与「调用数」有关(`--one-id` 一样涨就是这个原因)。改成 `await` 后同规模是 **−0.61 MB**。这条写进脚本头注释里:浸泡测试自己的悬挂 Promise 会伪装成插件的泄漏,先怀疑它。
2496
+
2497
+ (`/tmp/reqctl.mjs` 的独立控制给出同一结论:16000 次 await 过的请求 ≈ 2.43 MB,即 152 B/次,正是 `labels`;不 await 是 3 KB/次。)
2498
+
2499
+ ### C. 封顶之后:20000 个会话、242 400 步,堆是平的
2500
+
2501
+ ```
2502
+ node --expose-gc bench/soak.mjs --sessions 20000 --metrics
2503
+ sessions 20000 × 4 turns, 242400 steps
2504
+ heap warm 7.26 MB
2505
+ heap end 5.62 MB
2506
+ growth -1.64 MB over 20000 sessions
2507
+ per session -0.1 KB
2508
+ metrics 242400 steps written, caches trimmed
2509
+ ```
2510
+
2511
+ `--metrics` 是这一轮加的口子:让测量 sink 真的写盘(写进临时文件,跑完删掉),这样 `labels`/`steps`/`open`/`pendingChunk` 四个缓存也被搅动。带它跑 4000 会话是 **−0.58 MB**,不带它跑 20000 会话是 **−1.64 MB**;阈值是 0.004 KB/会话,失败时退出码非 0,可以直接进 CI。
2512
+
2513
+ 单点对照(`/tmp/labelctl.mjs`,20000 次 `label()`):
2514
+
2515
+ | 版本 | 堆增长 |
2516
+ | --- | --- |
2517
+ | 1.5.33(无界,sink 关闭也写) | **2.48 MB**(124 B/会话) |
2518
+ | 1.5.34(sink 关闭直接不写) | **0.01 MB** |
2519
+ | 1.5.34(sink 打开,封顶 2000) | **0.01 MB**(20000 次写入后仍只留 2000 条) |
2520
+
2521
+ 2.5 MB 不是能把宿主撑爆的量级——**这一条要诚实说清:真正的收益不是省了 2.5 MB,而是「插件里不再有随运行时长单调增长的缓存」**,而这个约束来自本机真实崩溃日志里的一条硬规则(堆内存要有上限)。上限也确实换了东西:`sink 关闭时不再记 label` 是一次纯粹的白拿。
2522
+
2523
+ ### D. 同一轮里对已发布版本的回归复核(`V1533`,12 次运行)
2524
+
2525
+ 改稳定性不能只看测试,还要确认「已发布的 1.5.33 源码在跑起来时没退化」。所以先跑了一班 `debugfix`+`migrate` 的 3 对 ABBA 块(`off` 与 `def15` 同块交替),用的是仓库源码(`node_modules/dsh-fast-mode` 指回仓库):
2526
+
2527
+ | 口径 | `def15` | `off` |
2528
+ | --- | --- | --- |
2529
+ | 6 次运行合计墙钟 | 654.0 s(**0.50×**) | 1304.5 s |
2530
+ | 输出 token | 60 135(0.57×) | 104 492 |
2531
+ | 步数 | 45(0.46×) | 99 |
2532
+ | 绿/运行 | **6/6** | 6/6 |
2533
+ | 逐对更快 | 5/6 | — |
2534
+
2535
+ 两族的逐对几何分别是 `debugfix` 0.50×、`migrate` 0.50×,与历史块同量级(这两个族本来就是插件收益最大的地方),说明封顶没有动到任何行为路径。
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.35(1.5.34 的补包:把 `bench/soak.mjs` 打进 npm 包)
4
+
5
+ - 1.5.34 的 `BENCHMARK.md` 让人「自己跑一遍浸泡」,但 `bench/soak.mjs` 没在 `package.json` 的 `files` 里——装了包的人照着跑会发现文件不存在。这一版只补这一行 `files`(自包含:只 import `../index.js`),行为零改动,测试仍 313 全绿。
6
+
7
+ ## 1.5.34(稳定性版:插件里最后 6 个随宿主任期增长的缓存全部封顶,附一个可重跑的浸泡测试)
8
+
9
+ 按外部评审这一轮的建议,不新增任何提醒时机、重复提醒或预算旋钮,默认值与全部开关一律不动。只做一件事:**让插件在「几周不重启的宿主」里不再积累任何东西**,并把这件事变成能重跑的数字。
10
+
11
+ - **审计了插件自己持有的 8 个缓存,其中 6 个之前是无界的。** `mapSent`(`${sid}${cwd}`)、`startedAt`(`callId`,结果没回来的调用不会被 `delete`)、测量 sink 的 `labels`/`steps`/`open`/`pendingChunk` 都不封顶;`compacted`、`autoBudgets`、`sessions` 有上限但各写一遍淘汰循环。现在全部走同一个 `src/bounded.js` 的 `trim(cache, cap)`(插入顺序即淘汰顺序,`cap <= 0` 不动),上限分别是 500 / 2000 / 2000 / 2000 / 2000 / 1000。上限远高于任何真实并发量,正常使用不会淘汰活条目。
12
+ - **sink 关闭时不再记 label。** `agent/request` 每个请求都会调 `label()`,而默认配置根本不写测量文件——1.5.33 及以前照样把每个会话的档位记进一个 Map。20000 次 `label()` 的堆增长因此从 **2.48 MB 降到 0.01 MB**(`/tmp/labelctl.mjs`)。淘汰也不会写错行:被淘汰会话的测量行记 `mode: 'unknown'`,不再可能借用别人的档位,这一条是 `test/bounded.test.js` 的断言。
13
+ - **新增 `bench/soak.mjs`:一个从不重启的假宿主。** 用与 `test/plugin.test.js` 相同的假 Cordis 挂载真插件,跑 N 个会话 × 4 轮、每轮 `step/start` → `agent/request` → 工具调用 → `step/end`。20000 会话 / 242400 步:堆增长 **−1.64 MB(−0.1 KB/会话)**;加 `--metrics`(真的写盘,跑完删临时文件)跑 4000 会话是 −0.58 MB。阈值 0.004 KB/会话,超了退出码非 0。
14
+ - **这个脚本第一次跑报的是假泄漏,值得记下。** 4000 会话 +63.67 MB,`--off` 一模一样;二分到 `--no-request` 变 −0.55 MB,`--one-id` 仍 +49 MB——根因是脚本自己 `ctx.fire('agent/request', …)` 没 `await`,16000 个 pending Promise 冒充成插件泄漏。改成 `await` 后同规模 −0.61 MB。脚本头注释里写清了这条,免得下次又去插件里找。
15
+ - **同时复核了已发布源码没退化(`V1533`,12 次运行):** `debugfix`+`migrate` 3 对 ABBA,`def15` 6 次合计墙钟 **654.0 s(0.50×)**、步数 0.46×、输出 0.57×、逐对 5/6 更快、两臂 **6/6 全绿**,与历史块同量级。
16
+ - 测试 308 → **313 全绿**(新增 `test/bounded.test.js` 5 条)。动 `index.js`、`src/metrics.js`、`src/auto.js`、`src/bounded.js`、`bench/soak.mjs`、`BENCHMARK.md`、`docs/REFERENCE.md`;顺手把 `README.en.md` 里「右键有四行设置」改成五行(1.5.33 加了第五行,英文版漏改)。面板、配置、默认值零改动。
17
+
3
18
  ## 1.5.33(把「多个开关一起开」里那个真正省时的开关做进面板:第五行「一次读多个文件」)
4
19
 
5
20
  - **面板多了第五行,`openBatch` 第一次能被用户点到。** 1.5.32 实测的 `combo3`(默认 + `workspaceMap` + `openBatch`,11 对 0.742×)里有一半来自 `openBatch`,但它之前只在配置里,面板够不着——等于「测出来了但用户开不了」。这一版把它做成和「先列文件清单」同级的逐会话开关:写 `sessions` 列里本会话条目的 `batch`,宿主侧映射成 `openBatch`(`src/state.js` 的 `configOverridesFor`),不动共享默认值,只影响本会话。
package/README.en.md CHANGED
@@ -103,14 +103,14 @@ Legacy `fast` / `turbo` / `max` / `ludicrous` values are read as `auto` with a d
103
103
 
104
104
  ## The switch
105
105
 
106
- The bolt is the switch: one click writes on/off, per conversation, restored on reload. Right-click it for four per-conversation settings:
106
+ The bolt is the switch: one click writes on/off, per conversation, restored on reload. Right-click it for a five-row panel — the switch itself and four settings:
107
107
 
108
108
  - **Less narration** — master switch for the step-4 reminder; with it off, long tasks take about 2.7x longer and write about twice as much. On by default.
109
109
  - **Step cap** — `15` / `30` / off, off by default. At the cap it reports instead of truncating the task.
110
110
  - **Workspace inventory** — before the turn's first tool call, one inventory notice (relative paths + byte sizes + visible test entry points) so the turn does not open with `ls`/`find`. Off by default; over 23 pairs it measured 0.927x geometric with a slower tail and 10% more steps, so it stays a switch to try rather than a default.
111
111
  - **Read several files at once** — batches independent reads into one call. Off by default; the measured gain shows up when it is stacked with the inventory switch (below), not alone.
112
112
 
113
- All four apply from the next request, per conversation. No dropdown, no multiplier estimate.
113
+ All five rows take effect from the next request, per conversation. No dropdown, no multiplier estimate.
114
114
 
115
115
  **Turn several on at once?** Yes, but only the measured pair. Defaults plus inventory plus batched reads is **0.742x** over 11 pairs; adding the step-2 reminder timing (not a panel row, still on HOLD) reaches **0.669x** over 22 pairs, CI95 [0.554, 0.799], 18/22 faster, hidden tests green. Each switch alone lands at 0.80–0.88 with a confidence interval that crosses 1 — the gain comes from each one cutting a different share of the same cost, not from any single switch. The other knobs (`batchVerify`, `preferPatch`, step budget, wider fan-out) all land at 0.96–1.10; do not stack them.
116
116
 
package/bench/soak.mjs ADDED
@@ -0,0 +1,189 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Soak the plugin in a long-lived host process and watch the heap.
4
+ *
5
+ * 1.5.34 bounded every long-lived cache the plugin owns (`compacted`,
6
+ * `mapSent`, `autoBudgets`, `startedAt`, and the metrics sink's labels/steps/
7
+ * open/pending sets). A unit test can check the mapping logic but not the
8
+ * growth, because the caches are private to the closure. This script mounts the
9
+ * real plugin exactly like the harness does and then plays a host that never
10
+ * restarts: thousands of sessions, each with its own turns and tool calls,
11
+ * interleaved so every cache sees churn.
12
+ *
13
+ * It reports heap after warm-up and at the end. Growth stays flat when the caps
14
+ * work; without them the heap climbs with the session count and a real host
15
+ * eventually dies.
16
+ *
17
+ * node --expose-gc bench/soak.mjs # 4000 sessions, 4 turns each
18
+ * node --expose-gc bench/soak.mjs --sessions 8000 # longer
19
+ * node --expose-gc bench/soak.mjs --metrics # exercise the metrics sink
20
+ * # (temp file, then removed)
21
+ *
22
+ * Two notes from building this. The event feed has to be *awaited*: firing
23
+ * `agent/request` without awaiting left 16 000 promises pending and reported
24
+ * +49 MB that belonged to the test, not the plugin. And the heap baseline is
25
+ * taken after a warm-up batch, because the first sessions pay for lazily built
26
+ * module state that is not growth.
27
+ */
28
+ import { rmSync } from 'node:fs'
29
+ import { tmpdir } from 'node:os'
30
+ import { join } from 'node:path'
31
+
32
+ import { apply } from '../index.js'
33
+
34
+ /** Same fake-Cordis shape as `test/plugin.test.js`, trimmed to what the soak drives. */
35
+ function fakeContext(services = {}) {
36
+ const handlers = new Map()
37
+ return {
38
+ handlers,
39
+ on(event, handler) {
40
+ handlers.set(event, handler)
41
+ return () => handlers.delete(event)
42
+ },
43
+ inject(names, callback) {
44
+ if (!names.every(name => services[name])) return undefined
45
+ const child = {
46
+ ...services,
47
+ get: name => services[name],
48
+ effect(setup) {
49
+ const disposer = setup()
50
+ return typeof disposer === 'function' ? disposer : () => {}
51
+ },
52
+ }
53
+ return callback(child)
54
+ },
55
+ get: name => services[name],
56
+ logger: { info() {}, warn() {}, debug() {} },
57
+ fire(event, ...args) {
58
+ const handler = handlers.get(event)
59
+ return handler ? handler(...args) : undefined
60
+ },
61
+ }
62
+ }
63
+
64
+ const llm = {
65
+ resolveModelInfo: () => ({
66
+ reasoning: { efforts: ['none', 'minimal', 'low', 'medium', 'high'].map(id => ({ id })), defaultEffort: 'high' },
67
+ }),
68
+ }
69
+
70
+ const argOf = (flag, fallback) => {
71
+ const hit = process.argv.indexOf(flag)
72
+ return hit > -1 ? Number(process.argv[hit + 1]) : fallback
73
+ }
74
+
75
+ const SESSIONS = argOf('--sessions', 4000)
76
+ const TURNS = argOf('--turns', 4)
77
+ const STEPS = 3
78
+
79
+ const mb = () => process.memoryUsage().heapUsed / 1024 / 1024
80
+
81
+ /**
82
+ * Measure retained heap, not allocated heap. Without a collection the number is
83
+ * mostly garbage from the steps just driven, which is what makes a healthy run
84
+ * look like it leaks. `--expose-gc` is required for a meaningful number; without
85
+ * it the script says so instead of reporting a false failure.
86
+ */
87
+ const settle = () => {
88
+ if (typeof global.gc === 'function') {
89
+ global.gc()
90
+ global.gc()
91
+ return true
92
+ }
93
+ return false
94
+ }
95
+
96
+ /** One turn: the pre-step notice path plus a request, as the harness orders them. */
97
+ const SKIP_EVENT = process.argv.includes('--no-event')
98
+ const SKIP_REQUEST = process.argv.includes('--no-request')
99
+
100
+ async function drive(ctx, session, turn, bump) {
101
+ const agent = { id: session.id, session }
102
+ const emit = (event) => {
103
+ if (!SKIP_EVENT) ctx.fire('session/event', session, event)
104
+ }
105
+ emit({ type: 'turn/start', turn })
106
+ for (let step = 1; step <= STEPS; step += 1) {
107
+ emit({ type: 'step/start', turn, step, time: Date.now() })
108
+ if (!SKIP_REQUEST) {
109
+ await ctx.fire(
110
+ 'agent/request',
111
+ { agent, turn, step, signal: new AbortController().signal },
112
+ async () => ({ provider: 'company', model: 'deepseek-v4.1-flash', reasoningEffort: 'medium' }),
113
+ )
114
+ }
115
+ emit({ type: 'assistant/message', turn, step, time: Date.now(), usage: { inputTokens: 200, outputTokens: 30 } })
116
+ emit({ type: 'step/end', turn, step, time: Date.now() })
117
+ bump()
118
+ }
119
+ // Compaction happens on a turn boundary in the real host; emitting it here keeps
120
+ // the post-compaction reminder cache under churn.
121
+ if (turn % 2 === 1) emit({ type: 'turn/compacted', turn })
122
+ }
123
+
124
+ async function main() {
125
+ const ctx = fakeContext({ llm })
126
+ // Metrics and the experiment file off: a soak must not touch the developer's
127
+ // home directory, and it must not be steered by a live experiment file.
128
+ const OFF = process.argv.includes('--off')
129
+ // `--metrics` writes the same JSONL sink a real session writes, to a temp file
130
+ // outside the home directory, so the label/step caches see the same churn.
131
+ const METRICS = process.argv.includes('--metrics')
132
+ const metricsFile = METRICS ? join(tmpdir(), `dsh-fast-mode-soak-${process.pid}.jsonl`) : ''
133
+ apply(ctx, { enabled: !OFF, metrics: METRICS, metricsFile, benchmarkFile: '' })
134
+
135
+ // `--one-id` reuses a single session id, which separates per-session caches
136
+ // from anything retained per call.
137
+ const ONE = process.argv.includes('--one-id')
138
+ const sessions = Array.from({ length: SESSIONS }, (_, i) => ({ id: ONE ? 'soak-shared' : `soak-${i}` }))
139
+ const started = Date.now()
140
+ let steps = 0
141
+ const bump = () => { steps += 1 }
142
+
143
+ // Warm-up: the first sessions pay for module-level lazily built things
144
+ // (regexes, the gear table), which would otherwise look like growth.
145
+ for (const session of sessions.slice(0, Math.min(200, SESSIONS))) {
146
+ for (let turn = 0; turn < TURNS; turn += 1) await drive(ctx, session, turn, bump)
147
+ }
148
+ const collected = settle()
149
+ const warm = mb()
150
+
151
+ for (const session of sessions) {
152
+ for (let turn = 0; turn < TURNS; turn += 1) await drive(ctx, session, turn, bump)
153
+ }
154
+ settle()
155
+ const end = mb()
156
+
157
+ const elapsed = ((Date.now() - started) / 1000).toFixed(1)
158
+ const growth = end - warm
159
+ const perSession = growth / SESSIONS
160
+ console.log(`sessions ${SESSIONS} × ${TURNS} turns, ${steps} steps`)
161
+ console.log(`heap warm ${warm.toFixed(2)} MB`)
162
+ console.log(`heap end ${end.toFixed(2)} MB`)
163
+ console.log(`growth ${growth >= 0 ? '+' : ''}${growth.toFixed(2)} MB over ${SESSIONS} sessions`)
164
+ console.log(`per session ${(perSession * 1024).toFixed(1)} KB`)
165
+ console.log(`elapsed ${elapsed} s`)
166
+ if (!collected) console.log('note run with --expose-gc for a retained-heap number')
167
+ // Without the caps this adds a Map entry and a Set entry per session; V8's own
168
+ // noise is a few MB, so the threshold is generous on purpose — this checks
169
+ // "bounded", not "exact".
170
+ if (METRICS) {
171
+ // The sink is a diagnostic file, not a result: leave nothing behind.
172
+ try {
173
+ rmSync(metricsFile, { force: true })
174
+ rmSync(`${metricsFile}.1`, { force: true })
175
+ } catch {
176
+ // Temp file; a failed cleanup must not fail the soak.
177
+ }
178
+ console.log(`metrics ${steps} steps written, caches trimmed`)
179
+ }
180
+ if (perSession > 0.004) {
181
+ console.error('FAIL: heap grows with the session count — a cache is unbounded')
182
+ process.exitCode = 1
183
+ }
184
+ }
185
+
186
+ main().catch((error) => {
187
+ console.error(error)
188
+ process.exitCode = 1
189
+ })
package/docs/REFERENCE.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # 参考手册:原理、配置与边界
2
2
 
3
- 本手册描述 **dsh-fast-mode 1.5.33** 的实际行为(默认配置下的注入行为与 1.5.0 一致;1.5.14 起多了右键面板,1.5.33 面板为五行、五行都只影响本会话),对应仓库里的 `index.js`、`src/` 与 `client.js`。它不是宣传页:凡是能测量的地方给出数字,凡是没测量的地方直接说没测量。
3
+ 本手册描述 **dsh-fast-mode 1.5.35** 的实际行为(默认配置下的注入行为与 1.5.0 一致;1.5.14 起多了右键面板,1.5.33 面板为五行、五行都只影响本会话),对应仓库里的 `index.js`、`src/` 与 `client.js`。它不是宣传页:凡是能测量的地方给出数字,凡是没测量的地方直接说没测量。
4
4
 
5
5
  **唯一的契约:Fast 模式从不改变思考强度、thinking 预算或模型。** `agent/request` 钩子只做一件事——`await next()`,读一下请求携带的 `reasoningEffort` 记进测量,然后原样返回。用户选的是哪一档(会话里的 UI 选择,或者 profile 里 agent-default-model 固定下来的那一档),每个请求就用哪一档。
6
6
 
@@ -335,6 +335,7 @@ dsh-fast-mode-experiment stop # 结束实验
335
335
  - **设置只能由这次点击改,且带 revision。** 没有后台静默改配置的路径;冲突写入会失败并回滚,原因是 tooltip。
336
336
  - **计量是本地的。** 只写 `metricsFile`;`--lan` 是唯一的出网路径,只读,需要显式 token。
337
337
  - **看门狗只改 signal。** 调用方原来的 signal 在 `finally` 里恢复;取消后调用体的 rejection 被吞掉,不会变成未处理的拒绝。
338
+ - **每个缓存都有上限。** 插件持有的 8 个缓存全部以永不重复的 id 为键(会话 id、`callId`、`${sid}${cwd}`),而宿主进程会活几周,所以一个都不留无界:`compacted` 200、`mapSent` 500、`autoBudgets` 200、`startedAt` 2000、`warnedCaps` 有界、测量 sink 的 `labels` 2000 / `steps` 2000 / `open` 2000 / `pendingChunk` 1000、`src/auto.js` 的会话状态 200(LRU)。淘汰统一走 `src/bounded.js` 的 `trim(cache, cap)`,插入顺序即淘汰顺序。上限远高于真实并发量,所以正常使用不会淘汰活条目;真的淘汰了也不会写错行——测量行记 `mode: 'unknown'`,不会借用别人的档位。sink 关闭时 `label()` 直接不写(默认配置就是关闭)。`bench/soak.mjs` 用 20000 个会话的黑盒浸泡验证这条(−0.1 KB/会话)。
338
339
 
339
340
  ## 诚实的限制
340
341
 
package/index.js CHANGED
@@ -60,6 +60,7 @@ import { appendFileSync } from 'node:fs'
60
60
  import { homedir } from 'node:os'
61
61
  import { join } from 'node:path'
62
62
 
63
+ import { trim } from './src/bounded.js'
63
64
  import { CONTROL, createAssignment } from './src/benchmark.js'
64
65
  import { contentChars, GEARS, resolveConfig, stableKey } from './src/effort.js'
65
66
  import { buildGuidance, GUIDANCE_ORDER, GUIDANCE_SECTION } from './src/guidance.js'
@@ -97,9 +98,17 @@ const TAG = '[fast-mode]'
97
98
  * Shipped identity of this build. Traced into the debug log so a stale install is
98
99
  * visible at a glance, and pinned by a test against `package.json`.
99
100
  */
100
- export const VERSION = '1.5.33'
101
+ export const VERSION = '1.5.35'
101
102
  const GLOBAL_AGENT = '__global__'
102
103
 
104
+ /**
105
+ * Ceilings for the two caches keyed by ids that never repeat. Both are read
106
+ * by the *current* session or call, so the caps sit far above any live working
107
+ * set and only exist to keep a process that never restarts from growing.
108
+ */
109
+ const MAP_SENT_MAX = 500
110
+ const STARTED_MAX = 2000
111
+
103
112
  /**
104
113
  * Insert notices after the last message the step claims to be answering.
105
114
  *
@@ -280,7 +289,11 @@ export function apply(ctx, rawConfig) {
280
289
  // The same listener remembers the compactions, so the two things that read the
281
290
  // event stream are read in one place and in one order.
282
291
  const compacted = new Set()
283
- /** `${sid}${cwd}` pairs already given a workspace inventory. */
292
+ /**
293
+ * `${sid}${cwd}` pairs already given a workspace inventory. Capped for the
294
+ * same reason as `compacted`: the entry is only useful while that session is
295
+ * current, and a host that stays up for weeks accumulates them.
296
+ */
284
297
  const mapSent = new Set()
285
298
  /**
286
299
  * @param {{id?: string}} session
@@ -295,8 +308,8 @@ export function apply(ctx, rawConfig) {
295
308
  if (!sid) return
296
309
  // Bounded: a long-lived process sees thousands of sessions, and only the
297
310
  // next step of a just-compacted one needs the flag.
298
- if (compacted.size >= 200) compacted.delete(compacted.values().next().value)
299
311
  compacted.add(sid)
312
+ trim(compacted, 200)
300
313
  } catch {
301
314
  // A reminder that cannot be scheduled must not affect the event stream.
302
315
  }
@@ -390,7 +403,12 @@ export function apply(ctx, rawConfig) {
390
403
  * oldest entries are dropped instead of kept forever.
391
404
  */
392
405
  const autoBudgets = new Map()
393
- /** @type {Map<string, number>} */
406
+ /**
407
+ * Call id → start time. Deleted on `tools/post-execute`; a call whose result
408
+ * never arrives would otherwise keep its timestamp for the life of the
409
+ * process, so the map is capped as well.
410
+ * @type {Map<string, number>}
411
+ */
394
412
  const startedAt = new Map()
395
413
  /** Parallel caps already warned about. */
396
414
  const warnedCaps = new Set()
@@ -535,8 +553,8 @@ export function apply(ctx, rawConfig) {
535
553
  noteStep(auto, sid)
536
554
  notePolicy(auto, sid, cfg)
537
555
  const schedule = autoSchedule(auto, sid)
538
- if (autoBudgets.size >= 200) autoBudgets.delete(autoBudgets.keys().next().value)
539
556
  autoBudgets.set(sid, schedule.stallBudgetMs)
557
+ trim(autoBudgets, 200)
540
558
  if (schedule.directive) {
541
559
  notices.push(createNoticeMessage(schedule.directive.text, schedule.directive.summary))
542
560
  if (initial.debug) {
@@ -569,6 +587,7 @@ export function apply(ctx, rawConfig) {
569
587
  }
570
588
  if (map) {
571
589
  mapSent.add(key)
590
+ trim(mapSent, MAP_SENT_MAX)
572
591
  notices.push(createNoticeMessage(map, 'auto: workspace inventory'))
573
592
  }
574
593
  }
@@ -649,7 +668,10 @@ export function apply(ctx, rawConfig) {
649
668
  }
650
669
 
651
670
  ctx.on('tools/pre-execute', async (exec, next) => {
652
- if (exec && typeof exec.callId === 'string') startedAt.set(exec.callId, Date.now())
671
+ if (exec && typeof exec.callId === 'string') {
672
+ startedAt.set(exec.callId, Date.now())
673
+ trim(startedAt, STARTED_MAX)
674
+ }
653
675
  return next()
654
676
  })
655
677
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-fast-mode",
3
- "version": "1.5.33",
3
+ "version": "1.5.35",
4
4
  "description": "DeepSeek Harness fast mode: same thinking effort, fewer round trips. An adaptive parallel tool fan-out, output discipline that never trims the answer, and a cancellable stall guard. Never changes reasoning effort, the model, or the request body. Provider-agnostic, one dependency (@deepseek-ai/schemastery).",
5
5
  "repository": {
6
6
  "type": "git",
@@ -46,6 +46,7 @@
46
46
  "bench/ablate.py",
47
47
  "bench/turns.py",
48
48
  "bench/replay.mjs",
49
+ "bench/soak.mjs",
49
50
  "docs/",
50
51
  "LICENSE"
51
52
  ],
package/src/auto.js CHANGED
@@ -37,6 +37,8 @@
37
37
 
38
38
  import { statSync } from 'node:fs'
39
39
  import { isAbsolute, resolve as resolvePath } from 'node:path'
40
+
41
+ import { trim } from './bounded.js'
40
42
  /** The gear id the adaptive scheduler runs under. */
41
43
  export const AUTO = 'auto'
42
44
 
@@ -299,10 +301,7 @@ function entryOf(state, agent) {
299
301
  turnUnsafe: 0,
300
302
  }
301
303
  state.sessions.set(key, entry)
302
- if (state.sessions.size > AUTO_SESSIONS) {
303
- const oldest = state.sessions.keys().next().value
304
- if (oldest !== undefined) state.sessions.delete(oldest)
305
- }
304
+ trim(state.sessions, AUTO_SESSIONS)
306
305
  return entry
307
306
  }
308
307
 
package/src/bounded.js ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Keep a long-lived cache inside a fixed number of entries.
3
+ *
4
+ * Every cache in this plugin is keyed by something that never repeats — a
5
+ * session id, a `callId`, a `${sid}${cwd}` pair. A host process stays up for
6
+ * weeks, so an uncapped cache grows with the number of sessions it has ever
7
+ * seen, and eventually the host dies of it. `index.js`, `auto.js` and
8
+ * `src/metrics.js` all take this one shape, so a cache added later cannot
9
+ * forget the cap by writing its own loop differently.
10
+ *
11
+ * Insertion order is the eviction order for both `Map` and `Set`, so the first
12
+ * key returned by `keys()` is the oldest write — unless the caller re-inserts on
13
+ * read, which is what the session cache does to get least-recently-used order.
14
+ *
15
+ * Deleting an entry never breaks a row: a session whose label was evicted is
16
+ * recorded as `unknown`, not as somebody else's mode.
17
+ *
18
+ * @template {Map<any, any> | Set<any>} T
19
+ * @param {T} cache
20
+ * @param {number} cap
21
+ * @returns {T} the same cache, so it can be used as an expression
22
+ */
23
+ export function trim(cache, cap) {
24
+ if (!(cap > 0)) return cache
25
+ while (cache.size > cap) {
26
+ const oldest = cache.keys().next().value
27
+ if (oldest === undefined) break
28
+ cache.delete(oldest)
29
+ }
30
+ return cache
31
+ }
package/src/metrics.js CHANGED
@@ -21,11 +21,28 @@
21
21
  import { appendFileSync, mkdirSync, renameSync, statSync } from 'node:fs'
22
22
  import { dirname } from 'node:path'
23
23
 
24
+ import { trim } from './bounded.js'
24
25
  import { findUsage, readUsage } from './usage.js'
25
26
 
26
27
  /** The measurement format. Bumped when a field changes meaning. */
27
28
  export const METRICS_VERSION = 2
28
29
 
30
+ /**
31
+ * Ceilings for the four long-lived caches below. A host process lives for weeks
32
+ * and these caches are keyed by ids that never repeat, so "one entry per session
33
+ * it has ever seen" is an unbounded ceiling in disguise. The caps are far above
34
+ * anything one process holds at a time (the harness runs a handful of sessions
35
+ * and a few calls concurrently), so in practice they never evict a live entry —
36
+ * they exist so a long uptime cannot turn into a heap that only a restart fixes.
37
+ */
38
+ export const LABELS_MAX = 2000
39
+ /** In-flight step accumulators; one per open step across all sessions. */
40
+ export const STEPS_MAX = 2000
41
+ /** Which step a session's first streamed chunk belongs to. */
42
+ export const OPEN_MAX = 2000
43
+ /** A chunk that arrived before its `step/start`. */
44
+ export const PENDING_MAX = 1000
45
+
29
46
  /**
30
47
  * The size at which the step journal is rotated to `<file>.1`.
31
48
  *
@@ -190,6 +207,7 @@ export function createRecorder(options = {}) {
190
207
  seen: 0,
191
208
  }
192
209
  steps.set(key, entry)
210
+ trim(steps, STEPS_MAX)
193
211
  return { key, entry }
194
212
  }
195
213
 
@@ -209,6 +227,10 @@ export function createRecorder(options = {}) {
209
227
  */
210
228
  label(sid, label) {
211
229
  if (typeof sid !== 'string' || sid.length === 0) return
230
+ // With the sink off nothing will ever read this map, and the request hook
231
+ // calls this for every session: remembering labels for a file that does
232
+ // not exist is pure retention. (1.5.33 and earlier kept them anyway.)
233
+ if (!on) return
212
234
  const previous = labels.get(sid)
213
235
  if (previous && previous.mode === label.mode && previous.gear === label.gear
214
236
  && previous.effort === label.effort && previous.route === label.route) return
@@ -218,6 +240,7 @@ export function createRecorder(options = {}) {
218
240
  effort: label.effort,
219
241
  route: label.route,
220
242
  })
243
+ trim(labels, LABELS_MAX)
221
244
  },
222
245
 
223
246
  /** The last label recorded for a session. */
@@ -244,6 +267,8 @@ export function createRecorder(options = {}) {
244
267
  entry.started = time
245
268
  steps.set(key, entry)
246
269
  open.set(sid, key)
270
+ trim(steps, STEPS_MAX)
271
+ trim(open, OPEN_MAX)
247
272
  const early = pendingChunk.get(sid)
248
273
  if (early) {
249
274
  entry.chunkFirst = early.time
@@ -271,7 +296,10 @@ export function createRecorder(options = {}) {
271
296
  if (!entry) {
272
297
  // The live stream can arrive a few milliseconds before the durable
273
298
  // `step/start`; hold the first token until the step exists.
274
- if (!pendingChunk.has(sid)) pendingChunk.set(sid, { time, kind: chunkType })
299
+ if (!pendingChunk.has(sid)) {
300
+ pendingChunk.set(sid, { time, kind: chunkType })
301
+ trim(pendingChunk, PENDING_MAX)
302
+ }
275
303
  return
276
304
  }
277
305
  if (entry.chunkFirst === undefined) {