@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
@@ -0,0 +1,329 @@
1
+ // src/execution/dialog-queue.ts
2
+ //
3
+ // L2 跨子进程全局 dialog 串行队列。
4
+ //
5
+ // 进程单例语义:跨所有子进程共享,串行所有 dialog 类 UI 请求
6
+ //(isDialogMethod(method)===true,即 select/confirm/input/editor)。
7
+ //
8
+ // 设计动机(.fix-plans/00-master-summary.md §一 冲突 3):
9
+ // - L1 per-child 队列(session-runner.createUiRequestQueue)只解决同一子进程内的串行,
10
+ // 多个并行子进程仍可同时把 dialog 请求涌向父 UI(争输入焦点)。
11
+ // - L2 全局队列在主 agent handler 入口前再串行一次,保证同一时刻主 agent 只呈现一个 dialog。
12
+ // - 排队绑定 method 交互模型(dialog 才排队),不绑定 channel——排队是 Pi 协议固有属性。
13
+ //
14
+ // SR-4(child close reject):入队项带 child 引用,child close 时把该 child 的 pending dialog
15
+ // 全部 resolve 为 {cancelled:true},防 Promise 永挂 + 内存泄漏。
16
+ //
17
+ // handler 抛错兜底:catch → 回 {cancelled:true} → 继续处理下一个。不能让一个失败卡死队列。
18
+ //
19
+ // 调用方约定:只对 dialog 类(isDialogMethod===true)调 enqueue;fire-and-forget 由调用方
20
+ // 直接调 handler(见 ui-request-handler-factory.ts),不经过本队列。enqueue 内仍防御性兼容
21
+ // fire-and-forget(万一调用方未判):直接调 handler 返回,不入队串行。调用方不应依赖此防御。
22
+
23
+ import { isDialogMethod } from "./ui-interaction-model.ts";
24
+
25
+ // ── 类型定义(本模块是 UiRequest/UiResponse/UiRequestHandler 的规范来源) ──
26
+ // session-runner.ts 和 ui-channels.ts 复用这些类型(session-runner 再导出供测试 import)。
27
+
28
+ /** Pi extension_ui_request 的方法枚举(dialog + fire-and-forget 两类)。
29
+ * dialog 类:select/confirm/input/editor(占输入焦点,等响应)。
30
+ * fire-and-forget 类:notify/setStatus/setWidget/setTitle/set_editor_text(纯展示/写入)。
31
+ * (string & {}) 兜底:Pi 未来新增 method 或未知 method 走字符串字面量类型。 */
32
+ export type UiMethod =
33
+ | "select"
34
+ | "confirm"
35
+ | "input"
36
+ | "editor"
37
+ | "notify"
38
+ | "setStatus"
39
+ | "setWidget"
40
+ | "setTitle"
41
+ | "set_editor_text"
42
+ | (string & {});
43
+
44
+ /** UI 请求(session-runner 构造后传给 handler)。
45
+ *
46
+ * method 是判别字段,决定排队策略(dialog 排队)和业务路由(channel 分发)。
47
+ * method 特定字段按 method 可选出现(与 ExtensionUiRequest 1:1,由 session-runner 从
48
+ * ExtensionUiRequest 平铺构造)。channel/channelPayload 由 parseChannel 填充。
49
+ *
50
+ * 契约来源:.fix-plans/00-master-summary.md §二 2.2。 */
51
+ export interface UiRequest {
52
+ /** Pi rpc-types.ts 的 method(select/confirm/input/editor 为 dialog 类)。 */
53
+ method: UiMethod;
54
+ /** 请求 id(从 extension_ui_request envelope 顶层提取,用于 response 关联)。 */
55
+ id: string;
56
+ // method 特定字段(按 method 可选,与 ExtensionUiRequest 1:1)
57
+ title?: string;
58
+ options?: string[];
59
+ message?: string;
60
+ placeholder?: string;
61
+ prefill?: string;
62
+ notifyType?: string;
63
+ statusKey?: string;
64
+ statusText?: string | undefined;
65
+ widgetKey?: string;
66
+ widgetLines?: string[] | undefined;
67
+ widgetPlacement?: "aboveEditor" | "belowEditor";
68
+ text?: string;
69
+ timeout?: number;
70
+ /** channel 名(从 method 对应字段的 NUL 前缀解析)。
71
+ * select → 从 title 解析;setWidget → 从 widgetLines[0] 解析;其他 → undefined。
72
+ * 已知值:"ask_user"(select)、"gui_widget"(setWidget)。handler 按 channel 分发。 */
73
+ channel?: string;
74
+ /** channel 解析后的结构化 payload(已 JSON.parse)。
75
+ * ask_user: {questions, allowCancel};gui_widget: {component};无 channel: undefined。 */
76
+ channelPayload?: unknown;
77
+ /** 内部元数据字段:发起该 UI 请求的子进程 pid(由 session-runner.handleUiRequest 从
78
+ * child.pid 填入)。L2 队列据此关联 rejectChildDialogs(child close 时批量 reject)。
79
+ * 下划线前缀表示内部字段,非 Pi 协议字段,不参与 stdin 回写。 */
80
+ _childPid?: number;
81
+ }
82
+
83
+ /** UI 响应(handler 返回,session-runner 按 shape 回写 stdin)。
84
+ * - {value}: select/input/editor 的答案
85
+ * - {confirmed}: confirm 的答案
86
+ * - {cancelled}: 取消(child close / handler 抛错 / 用户取消)
87
+ * - {ack}: fire-and-forget(当前不透传到 TUI,留作协议完整) */
88
+ export type UiResponse =
89
+ | { value: string }
90
+ | { confirmed: boolean }
91
+ | { cancelled: true }
92
+ | { ack: true };
93
+
94
+ /** UI 请求 handler 签名(单函数,按 req.method 内部路由)。
95
+ * 实现方负责:channel 业务路由(ask_user → AskUserComponent)+ 默认转发(ctx.ui.*)。
96
+ * 抛错由调用方(DialogGlobalQueue / session-runner)兜底为 {cancelled:true}。 */
97
+ export type UiRequestHandler = (req: UiRequest) => Promise<UiResponse>;
98
+
99
+ // ── DialogGlobalQueue 实现 ──
100
+
101
+ /** 入队项的 child 引用形状(只取 pid 用于 rejectChildDialogs 匹配)。 */
102
+ export interface DialogChildRef {
103
+ pid: number;
104
+ }
105
+
106
+ /** enqueue 的可选项。child 用于 rejectChildDialogs 关联(child close 时批量 reject)。 */
107
+ export interface EnqueueOptions {
108
+ child?: DialogChildRef;
109
+ }
110
+
111
+ /** 队列内一项:请求 + handler + resolve + 所属 child。
112
+ * pending 状态持有 resolve,handler 完成 / rejectChildDialogs 时调它 settle Promise。
113
+ * settled 标志保证只 settle 一次(rejectChildDialogs 与 handler 完成可能竞争)。 */
114
+ interface QueueItem {
115
+ req: UiRequest;
116
+ handler: UiRequestHandler;
117
+ resolve: (resp: UiResponse) => void;
118
+ childPid: number | undefined;
119
+ /** 是否已 settle(防 handler 完成 / rejectChildDialogs 重复 resolve)。 */
120
+ settled: boolean;
121
+ }
122
+
123
+ /**
124
+ * L2 跨子进程全局 dialog 串行队列(进程单例)。
125
+ *
126
+ * 用法(createUiRequestHandlerForMode 返回的总 handler 内):
127
+ * ```ts
128
+ * const dialogQueue = new DialogGlobalQueue();
129
+ * return async (req: UiRequest) => {
130
+ * // 调用方负责判断:dialog 入队,fire-and-forget 直接调 realHandler
131
+ * if (isDialogMethod(req.method)) return dialogQueue.enqueue(req, realHandler);
132
+ * return realHandler(req);
133
+ * };
134
+ * ```
135
+ *
136
+ * 语义保证:
137
+ * - FIFO 串行:前一个 handler settle 后才处理下一个
138
+ * - SR-4:rejectChildDialogs(child) 把该 child 的 pending 全部 resolve 为 {cancelled:true}
139
+ * - handler 抛错兜底:catch → {cancelled:true} → 继续下一个(队列不卡死)
140
+ * - 调用方约定只对 dialog 类调 enqueue;fire-and-forget 由调用方直接调 handler 不入队
141
+ *(enqueue 内仍防御性兼容 fire-and-forget,但不保证行为)
142
+ *
143
+ * 线程模型:纯 Promise + 微任务驱动,无锁。Node 单线程 event loop 保证队列状态一致。
144
+ *
145
+ * 单 session 假设(M-2,与 index.ts lastSessionId 同源):本队列是进程级单例(实例挂在
146
+ * globalThis[Symbol.for("@zhushanwen/pi-subagents.dialogQueue")],见 getOrCreateDialogQueue)。
147
+ * rejectAll()/clear() 清空所有 pending dialog——无 per-session 隔离。Pi 当前架构保证单进程
148
+ * 单 session 串行(同进程不会并发多个 session),故 session_shutdown 调 rejectAll() 只会清掉
149
+ * 当前 session 的 pending。若未来 Pi 支持同进程多 session 并发,session A 退出会误清 session B
150
+ * 的 pending dialog——届时需改为 per-session 隔离(入队项 QueueItem 带 sessionId,rejectAll
151
+ * 改 rejectAllForSession(sessionId),session_shutdown 只清当前 session)。
152
+ */
153
+ export class DialogGlobalQueue {
154
+ /** 等待处理的队列(FIFO)。正在处理的项从 queue shift 出后由 current 持有。 */
155
+ private queue: QueueItem[] = [];
156
+ /** 正在处理的项(handler 已调、未 settle)。用于 rejectChildDialogs 取消占位中的 dialog。 */
157
+ private current: QueueItem | undefined;
158
+ private processing = false;
159
+
160
+ /**
161
+ * 入队一个 UI 请求,返回 Promise<UiResponse>。
162
+ *
163
+ * 调用方约定:只对 dialog 类(isDialogMethod===true)调 enqueue。fire-and-forget 由
164
+ * 调用方(ui-request-handler-factory.ts)在 enqueue 前判 isDialogMethod 后直接调 handler,
165
+ * 不经过本队列。enqueue 内仍防御性兼容 fire-and-forget(万一调用方未判):直接调 handler 返回,
166
+ * 不入队串行,但调用方不应依赖此防御行为。
167
+ *
168
+ * dialog 项处理(TC-E4 case 1):进队列 FIFO 串行,等前一个 settle 后才调 handler
169
+ *(争输入焦点,防并发弹窗)。
170
+ *
171
+ * handler 抛错兜底:catch → 回 {cancelled:true}(dialog 路径,队列不卡死)。
172
+ * SR-4:opts.child 用于 rejectChildDialogs 关联(dialog 项会被批量 reject,含 current)。
173
+ *
174
+ * @param req UI 请求(约定只传 dialog 类;fire-and-forget 防御性兼容)
175
+ * @param handler 真正执行请求的 handler(TUI/GUI 模式分流后的 realHandler)
176
+ * @param opts 可选 child 引用(用于 rejectChildDialogs 关联)
177
+ * @returns handler 的响应;dialog 抛错时回 {cancelled:true};child close 时回 {cancelled:true}
178
+ */
179
+ enqueue(
180
+ req: UiRequest,
181
+ handler: UiRequestHandler,
182
+ opts?: EnqueueOptions,
183
+ ): Promise<UiResponse> {
184
+ // 防御性兼容 fire-and-forget:调用方约定不应传此类 req 进 enqueue(factory 层已判
185
+ // isDialogMethod 直接调 handler),但万一调用方未判,这里直接调 handler 不入队,避免阻塞。
186
+ // 直接返回 handler 真实结果(不做兜底形变为 cancelled——fire-and-forget 无串行语义)。
187
+ if (!isDialogMethod(req.method)) {
188
+ return handler(req);
189
+ }
190
+ // dialog:入队 FIFO 串行
191
+ return new Promise<UiResponse>((resolve) => {
192
+ this.queue.push({
193
+ req,
194
+ handler,
195
+ resolve,
196
+ childPid: opts?.child?.pid,
197
+ settled: false,
198
+ });
199
+ void this.processNext();
200
+ });
201
+ }
202
+
203
+ /**
204
+ * settle 一个 item(幂等)。handler 完成 / rejectChildDialogs / rejectAll 都通过本方法,
205
+ * settled 标志保证只 settle 一次(防竞争)。
206
+ *
207
+ * #19 单一推进点:本方法 settle Promise + 清状态后,**唯一**调 processNext 推进队列。
208
+ * processNext 尾部不再调 processNext(旧代码双重推进,虽靠 processing 标志幂等,但语义混乱)。
209
+ * 为什么推进必须在 settleItem 而非 processNext 尾部:rejectChildDialogs 取消一个永不 settle
210
+ * 的 current(handler 等用户输入卡死)时,processNext 的 `await item.handler` 永不 resume,
211
+ * 尾部不会执行;只有 settleItem 里的 processNext 才能打破死锁,推进下一个。
212
+ */
213
+ private settleItem(item: QueueItem, resp: UiResponse): void {
214
+ if (item.settled) return;
215
+ item.settled = true;
216
+ item.resolve(resp);
217
+ // 若是正在处理的项,清空 current/processing 并推进队列(唯一推进点)。
218
+ // 非当前项(队列中被 reject)只 settle Promise,不影响 current/推进。
219
+ if (this.current === item) {
220
+ this.current = undefined;
221
+ this.processing = false;
222
+ void this.processNext();
223
+ }
224
+ }
225
+
226
+ /**
227
+ * SR-4:把指定 child 的所有 pending dialog resolve 为 {cancelled:true}。
228
+ *
229
+ * 触发场景:子进程 close(用户取消 / crash / 超时 kill)时,其 pending dialog 的 handler
230
+ * 可能永不 settle(等用户输入),导致 Promise 永挂 + 内存泄漏。本方法批量清理。
231
+ *
232
+ * 处理范围(TC-E4 case 2):
233
+ * - 正在处理中(current)的该 child 项:settle {cancelled:true},解阻塞队列推进下一个
234
+ * (关键:handler 可能永不 settle,必须由这里打破死锁)
235
+ * - 队列中等待处理的该 child 项:settle {cancelled:true} 并移除
236
+ *
237
+ * 不影响其他 child 的 pending dialog(TC-E4 case 2 子测试 2)。
238
+ */
239
+ rejectChildDialogs(child: DialogChildRef): void {
240
+ // 先处理正在处理的项(可能永不 settle,必须由这里解阻塞)
241
+ if (this.current && this.current.childPid === child.pid) {
242
+ this.settleItem(this.current, { cancelled: true });
243
+ }
244
+ // 再处理队列中等待的项
245
+ if (this.queue.length === 0) return;
246
+ const remaining: QueueItem[] = [];
247
+ for (const item of this.queue) {
248
+ if (item.childPid === child.pid) {
249
+ // settle 该 child 的 pending Promise 为 cancelled
250
+ this.settleItem(item, { cancelled: true });
251
+ } else {
252
+ remaining.push(item);
253
+ }
254
+ }
255
+ this.queue = remaining;
256
+ }
257
+
258
+ /**
259
+ * 处理队列下一项(FIFO)。
260
+ *
261
+ * processing 标志保证串行:handler 运行期间 processing=true,新的 processNext 调用直接返回;
262
+ * handler settle 后由 settleItem 清 processing=false 并推进下一项(#19 单一推进点)。
263
+ *
264
+ * handler 抛错兜底(TC-E4 case 3):catch → settle {cancelled:true} → 继续。
265
+ * 不能让一个失败卡死队列(processing 永远 true)。
266
+ */
267
+ private async processNext(): Promise<void> {
268
+ if (this.processing) return;
269
+ if (this.queue.length === 0) return;
270
+ this.processing = true;
271
+ const item = this.queue.shift()!;
272
+ this.current = item;
273
+ try {
274
+ const resp = await item.handler(item.req);
275
+ // handler 完成:settle(若已被 rejectChildDialogs 抢先 settle 则 noop)。
276
+ // settleItem 内会清 current/processing 并推进队列(#19 单一推进点)。
277
+ this.settleItem(item, resp);
278
+ } catch {
279
+ // handler 抛错兜底:回 cancelled,不向上抛(队列不能卡死)
280
+ this.settleItem(item, { cancelled: true });
281
+ }
282
+ // #19:不在尾部再调 processNext——推进统一由 settleItem 负责(避免双重推进)。
283
+ }
284
+
285
+ /**
286
+ * #10:把所有 pending dialog(queue + current)全部 settle 为 {cancelled:true},
287
+ * 并清空 queue/current/processing 状态。session_shutdown 调用,保证不留永挂 Promise。
288
+ *
289
+ * 约定签名:rejectAll(): void(无参,返 void)。Group C 的 index.ts session_shutdown 依赖此契约。
290
+ *
291
+ * 幂等:依赖 settleItem 的 settled 标志——重复调用只会对已 settled 项 noop。
292
+ * 顺序敏感(#19 推进点在 settleItem):必须先清空 queue 数组再 settle current,
293
+ * 否则 settleItem(current) 同步触发的 processNext 会从旧 queue 抢占下一项作为新 current,
294
+ * 避开本方法的 cancel 语义。清空后 processNext 看到空队列直接返回,新 current 不会被抢占。
295
+ *
296
+ * 单 session 假设(M-2):见类注释。本方法清空所有 pending 不分 session——依赖 Pi 单进程
297
+ * 单 session 串行保证。session_shutdown handler(index.ts)调用本方法时,进程内只会有当前
298
+ * session 的 pending dialog。多 session 并发场景的迁移策略(rejectAllForSession)见类注释。
299
+ */
300
+ rejectAll(): void {
301
+ // 先捕获并清空队列——防 settleItem(current) 触发的 processNext 抢占同队列下一项
302
+ const items = this.queue;
303
+ this.queue = [];
304
+ // 再 settle current(processNext 此时看到空队列,不会抢占)
305
+ if (this.current) {
306
+ this.settleItem(this.current, { cancelled: true });
307
+ }
308
+ // settle 所有原队列项(Promise 必须 resolve,不能挂)
309
+ for (const item of items) {
310
+ this.settleItem(item, { cancelled: true });
311
+ }
312
+ // 状态清理(幂等:settleItem(current) 可能已清)
313
+ this.current = undefined;
314
+ this.processing = false;
315
+ }
316
+
317
+ /** 清空队列(dispose 用)。pending 项的 Promise 不 settle(dispose 时调用方已不关心)。
318
+ * 如需 settle,dispose 前应先 rejectChildDialogs 或 rejectAll。 */
319
+ clear(): void {
320
+ this.queue = [];
321
+ this.current = undefined;
322
+ this.processing = false;
323
+ }
324
+
325
+ /** 当前队列长度(测试/诊断用)。含等待处理项(不含 current)。 */
326
+ get size(): number {
327
+ return this.queue.length;
328
+ }
329
+ }
@@ -0,0 +1,160 @@
1
+ // src/execution/finalize-record.ts
2
+ //
3
+ // 时序收尾逻辑(从 subagent-service.ts 提取,降低主文件行数 < 1000 上限)。
4
+ //
5
+ // D-017 时序:collectPatch → completeRecord → archive → cleanup(finalized+worktree+
6
+ // aliveMarker+pending注销) → manifest(最后 best-effort)。
7
+ //
8
+ // [Critical #1 / PR #85] cleanup 全部在 manifest 写之前执行——manifest 是诊断辅助
9
+ //(orphan recovery),写失败仅记录不阻断。旧实现 Step 2.5 throw 会跳过 Step 3 cleanup,
10
+ // 导致磁盘满/权限错时 worktree 泄漏 + finalized marker 不写 + alive marker 残留 +
11
+ // pending 记账错乱。现 manifest 写移到 Step 4(最后),best-effort(console.error +
12
+ // appendEntry,不 throw)。
13
+ //
14
+ // B9 兜底:completeRecord/archive 抛错→后续 cleanup/manifest 仍执行。
15
+
16
+ import * as fs from "node:fs";
17
+ import * as path from "node:path";
18
+
19
+ import { removeAliveMarker } from "./alive-store.ts";
20
+ import { bestEffort } from "./best-effort.ts";
21
+ import { completeRecord } from "./execution-record.ts";
22
+ import { writeFinalized } from "./finalized-marker.ts";
23
+ import type { ManifestStore } from "./manifest-store.ts";
24
+ import type { ModelConfigService } from "./model-config-service.ts";
25
+ import { getSubagentSessionDir } from "./path-encoding.ts";
26
+ import type { RecordStore } from "./record-store.ts";
27
+ import { writeCancelledTombstone } from "./tombstone-store.ts";
28
+ import type { AgentResult, ExecutionRecord } from "./types.ts";
29
+ import type { WorktreeManager } from "./worktree-manager.ts";
30
+
31
+ /** doFinalizeRecord 的依赖(从 SubagentService 注入,避免 this 绑定 + 解耦可测试)。 */
32
+ export interface FinalizeDeps {
33
+ manifestStore: ManifestStore;
34
+ worktreeManager: WorktreeManager;
35
+ store: RecordStore;
36
+ modelService: ModelConfigService;
37
+ /** Pi ExtensionAPI(仅用 appendEntry 记录 manifest 写失败事件)。null 在 dispose 后。 */
38
+ pi: { appendEntry?: (type: string, data: unknown) => void } | null;
39
+ /** 清节流状态(防 trailing timer 在 record 归档后误发陈旧 onUpdate)。 */
40
+ clearThrottle(id: string): void;
41
+ /** pending-notifications 终态注销(绑定 pi.events.emit,由调用方闭包提供)。 */
42
+ emitUnregister(id: string, status: string): void;
43
+ }
44
+
45
+ /**
46
+ * 时序收尾(D-017)。步骤 0→4 全部 best-effort 互不阻断(除 manifest 外都幂等)。
47
+ *
48
+ * [Critical #1] Step 3 cleanup 全部在 Step 4 manifest 之前——manifest 写失败仅 console.error +
49
+ * appendEntry,不 throw 不跳过 cleanup。task/slug/model 从 ExecutionRecord 抓取(配合 ManifestRecord
50
+ * 补字段),manifestToSubagent 投影真实值而非硬编码空串。
51
+ */
52
+ export async function doFinalizeRecord(
53
+ deps: FinalizeDeps,
54
+ record: ExecutionRecord,
55
+ result: AgentResult,
56
+ status: "done" | "failed" | "cancelled",
57
+ ): Promise<void> {
58
+ // 终态清节流状态:防 trailing timer 在 record 归档后误发陈旧 onUpdate
59
+ deps.clearThrottle(record.id);
60
+
61
+ // ── Step 0: collectPatch(best-effort)──
62
+ // [MF#3] patchFile 写到 worktree 之外(sessionsDir/<branch>.patch),避免被 cleanup 删除;
63
+ // 路径回填 record.patchFile,供调用方(tool result / /subagents list)应用。
64
+ if (record.worktreeHandle) {
65
+ try {
66
+ const sessionsDir = getSubagentSessionDir(
67
+ deps.modelService.getAgentDir(),
68
+ record.worktreeHandle.mainCwd,
69
+ );
70
+ fs.mkdirSync(sessionsDir, { recursive: true });
71
+ const patchFile = path.join(sessionsDir, `${record.worktreeHandle.branch}.patch`);
72
+ const patch = deps.worktreeManager.collectPatch(record.worktreeHandle, patchFile);
73
+ if (patch.written) record.patchFile = patchFile;
74
+ } catch (pe: unknown) {
75
+ bestEffort(pe, "collectPatch (finalizeRecord Step0)");
76
+ }
77
+ }
78
+
79
+ // ── Step 1: completeRecord(B9: 抛错→后续仍执行)──
80
+ try {
81
+ completeRecord(record, result, status);
82
+ } catch (err) {
83
+ bestEffort(err, "completeRecord (finalizeRecord B9)", "error");
84
+ }
85
+
86
+ // ── Step 2: archive(B9: 抛错→后续仍执行)──
87
+ try {
88
+ deps.store.archive(record);
89
+ } catch (err) {
90
+ bestEffort(err, "store.archive (finalizeRecord B9)", "error");
91
+ }
92
+
93
+ // ── Step 3: finalized + cleanup + aliveMarker + pending注销(全部先执行,幂等)──
94
+ // [Critical] 清理必须在 manifest 写入之前:worktree cleanup / finalized marker / aliveMarker
95
+ // 都是幂等且不可跳过的副作用。绝不能因 manifest 写失败而跳过 worktree cleanup
96
+ // (否则 worktree 泄漏)。各件独立 try/catch,互不阻断。
97
+ if (record.sessionFile) {
98
+ try {
99
+ // MF-1 fix: cancelled 状态写 tombstone 而非 finalized,防重建丢失 cancelled
100
+ if (status === "cancelled") {
101
+ writeCancelledTombstone(record.sessionFile, {
102
+ id: record.id,
103
+ status: "cancelled",
104
+ agent: record.agent,
105
+ startedAt: record.startedAt,
106
+ endedAt: record.endedAt ?? Date.now(),
107
+ });
108
+ } else {
109
+ writeFinalized(record.sessionFile);
110
+ }
111
+ } catch (err) {
112
+ bestEffort(err, "writeFinalized/tombstone (finalizeRecord Step3)");
113
+ }
114
+ }
115
+ if (record.worktreeHandle) {
116
+ try {
117
+ deps.worktreeManager.cleanup(record.worktreeHandle);
118
+ } catch (err) {
119
+ bestEffort(err, "worktree cleanup (finalizeRecord Step3)");
120
+ }
121
+ }
122
+ if (record.sessionFile) {
123
+ try {
124
+ removeAliveMarker(record.sessionFile);
125
+ } catch (err) {
126
+ bestEffort(err, "removeAliveMarker (finalizeRecord Step3)");
127
+ }
128
+ }
129
+
130
+ // pending-notifications:终态注销(只记 registry 状态,通知由 BgNotifier 发)
131
+ deps.emitUnregister(record.id, status);
132
+
133
+ // ── Step 4 (last): manifest 持久化(best-effort,不阻断、不 throw)──
134
+ // [Critical #1] manifest 是诊断辅助(orphan recovery),不是正确性依赖。写失败时
135
+ // 仅记录(console.error + appendEntry),绝不让 manifest 写失败跳过上面的 worktree
136
+ // cleanup 或抛出打断 finalize 链。旧实现 Step 2.5 throw 会跳过 Step 3 cleanup。
137
+ // task/slug/model 从 ExecutionRecord 抓取(配合 ManifestRecord 补字段),
138
+ // manifestToSubagent 投影时用真实值而非硬编码空串。
139
+ try {
140
+ await deps.manifestStore.writeManifest({
141
+ id: record.id,
142
+ rootSessionId: record.rootSessionId ?? "",
143
+ agentName: record.agent,
144
+ status: status === "done" ? "completed" : status,
145
+ createdAt: record.startedAt,
146
+ completedAt: record.endedAt ?? Date.now(),
147
+ sessionFile: record.sessionFile,
148
+ task: record.task,
149
+ slug: record.slug,
150
+ model: record.model,
151
+ });
152
+ } catch (err) {
153
+ const msg = err instanceof Error ? err.message : String(err);
154
+ console.error(`[subagent] manifest 写入失败 (record=${record.id}): ${msg}`);
155
+ deps.pi?.appendEntry?.("subagent:manifest-write-failed", {
156
+ id: record.id,
157
+ error: msg,
158
+ });
159
+ }
160
+ }
@@ -0,0 +1,104 @@
1
+ // src/execution/get-state-handshake.ts
2
+ //
3
+ // FR-4: get_state RPC 握手逻辑。
4
+ //
5
+ // 从 session-runner.ts 提取(保持文件 < 1000 行)。职责单一:通过 get_state RPC
6
+ // 查询子进程 sessionFile/sessionId,带超时重试。session-runner spawn 后无条件调用。
7
+ //
8
+ // 设计要点:
9
+ // - 重试节奏:单次超时 GET_STATE_TIMEOUT_MS(2s)后,等 GET_STATE_RETRY_INTERVAL_MS(500ms)
10
+ // 再发起下一次 get_state,最多 GET_STATE_MAX_RETRIES(3)次。修复点:旧实现超时后
11
+ // 立即递归 tryOnce(),GET_STATE_RETRY_INTERVAL_MS 声明了却从未使用(eslint error 阻断),
12
+ // 现在让常量名与行为一致——重试前真的等间隔。
13
+ // - 加速路径:sessionFile 一旦拿到立即 resolve(不等剩余重试)。
14
+ // - 全部超时:resolve 空对象(调用方走兜底查找)。
15
+
16
+ import type { ChildProcess } from "node:child_process";
17
+
18
+ import { sendGetStateCommand } from "./stdin-writer.ts";
19
+
20
+ /** FR-4: get_state RPC 握手最大重试次数。 */
21
+ const GET_STATE_MAX_RETRIES = 3;
22
+ /** FR-4: get_state RPC 握手重试间隔(ms)——单次超时后等待此间隔再重试。 */
23
+ const GET_STATE_RETRY_INTERVAL_MS = 500;
24
+ /** FR-4: get_state RPC 握手单次超时(ms)。 */
25
+ const GET_STATE_TIMEOUT_MS = 2000;
26
+
27
+ /** get_state 握手结果。 */
28
+ export interface GetStateResult {
29
+ sessionFile?: string;
30
+ sessionId?: string;
31
+ }
32
+
33
+ /**
34
+ * FR-4: 通过 get_state RPC 查询子进程获取 sessionFile/sessionId。
35
+ *
36
+ * 当 stdout header 未获取到 sessionFile 时,尝试通过 get_state RPC 查询。
37
+ * 最多重试 GET_STATE_MAX_RETRIES 次,单次超时 GET_STATE_TIMEOUT_MS 后等待
38
+ * GET_STATE_RETRY_INTERVAL_MS 再发起下一次重试。
39
+ *
40
+ * @param child 子进程(stdin 写入 get_state 命令)
41
+ * @param addResponseListener 注册 response 监听器的函数(stdout pump 中调用)
42
+ * @returns 握手结果(可能为空——所有重试均超时/失败)
43
+ */
44
+ export function performGetStateHandshake(
45
+ child: ChildProcess,
46
+ addResponseListener: (id: string, resolver: (data: unknown) => void) => void,
47
+ ): Promise<GetStateResult> {
48
+ return new Promise<GetStateResult>((resolve) => {
49
+ const collected: GetStateResult = {};
50
+ let attempts = 0;
51
+ let resolved = false;
52
+
53
+ function tryOnce(): void {
54
+ if (resolved) return;
55
+ attempts++;
56
+ const reqId = sendGetStateCommand(child);
57
+
58
+ // [#15] 本次 tryOnce 私有的 timer(2s 超时 + 超时后派生的 retry)。
59
+ // 关键:response 回调通过闭包引用的是"本次 tryOnce 对应的 timer",而非某个
60
+ // 外层共享变量——即便后续 tryOnce(#2) 重新发起请求,旧 reqId 的迟到 response 回调
61
+ // 闭包仍指向它自己那次 tryOnce 的 timer,不会误清新 reqId 的 timer。retry 句柄也
62
+ // 一并捕获,response 到达时同步取消"已在排队但尚未触发的下一次重试"。
63
+ let pendingRetry: ReturnType<typeof setTimeout> | undefined;
64
+ const timer: ReturnType<typeof setTimeout> = setTimeout(() => {
65
+ pendingRetry = undefined;
66
+ // 单次超时:等待间隔后重试,或放弃
67
+ if (attempts < GET_STATE_MAX_RETRIES && !resolved) {
68
+ // [Bug fix] 旧实现直接 tryOnce() 立即重试,GET_STATE_RETRY_INTERVAL_MS 声明却
69
+ // 从未使用(eslint error 阻断 commit)。现在重试前等待间隔,让常量名与行为一致。
70
+ pendingRetry = setTimeout(() => tryOnce(), GET_STATE_RETRY_INTERVAL_MS);
71
+ pendingRetry.unref();
72
+ } else if (!resolved) {
73
+ resolved = true;
74
+ resolve(collected);
75
+ }
76
+ }, GET_STATE_TIMEOUT_MS);
77
+ timer.unref();
78
+
79
+ addResponseListener(reqId, (data: unknown) => {
80
+ if (resolved) return;
81
+ // [#15] 闭包清理本次 tryOnce 的 timer(2s 超时 + 排队中的 retry),不碰其他 reqId 的 timer。
82
+ clearTimeout(timer);
83
+ if (pendingRetry) clearTimeout(pendingRetry);
84
+ if (data && typeof data === "object") {
85
+ const d = data as Record<string, unknown>;
86
+ if (typeof d.sessionFile === "string" && d.sessionFile.length > 0) {
87
+ collected.sessionFile = d.sessionFile;
88
+ }
89
+ if (typeof d.sessionId === "string" && d.sessionId.length > 0) {
90
+ collected.sessionId = d.sessionId;
91
+ }
92
+ }
93
+ // sessionFile 已获取——立即 resolve(无需更多重试)
94
+ if (collected.sessionFile) {
95
+ resolved = true;
96
+ resolve(collected);
97
+ }
98
+ // 否则等待超时重试
99
+ });
100
+ }
101
+
102
+ tryOnce();
103
+ });
104
+ }
@@ -0,0 +1,52 @@
1
+ // src/execution/host-mode.ts
2
+ //
3
+ // 主进程运行模式分类工具。
4
+ //
5
+ // 将 Pi 的 ExtensionMode("tui"|"rpc"|"json"|"print")聚合为业务语义的
6
+ // HostMode("tui"|"gui"|"headless"),供 session-runner 的 W4 提示词守卫、
7
+ // handler 工厂分流、stdio 选择等消费点统一调用。
8
+ //
9
+ // 判定依据见 AGENTS.md「运行时环境区分」章节 +
10
+ // docs/pi-tui-development-guide.md 第四部分第 8 节。
11
+ // ExtensionMode 来自 Pi 源码 packages/coding-agent/src/core/extensions/types.ts:299
12
+ // (dist 中 core/extensions/types.d.ts:207)。
13
+ //
14
+ // 设计动机(.fix-plans/00-master-summary.md §一冲突 4):
15
+ // - 把散落多处的 `ctx.mode === "tui" || ctx.mode === "rpc"` 字面量比较
16
+ // 集中到单一修改点,未来 Pi 新增 mode 值时只改本文件。
17
+ // - 业务语义命名("gui"/"headless")比原始枚举值更清晰表达意图。
18
+
19
+ import type { ExtensionMode } from "@mariozechner/pi-coding-agent";
20
+
21
+ /** 主进程运行模式分类。基于 ExtensionMode 聚合为业务语义。
22
+ * - "tui":纯 Pi TUI,ctx.ui.custom 可用,用户在终端交互
23
+ * - "gui":xyz-agent GUI,通过 rpc sidecar 通道与前端 Vue 组件交互
24
+ * - "headless":无交互 UI 通道(json/print 输出模式,或 mode 未穿透) */
25
+ export type HostMode = "tui" | "gui" | "headless";
26
+
27
+ /** 从 ExtensionContext.mode 解析主进程模式分类。
28
+ * - "tui" → tui(纯 Pi TUI,ctx.ui.custom 可用)
29
+ * - "rpc" → gui(xyz-agent GUI,sidecar 通道可用)
30
+ * - "json"/"print"/undefined → headless(无交互通道)
31
+ *
32
+ * undefined 归入 headless 是向后兼容:mode 未穿透 SessionRunnerContext 时
33
+ * 按「无 UI」保守处理,避免误注入依赖 UI 的逻辑。 */
34
+ export function resolveHostMode(mode: ExtensionMode | undefined): HostMode {
35
+ if (mode === "tui") return "tui";
36
+ if (mode === "rpc") return "gui";
37
+ return "headless"; // json/print/undefined
38
+ }
39
+
40
+ /** 主进程是否会响应子进程的 ask_user(UI 透传)。
41
+ * tui + gui 都会(冲突 3 裁决:TUI 必须注入 handler;GUI 透传所有 UI),
42
+ * headless 不会(无 UI 通道,注入 W4 提示词会误导 LLM)。 */
43
+ export function willRespondToAskUser(mode: ExtensionMode | undefined): boolean {
44
+ const host = resolveHostMode(mode);
45
+ return host === "tui" || host === "gui";
46
+ }
47
+
48
+ /** 主进程是否有交互 UI 通道(TUI 组件 / GUI sidecar)。
49
+ * 非 headless 即有。用于 stdio 选择等不区分 tui/gui 的决策点。 */
50
+ export function hasInteractiveUI(mode: ExtensionMode | undefined): boolean {
51
+ return resolveHostMode(mode) !== "headless";
52
+ }