dsh-fast-mode 1.5.8 → 1.5.10

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
@@ -57,6 +57,8 @@ W1 出现过唯一一格门禁违规:`ledger` p1 的 `def15` 红在 `test_roun
57
57
 
58
58
  ## 全量池化复核(2026-10-27):7 个干净块合池 120 对,总量 **0.80×**,「26–28 %」撤回
59
59
 
60
+ > **这一节是历史记录,数字已被后面的 9 块 132 对口径取代**(见文末「合池的自助区间与逐任务稳定性(2026-10-27,含 PPX2 与 SG2)」:0.825×、CI95 0.747–0.914)。留在这里是为了说明「26–28 %」为什么被撤回,不要引用本节表格里的家族数字。
61
+
60
62
  之前对外说的是「1.5.1 那一块 0.72×、W5 0.73×、W7 0.73×,三块合并 65 个有效单元 0.74×」。那三块是**挑出来的**:它们都恰好是默认臂表现好的块。样本扩大以后,把**所有**有 `off` 锚的干净块一起加总——不再挑块、不再排除任何任务——数字变差,所以 26–28 % 撤回,改成约 **20 %**。
61
63
 
62
64
  口径与判据:
@@ -339,7 +341,7 @@ W10(10 个有效单元)+ W11(13 个有效单元)合并,23 个单元:
339
341
 
340
342
  | 方向 | 实测 | 判据 | 剩余余量依据 |
341
343
  |---|---|---|---|
342
- | `outputBudget`/`bud8` | 0.920× | 慢于默认,淘汰 | 正文只占输出 6 % |
344
+ | `outputBudget`/`bud8` | ~~0.920×~~ **作废** | 未测量:机制没接线(1.5.10 才修) | 正文只占输出 6 % |
343
345
  | `fanoutWidth: 12`/`wide15` | 1.148× | 慢于默认,淘汰 | 并发不减推理 |
344
346
  | `openBatch`/`open15` | 0.837×、头对头 1.136× | 未复现,淘汰 | — |
345
347
  | `preferPatch`/`pp16` | 未测出效果 | 余量上限 ≈ 输出 0.2 % | 153 次整文件写入里重复写同路径只占 0.8 % |
@@ -434,6 +436,8 @@ W10(10 个有效单元)+ W11(13 个有效单元)合并,23 个单元:
434
436
 
435
437
  ### G. 对照臂:按真实 usage 的 `outputBudget`(3 家族 × 3 对,9/9 绿)
436
438
 
439
+ > **本节数字于 1.5.10 整体作废,`outputBudget` 回到「未测量」。** 原因不是结果不好,而是**机制没有接线**:`outputBudget` 在 `src/config.js` 里声明了,但 `src/effort.js` 构造策略对象时不带这个字段,调度器读到的永远是 `undefined`,所以 `bud8` 臂实际跑的就是 `def15`——两条臂的差值是噪声。同一轮的 `bud8` 原始结果文件(本应对应 3 家族 × 3 对)在 `bench/results/` 里也已不存在,只剩 W4 里 20 个同样失效的 `bud8` 单元。**别引用本节表格里的任何数字**;1.5.10 修好传递链后,`outputBudget` 需要在新块里重新测。同理,`shapeGate` 的 `gate15`/`gateonly`/`sg15` 三臂也从未真正生效(见文末「1.5.10 的接线审计与撤回」)。
440
+
437
441
  前面说过写文件家族的问题不在提示词。这一轮就把"压输出"做成 harness 机制而不是再写一段提示:`outputBudget` 读上游 `usage.outputTokens`,本轮累计到线就发一次「收掉当前闭环、跑一次证明它的检查、汇报」,每轮最多一次、默认 `0`(关)。对照臂 `bud8` = 默认配置 + `outputBudget: 8000`,与 `off`、`def15` 同轮 ABBA,`codeimpl` / `migrate` / `codebase` 各 3 对:
438
442
 
439
443
  | 臂 | 总墙钟 | × | 总输出 token | × | 总步数 | × |
@@ -1452,31 +1456,127 @@ node bin/experiment.mjs stop # end it; later sessions g
1452
1456
  - **速度**:墙钟合计 **0.967×**(1543.5 s → 1493.3 s),逐格中位 **0.999×**、3/5 格更快,步数 0.66×。逐格:`ledger` 0.78×、`shellops` 0.98×、`migrate` 1.00×、`stategraph` 1.31×、`codebase` 1.79×。
1453
1457
  - **读法**:单对、n=5,噪声带比合池 0.798× 的总口径宽,**不用这块替换总量结论**;它只用于回答「发版这一步有没有把质量或方向弄坏」。质量答案是没坏;速度方向在这一块落进噪声(0.97×)。
1454
1458
  - 原始结果 `bench/results/suite-REL16.jsonl`;`python3 bench/report.py --files bench/results/suite-REL16.jsonl --baseline off` 可复算。
1455
- ## 合池的自助区间与逐任务稳定性(2026-10-27,含 PPX2)
1459
+ ## 合池的自助区间与逐任务稳定性(2026-10-27,含 PPX2 与 SG2)
1456
1460
 
1457
- 合池点估计 **0.8087×** 是 128 对有效单元的比值加总,不是承诺值。给这 128 对做 2000 次自助重抽(`python3 bench/pool.py --ci 2000`)后:**墙钟总比 95 % 区间 [0.734, 0.894]**,中位 **[0.706, 0.885]**,步数总比 0.8062×,更快 87/128。区间上沿还贴着 0.9,所以对外只说「大约两成」,不说「至少两成」;要收窄区间只能加样本,不是换机制。
1461
+ 合池点估计 **0.825×** 是 132 对有效单元的比值加总,不是承诺值。给这 132 对做 2000 次自助重抽(`python3 bench/pool.py --ci 2000`)后:**墙钟总比 95 % 区间 [0.747, 0.914]**、逐格中位 0.813×(区间 [0.710, 0.883])、89/132 对更快。`bench/results/suite-*.jsonl` 里每行都带 `valid` 判定,合池只吃 `exit==0 && steps>=1 && out>0 && exact && !leak` 的单元。
1458
1462
 
1459
- PPX2 是这一轮唯一新增的 off 锚定块(4 家族 × 2 对 × 3 臂,`bench/results/suite-PPX2.jsonl`)。它合进来的总量是 **0.912×(8 对,6/8 更快)**,把合池从 0.798× 抬到 **0.809×** —— 方向没变,幅度更保守。它还暴露了一件事:**同一批任务在另一块里符号会翻**,见下表 `debugfix`/`perfrefactor` 两行。
1463
+ 新增的 off 锚定块 **SG2**(4 家族 × 1 对 × 3 臂,`bench/results/suite-SG2.jsonl`)合进来是 **1.235×(4 对,2/4 更快)**,把合池从 0.809× 抬到 **0.825×**。它也是第一块把 `ledger`(2.024×,输出 34.1k vs 18.7k token)和 `perfrefactor`(1.427×)的反向样本算进分母的块——抬上去的那 1.6 个点就是把这两类任务如实计入的代价。
1460
1464
 
1461
- 逐任务拆开看,方向稳定的只剩两个家族(双侧符号检验,7 块 + PPX2,共 128 对):
1465
+ > **SG2 的 `sg15` 臂作废(1.5.10)**:`sg15` 打开的是 `shapeGate`,而这个字段当时根本没传进策略对象(见文末审计),所以 `sg15` 与 `def15` 是**同一条策略**。同块里两者 1037.5 s vs 1030.3 s 的 0.8 % 差值正是「同一策略跑两遍」应有的噪声;据此写下的「`sg15` 比 `def15` 更差 1.295× ⇒ `shapeGate` 继续默认关」**没有证据支持,撤回**。SG2 里 `off` 对 `def15` 的 4 对仍然有效,1.235× 与合池 0.825× 不受影响。
1462
1466
 
1463
- | 任务 | n | 中位墙钟 | 均值 | 四分位区间 | 更快 | 符号检验 p | 输出比中位 |
1464
- |---|---|---|---|---|---|---|---|
1465
- | `codeimpl` | 15 | 0.709 | 0.754 | 0.62–0.90 | 13/15 | **0.007** | 0.831 |
1466
- | `stategraph` | 10 | 0.667 | 0.741 | 0.59–0.72 | 8/10 | 0.109 | 0.737 |
1467
- | `migrate` | 15 | 0.739 | 0.849 | 0.53–1.28 | 11/15 | 0.118 | 0.802 |
1468
- | `codebase` | 11 | 0.586 | 0.898 | 0.51–1.49 | 7/11 | 0.549 | 0.657 |
1469
- | `shellops` | 12 | 0.774 | 0.928 | 0.66–1.44 | 8/12 | 0.388 | 0.932 |
1470
- | `ledger` | 13 | 0.782 | 0.822 | 0.45–1.22 | 8/13 | 0.581 | 0.950 |
1471
- | `shellpipeline` | 12 | 0.844 | 0.835 | 0.67–0.99 | 10/12 | **0.039** | 0.761 |
1472
- | `perfrefactor` | 13 | 0.890 | 1.118 | 0.72–1.36 | 7/13 | 1.000 | 0.865 |
1473
- | `csvpipe` | 13 | 0.983 | 1.014 | 0.54–1.48 | 8/13 | 0.581 | 0.883 |
1474
- | `debugfix` | 14 | 1.183 | 1.323 | 0.83–1.92 | 7/14 | 1.000 | 1.286 |
1475
-
1476
- - **稳的**:`codeimpl`(13/15 更快,p = 0.007,中位 0.709×)与 `shellpipeline`(10/12,p = 0.039)。这两个家族的收益同时出现在墙钟和步数上。
1477
- - **中位好看但不稳的**:`codebase` 中位 0.586× 但均值 0.898×、四分位 0.51–1.49×,p = 0.549 —— 靠少数长格拉低中位,不能说成「普遍快 41 %」。
1478
- - **不许当结论的两行**:`debugfix` 在中位 1.183×(p = 1.000,7/14 更快);但在 PPX2 这一块里 `debugfix` 是 **0.651×(2/2 更快)**、`perfrefactor` 是 **0.687×(2/2 更快)**,与混池方向相反。样本只有 2 对时不构成反证,但足以说明**「哪个家族快」不能跨块引用** —— 对外只能说合池,不能说逐家族表是稳定的。
1479
- - **输出口径**:`debugfix` 输出比 1.286× 仍是唯一明显变长的家族;`codebase`/`stategraph`/`shellpipeline` 输出明显变短(0.657/0.737/0.761)。合池输出 0.806×,与墙钟同向 —— 速来自少写,不是来自少做。
1467
+ | 块 | n | 墙钟总比 | 更快 |
1468
+ |---|---|---|---|
1469
+ | `W1` | 11 | 0.710× | 7/11 |
1470
+ | `W3` | 29 | 0.721× | 23/29 |
1471
+ | `W5` | 20 | 0.793× | 11/20 |
1472
+ | `W6` | 19 | 0.931× | 11/19 |
1473
+ | `W7` | 17 | 0.734× | 13/17 |
1474
+ | `W8b` | 19 | 0.819× | 13/19 |
1475
+ | `REL16` | 5 | 0.967× | 3/5 |
1476
+ | `PPX2` | 8 | 0.912× | 6/8 |
1477
+ | `SG2` | 4 | 1.235× | 2/4 |
1478
+
1479
+ 合池输出比 **0.822×**、步数比 **0.809×**、逐格中位 **0.813×**。
1480
+
1481
+ | 任务 | n | 中位墙钟 | 均值 | 四分位区间 | 更快 | 符号检验 p |
1482
+ |---|---|---|---|---|---|---|
1483
+ | `codebase` | 11 | 0.586 | 0.898 | 0.51–1.49 | 7/11 | 0.549 |
1484
+ | `stategraph` | 10 | 0.667 | 0.741 | 0.57–0.86 | 8/10 | 0.109 |
1485
+ | `shellops` | 13 | 0.683 | 0.896 | 0.55–1.38 | 9/13 | 0.267 |
1486
+ | `codeimpl` | 15 | 0.709 | 0.754 | 0.62–0.90 | 13/15 | **0.007** |
1487
+ | `migrate` | 15 | 0.739 | 0.849 | 0.53–1.28 | 11/15 | 0.118 |
1488
+ | `ledger` | 14 | 0.812 | 0.908 | 0.44–1.29 | 8/14 | 0.791 |
1489
+ | `shellpipeline` | 12 | 0.844 | 0.835 | 0.66–0.97 | 10/12 | **0.039** |
1490
+ | `perfrefactor` | 14 | 0.961 | 1.140 | 0.69–1.53 | 7/14 | 1.000 |
1491
+ | `debugfix` | 15 | 0.976 | 1.287 | 0.77–1.92 | 8/15 | 1.000 |
1492
+ | `csvpipe` | 13 | 0.983 | 1.014 | 0.54–1.50 | 8/13 | 0.581 |
1493
+
1494
+ - **稳的**:`codeimpl`(13/15 更快,p = 0.007,中位 0.709×)与 `shellpipeline`(10/12,p = 0.039)。这两个家族的收益同时出现在墙钟和步数上,是唯一能对外说「这个形状有稳定收益」的两行。
1495
+ - **中位好看但不稳的**:`codebase` 中位 0.586× 但均值 0.898×、四分位 0.51–1.49×,p = 0.549 —— 靠少数长格拉低中位,不能说成「普遍快 41 %」。`shellops` 同理(中位 0.683×、均值 0.896×、p = 0.267)。
1496
+ - **加了 SG2 之后方向变坏的**:`ledger` 中位 0.812× 但均值 0.908×、四分位 0.44–1.29×(SG2 里 2.024×);`debugfix` 中位 0.976×、均值 1.287×(输出 1.29×);`perfrefactor` 中位 0.961×、均值 1.140×。这三类不是「快得少」,是**平均在变慢**,默认指引写「先试一次,复验变多就关」。
1497
+ - **不许跨块引用的行**:`debugfix` 在 PPX2 这一块里是 **0.651×(2/2 更快)**、`perfrefactor` 是 **0.687×(2/2 更快)**,与混池方向相反。块内 n=2 撑不起任何引用,写在这里只是提醒别拿单块当结论。
1498
+ - **输出口径**:合池输出 0.822×,与墙钟同向 —— 速度来自少写、少复验,不是来自更快地写同样多的东西。`debugfix` 输出 1.29× 是唯一明显变长的家族。
1480
1499
  - 开销口径:这些区间是**墙钟**的自助区间;块内两臂逐对交替跑,所以不含跨时段机器漂移。块间不可比,合池只做同 `(tag, task, pair)` 的配对。
1481
1500
 
1482
- **什么时候开、什么时候别开**(按形状,不按任务名,README 同款):开在多产物、反复复验的中长任务;`debugfix`/`perfrefactor` 这类短改修**先用一次,看它是否在替你复验**——混池里它们是负的,但 PPX2 里是正的,所以给的是「先试再定」,不是禁令;`csvpipe` 中位 0.983×,接近 1,等于没赚没亏。开关本来就按会话记:同一会话里一旦发现提示在拖后腿,关掉即可,不需要重启宿主。
1501
+ ## DBG2:三个慢家族的定向复测(2026-10-27,未进合池)
1502
+
1503
+ 原计划 3 家族 × 3 对 × 2 臂 = 18 格,只跑完 14 格(`csvpipe` 第 3 对卡在 18 分钟未收),**7 对可用**:`debugfix` 3 对、`perfrefactor` 2 对、`csvpipe` 2 对。这一块从 13:21 跑到 14:2x,机器 load 5–8(Spotlight 重启后重建索引),**墙钟被拉长约 2–4 倍,不满足合池的负载前提**,所以只作方向参考、不进 `CLEAN`:
1504
+
1505
+ | 家族 | n | 墙钟总比 | 逐对 |
1506
+ |---|---|---|---|
1507
+ | `debugfix` | 3 | 1.021× | 1.059 / 1.060 / 0.943 |
1508
+ | `perfrefactor` | 2 | 1.207× | 1.291 / 1.122 |
1509
+ | `csvpipe` | 2 | 1.783× | 3.298 / 0.268 |
1510
+
1511
+ 读法:`debugfix` 在负载下基本持平(三条全在 1.0 上下),`perfrefactor` 两条都慢 12–29 %,`csvpipe` 两条方向完全相反(一条跑飞成 3.3×、一条 0.27×)——后者说明这个家族的方差大过机制本身,任何单点数字都不能引用。**结论与混池一致:`debugfix`/`perfrefactor`/`csvpipe` 默认不承诺收益,指引写「先试一次」。**
1512
+
1513
+ **什么时候开、什么时候别开**(按形状,不按任务名,README 同款):开在多产物、反复复验的中长任务;`debugfix`/`perfrefactor`/`csvpipe` 这类短改修和数据整形**先用一次,看它是否在替你复验**——混池里它们是负的,所以给的是「先试再定」,不是「都会快」。合池的 0.825× 是全部样本的加总口径,别拿它单点承诺某个任务。
1514
+
1515
+ ## 按场景开关值不值:132 对上的反事实算账(2026-10-27,纯分析,不新增格子)
1516
+
1517
+ 「按场景动态启用/关闭」听起来比全局开更优,但在已有样本上直接算反事实就很清楚:
1518
+
1519
+ | 策略 | 开着的单元 | 合池总墙钟 |
1520
+ |---|---|---|
1521
+ | 全关(`off` 基线) | 0/132 | 1.0000× |
1522
+ | 只开 `codeimpl`+`stategraph` | 25/132 | 0.9313× |
1523
+ | 再加 `shellpipeline`+`shellops` | 50/132 | 0.8763× |
1524
+ | 再加 `migrate`+`codebase` | 76/132 | 0.8414× |
1525
+ | **全开(当前默认)** | 132/132 | **0.8247×** |
1526
+ | 全开但 `debugfix`/`perfrefactor` 关 | 103/132 | 0.8235× |
1527
+
1528
+ 读法两条,都是负结果,但有用:
1529
+
1530
+ 1. **按「只开稳的家族」做路由,总量比全开更差**(0.93× / 0.88× / 0.84×)。原因很朴素:`migrate`(0.822×)、`codebase`(0.808×)、`ledger`(0.916×)这些「不够稳所以不该单独引用」的家族,加总起来仍然是净收益;把它们关掉等于把收益丢掉,只留下几格漂亮中位。
1531
+ 2. **只把明显回退的两类关掉(`debugfix` 1.032×、`perfrefactor` 1.009×),总量从 0.8247× 变 0.8235×**——省下 0.12 个百分点。也就是说**按场景开关不是速度杠杆,是回归规避杠杆**:它买到的是「这两类任务不会变慢」,买不到更快的总墙钟。
1532
+
1533
+ 所以默认仍然是「全局开 + 按形状提示先试」,没有引入按家族名的硬开关:运行时看不到家族标签,算账也不涨速度,而按步数硬停会让任务「没做完就停」——这条线不碰。
1534
+
1535
+ ## 预注册:`shapeGate`(运行时按形状静音)的通过判据(2026-10-27,先写判据再跑)
1536
+
1537
+ `shapeGate` 是实现里唯一真正「按场景动态关闭」的开关(`src/auto.js:596`:一个回合里已有 ≥4 次调用、且写类调用占 60 % 以上时,调度器不再发任何加速指令,回到原生沉默)。它默认关。要开它,先固定判据,跑完照判据判,不许事后改口径:
1538
+
1539
+ 1. **质量门禁**:同一块内 `gateonly` 与 `off` 的隐藏测试必须全部 exact、无 leak、exit 0;任何一个 BAD 即不通过。
1540
+ 2. **回退家族**:`debugfix` 与 `csvpipe`(已知写重、小改修)上 `gateonly/def15` 的同块墙钟比 ≤ 1.00,逐对中位也 ≤ 1.03。
1541
+ 3. **不伤收益**:`codeimpl`(最快家族)上 `gateonly/def15` ≤ 1.03,且三家族合计 ≤ 1.00。
1542
+ 4. **样本量**:每家族 ≥ 2 个同块有效配对;不足 2 对则结论只能是「未判定」,不能写成通过。
1543
+ 5. 通过才把它并入默认,并在 CHANGELOG 写明「在写重回合自动静音」;不通过就保持默认关,把负结果写在这里。
1544
+
1545
+ 块:`--tasks debugfix,csvpipe,codeimpl --arms off,def15,gateonly --pairs 2 --tag GATE3`(18 个单元,单块串行,load ≤ 6.5 才开跑)。
1546
+
1547
+ ## 成本模型只能在块内解释:跨块回归是假的(2026-10-27,纯分析)
1548
+
1549
+ 把九个干净块的 133 个 `off` 单元丢进一个回归,得 `wall = 18.58 + 8.50·out/1k − 0.93·step`,R²=0.46,而块内(W8b,n=19)是 `wall = −8.8 + 13.48·out/1k + 1.62·step`,R²=0.92。步数系数在跨块回归里**翻成负数**——这不是机制,是块间负载差混进了截距:机器忙闲不同,同一份工作量在 W3 和 W8b 上的墙钟差得比 `out` 能解释的多。所以「每步值多少 token」「砍 40 % 步数能到 0.60×」这类算账只能在单个块内做,跨块算出来的数一律当噪声,别写进报告。
1550
+
1551
+ 同时在**真实会话**的 18 030 个 `mode=on` 步骤上量了时间去哪了(这是运行时自己的遥测,不是基准数据,只作方向参考):`ΣllmMs/ΣstepMs = 0.68`,其中 TTFT 占 LLM 时间的 0.19、解码占 0.81,解码速率 53 tok/s。也就是说**每步墙钟里有约 32 % 根本不在模型调用里**(工具执行、宿主调度、渲染)。推论:纯注入式插件即使把模型时间砍到零,上限也只有约 0.68×;要到 0.60× 必须同时减少步数与工具往返,而不只是压输出——这跟「不许把没做完的活强行停下」直接冲突,所以 0.60× 在当前约束下应当继续按「不可达」处理,对外只报 0.8247×(CI95 0.747–0.914)。
1552
+
1553
+ ## 加样本修不了置信区间:132 对的重采样宽度预算(2026-10-27,纯分析)
1554
+
1555
+ 在现有 132 对上做 2000 次重采样,合池比值 0.8247×,CI95 宽度 0.184。再按同样口径外推:
1556
+
1557
+ | 单元数 | CI95 宽度(重采样均值) |
1558
+ |---|---|
1559
+ | 132(现有) | 0.184 |
1560
+ | 144(+12 对,即 NX1 全部落地) | ≈0.166 |
1561
+ | 156(+24 对) | ≈0.154 |
1562
+ | 37 对(三个快家族单独看) | 0.184(n=12 时 0.298,n=37 时 0.184) |
1563
+
1564
+ 结论:**「再多跑一些对」不是收紧精度的有效手段**——把样本翻一倍量级才可能把宽度压到 0.12 附近,而要 ±0.03 需要几百对,在单块串行、load ≤7 的纪律下不现实。所以对外口径继续用「点估计 + 已有 CI95 + 符号检验」,把增量精力放在**降方差**(严格 ABBA、同块对照、剔除负载持续超标的单元)和**中位数/符号检验**上,而不是堆样本量。NX1 的 12 对按上表只把宽度从 0.184 拉到 ≈0.166,它的真实价值是给 `codeimpl`/`shellpipeline`/`stategraph` 三个家族各多一对同块证据(符号检验从 37 对变 49 对的稳健性),不是让总口径变窄。
1565
+
1566
+ ## 预注册:`outputBudget`(回合内输出预算,非请求体字段)的通过判据(2026-10-27,先写判据再跑)
1567
+
1568
+ 机制:`outputBudget` 走的是调度器话术——当本回合输出 token 超过阈值时,`src/auto.js:667–673` 发一条「收敛输出」指令,只发一次(`outBudgetSent`),不改请求体、不改 `reasoningEffort`。是唯一还没在同块对照下测过的输出侧杠杆。待跑命令(`bench/arms/bud8.yml`,`outputBudget: 8000`):
1569
+
1570
+ ```
1571
+ python3 bench/suite-run.py --tasks debugfix,ledger,codeimpl --arms off,def15,bud8 --pairs 2 --tag BUD3
1572
+ ```
1573
+
1574
+ 判据(全部满足才并入默认,否则记为负结果并保持默认关):
1575
+
1576
+ 1. **质量门禁**:`BUD3` 块内 `bud8` 每个单元 `exit==0`、`exact`、无 `leak`;`off` 与 `def15` 在同块同样全绿(分母按有效单元算,不按格数算)。
1577
+ 2. **输出真的降了**:`bud8/def15` 的同块输出 token 总和比 ≤ 0.90;若 ≥ 0.97 判定「机制未生效」,不许用墙钟数字解释。
1578
+ 3. **墙钟不倒退**:`bud8/def15` 同块墙钟总和比 ≤ 1.00,逐对中位数 ≤ 1.03;三个家族里至少两个家族单独 ≤ 1.00。
1579
+ 4. **每家族 ≥ 2 个同块有效对**,否则该家族写「未判定」,不并入总体结论。
1580
+ 5. 通过 → `outputBudget` 默认值从关改为 8000 并进 CHANGELOG;不通过 → 在本文档记录负结果,默认保持关。
1581
+
1582
+ 注意:`bud8` 的历史 20 行来自**没有 tag 的遗留块**(每家族仅 1 对、无 ABBA 保证),已判定不可用,不得拿来替代 `BUD3`;上面第 2 条的旧值 `out/def15≈0.94` 只作方向参考。
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.10
4
+
5
+ - **接线审计(先说结论:1.5.9 有两条机制从未真正生效)**。按 schema 的 23 个键逐个对照 `resolveConfig()` 返回的 policy,发现 `shapeGate`、`outputBudget` 在 `src/config.js` 里声明、却**没有**被 `resolveConfig` 带出去:钩子读到的永远是 `undefined`,`bud8`、`sg15`、`gateonly`、`gate15` 四个臂跑的都是 `def15` 的行为。所以这些块的数字**不是“机制没用”,而是“机制没被测到”**——一律标 **untested**,不写成胜负;`BENCHMARK.md` 里受影响的表格行、G 段与 SG2 的对照结论已就地撤回。这两个键现在真的接上了,并有传播测试(`test/effort.test.js`)防回归。
6
+ - **复用提醒不再基于猜测(`reuse-check` 重写)**。旧实现把任何“带参数的成功调用”都记成一次通过,靠一张正则白名单判断写入;`python3 mutate_state.py` 这种真会改文件却不匹配任何一条的命令因此不会被判成写入,提醒于是会说“没有相关变化”——那是没有证据的断言。现在:只把**识别得出的验证命令**(`npm/pnpm/yarn/bun test`、`pytest`、`node --test`、`go/cargo test`、`tsc`、`eslint`、`ruff`、`mypy`、`make test|check` 等)记成通过;`shell` 命令按 `readonly` / `verify` / `unknown` 分类,`unknown` 与写入同样作废已存结果;多段命令(`&&`、`;`、`|`、换行)除非每一段都能证明是只读或验证,否则算 `unknown`;对写过的文件记 `{mtimeMs,size}` 指纹,发提醒前重新 stat,指纹变了或 stat 失败就不发并作废;验证失败会撤掉它对上的那次通过。提醒文案只陈述已证明的事实。
7
+ - **看门狗取消后确认进程真的退出**。旧版取消后只等一个 250 ms–5 s 的窗口就返回失败,`quiesced: false` 只进了日志,模型侧看到的仍是“已经结束”的口气。现在是两段有界等待:先等收敛(随预算缩放),仍在跑就再等一段退出确认窗口,`settleMs + confirmMs` 由 `confirmWindowFor()` 夹在 15 s 以内;结果同时记 `quiesced`(是否在窗口内收敛)、`confirmed`(是否确认退出)、`waitedMs`。未确认时失败文案明确说“退出未被观察到、进程可能还在跑、不要动它可能写的文件”,并把 `pgrep -fl` / `ps -o pid,stat,command -p` 作为下一步给出去;日志行也带上 `confirmed` 与 `waitedMs`。1.5.5 那条“5 s 窗口”的描述被本版取代。
8
+ - **新增 `scheduler` 开关,为干净消融提供第四条臂**。此前输出克制纪律被 `gear.directive` 夹住,而所有提醒也共用同一个通道,于是“只开纪律 / 只开调度”无法表达,四臂消融根本跑不起来。`scheduler: false` 现在只关掉提醒通道,宽度与看门狗预算照旧;新臂 `bench/arms/disc-only.yml` = 默认配置 + `scheduler: false`(纪律仍在、零提醒),配上已有的 `disc-off.yml`(提醒仍在、纪律关)即可在**同一区块**内把 off / 只纪律 / 只调度 / 全开四条臂交错跑完。该四臂数据尚未跑(机器 load 9–10,不满足 ≤ 6.5 的成块前提),跑完才会把数字写进 `BENCHMARK.md`。
9
+ - **测试**:260 个用例全绿(`node --test`,10.7 s),含新增的接线传播、复用提醒分类/指纹/撤回、两段等待与 `confirmWindowFor` 边界、以及 `scheduler:false` 静默但保留宽度的用例。
10
+ - **版本**:`1.5.10`。以上三项都是 1.5.9 评审指出的可靠性问题,不是新增提速机制;对外速度口径仍是 1.5.9 的 **0.825×(132 对)**,且必须同时给逐格中位 0.813×。
11
+
12
+ ## 1.5.9
13
+
14
+ - **合池口径再扩一块(无行为变更,纯数据与文档)**:新增 SG2 块(`shellops`/`debugfix`/`perfrefactor`/`ledger` × 1 对 × 3 臂,4 对 off 锚定)后,合池变成 **9 块、132 对、10 个家族**:总墙钟 **0.825×**(省 18 %)、输出 token 0.822×、步数 0.809×、逐格中位 0.813×、132 对里 89 对更快;2000 次自助重抽的总比 95 % 区间 **[0.747, 0.914]**、中位区间 [0.710, 0.883]。上一版对外写的 0.809× / 省 19 % 是 128 对口径,**本版按更全样本改口径**,不是行为变了。
15
+ - **SG2 自己就是一块反向样本**:4 对合进来是 **1.235×(2/4 更快)**,其中 `ledger` 2.024×(输出 34.1k vs 18.7k token)、`perfrefactor` 1.427×,正是把「输出会变长的家族」如实计进分母的结果。`sg15` 臂在同块比 `def15` 更差(1.295×、中位 1.392×、1/4 更快),**继续默认关**,不进默认机制。
16
+ - **逐家族稳定性重算**:稳的仍然只有 `codeimpl`(13/15 更快、p = 0.007、中位 0.709×)和 `shellpipeline`(10/12、p = 0.039、中位 0.844×);`ledger`(均值 0.908×、四分位 0.44–1.29×)、`debugfix`(均值 1.287×、输出 1.29×)、`perfrefactor`(均值 1.140×)在扩样本后**平均在变慢**,文档与 README 的「什么时候别开」改按这三类写。
17
+ - **DBG2 定向复测(未进合池)**:`debugfix`/`perfrefactor`/`csvpipe` 各 3 对,只跑完 7 对(`csvpipe` 第 3 对卡在 18 分钟未收),且全程机器 load 5–8、墙钟被拉长 2–4 倍,**不满足合池前提**,只作方向参考:`debugfix` 1.021×(三条 1.06/1.06/0.94)、`perfrefactor` 1.207×(1.29/1.12)、`csvpipe` 1.783×(一条 3.30×、一条 0.27×,方差大过机制)。结论与混池一致:这三类默认不承诺收益。
18
+ - **复现前置**:`python3` 必须是能 `import yaml` 的解释器(本机 `/usr/bin/python3` 因 Xcode license 返回 rc=69);`bench/pool.py` 的区块清单已含 `SG2`。
19
+
3
20
  ## 1.5.8
4
21
 
5
22
  - **合池口径再收紧(无行为变更,纯数据与文档)**:新增 PPX2 块(4 家族 × 2 对 × 3 臂,8 对 off 锚定)后,合池变成 **8 块、128 对、10 个家族**:总墙钟 **0.809×**(省 19 %)、输出 0.806×、步数 0.806×、逐格中位 0.813×、87/128 更快,2000 次自助重抽 95 % 区间 **[0.734, 0.894]**(中位 [0.706, 0.885])。上一版的 0.798× / 120 对是被一块偏乐观的数据抬高的,这版把 PPX2 一起算进去。
package/README.en.md CHANGED
@@ -40,9 +40,9 @@ No model switch, no `reasoningEffort` rewrite, no request-field injection (`serv
40
40
 
41
41
  ## Measured
42
42
 
43
- **Toggle it on and the headline is: about 19% faster in total, and 15–30% faster on the shapes it fits.** That is every clean block with an `off` anchor pooled (8 blocks, 128 valid pairs, 10 task families; recompute with `python3 bench/pool.py`): total wall clock **0.809×** (19% less), output tokens 0.806×, steps 0.806×, per-cell median 0.813×, faster in 87 of 128 pairs. Bootstrapping those 128 pairs (`python3 bench/pool.py --ci 2000`) puts the total ratio at a 95% interval of **[0.734, 0.894]** (11% to 27% faster) and the per-cell median at [0.706, 0.885] — "about 19%" is a point estimate, not a floor. Per-family stability and sign tests are in [BENCHMARK.md](BENCHMARK.md), section "合池的自助区间与逐任务稳定性", which also explains why per-family numbers cannot be quoted across blocks.
43
+ **Toggle it on and the headline is: about 18% faster in total, and 15–25% faster on the shapes it fits.** That is every clean block with an `off` anchor pooled (9 blocks, 132 valid pairs, 10 task families; recompute with `python3 bench/pool.py`): total wall clock **0.825×** (18% less), output tokens 0.822×, steps 0.809×, per-cell median 0.813×, faster in 89 of 132 pairs. Bootstrapping those 132 pairs (`python3 bench/pool.py --ci 2000`) puts the total ratio at a 95% interval of **[0.747, 0.914]** (9% to 25% faster) and the per-cell median at [0.710, 0.883] — "about 18%" is a point estimate, not a floor. Per-family stability and sign tests are in [BENCHMARK.md](BENCHMARK.md), section "合池的自助区间与逐任务稳定性", which also explains why per-family numbers cannot be quoted across blocks.
44
44
 
45
- **When to turn it on, and when not to.** On: mid-length tasks with several artifacts and repeated re-checks — `codeimpl` 0.608x, `stategraph` 0.708x, `migrate` 0.748x, `shellpipeline` 0.749x, with `shellops` 0.803x, `codebase` 0.808x and `ledger` 0.811x on the same side. Off: one-to-five-step repairs — `debugfix` 1.245x and `perfrefactor` 1.077x, where the injected reminder cannot pay for itself. `csvpipe` 0.982x is a wash. The switch is the lightning bolt per conversation; it remembers your last choice and applies from the next request.
45
+ **When to turn it on, and when not to.** On: mid-length tasks with several artifacts and repeated re-checks — `codeimpl` 0.663x (faster in 13 of 15 pairs, sign test p = 0.007) and `shellpipeline` 0.749x (p = 0.039), with `stategraph` 0.708x, `shellops` 0.748x, `codebase` 0.808x and `migrate` 0.822x on the same side but with too few pairs to stand alone. Off: one-to-five-step repairs and data reshaping — `debugfix` 1.032x (output actually 1.29x longer) and `perfrefactor` 1.009x, where the injected reminder cannot pay for itself. `ledger` 0.915x and `csvpipe` 0.982x are a wash. The switch is the lightning bolt per conversation; it remembers your last choice and applies from the next request.
46
46
 
47
47
  **1.5.0 ships no step-based finish line by default.** `finishLineSteps` and `stepBudget` both default to `0`: there is no "remind me at step 10" and no "stop at step 30". When to finish is decided by task state and completion evidence — repeating an already-passing check verbatim against the same artifact suggests reusing the result and spending the step on what is left; several steps with no new result suggests changing the method (a reminder only, it never ends the turn); a stuck tool is cancelled by the watchdog and awaited until it really stops. A finished task is reported normally; a reminder never votes to stop for you.
48
48
 
package/README.md CHANGED
@@ -40,9 +40,9 @@
40
40
 
41
41
  ## 实测
42
42
 
43
- **打开小闪电,排头的一句结论:整任务总量约快 19 %,适合的形状快 15–30 %。** 这是把**所有**有 `off` 锚的干净块一起加总的结果(8 个块、128 对有效配对、10 个任务家族,`python3 bench/pool.py` 可复算):总墙钟 **0.809×**(省 19 %)、输出 token 0.806×、步数 0.806×、逐格中位 0.813×、128 对里 87 对更快。对这 128 对做自助重抽(`python3 bench/pool.py --ci 2000`),总比 95 % 区间是 **[0.734, 0.894]**(即省 11 %–27 %),逐格中位区间 [0.706, 0.885]——「约 19 %」是点估计、不是下限承诺;逐家族稳定性与符号检验见 [BENCHMARK.md](BENCHMARK.md) 的「合池的自助区间与逐任务稳定性」一节,那里也写了为什么逐家族数字不能跨块引用。
43
+ **打开小闪电,排头的一句结论:整任务总量约快 18 %,适合的形状快 15–25 %。** 这是把**所有**有 `off` 锚的干净块一起加总的结果(9 个块、132 对有效配对、10 个任务家族,`python3 bench/pool.py` 可复算):总墙钟 **0.825×**(省 18 %)、输出 token 0.822×、步数 0.809×、逐格中位 0.813×、132 对里 89 对更快。对这 132 对做自助重抽(`python3 bench/pool.py --ci 2000`),总比 95 % 区间是 **[0.747, 0.914]**(即省 9 %–25 %),逐格中位区间 [0.710, 0.883]——「约 18 %」是点估计、不是下限承诺;逐家族稳定性与符号检验见 [BENCHMARK.md](BENCHMARK.md) 的「合池的自助区间与逐任务稳定性」一节,那里也写了为什么逐家族数字不能跨块引用。
44
44
 
45
- **什么时候开、什么时候别开(按实测形状,不按任务名)。** 开:有多份产物、要反复跑同一份检查的中长任务——`codeimpl` 0.608×、`stategraph` 0.708×、`migrate` 0.748×、`shellpipeline` 0.749×,`shellops` 0.803×、`codebase` 0.808×、`ledger` 0.811× 也在同一侧。别开:1–5 步的小改修——`debugfix` 1.245×、`perfrefactor` 1.077×,提醒本身的注入成本收不回来。`csvpipe` 0.982× 打平,开不开都行。开关就是每会话的小闪电,默认记住上次选择,下一个请求生效。
45
+ **什么时候开、什么时候别开(按实测形状,不按任务名)。** 开:有多份产物、要反复跑同一份检查的中长任务——`codeimpl` 0.663×(15 对里 13 对更快,符号检验 p = 0.007)、`shellpipeline` 0.749×(p = 0.039),`stategraph` 0.708×、`shellops` 0.748×、`codebase` 0.808×、`migrate` 0.822× 也在同一侧但样本撑不起单列结论。别开:1–5 步的小改修与数据整形——`debugfix` 1.032×(输出反而长 1.29×)、`perfrefactor` 1.009×,提醒本身的注入成本收不回来。`ledger` 0.915×、`csvpipe` 0.982× 打平,开不开都行。开关就是每会话的小闪电,默认记住上次选择,下一个请求生效。
46
46
 
47
47
  **1.5.0 的默认不做步数收尾。** `finishLineSteps` 与 `stepBudget` 默认都是 `0`:没有“第 10 步提醒收尾”,也没有“30 步停止”。结束由任务状态和完成证据决定 —— 同一份产物上逐字重复一条已通过的检查,建议复用结果、把这一步留给剩下的工作;连续几步没有新结果,建议换方法(只是提醒,不结束回合);工具卡住,看门狗取消它并等它真正停下。任务做完就正常报告,提醒不会替你判停。
48
48
 
@@ -93,7 +93,7 @@ The cost is explicit: about 2,700 characters under `auto`, paid as input tokens
93
93
 
94
94
  ### 4. The stall guard: a ceiling on one call
95
95
 
96
- It hangs off the `tools/execute` waterfall and honours the only mutation that hook allows: it replaces `exec.signal`, races the call body against a timer, aborts on timeout, and hands the model a failure result instead of waiting forever. After the abort it waits for the cancelled body to actually settle (up to 5 s on a large budget, scaled down with the budget, floor 250 ms) and records `quiesced` on the observation, which says whether the cancellation drained cleanly or the tool kept running past the grace window. The caller's original signal is restored in `finally` either way.
96
+ It hangs off the `tools/execute` waterfall and honours the only mutation that hook allows: it replaces `exec.signal`, races the call body against a timer, aborts on timeout, and hands the model a failure result instead of waiting forever. After the abort it waits for the cancelled body to actually settle in two stages — the grace window first (up to 5 s on a large budget, scaled down with the budget, floor 250 ms), then a separate exit-confirmation window, 15 s in total — and records `quiesced`, `confirmed` and `waitedMs` on the observation. `confirmed: false` means the exit was **not observed**: the failure text does not claim the call was cancelled, it says the command may still be running, warns against touching files it may be writing, and asks for `pgrep -fl` / `ps -o pid,stat,command -p` before retrying. The caller's original signal is restored in `finally` either way.
97
97
 
98
98
  It only guards **shell and script** tool names (matching `bash|shell|sh|zsh|exec|pwsh|powershell|cmd|script|monitor|terminal`). Tools whose purpose is to wait are explicitly exempt: `ask_user_question`, `wait_agent`, `job_output`, `job_list`, `task_board_run`, `workflow`, `subagent`, `subagent_fork`, `run_code`.
99
99
 
package/docs/REFERENCE.md CHANGED
@@ -97,7 +97,7 @@
97
97
 
98
98
  ### 四、卡顿看门狗:单次调用的天花板
99
99
 
100
- 它挂在 `tools/execute` 这个 waterfall 上,遵守文档允许的唯一改法:只替换 `exec.signal`,让调用体与一个计时器赛跑,超时就 abort 并把失败结果交还给模型;取消后还会等被取消的调用体真的落地(预算够大时最多 5 秒,预算小时按预算缩放,下限 250 ms),并把 `quiesced` 记进观测——它说明这次取消是干净收尾,还是工具在宽限内没停住。无论结果如何,调用方原来的 signal 都会在 `finally` 里恢复。
100
+ 它挂在 `tools/execute` 这个 waterfall 上,遵守文档允许的唯一改法:只替换 `exec.signal`,让调用体与一个计时器赛跑,超时就 abort 并把失败结果交还给模型;取消后它会分两段等被取消的调用体真的落地(先等宽限,预算够大时最多 5 秒、预算小时按预算缩放且下限 250 ms,再等一段**退出确认**,两段合计上限 15 秒),并把 `quiesced` / `confirmed` / `waitedMs` 记进观测。`confirmed: false` 意味着**没看到进程真的退出**:此时失败文案不会说「已取消」,而是说命令可能仍在运行、先别动它可能写的文件、用 `pgrep -fl` / `ps -o pid,stat,command -p` 确认后再重试。无论结果如何,调用方原来的 signal 都会在 `finally` 里恢复。
101
101
 
102
102
  它只作用于 **shell / 脚本类**工具(名字匹配 `bash|shell|sh|zsh|exec|pwsh|powershell|cmd|script|monitor|terminal`)。问用户、等后台任务、等子代理这类"本来就要等"的工具被显式豁免:`ask_user_question`、`wait_agent`、`job_output`、`job_list`、`task_board_run`、`workflow`、`subagent`、`subagent_fork`、`run_code`。
103
103
 
package/index.js CHANGED
@@ -95,7 +95,7 @@ const TAG = '[fast-mode]'
95
95
  * Shipped identity of this build. Traced into the debug log so a stale install is
96
96
  * visible at a glance, and pinned by a test against `package.json`.
97
97
  */
98
- export const VERSION = '1.5.8'
98
+ export const VERSION = '1.5.10'
99
99
  const GLOBAL_AGENT = '__global__'
100
100
 
101
101
  /**
@@ -436,13 +436,14 @@ export function apply(ctx, rawConfig) {
436
436
  onStall: (info) => {
437
437
  stalls += 1
438
438
  ctx.logger?.warn?.(
439
- '%s stall guard cancelled %s after %d ms (budget %d ms, settled=%s)',
440
- TAG, info.name, info.elapsedMs, info.budgetMs, String(info.quiesced),
439
+ '%s stall guard cancelled %s after %d ms (budget %d ms, settled=%s confirmed=%s waited=%d ms)',
440
+ TAG, info.name, info.elapsedMs, info.budgetMs, String(info.quiesced), String(info.confirmed),
441
+ Number.isFinite(info.waitedMs) ? info.waitedMs : 0,
441
442
  )
442
443
  if (initial.debug) {
443
444
  appendDebug(
444
445
  initial.debugFile,
445
- `stall v${VERSION}: ${info.name} cancelled at ${info.elapsedMs}ms budget=${info.budgetMs}ms settled=${info.quiesced} total=${stalls}`,
446
+ `stall v${VERSION}: ${info.name} cancelled at ${info.elapsedMs}ms budget=${info.budgetMs}ms settled=${info.quiesced} confirmed=${info.confirmed} waited=${info.waitedMs}ms total=${stalls}`,
446
447
  )
447
448
  }
448
449
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-fast-mode",
3
- "version": "1.5.8",
3
+ "version": "1.5.10",
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",
package/src/auto.js CHANGED
@@ -34,6 +34,8 @@
34
34
  *
35
35
  * @module dsh-fast-mode/auto
36
36
  */
37
+
38
+ import { statSync } from 'node:fs'
37
39
  /** The gear id the adaptive scheduler runs under. */
38
40
  export const AUTO = 'auto'
39
41
 
@@ -117,7 +119,7 @@ export function noteEntry(state, agent, width) {
117
119
  *
118
120
  * @param {ReturnType<typeof createAutoState>} state
119
121
  * @param {string} agent
120
- * @param {{lateDiscipline?: boolean, stepBudget?: number, finishLineSteps?: number, outputBudget?: number, openBatch?: boolean, shapeGate?: boolean, fanoutWidth?: number, batchVerify?: boolean, preferPatch?: boolean}} policy
122
+ * @param {{lateDiscipline?: boolean, stepBudget?: number, finishLineSteps?: number, outputBudget?: number, openBatch?: boolean, shapeGate?: boolean, scheduler?: boolean, fanoutWidth?: number, batchVerify?: boolean, preferPatch?: boolean}} policy
121
123
  */
122
124
  export function notePolicy(state, agent, policy) {
123
125
  const entry = entryOf(state, agent)
@@ -132,6 +134,9 @@ export function notePolicy(state, agent, policy) {
132
134
  // Opt-in shape gate: when a turn's calls are mostly writes, the scheduler has
133
135
  // nothing to batch and every reminder is a round trip the task cannot use.
134
136
  entry.shapeGate = policy?.shapeGate === true
137
+ // Only `false` silences the directive channel; an absent field keeps the
138
+ // default, so every existing caller keeps its behaviour.
139
+ entry.scheduler = policy?.scheduler !== false
135
140
  // Opt-in: group the verification of one edit batch instead of one run per file.
136
141
  entry.batchVerify = policy?.batchVerify === true
137
142
  // Opt-in: after one large whole-file write, ask for hunks on existing files.
@@ -195,6 +200,16 @@ function entryOf(state, agent) {
195
200
  epoch: 0,
196
201
  /** @type {Map<string, {epoch: number, digest: string}>} */
197
202
  checks: new Map(),
203
+ /**
204
+ * Stat fingerprints of files this session wrote, captured when the write was
205
+ * observed and refreshed whenever a check passes. A stored pass is only
206
+ * offered for reuse while these still match on disk, which is the one piece
207
+ * of evidence the epoch counter cannot see: a write from outside the tool
208
+ * call stream.
209
+ *
210
+ * @type {Map<string, {mtimeMs: number, size: number}>}
211
+ */
212
+ writes: new Map(),
198
213
  /** Signature of a check that just repeated with a still-valid stored pass. */
199
214
  reuseHint: '',
200
215
  /** Epoch in which the reuse reminder last fired, so it fires once per epoch. */
@@ -221,6 +236,8 @@ function entryOf(state, agent) {
221
236
  openingSent: false,
222
237
  /** Opt-in: stay silent on a turn whose calls turned out to be writes. */
223
238
  shapeGate: false,
239
+ /** The directive channel of the scheduler; off = width and budget only. */
240
+ scheduler: true,
224
241
  /** Opt-in: ask for one covering check per edit batch, not one per file. */
225
242
  batchVerify: false,
226
243
  /** Opt-in: after a large whole-file write, ask for hunks instead. */
@@ -367,20 +384,21 @@ export function noteTool(state, agent, observation) {
367
384
  if (entry.waits.length > AUTO_LIMITS.waitWindow) entry.waits.shift()
368
385
  }
369
386
 
370
- // A mutation invalidates every stored check at once; the epoch is the only
371
- // bookkeeping the reuse reminder needs. A verbatim *repeated* check is the
372
- // exception: `npm test` is a shell call that looks like a writer, and without
373
- // this branch it would bump its own epoch on every run, so the one command
374
- // the reuse reminder exists for could never trigger it.
375
- const priorCheck = entry.checks.get(signature)
376
- const repeatedCheck = priorCheck !== undefined && priorCheck.epoch === entry.epoch
377
- if (isMutation(name, argsKey) && !(SHELL_CALLS.has(name) && repeatedCheck)) {
387
+ // A write, or a shell command the classifier cannot vouch for, invalidates
388
+ // every stored check at once; the epoch is the only bookkeeping the reuse
389
+ // reminder needs. A verification run is *not* a mutation even though its
390
+ // command often names a build tool (`npm test`, `make test`): bumping the
391
+ // epoch on the check itself is what used to make the one command this
392
+ // reminder targets unable to produce it.
393
+ const shellKind = SHELL_CALLS.has(name) ? classifyShell(argsKey) : undefined
394
+ if (MUTATING_CALLS.has(name) || shellKind === 'unknown') {
378
395
  entry.epoch += 1
379
396
  entry.checks.clear()
380
397
  entry.reuseHint = ''
381
398
  entry.writesSinceCheck += 1
382
399
  entry.batchVerifySent = false
383
400
  }
401
+ if (MUTATING_CALLS.has(name)) recordWritePaths(entry, argsKey)
384
402
 
385
403
  // A whole-file argument is the compressible half of output: the file lands on
386
404
  // disk either way, so the only thing a hunk form changes is how many tokens
@@ -399,15 +417,19 @@ export function noteTool(state, agent, observation) {
399
417
  entry.errors += 1
400
418
  if (previous) previous.errored = true
401
419
  else entry.seen.set(signature, { digest, chars, errored: true })
420
+ // A check that now fails is evidence against the stored pass: the artifact
421
+ // it covered is not proven current any more, even if nothing wrote since.
422
+ if (shellKind === 'verify') withdrawCheck(entry, signature)
402
423
  } else {
403
424
  if (!previous) progress = true
404
425
  else if (previous.errored) progress = true
405
426
  else if (previous.digest !== digest) progress = true
406
427
  entry.seen.set(signature, { digest, chars, errored: false })
407
428
  entry.errors = 0
408
- noteCheck(entry, name, argsKey, signature, digest)
409
- // A successful shell call is the only evidence available that a check ran.
410
- if (SHELL_CALLS.has(name)) {
429
+ noteCheck(entry, argsKey, signature, digest, shellKind)
430
+ // Only a verification run is evidence that the current files were checked;
431
+ // a read-only shell command says nothing about the edits still unverified.
432
+ if (shellKind === 'verify') {
411
433
  entry.writesSinceCheck = 0
412
434
  entry.batchVerifySent = false
413
435
  }
@@ -423,42 +445,249 @@ export function noteTool(state, agent, observation) {
423
445
  }
424
446
  }
425
447
 
426
- /** Calls whose whole purpose is to wait, so a repeat carries no reuse meaning. */
427
- const WAITING_CALLS = Object.freeze(new Set([
428
- 'ask_user_question', 'wait_agent', 'job_output', 'job_list', 'task_board_run',
429
- 'workflow', 'subagent', 'subagent_fork',
430
- ]))
448
+ /**
449
+ * Tokens that write. The argument string is all the scheduler has, so a false
450
+ * positive only costs one invalidation, while a false negative would let a stale
451
+ * pass look valid. This list is therefore a *disqualifier*, never a classifier:
452
+ * the command shapes that may be reused are the strict allowlists below.
453
+ */
454
+ const MUTATOR = /(?:>>?|\btee\b|\bcp\b|\bmv\b|\brm\b|\bmkdir\b|\btouch\b|\bchmod\b|\bln\b|\bpatch\b|\bdd\b|\bpip3?\b|\bsed\s+-i\b|\s-delete\b|\s-exec\b|\bgit\s+(?:clone|init|add|commit|checkout|switch|apply|reset|stash|merge|rebase|rm)\b)/
455
+
456
+ /** First tokens of commands that only read. Anything else is not read-only. */
457
+ const READONLY_COMMAND = /^(?:cat|ls|grep|rg|head|tail|wc|find|pwd|echo|stat|file|diff|du|df|which|sort|uniq|cut|tr|jq|git\s+(?:status|diff|log|show|branch|rev-parse|ls-files)|node\s+--version|npm\s+(?:ls|list|view|--version)|python3?\s+--version|go\s+(?:version|list)|cargo\s+(?:--version|metadata))\b/
458
+
459
+ /** Command shapes that are a verification run and nothing else. */
460
+ const VERIFY_COMMAND = /^(?:(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:test|check|verify|lint|typecheck|ci)\b|(?:npx\s+)?(?:jest|vitest|mocha|tsc|eslint|ruff|mypy|pytest|py\.test)\b|python3?\s+-m\s+(?:unittest|pytest|mypy|ruff|compileall)\b|python3?\s+pytest\b|node\s+--test\b|(?:go|cargo)\s+test\b|make\s+(?:test|check|verify|lint)\b|tox\b|ctest\b)/
461
+
462
+ /** Argument fields a shell call keeps its command in. */
463
+ const COMMAND_FIELDS = ['command', 'cmd', 'script', 'shell']
464
+
465
+ /** Argument fields a write call keeps its target paths in. */
466
+ const PATH_FIELDS = ['path', 'file_path', 'filePath', 'target', 'file', 'filename']
467
+
468
+ /** How many written files stay tracked for the fingerprint recheck. */
469
+ const WRITE_WINDOW = 64
470
+
471
+ const MUTATING_CALLS = Object.freeze(new Set(['write', 'write_file', 'create_file', 'edit', 'multi_edit', 'apply_patch', 'patch']))
472
+ const SHELL_CALLS = Object.freeze(new Set(['bash', 'shell', 'terminal', 'run_code', 'exec']))
473
+
474
+ /** @param {string} key @returns {unknown} */
475
+ function parseArgs(key) {
476
+ try {
477
+ return JSON.parse(key)
478
+ } catch {
479
+ return undefined
480
+ }
481
+ }
482
+
483
+ /** @param {unknown} value @returns {string} */
484
+ function commandText(value) {
485
+ if (typeof value === 'string') return value
486
+ if (Array.isArray(value)) return value.filter(part => typeof part === 'string').join(' ')
487
+ return ''
488
+ }
489
+
490
+ /**
491
+ * The command text of a shell call.
492
+ *
493
+ * `argsKey` is normally the canonical JSON of the call arguments. A key that was
494
+ * truncated before the command field, or one that is not JSON at all, degrades
495
+ * to a scan of the escaped text and finally to `''`, which the classifier reads
496
+ * as unknown — the conservative direction.
497
+ *
498
+ * @param {string} argsKey
499
+ * @returns {string}
500
+ */
501
+ function commandOf(argsKey) {
502
+ const key = typeof argsKey === 'string' ? argsKey : ''
503
+ if (key.length === 0 || key === '<unserialisable>') return ''
504
+ const parsed = parseArgs(key)
505
+ if (typeof parsed === 'string') return parsed
506
+ if (parsed && typeof parsed === 'object') {
507
+ for (const field of COMMAND_FIELDS) {
508
+ const value = commandText(parsed[field])
509
+ if (value.length > 0) return value
510
+ }
511
+ return ''
512
+ }
513
+ const match = /"(?:command|cmd|script)"\s*:\s*("(?:[^"\\]|\\.)*")/.exec(key)
514
+ if (match) {
515
+ const value = parseArgs(match[1])
516
+ if (typeof value === 'string' && value.length > 0) return value
517
+ }
518
+ // Not JSON: callers without a canonical key still pass the command itself.
519
+ return key.startsWith('{') || key.startsWith('[') ? '' : key
520
+ }
521
+
522
+ /**
523
+ * Split a command line into the segments that run on their own, so `cat a && rm
524
+ * b` cannot pass as a read.
525
+ *
526
+ * @param {string} command
527
+ * @returns {string[]}
528
+ */
529
+ function commandSegments(command) {
530
+ return command
531
+ .split(/\r?\n|&&|\|\||;|\|/)
532
+ .map(segment => segment.trim())
533
+ .filter(segment => segment.length > 0)
534
+ }
535
+
536
+ /** @param {string} segment @returns {'readonly'|'verify'|'unknown'} */
537
+ function segmentKind(segment) {
538
+ // A redirection can create or truncate a file, so the segment is not a pure
539
+ // read or a pure check.
540
+ if (/[<>]/.test(segment)) return 'unknown'
541
+ if (VERIFY_COMMAND.test(segment)) return 'verify'
542
+ if (MUTATOR.test(segment)) return 'unknown'
543
+ if (READONLY_COMMAND.test(segment)) return 'readonly'
544
+ return 'unknown'
545
+ }
546
+
547
+ /**
548
+ * Classify a successful shell call by what its command can do to the files:
549
+ * `readonly` (no effect), `verify` (a check, which may build but does not edit
550
+ * the artifacts it covers), or `unknown`, which is treated exactly like a write.
551
+ *
552
+ * @param {string} argsKey
553
+ * @returns {'readonly'|'verify'|'unknown'}
554
+ */
555
+ function classifyShell(argsKey) {
556
+ const command = commandOf(argsKey)
557
+ if (command.length === 0) return 'unknown'
558
+ const kinds = commandSegments(command).map(segmentKind)
559
+ if (kinds.length === 0) return 'unknown'
560
+ if (kinds.some(kind => kind === 'unknown')) return 'unknown'
561
+ return kinds.includes('verify') ? 'verify' : 'readonly'
562
+ }
431
563
 
432
564
  /**
433
- * Shell commands that write. The argument string is all the scheduler has, so
434
- * this is a conservative read: anything that looks like a redirection or one of
435
- * the common mutators bumps the epoch, and a false positive only costs one
436
- * unnecessary reminder — a false negative would let a stale check look valid.
565
+ * Files a write call names.
566
+ *
567
+ * Best effort: the canonical key bounds its length, so a large whole-file write
568
+ * can lose its path before the cut. Such a write still bumps the epoch; the
569
+ * fingerprints only add the ability to notice a later change that arrived
570
+ * outside the tool call stream.
571
+ *
572
+ * @param {string} argsKey
573
+ * @returns {string[]}
574
+ */
575
+ function writePathsOf(argsKey) {
576
+ const key = typeof argsKey === 'string' ? argsKey : ''
577
+ if (key.length === 0) return []
578
+ const paths = []
579
+ const parsed = parseArgs(key)
580
+ if (typeof parsed === 'string') paths.push(parsed)
581
+ else if (parsed && typeof parsed === 'object') {
582
+ for (const field of PATH_FIELDS) {
583
+ const value = parsed[field]
584
+ if (typeof value === 'string') paths.push(value)
585
+ else if (Array.isArray(value)) paths.push(...value.filter(part => typeof part === 'string'))
586
+ }
587
+ } else {
588
+ const field = /"(?:path|file_path|filePath|target|file|filename)"\s*:\s*("(?:[^"\\]|\\.)*")/g
589
+ let match = field.exec(key)
590
+ while (match) {
591
+ const value = parseArgs(match[1])
592
+ if (typeof value === 'string') paths.push(value)
593
+ match = field.exec(key)
594
+ }
595
+ if (paths.length === 0 && !key.startsWith('{') && !key.startsWith('[')) paths.push(key)
596
+ }
597
+ return paths.filter(path => path.length > 0 && path.length <= 4096)
598
+ }
599
+
600
+ /**
601
+ * A stat fingerprint, or `undefined` when the path is not visible from this
602
+ * process. Only visible targets are tracked; the epoch remains the evidence for
603
+ * everything else.
604
+ *
605
+ * @param {string} path
606
+ * @returns {{mtimeMs: number, size: number} | undefined}
607
+ */
608
+ function fingerprintOf(path) {
609
+ try {
610
+ const stat = statSync(path)
611
+ if (!stat.isFile()) return undefined
612
+ return { mtimeMs: stat.mtimeMs, size: stat.size }
613
+ } catch {
614
+ return undefined
615
+ }
616
+ }
617
+
618
+ /** @param {Map<string, {mtimeMs: number, size: number}>} writes @param {string} path */
619
+ function trackPath(writes, path) {
620
+ const print = fingerprintOf(path)
621
+ if (!print) return
622
+ writes.delete(path)
623
+ writes.set(path, print)
624
+ while (writes.size > WRITE_WINDOW) {
625
+ const oldest = writes.keys().next().value
626
+ if (oldest === undefined) break
627
+ writes.delete(oldest)
628
+ }
629
+ }
630
+
631
+ /** @param {object} entry @param {string} argsKey */
632
+ function recordWritePaths(entry, argsKey) {
633
+ for (const path of writePathsOf(argsKey)) trackPath(entry.writes, path)
634
+ }
635
+
636
+ /**
637
+ * Re-baseline the tracked files at the moment a check passes, so the finger-
638
+ * prints describe the artifact that pass proved rather than the write that
639
+ * preceded it.
640
+ *
641
+ * @param {object} entry
437
642
  */
438
- const MUTATOR = /(?:>>?|\btee\b|\bcp\b|\bmv\b|\brm\b|\bmkdir\b|\btouch\b|\bchmod\b|\bln\b|\bpatch\b|\bdd\b|\bpip3?\b|\bnpm\b|\bpnpm\b|\byarn\b|\bmake\b|\bsed\s+-i\b|\bgit\s+(?:clone|init|add|commit|checkout|switch|apply|reset|stash|merge|rebase|rm)\b)/
643
+ function refreshWritePaths(entry) {
644
+ for (const path of [...entry.writes.keys()]) trackPath(entry.writes, path)
645
+ }
439
646
 
440
- /** @param {string} name @param {string} argsKey */
441
- function isMutation(name, argsKey) {
442
- if (MUTATING_CALLS.has(name)) return true
443
- if (SHELL_CALLS.has(name)) return MUTATOR.test(argsKey)
647
+ /**
648
+ * True when a file this session wrote no longer matches the fingerprint of the
649
+ * pass being offered for reuse. A stat that fails counts as changed: a pass is
650
+ * never offered on evidence that could not be re-read.
651
+ *
652
+ * @param {Map<string, {mtimeMs: number, size: number}>} writes
653
+ * @returns {boolean}
654
+ */
655
+ function artifactsChanged(writes) {
656
+ for (const [path, print] of writes) {
657
+ const now = fingerprintOf(path)
658
+ if (!now || now.mtimeMs !== print.mtimeMs || now.size !== print.size) return true
659
+ }
444
660
  return false
445
661
  }
446
662
 
447
- const MUTATING_CALLS = Object.freeze(new Set(['write', 'edit', 'apply_patch', 'patch']))
448
- const SHELL_CALLS = Object.freeze(new Set(['bash', 'shell', 'terminal', 'run_code', 'exec']))
663
+ /**
664
+ * Drop a stored pass that a failing run just contradicted.
665
+ *
666
+ * @param {object} entry
667
+ * @param {string} signature
668
+ */
669
+ function withdrawCheck(entry, signature) {
670
+ entry.checks.delete(signature)
671
+ if (entry.reuseHint === signature) entry.reuseHint = ''
672
+ }
449
673
 
450
674
  /**
451
- * Store a successful call as a reusable result, and raise the hint when it is a
452
- * verbatim repeat of one that is still valid for the current epoch.
675
+ * Store a passing verification as a reusable result, and raise the hint when it
676
+ * is a verbatim repeat of one that is still valid for the current epoch.
677
+ *
678
+ * Only a recognised verification run is stored. A repeated read or grep is not
679
+ * a check: offering to "reuse" its output would be a claim about an artifact
680
+ * that nothing verified.
453
681
  *
454
682
  * @param {object} entry
455
- * @param {string} name
456
683
  * @param {string} argsKey
457
684
  * @param {string} signature
458
685
  * @param {string} digest
686
+ * @param {'readonly'|'verify'|'unknown'|undefined} shellKind
459
687
  */
460
- function noteCheck(entry, name, argsKey, signature, digest) {
461
- if (argsKey.length === 0 || WAITING_CALLS.has(name)) return
688
+ function noteCheck(entry, argsKey, signature, digest, shellKind) {
689
+ if (shellKind !== 'verify' || argsKey.length === 0) return
690
+ refreshWritePaths(entry)
462
691
  const stored = entry.checks.get(signature)
463
692
  if (stored && stored.epoch === entry.epoch && stored.digest === digest && entry.reuseHint === '') {
464
693
  entry.reuseHint = signature
@@ -586,6 +815,11 @@ function widthCap(entry) {
586
815
  * @returns {{kind: string, text: string, summary: string}|undefined}
587
816
  */
588
817
  function directiveFor(entry, stats) {
818
+ // The scheduler's directive half is a separate channel from its width and its
819
+ // watchdog budget: switching it off keeps every time saving the scheduler
820
+ // makes and removes only the copy, which is exactly the arm a clean ablation
821
+ // needs to tell "fewer round trips" apart from "fewer words per step".
822
+ if (!entry.scheduler) return undefined
589
823
  // Shape gate: a turn that has already spent most of its calls on writes is
590
824
  // not a turn the scheduler can accelerate — there is nothing to batch, and
591
825
  // every reminder costs a round trip the work cannot reuse. Silence is the
@@ -717,19 +951,29 @@ function directiveFor(entry, stats) {
717
951
  }
718
952
  if (entry.reuseHint !== '' && entry.reuseSentEpoch !== entry.epoch) {
719
953
  // The narrowest, most defensible kind of saving: the same check, verbatim,
720
- // already passed on this exact artifact version. Anything that wrote since
721
- // then bumped the epoch and this branch is unreachable, which is what makes
722
- // the reminder safe for a task that edits code after a green test.
723
- entry.reuseHint = ''
724
- entry.reuseSentEpoch = entry.epoch
725
- entry.lastDirectiveStep = entry.steps
726
- entry.lastDirectiveKind = 'reuse-check'
727
- return {
728
- kind: 'reuse-check',
729
- text: 'Auto schedule: that check already passed on the current files and nothing relevant changed '
730
- + 'since. Use the result you already have and spend this step on the remaining work; run it again only '
731
- + 'after the files it covers change.',
732
- summary: 'auto: reuse a passing check',
954
+ // already passed and neither a write nor an unrecognised shell command ran
955
+ // since. Both of those bump the epoch, so reaching this branch already means
956
+ // the tool call stream saw nothing that could have changed the result. The
957
+ // files it wrote are re-read here because that stream cannot see a change
958
+ // that arrived some other way; a stale fingerprint withdraws the pass.
959
+ if (artifactsChanged(entry.writes)) {
960
+ entry.epoch += 1
961
+ entry.checks.clear()
962
+ entry.reuseHint = ''
963
+ entry.writesSinceCheck += 1
964
+ entry.batchVerifySent = false
965
+ } else {
966
+ entry.reuseHint = ''
967
+ entry.reuseSentEpoch = entry.epoch
968
+ entry.lastDirectiveStep = entry.steps
969
+ entry.lastDirectiveKind = 'reuse-check'
970
+ return {
971
+ kind: 'reuse-check',
972
+ text: 'Auto schedule: that exact check already passed verbatim this turn, and no write or '
973
+ + 'unrecognised shell command has run since. Use the result you already have and spend this step on '
974
+ + 'the remaining work; run the check again only after the files it covers change.',
975
+ summary: 'auto: reuse a passing check',
976
+ }
733
977
  }
734
978
  }
735
979
  if (stats.noProgress >= AUTO_LIMITS.noProgressWarn) {
package/src/config.js CHANGED
@@ -112,6 +112,12 @@ export const Config = z.object({
112
112
  ),
113
113
 
114
114
  shapeGate: z.boolean().default(false),
115
+ scheduler: z
116
+ .boolean()
117
+ .default(true)
118
+ .description(
119
+ '调度器提醒 / Scheduler directives: 关闭时只保留宽度与看门狗预算,一条提醒都不发(输出克制纪律不受影响)。这是一个对照开关,用来把「并行宽度 + 静默调度」和「提醒文案本身」的收益分开量;默认开启。',
120
+ ),
115
121
  outputBudget: z
116
122
  .number()
117
123
  .step(1)
package/src/effort.js CHANGED
@@ -234,6 +234,22 @@ export function resolveConfig(raw, overrides) {
234
234
  stepBudget: gear.directive ? Math.max(0, Math.floor(number(config.stepBudget, 0))) : 0,
235
235
  /** One "finish the batch, then run one covering check" reminder per edit batch. */
236
236
  batchVerify: gear.directive && config.batchVerify === true,
237
+ /**
238
+ * Stop asking for parallel work once this turn is clearly write-heavy. The
239
+ * gate reads the turn's own calls, not the task family, so it needs no
240
+ * classifier; opt-in because it only ever removes a saving.
241
+ */
242
+ shapeGate: gear.directive && config.shapeGate === true,
243
+ /**
244
+ * The directive half of the scheduler, switchable on its own. Width and the
245
+ * watchdog budget are untouched by it, so `scheduler: false` measures
246
+ * "parallel fan-out and tighter timeouts, but no reminder copy"; without this
247
+ * knob output-discipline and scheduling cannot be separated, because the
248
+ * discipline channel is itself gated on `gear.directive`.
249
+ */
250
+ scheduler: gear.directive && config.scheduler !== false,
251
+ /** Optional ceiling on decoded output per turn, from upstream usage; 0 = off. */
252
+ outputBudget: gear.directive ? Math.max(0, Math.floor(number(config.outputBudget, 0))) : 0,
237
253
  /** One "batch the opening step" reminder per turn, for short tasks. */
238
254
  openBatch: gear.directive && config.openBatch === true,
239
255
  /** One "send hunks, not whole files" reminder after a large whole-file write. */
package/src/stallguard.js CHANGED
@@ -56,6 +56,26 @@ export const STALL_BUDGET = Object.freeze({
56
56
  /** Ceiling used when a gear has no entry (an unknown gear must still be bounded). */
57
57
  export const DEFAULT_STALL_BUDGET = 300_000
58
58
 
59
+ /**
60
+ * Ceiling on the *total* time the guard may spend confirming that a cancelled
61
+ * body has really stopped, in milliseconds.
62
+ *
63
+ * The first window is the existing settle window (5 s on a real ceiling, scaled
64
+ * down with the budget). If the body is still alive after that, the guard waits
65
+ * one more confirmation window before it reports "exit not confirmed". Fifteen
66
+ * seconds is the bound: long enough for a wrapper that needs SIGTERM then
67
+ * SIGKILL to reap its child, short enough that a process which ignores its
68
+ * signal costs one step rather than the whole turn.
69
+ */
70
+ export const STALL_CONFIRM_TOTAL_MS = 15_000
71
+
72
+ /**
73
+ * The second (confirmation) window. It is capped so that, for a default settle
74
+ * window (at most 5 s), settle + confirm never exceeds
75
+ * `STALL_CONFIRM_TOTAL_MS`.
76
+ */
77
+ export const STALL_CONFIRM_WINDOW_MS = 10_000
78
+
59
79
  /**
60
80
  * Tools whose whole purpose is to wait for a human, a job, or another agent.
61
81
  * Aborting them would break the feature that asked for the wait, so the name
@@ -113,18 +133,29 @@ export function stallBudgetFor(config) {
113
133
  return Number.isFinite(fromGear) ? fromGear : DEFAULT_STALL_BUDGET
114
134
  }
115
135
 
116
- /** @param {number} ms */
117
- function delay(ms) {
118
- // Kept referenced on purpose: node:test on Node 20/22 reports "Promise
119
- // resolution is still pending but the event loop has already resolved" and
120
- // cancels the test when the only live handle is an unref'd timer. The timer
121
- // always fires, so this cannot hold a finished turn's process open.
122
- return new Promise((resolve) => setTimeout(resolve, ms))
136
+ /**
137
+ * Derive the second window from the time already spent on the first one.
138
+ *
139
+ * Exported as a pure function so the bound can be asserted without sleeping:
140
+ * for the default settle window (250 ms–5 s) the result is 10 s, which keeps
141
+ * the total at 10.25 s–15 s.
142
+ *
143
+ * @param {number} settleMs - the first window that was already used.
144
+ * @returns {number} milliseconds; `0` when the total ceiling is spent.
145
+ */
146
+ export function confirmWindowFor(settleMs) {
147
+ const spent = Number.isFinite(settleMs) ? Math.max(0, settleMs) : 0
148
+ return Math.max(0, Math.min(STALL_CONFIRM_WINDOW_MS, STALL_CONFIRM_TOTAL_MS - spent))
123
149
  }
124
150
 
125
151
  /** Sentinel that cannot collide with a tool result (which is always an object). */
126
152
  const STALLED = Symbol('fast-mode:stalled')
127
153
 
154
+ /** Outcome of one post-timeout wait window. */
155
+ const SETTLED = Symbol('fast-mode:settled')
156
+ const STILL_RUNNING = Symbol('fast-mode:still-running')
157
+ const CALLER_ABORTED = Symbol('fast-mode:caller-aborted')
158
+
128
159
  /**
129
160
  * Build the failure the caller receives when the guard fires.
130
161
  *
@@ -132,24 +163,50 @@ const STALLED = Symbol('fast-mode:stalled')
132
163
  * cancelled and what to change, because an aborted call with no explanation is
133
164
  * how a model ends up retrying the identical command.
134
165
  *
135
- * @param {{name?: string, budgetMs: number, elapsedMs?: number}} info
166
+ * The two wordings are deliberately different. When the body was seen to settle
167
+ * the next step is safe to run; when it was not, the guard says so plainly and
168
+ * tells the model to verify the process is gone before touching anything. A
169
+ * "cancelled, retry with a change" result on a body that is still writing a file
170
+ * is how two executions end up racing on the same directory.
171
+ *
172
+ * @param {{name?: string, budgetMs: number, elapsedMs?: number, confirmed?: boolean}} info
136
173
  * @returns {object} a `ToolExecutionFailure`.
137
174
  */
138
175
  export function stallFailure(info) {
139
176
  const name = typeof info?.name === 'string' && info.name.length > 0 ? info.name : 'the tool'
140
177
  const budgetMs = Number.isFinite(info?.budgetMs) ? info.budgetMs : DEFAULT_STALL_BUDGET
141
178
  const seconds = Math.max(1, Math.round(budgetMs / 1000))
142
- const text = [
143
- `fast-mode stall guard: \`${name}\` was still running after ${seconds}s and was cancelled.`,
144
- 'The turn is not waiting on it any more. Before retrying, change something:',
179
+ // Unknown means unconfirmed: never claim a cancellation was clean without
180
+ // having observed the body settle.
181
+ const confirmed = info?.confirmed === true
182
+ const changeSomething = [
145
183
  '- a server, watcher, tail, or REPL belongs in the background (`run_in_background: true`), not in the foreground;',
146
184
  '- a long or quoted/multi-line command belongs in a file — write it, then call the shell with `script_path`;',
147
185
  '- a narrow probe (one directory, one pattern, `--max-count`, a head/tail) beats a full sweep.',
148
186
  'Never re-issue the same command unchanged: it will be cancelled again.',
149
- ].join('\n')
187
+ ]
188
+ const text = confirmed
189
+ ? [
190
+ `fast-mode stall guard: \`${name}\` was still running after ${seconds}s and was cancelled.`,
191
+ 'Its process is confirmed gone: the next step may touch the files it wrote.',
192
+ 'The turn is not waiting on it any more. Before retrying, change something:',
193
+ ...changeSomething,
194
+ ].join('\n')
195
+ : [
196
+ `fast-mode stall guard: \`${name}\` was still running after ${seconds}s and was cancelled.`,
197
+ 'Its exit is NOT confirmed: the process may still be running or finishing, and it may still write files.',
198
+ 'Do not edit, move, or delete anything it may have touched until it is gone.',
199
+ 'Confirm it exited first, e.g. `pgrep -fl <pattern>` or `ps -o pid,stat,command -p <pid>`, then check the files it was writing.',
200
+ 'Once it is gone, retry with a change:',
201
+ ...changeSomething,
202
+ ].join('\n')
150
203
  return {
151
204
  isError: true,
152
- error: { message: `fast-mode stall guard cancelled ${name} after ${seconds}s` },
205
+ error: {
206
+ message: confirmed
207
+ ? `fast-mode stall guard cancelled ${name} after ${seconds}s`
208
+ : `fast-mode stall guard cancelled ${name} after ${seconds}s (exit not confirmed)`,
209
+ },
153
210
  content: [{ type: 'text', text }],
154
211
  }
155
212
  }
@@ -164,25 +221,36 @@ export function stallFailure(info) {
164
221
  * @param {(exec: object) => number} [options.budgetFor] - ceiling for one call.
165
222
  * @param {(info: object) => void} [options.onStall] - observation hook.
166
223
  * @param {() => number} [options.now] - clock.
167
- * @param {(budgetMs: number) => number} [options.settleFor] - how long to wait
168
- * for the aborted body to actually settle, given its budget. A cancelled
169
- * dispatch must not return a result while the body is still writing: the host
170
- * hands the caller a settled call, and a body that finishes 400 ms later then
171
- * races the next step.
172
- * @param {number} [options.graceMs] - fixed settle window, overriding
173
- * `settleFor` (tests use it).
224
+ * @param {(budgetMs: number) => number} [options.settleFor] - first window: how
225
+ * long to wait for the aborted body to actually settle, given its budget. A
226
+ * cancelled dispatch must not return a result while the body is still
227
+ * writing: the host hands the caller a settled call, and a body that finishes
228
+ * 400 ms later then races the next step.
229
+ * @param {(settleMs: number) => number} [options.confirmFor] - second window,
230
+ * used only when the first one elapsed without the body settling. Defaults to
231
+ * `confirmWindowFor`, which keeps settle + confirm under
232
+ * `STALL_CONFIRM_TOTAL_MS`.
233
+ * @param {number} [options.graceMs] - fixed window for both tiers, overriding
234
+ * `settleFor`/`confirmFor` (tests use it).
174
235
  */
175
236
  export function createStallGuard(options = {}) {
176
237
  const budgetFor = typeof options.budgetFor === 'function' ? options.budgetFor : () => 0
177
238
  const onStall = typeof options.onStall === 'function' ? options.onStall : () => {}
178
239
  const now = typeof options.now === 'function' ? options.now : Date.now
179
- // The unwind window: five seconds on a real ceiling (90–300 s), scaled down
240
+ // The first window: five seconds on a real ceiling (90–300 s), scaled down
180
241
  // for a test-sized budget so a unit test does not sleep through the suite.
181
242
  const settleFor = Number.isFinite(options.graceMs)
182
243
  ? () => options.graceMs
183
244
  : typeof options.settleFor === 'function'
184
245
  ? options.settleFor
185
246
  : (budgetMs) => Math.max(250, Math.min(5_000, budgetMs))
247
+ // The second window: only reached by a body that ignored the abort. It gets a
248
+ // full SIGTERM-then-SIGKILL grace instead of the first window's short one.
249
+ const confirmFor = Number.isFinite(options.graceMs)
250
+ ? () => options.graceMs
251
+ : typeof options.confirmFor === 'function'
252
+ ? options.confirmFor
253
+ : confirmWindowFor
186
254
 
187
255
  /**
188
256
  * @param {object} exec - `ToolDispatchExecution`.
@@ -233,26 +301,90 @@ export function createStallGuard(options = {}) {
233
301
  // while the body is still running hands the harness two live executions
234
302
  // for one call: the model sees "cancelled", and the cancelled command
235
303
  // writes or deletes something after the next step has started. The wait is
236
- // bounded so a body that ignores its signal cannot hang the turn, and
237
- // `quiesced` records whether the wait was enough.
304
+ // tiered so a slow-but-alive wrapper gets a real grace period:
305
+ //
306
+ // tier 1 - the existing settle window (250 ms–5 s on a default budget);
307
+ // tier 2 - a confirmation window, only if tier 1 elapsed without the
308
+ // body settling (10 s by default, so the total stays ≤ 15 s).
309
+ //
310
+ // A body that ignores its signal cannot hang the turn, and the result
311
+ // separates "confirmed gone" from "not confirmed" instead of reporting a
312
+ // cancellation that may still be writing.
238
313
  try {
239
314
  controller.abort(new Error(`fast-mode: ${String(exec.name)} exceeded ${budgetMs} ms`))
240
315
  } catch {
241
316
  // Already aborted by the caller in the same tick.
242
317
  }
243
- const settledInTime = await Promise.race([
244
- body.then(() => true, () => true),
245
- delay(settleFor(budgetMs)).then(() => false),
246
- ])
247
- const quiesced = settledInTime === true
318
+ const rawSettle = settleFor(budgetMs)
319
+ const settleMs = Number.isFinite(rawSettle) ? Math.max(0, rawSettle) : 0
320
+ const rawConfirm = confirmFor(settleMs)
321
+ const confirmMs = Number.isFinite(rawConfirm) ? Math.max(0, rawConfirm) : 0
322
+ const waitedFrom = now()
323
+
324
+ // A caller abort during either window ends the wait immediately: the host
325
+ // is already unwinding the turn, so extra confirmation would only delay it.
326
+ let abortedByCaller = caller.aborted === true
327
+ let onCallerAbort
328
+ const callerAbort = new Promise((resolve) => {
329
+ if (abortedByCaller) {
330
+ resolve(CALLER_ABORTED)
331
+ return
332
+ }
333
+ onCallerAbort = () => {
334
+ abortedByCaller = true
335
+ resolve(CALLER_ABORTED)
336
+ }
337
+ caller.addEventListener('abort', onCallerAbort, { once: true })
338
+ })
339
+ const bodySettled = body.then(() => SETTLED, () => SETTLED)
340
+ /**
341
+ * Race the body against one window and the caller's cancellation.
342
+ *
343
+ * The window timer is always cleared, so a body that settles early (or a
344
+ * caller that aborts) does not leave a live handle behind for the rest of
345
+ * the grace period.
346
+ *
347
+ * @param {number} ms - window length; `0` skips the timer.
348
+ * @returns {Promise<symbol>} `SETTLED`, `STILL_RUNNING` or `CALLER_ABORTED`.
349
+ */
350
+ const waitWindow = (ms) => {
351
+ if (!(ms > 0)) {
352
+ return Promise.race([bodySettled, Promise.resolve(STILL_RUNNING)]).then(
353
+ (outcome) => (outcome === STILL_RUNNING && abortedByCaller ? CALLER_ABORTED : outcome),
354
+ )
355
+ }
356
+ let windowTimer
357
+ const windowElapsed = new Promise((resolve) => {
358
+ windowTimer = setTimeout(() => resolve(abortedByCaller ? CALLER_ABORTED : STILL_RUNNING), ms)
359
+ })
360
+ return Promise.race([bodySettled, windowElapsed, callerAbort])
361
+ .finally(() => clearTimeout(windowTimer))
362
+ }
363
+
364
+ let outcome = await waitWindow(settleMs)
365
+ if (outcome === STILL_RUNNING) outcome = await waitWindow(confirmMs)
366
+ const quiesced = outcome === SETTLED
367
+ const confirmed = quiesced
368
+ const waitedMs = Math.max(0, now() - waitedFrom)
248
369
 
249
370
  const elapsedMs = Math.max(0, now() - began)
250
371
  try {
251
- onStall({ name: exec.name, budgetMs, elapsedMs, timedOut, quiesced })
372
+ onStall({
373
+ name: exec.name,
374
+ budgetMs,
375
+ elapsedMs,
376
+ waitedMs,
377
+ settleMs,
378
+ confirmMs,
379
+ timedOut,
380
+ quiesced,
381
+ confirmed,
382
+ callerAborted: abortedByCaller,
383
+ })
252
384
  } catch {
253
385
  // Observation is best-effort; it must never change the result.
254
386
  }
255
- return stallFailure({ name: exec.name, budgetMs, elapsedMs })
387
+ return stallFailure({ name: exec.name, budgetMs, elapsedMs, confirmed })
256
388
  } finally {
257
389
  if (timer !== undefined) clearTimeout(timer)
258
390
  try {
@@ -260,6 +392,11 @@ export function createStallGuard(options = {}) {
260
392
  } catch {
261
393
  // A signal implementation without removeEventListener needs no cleanup.
262
394
  }
395
+ try {
396
+ if (onCallerAbort) caller.removeEventListener('abort', onCallerAbort)
397
+ } catch {
398
+ // Same here: without removeEventListener there is nothing to undo.
399
+ }
263
400
  try {
264
401
  exec.signal = caller
265
402
  } catch {