@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.
- package/README.md +56 -0
- package/agents/{scout.md → explorer.md} +1 -1
- package/agents/orchestrator.md +48 -0
- package/package.json +1 -1
- package/src/execution/__tests__/agent-registry.test.ts +3 -3
- package/src/execution/__tests__/ask-user-transit-e2e.test.ts +484 -0
- package/src/execution/__tests__/channel-registry-handshake.test.ts +233 -0
- package/src/execution/__tests__/crash-recovery.test.ts +5 -1
- package/src/execution/__tests__/dialog-queue.test.ts +299 -0
- package/src/execution/__tests__/execute-nesting.test.ts +1 -1
- package/src/execution/__tests__/execute-options-mapper.test.ts +1 -1
- package/src/execution/__tests__/finalize-record.test.ts +173 -0
- package/src/execution/__tests__/gui-mode-dispatch.test.ts +2 -3
- package/src/execution/__tests__/helpers/spawn-mock.ts +209 -0
- package/src/execution/__tests__/host-mode.test.ts +87 -0
- package/src/execution/__tests__/index-session-start.test.ts +342 -0
- package/src/execution/__tests__/list-component.test.ts +1 -1
- package/src/execution/__tests__/notifier-flush.test.ts +78 -0
- package/src/execution/__tests__/path-encoding.test.ts +30 -1
- package/src/execution/__tests__/record-store.test.ts +86 -2
- package/src/execution/__tests__/records-cwd-isolation.test.ts +91 -0
- package/src/execution/__tests__/rpc-mode.test.ts +89 -0
- package/src/execution/__tests__/run-spawn-edges.test.ts +157 -153
- package/src/execution/__tests__/run-spawn-integration.test.ts +85 -151
- package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +193 -0
- package/src/execution/__tests__/session-file-gc.test.ts +46 -0
- package/src/execution/__tests__/session-start-reaper.test.ts +7 -1
- package/src/execution/__tests__/spawn-args.test.ts +14 -19
- package/src/execution/__tests__/spawn-event-adapter-rpc.test.ts +189 -0
- package/src/execution/__tests__/stdin-writer.test.ts +353 -0
- package/src/execution/__tests__/subagent-service.test.ts +73 -3
- package/src/execution/__tests__/tool-action.test.ts +1 -1
- package/src/execution/__tests__/ui-channels.test.ts +187 -0
- package/src/execution/__tests__/ui-interaction-model.test.ts +67 -0
- package/src/execution/__tests__/ui-request-handler-factory.test.ts +166 -0
- package/src/execution/__tests__/ui-request-handler.test.ts +204 -0
- package/src/execution/__tests__/ui-request-observability.test.ts +101 -0
- package/src/execution/__tests__/ui-request-queue.test.ts +133 -0
- package/src/execution/__tests__/worktree-manager.test.ts +1 -1
- package/src/execution/agent-registry.ts +1 -1
- package/src/execution/channel-registry-access.ts +138 -0
- package/src/execution/dialog-queue.ts +329 -0
- package/src/execution/finalize-record.ts +160 -0
- package/src/execution/get-state-handshake.ts +104 -0
- package/src/execution/host-mode.ts +52 -0
- package/src/execution/manifest-store.ts +206 -0
- package/src/execution/notifier.ts +5 -1
- package/src/execution/path-encoding.ts +18 -0
- package/src/execution/pi-invocation.ts +1 -1
- package/src/execution/record-store.ts +108 -2
- package/src/execution/session-file-gc.ts +25 -3
- package/src/execution/session-runner.ts +216 -32
- package/src/execution/spawn-event-adapter.ts +219 -6
- package/src/execution/stdin-writer.ts +106 -0
- package/src/execution/subagent-service.ts +167 -197
- package/src/execution/ui-channels.ts +216 -0
- package/src/execution/ui-interaction-model.ts +48 -0
- package/src/execution/ui-request-handler-factory.ts +175 -0
- package/src/execution/ui-request-observability.ts +77 -0
- package/src/execution/ui-request-queue.ts +168 -0
- package/src/index.ts +90 -6
- package/src/interface/format.ts +2 -0
- package/src/interface/subagent-actions.ts +9 -2
- package/src/interface/subagent-tool.ts +9 -8
|
@@ -1,18 +1,10 @@
|
|
|
1
|
-
//
|
|
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
|
-
|
|
14
|
-
import
|
|
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 {
|
|
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
|
|
182
|
-
*
|
|
183
|
-
*
|
|
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
|
-
|
|
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
|
-
// [
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
//
|
|
244
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
*
|
|
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
|
-
//
|
|
341
|
-
//
|
|
342
|
-
//
|
|
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
|
-
|
|
536
|
-
const
|
|
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
|
-
|
|
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
|
|
754
|
-
*
|
|
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
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
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
|
-
*
|
|
835
|
-
*
|
|
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
|
|
861
|
-
//
|
|
862
|
-
//
|
|
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
|
-
*
|
|
870
|
-
*
|
|
871
|
-
*
|
|
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
|
-
//
|
|
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 };
|