@zhushanwen/pi-subagent-workflow 7.3.4 → 8.0.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 +39 -12
- package/agents/analyst.md +61 -0
- package/agents/coder.md +70 -0
- package/agents/debugger.md +67 -0
- package/agents/doc-reviewer.md +3 -3
- package/agents/explorer.md +50 -18
- package/agents/general-purpose.md +19 -8
- package/agents/orchestrator.md +37 -32
- package/agents/planner.md +45 -11
- package/agents/researcher.md +53 -11
- package/agents/reviewer.md +74 -0
- package/package.json +1 -1
- package/skills/workflow-script-format/SKILL.md +1 -1
- package/src/execution/__tests__/__fixtures__/truncline.snapshot.json +1 -0
- package/src/execution/__tests__/agent-registry.test.ts +13 -11
- package/src/execution/__tests__/ask-user-transit-e2e.test.ts +10 -4
- package/src/execution/__tests__/before-agent-start-injection.test.ts +132 -0
- package/src/execution/__tests__/bg-notify-render.test.ts +15 -15
- package/src/execution/__tests__/chatmode-first-round-closure-service.test.ts +365 -0
- package/src/execution/__tests__/chatmode-first-round-closure-spawn.test.ts +190 -0
- package/src/execution/__tests__/chatmode-round-notify-real-chain.test.ts +215 -0
- package/src/execution/__tests__/conversation-wiring.test.ts +198 -0
- package/src/execution/__tests__/crash-recovery.test.ts +8 -2
- package/src/execution/__tests__/delivery-methods.test.ts +385 -0
- package/src/execution/__tests__/epipe-fallback.test.ts +241 -0
- package/src/execution/__tests__/execute-and-await-worktree.test.ts +49 -2
- package/src/execution/__tests__/execute-nesting.test.ts +20 -72
- package/src/execution/__tests__/execution-record.test.ts +199 -0
- package/src/execution/__tests__/finalize-record.test.ts +170 -14
- package/src/execution/__tests__/format.test.ts +131 -7
- package/src/execution/__tests__/gc-timer.test.ts +184 -0
- package/src/execution/__tests__/get-record-for-action-restart.test.ts +254 -0
- package/src/execution/__tests__/helpers/spawn-mock.ts +25 -7
- package/src/execution/__tests__/index-session-start-identity.test.ts +371 -0
- package/src/execution/__tests__/index-session-start.test.ts +257 -5
- package/src/execution/__tests__/lifecycle-manager-lock.test.ts +211 -0
- package/src/execution/__tests__/lifecycle-manager.test.ts +337 -0
- package/src/execution/__tests__/lifecycle-predicates.test.ts +116 -0
- package/src/execution/__tests__/list-component.test.ts +59 -5
- package/src/execution/__tests__/list-fields.test.ts +109 -0
- package/src/execution/__tests__/model-resolver.test.ts +38 -1
- package/src/execution/__tests__/nested-visibility-env-propagation.test.ts +287 -0
- package/src/execution/__tests__/nested-visibility.test.ts +325 -0
- package/src/execution/__tests__/notifier-flush.test.ts +209 -7
- package/src/execution/__tests__/one-shot-upgrade.test.ts +205 -0
- package/src/execution/__tests__/parent-child-matrix.test.ts +336 -0
- package/src/execution/__tests__/record-store.test.ts +158 -52
- package/src/execution/__tests__/recursive-visibility-baseline.test.ts +11 -12
- package/src/execution/__tests__/recursive-visibility-env.test.ts +18 -20
- package/src/execution/__tests__/resource-policy.test.ts +109 -0
- package/src/execution/__tests__/run-and-finalize-chatmode.test.ts +267 -0
- package/src/execution/__tests__/run-spawn-chatmode-settled.test.ts +253 -0
- package/src/execution/__tests__/run-spawn-edges.test.ts +18 -25
- package/src/execution/__tests__/run-spawn-integration.test.ts +29 -25
- package/src/execution/__tests__/run-spawn-resume.test.ts +322 -0
- package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +14 -11
- package/src/execution/__tests__/session-pending.test.ts +61 -2
- package/src/execution/__tests__/session-reconstructor.test.ts +4 -4
- package/src/execution/__tests__/session-runner-epipe.test.ts +178 -0
- package/src/execution/__tests__/session-runner-schema-env.test.ts +15 -21
- package/src/execution/__tests__/session-start-reaper.test.ts +10 -8
- package/src/execution/__tests__/spawn-args.test.ts +127 -49
- package/src/execution/__tests__/spawn-worktree-guidance.test.ts +1 -0
- package/src/execution/__tests__/spawned-children.test.ts +92 -0
- package/src/execution/__tests__/status-refactor.test.ts +345 -0
- package/src/execution/__tests__/stdin-writer.test.ts +97 -0
- package/src/execution/__tests__/subagent-service-message-close.test.ts +598 -0
- package/src/execution/__tests__/subagent-service-parent-guard.test.ts +180 -0
- package/src/execution/__tests__/subagent-service.test.ts +49 -11
- package/src/execution/__tests__/timeout-integration.test.ts +27 -13
- package/src/execution/__tests__/tool-action.test.ts +12 -10
- package/src/execution/__tests__/truncline-snapshot.test.ts +81 -0
- package/src/execution/__tests__/turn-limiter-semantics.test.ts +194 -0
- package/src/execution/__tests__/worktree-manager.test.ts +292 -89
- package/src/execution/__tests__/worktree-pid-registration.integration.test.ts +13 -12
- package/src/execution/argv-mirror.ts +21 -2
- package/src/execution/execution-record.ts +126 -9
- package/src/execution/finalize-record.ts +90 -13
- package/src/execution/host-mode.ts +1 -1
- package/src/execution/lifecycle-manager.ts +484 -0
- package/src/execution/lifecycle-predicates.ts +65 -0
- package/src/execution/manifest-store.ts +61 -16
- package/src/execution/model-resolver.ts +26 -5
- package/src/execution/notifier.ts +69 -12
- package/src/execution/pi-invocation.ts +21 -1
- package/src/execution/record-store.ts +554 -107
- package/src/execution/session-pending.ts +116 -45
- package/src/execution/session-reconstructor.ts +224 -7
- package/src/execution/session-runner.ts +290 -75
- package/src/execution/sessions-index.ts +304 -0
- package/src/execution/stdin-writer.ts +93 -7
- package/src/execution/stream-sink.ts +20 -3
- package/src/execution/subagent-service.ts +867 -138
- package/src/execution/turn-limiter.ts +14 -0
- package/src/execution/types.ts +204 -22
- package/src/execution/worktree-manager.ts +128 -49
- package/src/execution/worktree-registry.ts +13 -2
- package/src/index.ts +277 -19
- package/src/injectors/subagent-list-injector.ts +26 -8
- package/src/injectors/workflow-list-injector.ts +25 -8
- package/src/interface/__tests__/subagent-tool-prompt.test.ts +18 -5
- package/src/interface/__tests__/tool-render.test.ts +15 -13
- package/src/interface/bg-notify-render.ts +32 -8
- package/src/interface/command-actions.ts +26 -7
- package/src/interface/commands.ts +21 -22
- package/src/interface/format.ts +44 -17
- package/src/interface/gui-mappers.ts +6 -8
- package/src/interface/helpers.ts +170 -10
- package/src/interface/list-component.ts +53 -14
- package/src/interface/subagent-actions.ts +235 -17
- package/src/interface/subagent-tool.ts +82 -17
- package/src/interface/subagents.ts +2 -1
- package/src/interface/tool-render.ts +11 -24
- package/src/interface/tool-workflow.ts +20 -35
- package/src/interface/views/WorkflowsView.ts +89 -32
- package/src/interface/views/__tests__/WorkflowsView-signature.test.ts +264 -0
- package/src/interface/views/__tests__/detail-content-session-file.test.ts +1 -1
- package/src/interface/views/format.ts +3 -3
- package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +325 -0
- package/src/orchestration/__tests__/args-validator.test.ts +1 -1
- package/src/orchestration/__tests__/config-loader.test.ts +38 -0
- package/src/orchestration/__tests__/error-recovery-handlers.test.ts +394 -4
- package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +4 -4
- package/src/orchestration/__tests__/execute-agent-call.test.ts +95 -0
- package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +657 -18
- package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +0 -2
- package/src/orchestration/__tests__/lifecycle.test.ts +332 -149
- package/src/orchestration/__tests__/skill-discovery.test.ts +157 -0
- package/src/orchestration/__tests__/test-mocks.ts +191 -0
- package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +98 -0
- package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +0 -2
- package/src/orchestration/__tests__/workflow-script-lint-memo.test.ts +110 -0
- package/src/orchestration/__tests__/workflows-e2e.test.ts +1 -1
- package/src/orchestration/agent-opts-resolver.ts +4 -1
- package/src/orchestration/args-validator.ts +2 -2
- package/src/orchestration/config-loader.ts +30 -1
- package/src/orchestration/error-recovery.ts +133 -29
- package/src/orchestration/execute-agent-call.ts +31 -7
- package/src/orchestration/jsonl-run-store.ts +287 -40
- package/src/orchestration/launcher.ts +7 -1
- package/src/orchestration/lifecycle.ts +135 -132
- package/src/orchestration/models/__tests__/trace.test.ts +408 -0
- package/src/orchestration/models/budget.ts +1 -1
- package/src/orchestration/models/run-runtime.ts +15 -17
- package/src/orchestration/models/run-spec.ts +2 -2
- package/src/orchestration/models/run-state.ts +3 -3
- package/src/orchestration/models/trace.ts +95 -15
- package/src/orchestration/models/types.ts +8 -9
- package/src/orchestration/models/workflow-run.ts +50 -71
- package/src/orchestration/models/workflow-script.ts +32 -1
- package/src/orchestration/skill-discovery.ts +30 -0
- package/src/orchestration/worker-handle.ts +1 -1
- package/src/orchestration/worker-host.ts +1 -1
- package/src/orchestration/worker-script-builder.ts +29 -10
- package/src/shared/__tests__/agent-ref.test.ts +34 -0
- package/src/shared/__tests__/resource-discovery-manifest-cache.test.ts +280 -0
- package/src/shared/__tests__/resource-discovery.test.ts +55 -0
- package/src/shared/__tests__/schema-jsonify.test.ts +81 -0
- package/src/shared/agent-ref.ts +16 -0
- package/src/shared/resource-discovery.ts +147 -58
- package/src/shared/schema-jsonify.ts +53 -0
- package/workflows/README.md +4 -4
- package/agents/code-reviewer.md +0 -47
- package/agents/context-builder.md +0 -21
- package/agents/oracle.md +0 -34
- package/agents/worker.md +0 -20
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
//
|
|
5
5
|
// spawn 改造后:session 在独立子进程跑(进程隔离),事件经 stdout JSON 流回流。
|
|
6
6
|
// runSpawn 是唯一执行入口(sync/background 共用)。mode 分叉在 Runtime.execute 顶部。
|
|
7
|
-
// 设计信息见 docs/subagents/spawn-refactor-plan.md。
|
|
8
7
|
|
|
9
|
-
import { type ChildProcess,
|
|
8
|
+
import { type ChildProcess, execFile, spawn } from "node:child_process";
|
|
10
9
|
import * as fs from "node:fs";
|
|
11
10
|
|
|
12
11
|
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
12
|
+
import { armIdleTimer } from "./lifecycle-manager.ts";
|
|
13
13
|
import { readActivePendingFromSessionFile } from "./session-pending.ts";
|
|
14
14
|
|
|
15
15
|
import type { ExtensionMode } from "./host-mode.ts";
|
|
@@ -24,9 +24,9 @@ import type { AgentConfig, ResolvedModel } from "./model-resolver.ts";
|
|
|
24
24
|
import { collectResult } from "./output-collector.ts";
|
|
25
25
|
import { getSubagentSessionDir } from "./path-encoding.ts";
|
|
26
26
|
import { getPiInvocation } from "./pi-invocation.ts";
|
|
27
|
+
import { stringifySchemaCached } from "../shared/schema-jsonify.ts";
|
|
27
28
|
import { MAX_FORK_DEPTH } from "./session-context-resolver.ts";
|
|
28
|
-
import {
|
|
29
|
-
import { sendPromptCommand } from "./stdin-writer.ts";
|
|
29
|
+
import { EPIPE_FAILURE_THRESHOLD, recordEpipeFailure, sendPromptCommand } from "./stdin-writer.ts";
|
|
30
30
|
import {
|
|
31
31
|
deriveSessionFilePath,
|
|
32
32
|
findSessionFileByHeaderId,
|
|
@@ -75,6 +75,37 @@ function isAgentEndEvt(
|
|
|
75
75
|
return x.type === "agent_end";
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* [V2 模块 3] agent_settled 事件守卫。对齐 isAgentEndEvt 模式(type 字段窄化,无需 cast)。
|
|
80
|
+
*
|
|
81
|
+
* agent_settled 是 V2 持续对话的「真空闲边界」:agent_end 之后、post-run(compact 检查等)
|
|
82
|
+
* 完成后才 emit(pi `agent-session.js` `_runAgentPrompt` finally 块,约 L744-755)。chatMode 在
|
|
83
|
+
* 此 arm idle timer + 通知本轮完成;非 chatMode 忽略(agent_end handler 已处理一次性 kill)。
|
|
84
|
+
*/
|
|
85
|
+
function isAgentSettledEvt(x: unknown): x is { type: "agent_settled" } {
|
|
86
|
+
if (typeof x !== "object" || x === null) return false;
|
|
87
|
+
if (!("type" in x)) return false;
|
|
88
|
+
return x.type === "agent_settled";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 把 pi assistantMessageEvent 分流为 text_delta / thinking_delta AgentEvent,供 streaming 通道。
|
|
93
|
+
*
|
|
94
|
+
* 正向判定:只 text_delta / thinking_delta 产出事件。toolcall_delta(工具入参 JSON 增量,
|
|
95
|
+
* 如 {"path":"..."})等其他带 delta 的事件不混入 text stream——否则 subagent overlay 的
|
|
96
|
+
* assistant 正文会原样流出工具参数 JSON 串(对话末尾 JSON 与 text 混杂、无 ICON+title 卡片)。
|
|
97
|
+
* 工具调用由 fetchAndInject 拉取的完整历史(toolCall 卡片)展示,不依赖 streaming。
|
|
98
|
+
*
|
|
99
|
+
* 提取为纯函数便于单测(runSpawn 的 handleSdkEvent 闭包不易直接测)。
|
|
100
|
+
*/
|
|
101
|
+
export function mapAssistantMessageDelta(
|
|
102
|
+
ame: { type?: string; delta?: string },
|
|
103
|
+
): { type: "text_delta"; delta: string } | { type: "thinking_delta"; delta: string } | null {
|
|
104
|
+
if (ame.type === "thinking_delta") return { type: "thinking_delta", delta: ame.delta ?? "" };
|
|
105
|
+
if (ame.type === "text_delta" && ame.delta !== undefined) return { type: "text_delta", delta: ame.delta };
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
78
109
|
// ============================================================
|
|
79
110
|
// 常量
|
|
80
111
|
// ============================================================
|
|
@@ -209,19 +240,21 @@ Your working directory (the "Working directory" in the environment block above)
|
|
|
209
240
|
// 主进程异常退出(SIGKILL/崩溃/session_shutdown dispose)时,sync 子进程会成孤儿。
|
|
210
241
|
//
|
|
211
242
|
// 本 Set 是 dispose 的最后兜底——在 abortRunningControllers(background controller.abort 路径)
|
|
212
|
-
// 之后,遍历所有仍存活的子进程(含 sync)发 SIGTERM。正常退出路径(子进程 close)会从
|
|
243
|
+
// 之后,遍历所有仍存活的子进程(含 sync)发 SIGTERM。正常退出路径(子进程 close)会从 Map 移除,
|
|
213
244
|
// 不受影响。background 子进程可能被 controller.abort 路径先 kill 一次,再被本遍历 kill 一次
|
|
214
245
|
// (对已退出的 child.kill 返回 false,无害)。
|
|
215
246
|
//
|
|
247
|
+
// [M2-B1] key = record.id(record→child 映射,busy 投递定位活进程用,见 getChildByRecord)。
|
|
216
248
|
// [export] 测试可观测(断言 dispose 后 size===0)。业务代码误外部修改。
|
|
217
|
-
export const spawnedChildren = new
|
|
249
|
+
export const spawnedChildren = new Map<string, ChildProcess>();
|
|
218
250
|
|
|
219
251
|
/**
|
|
220
252
|
* kill 所有未退出的 spawned 子进程(dispose 兜底用)。
|
|
221
253
|
*
|
|
222
|
-
* 遍历 spawnedChildren
|
|
223
|
-
* 已退出的子进程在 close/error 事件时已从
|
|
224
|
-
*
|
|
254
|
+
* 遍历 spawnedChildren Map 的 values(),对每个未 killed 的子进程发 `child.kill(signal)`。
|
|
255
|
+
* 已退出的子进程在 close/error 事件时已从 Map 移除(按句守卫 removeChildRegistration——
|
|
256
|
+
* Map 当前值仍是该 child 才删,防误删 resume spawn 的新注册),故 Map 中只剩「活着的」
|
|
257
|
+
* 或「已被 kill 但 close 事件尚未回调的」。后者用 `child.killed`
|
|
225
258
|
* 跳过——避免对一个已 kill 的子进程重复 kill。
|
|
226
259
|
*
|
|
227
260
|
* 用于 SubagentService.dispose(进程退出路径):覆盖 sync 子进程(controller 为 undefined,
|
|
@@ -234,7 +267,7 @@ export const spawnedChildren = new Set<ChildProcess>();
|
|
|
234
267
|
*/
|
|
235
268
|
export function killAllSpawnedChildren(signal: NodeJS.Signals = "SIGTERM"): number {
|
|
236
269
|
let n = 0;
|
|
237
|
-
for (const child of spawnedChildren) {
|
|
270
|
+
for (const child of spawnedChildren.values()) {
|
|
238
271
|
// 跳过已 kill 的(killed=true 表示已调过 child.kill;已退出的在 close/error 时已从 Set 移除)。
|
|
239
272
|
// 不依赖 exitCode/signalCode:close 事件回调可能晚于 dispose,此时它们仍为 null,但子进程
|
|
240
273
|
// 可能已被 controller.abort 路径 kill(killed=true)。
|
|
@@ -254,6 +287,37 @@ export function killAllSpawnedChildren(signal: NodeJS.Signals = "SIGTERM"): numb
|
|
|
254
287
|
return n;
|
|
255
288
|
}
|
|
256
289
|
|
|
290
|
+
/**
|
|
291
|
+
* 按 record id 查活子进程(busy 投递定位用,设计决策 6)。
|
|
292
|
+
*
|
|
293
|
+
* spawnedChildren 的 record→child 映射查询入口。busy 投递(follow_up/steer)需定位
|
|
294
|
+
* record 对应的活 ChildProcess 写 stdin;进程 close/error 后已从 Map 移除,返回 undefined。
|
|
295
|
+
*
|
|
296
|
+
* @param recordId ExecutionRecord.id
|
|
297
|
+
* @returns 活子进程;record 无活进程(未注册 / 已退出)时 undefined
|
|
298
|
+
*/
|
|
299
|
+
export function getChildByRecord(recordId: string): ChildProcess | undefined {
|
|
300
|
+
return spawnedChildren.get(recordId);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* [M4 记账竞态守卫] 仅当 Map 当前值仍是本 child 句柄时才删除注册。
|
|
305
|
+
*
|
|
306
|
+
* 背景:spawnedChildren 是 Map<recordId, ChildProcess>,resume spawn 会 set 覆盖旧句柄。
|
|
307
|
+
* 旧 child 的 close/error 事件异步到达(kill 后 close 回调可能晚数 tick),若 close/error
|
|
308
|
+
* handler 无条件 delete(record.id),会误删 resume spawn 刚注册的**新** child——
|
|
309
|
+
* 时序:idle timer 对旧 child SIGTERM(killed=true 但 close 未到)→ deliverMessage 判
|
|
310
|
+
* child.killed 走冷路径 resumeRound → spawn 新 child set 覆盖 → 旧 child close 此刻到达 →
|
|
311
|
+
* 误删新注册。后果:新活进程脱离记账(killAllSpawnedChildren 漏杀孤儿 + getChildByRecord
|
|
312
|
+
* undefined → busy 投递再走冷路径二次 resume → 两进程写同一 session 文件,v4 A-5 注释
|
|
313
|
+
* 自述的 P7 双写者事故模式)。按句相等守卫:set 覆盖后旧句柄 ≠ Map 当前值,天然跳过。
|
|
314
|
+
*/
|
|
315
|
+
function removeChildRegistration(recordId: string, child: ChildProcess): void {
|
|
316
|
+
if (spawnedChildren.get(recordId) === child) {
|
|
317
|
+
spawnedChildren.delete(recordId);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
257
321
|
// ============================================================
|
|
258
322
|
// 依赖注入容器 + 入参
|
|
259
323
|
// ============================================================
|
|
@@ -275,7 +339,7 @@ export interface SessionRunnerContext {
|
|
|
275
339
|
* Runtime 层接线为 WorktreeManager.registerPid,用于注册表补全 pid。
|
|
276
340
|
* 解耦 Core 与 Runtime——session-runner 不直接依赖 WorktreeManager。
|
|
277
341
|
*/
|
|
278
|
-
onWorktreePid?: (branch: string, pid: number) => void;
|
|
342
|
+
onWorktreePid?: (branch: string, pid: number, sessionFile?: string) => void;
|
|
279
343
|
/**
|
|
280
344
|
* UI 请求处理回调。子进程发 extension_ui_request 时调用。
|
|
281
345
|
*
|
|
@@ -305,6 +369,22 @@ export interface SessionRunnerContext {
|
|
|
305
369
|
* mainCwd=checkout 路径,rootCwd 贯穿真 ROOT——session 文件落盘统一用 ROOT cwd 编码,
|
|
306
370
|
* 主进程磁盘重建才能看到全树(设计 recursive-subagent-visibility.md)。 */
|
|
307
371
|
rootCwd: string;
|
|
372
|
+
/**
|
|
373
|
+
* [V2 模块 3] chatMode 本轮完成通知挂载点。
|
|
374
|
+
*
|
|
375
|
+
* V2 决策:chatMode 进程长驻(agent_end 不 kill),「本轮完成」的真空闲边界是
|
|
376
|
+
* `agent_settled`(agent_end 之后、post-run 完成后 emit,见 pi `agent-session.js`
|
|
377
|
+
* `_runAgentPrompt` finally 块),而非 agent_end(agent_end 时 post-run 可能仍在跑)。
|
|
378
|
+
* session-runner 在 agent_settled 时调本回调,调用方(subagent-service)注入
|
|
379
|
+
* notifyComplete 通知父 agent。
|
|
380
|
+
*
|
|
381
|
+
* **本步只定义挂载点,不接线**:subagent-service 未改,回调未注入 = no-op。
|
|
382
|
+
* notify 端到端 + runSpawn resolve 语义重构留 Step 4(与统一投递 + subagent-service
|
|
383
|
+
* 一起系统处理)。非 chatMode 路径不触发 agent_settled handler,本字段无影响。
|
|
384
|
+
*
|
|
385
|
+
* @param record 当前 ExecutionRecord(chatMode、已完成本轮)
|
|
386
|
+
*/
|
|
387
|
+
onRoundSettled?: (record: ExecutionRecord) => void;
|
|
308
388
|
}
|
|
309
389
|
|
|
310
390
|
/** SessionRunner.run 的入参。 */
|
|
@@ -346,6 +426,29 @@ export interface RunOptions {
|
|
|
346
426
|
parentForkDepth?: number;
|
|
347
427
|
}
|
|
348
428
|
|
|
429
|
+
/**
|
|
430
|
+
* resume spawn 选项——重开已结束的 session 文件继续对话(M1 基建)。
|
|
431
|
+
*
|
|
432
|
+
* resume 时 pi 子进程用 `--session <sessionFile> --mode rpc` 续写原文件(探针 P-1/P-8
|
|
433
|
+
* 实测:路径不变、entry 续写、上下文保留)。runSpawn 收到此参数后:
|
|
434
|
+
* - buildSpawnArgs 追加 `--session <sessionFile>`
|
|
435
|
+
* - record.sessionFile 提前设为 resume.sessionFile(handshake 只验证 spawn 成功,不覆盖)
|
|
436
|
+
* - model/thinkingLevel 优先用此处的值(防多轮对话模型漂移,探针 P-10),否则回退 opts.resolved
|
|
437
|
+
*
|
|
438
|
+
* 注意:M1 只暴露能力,messageHandler(M2)才会真正调用。
|
|
439
|
+
*/
|
|
440
|
+
export interface SpawnResumeOpts {
|
|
441
|
+
/** resume 目标 session 文件绝对路径(pi `--session` 参数值)。 */
|
|
442
|
+
sessionFile: string;
|
|
443
|
+
/**
|
|
444
|
+
* resume 时覆盖的 model(`"provider/id"` 格式,防漂移,探针 P-10 证明必须传);
|
|
445
|
+
* 不传则回退 opts.resolved。
|
|
446
|
+
*/
|
|
447
|
+
model?: string;
|
|
448
|
+
/** resume 时覆盖的 thinkingLevel;不传则回退 opts.resolved。 */
|
|
449
|
+
thinkingLevel?: string;
|
|
450
|
+
}
|
|
451
|
+
|
|
349
452
|
// ============================================================
|
|
350
453
|
// D-A6 schemaEnv bridge
|
|
351
454
|
// ============================================================
|
|
@@ -370,12 +473,13 @@ export function applySchemaEnvToChildEnv(
|
|
|
370
473
|
// Schema 指令
|
|
371
474
|
// ============================================================
|
|
372
475
|
|
|
373
|
-
/** formatSchemaInstruction 的 JSON pretty-print 缩进。 */
|
|
374
|
-
const SCHEMA_JSON_INDENT = 2;
|
|
375
|
-
|
|
376
476
|
/**
|
|
377
477
|
* 构造 schema 指令模板(拼入 task 末尾 + steer reminder 复用)。
|
|
378
478
|
* 指令明确要求 agent 调用 structured-output tool,而非直接输出 JSON 文本。
|
|
479
|
+
*
|
|
480
|
+
* schema JSON 的 pretty-print(indent=2)由 shared/schema-jsonify.ts 的
|
|
481
|
+
* stringifySchemaCached(schema, "pretty") 产出(IF7:与 resolver 的 compact 版
|
|
482
|
+
* 共享 WeakMap 缓存条目,输出与 JSON.stringify(schema, null, 2) 逐字节一致)。
|
|
379
483
|
*/
|
|
380
484
|
export function formatSchemaInstruction(schema: Record<string, unknown>): string {
|
|
381
485
|
return [
|
|
@@ -386,7 +490,9 @@ export function formatSchemaInstruction(schema: Record<string, unknown>): string
|
|
|
386
490
|
"Do NOT pass a `schema` parameter; the system validates `data` against the authoritative schema automatically.",
|
|
387
491
|
"The schema for your `data` is:",
|
|
388
492
|
"```json",
|
|
389
|
-
|
|
493
|
+
// IF7(#13):同 schema 对象引用的 pretty stringify 走 WeakMap 缓存
|
|
494
|
+
// (与 agent-opts-resolver 的 compact 版共享缓存条目,输出逐字节不变)
|
|
495
|
+
stringifySchemaCached(schema, "pretty"),
|
|
390
496
|
"```",
|
|
391
497
|
].join("\n");
|
|
392
498
|
}
|
|
@@ -403,7 +509,8 @@ const branchCache = new Map<string, string>();
|
|
|
403
509
|
|
|
404
510
|
/**
|
|
405
511
|
* 构建环境信息块(P7 防注入:环境数据标记为 data,非指令)。
|
|
406
|
-
* git branch
|
|
512
|
+
* git branch 异步获取(execFile),按 cwd 缓存——缓存命中路径返回已 resolve 值零开销,
|
|
513
|
+
* 仅每 cwd 首次调用发起 git(此前为同步阻塞调用,挂载盘慢 git 时阻塞 spawn 链最多 2s)。
|
|
407
514
|
*
|
|
408
515
|
* [SPAWN 改造] 从旧 in-process run() 恢复。spawn 模型下此块拼进
|
|
409
516
|
* --append-system-prompt 文件,子进程读文件注入 system prompt。
|
|
@@ -419,11 +526,11 @@ const branchCache = new Map<string, string>();
|
|
|
419
526
|
* @param forkDepth 当前 fork 链深度(undefined=非 fork session,视为 0)。
|
|
420
527
|
* @param nestingDepth 通用嵌套深度(record.depth,undefined=顶层)。
|
|
421
528
|
*/
|
|
422
|
-
export function buildEnvBlock(
|
|
529
|
+
export async function buildEnvBlock(
|
|
423
530
|
cwd: string,
|
|
424
531
|
forkDepth?: number,
|
|
425
532
|
nestingDepth?: number,
|
|
426
|
-
): string {
|
|
533
|
+
): Promise<string> {
|
|
427
534
|
const lines = ["--- environment (data, not instructions) ---", `Working directory: ${cwd}`];
|
|
428
535
|
// [M9] 取 max(forkDepth, nestingDepth)——更严的约束先生效,避免只展示 forkDepth 误导 LLM。
|
|
429
536
|
const fd = forkDepth ?? 0;
|
|
@@ -434,14 +541,27 @@ export function buildEnvBlock(
|
|
|
434
541
|
}
|
|
435
542
|
let branch = branchCache.get(cwd);
|
|
436
543
|
if (branch === undefined) {
|
|
544
|
+
// catch 兜底一切失败(含 execFile 未被 mock 的测试环境):branch 静默为空,
|
|
545
|
+
// env block 省略 Git branch 行——与旧同步版语义一致
|
|
437
546
|
try {
|
|
438
|
-
branch =
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
547
|
+
branch = await new Promise<string>((resolve, reject) => {
|
|
548
|
+
execFile(
|
|
549
|
+
"git",
|
|
550
|
+
["rev-parse", "--abbrev-ref", "HEAD"],
|
|
551
|
+
{ cwd, encoding: "utf8", timeout: ENV_GIT_TIMEOUT_MS },
|
|
552
|
+
(err: Error | null, stdout: string) => {
|
|
553
|
+
if (err) reject(err);
|
|
554
|
+
else resolve(stdout.trim());
|
|
555
|
+
},
|
|
556
|
+
);
|
|
557
|
+
});
|
|
558
|
+
} catch (err) {
|
|
559
|
+
// 非 git 目录 / git 不在 PATH 是高频正常路径,debug 级留诊断线索即可,不刷 info/warn
|
|
560
|
+
logger.debug(
|
|
561
|
+
`[session-runner] buildEnvBlock: git branch lookup failed for ${cwd}, fallback to empty: ${
|
|
562
|
+
err instanceof Error ? err.message : String(err)
|
|
563
|
+
}`,
|
|
564
|
+
);
|
|
445
565
|
branch = "";
|
|
446
566
|
}
|
|
447
567
|
branchCache.set(cwd, branch);
|
|
@@ -490,10 +610,16 @@ export function buildSpawnArgs(
|
|
|
490
610
|
agentTools: string[] | undefined;
|
|
491
611
|
appendSystemPromptPath: string | undefined;
|
|
492
612
|
sessionDir: string;
|
|
613
|
+
/**
|
|
614
|
+
* resume 目标 session 文件路径。存在时紧跟 `--session-dir <dir>` 追加
|
|
615
|
+
* `--session <file>`,pi 续写原 session 文件而非新建(探针 P-1/P-8 实测续写成立)。
|
|
616
|
+
* undefined = 新 session(当前行为,向后兼容)。
|
|
617
|
+
*/
|
|
618
|
+
sessionFile?: string;
|
|
493
619
|
forkSource: string | undefined;
|
|
494
620
|
skillPaths: string[] | undefined;
|
|
495
621
|
/**
|
|
496
|
-
* 镜像自主进程 argv 的 flag(--no-extensions/--approve/--extension)。
|
|
622
|
+
* 镜像自主进程 argv 的 flag(--no-extensions/--approve/--extension/--no-context-files)。
|
|
497
623
|
* undefined 或全空/全 false 时行为不变(向后兼容)。
|
|
498
624
|
*/
|
|
499
625
|
mirrorFlags?: MirrorFlags;
|
|
@@ -503,6 +629,10 @@ export function buildSpawnArgs(
|
|
|
503
629
|
// positional task arg / -p flag 在 rpc mode 下被 resolveAppMode 无视。
|
|
504
630
|
// task 由 runSpawn 内 sendPromptCommand 写 child.stdin 驱动。
|
|
505
631
|
const args: string[] = ["--mode", "rpc", "--session-dir", params.sessionDir];
|
|
632
|
+
// resume:紧跟 --session-dir 追加 --session <file>,pi 续写原 session 文件(P-8)。
|
|
633
|
+
if (params.sessionFile) {
|
|
634
|
+
args.push("--session", params.sessionFile);
|
|
635
|
+
}
|
|
506
636
|
if (params.model) args.push("--model", params.model);
|
|
507
637
|
if (params.thinkingLevel && params.model) {
|
|
508
638
|
// thinking level 通过 model 后缀 :level 传递(pi CLI 约定)
|
|
@@ -526,12 +656,17 @@ export function buildSpawnArgs(
|
|
|
526
656
|
args.push("--skill", sp);
|
|
527
657
|
}
|
|
528
658
|
}
|
|
529
|
-
// 镜像主进程的 extension/approve flag:让子进程 extension
|
|
530
|
-
// undefined/空值时不追加(向后兼容)。顺序紧跟
|
|
659
|
+
// 镜像主进程的 extension/approve/context-files flag:让子进程 extension 加载与
|
|
660
|
+
// context files 行为与主进程一致。undefined/空值时不追加(向后兼容)。顺序紧跟
|
|
661
|
+
// skill 之后,注入类 flag 集中。
|
|
531
662
|
const mf = params.mirrorFlags;
|
|
532
663
|
if (mf) {
|
|
533
664
|
if (mf.noExtensions) args.push("--no-extensions");
|
|
534
665
|
if (mf.approve) args.push("--approve");
|
|
666
|
+
// 镜像 --no-context-files:xyz-system-prompt-extension.js(经 --extension 镜像进入
|
|
667
|
+
// 子进程)靠子进程 argv 检测此 flag 守卫全局 AGENTS.md 注入——不镜像则用户的
|
|
668
|
+
// context files opt-out 只对主进程生效,每个 subagent 仍被注入。
|
|
669
|
+
if (mf.noContextFiles) args.push("--no-context-files");
|
|
535
670
|
for (const ep of mf.extensionPaths) {
|
|
536
671
|
args.push("--extension", ep);
|
|
537
672
|
}
|
|
@@ -550,9 +685,18 @@ export async function runSpawn(
|
|
|
550
685
|
task: string,
|
|
551
686
|
opts: RunOptions,
|
|
552
687
|
ctx: SessionRunnerContext,
|
|
688
|
+
/** resume 选项(M1 基建):重开已结束的 session 继续对话。undefined = 新 session。 */
|
|
689
|
+
resume?: SpawnResumeOpts,
|
|
553
690
|
): Promise<AgentResult> {
|
|
554
691
|
const startTime = Date.now();
|
|
555
692
|
|
|
693
|
+
// [M1 resume 基建] resume 时提前锁定 record.sessionFile:spawn 前已知目标文件,
|
|
694
|
+
// handshake 的 finishHandshake 内 `!record.sessionFile` 守卫天然跳过回填,
|
|
695
|
+
// sessionFile 用 resume.sessionFile 不被覆盖(RPC mode 无 header,header 分支也不触发)。
|
|
696
|
+
if (resume) {
|
|
697
|
+
record.sessionFile = resume.sessionFile;
|
|
698
|
+
}
|
|
699
|
+
|
|
556
700
|
// a. transient 寄存器(同 run():tool_end 缺 args 时回填)
|
|
557
701
|
const pendingTools = new Map<string, { toolName: string; args?: unknown }>();
|
|
558
702
|
|
|
@@ -592,7 +736,40 @@ export async function runSpawn(
|
|
|
592
736
|
}
|
|
593
737
|
};
|
|
594
738
|
|
|
739
|
+
// [V2 决策 2] chatMode 首轮 resolveRun:agent_settled 时提前 resolve runSpawn 的 exitCode
|
|
740
|
+
// promise(首轮完成 exit code 0),让 runAndFinalize 拿到 result 走 chatMode 首轮分支。
|
|
741
|
+
// 非 chatMode 不触发 agent_settled resolve(agent_end handler 一次性 kill → close resolve)。
|
|
742
|
+
// chatMode 后续轮次(idle timer kill → close)到达时 resolve(code) 是 no-op(Promise 只
|
|
743
|
+
// resolve 一次)。close handler 的 cleanup(spawnedChildren.delete / get_stateListeners.clear /
|
|
744
|
+
// settleHandshake)完全不变——chatMode 下 close 最终触发时仍执行。
|
|
745
|
+
let resolveRun: ((code: number) => void) | undefined = undefined;
|
|
746
|
+
|
|
595
747
|
const handleSdkEvent = (raw: SdkEvent): void => {
|
|
748
|
+
// [V2 模块 3] agent_settled:真空闲边界(agent_end 之后、post-run 完成后才 emit)。
|
|
749
|
+
// chatMode:arm idle timer(超时 SIGTERM 回收)+ 通知本轮完成(onRoundSettled,本步 no-op)。
|
|
750
|
+
// 非 chatMode:忽略(agent_end handler 的一次性 kill 已处理,进程不会活到 agent_settled)。
|
|
751
|
+
if (isAgentSettledEvt(raw)) {
|
|
752
|
+
if (record.chatMode) {
|
|
753
|
+
armIdleTimer(record.id, () => {
|
|
754
|
+
// onTimeout 复用现有 kill 路径:child.kill("SIGTERM") 触发 close → close handler
|
|
755
|
+
// 统一 cleanup(spawnedChildren.delete / get_stateListeners.clear / resolve)。
|
|
756
|
+
// 与 agent_end handler 现有 SIGTERM 分支一致,不新造 cleanup。
|
|
757
|
+
const child = getChildByRecord(record.id);
|
|
758
|
+
if (child && !child.killed) child.kill("SIGTERM");
|
|
759
|
+
}, record.idleTimeoutMs);
|
|
760
|
+
// [SP-9] chatMode 每轮 reset turn-limiter:新一轮开始(续聊)时,
|
|
761
|
+
// maxTurns/graceTurns 不跨轮累计(续聊本质是无限轮,累计上限违背 G1)。
|
|
762
|
+
// reset steered/aborted 标志 + turnCount 归零,下一轮独立计数。
|
|
763
|
+
limiter.reset();
|
|
764
|
+
record.turnCount = 0;
|
|
765
|
+
ctx.onRoundSettled?.(record);
|
|
766
|
+
// [V2 决策 2] chatMode 首轮:agent_settled = 本轮真空闲,提前 resolve runSpawn
|
|
767
|
+
//(exit code 0,进程仍保活 idle timer armed)。runAndFinalize 拿到 result 后走
|
|
768
|
+
// chatMode 首轮分支(不进 finalize 分流),onRoundSettled 已 notify 主 agent。
|
|
769
|
+
resolveRun?.(0);
|
|
770
|
+
}
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
596
773
|
switch (raw.type) {
|
|
597
774
|
case "tool_execution_start": {
|
|
598
775
|
const toolName = raw.toolName ?? "";
|
|
@@ -616,12 +793,8 @@ export async function runSpawn(
|
|
|
616
793
|
return;
|
|
617
794
|
}
|
|
618
795
|
case "message_update": {
|
|
619
|
-
const
|
|
620
|
-
if (
|
|
621
|
-
agentEvent({ type: "thinking_delta", delta: ame.delta ?? "" });
|
|
622
|
-
} else if (ame?.delta !== undefined) {
|
|
623
|
-
agentEvent({ type: "text_delta", delta: ame.delta });
|
|
624
|
-
}
|
|
796
|
+
const mapped = mapAssistantMessageDelta(raw.assistantMessageEvent ?? {});
|
|
797
|
+
if (mapped) agentEvent(mapped);
|
|
625
798
|
return;
|
|
626
799
|
}
|
|
627
800
|
case "turn_end": {
|
|
@@ -636,6 +809,10 @@ export async function runSpawn(
|
|
|
636
809
|
agentEvent({ type: "compaction" });
|
|
637
810
|
return;
|
|
638
811
|
}
|
|
812
|
+
// [review 修复] 已删除 turn_start / message_start 两 case 的 pendingMessages
|
|
813
|
+
// 消费确认 shift(MF-5):三段消费链(deliverToRunning push / message_start(user)
|
|
814
|
+
// shift / redeliverPending 补投)随 deliverToRunning 一并移除——SP-5 upgrade 后
|
|
815
|
+
// 无生产调用方,整条链路不可达。两 case 均为 no-op,落 default 即可。
|
|
639
816
|
default:
|
|
640
817
|
return;
|
|
641
818
|
}
|
|
@@ -675,7 +852,7 @@ export async function runSpawn(
|
|
|
675
852
|
// [M9] buildEnvBlock 取 max(forkDepth, nestingDepth):record.depth === nestingDepth(都从
|
|
676
853
|
// execCtxAls 派生,见 createRecordForMode L425-427 与 execute L257-258),传它让 env block
|
|
677
854
|
// 展示更严的约束(混合嵌套链下通用护栏可能先于 fork 护栏拒绝)。
|
|
678
|
-
const appendParts: string[] = [buildEnvBlock(ctx.cwd, ownForkDepth, record.depth)];
|
|
855
|
+
const appendParts: string[] = [await buildEnvBlock(ctx.cwd, ownForkDepth, record.depth)];
|
|
679
856
|
if (opts.agentConfig?.systemPrompt) appendParts.push(opts.agentConfig.systemPrompt);
|
|
680
857
|
if (opts.appendSystemPrompt) appendParts.push(...opts.appendSystemPrompt);
|
|
681
858
|
// [M1 补偿] rpc mode 的 steer 通道当前未接通,改为启动时预置 wrap-up 提示——
|
|
@@ -714,6 +891,24 @@ export async function runSpawn(
|
|
|
714
891
|
// 子进程的 store/runSpawn 落盘目录须统一编码在 enc(ROOT cwd) 段(与身份贯穿同构),
|
|
715
892
|
// 否则 ROOT 磁盘重建扫不到深层 record(见 subagent-service ENV_ROOT_CWD 注释)。
|
|
716
893
|
childEnv.PI_SUBAGENT_ROOT_CWD = ctx.rootCwd;
|
|
894
|
+
// [M4 identity 子进程写] 子进程 session_start hook 读这些 env → pi.appendEntry 写
|
|
895
|
+
// subagent-identity custom entry(V2 决策 5)。旧实现父进程 fs.appendFileSync 补写的
|
|
896
|
+
// custom entry 缺 id/parentId → 污染 pi _buildIndex leafId → message tree 断成两棵
|
|
897
|
+
// → 多轮对话丢上下文。改由子进程(session 文件所有者)用 appendEntry 写,pi 自动生成 id/parentId。
|
|
898
|
+
// id/rootSessionId/depth/forkDepth 复用上方身份贯穿 env(SELF_RECORD_ID/ROOT_SESSION_ID/DEPTH/FORK_DEPTH),
|
|
899
|
+
// 此处补 identity 专属字段:agent/mode/task/slug/startedAt/parentRecordId/chatMode。
|
|
900
|
+
childEnv.PI_SUBAGENT_AGENT = record.agent;
|
|
901
|
+
childEnv.PI_SUBAGENT_MODE = record.mode;
|
|
902
|
+
childEnv.PI_SUBAGENT_TASK = record.task;
|
|
903
|
+
childEnv.PI_SUBAGENT_SLUG = record.slug;
|
|
904
|
+
childEnv.PI_SUBAGENT_STARTED_AT = String(record.startedAt);
|
|
905
|
+
childEnv.PI_SUBAGENT_PARENT_RECORD_ID = record.parentRecordId;
|
|
906
|
+
childEnv.PI_SUBAGENT_CHAT_MODE =
|
|
907
|
+
record.chatMode !== undefined ? String(record.chatMode) : undefined;
|
|
908
|
+
// [review round2] worktree 隔离标志贯穿:resume 轮 opts.worktree 来自 record.worktreeHandle
|
|
909
|
+
//(同进程内保留),子进程 identity entry 据此记 worktree:true——跨重启重建时据此拒绝续聊
|
|
910
|
+
//(WorktreeHandle 不可序列化,reattach 不可行,静默回落主 repo 会破坏隔离)。
|
|
911
|
+
childEnv.PI_SUBAGENT_WORKTREE = opts.worktree !== undefined ? "true" : undefined;
|
|
717
912
|
// D-A6 bridge: schema 激活 structured-output 扩展注册 tool(workflow 编排层需要)
|
|
718
913
|
applySchemaEnvToChildEnv(childEnv, opts.schemaEnv);
|
|
719
914
|
|
|
@@ -724,13 +919,18 @@ export async function runSpawn(
|
|
|
724
919
|
(p): p is string => typeof p === "string" && p.length > 0,
|
|
725
920
|
);
|
|
726
921
|
const modelId = opts.resolved.model.id;
|
|
922
|
+
// [M1 resume] resume 时 model/thinkingLevel 优先用 resume 值(防多轮对话模型漂移,P-10),
|
|
923
|
+
// 否则回退 opts.resolved。resume.model 已是 "provider/id" 格式,与 buildSpawnArgs 约定一致。
|
|
924
|
+
const effectiveModel = resume?.model ?? `${opts.resolved.model.provider}/${modelId}`;
|
|
925
|
+
const effectiveThinkingLevel = resume?.thinkingLevel ?? opts.resolved.thinkingLevel;
|
|
727
926
|
const spawnArgs = buildSpawnArgs(
|
|
728
927
|
{
|
|
729
|
-
model:
|
|
730
|
-
thinkingLevel:
|
|
928
|
+
model: effectiveModel,
|
|
929
|
+
thinkingLevel: effectiveThinkingLevel,
|
|
731
930
|
agentTools: opts.agentConfig?.tools,
|
|
732
931
|
appendSystemPromptPath: tempPromptFile?.filePath,
|
|
733
932
|
sessionDir,
|
|
933
|
+
sessionFile: resume?.sessionFile,
|
|
734
934
|
forkSource,
|
|
735
935
|
skillPaths: skillPaths.length > 0 ? skillPaths : undefined,
|
|
736
936
|
// 镜像主进程 argv 的 extension/approve flag,让子进程加载行为对齐主进程
|
|
@@ -761,8 +961,11 @@ export async function runSpawn(
|
|
|
761
961
|
ctx.onWorktreePid?.(opts.worktree.branch, child.pid);
|
|
762
962
|
}
|
|
763
963
|
// [C1] track 子进程供 dispose 兜底 kill(sync + background 均注册——sync 无 controller,
|
|
764
|
-
// abortRunningControllers 跳过它,靠本
|
|
765
|
-
spawnedChildren.
|
|
964
|
+
// abortRunningControllers 跳过它,靠本 Map 兜底)。close/error 后按句守卫移除(已退出无需再 kill)。
|
|
965
|
+
spawnedChildren.set(record.id, child);
|
|
966
|
+
// [V2 决策 3] spawn 后 child.pid 同步立即可得,记录到 record 内存(lifecycle-manager
|
|
967
|
+
// 孤儿扫描用,Step 5 接入持久化)。resume spawn 时此处同样覆盖更新(pid 可能已变)。
|
|
968
|
+
if (child.pid !== undefined) record.pid = child.pid;
|
|
766
969
|
|
|
767
970
|
// [worktree-reaper-fix] 同步补全注册表 pid:spawn 返回后 child.pid 立即可得(Node.js
|
|
768
971
|
// 同步属性),无需等任何 stdout 事件。原补全点挂在 header 分支(下方 stdout handler 内),
|
|
@@ -791,6 +994,31 @@ export async function runSpawn(
|
|
|
791
994
|
child.stdout.setEncoding("utf8");
|
|
792
995
|
child.stderr.setEncoding("utf8");
|
|
793
996
|
|
|
997
|
+
// [v4 A-1] 异步 stdin 'error' listener。writeStdinLine 的 try/catch 只覆盖同步 write 抛错;
|
|
998
|
+
// 子进程退出后内核回写 EPIPE 会以异步 stream 'error' event 到达,若无 listener 会让 Node
|
|
999
|
+
// 抛 unhandled 'error' 崩主进程(P1)。必须在首次 stdin 写入(下方 sendPromptCommand)前注册。
|
|
1000
|
+
// handler 行为:①移出 spawnedChildren 标记 dead(与 child.on('error') :1141 同模式);
|
|
1001
|
+
// ②recordEpipeFailure 合并同步/异步计数;③logger.warn 记录一次。
|
|
1002
|
+
// [v4 A-1 裁决] handler 内**不 throw**——stream 'error' listener 内 throw 会经 Node 内部
|
|
1003
|
+
// emit() 传播为 uncaughtException 崩主进程,违背 A-1 防崩核心目标。达阈值的 throw 留给
|
|
1004
|
+
// 同步路径(deliverMessage catch 合并计数达 EPIPE_FAILURE_THRESHOLD 时同步 throw,不崩)。
|
|
1005
|
+
// async handler 只移句柄 + 计数 + warn;进程已 dead(移句柄),下次 deliverMessage 检测
|
|
1006
|
+
// dead 走冷路径,冷路径 write EPIPE 同步计数达阈值同步 throw——防死循环且不崩。
|
|
1007
|
+
child.stdin.on("error", (err: Error) => {
|
|
1008
|
+
// [M4] 按值守卫:resume spawn 已覆盖注册时不误删新 child(见 removeChildRegistration)
|
|
1009
|
+
removeChildRegistration(record.id, child);
|
|
1010
|
+
const count = recordEpipeFailure(record.id);
|
|
1011
|
+
logger.warn(`[subagents] async stdin error for ${record.id}`, {
|
|
1012
|
+
detail: err.message,
|
|
1013
|
+
epipeCount: count,
|
|
1014
|
+
threshold: EPIPE_FAILURE_THRESHOLD,
|
|
1015
|
+
hint:
|
|
1016
|
+
count >= EPIPE_FAILURE_THRESHOLD
|
|
1017
|
+
? "sync path will throw on next write EPIPE"
|
|
1018
|
+
: undefined,
|
|
1019
|
+
});
|
|
1020
|
+
});
|
|
1021
|
+
|
|
794
1022
|
// 喂 prompt 命令驱动子进程开始处理 task。pi runRpcMode 只消费 stdin RpcCommand,
|
|
795
1023
|
// 不读 positional arg;必须在 spawn 后主动写,否则子进程阻塞、totalTokens 恒 0。
|
|
796
1024
|
// 时机安全:pipe 内核缓冲不丢;pi 在 rebindSession 后才挂 stdin reader。
|
|
@@ -886,7 +1114,9 @@ export async function runSpawn(
|
|
|
886
1114
|
// create 时 pid 未知写 0 占位,此处拿到 child.pid 后回调 WorktreeManager.registerPid。
|
|
887
1115
|
// 取代旧的 .session mapping sidecar——注册表是 reaper 的唯一数据源。
|
|
888
1116
|
if (opts.worktree && child.pid) {
|
|
889
|
-
|
|
1117
|
+
// 透传 record.sessionFile:填入 registry entry(reaper 据 pid 死活判孤儿),
|
|
1118
|
+
// first header 时 sessionFile 已回填(deriveSessionFilePath 在本分支上方)。
|
|
1119
|
+
ctx.onWorktreePid?.(opts.worktree.branch, child.pid, record.sessionFile);
|
|
890
1120
|
}
|
|
891
1121
|
// FR-4 加速路径:header 到达即 finishHandshake(header 已提供 sessionId,
|
|
892
1122
|
// 足以推导 sessionFile + 兜底查找,无需等 get_state response)。
|
|
@@ -907,6 +1137,14 @@ export async function runSpawn(
|
|
|
907
1137
|
if (evt.willRetry) {
|
|
908
1138
|
// agent 会重试,不能 kill。
|
|
909
1139
|
} else {
|
|
1140
|
+
// [V2 决策 1] chatMode:对话模式进程不因轮次死。agent_end 不 kill、不 MF-3/MF-4。
|
|
1141
|
+
// 等待 agent_settled(真空闲信号)arm idle timer + notify(onRoundSettled)。
|
|
1142
|
+
// 用 continue 而非 return:return 会跳出 stdout data handler 的 for(line) 循环,
|
|
1143
|
+
// 丢弃同一 flush 内 agent_end 之后的事件(如紧随的 agent_settled)。continue 只
|
|
1144
|
+
// 跳过当前行剩余(handleSdkEvent 对 agent_end 是 no-op),继续处理后续行。
|
|
1145
|
+
if (record.chatMode) {
|
|
1146
|
+
continue;
|
|
1147
|
+
}
|
|
910
1148
|
// [recursive-orchestration] 条件 kill:读子进程 session 文件算活跃后代
|
|
911
1149
|
// (pending:register − unregister 差集)。有活跃后代(background subagent /
|
|
912
1150
|
// workflow)→ 保持进程 idle,等后代完成时 notifier triggerTurn steer 唤醒;
|
|
@@ -990,9 +1228,13 @@ export async function runSpawn(
|
|
|
990
1228
|
|
|
991
1229
|
// 等待子进程退出
|
|
992
1230
|
const exitCode = await new Promise<number>((resolve) => {
|
|
1231
|
+
// [V2 决策 2] resolveRun 指向本 promise 的 resolve:chatMode 首轮 agent_settled 时
|
|
1232
|
+
// 由 handleSdkEvent 提前调 resolveRun(0),close 最终到达时 resolve(code) 是 no-op。
|
|
1233
|
+
resolveRun = resolve;
|
|
993
1234
|
child.on("close", async (code: number | null) => {
|
|
994
|
-
// [C1] 子进程已退出,从 orphan-tracking
|
|
995
|
-
|
|
1235
|
+
// [C1] 子进程已退出,从 orphan-tracking Map 移除(dispose 兜底无需再 kill 它)。
|
|
1236
|
+
// [M4] 按值守卫:close 事件晚于 resume spawn 到达时不误删新 child 注册。
|
|
1237
|
+
removeChildRegistration(record.id, child);
|
|
996
1238
|
// FR-4: 清理 get_state 监听器(子进程已退出,无更多 response)
|
|
997
1239
|
get_stateListeners.clear();
|
|
998
1240
|
// FR-4: 子进程已退出,get_state response 不会再来。若握手仍未 settle,立即放弃
|
|
@@ -1017,9 +1259,10 @@ export async function runSpawn(
|
|
|
1017
1259
|
// [worktree-reaper-fix] 拼 spawnCwd 进错误消息:ENOENT 的 err.message 只含 command 名,
|
|
1018
1260
|
// 无 cwd 线索(worktree 被 reaper 误删后 cwd 指向虚空)会导致误诊——2026-08-11 事故
|
|
1019
1261
|
// AI 误判"node 被卸载"的直接原因。
|
|
1262
|
+
// [M4] 按值守卫:error 事件晚于 resume spawn 到达时不误删新 child 注册。
|
|
1263
|
+
removeChildRegistration(record.id, child);
|
|
1020
1264
|
// [S3] code 读取带运行时 guard:非 ErrnoException(普通 Error)时 code 为 undefined,
|
|
1021
1265
|
// 不加 cwd hint(行为与修复前一致);仅 ENOENT 才拼 cwd。
|
|
1022
|
-
spawnedChildren.delete(child);
|
|
1023
1266
|
const errno = err as NodeJS.ErrnoException;
|
|
1024
1267
|
const errCode = "code" in err ? errno.code : undefined;
|
|
1025
1268
|
const cwdHint = errCode === "ENOENT" ? ` (cwd: ${spawnCwd})` : "";
|
|
@@ -1031,10 +1274,10 @@ export async function runSpawn(
|
|
|
1031
1274
|
opts.signal?.removeEventListener("abort", onAbort);
|
|
1032
1275
|
clearTimeout(watchdog);
|
|
1033
1276
|
|
|
1034
|
-
// [持久化 A] sessionFile
|
|
1035
|
-
//
|
|
1036
|
-
//
|
|
1037
|
-
//
|
|
1277
|
+
// [持久化 A] sessionFile 兜底校验。
|
|
1278
|
+
// identity custom entry 已改由子进程 session_start hook 写(M4 / V2 决策 5),
|
|
1279
|
+
// 父进程不再 fs 补写——fs 补写的 entry 缺 id/parentId 污染 pi _buildIndex。
|
|
1280
|
+
// 此处仅保留 sessionFile 路径兜底(deriveSessionFilePath/握手路径可能不准)。
|
|
1038
1281
|
if (record.sessionFile) {
|
|
1039
1282
|
// 兜底:deriveSessionFilePath 推导或握手返回的路径可能不存在(pi 命名规则变化),
|
|
1040
1283
|
// 用 sessionId 后缀匹配实际文件。匹配到则修正 record.sessionFile。
|
|
@@ -1046,34 +1289,6 @@ export async function runSpawn(
|
|
|
1046
1289
|
if (actual) record.sessionFile = actual;
|
|
1047
1290
|
}
|
|
1048
1291
|
}
|
|
1049
|
-
// 补写 identity custom entry(子进程已退出,append 安全)。
|
|
1050
|
-
if (fs.existsSync(record.sessionFile)) {
|
|
1051
|
-
const identity: SubagentIdentityData = {
|
|
1052
|
-
id: record.id,
|
|
1053
|
-
agent: record.agent,
|
|
1054
|
-
mode: record.mode,
|
|
1055
|
-
task: record.task,
|
|
1056
|
-
slug: record.slug,
|
|
1057
|
-
startedAt: record.startedAt,
|
|
1058
|
-
rootSessionId: record.rootSessionId,
|
|
1059
|
-
parentRecordId: record.parentRecordId,
|
|
1060
|
-
depth: record.depth,
|
|
1061
|
-
forkDepth: opts.fork ? (opts.parentForkDepth ?? 0) + 1 : undefined,
|
|
1062
|
-
};
|
|
1063
|
-
try {
|
|
1064
|
-
fs.appendFileSync(
|
|
1065
|
-
record.sessionFile,
|
|
1066
|
-
`${JSON.stringify({ type: "custom", customType: IDENTITY_CUSTOM_TYPE, data: identity })}\n`,
|
|
1067
|
-
"utf-8",
|
|
1068
|
-
);
|
|
1069
|
-
} catch (err) {
|
|
1070
|
-
// best-effort:identity 写入失败不影响执行结果,但会影响 /subagents list 重建。
|
|
1071
|
-
// 记录到 logger(非阻断)—— 终态 record 会从 list 消失,这是可观测的退化信号。
|
|
1072
|
-
logger.error(`[subagents] identity append failed for ${record.sessionFile}`, {
|
|
1073
|
-
detail: err instanceof Error ? err.message : String(err),
|
|
1074
|
-
});
|
|
1075
|
-
}
|
|
1076
|
-
}
|
|
1077
1292
|
}
|
|
1078
1293
|
|
|
1079
1294
|
// 判定成功/失败(三来源:exitCode + record.lastError + abort 原因)
|