@zhushanwen/pi-subagent-workflow 0.1.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 (130) hide show
  1. package/README.md +56 -0
  2. package/agents/context-builder.md +1 -3
  3. package/agents/explorer.md +27 -0
  4. package/agents/oracle.md +2 -2
  5. package/agents/orchestrator.md +48 -0
  6. package/agents/planner.md +1 -3
  7. package/agents/researcher.md +0 -2
  8. package/agents/reviewer.md +2 -2
  9. package/agents/worker.md +0 -2
  10. package/package.json +5 -3
  11. package/skills/workflow-script-format/SKILL.md +6 -6
  12. package/src/execution/__tests__/agent-registry.test.ts +3 -3
  13. package/src/execution/__tests__/agent-result-mapper.test.ts +24 -2
  14. package/src/execution/__tests__/ask-user-transit-e2e.test.ts +484 -0
  15. package/src/execution/__tests__/channel-registry-handshake.test.ts +233 -0
  16. package/src/execution/__tests__/concurrency-pool.test.ts +33 -0
  17. package/src/execution/__tests__/crash-recovery.test.ts +5 -1
  18. package/src/execution/__tests__/dialog-queue.test.ts +299 -0
  19. package/src/execution/__tests__/execute-nesting.test.ts +1 -1
  20. package/src/execution/__tests__/execute-options-mapper.test.ts +41 -9
  21. package/src/execution/__tests__/finalize-record.test.ts +173 -0
  22. package/src/execution/__tests__/gui-mode-dispatch.test.ts +59 -0
  23. package/src/execution/__tests__/helpers/spawn-mock.ts +209 -0
  24. package/src/execution/__tests__/host-mode.test.ts +87 -0
  25. package/src/execution/__tests__/index-session-start.test.ts +342 -0
  26. package/src/execution/__tests__/list-component.test.ts +1 -1
  27. package/src/execution/__tests__/notifier-flush.test.ts +78 -0
  28. package/src/execution/__tests__/path-encoding.test.ts +30 -1
  29. package/src/execution/__tests__/record-store.test.ts +86 -2
  30. package/src/execution/__tests__/records-cwd-isolation.test.ts +91 -0
  31. package/src/execution/__tests__/rpc-mode.test.ts +89 -0
  32. package/src/execution/__tests__/run-spawn-edges.test.ts +157 -153
  33. package/src/execution/__tests__/run-spawn-integration.test.ts +85 -151
  34. package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +193 -0
  35. package/src/execution/__tests__/sdk-contract.test.ts +5 -2
  36. package/src/execution/__tests__/session-file-gc.test.ts +46 -0
  37. package/src/execution/__tests__/session-reconstructor.test.ts +20 -0
  38. package/src/execution/__tests__/session-start-reaper.test.ts +7 -1
  39. package/src/execution/__tests__/spawn-args.test.ts +14 -19
  40. package/src/execution/__tests__/spawn-event-adapter-rpc.test.ts +189 -0
  41. package/src/execution/__tests__/stdin-writer.test.ts +353 -0
  42. package/src/execution/__tests__/subagent-service-abort.test.ts +60 -0
  43. package/src/execution/__tests__/subagent-service.test.ts +73 -3
  44. package/src/execution/__tests__/subprocess-agent-runner.test.ts +72 -3
  45. package/src/execution/__tests__/tool-action.test.ts +27 -5
  46. package/src/execution/__tests__/ui-channels.test.ts +187 -0
  47. package/src/execution/__tests__/ui-interaction-model.test.ts +67 -0
  48. package/src/execution/__tests__/ui-request-handler-factory.test.ts +166 -0
  49. package/src/execution/__tests__/ui-request-handler.test.ts +204 -0
  50. package/src/execution/__tests__/ui-request-observability.test.ts +101 -0
  51. package/src/execution/__tests__/ui-request-queue.test.ts +133 -0
  52. package/src/execution/__tests__/worktree-manager.test.ts +1 -1
  53. package/src/execution/agent-registry.ts +1 -1
  54. package/src/execution/agent-result-mapper.ts +4 -1
  55. package/src/execution/channel-registry-access.ts +138 -0
  56. package/src/execution/concurrency-pool.ts +38 -6
  57. package/src/execution/dialog-queue.ts +329 -0
  58. package/src/execution/execute-options-mapper.ts +21 -4
  59. package/src/execution/execution-record.ts +5 -0
  60. package/src/execution/finalize-record.ts +160 -0
  61. package/src/execution/get-state-handshake.ts +104 -0
  62. package/src/execution/host-mode.ts +52 -0
  63. package/src/execution/manifest-store.ts +206 -0
  64. package/src/execution/notifier.ts +5 -1
  65. package/src/execution/path-encoding.ts +18 -0
  66. package/src/execution/pi-invocation.ts +1 -1
  67. package/src/execution/record-store.ts +110 -2
  68. package/src/execution/session-file-gc.ts +25 -3
  69. package/src/execution/session-reconstructor.ts +11 -0
  70. package/src/execution/session-runner.ts +228 -32
  71. package/src/execution/spawn-event-adapter.ts +219 -6
  72. package/src/execution/stdin-writer.ts +106 -0
  73. package/src/execution/stream-sink.ts +83 -0
  74. package/src/execution/subagent-service.ts +230 -235
  75. package/src/execution/subprocess-agent-runner.ts +16 -4
  76. package/src/execution/types.ts +23 -3
  77. package/src/execution/ui-channels.ts +216 -0
  78. package/src/execution/ui-interaction-model.ts +48 -0
  79. package/src/execution/ui-request-handler-factory.ts +175 -0
  80. package/src/execution/ui-request-observability.ts +77 -0
  81. package/src/execution/ui-request-queue.ts +168 -0
  82. package/src/index.ts +101 -4
  83. package/src/interface/__tests__/subagent-tool-prompt.test.ts +84 -0
  84. package/src/interface/__tests__/workflow-state-file-exposure.test.ts +38 -0
  85. package/src/interface/__tests__/workflow-tool-prompt.test.ts +50 -0
  86. package/src/interface/command-actions.ts +77 -0
  87. package/src/interface/commands.ts +40 -4
  88. package/src/interface/format.ts +2 -0
  89. package/src/interface/gui-mappers.ts +83 -0
  90. package/src/interface/helpers.ts +52 -9
  91. package/src/interface/list-component.ts +3 -1
  92. package/src/interface/subagent-actions.ts +44 -24
  93. package/src/interface/subagent-tool.ts +56 -24
  94. package/src/interface/subagents.ts +45 -5
  95. package/src/interface/tool-render.ts +16 -5
  96. package/src/interface/tool-workflow-script.ts +113 -15
  97. package/src/interface/tool-workflow.ts +92 -34
  98. package/src/interface/views/WorkflowsView.ts +13 -4
  99. package/src/interface/views/__tests__/detail-content-session-file.test.ts +70 -0
  100. package/src/interface/views/detail-content.ts +20 -0
  101. package/src/orchestration/__tests__/agent-call-catch-fallback.test.ts +208 -0
  102. package/src/orchestration/__tests__/agent-call-stream.test.ts +157 -0
  103. package/src/orchestration/__tests__/error-recovery-handlers.test.ts +2 -0
  104. package/src/orchestration/__tests__/execute-agent-call.test.ts +171 -0
  105. package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +177 -0
  106. package/src/orchestration/__tests__/worker-script-builder.test.ts +15 -0
  107. package/src/orchestration/agent-opts-resolver.ts +11 -2
  108. package/src/orchestration/error-recovery.ts +131 -23
  109. package/src/orchestration/execute-agent-call.ts +12 -3
  110. package/src/orchestration/jsonl-run-store.ts +10 -0
  111. package/src/orchestration/lifecycle.ts +1 -1
  112. package/src/orchestration/models/agent-call.ts +7 -0
  113. package/src/orchestration/models/ports.ts +15 -2
  114. package/src/orchestration/models/run-spec.ts +6 -0
  115. package/src/orchestration/models/trace.ts +1 -0
  116. package/src/orchestration/models/types.ts +19 -0
  117. package/src/orchestration/node-ops.ts +2 -0
  118. package/src/orchestration/worker-script-builder.ts +1 -0
  119. package/workflows/README.md +58 -0
  120. package/workflows/chain.js +107 -0
  121. package/workflows/map-reduce.js +142 -0
  122. package/workflows/parallel.js +131 -0
  123. package/workflows/scatter-gather.js +146 -0
  124. package/agents/scout.md +0 -17
  125. package/examples/README.md +0 -43
  126. package/examples/chain.example.js +0 -92
  127. package/examples/map-reduce.example.js +0 -99
  128. package/examples/parallel.example.js +0 -82
  129. package/examples/scatter-gather.example.js +0 -106
  130. package/src/interface/gui-adapter.ts +0 -136
@@ -1,20 +1,18 @@
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";
16
6
 
7
+ import type { ExtensionMode } from "@mariozechner/pi-coding-agent";
8
+
9
+ import type { AgentResult as WorkflowAgentResult } from "../orchestration/models/types.ts";
10
+ // D-A10: workflow 侧 AgentResult 映射(executeAndAwait 出口)
11
+ import { mapToWorkflowAgentResult } from "./agent-result-mapper.ts";
12
+ import { removeAliveMarker } from "./alive-store.ts";
13
+ import { bestEffort } from "./best-effort.ts";
17
14
  import { type ConcurrencyPool,DefaultConcurrencyPool } from "./concurrency-pool.ts";
15
+ import type { DialogGlobalQueue, UiRequestHandler } from "./dialog-queue.ts";
18
16
  import {
19
17
  completeRecord,
20
18
  createRecord,
@@ -22,10 +20,20 @@ import {
22
20
  snapshot,
23
21
  tryTransition,
24
22
  } from "./execution-record.ts";
23
+ import { doFinalizeRecord } from "./finalize-record.ts";
24
+ import { ManifestStore } from "./manifest-store.ts";
25
+ import type { ModelConfigService } from "./model-config-service.ts";
25
26
  import type { AgentConfig, ModelInfo, ResolvedModel } from "./model-resolver.ts";
26
- import { getSubagentSessionDir } from "./path-encoding.ts";
27
+ import type { BgNotifyRecord, NotifierHost } from "./notifier.ts";
28
+ import { BgNotifier } from "./notifier.ts";
29
+ import { getSubagentRecordsDir, getSubagentSessionDir } from "./path-encoding.ts";
30
+ import type { StatusFilter } from "./record-store.ts";
31
+ import { RecordStore } from "./record-store.ts";
27
32
  import { MAX_FORK_DEPTH } from "./session-context-resolver.ts";
28
33
  import { killAllSpawnedChildren, runSpawn, type SessionRunnerContext } from "./session-runner.ts";
34
+ import type { StreamSink } from "./stream-sink.ts";
35
+ import { SubagentStream } from "./stream-sink.ts";
36
+ import { writeCancelledTombstone } from "./tombstone-store.ts";
29
37
  import type { WorktreeHandle } from "./types.ts";
30
38
  import type {
31
39
  AgentEvent,
@@ -40,20 +48,25 @@ import type {
40
48
  } from "./types.ts";
41
49
  import { ForkDepthExceededError } from "./types.ts";
42
50
  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";
51
+ import { registerGlobalObservability, UiRequestObservability } from "./ui-request-observability.ts";
54
52
  import { WorktreeManager } from "./worktree-manager.ts";
55
- import { BgNotifier } from "./notifier.ts";
56
- import type { BgNotifyRecord, NotifierHost } from "./notifier.ts";
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 });
57
70
 
58
71
  /** Pi ExtensionAPI 的最小接口(duck-typed)。
59
72
  * subagent-service 直接调 pi.sendMessage 发 background 完成通知(BgNotifier 滑动窗口合并),
@@ -67,6 +80,11 @@ interface PiLike {
67
80
  ): void;
68
81
  }
69
82
 
83
+ /** UI streaming sink 的最小接口(ctx.ui.setWidget 的 duck-typed 子集)。
84
+ * session_start 时从 ctx.ui 注入,background 执行期间用于把合并后的 text_delta
85
+ * 通过 setWidget 通道转发到 RPC stdout(不经 sendMessage 的持久化路径)。 */
86
+ export type { StreamSink } from "./stream-sink.ts";
87
+
70
88
  /** pending-notifications 注册/注销 helper(避免重复代码)。 */
71
89
  function emitPendingRegister(pi: PiLike | null, id: string, name?: string): void {
72
90
  pi?.events.emit("pending:register", {
@@ -94,26 +112,31 @@ export interface SubagentServiceInit {
94
112
  modelService: ModelConfigService;
95
113
  /** 缓存的主 session file 获取函数(fork source 解析用)。 */
96
114
  getMainSessionFile?: () => string | undefined;
115
+ /** W2: UI 请求处理回调(ask_user 扩展)。
116
+ * 签名见 dialog-queue.ts UiRequestHandler:接收 UiRequest,返回 UiResponse。 */
117
+ uiRequestHandler?: UiRequestHandler;
97
118
  }
98
119
 
99
120
  /** session_start 注入参数(session 级)。 */
100
121
  export interface SubagentServiceSessionInit {
101
122
  pi: PiLike;
102
123
  sessionId: string;
124
+ /** UI streaming sink(ctx.ui.setWidget),用于 background text_delta 转发。 */
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;
103
135
  }
104
136
 
105
137
  /** background 优先级(保留 priority 排序机制,单一值)。 */
106
138
  const PRIORITY_BACKGROUND = 1000;
107
139
 
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
140
  /** 触发 onUpdate 的事件类型(streaming delta 不触发,避免每 token 刷新)。 */
118
141
  const TRIGGERING_EVENT_TYPES = new Set<AgentEvent["type"]>([
119
142
  "tool_start",
@@ -158,21 +181,26 @@ export class SubagentService {
158
181
  private readonly cwd: string;
159
182
  private readonly worktreeManager: WorktreeManager;
160
183
  private readonly getMainSessionFile: (() => string | undefined) | undefined;
161
-
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();
162
190
  private pi: PiLike | null = null;
163
191
  /** 当前 Pi session ID(session 隔离过滤用)。initSession 时注入。 */
164
192
  private sessionId: string | null = null;
193
+ /** UI streaming sink(ctx.ui.setWidget)。workflow 域经 getStreamSink() 取用。 */
194
+ private streamSink: StreamSink | null = null;
195
+ getStreamSink(): StreamSink | null { return this.streamSink; }
165
196
  private _disposed = false;
166
197
  private _seq = 0;
167
198
  /** background 完成通知器(滑动窗口合并 + 去重)。session_start revive,shutdown dispose。 */
168
199
  private readonly notifier: BgNotifier;
169
200
  /** [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
- * “请求作用域”状态的标准机制,每条调用链隔离。 */
201
+ * 主 session=0;fork 进入子 session 期间推进为子深度,供嵌套 fork ALS 读到自身深度作为
202
+ * parentForkDepth。并发 background fork 各自独立调用链,不再互相压低深度值。
203
+ * [MF#2] 旧实现用单实例字段跨执行链共享并发下 A 还原深度后 B 读到被压低值 → 护栏失效。 */
176
204
  private readonly forkDepthAls = new AsyncLocalStorage<number>();
177
205
 
178
206
  /** subagent 执行上下文按 async 调用链传递(当前正在跑的 record 身份 + 递归深度)。
@@ -181,23 +209,62 @@ export class SubagentService {
181
209
  * 与 forkDepthAls 独立:后者只数 fork 链(fork=true 才递增),本 ALS 数所有 subagent 嵌套。 */
182
210
  private readonly execCtxAls = new AsyncLocalStorage<{ recordId: string | undefined; depth: number }>();
183
211
 
212
+ private readonly manifestStore: ManifestStore;
213
+
184
214
  constructor(init: SubagentServiceInit) {
185
215
  this.cwd = init.cwd;
186
216
  this.modelService = init.modelService;
187
217
  this.getMainSessionFile = init.getMainSessionFile;
218
+ this.uiRequestHandler = init.uiRequestHandler;
188
219
  this.pool = new DefaultConcurrencyPool(this.modelService.getGlobalConfig().maxConcurrent);
189
220
  this.worktreeManager = new WorktreeManager(this.modelService.getAgentDir());
190
221
  const sessionsDir = getSubagentSessionDir(this.modelService.getAgentDir(), init.cwd);
191
- 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);
192
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);
193
230
  }
194
231
 
195
232
  // ── 生命周期(index.ts 调)──────────────────────────────
196
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
+
197
248
  /** session_start 注入 pi + revive(modelRegistry/entries 归 ModelConfigService.initModel)。 */
198
249
  initSession(init: SubagentServiceSessionInit): void {
199
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);
200
255
  this.sessionId = init.sessionId;
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
+ }
201
268
  // [SPAWN fork depth 跨进程传递] 子进程被父 spawn 时,父通过 env
202
269
  // PI_SUBAGENT_FORK_DEPTH 传入当前 fork 链深度。子进程 session_start 时
203
270
  // 读取作为 forkDepthAls 基线,使后续嵌套 spawn fork 能从正确深度递增。
@@ -215,6 +282,19 @@ export class SubagentService {
215
282
  this.notifier.revive();
216
283
  }
217
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
+
218
298
  /** session 结束清理(清定时器,丢弃 pending 通知)。幂等。
219
299
  *
220
300
  * [M-7] dispose 顺序假设:pending:unregister emit 依赖 pending-notifications 扩展的
@@ -224,32 +304,23 @@ export class SubagentService {
224
304
  dispose(): void {
225
305
  if (this._disposed) return;
226
306
  this._disposed = true;
227
- // [R0/C1 孤儿进程修复] 进程退出路径:两层兜底 kill 所有 spawned 子进程(sync + background)。
228
- // 1. store.abortRunningControllers():background recordcontroller.abort runSpawn signal
229
- // listener child.kill("SIGTERM")。这是 background CAS 收尾语义路径(不能动)。
230
- // 2. killAllSpawnedChildren():遍历 session-runner 的 spawnedChildren Set(sync + background
231
- // 均注册),对仍存活的发 SIGTERM。sync record controller undefinedabortRunningControllers
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)状态不一致。
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,避免进程退出后两侧状态不一致。
242
313
  // 必须在 abortRunningControllers 之前——此时 record 仍 running,listRunning 能取到。
243
- // 只 emit running 的 record(已终态的由其正常路径 emit 过,不重复)。
244
314
  for (const record of this.store.listRunning()) {
245
315
  emitPendingUnregister(this.pi, record.id, "failed");
246
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 已由子进程写入)。
247
323
  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
324
  killAllSpawnedChildren();
254
325
  for (const s of this.throttleState.values()) {
255
326
  if (s.timer !== undefined) clearTimeout(s.timer);
@@ -263,9 +334,11 @@ export class SubagentService {
263
334
 
264
335
  // ── 执行(subagent-tool 调)────────────────────────────
265
336
 
266
- /** background 完成回注(record → BgNotifyRecord 映射 + notifier.notify)。 */
337
+ /** background 完成回注(record → BgNotifyRecord 映射 + notifier.notify)。
338
+ * 非终态 status(running/crashed)静默跳过——notify 只对 done/failed/cancelled 有意义。 */
267
339
  private notifyComplete(record: ExecutionRecord): void {
268
- this.notifier.notify(this.toNotifyRecord(record));
340
+ const notify = this.toNotifyRecord(record);
341
+ if (notify) this.notifier.notify(notify);
269
342
  }
270
343
 
271
344
  /** notifier 的 NotifierHost 适配器(绑定到 pi.sendMessage + store 查询)。 */
@@ -280,12 +353,16 @@ export class SubagentService {
280
353
  };
281
354
  }
282
355
 
283
- /** record → BgNotifyRecord(notifier.notify 入参映射,内部不外露)。 */
284
- 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 {
285
360
  const snap = snapshot(record);
361
+ const s = snap.status;
362
+ if (s !== "done" && s !== "failed" && s !== "cancelled") return undefined;
286
363
  return {
287
364
  id: snap.id,
288
- status: snap.status as "done" | "failed" | "cancelled",
365
+ status: s,
289
366
  agent: snap.agent,
290
367
  model: snap.model,
291
368
  result: snap.result,
@@ -297,14 +374,8 @@ export class SubagentService {
297
374
  }
298
375
 
299
376
  /**
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)。
377
+ * 预解析 model(renderCall 标题行用,同步)。代理 modelService.resolveModel。
378
+ * 仅解析 override/agentConfig 路径;ctxModel 缺失时拋错,调用方 catch 降级。
308
379
  */
309
380
  resolveModel(
310
381
  agent: string,
@@ -325,13 +396,9 @@ export class SubagentService {
325
396
 
326
397
  // 通用嵌套深度护栏(D-033):execCtxAls 记录所有 subagent 嵌套层级(fork + 非 fork),
327
398
  // 每层 +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 上限不漂移。
399
+ // 但耗资源且 LLM 易陷入「委派→再委派」死循环。在所有副作用之前拦截,错误直达调用方。
400
+ // 计数基准:顶层 nestingDepth=0,nestingDepth>MAX 被拒。与 fork 体积护栏(parentForkDepth 检查)
401
+ // 互补:本护栏更严(计所有嵌套),混合链下先生效;两者共享 MAX_FORK_DEPTH 上限不漂移。
335
402
  const parentNesting = this.execCtxAls.getStore();
336
403
  const nestingDepth = parentNesting ? parentNesting.depth + 1 : 0;
337
404
  if (nestingDepth > MAX_FORK_DEPTH) {
@@ -430,6 +497,7 @@ export class SubagentService {
430
497
  opts: ExecuteOptions,
431
498
  signal?: AbortSignal,
432
499
  onEvent?: (event: AgentEvent) => void,
500
+ stream?: SubagentStream,
433
501
  ): Promise<WorkflowAgentResult> {
434
502
  this.assertReady();
435
503
 
@@ -455,8 +523,7 @@ export class SubagentService {
455
523
  // ── 步骤 4: signal 决议 ──
456
524
  const effectiveSignal = signal ?? record.controller?.signal;
457
525
 
458
- // ── 步骤 5: runAndFinalize(await,不 detached)──
459
- // BC-11:onUpdate 置 undefined(不回流 tool UI 细节),onEvent 独立传(AgentEvent 透传 workflow)
526
+ // 步骤 5: runAndFinalize(await,不 detached)。onUpdate=undefined(BC-11),onEvent 独立传,stream 透传。
460
527
  const result = await this.runAndFinalize(
461
528
  record,
462
529
  { ...opts, onUpdate: undefined },
@@ -465,6 +532,7 @@ export class SubagentService {
465
532
  effectiveSignal,
466
533
  PRIORITY_BACKGROUND,
467
534
  onEvent,
535
+ stream,
468
536
  );
469
537
 
470
538
  // ── 步骤 6: D-A10 AgentResult 映射 ──
@@ -519,10 +587,8 @@ export class SubagentService {
519
587
  opts: ExecuteOptions,
520
588
  mode: ExecutionMode,
521
589
  ): 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()}`;
590
+ // FR-1: record id 用全局 UUID,不依赖 transcript/PID
591
+ const id = crypto.randomUUID();
526
592
  const controller = new AbortController();
527
593
 
528
594
  // 从 async 调用链读父执行上下文:主 session 链上无 store → 顶层 record;
@@ -538,6 +604,7 @@ export class SubagentService {
538
604
  thinkingLevel: identity.resolved.thinkingLevel,
539
605
  mode,
540
606
  task: opts.task,
607
+ slug: opts.slug,
541
608
  startedAt: Date.now(),
542
609
  rootSessionId: this.sessionId ?? undefined,
543
610
  parentRecordId,
@@ -567,13 +634,20 @@ export class SubagentService {
567
634
  signal: AbortSignal | undefined,
568
635
  priority: number,
569
636
  rawOnEvent?: (event: AgentEvent) => void,
637
+ stream?: SubagentStream,
570
638
  ): Promise<AgentResult> {
571
- // 仅 background 进并发池限流,分层配额:每层嵌套 depth 让有效配额 -1(下限 1)。
572
- // 顶层 depth=0 拿满配额;嵌套越深有效并发越小,防子 agent fan-out 压垮主 agent 的 pool。
573
639
  const pooled = record.mode === "background";
640
+ let acquired = false;
574
641
  if (pooled) {
575
642
  const effectiveMaxConcurrent = Math.max(1, this.pool.maxConcurrent - record.depth);
576
- await this.pool.acquire(priority, effectiveMaxConcurrent);
643
+ try {
644
+ await this.pool.acquire(priority, effectiveMaxConcurrent, signal);
645
+ acquired = true;
646
+ } catch {
647
+ // S1: 排队中被 abort(signal.aborted)走 cancelled,与已运行被 abort 一致。
648
+ if (signal?.aborted) return this.finalizeAborted(record);
649
+ return this.finalizeFailed(record, new Error("aborted"));
650
+ }
577
651
  }
578
652
  // onEvent 包装:AgentEvent → onUpdate(project(record)) 回流调用方
579
653
  const onEvent = rawOnEvent
@@ -581,16 +655,12 @@ export class SubagentService {
581
655
  ? (event: AgentEvent): void => this.onEventThrottled(record, event, opts.onUpdate!)
582
656
  : undefined);
583
657
 
584
- // 解析 worktree 参数:boolean → WorktreeHandle | undefined
658
+ // 解析 worktree 参数:boolean → WorktreeHandle | undefined(true/undefined 由 run 内部处理)
585
659
  let worktreeHandle: WorktreeHandle | undefined;
586
660
  if (typeof opts.worktree === "object") {
587
661
  worktreeHandle = opts.worktree;
588
662
  }
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 能读到该深度。
663
+ // [MF#4][MF#2] fork 深度护栏:ALS 传递深度(主 session 链无 store→0,fork 推进 +1)。
594
664
  const parentDepth = this.forkDepthAls.getStore() ?? 0;
595
665
  const effectiveDepth = opts.fork ? parentDepth + 1 : parentDepth;
596
666
 
@@ -612,6 +682,7 @@ export class SubagentService {
612
682
  graceTurns: opts.graceTurns,
613
683
  signal,
614
684
  onEvent,
685
+ stream, // text_delta streaming(background 路径有值,workflow 路径 undefined)
615
686
  fork: opts.fork,
616
687
  worktree: worktreeHandle,
617
688
  parentForkDepth: parentDepth, // [MF#4] 父链深度,不从 opts 读
@@ -626,7 +697,9 @@ export class SubagentService {
626
697
  result = await this.finalizeFailed(record, err);
627
698
  return result;
628
699
  } finally {
629
- if (pooled) this.pool.release();
700
+ if (pooled && acquired) this.pool.release();
701
+ // 清除 streaming widget(subagent 终态,幂等)
702
+ stream?.dispose();
630
703
  }
631
704
 
632
705
  // status 唯一判定点:success ? done : (aborted ? cancelled : failed)
@@ -650,7 +723,15 @@ export class SubagentService {
650
723
  signal: AbortSignal | undefined,
651
724
  priority: number,
652
725
  ): void {
653
- void this.runAndFinalize(record, opts, ctx, identity, signal, priority)
726
+ // 创建 streaming 生命周期对象——streamSink null(session_start 未注入)时降级为 undefined。
727
+ const stream = this.streamSink
728
+ ? new SubagentStream(record.id, this.streamSink)
729
+ : undefined;
730
+
731
+ void this.runAndFinalize(
732
+ record, opts, ctx, identity, signal, priority,
733
+ undefined, stream,
734
+ )
654
735
  .then(() => {
655
736
  // background 回注:仅当本路径抢到 CAS(status 已转 done/failed)才 notify。
656
737
  // cancel 抢先时 status=cancelled,cancelBackground 自己 notify,此处跳过。
@@ -659,7 +740,9 @@ export class SubagentService {
659
740
  }
660
741
  })
661
742
  .catch((err: unknown) => {
662
- // detached 吞错:runAndFinalize 内部已 finalize record(含 emitPendingUnregister),不外抛
743
+ // detached 吞错:runAndFinalize 内部已 finalize record(含 emitPendingUnregister),
744
+ // 且 finalizeRecord 的 manifest 写入已降级为 best-effort(失败仅 console.error + appendEntry,
745
+ // 不外抛)。因此此处不应走到——但作为最后一道兼底,记录调试日志后吞下,不外抛。
663
746
  // 完成通知由 finalizeRecord 内的 emitPendingUnregister 承担(pending-notifications 消费)。
664
747
  // cancel 抢先时 status=cancelled,cancelBackground 自己 emit,此处无需重复。
665
748
  if (err instanceof Error) {
@@ -676,17 +759,8 @@ export class SubagentService {
676
759
  }
677
760
  // 抢到锁:completeRecord(用空 result 填 cancelled)+ archive(立即移出内存)+ notify。
678
761
  // 写 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
- };
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: [] };
690
764
  completeRecord(record, cancelledResult, "cancelled");
691
765
  // 写 tombstone(best-effort,sessionFile 可能为 undefined——窗口期 cancel)。
692
766
  if (record.sessionFile) {
@@ -722,109 +796,36 @@ export class SubagentService {
722
796
  }
723
797
 
724
798
  /**
725
- * D-017 时序收尾:collectPatch completeRecord → archive → writeFinalized + cleanup + removeAliveMarker。
726
- * B9 兜底:completeRecord/archive 抛错→ finalized/cleanup/aliveMarker 仍执行。
727
- */
799
+ * D-017 时序收尾:委托 doFinalizeRecord(提取到 finalize-record.ts,降低本文件行数)。
800
+ * [Critical #1] cleanup 全部在 manifest 写之前,manifest best-effort 不阻断(详见 finalize-record.ts)。 */
728
801
  private async finalizeRecord(
729
802
  record: ExecutionRecord,
730
803
  result: AgentResult,
731
804
  status: "done" | "failed" | "cancelled",
732
805
  ): 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);
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
+ );
807
820
  }
808
821
 
809
- /**
810
- * run() 创建期异常的收尾(H1 修复)。
811
- * run() 正常路径不抛错,但 createAndConfigureSession 失败会抛——
812
- * 本方法合成 failed AgentResult → CAS 抢锁 → finalizeRecord
813
- * (与正常路径同形:completeRecord + archive)。
814
- * 返回合成 result 供 runAndFinalize 继续返回(不 re-throw,swallow 策略)。
815
- */
822
+ /** run() 创建期异常的收尾(H1 修复):createAndConfigureSession 失败会抛,本方法合成 failed
823
+ * AgentResult CAS 抢锁 → finalizeRecord(与正常路径同形)。返回合成 result 供 runAndFinalize
824
+ * 继续返回(不 re-throw,swallow 策略)。 */
816
825
  private async finalizeFailed(record: ExecutionRecord, err: unknown): Promise<AgentResult> {
817
826
  const errMsg = err instanceof Error ? err.message : String(err);
818
827
  // 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
+ const failedResult: AgentResult = { text: "", turns: record.turnCount, durationMs: Date.now() - record.startedAt, success: false, error: errMsg, sessionId: record.id, toolCalls: [] };
828
829
  // CAS 抢锁:抢到(status 仍 running)则完整收尾;没抢到(cancel 已先设 cancelled)跳过。
829
830
  if (tryTransition(record, "failed")) {
830
831
  await this.finalizeRecord(record, failedResult, "failed");
@@ -832,27 +833,24 @@ export class SubagentService {
832
833
  return failedResult;
833
834
  }
834
835
 
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 各自独立节流,互不干扰。
836
+ /** S1: 排队中被 abort cancelled 终态(对齐已运行被 abort 的 cancelBackground)。 */
837
+ private async finalizeAborted(record: ExecutionRecord): Promise<AgentResult> {
838
+ const cancelledResult: AgentResult = { text: "", turns: record.turnCount, durationMs: Date.now() - record.startedAt, success: false, error: "cancelled by user", sessionId: record.id, toolCalls: [] };
839
+ if (tryTransition(record, "cancelled")) {
840
+ await this.finalizeRecord(record, cancelledResult, "cancelled");
841
+ }
842
+ return cancelledResult;
843
+ }
844
+
845
+ // onUpdate 节流状态(per-record Map)。每条 record 独立节流,避免嵌套(fork 链:主→A→B)
846
+ // 多条 onUpdate 链争用同一份状态。旧实现用单实例字段——trailing timer 异步导致跨链争用
847
+ // → onUpdate 被吞/延迟 → 主 agent 对话流残影。per-record 化让 A/B 各自独立节流。
841
848
  private readonly throttleState = new Map<string, { lastEmitAt: number; timer?: ReturnType<typeof setTimeout> }>();
842
849
 
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
- */
850
+ /** AgentEvent 节流回流到 onUpdate(streaming delta 不触发 + 时间窗节流)。
851
+ * 名为 Throttled 必须真节流——否则嵌套场景一秒 10+ 事件密集回流 Pi tool_execution_update
852
+ * 密集重绘 → 流式 tool 组件残影。leading + trailing:首次立即发(响应性),窗口内后续合并
853
+ * 到末尾补发一次(保证终态事件不丢)。节流状态 per-record,trailing timer 不会跨链污染。 */
856
854
  private onEventThrottled(
857
855
  record: ExecutionRecord,
858
856
  event: AgentEvent,
@@ -934,26 +932,23 @@ export class SubagentService {
934
932
  mainSessionFile: this.getMainSessionFile?.() ?? undefined,
935
933
  // worktree pid 回调:session-runner first header 时补全注册表 pid。
936
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(),
937
941
  };
938
942
  }
939
943
  }
940
944
 
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。
945
+ // ── 进程单例访问器 ────────────────────────────────────
946
+ // globalThis[Symbol.for] 防 jiti 路径不同致单例分裂。详见 docs/standards.md §7.5。
950
947
  const SERVICE_SLOT_KEY = Symbol.for("@zhushanwen/pi-subagents.service");
951
948
 
952
949
  type ServiceSlot = { current: SubagentService | null };
953
950
 
954
951
  function getServiceSlot(): ServiceSlot {
955
- // globalThis 无 symbol 索引签名,但运行时支持 symbol 键——用 Reflect 安全读写,
956
- // 避免双重断言。ServiceSlot 是运行时保证的固定形状(同文件唯一写入点)。
957
952
  let slot = Reflect.get(globalThis, SERVICE_SLOT_KEY) as ServiceSlot | undefined;
958
953
  if (!slot) {
959
954
  slot = { current: null };