@zhushanwen/pi-subagent-workflow 0.2.0 → 0.3.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 (64) hide show
  1. package/README.md +56 -0
  2. package/agents/{scout.md → explorer.md} +1 -1
  3. package/agents/orchestrator.md +48 -0
  4. package/package.json +1 -1
  5. package/src/execution/__tests__/agent-registry.test.ts +3 -3
  6. package/src/execution/__tests__/ask-user-transit-e2e.test.ts +484 -0
  7. package/src/execution/__tests__/channel-registry-handshake.test.ts +233 -0
  8. package/src/execution/__tests__/crash-recovery.test.ts +5 -1
  9. package/src/execution/__tests__/dialog-queue.test.ts +299 -0
  10. package/src/execution/__tests__/execute-nesting.test.ts +1 -1
  11. package/src/execution/__tests__/execute-options-mapper.test.ts +1 -1
  12. package/src/execution/__tests__/finalize-record.test.ts +173 -0
  13. package/src/execution/__tests__/gui-mode-dispatch.test.ts +2 -3
  14. package/src/execution/__tests__/helpers/spawn-mock.ts +209 -0
  15. package/src/execution/__tests__/host-mode.test.ts +87 -0
  16. package/src/execution/__tests__/index-session-start.test.ts +342 -0
  17. package/src/execution/__tests__/list-component.test.ts +1 -1
  18. package/src/execution/__tests__/notifier-flush.test.ts +78 -0
  19. package/src/execution/__tests__/path-encoding.test.ts +30 -1
  20. package/src/execution/__tests__/record-store.test.ts +86 -2
  21. package/src/execution/__tests__/records-cwd-isolation.test.ts +91 -0
  22. package/src/execution/__tests__/rpc-mode.test.ts +89 -0
  23. package/src/execution/__tests__/run-spawn-edges.test.ts +157 -153
  24. package/src/execution/__tests__/run-spawn-integration.test.ts +85 -151
  25. package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +193 -0
  26. package/src/execution/__tests__/session-file-gc.test.ts +46 -0
  27. package/src/execution/__tests__/session-start-reaper.test.ts +7 -1
  28. package/src/execution/__tests__/spawn-args.test.ts +14 -19
  29. package/src/execution/__tests__/spawn-event-adapter-rpc.test.ts +189 -0
  30. package/src/execution/__tests__/stdin-writer.test.ts +353 -0
  31. package/src/execution/__tests__/subagent-service.test.ts +73 -3
  32. package/src/execution/__tests__/tool-action.test.ts +1 -1
  33. package/src/execution/__tests__/ui-channels.test.ts +187 -0
  34. package/src/execution/__tests__/ui-interaction-model.test.ts +67 -0
  35. package/src/execution/__tests__/ui-request-handler-factory.test.ts +166 -0
  36. package/src/execution/__tests__/ui-request-handler.test.ts +204 -0
  37. package/src/execution/__tests__/ui-request-observability.test.ts +101 -0
  38. package/src/execution/__tests__/ui-request-queue.test.ts +133 -0
  39. package/src/execution/__tests__/worktree-manager.test.ts +1 -1
  40. package/src/execution/agent-registry.ts +1 -1
  41. package/src/execution/channel-registry-access.ts +138 -0
  42. package/src/execution/dialog-queue.ts +329 -0
  43. package/src/execution/finalize-record.ts +160 -0
  44. package/src/execution/get-state-handshake.ts +104 -0
  45. package/src/execution/host-mode.ts +52 -0
  46. package/src/execution/manifest-store.ts +206 -0
  47. package/src/execution/notifier.ts +5 -1
  48. package/src/execution/path-encoding.ts +18 -0
  49. package/src/execution/pi-invocation.ts +1 -1
  50. package/src/execution/record-store.ts +108 -2
  51. package/src/execution/session-file-gc.ts +25 -3
  52. package/src/execution/session-runner.ts +216 -32
  53. package/src/execution/spawn-event-adapter.ts +219 -6
  54. package/src/execution/stdin-writer.ts +106 -0
  55. package/src/execution/subagent-service.ts +167 -197
  56. package/src/execution/ui-channels.ts +216 -0
  57. package/src/execution/ui-interaction-model.ts +48 -0
  58. package/src/execution/ui-request-handler-factory.ts +175 -0
  59. package/src/execution/ui-request-observability.ts +77 -0
  60. package/src/execution/ui-request-queue.ts +168 -0
  61. package/src/index.ts +90 -6
  62. package/src/interface/format.ts +2 -0
  63. package/src/interface/subagent-actions.ts +9 -2
  64. package/src/interface/subagent-tool.ts +9 -8
@@ -1,18 +1,10 @@
1
- // src/runtime/subagent-service.ts
2
- //
3
- // 执行编排 + 记录 + 通知领域 Service。"跑一次子代理 + 管理执行状态"。
4
- //
5
- // 与 ModelConfigService(配置/模型解析域)正交——本 Service 持有其引用但不暴露给外部。
6
- // executor 逻辑已合并进本文件——它是 SubagentService.execute 的编排逻辑,
7
- // 没有独立状态/生命周期,不需要独立文件。合并后行为方法自然 private。
8
- //
1
+ // 执行编排 + 记录 + 通知领域 Service。
9
2
  // 上游:subagent-tool(execute/query/cancel)、TUI(onChange/listRunning/collectRecords)。
10
3
  // session_start 时经 initSession 注入 pi;modelRegistry/entries 归 ModelConfigService.initModel。
11
4
 
12
5
  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";
6
+
7
+ import type { ExtensionMode } from "@mariozechner/pi-coding-agent";
16
8
 
17
9
  import type { AgentResult as WorkflowAgentResult } from "../orchestration/models/types.ts";
18
10
  // D-A10: workflow 侧 AgentResult 映射(executeAndAwait 出口)
@@ -20,6 +12,7 @@ import { mapToWorkflowAgentResult } from "./agent-result-mapper.ts";
20
12
  import { removeAliveMarker } from "./alive-store.ts";
21
13
  import { bestEffort } from "./best-effort.ts";
22
14
  import { type ConcurrencyPool,DefaultConcurrencyPool } from "./concurrency-pool.ts";
15
+ import type { DialogGlobalQueue, UiRequestHandler } from "./dialog-queue.ts";
23
16
  import {
24
17
  completeRecord,
25
18
  createRecord,
@@ -27,12 +20,13 @@ import {
27
20
  snapshot,
28
21
  tryTransition,
29
22
  } from "./execution-record.ts";
30
- import { writeFinalized } from "./finalized-marker.ts";
23
+ import { doFinalizeRecord } from "./finalize-record.ts";
24
+ import { ManifestStore } from "./manifest-store.ts";
31
25
  import type { ModelConfigService } from "./model-config-service.ts";
32
26
  import type { AgentConfig, ModelInfo, ResolvedModel } from "./model-resolver.ts";
33
27
  import type { BgNotifyRecord, NotifierHost } from "./notifier.ts";
34
28
  import { BgNotifier } from "./notifier.ts";
35
- import { getSubagentSessionDir } from "./path-encoding.ts";
29
+ import { getSubagentRecordsDir, getSubagentSessionDir } from "./path-encoding.ts";
36
30
  import type { StatusFilter } from "./record-store.ts";
37
31
  import { RecordStore } from "./record-store.ts";
38
32
  import { MAX_FORK_DEPTH } from "./session-context-resolver.ts";
@@ -54,8 +48,26 @@ import type {
54
48
  } from "./types.ts";
55
49
  import { ForkDepthExceededError } from "./types.ts";
56
50
  import { DEFAULT_AGENT_NAME } from "./types.ts";
51
+ import { registerGlobalObservability, UiRequestObservability } from "./ui-request-observability.ts";
57
52
  import { WorktreeManager } from "./worktree-manager.ts";
58
53
 
54
+ /** dispose 后注入的 stub UI 请求 handler。
55
+ *
56
+ * [背景] Pi 单进程 session 串行接管。session A shutdown 时 SIGTERM 子进程后、
57
+ * 子进程彻底 close 前(pi 子进程 trap SIGTERM 做 graceful shutdown,窗口几十~几百 ms),
58
+ * 子进程的 trailing extension_ui_request 仍可能被父进程 pump 解析,调到 A 的 handler 闭包。
59
+ * 若 dispose 不清 uiRequestHandler,旧 handler 闭包仍持有 A 的 ctx,触发
60
+ * ui-request-queue.ts 的 catch 分支打 `[subagents] uiRequestHandler threw` 误导性
61
+ * console.error(看起来像 bug,实际是预期竞态;三层兜底已确保功能正确)。
62
+ *
63
+ * stub 始终返回 {cancelled:true},不调 ctx.ui、不捕获任何 ctx,让 trailing ui_request
64
+ * 干净降级为 cancelled(等价于子进程主动取消)。
65
+ *
66
+ * 不置 undefined —— 那会让 trailing ui_request 走 ui-request-queue.ts 的 handler-missing
67
+ * 分支触发 notifyMissingHandlerGlobal warn,噪声性质从 threw-error 变 missing-handler,
68
+ * 没真正解决。 */
69
+ const disposedUiRequestStub: UiRequestHandler = () => Promise.resolve({ cancelled: true });
70
+
59
71
  /** Pi ExtensionAPI 的最小接口(duck-typed)。
60
72
  * subagent-service 直接调 pi.sendMessage 发 background 完成通知(BgNotifier 滑动窗口合并),
61
73
  * 不委托 pending-notifications EventBus 中继——后者只管 registry 不参与通知发送。 */
@@ -100,6 +112,9 @@ export interface SubagentServiceInit {
100
112
  modelService: ModelConfigService;
101
113
  /** 缓存的主 session file 获取函数(fork source 解析用)。 */
102
114
  getMainSessionFile?: () => string | undefined;
115
+ /** W2: UI 请求处理回调(ask_user 扩展)。
116
+ * 签名见 dialog-queue.ts UiRequestHandler:接收 UiRequest,返回 UiResponse。 */
117
+ uiRequestHandler?: UiRequestHandler;
103
118
  }
104
119
 
105
120
  /** session_start 注入参数(session 级)。 */
@@ -108,20 +123,20 @@ export interface SubagentServiceSessionInit {
108
123
  sessionId: string;
109
124
  /** UI streaming sink(ctx.ui.setWidget),用于 background text_delta 转发。 */
110
125
  streamSink?: StreamSink;
126
+ /** 主进程运行模式(W4 守卫:headless 不注入 ask_user RPC 提示词)。
127
+ * initSession 读取后存入 this.sessionMode,buildSessionRunnerContext 透传给 session-runner。 */
128
+ mode?: ExtensionMode;
129
+ /** UI 请求 handler(session 级覆盖进程级)。
130
+ * initSession 读取后覆盖 this.uiRequestHandler(setUiRequestHandler 的 session 级等价入口)。 */
131
+ uiRequestHandler?: UiRequestHandler;
132
+ /** L2 跨子进程全局 dialog 串行队列(进程单例)。透传给 session-runner,
133
+ * child close 时调 rejectChildDialogs 清理 pending(SR-4 防全局死锁)。 */
134
+ dialogQueue?: DialogGlobalQueue;
111
135
  }
112
136
 
113
137
  /** background 优先级(保留 priority 排序机制,单一值)。 */
114
138
  const PRIORITY_BACKGROUND = 1000;
115
139
 
116
- /** [MF#5] sessionId 短哈希前缀(6 hex)。两个并发 Pi 进程在同一 repo fork 时,seq 各自从 0
117
- * 自增 → recordId=run-1 / branch=pi-sub-run-1 冲突 → 第二个 git worktree add -b 失败。
118
- * 加 session 作用域前缀保证跨进程唯一。sessionId 缺失时用 'x' 兌底(空值不进 hash)。 */
119
- const SESSION_TAG_HEX_LEN = 6;
120
- function sessionTag(sessionId: string | null): string {
121
- if (!sessionId) return "x";
122
- return createHash("sha1").update(sessionId).digest("hex").slice(0, SESSION_TAG_HEX_LEN);
123
- }
124
-
125
140
  /** 触发 onUpdate 的事件类型(streaming delta 不触发,避免每 token 刷新)。 */
126
141
  const TRIGGERING_EVENT_TYPES = new Set<AgentEvent["type"]>([
127
142
  "tool_start",
@@ -166,7 +181,12 @@ export class SubagentService {
166
181
  private readonly cwd: string;
167
182
  private readonly worktreeManager: WorktreeManager;
168
183
  private readonly getMainSessionFile: (() => string | undefined) | undefined;
169
-
184
+ /** UI 请求 handler(进程级,可被 setUiRequestHandler / initSession 覆盖)。 */
185
+ private uiRequestHandler: SubagentServiceInit["uiRequestHandler"];
186
+ /** L2 dialog 串行队列(进程级)。SR-4:child close 时 session-runner 调 rejectChildDialogs 清理。 */
187
+ private dialogQueue: DialogGlobalQueue | undefined;
188
+ /** UI 请求可观测性(sessionMode + handler 缺失告警去重,提取自本类降低行数)。 */
189
+ private readonly uiObservability = new UiRequestObservability();
170
190
  private pi: PiLike | null = null;
171
191
  /** 当前 Pi session ID(session 隔离过滤用)。initSession 时注入。 */
172
192
  private sessionId: string | null = null;
@@ -178,12 +198,9 @@ export class SubagentService {
178
198
  /** background 完成通知器(滑动窗口合并 + 去重)。session_start revive,shutdown dispose。 */
179
199
  private readonly notifier: BgNotifier;
180
200
  /** [MF#4][MF#2] fork 深度按 async 调用链传递(AsyncLocalStorage),替代共享可变计数器。
181
- * 主 session=0;fork 进入子 session 期间推进为子深度,供嵌套 fork execute(子 agent
182
- * 在 run() 期间再调 subagent tool)经 ALS 读到自身深度作为 parentForkDepth。并发 background
183
- * fork 各自独立调用链,不再互相压低深度值MAX_FORK_DEPTH 递归护栏不被绕过。
184
- * [MF#2] 旧实现用单实例字段 currentForkDepth 跨所有执行链共享:并发 background 下 A 还原
185
- * 深度后 B 的嵌套 fork 读到被压低的值 → 护栏恒不过限。ALS 是 node 跨 async 边界传递
186
- * “请求作用域”状态的标准机制,每条调用链隔离。 */
201
+ * 主 session=0;fork 进入子 session 期间推进为子深度,供嵌套 fork ALS 读到自身深度作为
202
+ * parentForkDepth。并发 background fork 各自独立调用链,不再互相压低深度值。
203
+ * [MF#2] 旧实现用单实例字段跨执行链共享并发下 A 还原深度后 B 读到被压低值 → 护栏失效。 */
187
204
  private readonly forkDepthAls = new AsyncLocalStorage<number>();
188
205
 
189
206
  /** subagent 执行上下文按 async 调用链传递(当前正在跑的 record 身份 + 递归深度)。
@@ -192,24 +209,62 @@ export class SubagentService {
192
209
  * 与 forkDepthAls 独立:后者只数 fork 链(fork=true 才递增),本 ALS 数所有 subagent 嵌套。 */
193
210
  private readonly execCtxAls = new AsyncLocalStorage<{ recordId: string | undefined; depth: number }>();
194
211
 
212
+ private readonly manifestStore: ManifestStore;
213
+
195
214
  constructor(init: SubagentServiceInit) {
196
215
  this.cwd = init.cwd;
197
216
  this.modelService = init.modelService;
198
217
  this.getMainSessionFile = init.getMainSessionFile;
218
+ this.uiRequestHandler = init.uiRequestHandler;
199
219
  this.pool = new DefaultConcurrencyPool(this.modelService.getGlobalConfig().maxConcurrent);
200
220
  this.worktreeManager = new WorktreeManager(this.modelService.getAgentDir());
201
221
  const sessionsDir = getSubagentSessionDir(this.modelService.getAgentDir(), init.cwd);
202
- this.store = new RecordStore(sessionsDir);
222
+ const recordsDir = getSubagentRecordsDir(this.modelService.getAgentDir(), init.cwd);
223
+ this.manifestStore = new ManifestStore(recordsDir);
224
+ this.store = new RecordStore(sessionsDir, this.manifestStore, this.pi ?? undefined);
203
225
  this.notifier = new BgNotifier(this.piAdapter());
226
+ // #11:注册进程级 observability 单例——ui-request-queue.handleUiRequest 经
227
+ // globalThis 桥接(notifyMissingHandlerGlobal)调到同一实例,共享
228
+ // warnedMissingHandlerSessions 去重集合。未注册时 queue 走 fallback warn(不去重)。
229
+ registerGlobalObservability(this.uiObservability);
204
230
  }
205
231
 
206
232
  // ── 生命周期(index.ts 调)──────────────────────────────
207
233
 
234
+ /** 覆盖 UI 请求 handler(W3: index.ts session_start 时按 mode 注入 handler 后调)。
235
+ * 委托 uiObservability 重置缺失告警去重——新 handler 就位后允许重新 warn。 */
236
+ setUiRequestHandler(handler: UiRequestHandler | undefined): void {
237
+ this.uiRequestHandler = handler;
238
+ this.uiObservability.resetMissingHandlerWarnings();
239
+ }
240
+
241
+ /** session-runner handleUiRequest 在 handler 缺失时调用(FR-9 可观测性)。
242
+ * 委托 uiObservability:按 session 去重,同一 session 的多次 UI 请求只 warn 一次。
243
+ * W2: console.warn 兜底。W3 接入 pi.appendEntry("subagent:ui-request-missing-handler", ...)。 */
244
+ notifyMissingHandler(sessionId: string): void {
245
+ this.uiObservability.notifyMissingHandler(sessionId);
246
+ }
247
+
208
248
  /** session_start 注入 pi + revive(modelRegistry/entries 归 ModelConfigService.initModel)。 */
209
249
  initSession(init: SubagentServiceSessionInit): void {
210
250
  this.pi = init.pi;
251
+ // 同步注入 pi 到 RecordStore(构造时 this.pi 为 null,session_start 后才有真实 handle)。
252
+ // RecordStore 跳过损坏 manifest 时调 appendEntry 上报用户可见——若不重新注入,
253
+ // 上报通道永远是 no-op,事故排查依然静默。
254
+ this.store.setPi(this.pi);
211
255
  this.sessionId = init.sessionId;
212
256
  this.streamSink = init.streamSink ?? null;
257
+ // 读取 mode(W4 守卫透传给 session-runner)+ session 级 handler 覆盖。
258
+ this.uiObservability.setMode(init.mode);
259
+ if (init.uiRequestHandler !== undefined) {
260
+ this.uiRequestHandler = init.uiRequestHandler;
261
+ this.uiObservability.resetMissingHandlerWarnings();
262
+ }
263
+ // SR-4:注入 L2 dialog 队列(child close 清理路径)。undefined 时 buildSessionRunnerContext
264
+ // 透传 undefined,session-runner onClose 跳过 L2 清理(仅清 L1,保留旧行为)。
265
+ if (init.dialogQueue !== undefined) {
266
+ this.dialogQueue = init.dialogQueue;
267
+ }
213
268
  // [SPAWN fork depth 跨进程传递] 子进程被父 spawn 时,父通过 env
214
269
  // PI_SUBAGENT_FORK_DEPTH 传入当前 fork 链深度。子进程 session_start 时
215
270
  // 读取作为 forkDepthAls 基线,使后续嵌套 spawn fork 能从正确深度递增。
@@ -227,6 +282,19 @@ export class SubagentService {
227
282
  this.notifier.revive();
228
283
  }
229
284
 
285
+ /** 启动恢复:扫描 manifest tmp 残留(崩溃打断的 writeManifest 留下的 *.json.tmp.<pid>),
286
+ * 3 分支判定(manifest已存在删tmp / tmp合法promote / tmp非法删)。幂等,不 throw。
287
+ * ADR-035 启动恢复接线——session_start 每次都调(与 maybeCleanupExpiredSessionFiles 一致)。
288
+ * manifestStore 保持 private 封装,本方法是唯一公开入口。 */
289
+ async recoverManifestTmpFiles(): Promise<{ deleted: number; recovered: number }> {
290
+ try {
291
+ return await this.manifestStore.recoverTmpFiles();
292
+ } catch (err) {
293
+ bestEffort(err, "recoverManifestTmpFiles", "error");
294
+ return { deleted: 0, recovered: 0 };
295
+ }
296
+ }
297
+
230
298
  /** session 结束清理(清定时器,丢弃 pending 通知)。幂等。
231
299
  *
232
300
  * [M-7] dispose 顺序假设:pending:unregister emit 依赖 pending-notifications 扩展的
@@ -236,32 +304,23 @@ export class SubagentService {
236
304
  dispose(): void {
237
305
  if (this._disposed) return;
238
306
  this._disposed = true;
239
- // [R0/C1 孤儿进程修复] 进程退出路径:两层兜底 kill 所有 spawned 子进程(sync + background)。
240
- // 1. store.abortRunningControllers():background recordcontroller.abort runSpawn signal
241
- // listener child.kill("SIGTERM")。这是 background CAS 收尾语义路径(不能动)。
242
- // 2. killAllSpawnedChildren():遍历 session-runner 的 spawnedChildren Set(sync + background
243
- // 均注册),对仍存活的发 SIGTERM。sync record controller undefinedabortRunningControllers
244
- // 跳过它们——此处补齐,防止 sync 子进程成孤儿(主进程崩溃/SIGKILL 之外的退出路径)。
245
- // 必须在 store.dispose 之前(dispose 后 records 仍可访问,但语义上先 kill 再清场)。
246
- // 注意:dispose 是同步返回,主进程可能紧接着 process.exit(),runSpawn 的 finally 清理
247
- //(identity 补写等)可能来不及跑——这是可接受的退化(session.jsonl 已由子进程写入,
248
- // 缺 identity entry 只影响 list 重建的可观测性,不丢执行数据)。
249
- //
250
- // [T2 AC-4.3 双重记账一致性] 进程退出时所有 running record 异常终止(runAndFinalize 的
251
- // finalizeRecord 不会再跑——detached promise 随进程退出而丢弃)。此处为每个 running record
252
- // emit pending:unregister(reason=failed),让 pending-notifications 清理 registry entry,
253
- // 避免进程退出后两侧(subagent store vs pending registry)状态不一致。
307
+ // [dispose stub] 第一时间换 stub,防 trailing ui_request 调到 stale handler 闭包
308
+ // (仍持有 disposed session ctx)产生误导性 console.error。stub 干净降级为 cancelled。
309
+ // 必须在 emit/abort 之前——这些步骤可能同步触发 trailing pump。
310
+ this.setUiRequestHandler(disposedUiRequestStub);
311
+ // [T2 AC-4.3 双重记账一致性] 为每个 running record emit pending:unregister(reason=failed)
312
+ // pending-notifications 清理 registry entry,避免进程退出后两侧状态不一致。
254
313
  // 必须在 abortRunningControllers 之前——此时 record 仍 running,listRunning 能取到。
255
- // 只 emit running 的 record(已终态的由其正常路径 emit 过,不重复)。
256
314
  for (const record of this.store.listRunning()) {
257
315
  emitPendingUnregister(this.pi, record.id, "failed");
258
316
  }
317
+ // [R0/C1 孤儿进程修复] 两层兜底 kill 所有 spawned 子进程(sync + background):
318
+ // 1. abortRunningControllers:background record 的 controller.abort → child.kill(CAS 收尾语义)。
319
+ // 2. killAllSpawnedChildren:遍历 session-runner spawnedChildren Set,对仍存活的发 SIGTERM
320
+ // (sync record 的 controller 是 undefined,abortRunningControllers 跳过它们,此处补齐)。
321
+ // 必须在 store.dispose 之前(先 kill 再清场)。dispose 同步返回后主进程可能立即 exit,
322
+ // runSpawn 的 finally 清理可能来不及跑——可接受退化(session.jsonl 已由子进程写入)。
259
323
  this.store.abortRunningControllers();
260
- // [C1] orphan 进程兜底:abortRunningControllers 只能 kill background 子进程(有 controller)。
261
- // sync 子进程的 controller 是 undefined(见 createRecordForMode),主进程退出时会被遗漏成孤儿。
262
- // killAllSpawnedChildren 遍历 session-runner 的 spawnedChildren Set(sync + background 均注册),
263
- // 对仍存活的子进程发 SIGTERM。background 子进程此时已被 controller.abort 路径 kill,
264
- // 此处对它们的二次 kill 是无害 noop(已 killed/退出)。不 await 子进程退出(dispose 要快)。
265
324
  killAllSpawnedChildren();
266
325
  for (const s of this.throttleState.values()) {
267
326
  if (s.timer !== undefined) clearTimeout(s.timer);
@@ -275,9 +334,11 @@ export class SubagentService {
275
334
 
276
335
  // ── 执行(subagent-tool 调)────────────────────────────
277
336
 
278
- /** background 完成回注(record → BgNotifyRecord 映射 + notifier.notify)。 */
337
+ /** background 完成回注(record → BgNotifyRecord 映射 + notifier.notify)。
338
+ * 非终态 status(running/crashed)静默跳过——notify 只对 done/failed/cancelled 有意义。 */
279
339
  private notifyComplete(record: ExecutionRecord): void {
280
- this.notifier.notify(this.toNotifyRecord(record));
340
+ const notify = this.toNotifyRecord(record);
341
+ if (notify) this.notifier.notify(notify);
281
342
  }
282
343
 
283
344
  /** notifier 的 NotifierHost 适配器(绑定到 pi.sendMessage + store 查询)。 */
@@ -292,12 +353,16 @@ export class SubagentService {
292
353
  };
293
354
  }
294
355
 
295
- /** record → BgNotifyRecord(notifier.notify 入参映射,内部不外露)。 */
296
- private toNotifyRecord(record: ExecutionRecord): BgNotifyRecord {
356
+ /** record → BgNotifyRecord(notifier.notify 入参映射,内部不外露)。
357
+ * 运行时守卫:非 done/failed/cancelled 返回 undefined(调用方 notifyComplete 跳过 notify)。
358
+ * 守卫后 status 已收窄为 BgNotifyRecord.status union,无需 cast。 */
359
+ private toNotifyRecord(record: ExecutionRecord): BgNotifyRecord | undefined {
297
360
  const snap = snapshot(record);
361
+ const s = snap.status;
362
+ if (s !== "done" && s !== "failed" && s !== "cancelled") return undefined;
298
363
  return {
299
364
  id: snap.id,
300
- status: snap.status as "done" | "failed" | "cancelled",
365
+ status: s,
301
366
  agent: snap.agent,
302
367
  model: snap.model,
303
368
  result: snap.result,
@@ -309,14 +374,8 @@ export class SubagentService {
309
374
  }
310
375
 
311
376
  /**
312
- * 预解析 model(renderCall 标题行用,同步)。
313
- * 代理 modelService.resolveModel——renderCall execute 前调用,但 model 解析是同步的,
314
- * 让标题行能提前显示 model/thinking,不必等 execute。
315
- * hub 未就绪时抛(调用方 catch 降级)。
316
- *
317
- * 注意:renderCall 无 ctx,拿不到主 agent model。这里仅解析 override/agentConfig 路径,
318
- * 主 agent model 路径交给 execute(传 ctxModel)。renderCall 时如果用户未显式 override,
319
- * 本方法会因 ctxModel 缺失走第三层→ 拋错→ 调用方 catch 降级(不显示 model)。
377
+ * 预解析 model(renderCall 标题行用,同步)。代理 modelService.resolveModel。
378
+ * 仅解析 override/agentConfig 路径;ctxModel 缺失时拋错,调用方 catch 降级。
320
379
  */
321
380
  resolveModel(
322
381
  agent: string,
@@ -337,13 +396,9 @@ export class SubagentService {
337
396
 
338
397
  // 通用嵌套深度护栏(D-033):execCtxAls 记录所有 subagent 嵌套层级(fork + 非 fork),
339
398
  // 每层 +1。MAX_FORK_DEPTH 同时限 fork 链与通用嵌套——非 fork 递归虽不累积 session 体积,
340
- // 但耗资源(每层 createAgentSession + resourceLoader + session 文件)且 LLM 易陷入
341
- // 「委派子 agent agent 再委派」死循环(实测无护栏时递归到 L36 全 failed)。
342
- // 在所有副作用(record/worktree/session)之前拦截,错误直达调用方。
343
- // fork:true 的体积护栏(resolveSessionContext 的 parentForkDepth 检查)作为第二层保留。
344
- // 计数基准:顶层 nestingDepth=0;每次嵌套 execute +1。允许 0..MAX_FORK_DEPTH(共 11 层),
345
- // nestingDepth=MAX+1 被拒。与 fork 护栏(parentForkDepth>=MAX 拒,parent 计数基准)互补:
346
- // 本护栏更严(计所有嵌套),混合链下先生效;两者共享 MAX_FORK_DEPTH 上限不漂移。
399
+ // 但耗资源且 LLM 易陷入「委派→再委派」死循环。在所有副作用之前拦截,错误直达调用方。
400
+ // 计数基准:顶层 nestingDepth=0,nestingDepth>MAX 被拒。与 fork 体积护栏(parentForkDepth 检查)
401
+ // 互补:本护栏更严(计所有嵌套),混合链下先生效;两者共享 MAX_FORK_DEPTH 上限不漂移。
347
402
  const parentNesting = this.execCtxAls.getStore();
348
403
  const nestingDepth = parentNesting ? parentNesting.depth + 1 : 0;
349
404
  if (nestingDepth > MAX_FORK_DEPTH) {
@@ -532,10 +587,8 @@ export class SubagentService {
532
587
  opts: ExecuteOptions,
533
588
  mode: ExecutionMode,
534
589
  ): ExecutionRecord {
535
- const seq = ++this._seq;
536
- const tag = sessionTag(this.sessionId);
537
- // mode 类型固定 "background"——保留参数以兼容签名,但 id/controller 无需再分支。
538
- const id = `bg-${tag}-${seq}-${Date.now()}`;
590
+ // FR-1: record id 用全局 UUID,不依赖 transcript/PID
591
+ const id = crypto.randomUUID();
539
592
  const controller = new AbortController();
540
593
 
541
594
  // 从 async 调用链读父执行上下文:主 session 链上无 store → 顶层 record;
@@ -687,7 +740,9 @@ export class SubagentService {
687
740
  }
688
741
  })
689
742
  .catch((err: unknown) => {
690
- // detached 吞错:runAndFinalize 内部已 finalize record(含 emitPendingUnregister),不外抛
743
+ // detached 吞错:runAndFinalize 内部已 finalize record(含 emitPendingUnregister),
744
+ // 且 finalizeRecord 的 manifest 写入已降级为 best-effort(失败仅 console.error + appendEntry,
745
+ // 不外抛)。因此此处不应走到——但作为最后一道兼底,记录调试日志后吞下,不外抛。
691
746
  // 完成通知由 finalizeRecord 内的 emitPendingUnregister 承担(pending-notifications 消费)。
692
747
  // cancel 抢先时 status=cancelled,cancelBackground 自己 emit,此处无需重复。
693
748
  if (err instanceof Error) {
@@ -704,17 +759,8 @@ export class SubagentService {
704
759
  }
705
760
  // 抢到锁:completeRecord(用空 result 填 cancelled)+ archive(立即移出内存)+ notify。
706
761
  // 写 cancelled tombstone:session.jsonl 被 abort 截断,cancelled 状态靠 sidecar 标记,
707
- // collectRecords 重建时 override status=cancelled。
708
- // durationMs 用真实耗时(startedAt now),避免耗时统计恒为 0 失真。
709
- const cancelledResult: AgentResult = {
710
- text: "",
711
- turns: record.turnCount,
712
- durationMs: Date.now() - record.startedAt,
713
- success: false,
714
- error: "cancelled by user",
715
- sessionId: record.id,
716
- toolCalls: [],
717
- };
762
+ // collectRecords 重建时 override status=cancelled。durationMs 用真实耗时(startedAt → now)。
763
+ const cancelledResult: AgentResult = { text: "", turns: record.turnCount, durationMs: Date.now() - record.startedAt, success: false, error: "cancelled by user", sessionId: record.id, toolCalls: [] };
718
764
  completeRecord(record, cancelledResult, "cancelled");
719
765
  // 写 tombstone(best-effort,sessionFile 可能为 undefined——窗口期 cancel)。
720
766
  if (record.sessionFile) {
@@ -750,93 +796,32 @@ export class SubagentService {
750
796
  }
751
797
 
752
798
  /**
753
- * D-017 时序收尾:collectPatch completeRecord → archive → writeFinalized + cleanup + removeAliveMarker。
754
- * B9 兜底:completeRecord/archive 抛错→ finalized/cleanup/aliveMarker 仍执行。
755
- */
799
+ * D-017 时序收尾:委托 doFinalizeRecord(提取到 finalize-record.ts,降低本文件行数)。
800
+ * [Critical #1] cleanup 全部在 manifest 写之前,manifest best-effort 不阻断(详见 finalize-record.ts)。 */
756
801
  private async finalizeRecord(
757
802
  record: ExecutionRecord,
758
803
  result: AgentResult,
759
804
  status: "done" | "failed" | "cancelled",
760
805
  ): Promise<void> {
761
- // 终态清节流状态:防 trailing timer 在 record 归档后误发陈旧 onUpdate
762
- this.clearThrottle(record.id);
763
- // ── Step 0: collectPatch(best-effort)──
764
- // [MF#3] patchFile 写到 worktree 之外(sessionsDir/<branch>.patch),避免被 cleanup 删除;
765
- // 路径回填 record.patchFile,供调用方(tool result / /subagents list)应用。
766
- if (record.worktreeHandle) {
767
- try {
768
- const sessionsDir = getSubagentSessionDir(
769
- this.modelService.getAgentDir(),
770
- record.worktreeHandle.mainCwd,
771
- );
772
- fs.mkdirSync(sessionsDir, { recursive: true });
773
- const patchFile = path.join(sessionsDir, `${record.worktreeHandle.branch}.patch`);
774
- const patch = this.worktreeManager.collectPatch(record.worktreeHandle, patchFile);
775
- if (patch.written) record.patchFile = patchFile;
776
- } catch (pe: unknown) {
777
- bestEffort(pe, "collectPatch (finalizeRecord Step0)");
778
- }
779
- }
780
-
781
- // ── Step 1: completeRecord(B9: 抛错→3 仍执行)──
782
- try {
783
- completeRecord(record, result, status);
784
- } catch (err) {
785
- bestEffort(err, "completeRecord (finalizeRecord B9)", "error");
786
- }
787
-
788
- // ── Step 2: archive(B9: 抛错→3 仍执行)──
789
- try {
790
- this.store.archive(record);
791
- } catch (err) {
792
- bestEffort(err, "store.archive (finalizeRecord B9)", "error");
793
- }
794
-
795
- // ── Step 3: finalized + cleanup + aliveMarker(三件各自独立 try/catch)──
796
- if (record.sessionFile) {
797
- try {
798
- // MF-1 fix: cancelled 状态写 tombstone 而非 finalized,防重建丢失 cancelled
799
- if (status === "cancelled") {
800
- writeCancelledTombstone(record.sessionFile, {
801
- id: record.id,
802
- status: "cancelled",
803
- agent: record.agent,
804
- startedAt: record.startedAt,
805
- endedAt: record.endedAt ?? Date.now(),
806
- });
807
- } else {
808
- writeFinalized(record.sessionFile);
809
- }
810
- } catch (err) {
811
- bestEffort(err, "writeFinalized/tombstone (finalizeRecord Step3)");
812
- }
813
- }
814
- if (record.worktreeHandle) {
815
- try {
816
- this.worktreeManager.cleanup(record.worktreeHandle);
817
- } catch (err) {
818
- bestEffort(err, "worktree cleanup (finalizeRecord Step3)");
819
- }
820
- }
821
- if (record.sessionFile) {
822
- try {
823
- removeAliveMarker(record.sessionFile);
824
- } catch (err) {
825
- bestEffort(err, "removeAliveMarker (finalizeRecord Step3)");
826
- }
827
- }
828
-
829
- // pending-notifications:终态注销(只记 registry 状态,通知由 BgNotifier 发)
830
- emitPendingUnregister(this.pi, record.id, status);
806
+ await doFinalizeRecord(
807
+ {
808
+ manifestStore: this.manifestStore,
809
+ worktreeManager: this.worktreeManager,
810
+ store: this.store,
811
+ modelService: this.modelService,
812
+ pi: this.pi,
813
+ clearThrottle: (id) => this.clearThrottle(id),
814
+ emitUnregister: (id, st) => emitPendingUnregister(this.pi, id, st),
815
+ },
816
+ record,
817
+ result,
818
+ status,
819
+ );
831
820
  }
832
821
 
833
- /**
834
- * run() 创建期异常的收尾(H1 修复)。
835
- * run() 正常路径不抛错,但 createAndConfigureSession 失败会抛——
836
- * 本方法合成 failed AgentResult → CAS 抢锁 → finalizeRecord
837
- * (与正常路径同形:completeRecord + archive)。
838
- * 返回合成 result 供 runAndFinalize 继续返回(不 re-throw,swallow 策略)。
839
- */
822
+ /** run() 创建期异常的收尾(H1 修复):createAndConfigureSession 失败会抛,本方法合成 failed
823
+ * AgentResult CAS 抢锁 → finalizeRecord(与正常路径同形)。返回合成 result 供 runAndFinalize
824
+ * 继续返回(不 re-throw,swallow 策略)。 */
840
825
  private async finalizeFailed(record: ExecutionRecord, err: unknown): Promise<AgentResult> {
841
826
  const errMsg = err instanceof Error ? err.message : String(err);
842
827
  // durationMs 用真实耗时(startedAt → now),避免失败统计恒为 0 失真。
@@ -857,27 +842,15 @@ export class SubagentService {
857
842
  return cancelledResult;
858
843
  }
859
844
 
860
- // onUpdate 节流状态(per-record Map)。每条 record(每条 onUpdate 回流链)独立节流,
861
- // 避免嵌套(fork 链:主→A→B)多条 onUpdate 链争用同一份节流状态。
862
- // [HISTORICAL] 旧实现用单个实例字段,注释假设“fork 嵌套串行,同时只有一条链”——错误:
863
- // trailing timer 异步,B 设的 trailing 会在 B 完成、A 恢复期间触发,与 A 的同步事件争用
864
- // onUpdateLastEmitAt/onUpdateTrailingTimer → A 的 onUpdate 被吞/延迟 → 主 agent 对话流
865
- // A block 状态跳跃更新 → 残影。per-record 化让 A/B 各自独立节流,互不干扰。
845
+ // onUpdate 节流状态(per-record Map)。每条 record 独立节流,避免嵌套(fork 链:主→A→B)
846
+ // 多条 onUpdate 链争用同一份状态。旧实现用单实例字段——trailing timer 异步导致跨链争用
847
+ // onUpdate 被吞/延迟 → 主 agent 对话流残影。per-record 化让 A/B 各自独立节流。
866
848
  private readonly throttleState = new Map<string, { lastEmitAt: number; timer?: ReturnType<typeof setTimeout> }>();
867
849
 
868
- /**
869
- * AgentEvent 节流回流到 onUpdate(streaming delta 不触发 + 时间窗节流)。
870
- *
871
- * 名为 Throttled 必须真节流——只过滤事件类型时,每个 tool_start/tool_end/turn_end
872
- * 都直发 onUpdate,嵌套场景一秒 10+ 事件密集回流 → Pi tool_execution_update 密集重绘
873
- * → 行数变化的流式 tool 组件在 chatContainer diff 中残影(状态行堆叠)。
874
- *
875
- * leading + trailing:首次事件立即发(响应性),窗口内后续合并到末尾补发一次
876
- * (保证终态事件不丢——sync record 终态后 archive 移出内存,闭包持有的引用仍可 project)。
877
- *
878
- * 节流状态 per-record(Map):每条 record 独立 leading/trailing 窗口。嵌套(fork 链)
879
- * 时外层 A 与内层 B 各自节流,trailing timer 不会跨链污染。
880
- */
850
+ /** AgentEvent 节流回流到 onUpdate(streaming delta 不触发 + 时间窗节流)。
851
+ * 名为 Throttled 必须真节流——否则嵌套场景一秒 10+ 事件密集回流 Pi tool_execution_update
852
+ * 密集重绘 → 流式 tool 组件残影。leading + trailing:首次立即发(响应性),窗口内后续合并
853
+ * 到末尾补发一次(保证终态事件不丢)。节流状态 per-record,trailing timer 不会跨链污染。 */
881
854
  private onEventThrottled(
882
855
  record: ExecutionRecord,
883
856
  event: AgentEvent,
@@ -959,26 +932,23 @@ export class SubagentService {
959
932
  mainSessionFile: this.getMainSessionFile?.() ?? undefined,
960
933
  // worktree pid 回调:session-runner first header 时补全注册表 pid。
961
934
  onWorktreePid: (branch: string, pid: number) => this.worktreeManager.registerPid(branch, pid),
935
+ uiRequestHandler: this.uiRequestHandler,
936
+ // SR-4:L2 dialog 队列透传——child close 时 session-runner 据此调 rejectChildDialogs
937
+ // 清理 L2 pending dialog,防全局死锁。undefined 时 session-runner 跳过 L2 清理。
938
+ dialogQueue: this.dialogQueue,
939
+ // 主进程运行模式:session-runner W4 守卫据此决定是否注入 ask_user RPC 提示词。
940
+ mode: this.uiObservability.getMode(),
962
941
  };
963
942
  }
964
943
  }
965
944
 
966
- // ============================================================
967
- // 进程单例访问器(session_start 重建)
968
- // ============================================================
969
-
970
- // 用 globalThis[Symbol.for] 持有进程单例,避免 jiti 因路径字符串不同加载多份模块
971
- // 导致单例分裂。场景:其它扩展 import "@zhushanwen/pi-subagents" 与本扩展被 Pi host
972
- // 直接加载,若 jiti 缓存 key 用路径字符串(非 realpath),两份 subagent-service.ts 各持
973
- // 一个 _service,setSubagentService 写 A、getSubagentService 读 B(null)。globalThis 跨所有模块实例共享,彻底消除。
974
- // 详见 docs/standards.md §7.5。
945
+ // ── 进程单例访问器 ────────────────────────────────────
946
+ // globalThis[Symbol.for] 防 jiti 路径不同致单例分裂。详见 docs/standards.md §7.5。
975
947
  const SERVICE_SLOT_KEY = Symbol.for("@zhushanwen/pi-subagents.service");
976
948
 
977
949
  type ServiceSlot = { current: SubagentService | null };
978
950
 
979
951
  function getServiceSlot(): ServiceSlot {
980
- // globalThis 无 symbol 索引签名,但运行时支持 symbol 键——用 Reflect 安全读写,
981
- // 避免双重断言。ServiceSlot 是运行时保证的固定形状(同文件唯一写入点)。
982
952
  let slot = Reflect.get(globalThis, SERVICE_SLOT_KEY) as ServiceSlot | undefined;
983
953
  if (!slot) {
984
954
  slot = { current: null };