@zhushanwen/pi-subagent-workflow 0.1.0

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 (143) hide show
  1. package/agents/context-builder.md +17 -0
  2. package/agents/general-purpose.md +16 -0
  3. package/agents/oracle.md +17 -0
  4. package/agents/planner.md +17 -0
  5. package/agents/researcher.md +17 -0
  6. package/agents/reviewer.md +17 -0
  7. package/agents/scout.md +17 -0
  8. package/agents/worker.md +16 -0
  9. package/examples/README.md +43 -0
  10. package/examples/chain.example.js +92 -0
  11. package/examples/map-reduce.example.js +99 -0
  12. package/examples/parallel.example.js +82 -0
  13. package/examples/scatter-gather.example.js +106 -0
  14. package/index.ts +1 -0
  15. package/package.json +66 -0
  16. package/skills/workflow-script-format/SKILL.md +328 -0
  17. package/src/execution/__tests__/agent-registry.test.ts +164 -0
  18. package/src/execution/__tests__/agent-result-mapper.test.ts +128 -0
  19. package/src/execution/__tests__/alive-store.test.ts +147 -0
  20. package/src/execution/__tests__/bg-notify-render.test.ts +256 -0
  21. package/src/execution/__tests__/concurrency-pool.test.ts +217 -0
  22. package/src/execution/__tests__/config.test.ts +110 -0
  23. package/src/execution/__tests__/crash-recovery.test.ts +311 -0
  24. package/src/execution/__tests__/execute-nesting.test.ts +359 -0
  25. package/src/execution/__tests__/execute-options-mapper.test.ts +138 -0
  26. package/src/execution/__tests__/execution-record.test.ts +959 -0
  27. package/src/execution/__tests__/finalized-marker.test.ts +82 -0
  28. package/src/execution/__tests__/format-schema-instruction.test.ts +135 -0
  29. package/src/execution/__tests__/format.test.ts +320 -0
  30. package/src/execution/__tests__/helpers/mock-extension-api.ts +30 -0
  31. package/src/execution/__tests__/list-component.test.ts +347 -0
  32. package/src/execution/__tests__/model-resolver.test.ts +356 -0
  33. package/src/execution/__tests__/output-collector.test.ts +61 -0
  34. package/src/execution/__tests__/path-encoding.test.ts +75 -0
  35. package/src/execution/__tests__/pi-invocation.test.ts +73 -0
  36. package/src/execution/__tests__/record-store.test.ts +545 -0
  37. package/src/execution/__tests__/run-spawn-edges.test.ts +439 -0
  38. package/src/execution/__tests__/run-spawn-integration.test.ts +897 -0
  39. package/src/execution/__tests__/sdk-contract.test.ts +272 -0
  40. package/src/execution/__tests__/session-context-resolver.test.ts +167 -0
  41. package/src/execution/__tests__/session-file-gc.test.ts +247 -0
  42. package/src/execution/__tests__/session-reconstructor.test.ts +359 -0
  43. package/src/execution/__tests__/session-runner-schema-env.test.ts +314 -0
  44. package/src/execution/__tests__/session-start-reaper.test.ts +227 -0
  45. package/src/execution/__tests__/spawn-args.test.ts +244 -0
  46. package/src/execution/__tests__/spawn-event-adapter.test.ts +167 -0
  47. package/src/execution/__tests__/subagent-service.test.ts +678 -0
  48. package/src/execution/__tests__/subprocess-agent-runner.test.ts +389 -0
  49. package/src/execution/__tests__/temp-prompt.test.ts +53 -0
  50. package/src/execution/__tests__/timeout-integration.test.ts +381 -0
  51. package/src/execution/__tests__/tombstone-store.test.ts +73 -0
  52. package/src/execution/__tests__/tool-action.test.ts +330 -0
  53. package/src/execution/__tests__/turn-limiter.test.ts +65 -0
  54. package/src/execution/__tests__/worktree-manager.test.ts +423 -0
  55. package/src/execution/__tests__/worktree-registry.test.ts +161 -0
  56. package/src/execution/agent-registry.ts +252 -0
  57. package/src/execution/agent-result-mapper.ts +84 -0
  58. package/src/execution/alive-store.ts +92 -0
  59. package/src/execution/best-effort.ts +30 -0
  60. package/src/execution/concurrency-pool.ts +84 -0
  61. package/src/execution/config.ts +73 -0
  62. package/src/execution/execute-options-mapper.ts +86 -0
  63. package/src/execution/execution-record.ts +778 -0
  64. package/src/execution/finalized-marker.ts +51 -0
  65. package/src/execution/model-config-service.ts +225 -0
  66. package/src/execution/model-resolver.ts +247 -0
  67. package/src/execution/notifier.ts +168 -0
  68. package/src/execution/output-collector.ts +88 -0
  69. package/src/execution/path-encoding.ts +34 -0
  70. package/src/execution/pi-invocation.ts +70 -0
  71. package/src/execution/record-store.ts +350 -0
  72. package/src/execution/session-context-resolver.ts +64 -0
  73. package/src/execution/session-file-gc.ts +98 -0
  74. package/src/execution/session-reconstructor.ts +450 -0
  75. package/src/execution/session-runner.ts +725 -0
  76. package/src/execution/spawn-event-adapter.ts +150 -0
  77. package/src/execution/subagent-service.ts +973 -0
  78. package/src/execution/subprocess-agent-runner.ts +108 -0
  79. package/src/execution/temp-prompt.ts +57 -0
  80. package/src/execution/tombstone-store.ts +72 -0
  81. package/src/execution/turn-limiter.ts +88 -0
  82. package/src/execution/types.ts +634 -0
  83. package/src/execution/worktree-manager.ts +285 -0
  84. package/src/execution/worktree-registry.ts +144 -0
  85. package/src/index.ts +454 -0
  86. package/src/interface/bg-notify-render.ts +286 -0
  87. package/src/interface/commands.ts +157 -0
  88. package/src/interface/format.ts +501 -0
  89. package/src/interface/gui-adapter.ts +136 -0
  90. package/src/interface/helpers.ts +110 -0
  91. package/src/interface/list-component.ts +643 -0
  92. package/src/interface/list-shared.ts +84 -0
  93. package/src/interface/list-view.ts +373 -0
  94. package/src/interface/reentry-guard.ts +30 -0
  95. package/src/interface/subagent-actions.ts +294 -0
  96. package/src/interface/subagent-tool.ts +294 -0
  97. package/src/interface/subagents.ts +30 -0
  98. package/src/interface/tool-render.ts +333 -0
  99. package/src/interface/tool-workflow-script.ts +351 -0
  100. package/src/interface/tool-workflow.ts +485 -0
  101. package/src/interface/views/WorkflowsView.ts +944 -0
  102. package/src/interface/views/detail-content.ts +298 -0
  103. package/src/interface/views/format.ts +320 -0
  104. package/src/orchestration/__tests__/concurrency-gate.test.ts +125 -0
  105. package/src/orchestration/__tests__/config-loader.test.ts +381 -0
  106. package/src/orchestration/__tests__/error-recovery-handlers.test.ts +332 -0
  107. package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +166 -0
  108. package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +248 -0
  109. package/src/orchestration/__tests__/lifecycle.test.ts +385 -0
  110. package/src/orchestration/__tests__/script-lint.test.ts +347 -0
  111. package/src/orchestration/__tests__/worker-script-builder.test.ts +42 -0
  112. package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +319 -0
  113. package/src/orchestration/agent-opts-resolver.ts +128 -0
  114. package/src/orchestration/concurrency-gate.ts +69 -0
  115. package/src/orchestration/config-loader.ts +313 -0
  116. package/src/orchestration/error-recovery.ts +578 -0
  117. package/src/orchestration/execute-agent-call.ts +174 -0
  118. package/src/orchestration/jsonl-run-store.ts +292 -0
  119. package/src/orchestration/launcher.ts +368 -0
  120. package/src/orchestration/lifecycle.ts +373 -0
  121. package/src/orchestration/models/__tests__/budget.test.ts +367 -0
  122. package/src/orchestration/models/agent-call.ts +76 -0
  123. package/src/orchestration/models/budget.ts +148 -0
  124. package/src/orchestration/models/ports.ts +165 -0
  125. package/src/orchestration/models/run-runtime.ts +91 -0
  126. package/src/orchestration/models/run-spec.ts +54 -0
  127. package/src/orchestration/models/run-state.ts +44 -0
  128. package/src/orchestration/models/trace.ts +102 -0
  129. package/src/orchestration/models/types.ts +242 -0
  130. package/src/orchestration/models/workflow-run.ts +275 -0
  131. package/src/orchestration/models/workflow-script-registry.ts +32 -0
  132. package/src/orchestration/models/workflow-script.ts +90 -0
  133. package/src/orchestration/node-ops.ts +192 -0
  134. package/src/orchestration/script-lint.ts +387 -0
  135. package/src/orchestration/skill-discovery.ts +60 -0
  136. package/src/orchestration/worker-handle.ts +115 -0
  137. package/src/orchestration/worker-host.ts +93 -0
  138. package/src/orchestration/worker-script-builder.ts +281 -0
  139. package/src/orchestration/workflow-files.ts +85 -0
  140. package/src/orchestration/workflow-script-registry-impl.ts +128 -0
  141. package/src/shared/__tests__/resource-discovery.test.ts +226 -0
  142. package/src/shared/agent-event.ts +13 -0
  143. package/src/shared/resource-discovery.ts +535 -0
@@ -0,0 +1,973 @@
1
+ // src/runtime/subagent-service.ts
2
+ //
3
+ // 执行编排 + 记录 + 通知领域 Service。"跑一次子代理 + 管理执行状态"。
4
+ //
5
+ // 与 ModelConfigService(配置/模型解析域)正交——本 Service 持有其引用但不暴露给外部。
6
+ // executor 逻辑已合并进本文件——它是 SubagentService.execute 的编排逻辑,
7
+ // 没有独立状态/生命周期,不需要独立文件。合并后行为方法自然 private。
8
+ //
9
+ // 上游:subagent-tool(execute/query/cancel)、TUI(onChange/listRunning/collectRecords)。
10
+ // session_start 时经 initSession 注入 pi;modelRegistry/entries 归 ModelConfigService.initModel。
11
+
12
+ import { AsyncLocalStorage } from "node:async_hooks";
13
+ import { createHash } from "node:crypto";
14
+ import * as fs from "node:fs";
15
+ import * as path from "node:path";
16
+
17
+ import { type ConcurrencyPool,DefaultConcurrencyPool } from "./concurrency-pool.ts";
18
+ import {
19
+ completeRecord,
20
+ createRecord,
21
+ project,
22
+ snapshot,
23
+ tryTransition,
24
+ } from "./execution-record.ts";
25
+ import type { AgentConfig, ModelInfo, ResolvedModel } from "./model-resolver.ts";
26
+ import { getSubagentSessionDir } from "./path-encoding.ts";
27
+ import { MAX_FORK_DEPTH } from "./session-context-resolver.ts";
28
+ import { killAllSpawnedChildren, runSpawn, type SessionRunnerContext } from "./session-runner.ts";
29
+ import type { WorktreeHandle } from "./types.ts";
30
+ import type {
31
+ AgentEvent,
32
+ AgentResult,
33
+ ExecuteOptions,
34
+ ExecutionHandle,
35
+ ExecutionMode,
36
+ ExecutionRecord,
37
+ RecordSnapshot,
38
+ SubagentRecord,
39
+ SubagentToolDetails,
40
+ } from "./types.ts";
41
+ import { ForkDepthExceededError } from "./types.ts";
42
+ import { DEFAULT_AGENT_NAME } from "./types.ts";
43
+ import { bestEffort } from "./best-effort.ts";
44
+ import { removeAliveMarker } from "./alive-store.ts";
45
+ import { writeFinalized } from "./finalized-marker.ts";
46
+ import type { StatusFilter } from "./record-store.ts";
47
+ import { RecordStore } from "./record-store.ts";
48
+ import { writeCancelledTombstone } from "./tombstone-store.ts";
49
+
50
+ // D-A10: workflow 侧 AgentResult 映射(executeAndAwait 出口)
51
+ import { mapToWorkflowAgentResult } from "./agent-result-mapper.ts";
52
+ import type { AgentResult as WorkflowAgentResult } from "../orchestration/models/types.ts";
53
+ import type { ModelConfigService } from "./model-config-service.ts";
54
+ import { WorktreeManager } from "./worktree-manager.ts";
55
+ import { BgNotifier } from "./notifier.ts";
56
+ import type { BgNotifyRecord, NotifierHost } from "./notifier.ts";
57
+
58
+ /** Pi ExtensionAPI 的最小接口(duck-typed)。
59
+ * subagent-service 直接调 pi.sendMessage 发 background 完成通知(BgNotifier 滑动窗口合并),
60
+ * 不委托 pending-notifications EventBus 中继——后者只管 registry 不参与通知发送。 */
61
+ interface PiLike {
62
+ appendEntry(customType: string, data?: unknown): void;
63
+ events: { emit(channel: string, data: unknown): void };
64
+ sendMessage(
65
+ message: { customType: string; content: string; display: boolean; details?: unknown },
66
+ options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
67
+ ): void;
68
+ }
69
+
70
+ /** pending-notifications 注册/注销 helper(避免重复代码)。 */
71
+ function emitPendingRegister(pi: PiLike | null, id: string, name?: string): void {
72
+ pi?.events.emit("pending:register", {
73
+ id,
74
+ type: "subagent",
75
+ name: name ?? id,
76
+ });
77
+ }
78
+
79
+ function emitPendingUnregister(
80
+ pi: PiLike | null,
81
+ id: string,
82
+ reason: string,
83
+ ): void {
84
+ pi?.events.emit("pending:unregister", {
85
+ id,
86
+ reason,
87
+ });
88
+ }
89
+
90
+ /** Service 构造参数(进程级)。 */
91
+ export interface SubagentServiceInit {
92
+ cwd: string;
93
+ /** 配置/模型域 Service(execute 内部调其 resolveModel)。 */
94
+ modelService: ModelConfigService;
95
+ /** 缓存的主 session file 获取函数(fork source 解析用)。 */
96
+ getMainSessionFile?: () => string | undefined;
97
+ }
98
+
99
+ /** session_start 注入参数(session 级)。 */
100
+ export interface SubagentServiceSessionInit {
101
+ pi: PiLike;
102
+ sessionId: string;
103
+ }
104
+
105
+ /** background 优先级(保留 priority 排序机制,单一值)。 */
106
+ const PRIORITY_BACKGROUND = 1000;
107
+
108
+ /** [MF#5] sessionId 短哈希前缀(6 hex)。两个并发 Pi 进程在同一 repo fork 时,seq 各自从 0
109
+ * 自增 → recordId=run-1 / branch=pi-sub-run-1 冲突 → 第二个 git worktree add -b 失败。
110
+ * 加 session 作用域前缀保证跨进程唯一。sessionId 缺失时用 'x' 兌底(空值不进 hash)。 */
111
+ const SESSION_TAG_HEX_LEN = 6;
112
+ function sessionTag(sessionId: string | null): string {
113
+ if (!sessionId) return "x";
114
+ return createHash("sha1").update(sessionId).digest("hex").slice(0, SESSION_TAG_HEX_LEN);
115
+ }
116
+
117
+ /** 触发 onUpdate 的事件类型(streaming delta 不触发,避免每 token 刷新)。 */
118
+ const TRIGGERING_EVENT_TYPES = new Set<AgentEvent["type"]>([
119
+ "tool_start",
120
+ "tool_end",
121
+ "turn_end",
122
+ "message_end",
123
+ "error",
124
+ "compaction",
125
+ ]);
126
+
127
+ /**
128
+ * onUpdate 最小发射间隔(ms)。leading + trailing 时间窗节流:窗口内首次事件立即发,
129
+ * 后续合并到窗口末尾补发一次。与 tool-render.ts SPINNER_INTERVAL_MS 对齐——视觉刷新
130
+ * 200ms 一帧,onUpdate 比这更快无感知增益,反而密集打 Pi tool_execution_update
131
+ * (嵌套场景内层一秒可产生 10+ 事件)触发 chatContainer 重绘残影。
132
+ */
133
+ const ON_UPDATE_MIN_INTERVAL_MS = 200;
134
+
135
+ /** resolveIdentity 的产物——一次确定、写入 record 后不再变。 */
136
+ interface ResolvedIdentity {
137
+ agent: string;
138
+ agentConfig: AgentConfig | undefined;
139
+ resolved: ResolvedModel;
140
+ }
141
+
142
+ /**
143
+ * 执行编排 Service。进程级单例。
144
+ *
145
+ * session_start:
146
+ * 1. modelService = getModelConfigService() ?? new ModelConfigService({homeDir, agentDir})
147
+ * 2. service = getSubagentService() ?? new SubagentService({cwd, modelService})
148
+ * 3. modelService.initModel({modelRegistry, sessionId, entries})
149
+ * 4. service.initSession({pi, sessionId})
150
+ *
151
+ * session_shutdown:
152
+ * service.dispose()
153
+ */
154
+ export class SubagentService {
155
+ private readonly pool: ConcurrencyPool;
156
+ private readonly store: RecordStore;
157
+ private readonly modelService: ModelConfigService;
158
+ private readonly cwd: string;
159
+ private readonly worktreeManager: WorktreeManager;
160
+ private readonly getMainSessionFile: (() => string | undefined) | undefined;
161
+
162
+ private pi: PiLike | null = null;
163
+ /** 当前 Pi session ID(session 隔离过滤用)。initSession 时注入。 */
164
+ private sessionId: string | null = null;
165
+ private _disposed = false;
166
+ private _seq = 0;
167
+ /** background 完成通知器(滑动窗口合并 + 去重)。session_start revive,shutdown dispose。 */
168
+ private readonly notifier: BgNotifier;
169
+ /** [MF#4][MF#2] fork 深度按 async 调用链传递(AsyncLocalStorage),替代共享可变计数器。
170
+ * 主 session=0;fork 进入子 session 期间推进为子深度,供嵌套 fork 的 execute(子 agent
171
+ * 在 run() 期间再调 subagent tool)经 ALS 读到自身深度作为 parentForkDepth。并发 background
172
+ * fork 各自独立调用链,不再互相压低深度值 → MAX_FORK_DEPTH 递归护栏不被绕过。
173
+ * [MF#2] 旧实现用单实例字段 currentForkDepth 跨所有执行链共享:并发 background 下 A 还原
174
+ * 深度后 B 的嵌套 fork 读到被压低的值 → 护栏恒不过限。ALS 是 node 跨 async 边界传递
175
+ * “请求作用域”状态的标准机制,每条调用链隔离。 */
176
+ private readonly forkDepthAls = new AsyncLocalStorage<number>();
177
+
178
+ /** subagent 执行上下文按 async 调用链传递(当前正在跑的 record 身份 + 递归深度)。
179
+ * B run() 期间包此 ALS,B 内创建 C 时 createRecordForMode 读到 B 的 recordId/depth,
180
+ * 据此设 C.parentRecordId=B.id、C.depth=B.depth+1。主 session 链上无 store → 顶层。
181
+ * 与 forkDepthAls 独立:后者只数 fork 链(fork=true 才递增),本 ALS 数所有 subagent 嵌套。 */
182
+ private readonly execCtxAls = new AsyncLocalStorage<{ recordId: string | undefined; depth: number }>();
183
+
184
+ constructor(init: SubagentServiceInit) {
185
+ this.cwd = init.cwd;
186
+ this.modelService = init.modelService;
187
+ this.getMainSessionFile = init.getMainSessionFile;
188
+ this.pool = new DefaultConcurrencyPool(this.modelService.getGlobalConfig().maxConcurrent);
189
+ this.worktreeManager = new WorktreeManager(this.modelService.getAgentDir());
190
+ const sessionsDir = getSubagentSessionDir(this.modelService.getAgentDir(), init.cwd);
191
+ this.store = new RecordStore(sessionsDir);
192
+ this.notifier = new BgNotifier(this.piAdapter());
193
+ }
194
+
195
+ // ── 生命周期(index.ts 调)──────────────────────────────
196
+
197
+ /** session_start 注入 pi + revive(modelRegistry/entries 归 ModelConfigService.initModel)。 */
198
+ initSession(init: SubagentServiceSessionInit): void {
199
+ this.pi = init.pi;
200
+ this.sessionId = init.sessionId;
201
+ // [SPAWN fork depth 跨进程传递] 子进程被父 spawn 时,父通过 env
202
+ // PI_SUBAGENT_FORK_DEPTH 传入当前 fork 链深度。子进程 session_start 时
203
+ // 读取作为 forkDepthAls 基线,使后续嵌套 spawn fork 能从正确深度递增。
204
+ // 未设置(顶层主 session)→ 基线 0。enterWith 贯穿整个 session 生命周期。
205
+ const envDepth = process.env.PI_SUBAGENT_FORK_DEPTH;
206
+ if (envDepth !== undefined && envDepth !== "") {
207
+ const base = Number.parseInt(envDepth, 10);
208
+ if (!Number.isNaN(base) && base > 0) {
209
+ this.forkDepthAls.enterWith(base);
210
+ }
211
+ }
212
+ // revive(dispose 的逆操作:/resume /fork /new 后复活)
213
+ this._disposed = false;
214
+ this.store.revive();
215
+ this.notifier.revive();
216
+ }
217
+
218
+ /** session 结束清理(清定时器,丢弃 pending 通知)。幂等。
219
+ *
220
+ * [M-7] dispose 顺序假设:pending:unregister emit 依赖 pending-notifications 扩展的
221
+ * listener 仍然存活。若 pending-notifications 先于本扩展执行 session_shutdown(后注册
222
+ * 先执行的语义下会如此),listener 已注销,unregister 事件被静默丢弃。这是可接受的
223
+ * 退化——进程退出后两侧状态本就不保证一致,下次 session_start 的 crash recovery 会修正。 */
224
+ dispose(): void {
225
+ if (this._disposed) return;
226
+ this._disposed = true;
227
+ // [R0/C1 孤儿进程修复] 进程退出路径:两层兜底 kill 所有 spawned 子进程(sync + background)。
228
+ // 1. store.abortRunningControllers():background record 的 controller.abort → runSpawn signal
229
+ // listener → child.kill("SIGTERM")。这是 background 的 CAS 收尾语义路径(不能动)。
230
+ // 2. killAllSpawnedChildren():遍历 session-runner 的 spawnedChildren Set(sync + background
231
+ // 均注册),对仍存活的发 SIGTERM。sync record 的 controller 是 undefined,abortRunningControllers
232
+ // 跳过它们——此处补齐,防止 sync 子进程成孤儿(主进程崩溃/SIGKILL 之外的退出路径)。
233
+ // 必须在 store.dispose 之前(dispose 后 records 仍可访问,但语义上先 kill 再清场)。
234
+ // 注意:dispose 是同步返回,主进程可能紧接着 process.exit(),runSpawn 的 finally 清理
235
+ //(identity 补写等)可能来不及跑——这是可接受的退化(session.jsonl 已由子进程写入,
236
+ // 缺 identity entry 只影响 list 重建的可观测性,不丢执行数据)。
237
+ //
238
+ // [T2 AC-4.3 双重记账一致性] 进程退出时所有 running record 异常终止(runAndFinalize 的
239
+ // finalizeRecord 不会再跑——detached promise 随进程退出而丢弃)。此处为每个 running record
240
+ // emit pending:unregister(reason=failed),让 pending-notifications 清理 registry entry,
241
+ // 避免进程退出后两侧(subagent store vs pending registry)状态不一致。
242
+ // 必须在 abortRunningControllers 之前——此时 record 仍 running,listRunning 能取到。
243
+ // 只 emit running 的 record(已终态的由其正常路径 emit 过,不重复)。
244
+ for (const record of this.store.listRunning()) {
245
+ emitPendingUnregister(this.pi, record.id, "failed");
246
+ }
247
+ this.store.abortRunningControllers();
248
+ // [C1] orphan 进程兜底:abortRunningControllers 只能 kill background 子进程(有 controller)。
249
+ // sync 子进程的 controller 是 undefined(见 createRecordForMode),主进程退出时会被遗漏成孤儿。
250
+ // killAllSpawnedChildren 遍历 session-runner 的 spawnedChildren Set(sync + background 均注册),
251
+ // 对仍存活的子进程发 SIGTERM。background 子进程此时已被 controller.abort 路径 kill,
252
+ // 此处对它们的二次 kill 是无害 noop(已 killed/退出)。不 await 子进程退出(dispose 要快)。
253
+ killAllSpawnedChildren();
254
+ for (const s of this.throttleState.values()) {
255
+ if (s.timer !== undefined) clearTimeout(s.timer);
256
+ }
257
+ this.throttleState.clear();
258
+ // flush 待发通知后 dispose(防丢失)
259
+ this.notifier.flushPendingNotifications();
260
+ this.notifier.dispose();
261
+ this.store.dispose();
262
+ }
263
+
264
+ // ── 执行(subagent-tool 调)────────────────────────────
265
+
266
+ /** background 完成回注(record → BgNotifyRecord 映射 + notifier.notify)。 */
267
+ private notifyComplete(record: ExecutionRecord): void {
268
+ this.notifier.notify(this.toNotifyRecord(record));
269
+ }
270
+
271
+ /** notifier 的 NotifierHost 适配器(绑定到 pi.sendMessage + store 查询)。 */
272
+ private piAdapter(): NotifierHost {
273
+ return {
274
+ sendMessage: (message, options) => {
275
+ this.pi?.sendMessage(message, options);
276
+ },
277
+ hasRunningBackground: () => {
278
+ return this.store.listRunning().some((r) => r.mode === "background");
279
+ },
280
+ };
281
+ }
282
+
283
+ /** record → BgNotifyRecord(notifier.notify 入参映射,内部不外露)。 */
284
+ private toNotifyRecord(record: ExecutionRecord): BgNotifyRecord {
285
+ const snap = snapshot(record);
286
+ return {
287
+ id: snap.id,
288
+ status: snap.status as "done" | "failed" | "cancelled",
289
+ agent: snap.agent,
290
+ model: snap.model,
291
+ result: snap.result,
292
+ error: snap.error,
293
+ startedAt: snap.startedAt,
294
+ endedAt: snap.endedAt,
295
+ patchFile: record.patchFile,
296
+ };
297
+ }
298
+
299
+ /**
300
+ * 预解析 model(renderCall 标题行用,同步)。
301
+ * 代理 modelService.resolveModel——renderCall 在 execute 前调用,但 model 解析是同步的,
302
+ * 让标题行能提前显示 model/thinking,不必等 execute。
303
+ * hub 未就绪时抛(调用方 catch 降级)。
304
+ *
305
+ * 注意:renderCall 无 ctx,拿不到主 agent model。这里仅解析 override/agentConfig 路径,
306
+ * 主 agent model 路径交给 execute(传 ctxModel)。renderCall 时如果用户未显式 override,
307
+ * 本方法会因 ctxModel 缺失走第三层→ 拋错→ 调用方 catch 降级(不显示 model)。
308
+ */
309
+ resolveModel(
310
+ agent: string,
311
+ override?: { model?: string; thinkingLevel?: string },
312
+ ctxModel?: ModelInfo,
313
+ ): ResolvedModel {
314
+ return this.modelService.resolveModel(agent, override, ctxModel);
315
+ }
316
+
317
+ /**
318
+ * 统一执行入口。mode 固定 background(sync 已删除)。
319
+ * 内部完成:模型解析 → 执行 → 收尾。
320
+ *
321
+ * @param opts.ctxModel 主 agent 当前模型(模型解析第三层兼底)。undefined 时仅依赖 override/agentConfig。
322
+ */
323
+ async execute(opts: ExecuteOptions): Promise<ExecutionHandle> {
324
+ this.assertReady();
325
+
326
+ // 通用嵌套深度护栏(D-033):execCtxAls 记录所有 subagent 嵌套层级(fork + 非 fork),
327
+ // 每层 +1。MAX_FORK_DEPTH 同时限 fork 链与通用嵌套——非 fork 递归虽不累积 session 体积,
328
+ // 但耗资源(每层 createAgentSession + resourceLoader + session 文件)且 LLM 易陷入
329
+ // 「委派子 agent → 子 agent 再委派」死循环(实测无护栏时递归到 L36 全 failed)。
330
+ // 在所有副作用(record/worktree/session)之前拦截,错误直达调用方。
331
+ // fork:true 的体积护栏(resolveSessionContext 的 parentForkDepth 检查)作为第二层保留。
332
+ // 计数基准:顶层 nestingDepth=0;每次嵌套 execute +1。允许 0..MAX_FORK_DEPTH(共 11 层),
333
+ // nestingDepth=MAX+1 被拒。与 fork 护栏(parentForkDepth>=MAX 拒,parent 计数基准)互补:
334
+ // 本护栏更严(计所有嵌套),混合链下先生效;两者共享 MAX_FORK_DEPTH 上限不漂移。
335
+ const parentNesting = this.execCtxAls.getStore();
336
+ const nestingDepth = parentNesting ? parentNesting.depth + 1 : 0;
337
+ if (nestingDepth > MAX_FORK_DEPTH) {
338
+ throw new ForkDepthExceededError(
339
+ `subagent nesting depth ${nestingDepth} > ${MAX_FORK_DEPTH} (max recursion), refusing to spawn deeper`,
340
+ );
341
+ }
342
+
343
+ // [MF#7] worktree:true 需要 fork:true——否则下面三个 worktree 分支都不命中,
344
+ // worktreeHandle 恒 undefined → 子 agent 零文件隔离且零报错(静默 no-op)。此处在
345
+ // 任何副作用(record 创建 / worktree 创建)之前 fail-fast,不吞误用。
346
+ if (opts.worktree === true && !opts.fork) {
347
+ throw new Error(
348
+ "worktree:true requires fork:true (worktree isolation only applies to forked sessions). " +
349
+ "Set fork:true together with worktree:true.",
350
+ );
351
+ }
352
+
353
+ // mode 固定 background(sync 模式已删除)
354
+ const mode: ExecutionMode = "background";
355
+ const ctx = this.buildSessionRunnerContext(opts.cwd);
356
+
357
+ // ── 1. IDENTITY 解析(确认 → agentConfig → resolveModel)──
358
+ const identity = await this.resolveIdentity(opts);
359
+
360
+ // ── 2. RECORD 创建 + 注册 ──
361
+ const record = this.createRecordForMode(identity, opts, mode);
362
+ emitPendingRegister(this.pi, record.id, record.agent);
363
+
364
+ // ── 2.5 worktree 创建(仅 worktree===true 或已传入 handle 时)──
365
+ // record 先创建,worktree 失败时可 finalizeFailed(record 已在 store 中)。
366
+ // worktree 必须显式开启:worktree===true 创建新 worktree;worktree===undefined/false 不创建。
367
+ // fork 不隐含 worktree(UC-1 fork 可独立使用,fork 仅继承上下文,在 parent cwd 跑)。
368
+ let worktreeHandle: WorktreeHandle | undefined;
369
+ if (typeof opts.worktree === "object") {
370
+ // 传入的是已创建的 WorktreeHandle
371
+ worktreeHandle = opts.worktree;
372
+ } else if (opts.worktree === true) {
373
+ // worktree===true(显式要求)——创建新 worktree。MF#7 已保证此处 fork 必为 true。
374
+ try {
375
+ worktreeHandle = this.worktreeManager.create(this.cwd, record.id);
376
+ record.worktreeHandle = worktreeHandle;
377
+ } catch (err) {
378
+ // create 失败→不进入 run,finalizeFailed 统一收尾(含 emitPendingUnregister failed)
379
+ const _result = await this.finalizeFailed(record, err);
380
+ return this.buildEarlyFailedHandle(record);
381
+ }
382
+ }
383
+
384
+ // ── 3. MODE 固定 background:signal/controller、priority 固定 ──
385
+ const signal = record.controller!.signal;
386
+ const priority = PRIORITY_BACKGROUND;
387
+
388
+ // ── 4-7. background 包 detached 立即返回 id ──
389
+ // background 不回流 onUpdate(任何嵌套 subagent 的 onUpdate 都须 undefined,防
390
+ // SubagentResultComponent spinner setInterval 堆叠)。detached 运行对 tool 层不可见,
391
+ // 完成由 notify 驱动新 turn。
392
+ const bgDetails = project(record);
393
+ this.kickOffBackground(record, { ...opts, onUpdate: undefined, worktree: worktreeHandle }, ctx, identity, signal, priority);
394
+ return { mode: "background", subagentId: record.id, sessionFile: record.sessionFile, details: bgDetails };
395
+ }
396
+
397
+ /**
398
+ * 按 id 查内存 running record 的只读快照(G3-002 修复)。
399
+ * 不从 session.jsonl 重建(cancel/list 单点查询只关心内存 running record)。
400
+ * 供 tool 层 cancelHandler 翻译 throw 用(id 不存在 / mode / 终态三种错误)。
401
+ * 不存在返回 undefined。
402
+ */
403
+ findRecord(id: string): RecordSnapshot | undefined {
404
+ this.assertReady();
405
+ const record = this.store.getMutable(id);
406
+ return record ? snapshot(record) : undefined;
407
+ }
408
+
409
+ /** 取消 background record(tryTransition CAS 抢锁防重复副作用)。 */
410
+ cancel(id: string): boolean {
411
+ this.assertReady();
412
+ const record = this.store.getMutable(id);
413
+ if (!record) return false;
414
+ return this.cancelBackground(record);
415
+ }
416
+
417
+ // ── 编排层专用接口(workflow 消费)──────────────────────
418
+
419
+ /**
420
+ * workflow 编排层专用:sync-await 接口,内部走 background 管道但返回 Promise<AgentResult>。
421
+ *
422
+ * 与 execute() 的区别(D-A1):
423
+ * 1. 返回 workflow AgentResult(content 字段),非 ExecutionHandle
424
+ * 2. 不调 kickOffBackground → 不注入 followUp 完成通知(BC-11,结果直接返回 workflow)
425
+ * 3. T2 删 sync 时 executeAndAwait 不受牵连(独立方法)
426
+ *
427
+ * 共享:runSpawn + ConcurrencyPool + record + pending emit(D-A4)。
428
+ */
429
+ async executeAndAwait(
430
+ opts: ExecuteOptions,
431
+ signal?: AbortSignal,
432
+ onEvent?: (event: AgentEvent) => void,
433
+ ): Promise<WorkflowAgentResult> {
434
+ this.assertReady();
435
+
436
+ // ── BC-12 嵌套护栏:复用 execute() 的 execCtxAls 深度检查 ──
437
+ const parentNesting = this.execCtxAls.getStore();
438
+ const nestingDepth = parentNesting ? parentNesting.depth + 1 : 0;
439
+ if (nestingDepth > MAX_FORK_DEPTH) {
440
+ throw new ForkDepthExceededError(
441
+ `subagent nesting depth ${nestingDepth} > ${MAX_FORK_DEPTH} (max recursion), refusing to spawn deeper`,
442
+ );
443
+ }
444
+
445
+ // ── 步骤 1: IDENTITY 解析 ──
446
+ const identity = await this.resolveIdentity(opts);
447
+
448
+ // ── 步骤 2: RECORD 创建(mode="background" 进池)──
449
+ const record = this.createRecordForMode(identity, opts, "background");
450
+ emitPendingRegister(this.pi, record.id, record.agent);
451
+
452
+ // ── 步骤 3: SessionRunnerContext ──
453
+ const ctx = this.buildSessionRunnerContext(opts.cwd);
454
+
455
+ // ── 步骤 4: signal 决议 ──
456
+ const effectiveSignal = signal ?? record.controller?.signal;
457
+
458
+ // ── 步骤 5: runAndFinalize(await,不 detached)──
459
+ // BC-11:onUpdate 置 undefined(不回流 tool UI 细节),onEvent 独立传(AgentEvent 透传 workflow)
460
+ const result = await this.runAndFinalize(
461
+ record,
462
+ { ...opts, onUpdate: undefined },
463
+ ctx,
464
+ identity,
465
+ effectiveSignal,
466
+ PRIORITY_BACKGROUND,
467
+ onEvent,
468
+ );
469
+
470
+ // ── 步骤 6: D-A10 AgentResult 映射 ──
471
+ // [MF-2] 不在此 emit pending:unregister——runAndFinalize 内部已覆盖所有路径:
472
+ // - CAS 成功(runAndFinalize L629)→ finalizeRecord 末尾 emit(L797)
473
+ // - CAS 失败(cancel/finalizeFailed/dispose 抢先转终态)→ 那些路径各自已 emit
474
+ // (cancelBackground L709 / finalizeFailed→finalizeRecord / dispose L240)
475
+ // 旧实现无条件 emit 一次 → CAS 成功分支重复 emit(双注销)。
476
+ return mapToWorkflowAgentResult(result);
477
+ }
478
+
479
+ // ── 状态查询(TUI 调)──────────────────────────────────
480
+
481
+ /** 订阅 store 变更(widget/list requestRender)。返回取消订阅。 */
482
+ onChange(listener: () => void): () => void {
483
+ return this.store.onChange(listener);
484
+ }
485
+
486
+ /** 列出 running record 快照(widget 计数用)。 */
487
+ listRunning(): RecordSnapshot[] {
488
+ return this.store.listRunning();
489
+ }
490
+
491
+ /** 合并内存(running) + 磁盘(session.jsonl 重建) record(/subagents list + tool list 消费)。
492
+ * 按 rootSessionId 过滤,只返回当前 session 创建的 record(session 隔离)。 */
493
+ collectRecords(limit: number, statusFilter: StatusFilter = "all"): SubagentRecord[] {
494
+ return this.store.collectRecords(limit, statusFilter, this.sessionId ?? undefined);
495
+ }
496
+
497
+ // ── 执行内部:身份解析 + record 创建 ──────────
498
+
499
+ /** 步骤 1:身份解析。agentConfig → resolveModel(三层:override → agentConfig → 主 agent model)。 */
500
+ private async resolveIdentity(opts: ExecuteOptions): Promise<ResolvedIdentity> {
501
+ // 未显式指定 agent 时兜底为 DEFAULT_AGENT_NAME(与 TUI 层 extractAgentName 共用同一常量,
502
+ // 保证 block 标题显示的名与实际加载的 agent.md 一致)。见 types.ts 常量注释。
503
+ const agent = opts.agent ?? DEFAULT_AGENT_NAME;
504
+ const agentConfig = this.modelService.getAgentConfig(agent);
505
+
506
+ const resolved = this.modelService.resolveModel(
507
+ agent,
508
+ { model: opts.model, thinkingLevel: opts.thinkingLevel },
509
+ opts.ctxModel,
510
+ );
511
+
512
+ return { agent, agentConfig, resolved };
513
+ }
514
+
515
+ /** 步骤 2:按 mode 生成 id + controller,创建 record 并注册。
516
+ * [L-1] ExecutionMode 类型固定 "background"(sync 已删除),id/controller 分支简化。 */
517
+ private createRecordForMode(
518
+ identity: ResolvedIdentity,
519
+ opts: ExecuteOptions,
520
+ mode: ExecutionMode,
521
+ ): ExecutionRecord {
522
+ const seq = ++this._seq;
523
+ const tag = sessionTag(this.sessionId);
524
+ // mode 类型固定 "background"——保留参数以兼容签名,但 id/controller 无需再分支。
525
+ const id = `bg-${tag}-${seq}-${Date.now()}`;
526
+ const controller = new AbortController();
527
+
528
+ // 从 async 调用链读父执行上下文:主 session 链上无 store → 顶层 record;
529
+ // B run() 期间包了 execCtxAls,B 内创建 C 时读到 B → C.parentRecordId=B.id, C.depth=B.depth+1。
530
+ // depth 语义:顶层(无父)=0;有父=父 depth+1。靠 recordId 是否存在区分,不用负数魔数。
531
+ const parentCtx = this.execCtxAls.getStore();
532
+ const parentRecordId = parentCtx?.recordId;
533
+ const depth = parentCtx ? parentCtx.depth + 1 : 0;
534
+
535
+ const record = createRecord(id, {
536
+ agent: identity.agent,
537
+ model: `${identity.resolved.model.provider}/${identity.resolved.model.id}`,
538
+ thinkingLevel: identity.resolved.thinkingLevel,
539
+ mode,
540
+ task: opts.task,
541
+ startedAt: Date.now(),
542
+ rootSessionId: this.sessionId ?? undefined,
543
+ parentRecordId,
544
+ depth,
545
+ controller,
546
+ });
547
+
548
+ this.store.register(record);
549
+ return record;
550
+ }
551
+
552
+ /** [MF#R4] worktree 前置失败的 early-return handle。
553
+ * record 已被 finalizeFailed 收尾为 failed、detached promise 从未启动。 */
554
+ private buildEarlyFailedHandle(record: ExecutionRecord): ExecutionHandle {
555
+ const details = project(record);
556
+ return { mode: "background", subagentId: record.id, sessionFile: record.sessionFile, details };
557
+ }
558
+
559
+ // ── 执行内部:run + finalize(sync/bg 共用)──────────────
560
+
561
+ /** 共享的"干活 + 收尾"——sync 直接 await,background 在 detached 里调。 */
562
+ private async runAndFinalize(
563
+ record: ExecutionRecord,
564
+ opts: ExecuteOptions,
565
+ ctx: SessionRunnerContext,
566
+ identity: ResolvedIdentity,
567
+ signal: AbortSignal | undefined,
568
+ priority: number,
569
+ rawOnEvent?: (event: AgentEvent) => void,
570
+ ): Promise<AgentResult> {
571
+ // 仅 background 进并发池限流,分层配额:每层嵌套 depth 让有效配额 -1(下限 1)。
572
+ // 顶层 depth=0 拿满配额;嵌套越深有效并发越小,防子 agent fan-out 压垮主 agent 的 pool。
573
+ const pooled = record.mode === "background";
574
+ if (pooled) {
575
+ const effectiveMaxConcurrent = Math.max(1, this.pool.maxConcurrent - record.depth);
576
+ await this.pool.acquire(priority, effectiveMaxConcurrent);
577
+ }
578
+ // onEvent 包装:AgentEvent → onUpdate(project(record)) 回流调用方
579
+ const onEvent = rawOnEvent
580
+ ?? (opts.onUpdate
581
+ ? (event: AgentEvent): void => this.onEventThrottled(record, event, opts.onUpdate!)
582
+ : undefined);
583
+
584
+ // 解析 worktree 参数:boolean → WorktreeHandle | undefined
585
+ let worktreeHandle: WorktreeHandle | undefined;
586
+ if (typeof opts.worktree === "object") {
587
+ worktreeHandle = opts.worktree;
588
+ }
589
+ // worktree=true 或 undefined 时不传递 handle,由 run 内部处理
590
+
591
+ // [MF#4][MF#2] fork 深度护栏:深度按 async 调用链传递(ALS),不再用共享实例计数器。
592
+ // parentDepth = 当前调用链的深度(主 session 链上无 store→0);fork 时推进为 parentDepth+1,
593
+ // 包进 run() 的 ALS 作用域,使子 agent 在 prompt() 期间发起的嵌套 execute 能读到该深度。
594
+ const parentDepth = this.forkDepthAls.getStore() ?? 0;
595
+ const effectiveDepth = opts.fork ? parentDepth + 1 : parentDepth;
596
+
597
+ let result: AgentResult;
598
+ try {
599
+ // execCtxAls 包在 forkDepthAls 内层:B run() 期间它的 store={recordId:B.id,depth:B.depth},
600
+ // B 内创建 C 时 createRecordForMode 读到 B → C 挂到 B 名下。两层 ALS 独立但同生命周期。
601
+ result = await this.forkDepthAls.run(effectiveDepth, () =>
602
+ this.execCtxAls.run(
603
+ { recordId: record.id, depth: record.depth },
604
+ () => runSpawn(record, opts.task, {
605
+ resolved: identity.resolved,
606
+ agentConfig: identity.agentConfig,
607
+ appendSystemPrompt: opts.appendSystemPrompt,
608
+ skillPath: opts.skillPath,
609
+ schema: opts.schema,
610
+ schemaEnv: opts.schemaEnv, // D-A6 bridge: workflow 编排层透传 schema 到 childEnv
611
+ maxTurns: opts.maxTurns,
612
+ graceTurns: opts.graceTurns,
613
+ signal,
614
+ onEvent,
615
+ fork: opts.fork,
616
+ worktree: worktreeHandle,
617
+ parentForkDepth: parentDepth, // [MF#4] 父链深度,不从 opts 读
618
+ }, ctx),
619
+ ),
620
+ );
621
+ } catch (err) {
622
+ // run() 正常路径不抛错,但创建期异常(createAndConfigureSession 失败)
623
+ // 会逃逸出 run() —— 合成 failed result + 收尾。
624
+ // swallow(不 re-throw):sync 调用方拿到合成 failed result,background 的
625
+ // .then 正常跑 notify。避免异常逃逸到 tool 层 + record 卡 running。
626
+ result = await this.finalizeFailed(record, err);
627
+ return result;
628
+ } finally {
629
+ if (pooled) this.pool.release();
630
+ }
631
+
632
+ // status 唯一判定点:success ? done : (aborted ? cancelled : failed)
633
+ const status: "done" | "failed" | "cancelled" = result.success
634
+ ? "done"
635
+ : signal?.aborted ? "cancelled" : "failed";
636
+
637
+ // CAS 抢锁:抢到则完整收尾;没抢到(cancel 已先设 cancelled)则跳过
638
+ if (tryTransition(record, status)) {
639
+ await this.finalizeRecord(record, result, status);
640
+ }
641
+ return result;
642
+ }
643
+
644
+ /** background 的步骤 4-6:包进 detached promise(不 await),execute 立即返回。 */
645
+ private kickOffBackground(
646
+ record: ExecutionRecord,
647
+ opts: ExecuteOptions,
648
+ ctx: SessionRunnerContext,
649
+ identity: ResolvedIdentity,
650
+ signal: AbortSignal | undefined,
651
+ priority: number,
652
+ ): void {
653
+ void this.runAndFinalize(record, opts, ctx, identity, signal, priority)
654
+ .then(() => {
655
+ // background 回注:仅当本路径抢到 CAS(status 已转 done/failed)才 notify。
656
+ // cancel 抢先时 status=cancelled,cancelBackground 自己 notify,此处跳过。
657
+ if (record.status !== "cancelled") {
658
+ this.notifyComplete(record);
659
+ }
660
+ })
661
+ .catch((err: unknown) => {
662
+ // detached 吞错:runAndFinalize 内部已 finalize record(含 emitPendingUnregister),不外抛
663
+ // 完成通知由 finalizeRecord 内的 emitPendingUnregister 承担(pending-notifications 消费)。
664
+ // cancel 抢先时 status=cancelled,cancelBackground 自己 emit,此处无需重复。
665
+ if (err instanceof Error) {
666
+ console.debug(`[subagent] background finalize error (record=${record.id}): ${err.message}`);
667
+ }
668
+ });
669
+ }
670
+
671
+ /** 取消 background record。CAS 抢锁——抢到则 notify + 写 tombstone。 */
672
+ private cancelBackground(record: ExecutionRecord): boolean {
673
+ record.controller?.abort();
674
+ if (!tryTransition(record, "cancelled")) {
675
+ return false; // detached 已 finalize,cancel 来晚了
676
+ }
677
+ // 抢到锁:completeRecord(用空 result 填 cancelled)+ archive(立即移出内存)+ notify。
678
+ // 写 cancelled tombstone:session.jsonl 被 abort 截断,cancelled 状态靠 sidecar 标记,
679
+ // collectRecords 重建时 override status=cancelled。
680
+ // durationMs 用真实耗时(startedAt → now),避免耗时统计恒为 0 失真。
681
+ const cancelledResult: AgentResult = {
682
+ text: "",
683
+ turns: record.turnCount,
684
+ durationMs: Date.now() - record.startedAt,
685
+ success: false,
686
+ error: "cancelled by user",
687
+ sessionId: record.id,
688
+ toolCalls: [],
689
+ };
690
+ completeRecord(record, cancelledResult, "cancelled");
691
+ // 写 tombstone(best-effort,sessionFile 可能为 undefined——窗口期 cancel)。
692
+ if (record.sessionFile) {
693
+ writeCancelledTombstone(record.sessionFile, {
694
+ id: record.id,
695
+ status: "cancelled",
696
+ agent: record.agent,
697
+ startedAt: record.startedAt,
698
+ endedAt: record.endedAt ?? Date.now(),
699
+ });
700
+ }
701
+ this.store.archive(record);
702
+ // worktree cleanup + removeAliveMarker(cancel 不写 finalized,BC-4 互斥)
703
+ if (record.worktreeHandle) {
704
+ try {
705
+ this.worktreeManager.cleanup(record.worktreeHandle);
706
+ } catch (err) {
707
+ bestEffort(err, "worktree cleanup (cancelBackground)");
708
+ }
709
+ }
710
+ if (record.sessionFile) {
711
+ try {
712
+ removeAliveMarker(record.sessionFile);
713
+ } catch (err) {
714
+ bestEffort(err, "removeAliveMarker (cancelBackground)");
715
+ }
716
+ }
717
+ // pending-notifications:cancel 注销(只记 registry 状态)
718
+ emitPendingUnregister(this.pi, record.id, "cancelled");
719
+ // cancel 完成通知(与 kickOffBackground.then 对称——cancel 抢先时 .then 跳过 notify)
720
+ this.notifyComplete(record);
721
+ return true;
722
+ }
723
+
724
+ /**
725
+ * D-017 时序收尾:collectPatch → completeRecord → archive → writeFinalized + cleanup + removeAliveMarker。
726
+ * B9 兜底:completeRecord/archive 抛错→ finalized/cleanup/aliveMarker 仍执行。
727
+ */
728
+ private async finalizeRecord(
729
+ record: ExecutionRecord,
730
+ result: AgentResult,
731
+ status: "done" | "failed" | "cancelled",
732
+ ): Promise<void> {
733
+ // 终态清节流状态:防 trailing timer 在 record 归档后误发陈旧 onUpdate
734
+ this.clearThrottle(record.id);
735
+ // ── Step 0: collectPatch(best-effort,D-022 patchOk 守卫)──
736
+ // [MF#3] patchFile 写到 worktree 之外(sessionsDir/<branch>.patch),避免被 cleanup 删除;
737
+ // 路径回填 record.patchFile,供调用方(tool result / /subagents list)应用。
738
+ let patchOk = true;
739
+ if (record.worktreeHandle) {
740
+ try {
741
+ const sessionsDir = getSubagentSessionDir(
742
+ this.modelService.getAgentDir(),
743
+ record.worktreeHandle.mainCwd,
744
+ );
745
+ fs.mkdirSync(sessionsDir, { recursive: true });
746
+ const patchFile = path.join(sessionsDir, `${record.worktreeHandle.branch}.patch`);
747
+ const patch = this.worktreeManager.collectPatch(record.worktreeHandle, patchFile);
748
+ patchOk = !patch.failed;
749
+ // 仅 patch 实际写盘(非空 diff 且未失败)才回填,避免指向不存在文件的悬空路径——
750
+ // 否则 notifier/render/sync 路径会向 LLM 输出 `git apply <不存在>`(纯查询任务命中)。
751
+ if (patch.written) record.patchFile = patchFile;
752
+ } catch {
753
+ patchOk = false;
754
+ }
755
+ }
756
+
757
+ // ── Step 1: completeRecord(B9: 抛错→3 仍执行)──
758
+ try {
759
+ completeRecord(record, result, status);
760
+ } catch (err) {
761
+ bestEffort(err, "completeRecord (finalizeRecord B9)", "error");
762
+ }
763
+
764
+ // ── Step 2: archive(B9: 抛错→3 仍执行)──
765
+ try {
766
+ this.store.archive(record);
767
+ } catch (err) {
768
+ bestEffort(err, "store.archive (finalizeRecord B9)", "error");
769
+ }
770
+
771
+ // ── Step 3: finalized + cleanup + aliveMarker(三件各自独立 try/catch)──
772
+ if (record.sessionFile) {
773
+ try {
774
+ // MF-1 fix: cancelled 状态写 tombstone 而非 finalized,防重建丢失 cancelled
775
+ if (status === "cancelled") {
776
+ writeCancelledTombstone(record.sessionFile, {
777
+ id: record.id,
778
+ status: "cancelled",
779
+ agent: record.agent,
780
+ startedAt: record.startedAt,
781
+ endedAt: record.endedAt ?? Date.now(),
782
+ });
783
+ } else {
784
+ writeFinalized(record.sessionFile);
785
+ }
786
+ } catch (err) {
787
+ bestEffort(err, "writeFinalized/tombstone (finalizeRecord Step3)");
788
+ }
789
+ }
790
+ if (record.worktreeHandle && patchOk) {
791
+ try {
792
+ this.worktreeManager.cleanup(record.worktreeHandle);
793
+ } catch (err) {
794
+ bestEffort(err, "worktree cleanup (finalizeRecord Step3)");
795
+ }
796
+ }
797
+ if (record.sessionFile) {
798
+ try {
799
+ removeAliveMarker(record.sessionFile);
800
+ } catch (err) {
801
+ bestEffort(err, "removeAliveMarker (finalizeRecord Step3)");
802
+ }
803
+ }
804
+
805
+ // pending-notifications:终态注销(只记 registry 状态,通知由 BgNotifier 发)
806
+ emitPendingUnregister(this.pi, record.id, status);
807
+ }
808
+
809
+ /**
810
+ * run() 创建期异常的收尾(H1 修复)。
811
+ * run() 正常路径不抛错,但 createAndConfigureSession 失败会抛——
812
+ * 本方法合成 failed AgentResult → CAS 抢锁 → finalizeRecord
813
+ * (与正常路径同形:completeRecord + archive)。
814
+ * 返回合成 result 供 runAndFinalize 继续返回(不 re-throw,swallow 策略)。
815
+ */
816
+ private async finalizeFailed(record: ExecutionRecord, err: unknown): Promise<AgentResult> {
817
+ const errMsg = err instanceof Error ? err.message : String(err);
818
+ // durationMs 用真实耗时(startedAt → now),避免失败统计恒为 0 失真。
819
+ const failedResult: AgentResult = {
820
+ text: "",
821
+ turns: record.turnCount,
822
+ durationMs: Date.now() - record.startedAt,
823
+ success: false,
824
+ error: errMsg,
825
+ sessionId: record.id,
826
+ toolCalls: [],
827
+ };
828
+ // CAS 抢锁:抢到(status 仍 running)则完整收尾;没抢到(cancel 已先设 cancelled)跳过。
829
+ if (tryTransition(record, "failed")) {
830
+ await this.finalizeRecord(record, failedResult, "failed");
831
+ }
832
+ return failedResult;
833
+ }
834
+
835
+ // onUpdate 节流状态(per-record Map)。每条 record(每条 onUpdate 回流链)独立节流,
836
+ // 避免嵌套(fork 链:主→A→B)多条 onUpdate 链争用同一份节流状态。
837
+ // [HISTORICAL] 旧实现用单个实例字段,注释假设“fork 嵌套串行,同时只有一条链”——错误:
838
+ // trailing timer 异步,B 设的 trailing 会在 B 完成、A 恢复期间触发,与 A 的同步事件争用
839
+ // onUpdateLastEmitAt/onUpdateTrailingTimer → A 的 onUpdate 被吞/延迟 → 主 agent 对话流
840
+ // A block 状态跳跃更新 → 残影。per-record 化让 A/B 各自独立节流,互不干扰。
841
+ private readonly throttleState = new Map<string, { lastEmitAt: number; timer?: ReturnType<typeof setTimeout> }>();
842
+
843
+ /**
844
+ * AgentEvent 节流回流到 onUpdate(streaming delta 不触发 + 时间窗节流)。
845
+ *
846
+ * 名为 Throttled 必须真节流——只过滤事件类型时,每个 tool_start/tool_end/turn_end
847
+ * 都直发 onUpdate,嵌套场景一秒 10+ 事件密集回流 → Pi tool_execution_update 密集重绘
848
+ * → 行数变化的流式 tool 组件在 chatContainer diff 中残影(状态行堆叠)。
849
+ *
850
+ * leading + trailing:首次事件立即发(响应性),窗口内后续合并到末尾补发一次
851
+ * (保证终态事件不丢——sync record 终态后 archive 移出内存,闭包持有的引用仍可 project)。
852
+ *
853
+ * 节流状态 per-record(Map):每条 record 独立 leading/trailing 窗口。嵌套(fork 链)
854
+ * 时外层 A 与内层 B 各自节流,trailing timer 不会跨链污染。
855
+ */
856
+ private onEventThrottled(
857
+ record: ExecutionRecord,
858
+ event: AgentEvent,
859
+ onUpdate: (details: SubagentToolDetails) => void,
860
+ ): void {
861
+ if (!TRIGGERING_EVENT_TYPES.has(event.type)) return;
862
+ const state = this.throttleState.get(record.id) ?? { lastEmitAt: 0 };
863
+ const now = Date.now();
864
+ if (now - state.lastEmitAt >= ON_UPDATE_MIN_INTERVAL_MS) {
865
+ // leading:窗口外立即发,清掉该 record 残留的 trailing timer(避免补发陈旧状态)
866
+ if (state.timer !== undefined) {
867
+ clearTimeout(state.timer);
868
+ state.timer = undefined;
869
+ }
870
+ state.lastEmitAt = now;
871
+ this.throttleState.set(record.id, state);
872
+ onUpdate(project(record));
873
+ // 终态清 entry(与 trailing 分支对称):防 CAS 后到 leading 误发陈旧状态 + Map 无限增长。
874
+ if (record.status !== "running") this.throttleState.delete(record.id);
875
+ return;
876
+ }
877
+ // trailing:窗口末尾补发最新(per-record timer,不与其他 record 的 trailing 争用)。
878
+ if (state.timer === undefined) {
879
+ const wait = ON_UPDATE_MIN_INTERVAL_MS - (now - state.lastEmitAt);
880
+ state.timer = setTimeout(() => {
881
+ state.timer = undefined;
882
+ state.lastEmitAt = Date.now();
883
+ onUpdate(project(record));
884
+ // record 已终态且无 pending trailing → 清 entry 防 Map 无限增长
885
+ if (record.status !== "running") this.throttleState.delete(record.id);
886
+ }, wait);
887
+ this.throttleState.set(record.id, state);
888
+ }
889
+ }
890
+
891
+ /** 清指定 record 的节流状态(finalizeRecord 调,防终态后 trailing 误发陈旧状态)。 */
892
+ private clearThrottle(recordId: string): void {
893
+ const state = this.throttleState.get(recordId);
894
+ if (state?.timer !== undefined) clearTimeout(state.timer);
895
+ this.throttleState.delete(recordId);
896
+ }
897
+
898
+ // ── 内部 ────────────────────────────────────────────────
899
+
900
+ /**
901
+ * 校验 Service 就绪(pi 已注入 + 未 dispose)。
902
+ *
903
+ * dispose 后调用是异常路径:session_shutdown 已清资源,正常情况下紧接着
904
+ * session_start 会 initSession 复活。若走到这里说明 session_start 没跟上
905
+ * (RPC 边界 / reload 异常等),service 卡在 disposed 状态。
906
+ *
907
+ * 旧实现只抛 "hub disposed"——无信息,调用方和 AI 都看不懂,导致反复盲试。
908
+ * 现在给出原因 + 恢复指引(重启会话或 /new)。真实错误文本会经 renderResult
909
+ * 兜底透传到 AI(见 tool-render.ts extractResultError)。
910
+ */
911
+ private assertReady(): void {
912
+ if (this.pi === null) {
913
+ throw new Error("pi not injected (initSession not called?)");
914
+ }
915
+ if (this._disposed) {
916
+ throw new Error(
917
+ "subagents service disposed (session ended). " +
918
+ "This happens after session shutdown when the follow-up session_start did not arrive. " +
919
+ "Recovery: start a new session or run /new to revive the subagents runtime.",
920
+ );
921
+ }
922
+ }
923
+
924
+ /** 构造 SessionRunnerContext(spawn 模式:无需 SDK 实例)。 */
925
+ private buildSessionRunnerContext(overrideCwd?: string): SessionRunnerContext {
926
+ return {
927
+ cwd: overrideCwd ?? this.cwd,
928
+ agentDir: this.modelService.getAgentDir(),
929
+ // ADR-031 废弃 discovery.json 后,skillDirs 为空。子 session 的 --skill
930
+ // 由 agent({skill}) 调用方显式传入(resolveSkillPath → opts.skillPath)。
931
+ skillDirs: [],
932
+ mainCwd: this.cwd,
933
+ // mainSessionFile: fork source 解析用,从 session_start 缓存获取。
934
+ mainSessionFile: this.getMainSessionFile?.() ?? undefined,
935
+ // worktree pid 回调:session-runner first header 时补全注册表 pid。
936
+ onWorktreePid: (branch: string, pid: number) => this.worktreeManager.registerPid(branch, pid),
937
+ };
938
+ }
939
+ }
940
+
941
+ // ============================================================
942
+ // 进程单例访问器(session_start 重建)
943
+ // ============================================================
944
+
945
+ // 用 globalThis[Symbol.for] 持有进程单例,避免 jiti 因路径字符串不同加载多份模块
946
+ // 导致单例分裂。场景:其它扩展 import "@zhushanwen/pi-subagents" 与本扩展被 Pi host
947
+ // 直接加载,若 jiti 缓存 key 用路径字符串(非 realpath),两份 subagent-service.ts 各持
948
+ // 一个 _service,setSubagentService 写 A、getSubagentService 读 B(null)。globalThis 跨所有模块实例共享,彻底消除。
949
+ // 详见 docs/standards.md §7.5。
950
+ const SERVICE_SLOT_KEY = Symbol.for("@zhushanwen/pi-subagents.service");
951
+
952
+ type ServiceSlot = { current: SubagentService | null };
953
+
954
+ function getServiceSlot(): ServiceSlot {
955
+ // globalThis 无 symbol 索引签名,但运行时支持 symbol 键——用 Reflect 安全读写,
956
+ // 避免双重断言。ServiceSlot 是运行时保证的固定形状(同文件唯一写入点)。
957
+ let slot = Reflect.get(globalThis, SERVICE_SLOT_KEY) as ServiceSlot | undefined;
958
+ if (!slot) {
959
+ slot = { current: null };
960
+ Reflect.set(globalThis, SERVICE_SLOT_KEY, slot);
961
+ }
962
+ return slot;
963
+ }
964
+
965
+ /** 获取进程单例。session_start 前为 null。 */
966
+ export function getSubagentService(): SubagentService | null {
967
+ return getServiceSlot().current;
968
+ }
969
+
970
+ /** 设置进程单例(session_start 首次创建时)。 */
971
+ export function setSubagentService(service: SubagentService): void {
972
+ getServiceSlot().current = service;
973
+ }