@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.
- package/README.md +56 -0
- package/agents/context-builder.md +1 -3
- package/agents/explorer.md +27 -0
- package/agents/oracle.md +2 -2
- package/agents/orchestrator.md +48 -0
- package/agents/planner.md +1 -3
- package/agents/researcher.md +0 -2
- package/agents/reviewer.md +2 -2
- package/agents/worker.md +0 -2
- package/package.json +5 -3
- package/skills/workflow-script-format/SKILL.md +6 -6
- package/src/execution/__tests__/agent-registry.test.ts +3 -3
- package/src/execution/__tests__/agent-result-mapper.test.ts +24 -2
- 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__/concurrency-pool.test.ts +33 -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 +41 -9
- package/src/execution/__tests__/finalize-record.test.ts +173 -0
- package/src/execution/__tests__/gui-mode-dispatch.test.ts +59 -0
- 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__/sdk-contract.test.ts +5 -2
- package/src/execution/__tests__/session-file-gc.test.ts +46 -0
- package/src/execution/__tests__/session-reconstructor.test.ts +20 -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-abort.test.ts +60 -0
- package/src/execution/__tests__/subagent-service.test.ts +73 -3
- package/src/execution/__tests__/subprocess-agent-runner.test.ts +72 -3
- package/src/execution/__tests__/tool-action.test.ts +27 -5
- 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/agent-result-mapper.ts +4 -1
- package/src/execution/channel-registry-access.ts +138 -0
- package/src/execution/concurrency-pool.ts +38 -6
- package/src/execution/dialog-queue.ts +329 -0
- package/src/execution/execute-options-mapper.ts +21 -4
- package/src/execution/execution-record.ts +5 -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 +110 -2
- package/src/execution/session-file-gc.ts +25 -3
- package/src/execution/session-reconstructor.ts +11 -0
- package/src/execution/session-runner.ts +228 -32
- package/src/execution/spawn-event-adapter.ts +219 -6
- package/src/execution/stdin-writer.ts +106 -0
- package/src/execution/stream-sink.ts +83 -0
- package/src/execution/subagent-service.ts +230 -235
- package/src/execution/subprocess-agent-runner.ts +16 -4
- package/src/execution/types.ts +23 -3
- 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 +101 -4
- package/src/interface/__tests__/subagent-tool-prompt.test.ts +84 -0
- package/src/interface/__tests__/workflow-state-file-exposure.test.ts +38 -0
- package/src/interface/__tests__/workflow-tool-prompt.test.ts +50 -0
- package/src/interface/command-actions.ts +77 -0
- package/src/interface/commands.ts +40 -4
- package/src/interface/format.ts +2 -0
- package/src/interface/gui-mappers.ts +83 -0
- package/src/interface/helpers.ts +52 -9
- package/src/interface/list-component.ts +3 -1
- package/src/interface/subagent-actions.ts +44 -24
- package/src/interface/subagent-tool.ts +56 -24
- package/src/interface/subagents.ts +45 -5
- package/src/interface/tool-render.ts +16 -5
- package/src/interface/tool-workflow-script.ts +113 -15
- package/src/interface/tool-workflow.ts +92 -34
- package/src/interface/views/WorkflowsView.ts +13 -4
- package/src/interface/views/__tests__/detail-content-session-file.test.ts +70 -0
- package/src/interface/views/detail-content.ts +20 -0
- package/src/orchestration/__tests__/agent-call-catch-fallback.test.ts +208 -0
- package/src/orchestration/__tests__/agent-call-stream.test.ts +157 -0
- package/src/orchestration/__tests__/error-recovery-handlers.test.ts +2 -0
- package/src/orchestration/__tests__/execute-agent-call.test.ts +171 -0
- package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +177 -0
- package/src/orchestration/__tests__/worker-script-builder.test.ts +15 -0
- package/src/orchestration/agent-opts-resolver.ts +11 -2
- package/src/orchestration/error-recovery.ts +131 -23
- package/src/orchestration/execute-agent-call.ts +12 -3
- package/src/orchestration/jsonl-run-store.ts +10 -0
- package/src/orchestration/lifecycle.ts +1 -1
- package/src/orchestration/models/agent-call.ts +7 -0
- package/src/orchestration/models/ports.ts +15 -2
- package/src/orchestration/models/run-spec.ts +6 -0
- package/src/orchestration/models/trace.ts +1 -0
- package/src/orchestration/models/types.ts +19 -0
- package/src/orchestration/node-ops.ts +2 -0
- package/src/orchestration/worker-script-builder.ts +1 -0
- package/workflows/README.md +58 -0
- package/workflows/chain.js +107 -0
- package/workflows/map-reduce.js +142 -0
- package/workflows/parallel.js +131 -0
- package/workflows/scatter-gather.js +146 -0
- package/agents/scout.md +0 -17
- package/examples/README.md +0 -43
- package/examples/chain.example.js +0 -92
- package/examples/map-reduce.example.js +0 -99
- package/examples/parallel.example.js +0 -82
- package/examples/scatter-gather.example.js +0 -106
- package/src/interface/gui-adapter.ts +0 -136
|
@@ -1,20 +1,18 @@
|
|
|
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
|
-
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 {
|
|
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 {
|
|
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
|
-
|
|
56
|
-
|
|
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
|
|
171
|
-
*
|
|
172
|
-
*
|
|
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
|
-
|
|
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
|
-
// [
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
|
|
231
|
-
//
|
|
232
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
*
|
|
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
|
-
//
|
|
329
|
-
//
|
|
330
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
523
|
-
const
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
726
|
-
*
|
|
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
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
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
|
-
*
|
|
811
|
-
*
|
|
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
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
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
|
-
*
|
|
845
|
-
*
|
|
846
|
-
*
|
|
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
|
-
//
|
|
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 };
|