@thincoder/core 0.9.1

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.
Files changed (203) hide show
  1. package/abort-provenance.mjs +116 -0
  2. package/advisor/citations.mjs +139 -0
  3. package/advisor/compaction.mjs +174 -0
  4. package/advisor/convergence.mjs +80 -0
  5. package/advisor/history.mjs +77 -0
  6. package/advisor/loop.mjs +288 -0
  7. package/advisor/messages.mjs +299 -0
  8. package/advisor/notice.mjs +141 -0
  9. package/advisor/project-context.mjs +197 -0
  10. package/advisor/repos.mjs +150 -0
  11. package/advisor/run.mjs +190 -0
  12. package/advisor/truncate.mjs +57 -0
  13. package/advisor.mjs +281 -0
  14. package/agent/child-marks.mjs +24 -0
  15. package/agent/completion.mjs +145 -0
  16. package/agent/dispatch.mjs +493 -0
  17. package/agent/family-tools.mjs +174 -0
  18. package/agent/helpers.mjs +412 -0
  19. package/agent/post-turn.mjs +70 -0
  20. package/agent/record-results.mjs +174 -0
  21. package/agent/relay-prefix.mjs +39 -0
  22. package/agent/run-stages.mjs +244 -0
  23. package/agent/setup-reminders.mjs +199 -0
  24. package/agent/setup.mjs +234 -0
  25. package/agent/spawn-child.mjs +258 -0
  26. package/agent/suspension.mjs +240 -0
  27. package/agent/write-gate.mjs +87 -0
  28. package/agent-tools/advisor-async.mjs +481 -0
  29. package/agent-tools/advisor-settle.mjs +240 -0
  30. package/agent-tools/advisor.mjs +280 -0
  31. package/agent-tools/async-discard.mjs +143 -0
  32. package/agent-tools/async-settle.mjs +299 -0
  33. package/agent-tools/batch-segment.mjs +265 -0
  34. package/agent-tools/child-permission.mjs +45 -0
  35. package/agent-tools/consult.mjs +471 -0
  36. package/agent-tools/design-token.mjs +117 -0
  37. package/agent-tools/digest-budget.mjs +76 -0
  38. package/agent-tools/eng.mjs +102 -0
  39. package/agent-tools/escalate-async.mjs +302 -0
  40. package/agent-tools/goal.mjs +119 -0
  41. package/agent-tools/panel-blocks.mjs +24 -0
  42. package/agent-tools/parent-channel.mjs +231 -0
  43. package/agent-tools/plan.mjs +86 -0
  44. package/agent-tools/read-history.mjs +309 -0
  45. package/agent-tools/recent-changes.mjs +24 -0
  46. package/agent-tools/review-facts.mjs +31 -0
  47. package/agent-tools/settings.mjs +268 -0
  48. package/agent-tools/skill.mjs +63 -0
  49. package/agent-tools/spawn-gates.mjs +109 -0
  50. package/agent-tools/subagent-actions.mjs +496 -0
  51. package/agent-tools/subagent-async.mjs +456 -0
  52. package/agent-tools/subagent-panel.mjs +160 -0
  53. package/agent-tools/subagent-run.mjs +208 -0
  54. package/agent-tools/subagent-scheduler.mjs +446 -0
  55. package/agent-tools/subagent-spawn.mjs +478 -0
  56. package/agent-tools/subagent.mjs +419 -0
  57. package/agent-tools/task.mjs +87 -0
  58. package/agent-tools/timer.mjs +46 -0
  59. package/agent-tools/verify.mjs +295 -0
  60. package/agent-tools.mjs +23 -0
  61. package/agent.mjs +430 -0
  62. package/auto-think.mjs +115 -0
  63. package/compress-form.mjs +24 -0
  64. package/config-io.mjs +277 -0
  65. package/config-migrate.mjs +178 -0
  66. package/config-presets.mjs +49 -0
  67. package/config.mjs +419 -0
  68. package/context.mjs +495 -0
  69. package/conventions.mjs +223 -0
  70. package/embedding.mjs +120 -0
  71. package/escape.mjs +152 -0
  72. package/expand-home.mjs +16 -0
  73. package/explore-distill.mjs +152 -0
  74. package/generate-title.mjs +123 -0
  75. package/git/checkpoint.mjs +448 -0
  76. package/git/gitmem.mjs +100 -0
  77. package/history-window.mjs +179 -0
  78. package/hooks.mjs +108 -0
  79. package/i18n.mjs +106 -0
  80. package/index-bin.mjs +48 -0
  81. package/index-discover.mjs +176 -0
  82. package/ledger-cmd.mjs +209 -0
  83. package/ledger-db.mjs +86 -0
  84. package/ledger-surface.mjs +76 -0
  85. package/ledger.mjs +202 -0
  86. package/log.mjs +195 -0
  87. package/manifest.mjs +338 -0
  88. package/markdown.mjs +106 -0
  89. package/mcp/helpers.mjs +51 -0
  90. package/mcp/transport-http.mjs +248 -0
  91. package/mcp/transport-stdio.mjs +140 -0
  92. package/mcp/transport-ws.mjs +122 -0
  93. package/mcp.mjs +295 -0
  94. package/memory/code-index.mjs +219 -0
  95. package/memory/code-sync.mjs +427 -0
  96. package/memory/core.mjs +318 -0
  97. package/memory/delete.mjs +242 -0
  98. package/memory/docs.mjs +431 -0
  99. package/memory/file-walk.mjs +109 -0
  100. package/memory/origin.mjs +24 -0
  101. package/memory/scan.mjs +177 -0
  102. package/memory/schema.mjs +460 -0
  103. package/memory.mjs +21 -0
  104. package/model-ref.mjs +66 -0
  105. package/model-specs.mjs +277 -0
  106. package/package.json +32 -0
  107. package/peer-domains.mjs +265 -0
  108. package/peer-instances.mjs +178 -0
  109. package/permission.mjs +79 -0
  110. package/process-probe.mjs +315 -0
  111. package/prompt-files.mjs +113 -0
  112. package/prompt-overlays.mjs +78 -0
  113. package/prompts/advisor-design.md +43 -0
  114. package/prompts/advisor-round1.md +41 -0
  115. package/prompts/advisor-round2.md +46 -0
  116. package/prompts/advisor-round3.md +42 -0
  117. package/prompts/common.md +158 -0
  118. package/prompts/consult-base.md +19 -0
  119. package/prompts/discipline-engineering.md +123 -0
  120. package/prompts/discipline-normal.md +206 -0
  121. package/prompts/persona-coder.md +21 -0
  122. package/prompts/persona-eng-coder.md +41 -0
  123. package/prompts/persona-eng-designer.md +80 -0
  124. package/prompts/persona-engineering.md +160 -0
  125. package/prompts/persona-explore.md +15 -0
  126. package/prompts/persona-normal.md +35 -0
  127. package/prompts/persona-plan.md +27 -0
  128. package/provider/anthropic.mjs +225 -0
  129. package/provider/core.mjs +491 -0
  130. package/provider/errors.mjs +101 -0
  131. package/provider/google.mjs +257 -0
  132. package/provider/index.mjs +7 -0
  133. package/provider/list-models.mjs +163 -0
  134. package/provider/normalize.mjs +81 -0
  135. package/provider/rate.mjs +168 -0
  136. package/provider/responses.mjs +495 -0
  137. package/provider/retry.mjs +88 -0
  138. package/provider/sse.mjs +264 -0
  139. package/provider/wait-status.mjs +59 -0
  140. package/proxy.mjs +274 -0
  141. package/rules.mjs +53 -0
  142. package/session-gc.mjs +248 -0
  143. package/session-guard.mjs +59 -0
  144. package/session-lifecycle.mjs +305 -0
  145. package/session-migrate.mjs +48 -0
  146. package/session-rename.mjs +38 -0
  147. package/session-segments.mjs +99 -0
  148. package/session-slot-write.mjs +168 -0
  149. package/session-slots-manifest.mjs +264 -0
  150. package/session-slots.mjs +298 -0
  151. package/session-store.mjs +441 -0
  152. package/session.mjs +244 -0
  153. package/skills.mjs +234 -0
  154. package/text-budget.mjs +79 -0
  155. package/token-ttl.mjs +285 -0
  156. package/tool-docs/apply_patch.md +15 -0
  157. package/tool-docs/bash.md +38 -0
  158. package/tool-docs/delete.md +13 -0
  159. package/tool-docs/edit.md +30 -0
  160. package/tool-docs/execute.md +21 -0
  161. package/tool-docs/fetch.md +12 -0
  162. package/tool-docs/file_ops.md +17 -0
  163. package/tool-docs/get_current_time.md +8 -0
  164. package/tool-docs/git.md +54 -0
  165. package/tool-docs/glob.md +11 -0
  166. package/tool-docs/grep.md +19 -0
  167. package/tool-docs/hashline_edit.md +14 -0
  168. package/tool-docs/insert_after.md +15 -0
  169. package/tool-docs/lint.md +10 -0
  170. package/tool-docs/ls.md +12 -0
  171. package/tool-docs/lsp.md +10 -0
  172. package/tool-docs/process.md +10 -0
  173. package/tool-docs/question.md +16 -0
  174. package/tool-docs/read.md +20 -0
  175. package/tool-docs/read_image.md +8 -0
  176. package/tool-docs/tree.md +14 -0
  177. package/tool-docs/wait_for.md +22 -0
  178. package/tool-docs/websearch.md +16 -0
  179. package/tool-docs/write.md +11 -0
  180. package/tools/bash.mjs +276 -0
  181. package/tools/edit-batch.mjs +204 -0
  182. package/tools/edit-diff.mjs +388 -0
  183. package/tools/exec-run.mjs +43 -0
  184. package/tools/execute.mjs +243 -0
  185. package/tools/file.mjs +464 -0
  186. package/tools/git-checkpoint.mjs +143 -0
  187. package/tools/git-ext.mjs +173 -0
  188. package/tools/git.mjs +415 -0
  189. package/tools/glob-dialect.mjs +130 -0
  190. package/tools/index.mjs +76 -0
  191. package/tools/linter.mjs +120 -0
  192. package/tools/lsp.mjs +335 -0
  193. package/tools/ops.mjs +293 -0
  194. package/tools/patch.mjs +290 -0
  195. package/tools/question.mjs +26 -0
  196. package/tools/repomap.mjs +314 -0
  197. package/tools/search.mjs +248 -0
  198. package/tools/shared.mjs +467 -0
  199. package/tools/tree.mjs +81 -0
  200. package/tools/web.mjs +224 -0
  201. package/tools/write-path.mjs +191 -0
  202. package/traces/trace-store.mjs +303 -0
  203. package/undo-stack.mjs +47 -0
@@ -0,0 +1,208 @@
1
+ /**
2
+ * subagent-run.mjs — async spawn 执行器 executeAsyncSpawn(2026-09-05 module-split:
3
+ * subagent.mjs 726 > 500 硬限——execute 的 async 分支 verbatim 迁入(闭包变量参数化:
4
+ * parent/ctx/role/args + buildSpawnChild 产物),语义零变——subagent.mjs execute 调
5
+ * 用(只此一处);§20 补位/排队刷新仍来自 subagent-scheduler.mjs。
6
+ */
7
+
8
+ import { ASYNC_POOL_LIMITS, poolDomainOf, poolLimitsFor, runningPoolCount } from "./subagent-async.mjs"
9
+ import { runChildPipeline } from "./subagent-async.mjs"
10
+ import { logEvent } from "../log.mjs"
11
+ import { deathLine } from "../abort-provenance.mjs"
12
+ import { pushReal } from "../context.mjs"
13
+ import {
14
+ describeBlockers, refreshQueuedTokens, consumeSubagentToken, assertPoolKeyFree,
15
+ } from "./subagent-scheduler.mjs"
16
+ // ASYNC-RESULT-CONTAINER.md D3/D6:settle 公共收尾单点 + child signal 构建单点
17
+ import { bindChildController, buildChildSignal, settleAsyncEntry } from "./async-settle.mjs"
18
+
19
+ /**
20
+ * SUBAGENT-OBSERVE-SEND D2:注入队列回合边界消费核心——把 entry._injected 全部消息按普通
21
+ * user 回合推入子代理历史(pushReal → 下轮 chat 即含该指令)并清空队列。独立导出供测试
22
+ * 直接锁该接缝(AC2——"子下回合边界收到并作普通 user 指令"的历史落点)。consumeInjected
23
+ * 闭包即调用本函数。N2:不打断在跑工具——本函数只在回合边界(agent.mjs 循环头)被调。
24
+ * @returns {number} 本次投递条数(空队列 0)
25
+ */
26
+ export function drainInjectedQueue(entry, agent) {
27
+ const q = entry?._injected
28
+ if (!Array.isArray(q) || q.length === 0) return 0
29
+ const msgs = q.splice(0)
30
+ for (const m of msgs) pushReal(agent, { role: "user", content: String(m) })
31
+ return msgs.length
32
+ }
33
+
34
+ /**
35
+ * Async branch (AGENT-LOOP.md §15 D-A1/D-A6): spawn without waiting.
36
+ * The child runs the EXACT blocking pipeline (runChildPipeline — relay /
37
+ * turn-cap / permission / MIN_REPORT_CHARS / mergeChildMutations all unchanged),
38
+ * but the parent does not await it: the promise is parked in _asyncSubagents and
39
+ * consumed by the auto channel (§19.8 — turn-end collection / suspension digest;
40
+ * the check action is gone). Slot queue (§11.1 D-24a/R14 — 分域): the entry carries a
41
+ * pool domain (_pool = poolDomainOf(role)); running count < limit[its domain]
42
+ * (agent.poolLimits — default engCoder 4 / other 4) → start now; ≥ → enqueue
43
+ * (status "queued", position = queue index) — never rejected, never requiring the
44
+ * model to batch. Domains never block each other (engCoder pool full ≠ explore queued).
45
+ * @returns {string} JSON ack {id, role, status: running|queued[, position][, waiting][, reason]}
46
+ */
47
+ export function executeAsyncSpawn(parent, ctx, role, args, child, input, childOpts, childRunOpts, relayPrefix, childProvider, files, dependsOn) {
48
+ if ((ctx.depth ?? 0) > 0) {
49
+ throw new Error("async spawn only available at the top level")
50
+ }
51
+ parent._asyncSubagents ??= new Map()
52
+ parent._asyncQueue ??= []
53
+ const id = parent._subAgentCounter
54
+ // ED-5(AGENT-LOOP-SUBAGENT.md §6.21)一次性取号令牌消费:取号(subagent-spawn.mjs async
55
+ // 分支 nextSubagentId)与本消费点同步配对、无 await 间隙;漏调分配器 = 直读陈旧 counter
56
+ // ⇒ 抛错(防 map.set 覆写旧条目 = 静默丢报告)。断言通过即置 undefined(一次性)。
57
+ consumeSubagentToken(parent, id, "async spawn", role)
58
+ const entry = {
59
+ id, role, relayPrefix,
60
+ // §11.1 D-24a/R14:池域字段(域判定单一事实源——poolDomainOf——role 枚举见
61
+ // subagent.mjs ROLES;未知角色归 other)——running 计数/补位按域过滤。
62
+ _pool: poolDomainOf(role),
63
+ status: "queued", // 下面按等待态/槽位重定(避免两处判断漂移)
64
+ position: undefined,
65
+ report: null, error: null, done: false, cancelled: false,
66
+ promise: null, _settle: null, _settleSeq: 0,
67
+ // §19.5 D-M5 可决策字段(status 数据装配锚点):model 在 spawn 时记录;
68
+ // startedAt 在 ACTUAL start(queued 等待不计 elapsed);turn/maxTurns 由
69
+ // 下方 onToken 拦截层从子代理 ⟦ev⟧turn 事件镜像(T-M18 正确性断言)。
70
+ model: childProvider?.model ?? null,
71
+ startedAt: null,
72
+ turn: 0, maxTurns: 0,
73
+ // §19.5 D-M6 (round2 #2):条目级 AbortController——cancel 定向 abort 本
74
+ // 条目(runAgent signal 链);Ctrl+C 全停语义不变(基信号 abort 逐链传播)。
75
+ controller: null,
76
+ // §20 D-SD2 域元数据(AGENT-LOOP.md §20):running ∪ queued 条目全带——
77
+ // _files(归一化绝对路径)/ _dependsOn(字符串 id)——冲突检测与补位判据
78
+ // 的事实源;无调度参数 spawn 两字段皆空(legacy——不参与冲突检测——零改动)。
79
+ _files: files,
80
+ _dependsOn: dependsOn,
81
+ _lastQueuedSig: null, // ⟦ev⟧queued 去重 sig(refreshQueuedTokens)
82
+ // SUBAGENT-OBSERVE-SEND D2:父侧 send 注入队列——父 action:'send' push 消息,子
83
+ // 回合边界经 consumeInjected 回调消费(drain + pushReal 成 user 回合);settle 收尾
84
+ // 时仍残留 = 未投递(消息入队后子在下一回合边界前 settle)→ 附 settle 报告提示。
85
+ _injected: [],
86
+ }
87
+ // §20 D-SD3 准入落点:等待态(依赖未满足/域冲突/depc)→ queued(waiting-deps——
88
+ // 不占槽不启动——即使槽空);纯槽满(kind slot)→ 按域计数判定(§11.1 D-24a:
89
+ // runningIn(domain) < limit(domain)——跨域互不阻塞——每次入池判定时读配置)。
90
+ const blockers = describeBlockers(parent, entry)
91
+ if (blockers.kind === "slot") {
92
+ const limits = poolLimitsFor(parent) // 运行期读 + 校验(非法键回退默认——T-24a4)
93
+ entry.status = runningPoolCount(parent, entry._pool) >= (limits[entry._pool] ?? ASYNC_POOL_LIMITS[entry._pool])
94
+ ? "queued" : "running"
95
+ } else {
96
+ entry.status = "queued" // waiting-deps / dependency-cancelled——slot 空也不启动
97
+ }
98
+ // The settle signal — resolves when the run chain settles (never rejects).
99
+ entry.promise = new Promise((res) => { entry._settle = res })
100
+ // §19.5 D-M6:条目 controller 链到会话/回合基信号(_sessionSignal 优先——
101
+ // §17 挂起会话内 children 持会话 signal,digest 自身 Ctrl+C 不误伤)。
102
+ // D6 buildChildSignal 单点(ASYNC-RESULT-CONTAINER.md)。
103
+ const ctrl = new AbortController()
104
+ entry.controller = ctrl
105
+ // §20.3 站点 #10(第 24 批):hop 逐跳保 reason;#98 链结单点(interrupt 豁免面——
106
+ // Ctrl+I 不逐链中止池内子代理)。
107
+ bindChildController(ctrl, buildChildSignal(parent, ctx))
108
+ // §19.5 D-M5:turn 镜像拦截层(callbacks 包装层——在既有
109
+ // wrapChildCallbacks 之外再包一层,只解析 ⟦ev⟧turn 更新条目,其余原样转发)。
110
+ const trackOpts = { ...childOpts }
111
+ const parentOnToken = trackOpts.onToken
112
+ if (parentOnToken) {
113
+ trackOpts.onToken = (t) => {
114
+ const ev = String(t).match(/^⟦ev⟧turn\x1e(\d+)\x1e(\d+)\x1e/)
115
+ if (ev) {
116
+ entry.turn = Number(ev[1]) || 0
117
+ entry.maxTurns = Number(ev[2]) || 0
118
+ }
119
+ return parentOnToken(t)
120
+ }
121
+ // §27 R23 D-R23c1:嵌套 wrapper 标记随镜像层透传(wrapChildCallbacks 产物带
122
+ // _relayPrefix——本层重包后丢失会让异步子代理(默认 async——depth-0)内嵌 spawn
123
+ // 的生成侧补发射失效(emitNestedChildEvent 判据)——同步复制标记保语义)。
124
+ trackOpts.onToken._relayPrefix = parentOnToken._relayPrefix ?? null
125
+ }
126
+ entry.start = () => {
127
+ entry.status = "running"
128
+ entry.position = undefined
129
+ entry.startedAt = Date.now()
130
+ // §19.5.6 D-SF1(round3 #1):绑定子代理对象引用——绑定时刻 = 实际启动时
131
+ // (queued 条目 spawn-ack 时刻尚无子代理对象——§20 D-SD3b);绑定对象 = 子代理
132
+ // 对象(不是 _touchedFiles 数组引用——per-run 记账在 prepareRun 重置——数组
133
+ // 引用会陈旧——对象引用保证 status 查询时实时读——杀前一刻最新)。
134
+ entry.childAgent = child
135
+ // SUBAGENT-OBSERVE-SEND D2:子侧输入源贯通(设计"硬缺口"闭合)——把"注入队列消费
136
+ // 回调"经 childRunOpts 塞进 runAgent opts(runChildPipeline → runWithContinue →
137
+ // runAgent),子回合边界(agent.mjs 每轮开头的 consumeInjected?.() 点)消费
138
+ // entry._injected → pushReal 成 user 回合进子历史——当作普通用户指令处理。回调闭包
139
+ // 持 entry + child;空队列 no-op(每轮尝试——零开销)。N2:不打断在跑工具——入队
140
+ // 消息只在下一回合边界(当前工具完成后的下轮 chat 前)进上下文。
141
+ const consumeInjected = (ag) => { drainInjectedQueue(entry, ag ?? child) }
142
+ // §19.5 D-M7b ①: async 标记事件——零字段 ⟦ev⟧async token(sync 不发)。
143
+ // 锚点 = 实际启动(与 [model] 同步——queued 入队不 paint,补位启动才发);
144
+ // 先于 [model] 发出——区块创建即知 sub.async(routeSubToken 解析——
145
+ // ⏹ 门控与头标 async 的判定源)。父级直接 emit(depth-0 专属路径——
146
+ // 不经子代理文本 strip 白名单——与 ⟦ev⟧stopped/settled 同族)。
147
+ ctx.callbacks?.onToken?.(relayPrefix + "⟦ev⟧async\x1e")
148
+ // Deferred [model] emit: the TUI block is created at ACTUAL start.
149
+ ctx.callbacks?.onToken?.(relayPrefix + "[model]" + (childProvider.model ?? ""))
150
+ // Turn-cap on background children NEVER pops the continue panel (D-A3):
151
+ // §15 D-A3 exception (2026-09-02 unified rule, AGENT-LOOP.md §2): in an
152
+ // engineering && AUTO session the child auto-resumes — the user authorized
153
+ // unattended runs, no one is at the panel. Every other tier auto-declines
154
+ // and the partial-work report carries the cap reason. §18 D-E2 relies on
155
+ // this exception as the turn-cap fallback for the default-async eng-coder
156
+ // delivery (the internal protocol does not raise the 100-turn cap).
157
+ runChildPipeline(child, input, trackOpts, { ...childRunOpts, signal: entry.controller.signal, consumeInjected }, {
158
+ parent, role, args,
159
+ askContinue: () => Promise.resolve(Boolean(parent.config?.agent?.engineering && parent.autoApprove)),
160
+ })
161
+ .then((report) => { entry.report = report })
162
+ // §20.3 第 3 条合成器(第 24 批):原 message 前缀逐字保留 + 来源后缀
163
+ .catch((err) => { entry.error = deathLine(err, entry.controller?.signal) })
164
+ .finally(() => {
165
+ // SUBAGENT-OBSERVE-SEND D3(send→settle 竞态):settle 收尾时 _injected 仍残留
166
+ // = 消息入队后子代理在下一回合边界前 settle——未投递——附 settle 报告/错误提示
167
+ // (防父误以为引导已落地)。error 路径附 error;报告路径附 report 尾。
168
+ const undelivered = Array.isArray(entry._injected) ? entry._injected.length : 0
169
+ if (undelivered > 0) {
170
+ const note = `\n[note: ${undelivered} message(s) queued via subagent action:'send' were NOT delivered — the subagent settled before its next turn boundary; re-spawn with the direction if it still applies.]`
171
+ if (entry.error != null) entry.error += note
172
+ else entry.report = `${entry.report ?? ""}${note}`
173
+ entry._injected = [] // 子已 settle——消费面终——清空防重复提示(提示已随报告携带)
174
+ }
175
+ // settle 公共收尾单点(ASYNC-RESULT-CONTAINER.md D3——settleAsyncEntry):日志三连
176
+ // (ev:cancelled/child:done|error/ev:settled)/cancelled 分支(出池+墓碑+⟦ev⟧stopped
177
+ // +提醒)/挂起分流(pending 单容器+出池——统一守卫 !parentAborted——D4)/公共尾部
178
+ // (settleSeq/_settle/唤醒 waiter + 腾槽补位——helper 尾部恒补——见 async-settle.mjs)。
179
+ settleAsyncEntry(parent, entry, {
180
+ pool: parent._asyncSubagents,
181
+ ctx,
182
+ })
183
+ })
184
+ }
185
+ // ED-5(§6.21)入池键守卫:同 id 二次入池 = 覆写(静默丢报告 + status/cancel 错址)⇒ 抛错。
186
+ assertPoolKeyFree(parent._asyncSubagents, id, role)
187
+ parent._asyncSubagents.set(String(id), entry)
188
+ // LOGGING(LOGGING.md):child:spawn(async——注册即事件;status 记 queued/running 分流;
189
+ // 实际启动由补位 start() 触发——运行中由子内 llm/tool 事件可见)
190
+ logEvent("child:spawn", { role, id: `${role}#${id}`, kind: "async", status: entry.status, ms: 0 })
191
+ if (entry.status === "queued") {
192
+ parent._asyncQueue.push(entry)
193
+ entry.position = parent._asyncQueue.length
194
+ // §20 D-SD3b:排队 spawn 返回即建面板 waiting 块(⟦ev⟧queued 事件——spawn 侧
195
+ // 发——TUI routeSubToken 消费建块/更新头;启动后 ⟦ev⟧async 转 running——同 key
196
+ // 不重建)。refreshQueuedTokens 同时校正既有排队条目的位置/等待态头。
197
+ refreshQueuedTokens(parent, ctx.callbacks?.onToken)
198
+ const blk = describeBlockers(parent, entry)
199
+ const out = { id: String(id), role, status: "queued", position: entry.position }
200
+ if (blk.kind !== "slot") {
201
+ out.waiting = blk.kind === "depc" ? "dependency-cancelled" : "waiting-deps"
202
+ out.reason = blk.detail
203
+ }
204
+ return JSON.stringify(out)
205
+ }
206
+ entry.start()
207
+ return JSON.stringify({ id: String(id), role, status: "running" })
208
+ }
@@ -0,0 +1,446 @@
1
+ /**
2
+ * subagent-scheduler.mjs — §20 子 agent 任务调度器 + 文件域组(2026-09-05 自
3
+ * subagent-async.mjs 拆分——Module Split Policy §20.9——纯迁移零行为变化——
4
+ * AGENT-LOOP.md §20 D-SD1..SD5 + §21.1 D-SL1 环形死锁修正)。
5
+ * 内容:normalizeFileList / filesOverlap / effectiveFiles(SCHEDULER-DYNAMIC-DOMAIN
6
+ * 动态域——声明 ∪ running touched)/ depInfo / describeBlockers / detectStall(§21.1
7
+ * P-SL2 停滞机械检测)/ queueRunnable / assertNoDepCycle / dependentLabels /
8
+ * refreshQueuedTokens / maybeRefillAsync。
9
+ * ASYNC_POOL_LIMITS/poolLimitsFor/runningPoolCount 回引自 subagent-async.mjs(主体保有——
10
+ * 仅调用期使用——与主体对此处调度符号的回引构成惰性环——无求值期依赖)。
11
+ */
12
+ import { isAbsolute, relative, resolve } from "node:path"
13
+ import { existsSync, statSync } from "node:fs"
14
+ // zero-block 批 P1(TUI.md §6.8.3.2②):排队刷新 relay 异常留痕(禁静默——池状态仍不破坏)。
15
+ import { logEvent } from "../log.mjs"
16
+ // M5 F6(ENGINEERING-MODE-V2-MODULE-DELEGATION §2.3):files 声明面拦截谓词本体落
17
+ // spawn-gates.mjs(纯谓词零依赖——本处只加 import 调用,无环)。
18
+ import { rejectEngineeringFilePaths } from "./spawn-gates.mjs"
19
+ import { poolLimitsFor, runningPoolCount, ASYNC_POOL_LIMITS } from "./subagent-async.mjs"
20
+ // ASYNC-RESULT-CONTAINER.md D1:池 accessor——async-settle.mjs 反向 import 本模块
21
+ // (dependentLabels)构成同款惰性环(函数级绑定——无求值期依赖)。
22
+ // #94(VSC 侧并入):池 / pending / 墓碑读取全走载体吸收(carrierField / getAsyncPool /
23
+ // tombstoneOf——CLI 形字段优先 / VSC 形回退 history)。
24
+ import { carrierField, getAsyncPool, tombstoneOf } from "./async-settle.mjs"
25
+
26
+ // ═══════════════════════════════════════════════════════════════════════════
27
+ // §20 子 agent 任务调度器(AGENT-LOOP.md §20——D-SD1..SD5 + 20.4 处置注)
28
+ // 池条目域元数据(D-SD2:entry._files/_dependsOn——running ∪ queued 全带)、准入
29
+ // (D-SD3:域冲突/依赖未满足 → queued 等位)、补位扫描(D-SD4:最早可启动——
30
+ // 依赖全满足 + 域无冲突——waiting 越行不阻塞 slot 位)、释放规则(D-SD5——round2
31
+ // #3 锁定默认:依赖取消/失败 → 依赖者留 queued 标 dependency cancelled——仅父显式
32
+ // 处置或 AUTO 自动启动)、终态墓碑(round1 #8/T-SD14:自动通道注入消费与取消写墓碑——
33
+ // consumed 视为满足;非 consumed unknown id 才拒)。状态全部派生不存储(单点事实)。
34
+ // ═══════════════════════════════════════════════════════════════════════════
35
+
36
+ /** 文件域归一化(round1 #5——路径归一化再交集):相对 cwd 解析为绝对路径 + 去重;
37
+ * 非字符串/空项静默跳过(声明错误 = false-negative 明示风险——v1 边界)。
38
+ * §20.8 D-F1.1(2026-09-04):目录声明检测——fail-closed——尾斜杠形态 / 指向既有目录
39
+ * → throw(含路径——错误字符串英文定稿)——目录声明静默绕过冲突检测的通道闭合;
40
+ * 调用方(subagent.mjs spawn 入口)catch → 错误即工具结果(模型可见——无静默)。
41
+ * 已知限制(§20.8 未编号段——评审 #4):不存在的目录声明(无尾斜杠 + 目录未创建)仍通过——不处理。
42
+ * §28 R26 F-R26b(2026-09-07):父侧维护文件黑名单——归一化后 basename 全名匹配 +
43
+ * 大小写不敏感(todo.md/changelog.md 精确——路径任意层含 docs/、根、.thincoder/)——
44
+ * 命中 → throw(fail-closed——先于调度器准入——无排队残留——错误即工具结果);
45
+ * 提示列出全部违规条目——英文模板逐字定稿(AGENT-LOOP.md §28)。
46
+ * M5 F6(2026-09-17):黑名单随同迁入 spawn-gates.mjs(rejectEngineeringFilePaths——族 = ledger.db / CHANGELOG + scripts/**;老台账 md 族随 M2 退役)——本函数只留调用(错误文案逐字保留)。 */
47
+ export function normalizeFileList(files, cwd) {
48
+ const out = []
49
+ for (const f of Array.isArray(files) ? files : []) {
50
+ if (typeof f !== "string" || !f.trim()) continue
51
+ if (f.trimEnd().endsWith("/") || f.trimEnd().endsWith("\\")) { // 2.7 批:尾随空格目录声明("test/ ")同拒
52
+ throw new Error(`files must be file-level paths — directory declarations are not supported: ${f}`)
53
+ }
54
+ const abs = resolve(cwd ?? process.cwd(), f)
55
+ if (existsSync(abs) && statSync(abs).isDirectory()) {
56
+ throw new Error(`files must be file-level paths — directory declarations are not supported: ${f}`)
57
+ }
58
+ if (!out.includes(abs)) out.push(abs)
59
+ }
60
+ // M5 F6(ENGINEERING-MODE-V2-MODULE-DELEGATION §2.2):scripts/** + 过程档拦截——
61
+ // 谓词本体落 spawn-gates.mjs(rejectEngineeringFilePaths 收集全部违规 + 抛合并错误);
62
+ // 循环内目录声明先行(错误序不变——目录错误先于过程档错误)。父侧维护文件
63
+ // basename 族(ledger.db / CHANGELOG)随同迁入谓词——错误文案逐字保留。
64
+ if (files !== undefined && files !== null) rejectEngineeringFilePaths(files)
65
+ return out
66
+ }
67
+
68
+ /** 文件域相等比较键:Windows 大小写不敏感(vs Uri.fsPath 小写盘符差异同族——
69
+ * normalizeCwd 先例)——src/x vs ./src/X 在 win32 是同一文件。 */
70
+ const fileKey = (p) => (process.platform === "win32" ? p.toLowerCase() : p)
71
+
72
+ /** 两文件域首个共同文件(比较键)——无交集 null。 */
73
+ export function filesOverlap(a, b) {
74
+ if (!a?.length || !b?.length) return null
75
+ const keys = new Set(b.map(fileKey))
76
+ const hit = a.map(fileKey).find((k) => keys.has(k))
77
+ return hit ?? null
78
+ }
79
+
80
+ /** 冲突文件的显示形态(优先相对 cwd——面板/返回文本可读)。 */
81
+ function showFile(parent, key) {
82
+ const cwd = parent.cwd ?? process.cwd()
83
+ const rel = relative(cwd, key)
84
+ return rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : key
85
+ }
86
+
87
+ /** 有效文件域(SCHEDULER-DYNAMIC-DOMAIN 2026-09-09——动态域 = 声明 ∪ 运行中实际写入):
88
+ * running 且已绑 childAgent → 声明域 ∪ childAgent._touchedFiles(写工具批提交实时记录
89
+ * ——绝对路径与 normalizeFileList 同源——fileKey/filesOverlap 键空间零改动);去重按
90
+ * fileKey。queued 无 childAgent(start 才绑——VSC 首 onAgentTurn 才绑)——天然只声明域
91
+ * (`?.` null 安全——既有无 childAgent 用例零行为变化)。 */
92
+ export function effectiveFiles(e) {
93
+ const declared = e._files ?? []
94
+ if (e.status !== "running" || !e.childAgent) return declared
95
+ const out = [...declared]
96
+ const seen = new Set(declared.map(fileKey))
97
+ for (const f of e.childAgent._touchedFiles ?? []) {
98
+ const k = fileKey(f)
99
+ if (!seen.has(k)) { seen.add(k); out.push(f) }
100
+ }
101
+ return out
102
+ }
103
+
104
+ /**
105
+ * §20 依赖终态查询(单点事实——池条目 / pending(挂起期 settle 移交——注入前)/
106
+ * 终态墓碑(自动通道注入消费——consumed;取消/失败——D-SD5 分支)):
107
+ * - ok = settle 成功(报告已产出)/ consumed(自动通道注入消费——T-SD14 视为满足)
108
+ * - pending = running/queued 未终态(等启动/等完成)
109
+ * - failed / cancelled = 终态但非成功——依赖者走 dependency cancelled 分支(round2 #3)
110
+ * - discarded = 中止丢弃(批 4 CLI-ASYNC-DISCARD——报告不可达)⇒ 归 cancelled 口径(F4)
111
+ * - unknown = 从未存在(spawn 时明确错误——非 consumed 的 unknown 拒——T-SD10)
112
+ */
113
+ export function depInfo(parent, id) {
114
+ const key = String(id)
115
+ const e = getAsyncPool(parent, "subagent")?.get(key)
116
+ if (e) {
117
+ if (e.cancelled) return { state: "cancelled", role: e.role }
118
+ if (e.done) return e.error != null ? { state: "failed", role: e.role } : { state: "ok", role: e.role }
119
+ return { state: "pending", role: e.role }
120
+ }
121
+ // ASYNC-RESULT-CONTAINER.md D2:pending 单容器(四族统一停靠——依赖目标挂起期 settle
122
+ // 移交也在此——注入前视为已消费)。载体吸收(#94——VSC 形 pending 挂 history)。
123
+ const pend = (carrierField(parent, "_pendingAsyncResults") ?? []).find((x) => String(x.id) === key)
124
+ if (pend) return pend.error != null ? { state: "failed", role: pend.role } : { state: "ok", role: pend.role }
125
+ const t = tombstoneOf(parent, key)
126
+ if (t) {
127
+ // F4(批 4 CLI-ASYNC-DISCARD):丢弃墓碑 = 报告不可达 ⇒ 归 cancelled 口径(D-SD5 分支)。
128
+ const st0 = t.status === "discarded" ? "cancelled" : t.status
129
+ return { state: st0 === "cancelled" || st0 === "failed" ? st0 : "ok", role: t.role }
130
+ }
131
+ return { state: "unknown", role: null }
132
+ }
133
+
134
+ /** §20 等待态派生(无存储——refill/status/面板/spawn 返回同一事实源)。kind:
135
+ * - slot = 无阻塞(依赖全满足 + 域无冲突)——纯槽满等位(可启动——等 slot)
136
+ * - wait = 依赖未完成 / 域冲突(running ∪ queued——D-SD3 同界——self 除外)
137
+ * - depc = 依赖取消/失败(round2 #3——非 AUTO 锁住——需父显式处置;AUTO 视为可启动)
138
+ * detail 为状态行共享文本(面板 waiting for 标注 / status reason / spawn reason)。 */
139
+ export function describeBlockers(parent, entry) {
140
+ const wait = []
141
+ const depc = []
142
+ for (const depId of entry._dependsOn ?? []) {
143
+ const info = depInfo(parent, String(depId))
144
+ if (info.state === "pending" || info.state === "unknown") {
145
+ wait.push(`${info.role ?? "sub"}#${depId}(依赖未完成)`)
146
+ } else if (info.state === "cancelled" || info.state === "failed") {
147
+ if (parent.autoApprove) continue // AUTO 档自动启动(D-SD5——父不在场由 digest 决策)
148
+ depc.push(`${info.role ?? "sub"}#${depId}`)
149
+ }
150
+ }
151
+ const myFiles = entry._files ?? []
152
+ if (myFiles.length > 0) {
153
+ for (const e of getAsyncPool(parent, "subagent")?.values() ?? []) {
154
+ if (e === entry) continue
155
+ if (e.status !== "running" && e.status !== "queued") continue
156
+ // SCHEDULER-DYNAMIC-DOMAIN(2026-09-09):他条目域 = effectiveFiles(e)(声明 ∪
157
+ // running 实际 touched);命中仅来自 touched(∉ 声明域)→ 加注"运行中实际写入"
158
+ // ——纯声明命中文案不回归(评审 #7 重叠优先级:声明∩touched 同文件不加注)。
159
+ const hitDeclared = filesOverlap(myFiles, e._files ?? [])
160
+ const hit = hitDeclared ?? filesOverlap(myFiles, effectiveFiles(e))
161
+ if (!hit) continue
162
+ // §21.1 D-SL1.2(环形死锁修正——与 queueRunnable 同界——展示一致):后入
163
+ // 者(spawn 序晚于我——数字 id 比较)不列——只列"会真正阻断我的"(running
164
+ // 任意序 + 先入 queued);列后入者 = 误导"等一个其实等不到的人"。
165
+ if (e.status === "queued" && Number(e.id) > Number(entry.id)) continue
166
+ wait.push(`${e.role}#${e.id}(域冲突 ${showFile(parent, hit)}${hitDeclared ? "" : "(运行中实际写入)"})`)
167
+ }
168
+ }
169
+ // 长列表裁剪(块头宽度预算——细节 status 可查全量)
170
+ const cut = (arr) => (arr.length > 3 ? [...arr.slice(0, 3), `…(共 ${arr.length} 项)`] : arr)
171
+ if (depc.length > 0) {
172
+ const body = cut(depc).join("、")
173
+ return { kind: "depc", detail: wait.length > 0 ? `dependency cancelled: ${body};${cut(wait).join("、")}` : `dependency cancelled: ${body} — waiting for your decision (cancel this task to release, or AUTO starts it)` }
174
+ }
175
+ if (wait.length > 0) return { kind: "wait", detail: `waiting for: ${cut(wait).join("、")}` }
176
+ return { kind: "slot", detail: "" }
177
+ }
178
+
179
+ /** §21.1 P-SL2(D-SL2——混合边环形等待停滞机械检测——AGENT-LOOP.md §21.1 扩展注):
180
+ * 判据(收窄——零误报优先——宁可漏报不可误打断):池内 running = 0 && queued ≥ 2 &&
181
+ * 无 dependency-cancelled 标记条目 && 每 queued 的 blocker(files 冲突者 + 未 settle
182
+ * 依赖目标——与 describeBlockers/queueRunnable 同界——"真正会阻断我的"语义:files 只
183
+ * 算 running 任意序 + 先入 queued(D-SL1.1 序判定);依赖 pending = 池内未终态)⊆
184
+ * queued 集且非空(闭包无外逃 + 无 runnable 漏网)→ 停滞。返回 { chains }(每 queued
185
+ * 条目一条阻塞链——沿首个 blocker 走到首个重复节点(闭环)——每节点自带"等谁 + 为何等"
186
+ * 理由注(`X(reason) → Y` 形态——X 等 Y 因 reason);不满足 → null。
187
+ * 不报(F-SL2.2 收窄判据):running 锚点存在(依赖链/文件串行正常排队);depc 滞留
188
+ * (外部决策可解——§20 NF-SD 滞留有意——cancel 先入者即释放);单 queued;blocker
189
+ * 外逃(unknown 依赖目标/池外条目);blocker 空(runnable——settle/cancel 驱动 refill
190
+ * 会启动——非停滞)。自然流程中 wait 边恒指向先入者(依赖必须先前 spawn——unknown 拒 +
191
+ * 文件冲突只阻断后入者——id 序)——混合环仅人工注入可构造(T-SD5 同族防御断言)——
192
+ * 但状态既可能成环即机械可检——不依赖可达性论证。maybeRefillAsync 空转处不调用:refill
193
+ * 嵌在 settle/cancel 链上无模型可见输出通道(抛错即破坏池操作)——status 视图(actions
194
+ * 模块)即 F-SL2 的报错/标记落点(§19.8:check 守卫随 check 删除)。 */
195
+ export const STALL_NOTE = "cancel one task in the loop (action:'cancel') to break the cycle, then re-spawn it"
196
+
197
+ export function detectStall(parent) {
198
+ const map = getAsyncPool(parent, "subagent")
199
+ if (!(map instanceof Map) || map.size === 0) return null
200
+ const all = [...map.values()]
201
+ if (all.some((e) => e.status === "running")) return null // ① running 锚点——正常排队不报
202
+ const queued = all.filter((e) => e.status === "queued" && !e.cancelled)
203
+ if (queued.length < 2) return null // ② 单 queued——非停滞不报
204
+ // ③ 每 queued 的结构化 blocker 表(阻塞边 + 理由——镜像 describeBlockers 同界:
205
+ // 依赖 pending/unknown 阻断(cancelled/failed 非 AUTO = depc 标记——整池不报);
206
+ // files 冲突 running 任意序 + 先入 queued——后入者不阻断(D-SL1.1 同判)。
207
+ const edgesOf = new Map()
208
+ for (const q of queued) {
209
+ const list = []
210
+ for (const depId of q._dependsOn ?? []) {
211
+ const st = depInfo(parent, String(depId)).state
212
+ if (st === "pending") {
213
+ list.push({ to: map.get(String(depId)), reason: `dependsOn ${String(depId)}` })
214
+ } else if (st === "unknown") {
215
+ return null // 依赖目标池外(unknown)——外逃 blocker——不报(保守)
216
+ } else if (!parent.autoApprove) {
217
+ return null // dep-cancelled/failed 标记条目——外部决策可解——不报(F-SL2.2)
218
+ }
219
+ }
220
+ const myFiles = q._files ?? []
221
+ if (myFiles.length > 0) {
222
+ for (const e of all) {
223
+ if (e === q) continue
224
+ if (e.status !== "running" && e.status !== "queued") continue
225
+ const hit = filesOverlap(myFiles, effectiveFiles(e)) // 动态域(running 锚点守卫下零增量——读法同界防御一致)
226
+ if (!hit) continue
227
+ if (e.status === "queued" && Number(e.id) > Number(q.id)) continue // 后入不阻断
228
+ list.push({ to: e, reason: `files ${showFile(parent, hit)}——先入者` })
229
+ }
230
+ }
231
+ if (list.length === 0) return null // ④ 无 blocker = runnable——refill 会启动——非停滞
232
+ edgesOf.set(q, list)
233
+ }
234
+ // ⑤ 闭包无外逃:任一 blocker 落 queued 集外(含 cancelled/池外异常形态)→ 不报
235
+ for (const list of edgesOf.values()) {
236
+ for (const b of list) {
237
+ if (b.to == null || !queued.includes(b.to)) return null
238
+ }
239
+ }
240
+ // 链组装:每 queued 条目一条——沿首个 blocker 走到首个重复节点(闭环)——每节点
241
+ // 自带"我等谁 + 为何等"段(`X(reason) → Y`——本节点 blocker 理由注于本节点旁——
242
+ // 读取无歧义:X 等 Y 因为 reason——dependsOn 边不误读为目标属性——§21.1 扩展注
243
+ // 示例同信息形态——闭环以起始节点重复闭合(末节点不再注——闭合自明)。
244
+ const chains = []
245
+ for (const start of queued) {
246
+ const seen = []
247
+ let cur = start
248
+ for (;;) {
249
+ seen.push(cur)
250
+ const next = edgesOf.get(cur)?.[0]?.to
251
+ if (!next) break // 防御:预检保证不达(每节点 blocker 非空闭包内)
252
+ if (seen.includes(next)) { seen.push(next); break } // 闭环:重复节点入链闭合
253
+ cur = next
254
+ }
255
+ const frags = []
256
+ for (let i = 0; i < seen.length; i++) {
257
+ const edge = i < seen.length - 1 ? edgesOf.get(seen[i])?.[0] : null // 末节点=闭环重复——不再注
258
+ frags.push(`${seen[i].role}#${seen[i].id}${edge ? `(${edge.reason})` : ""}`)
259
+ }
260
+ chains.push({ task: start, text: frags.join(" → ") })
261
+ }
262
+ return { chains }
263
+ }
264
+
265
+
266
+ /** §6.9 终态守卫谓词单点(c1——2026-09-17 af 批 · 族无关):**取消 / 已完成条目永不启动**
267
+ * (守卫只跳过、不剔除、不重编号——队列内容零副作用)。两消费点 = 本档 `queueRunnable`
268
+ * (`maybeRefillAsync` 经它判 · 子代理族)· `refillAdvisorQueue`(评审族)——动机 = 台账
269
+ * #31:「取消后仍留队列 → 补位 `start()` 重启 ⇒ 幻影冻结块」的唯一燃料(族无关封死)。 */
270
+ export function entryTerminal(entry) {
271
+ return entry?.cancelled === true || entry?.done === true
272
+ }
273
+
274
+ /** §20 D-SD4 补位判据:依赖全满足(AUTO 下 depc 放行)+ 域无冲突(running 任意序 +
275
+ * queued 先入者——§21.1 D-SL1.1 序判定:同文件串行 = 先入者先启动、后入者等先入者
276
+ * ——不自锁;先入者启动后以 running 身份继续挡住后入者——self 除外)。
277
+ * 已知限制(§21.1 评审 #4——与 §20 NF-SD 同语义——滞留有意义不静默):先入者被
278
+ * depc 锁定时(依赖取消/失败且非 AUTO——永不自动启动),后入者滞留等它——cancel
279
+ * 先入者即释放(父显式可清;AUTO 档 depc 视为可启动——不滞留)。 */
280
+ export function queueRunnable(parent, entry) {
281
+ // §6.9 终态守卫(c1——族无关单点谓词):终态条目(cancelled / done)不启动——
282
+ // 与依赖 / 域判据并列的头判(队列内容零副作用——只跳过)。
283
+ if (entryTerminal(entry)) return false
284
+ for (const depId of entry._dependsOn ?? []) {
285
+ const state = depInfo(parent, String(depId)).state
286
+ if (state === "pending" || state === "unknown") return false
287
+ if ((state === "cancelled" || state === "failed") && !parent.autoApprove) return false
288
+ }
289
+ const myFiles = entry._files ?? []
290
+ if (myFiles.length > 0) {
291
+ for (const e of getAsyncPool(parent, "subagent")?.values() ?? []) {
292
+ if (e === entry) continue
293
+ if (e.status !== "running" && e.status !== "queued") continue
294
+ if (!filesOverlap(myFiles, effectiveFiles(e))) continue // 动态域:声明 ∪ running touched
295
+ // §21.1 D-SL1.1 序判定:queued 仅"先入者"(spawn 序早于我——数字 id 比较)阻断;
296
+ // 后入者不阻断——先入者先启动——两个 queued 同文件不再互等(环形死锁修正)。
297
+ // 防御(评审 #3——id 形态):池条目 id 为数字递增(_subAgentCounter——已核实);
298
+ // 异常形态 Number() 得 NaN → 比较 false → 不跳过 → 保守阻断(宁可多等——
299
+ // 不冒险并发——防 NaN 误放行)。
300
+ if (e.status === "queued" && Number(e.id) > Number(entry.id)) continue // 后入者不阻断——先入者先启动
301
+ return false
302
+ }
303
+ }
304
+ return true
305
+ }
306
+
307
+ /** §20 D-SD5 环防御(round2 #5——自然流程不可达:unknown id 拒 + spawn 序天然无环——
308
+ * 仅人工向池注入可构造——防御断言定位):从新 spawn 的依赖集出发沿池内条目
309
+ * _dependsOn 边做路径 DFS——路径上重复访问(可达环)→ 拒绝(A→B→A 永不自启——
310
+ * 错误明确——T-SD5)。运行/排队条目皆可成环节点;池小(≤4 槽 + 有限队列)深度有限。 */
311
+ export function assertNoDepCycle(parent, dependsOn) {
312
+ const edges = new Map()
313
+ for (const e of getAsyncPool(parent, "subagent")?.values() ?? []) {
314
+ if (e.status === "running" || e.status === "queued") {
315
+ edges.set(String(e.id), (e._dependsOn ?? []).map(String))
316
+ }
317
+ }
318
+ const onPath = new Set()
319
+ const visit = (id) => {
320
+ if (onPath.has(id)) {
321
+ throw new Error(`subagent dependsOn cycle detected: ${[...onPath, id].join(" → ")} — entries in a dependency loop can never start; cancel the dependents and restructure the chain`)
322
+ }
323
+ onPath.add(id)
324
+ for (const dep of edges.get(id) ?? []) visit(dep)
325
+ onPath.delete(id)
326
+ }
327
+ for (const d of dependsOn) visit(String(d))
328
+ }
329
+
330
+ /** 依赖某 id 的 queued 条目显示标签(D-SD5 提醒/标注——依赖者列表)。 */
331
+ export function dependentLabels(parent, depId) {
332
+ const key = String(depId)
333
+ const out = []
334
+ for (const e of parent._asyncQueue ?? []) {
335
+ if ((e._dependsOn ?? []).some((d) => String(d) === key)) out.push(`${e.role}#${e.id}`)
336
+ }
337
+ return out
338
+ }
339
+
340
+ /** §20 D-SD3b 排队态面板刷新(⟦ev⟧queued 事件族——TUI routeSubToken 消费):对全部
341
+ * queued 条目重算等待态并发射变化(去重 sig——kind/position/detail 全变才发)——
342
+ * 调用点 = 一切队列突变与等待态变迁(spawn 入队 / settle 后补位与依赖转移 / cancel
343
+ * 出队 / 自动通道消费)。position = 队列序(D-A1 既有——cancel 前移同源)。
344
+ * P1 禁静默(zero-block 批 §6.8.3.2②):relay 异常不再静默——留痕一条
345
+ * `ev:queued-paint-failed`(**池状态不被破坏**语义不变——catch 不重抛)。 */
346
+ export function refreshQueuedTokens(parent, onToken) {
347
+ if (typeof onToken !== "function") return
348
+ const queue = parent._asyncQueue ?? []
349
+ for (let i = 0; i < queue.length; i++) {
350
+ const e = queue[i]
351
+ const blk = describeBlockers(parent, e)
352
+ const sig = `${blk.kind}\x1e${i + 1}\x1e${blk.detail}`
353
+ if (e._lastQueuedSig === sig) continue
354
+ e._lastQueuedSig = sig
355
+ try {
356
+ onToken(`${e.relayPrefix}⟦ev⟧queued\x1e${blk.kind}\x1e${i + 1}\x1equeued\x1e${blk.detail}`)
357
+ } catch {
358
+ // relay 失败不影响池状态(现状);P1 禁静默:留痕一条(§6.8.3.2②)。
359
+ logEvent("ev:queued-paint-failed", { id: `${e.role}#${e.id}` })
360
+ }
361
+ }
362
+ }
363
+
364
+ /**
365
+ * Slot-queue refill (AGENT-LOOP.md §15 D-A1/D-A6 + §20 D-SD4 + §11.1 D-24a/R14 分域):
366
+ * start queue heads while a running slot is free — called from every settle
367
+ * (completion frees a slot) and from the turn-end collection's refill loop. §20:
368
+ * 队列现可混合 waiting-deps 与 slot-queued——扫描选"依赖全满足 + 域无冲突"的最早
369
+ * 条目启动(waiting 越行不阻塞槽位;多任务同时解除按 queued 序逐个启动)。
370
+ * §11.1 D-24a(R14):槽位判定按域——条目 _pool 域内 running 数 < 该域上限才启动
371
+ * (同域仍 4——纯单域队列行为与旧 shift 完全一致;跨域总量 8——各域独立腾槽,
372
+ * 互不阻塞)。配置每次补位时读(poolLimitsFor——变更即生效下个补位)。
373
+ */
374
+ export function maybeRefillAsync(parent) {
375
+ const queue = parent._asyncQueue ?? []
376
+ for (;;) {
377
+ let pick = -1
378
+ for (let i = 0; i < queue.length; i++) {
379
+ const e = queue[i]
380
+ if (!queueRunnable(parent, e)) continue
381
+ const pool = e._pool ?? "other" // 缺域字段(手工/旧条目)按 other——既有测试语义不变
382
+ if (runningPoolCount(parent, pool) >= (poolLimitsFor(parent)[pool] ?? ASYNC_POOL_LIMITS[pool])) continue
383
+ pick = i
384
+ break
385
+ }
386
+ if (pick < 0) return
387
+ queue.splice(pick, 1)[0].start()
388
+ }
389
+ }
390
+
391
+ /**
392
+ * 分配下一子代理 id——池活续号兜底(SUBAGENT-ID-COUNTER-AGENT,2026-09-09——双端同构
393
+ * 机制:VSC 端载体自 history expando 移本体并接本函数;CLI 端计数器一直挂 agent 本体
394
+ * (`_subAgentCounter`——per-run reset 清单不含——跨 run/跨压缩存活),本函数补齐
395
+ * poolMax 兜底:计数器丢失面(AUTO 撞上限续跑/挂起驱动的重建形态)不再复用仍在
396
+ * 跑/排队的条目 id——map.set 覆盖旧条目 = 静默丢报告 + status/cancel 错址)。优先级
397
+ * next = max(counter ?? 0, poolMax) + 1——取号后 counter 同步(首取号初始化)。id 作用
398
+ * 域 = 进程内(不做槽持久化——reload 后池清块消失,新进程从 1 无冲突——设计范围边界)。
399
+ * spawn(subagent-spawn.mjs async 分支)、escalate(escalate-async.mjs)、async-advisor
400
+ * 池(advisor-async.mjs——§11.2 跨池共号)共用;executeAsyncSpawn 直读 counter(分配与
401
+ * 消费同步——无 await 间隙)。CLI 两池键均为字符串(set(String(id))——解析分支防御保留)。
402
+ * @returns {number} 全池唯一的下一 id(单调——进程内)
403
+ */
404
+ export function nextSubagentId(parent) {
405
+ let poolMax = 0
406
+ for (const pool of [carrierField(parent, "_asyncSubagents"), carrierField(parent, "_asyncAdvisors")]) {
407
+ if (!pool || pool.size === 0) continue
408
+ for (const k of pool.keys()) {
409
+ const n = typeof k === "number" ? k : Number.parseInt(k, 10)
410
+ if (Number.isFinite(n) && n > poolMax) poolMax = n
411
+ }
412
+ }
413
+ const next = Math.max(parent?._subAgentCounter ?? 0, poolMax) + 1
414
+ parent._subAgentCounter = next
415
+ // ED-5(AGENT-LOOP-SUBAGENT.md §6.21):一次性取号令牌——与 counter 同段原子写
416
+ // (取号 → 消费同步、无 await 间隙);消费点断言同号后置 undefined(漏调分配器 ⇒ 抛错,
417
+ // 非静默覆写)。入池键守卫 = assertPoolKeyFree(下方)。
418
+ parent._lastSubagentId = next
419
+ return next
420
+ }
421
+
422
+ /**
423
+ * ED-5(AGENT-LOOP-SUBAGENT.md §6.21)一次性取号令牌消费——三消费点同族断言:
424
+ * `parent._lastSubagentId === id`(取号 → 消费同步配对,无 await 间隙;漏调
425
+ * nextSubagentId ⇒ 令牌缺位/错号 ⇒ 抛错——防直读陈旧 counter 的静默覆写),断言
426
+ * 通过即置 `undefined`(一次性——同一令牌跨站点复用第二消费必抛)。
427
+ * 调用点:executeAsyncSpawn(subagent-run.mjs)· launchEscalateAsync(escalate-async.mjs)·
428
+ * launchAsyncAdvisor(advisor-async.mjs)——site = 站点名(错误文案定位面)。
429
+ */
430
+ export function consumeSubagentToken(parent, id, site, role) {
431
+ if (parent?._lastSubagentId !== id) {
432
+ throw new Error(`subagent id allocator not called (nextSubagentId) before ${site}: ${role}#${id}`)
433
+ }
434
+ parent._lastSubagentId = undefined
435
+ }
436
+
437
+ /**
438
+ * ED-5(§6.21)入池键守卫——三入池点同族:`set(String(id))` 前断言键不存在;命中 =
439
+ * 覆写(静默丢报告 + status/cancel 错址)⇒ 抛错。调用点:子代理池(subagent-run.mjs)·
440
+ * escalate(escalate-async.mjs)· advisor 池(advisor-async.mjs)。
441
+ */
442
+ export function assertPoolKeyFree(pool, id, role) {
443
+ if (pool.has(String(id))) {
444
+ throw new Error(`subagent id collision: ${role}#${id} already in pool`)
445
+ }
446
+ }