dsh-expert-group 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,41 +8,6 @@
8
8
  把「按角色区分模型与思考深度」的专家子代理能力引入 DSH,并在 Web 界面的“dsh-expert-group”设置页维护全局模型、思考深度与通知偏好。
9
9
  同时把 oh-my-opencode-slim 的主代理 orchestrator 设计移植过来:根会话负责拆解、委派、整合和验证,专家子代理保持叶子 worker 约束。
10
10
 
11
- 本包**不修改** `@deepseek-ai/dsh` 的任何核心源码,也不修改 shipped agent preset。能力全部通过
12
- 现有 Host 服务与客户端 Slot 注册;**最低宿主契约**是下面这些声明在 `inject` 里的服务:
13
- `tools`、`subagents`、`systemPrompt`、`llm`、`agents`、`settings`、`webServer`。
14
-
15
- 其中 **goals 是可选只读投影源**:插件通过 `ctx.get("goals")` 读取,不把它声明为 `inject` 依赖,
16
- 且**只用于侧边栏的目标阶段展示**。插件不调用 `goals.pause` / `goals.resume`,等待语义完全由
17
- 「保持 root 当前 turn 打开」实现(见下)。宿主缺少该服务时,插件主体、专家派发与其他工具仍正常
18
- 加载,只是目标阶段投影为空。
19
-
20
- 等待判定需要一条**能证明**「该 root 没有 running 后代」的 root-scoped 查询,分两档:
21
- - **权威**:`subagents.listDescendants(rootSessionId)`——递归后代枚举,每行带
22
- `activity: "running" | "inactive"`;整条 listing reject、出现不可读分支
23
- (`kind: "diagnostic"`)或结构非法都按 unknown 处理。**只有**它「干净且完整」地回答
24
- 才能证明静止,这也是等待唯一的释放依据;
25
- - **降级**:公开 `workspace/session-activity` waterfall。它是**非专属**扩展点,
26
- **只能报告 activity,不能证明 quiet**,两个原因都必须处理:
27
- - 宿主 subagent provider 在空闲时是原样透传最内层返回值,因此「没有 provider」与
28
- 「provider 报告空」在返回值上同形;插件用模块唯一的 sentinel 兜底,sentinel 命中一律
29
- unknown;
30
- - 即使拿到的是**非 sentinel 的新数组**,也无法识别贡献者:任意第三方 listener 都能返回
31
- 一个全新的空数组(既不 spread 最内层值,也不检查 subagent 血缘),它与「provider
32
- 明确报告没有 running 后代」同形但**不可信**。因此**任何空数组**(含 fresh `[]`)
33
- 都不是静止证明。
34
- 该档唯一可用的明确结论是**正向**的「确有 `kind: "subagent"` 的 running 后代」,
35
- 它只用于继续等待。
36
-
37
- 两档都不可用时能力矩阵标记 `required: false`、`status: "unavailable"` 和
38
- `degradation: "root-turn-wait-descendant-query-unavailable"`;只有 waterfall 时标记
39
- `status: "degraded"` 和 `root-turn-wait-descendant-listing-unavailable`。任何 unknown
40
- 一律 fail-closed(继续等待,绝不把「查不到」当成「没有人跑」)。
41
-
42
- 图片规范化额外使用可选公开 `ctx.attachments` 服务,冷会话精确读取使用可选 `ctx.sessionQuery`;
43
- 两者缺失时只禁用受影响能力(`image-normalization-disabled` / `cold-session-read-disabled`),
44
- 文本专家能力仍可加载。
45
-
46
11
  ## 能力
47
12
 
48
13
  - 5 个专家子代理工具(默认启用):
@@ -59,92 +24,27 @@
59
24
  - **无内置默认模型**:新安装后所有角色均为空,需在“dsh-expert-group”设置页从 DSH
60
25
  已配置的模型中自行选择并保存;未配置模型的角色在调用时会给出明确提示。
61
26
  - 模型与思考深度通过 `llm.listProviders/listModels` + `resolveCallConfig` 校验,只接受设置中已添加的模型。
62
- - **动态 settings 保存能力**:插件不在模块加载期 import `@deepseek-ai/dsh-settings`,package 也不声明该 peer,因此默认安装不会额外下载 settings 包。目标运行时为 `0.2.0-rc.1`;插件通过宿主 settings 服务的 describe/update/mutate 面读写 profile 条目,并由 DEG 自有的 settings-commit 端点完成语义校验与 revision 冲突检测(宿主不再提供 owner validate 钩子)。
27
+ - **动态 settings 保存能力**:插件不在模块加载期 import `@deepseek-ai/dsh-settings`,package 也不声明该 peer,因此默认安装不会额外下载 settings 包。目标运行时为 `0.2.0-rc.2`;插件通过宿主 settings 服务的 describe/update/mutate 面读写 profile 条目,并由 DEG 自有的 settings-commit 端点完成语义校验与 revision 冲突检测(宿主不再提供 owner validate 钩子)。
63
28
  - 子代理的 `reasoningEffort` 通过子代理 `agent/request` waterfall 注入,绕开 `dsh-tool-subagent`
64
29
  `AgentOptions` 尚未公开该字段的限制。
65
30
 
66
- ## 安装与平台分工
67
-
68
- 本插件发布到 npm 公共源,按包名安装。**CLI/web 与桌面版是两套管理面,命令不通用**:
69
- CLI/web 的完整流程见 [安装说明.txt](安装说明.txt) 与 [web-install.ps1](web-install.ps1) / [web-uninstall.ps1](web-uninstall.ps1)(这两个脚本只处理 web profile);
70
- 桌面版见 [desktop-install.ps1](desktop-install.ps1)(构建 → 测试 → 打包 → 备份 → 安装 → 校验的快速部署流程,脚本头部注释即流程说明)。
71
-
72
- | 目标 | 安装入口 | 卸载入口 | 生效方式 |
73
- | --- | --- | --- | --- |
74
- | **CLI / `dsh web`** | `dsh plugin --profile web add dsh-expert-group` | `web-uninstall.ps1`(等价于 `dsh plugin --profile web remove dsh-expert-group`) | 重启 DSH Web |
75
- | **桌面版(Electron 客户端)** | 桌面版**应用自带**的 `dsh.cmd`(`plugin --profile desktop`,见下),或 GUI「插件 → 添加插件」 | `desktop-uninstall.ps1`(内部调用应用自带 CLI,见下) | 重启桌面版 |
76
-
77
- 两个卸载脚本各自只处理自己的 profile,**不可混用**:`web-uninstall.ps1` 操作 `profiles\web`,`desktop-uninstall.ps1` 操作 `profiles\desktop`。
31
+ ## 安装
78
32
 
79
- ### CLI / `dsh web`
33
+ 本插件发布到 npm 公共源,用 DSH 官方 `dsh plugin` CLI 按包名安装(`--profile` 为必填参数,一般填 `web`):
80
34
 
81
35
  ```powershell
82
36
  dsh plugin --profile web add dsh-expert-group
83
37
  ```
84
38
 
85
- 安装会把本包写入 `$DSH_HOME\profiles\web` 的依赖并加入 profile 的 bundle 层;**重启 DSH Web** 后生效(已运行的进程不会热加载新的 Host 行)。
86
-
87
- 要求:DSH Web 可运行(`dsh web`)、Node.js ≥ 22、PATH 上有 `pnpm`。**本插件不内置任何角色默认模型**——安装后需先在「设置 → dsh-expert-group」为各角色选择模型并保存。
88
-
89
- ### 桌面版(Electron 客户端)
90
-
91
- 桌面版的 profile 是 `desktop`,由 Electron 应用管理;**不要用 PATH 上的 npm 全局 `dsh` 去操作它**:
92
- 启动器里的开关 `manageDesktopProfile` **只作用于 `plugin` 子命令**,
93
- boot / `--dump-config` 路径是无条件拒绝的——**应用自带 CLI(rc.2)同样拒**:
94
-
95
- ```text
96
- error: profile "desktop" is managed exclusively by the Electron application
97
- ```
98
-
99
- 三条可用入口:
39
+ 前置条件:DSH Web 可正常运行(`dsh web`)、Node.js ≥ 22,且 PATH 上有 `pnpm`。安装后重启 `dsh web` 使插件生效。
100
40
 
101
- | 入口 | 适用场景 | 说明 |
102
- | --- | --- | --- |
103
- | **应用自带 CLI** | 安装 / 升级 / 卸载的默认路径 | `0.2.0-rc.2` 起发行包带 `resources\runtime\cli\bin\dsh.cmd`(`0.2.0-rc.1` 上不存在);只有它放行 `plugin --profile desktop` |
104
- | **GUI「插件 → 添加插件」** | 图形化安装 | 与版本无关;**按 spec 检查同名**——包名 / 本地目录形式的 spec 命中已安装依赖时提示「该插件已安装。如需升级,请卸载后重新安装」,**tgz spec 的实际行为本轮未验证**;也不提供版本豁免入口 |
105
- | **手工维护 profile 文件** | 无 CLI、无 GUI 的兜底 | 手工建 junction / 复制副本**不是默认升级方式**,会绕过 pnpm 的锁与 integrity 校验 |
41
+ 桌面版(Electron 客户端)的 `desktop` profile 由客户端独占管理,PATH 上的 `dsh` CLI 改不动它:先打开 Desktop 完成初始化再完全退出,用安装目录自带的 CLI 执行同一命令。
106
42
 
107
43
  ```powershell
108
- $cli = Join-Path '<DSH 安装目录>' 'resources\runtime\cli\bin\dsh.cmd' # 应用自带 CLI
109
- $tgz = '<候选包 tgz 的绝对路径>' # 也接受包名 / 本地目录绝对路径
110
-
111
- & $cli plugin --profile desktop add $tgz
112
- if ($LASTEXITCODE -ne 0) { throw "add 失败(exit=$LASTEXITCODE),先查落盘状态再决定回滚" }
113
-
114
- & $cli plugin --profile desktop list
115
- if ($LASTEXITCODE -ne 0) { throw "list 失败(exit=$LASTEXITCODE)" }
116
-
117
- # 卸载:可直接运行仓库根的 .\desktop-uninstall.ps1(内部就是下面的 remove);
118
- # 手工执行等价于把 add 换成 remove;版本豁免:version-exemptions / allow-version / revoke-version
44
+ & '<DSH 安装目录>\resources\runtime\cli\bin\dsh.cmd' plugin --profile desktop add dsh-expert-group
119
45
  ```
120
46
 
121
- **生效与验收:命令执行者是主代理,最后一步重启由用户完成。** 构建 → 测试 → 打包 → 备份 → 安装
122
- → 校验的完整流程(含判据与回滚)见 **[desktop-install.ps1](desktop-install.ps1)**(流程与判据在脚本头部注释与各阶段日志中)。
123
-
124
- > 在线(桌面版运行中)安装属于受控测试:CLI 只检查 profile 是否初始化(提示 `... then fully quit it before
125
- > running dsh plugin --profile desktop.`),**没有进程硬拦截**,因此可能出现内存里旧模块 + 磁盘上新模块混用。
126
- > **磁盘安装成功 ≠ 内存生效、更 ≠ 功能验收**——真正验收要等用户重启后读证据(卸载入口见 [desktop-uninstall.ps1](desktop-uninstall.ps1))。
127
-
128
- **验证安装登记与目标文件**(直接检查目标 profile,真实目录、Junction 和 pnpm 链接均适用):
129
-
130
- ```powershell
131
- $ErrorActionPreference = 'Stop'
132
- $home_dsh = if ($env:DSH_HOME) { $env:DSH_HOME } else { Join-Path $env:USERPROFILE '.dsh' }
133
- $d = Join-Path $home_dsh 'profiles\desktop'
134
- $pkg = Get-Content -LiteralPath (Join-Path $d 'package.json') -Raw -Encoding UTF8 | ConvertFrom-Json
135
- if (-not $pkg.dependencies.'dsh-expert-group' -or 'dsh-expert-group' -notin @($pkg.dsh.profile.bundles)) {
136
- throw '目标 profile 未完整登记 DEG 依赖与 bundle'
137
- }
138
- $installedManifest = Join-Path $d 'node_modules\dsh-expert-group\package.json'
139
- if (-not (Test-Path -LiteralPath $installedManifest -PathType Leaf)) { throw '目标 profile 中没有 DEG 包清单' }
140
- $installed = Get-Content -LiteralPath $installedManifest -Raw -Encoding UTF8 | ConvertFrom-Json
141
- if ($installed.name -ne 'dsh-expert-group') { throw '目标包名不符' }
142
- [pscustomobject]@{ Path = $installedManifest; Name = $installed.name; Version = $installed.version }
143
- ```
144
-
145
- 这只是快速检查;同版本替换还须按详细指南比对实际文件与候选 tgz 的哈希,不能只看版本号。
146
-
147
- **可选图像增强**:`sharp` 是 `optionalDependencies`(约 30 MB 原生二进制),用于把 JPEG / WebP / GIF 转码为 PNG;拉取失败或未安装都不影响插件加载,仅会拒绝非 PNG 图片的转码,其余功能全部正常。
47
+ 安装后重新打开 Desktop 使插件生效。本插件不内置角色默认模型:先在 DSH「设置 → 模型」配置模型,再到「设置 → dsh-expert-group」为各角色选择模型并保存。
148
48
 
149
49
  ## 界面总览
150
50
 
@@ -441,36 +341,3 @@ Expert Group 是 DEG 注册在右侧栏的任务视图:它把**当前会话所
441
341
  - 首次从「只有 Expert Group」的单 pane 分栏时,打开事务会先临时把右栏写到约 50%,目标 tab 确认落到返回的 pane 后保留 50% 的可调模式;分栏失败则恢复原来的 20% 锁定宽度。
442
342
  - 日常在已有子代理 pane 里打开 / 关闭 tab 不改宽度。
443
343
  - 首次形成两个 pane 后:Expert Group 占右栏 40%(视口约 20%),子代理 pane 占右栏 60%(视口约 30%);最后一个被跟踪的 pane 关闭后恢复约 20% 的默认宽度。
444
-
445
- **能力缺失时的降级**:宿主没有 `sidebarRight.openResource` 时箭头不渲染、其余行内容完整;缺少关闭确认能力时保留静态 `>>`;整条侧栏接缝不可用时 DEG 只注册页面,不猜测打开状态。
446
-
447
- ## 只读能力矩阵与运行时诊断
448
-
449
- Host RPC 的 `meta` 动作返回两份**只读投影**(设置页诊断块与客户端归一化都消费它们):
450
-
451
- - `capabilities`:宿主服务可达性与固定降级标记,带 schema 版本 `version`。
452
- 每个能力条目只允许固定字段(`required` / `status` / `degradation` / `methods` / `probe` /
453
- `transport` / `failure` / `binding`),不含任何自由文本。
454
- - 硬依赖(`tools` / `subagents` / `agents` / `settings` / `webServer` / `systemPrompt` / `llm`)
455
- 在 `inject` 中声明,缺失时插件不加载;
456
- - `rootTurnWait` 是可选宿主 seam:权威档是 `subagents.listDescendants`(**唯一**能证明
457
- 「该 root 没有 running 后代」的 seam,且必须干净且完整),降级档是公开的
458
- `workspace/session-activity` waterfall(只能报告 activity,不能证明 quiet)。
459
- 只有 waterfall 时为 `degraded` + `root-turn-wait-descendant-listing-unavailable`;
460
- 两档都没有时为 `unavailable` + `root-turn-wait-descendant-query-unavailable`,
461
- 此时等待一律 fail-closed(继续等待);
462
- - `connectionRpc` / `storageDomain` / `attachments` / `sessionQuery` 各自报告
463
- `available` / `degraded` / `absent` / `unknown`,缺失时只禁用对应能力。
464
- - `diagnostics`:children / fanout / recovery / settings / storage / handoff / notifications /
465
- rpc 的**计数与固定 token**,同样带 `version`。
466
-
467
- 敏感性边界:两份投影不含 prompt、原始请求体、token、Cookie、authorization、文件路径、
468
- 会话/child 标识或错误消息正文;能力状态与降级说明都从固定枚举取值,任何超出字符面的输入
469
- 在归一化时被丢弃。客户端遇到未知 `version` 时整体忽略该块,绝不猜测字段含义。
470
-
471
- 系统通知(Windows Toast)的诊断字段与固定 token 同样在这一份 `diagnostics` 里:
472
- `notifications.systemToast` 报告 `status` / `version` / `platform` / `aumidResolved` /
473
- `aumidReason` / `inflight` / `queued` / `sent` / `failed` / `dropped` / `lastReason`;
474
- 解释器不可用时 `status` 为 `absent` 且固定 `reason` 为 `powershell-unavailable`,
475
- AUMID 解析失败的固定 token 为 `shortcut-read-failed`。这些字段只报状态与计数,不含命令行、
476
- 路径或通知正文。
package/host/turn-wait.js CHANGED
@@ -124,6 +124,33 @@ export function createRootTurnWaitRegistry(options = {}) {
124
124
  }
125
125
  }
126
126
 
127
+ // 查询结果未知或仍发现运行中的后代时,使用无期限的退避复查。timer 只服务于
128
+ // 当前仍有 waiter 的活动记录;记录退休或最后一个 waiter 取消时必须清除它。
129
+ const RETRY_INITIAL_DELAY = 10;
130
+ const RETRY_MAX_DELAY = 1000;
131
+ function clearRetryTimer(record) {
132
+ if (record.retryTimer === void 0) return;
133
+ clearTimeout(record.retryTimer);
134
+ record.retryTimer = void 0;
135
+ }
136
+
137
+ function scheduleRetry(record) {
138
+ if (disposed || record.destroyed === true || record.released === true
139
+ || record.waiters.size === 0 || record.retryTimer !== void 0) return;
140
+ const delay = record.retryDelay;
141
+ const timer = setTimeout(() => {
142
+ if (record.retryTimer !== timer) return;
143
+ record.retryTimer = void 0;
144
+ if (disposed || record.destroyed === true || record.released === true
145
+ || record.waiters.size === 0) return;
146
+ scheduleCheck(record);
147
+ }, delay);
148
+ record.retryTimer = timer;
149
+ // unref 只避免测试/宿主进程因一个永不超时的等待记录被强行保持;进程仍活跃时 timer 正常执行。
150
+ timer.unref?.();
151
+ record.retryDelay = Math.min(RETRY_MAX_DELAY, delay * 2);
152
+ }
153
+
127
154
  /**
128
155
  * **唯一**的记录销毁路径:等待者收口、监听解除、登记表移除、exact Agent 强引用清空。
129
156
  *
@@ -148,6 +175,7 @@ export function createRootTurnWaitRegistry(options = {}) {
148
175
  record.checking = false;
149
176
  record.recheck = false;
150
177
  record.inSnapshot = false;
178
+ clearRetryTimer(record);
151
179
  releaseListeners(record);
152
180
  settleWaiters(record, released === true, reason);
153
181
  record.works.clear();
@@ -165,6 +193,8 @@ export function createRootTurnWaitRegistry(options = {}) {
165
193
  */
166
194
  function scheduleCheck(record) {
167
195
  if (disposed || record.destroyed === true || record.released === true) return;
196
+ // 显式活动事件或新 waiter 优先于退避 timer,避免同一记录并发发起重复查询。
197
+ clearRetryTimer(record);
168
198
  // 正在 onKnownSnapshot 的同步回调里:本次结算由外层判定统一收口,不再嵌套发起查询。
169
199
  if (record.inSnapshot === true) return;
170
200
  if (record.checking) {
@@ -198,9 +228,9 @@ export function createRootTurnWaitRegistry(options = {}) {
198
228
  * 不消费该标志,那次「有新活动,请复查」的意图就被吞掉,等待可能永久悬挂。因此
199
229
  * 两条分支都必须消费 `recheck` 并重跑一次查询。
200
230
  *
201
- * 不自旋的保证:`recheck` 只在**已有查询在飞**时被置位,而置位者只能是事件
231
+ * 事件驱动的 `recheck` 只在**已有查询在飞**时被置位,而置位者只能是事件
202
232
  * (track/settle/invalidate 都会推进 version)或新到达的 waiter。每次 rejection
203
- * 最多消费一个 `recheck`,因此「连续 reject 且没有新事件」时第二轮之后不再重跑。
233
+ * 最多消费一个 `recheck`;没有事件时不会同步自旋,unknown 结果改由退避 timer 异步复查。
204
234
  */
205
235
  function handleQuerySettled(record, queryId, version, result, error) {
206
236
  if (record.activeQueryId !== queryId) return;
@@ -210,10 +240,12 @@ export function createRootTurnWaitRegistry(options = {}) {
210
240
 
211
241
  if (error !== void 0) {
212
242
  report(error);
213
- // unknown:继续等待。仅当在飞期间确有复查意图时才重跑,绝不无条件自旋。
243
+ // 查询失败仍是 unknown:保留 fail-closed,并在有 waiter 时退避重查。
214
244
  if (record.recheck === true) {
215
245
  record.recheck = false;
216
246
  scheduleCheck(record);
247
+ } else {
248
+ scheduleRetry(record);
217
249
  }
218
250
  return;
219
251
  }
@@ -229,8 +261,13 @@ export function createRootTurnWaitRegistry(options = {}) {
229
261
  const known = result?.known === true;
230
262
  const running = known && Array.isArray(result.running) ? result.running : [];
231
263
  if (!known) {
232
- // 未知(查询失败 / 结构非法 / 状态无法确定):继续等待,**绝不**当成无人运行。
233
- if (record.recheck === true) { record.recheck = false; scheduleCheck(record); }
264
+ // 未知(结构非法 / 状态无法确定):继续等待,**绝不**当成无人运行。
265
+ if (record.recheck === true) {
266
+ record.recheck = false;
267
+ scheduleCheck(record);
268
+ } else {
269
+ scheduleRetry(record);
270
+ }
234
271
  return;
235
272
  }
236
273
  // 明确快照:先给调用方一次「完成通知缺失」的回退结算机会。回调只减少未结算工作,
@@ -246,7 +283,14 @@ export function createRootTurnWaitRegistry(options = {}) {
246
283
  // child 创建或 fanout retry。
247
284
  if (record.works.size > 0 || running.length > 0) {
248
285
  // 仍有未结算工作,或确有 running 后代:继续等待。
249
- if (record.recheck === true) { record.recheck = false; scheduleCheck(record); }
286
+ if (record.recheck === true) {
287
+ record.recheck = false;
288
+ scheduleCheck(record);
289
+ } else if (record.works.size === 0 && running.length > 0) {
290
+ // 登记工作已清空后,后代仍可能静默转为非运行,不能依赖 agent/status/disposed
291
+ // 事件才能再次观察;工作尚未结算时仍由其精确 settlement 触发复查。
292
+ scheduleRetry(record);
293
+ }
250
294
  return;
251
295
  }
252
296
  if (!isLiveRoot(record.agent)) {
@@ -285,6 +329,8 @@ export function createRootTurnWaitRegistry(options = {}) {
285
329
  cancelled: false,
286
330
  destroyed: false,
287
331
  releaseReason: void 0,
332
+ retryTimer: void 0,
333
+ retryDelay: RETRY_INITIAL_DELAY,
288
334
  unlisten: void 0,
289
335
  };
290
336
  if (typeof record.rootId !== "string" || record.rootId.length === 0) return void 0;
@@ -348,6 +394,30 @@ export function createRootTurnWaitRegistry(options = {}) {
348
394
  scheduleCheck(record);
349
395
  }
350
396
 
397
+ /**
398
+ * 取消当前 root 的所有 waiter,但保留未结算工作与监听。
399
+ *
400
+ * 用户插话只应打断上一轮 root 的等待,让新一轮消息立即进入处理;专家 child
401
+ * 仍可能运行,admission 也必须继续保留,后续 turn 才能再次等待同一批工作。
402
+ *
403
+ * @param {object} agent - exact root Agent。
404
+ * @param {string} reason - 取消原因码。
405
+ * @returns {boolean} 是否至少取消了一个 waiter。
406
+ */
407
+ function cancelWaiters(agent, reason = "cancelled") {
408
+ const record = records.get(agent);
409
+ if (record === void 0 || record.destroyed === true || record.waiters.size === 0) return false;
410
+ settleWaiters(record, false, reason);
411
+ clearRetryTimer(record);
412
+ // 让已经在飞的查询失效;它的迟到结果只能复位内部状态,不能重新收口旧 waiter。
413
+ if (record.checking) {
414
+ record.activeQueryId = ++record.querySequence;
415
+ record.checking = false;
416
+ record.recheck = false;
417
+ }
418
+ return true;
419
+ }
420
+
351
421
  /**
352
422
  * 等待该 root 静止。**永不超时**:只有精确结算 + root-scoped 明确空闲、
353
423
  * 或 retire/dispose 才会收口。
@@ -371,6 +441,7 @@ export function createRootTurnWaitRegistry(options = {}) {
371
441
  waiter.cleanup?.();
372
442
  waiter.cleanup = void 0;
373
443
  waiter.resolve({ released: false, reason: "signal-aborted" });
444
+ if (record.waiters.size === 0) clearRetryTimer(record);
374
445
  // 最后一个 waiter 取消后,忽略它启动的在途快照并释放 checking。
375
446
  if (record.waiters.size === 0 && record.checking) {
376
447
  record.activeQueryId = ++record.querySequence;
@@ -452,6 +523,7 @@ export function createRootTurnWaitRegistry(options = {}) {
452
523
  track,
453
524
  settle,
454
525
  invalidate,
526
+ cancelWaiters,
455
527
  engage,
456
528
  isEngaged,
457
529
  wait,