dsh-fast-mode 1.5.9 → 1.5.11
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 +89 -1
- package/CHANGELOG.md +9 -0
- package/docs/REFERENCE.en.md +1 -1
- package/docs/REFERENCE.md +1 -1
- package/index.js +5 -4
- package/package.json +5 -2
- package/src/auto.js +290 -46
- package/src/config.js +6 -0
- package/src/effort.js +16 -0
- package/src/stallguard.js +167 -30
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
|
|
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 | × | 总步数 | × |
|
|
@@ -1458,6 +1462,8 @@ node bin/experiment.mjs stop # end it; later sessions g
|
|
|
1458
1462
|
|
|
1459
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
|
|
|
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× 不受影响。
|
|
1466
|
+
|
|
1461
1467
|
| 块 | n | 墙钟总比 | 更快 |
|
|
1462
1468
|
|---|---|---|---|
|
|
1463
1469
|
| `W1` | 11 | 0.710× | 7/11 |
|
|
@@ -1505,3 +1511,85 @@ node bin/experiment.mjs stop # end it; later sessions g
|
|
|
1505
1511
|
读法:`debugfix` 在负载下基本持平(三条全在 1.0 上下),`perfrefactor` 两条都慢 12–29 %,`csvpipe` 两条方向完全相反(一条跑飞成 3.3×、一条 0.27×)——后者说明这个家族的方差大过机制本身,任何单点数字都不能引用。**结论与混池一致:`debugfix`/`perfrefactor`/`csvpipe` 默认不承诺收益,指引写「先试一次」。**
|
|
1506
1512
|
|
|
1507
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` 只作方向参考。
|
|
1583
|
+
|
|
1584
|
+
## 负载前提改了:从「等安静」改成「同块交错 + 记录负载」(2026-10-27,方法学变更)
|
|
1585
|
+
|
|
1586
|
+
连续 4 小时以上等不到 `load1 < 6.5`。查了是谁在占 CPU:不是外部的抓取任务,而是**这台机器现在的常态**——DSH 自己的两个渲染进程(70 % + 25 %)、SkyLight(71 %)、WebKit、一个虚拟机。也就是说 `load1` 8–10 是本机 GUI 会话的基线,原来的「安静窗口」前提在这台机器上永远不会满足,继续等等于把实验停摆。
|
|
1587
|
+
|
|
1588
|
+
新的前提(写下来是为了以后不被人拿绝对墙钟数字做文章):
|
|
1589
|
+
|
|
1590
|
+
1. **成块门槛从「安静」改成「不病态」**:`load1 < 12` 即开跑;只有 `load1 ≥ 14 连续 6 分钟`(真异常)或「24 分钟没有新增结果行」(真挂死)才中止。
|
|
1591
|
+
2. **抗污染靠设计而不是靠等**:`suite-run.py` 本来就是每对交错跑臂(ABBA),同块内 `off` 与 `def15`(或 `gateonly`/`bud8`)吃同一段负载。**对外报的量是同块比值,负载在时间上的整体缩放对比值近似一阶抵消**;受影响的只有绝对墙钟,而这些块的绝对墙钟一律不许跨块比较、也不许当「本机单任务耗时」引用。
|
|
1592
|
+
3. **每个块把自己的负载样本落盘**:`bench/results/suite-<TAG>.load.json`(`samples/mean/max`),分析时必须把块所属负载区间一并写出,例如「NX1_IMPL 在 mean 9.8 下测得」。
|
|
1593
|
+
4. **方差预期**:负载高时单格方差变大,所以结论继续用「同块总和比 + 逐对中位数 + 符号检验」,且**每家族 ≥ 2 个同块有效对**才算判定;只有 1 对的行一律写「未判定」。
|
|
1594
|
+
|
|
1595
|
+
这一条替换掉此前「`load` 持续 ≥ 8 就整块作废」的规则(那版规则在 20:44–20:53 的 NX1_IMPL 上刚把一个块砍到只剩 1 行,而阈值 8 恰好等于本机空闲基线,等于永久停机)。
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
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
|
+
|
|
3
12
|
## 1.5.9
|
|
4
13
|
|
|
5
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 对口径,**本版按更全样本改口径**,不是行为变了。
|
package/docs/REFERENCE.en.md
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
98
|
+
export const VERSION = '1.5.11'
|
|
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.
|
|
3
|
+
"version": "1.5.11",
|
|
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",
|
|
@@ -77,7 +77,10 @@
|
|
|
77
77
|
"immediately": true
|
|
78
78
|
}
|
|
79
79
|
},
|
|
80
|
-
"author":
|
|
80
|
+
"author": {
|
|
81
|
+
"name": "regrevia",
|
|
82
|
+
"url": "https://github.com/regrevia"
|
|
83
|
+
},
|
|
81
84
|
"icon": "./icon.png",
|
|
82
85
|
"bin": {
|
|
83
86
|
"dsh-fast-mode-experiment": "./bin/experiment.mjs",
|
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
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
// the
|
|
375
|
-
|
|
376
|
-
const
|
|
377
|
-
if (
|
|
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,
|
|
409
|
-
//
|
|
410
|
-
|
|
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
|
-
/**
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
*
|
|
434
|
-
*
|
|
435
|
-
* the
|
|
436
|
-
*
|
|
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
|
-
|
|
643
|
+
function refreshWritePaths(entry) {
|
|
644
|
+
for (const path of [...entry.writes.keys()]) trackPath(entry.writes, path)
|
|
645
|
+
}
|
|
439
646
|
|
|
440
|
-
/**
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
|
|
448
|
-
|
|
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
|
|
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,
|
|
461
|
-
if (argsKey.length === 0
|
|
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
|
|
721
|
-
//
|
|
722
|
-
// the
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
entry.
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
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
|
-
/**
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
]
|
|
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: {
|
|
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] -
|
|
168
|
-
* for the aborted body to actually settle, given its budget. A
|
|
169
|
-
* dispatch must not return a result while the body is still
|
|
170
|
-
* hands the caller a settled call, and a body that finishes
|
|
171
|
-
* races the next step.
|
|
172
|
-
* @param {number} [options.
|
|
173
|
-
*
|
|
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
|
|
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
|
-
//
|
|
237
|
-
//
|
|
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
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const
|
|
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({
|
|
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 {
|