@zhushanwen/pi-subagent-workflow 8.0.0 → 8.1.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/package.json +7 -4
- package/scripts/rfl.mjs +308 -0
- package/src/execution/__tests__/finalize-record.test.ts +33 -7
- package/src/execution/__tests__/helpers/spawn-mock.ts +12 -3
- package/src/execution/__tests__/record-store.test.ts +284 -2
- package/src/execution/__tests__/spawn-args.test.ts +5 -5
- package/src/execution/__tests__/subagent-service-message-close.test.ts +31 -0
- package/src/execution/__tests__/worktree-manager.test.ts +9 -2
- package/src/execution/__tests__/worktree-pid-registration.integration.test.ts +4 -1
- package/src/execution/__tests__/worktree-reconcile.integration.test.ts +181 -0
- package/src/execution/__tests__/worktree-registry.test.ts +72 -34
- package/src/execution/agent-result-mapper.ts +4 -1
- package/src/execution/argv-mirror.ts +1 -1
- package/src/execution/channel-registry-access.ts +3 -1
- package/src/execution/finalize-record.ts +16 -7
- package/src/execution/idle-gc.ts +47 -0
- package/src/execution/lifecycle-manager.ts +9 -2
- package/src/execution/record-entry.ts +118 -0
- package/src/execution/record-store.ts +290 -1
- package/src/execution/session-pending.ts +5 -4
- package/src/execution/session-runner.ts +484 -289
- package/src/execution/subagent-service.ts +87 -37
- package/src/execution/temp-prompt.ts +8 -3
- package/src/execution/types.ts +20 -2
- package/src/execution/worktree-manager.ts +325 -14
- package/src/execution/worktree-registry.ts +90 -33
- package/src/index.ts +42 -2
- package/src/interface/__tests__/tool-workflow-script-generate.test.ts +103 -26
- package/src/interface/__tests__/tool-workflow-throw-paths.test.ts +179 -0
- package/src/interface/format.ts +10 -4
- package/src/interface/tool-render.ts +10 -3
- package/src/interface/tool-workflow-script.ts +29 -33
- package/src/interface/tool-workflow.ts +47 -65
- package/src/interface/views/detail-content.ts +1 -1
- package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +9 -1
- package/src/orchestration/__tests__/execute-agent-call.test.ts +49 -1
- package/src/orchestration/__tests__/jsonl-run-store-loadall-sources.test.ts +171 -0
- package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +181 -19
- package/src/orchestration/__tests__/lifecycle-runid-injection.test.ts +96 -0
- package/src/orchestration/__tests__/review-fix-loop-e2e.test.ts +1108 -19
- package/src/orchestration/__tests__/test-mocks.ts +9 -3
- package/src/orchestration/__tests__/worker-returnmeta-passthrough.test.ts +164 -0
- package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +12 -0
- package/src/orchestration/__tests__/workflows-e2e.test.ts +37 -39
- package/src/orchestration/error-recovery.ts +4 -1
- package/src/orchestration/execute-agent-call.ts +13 -3
- package/src/orchestration/jsonl-run-store.ts +139 -60
- package/src/orchestration/lifecycle.ts +10 -1
- package/src/orchestration/worker-script-builder.ts +9 -1
- package/src/shared/__tests__/resource-discovery.test.ts +24 -0
- package/src/shared/agent-ref.ts +6 -1
- package/src/shared/resource-discovery.ts +15 -1
- package/src/shared/schema-jsonify.ts +4 -1
- package/workflows/review-fix-loop-utils.cjs +542 -32
- package/workflows/review-fix-loop.js +462 -109
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
// spawn 改造后:session 在独立子进程跑(进程隔离),事件经 stdout JSON 流回流。
|
|
6
6
|
// runSpawn 是唯一执行入口(sync/background 共用)。mode 分叉在 Runtime.execute 顶部。
|
|
7
7
|
|
|
8
|
-
import { type ChildProcess, execFile, spawn } from "node:child_process";
|
|
8
|
+
import { type ChildProcess, type ChildProcessWithoutNullStreams, execFile, spawn } from "node:child_process";
|
|
9
9
|
import * as fs from "node:fs";
|
|
10
10
|
|
|
11
11
|
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
12
|
+
import { bestEffort } from "./best-effort.ts";
|
|
12
13
|
import { armIdleTimer } from "./lifecycle-manager.ts";
|
|
13
14
|
import { readActivePendingFromSessionFile } from "./session-pending.ts";
|
|
14
15
|
|
|
@@ -110,19 +111,25 @@ export function mapAssistantMessageDelta(
|
|
|
110
111
|
// 常量
|
|
111
112
|
// ============================================================
|
|
112
113
|
|
|
114
|
+
/** 时间单位换算常量(命名后供 watchdog 系列常量组合,消除裸乘法字面量)。 */
|
|
115
|
+
const MS_PER_SECOND = 1000;
|
|
116
|
+
const SECONDS_PER_MINUTE = 60;
|
|
117
|
+
|
|
113
118
|
/** 默认 grace turns(soft limit 后宽限轮数,对齐旧实现 DEFAULT_GRACE_TURNS)。 */
|
|
114
119
|
const DEFAULT_GRACE_TURNS = 2;
|
|
115
120
|
|
|
116
|
-
/** watchdog
|
|
121
|
+
/** watchdog 下限:30 分钟。兜底防止子进程卡死在单个 tool 内(hang 的 bash/网络读),
|
|
117
122
|
* 导致 turn_end 永不触发、maxTurns limiter 失效、background 槽位/worktree/alive marker 泄漏。
|
|
118
123
|
* [M-1] 旧实现固定 30 分钟,与 maxTurns 无关——maxTurns=100 的长任务会被误杀。
|
|
119
124
|
* 现改为基于 maxTurns 动态计算(见 computeWatchdogMs)。 */
|
|
120
|
-
const
|
|
125
|
+
const WATCHDOG_FLOOR_MINUTES = 30;
|
|
126
|
+
const SPAWN_WATCHDOG_FLOOR_MS = WATCHDOG_FLOOR_MINUTES * SECONDS_PER_MINUTE * MS_PER_SECOND;
|
|
121
127
|
|
|
122
|
-
/** [M-1] 单 turn
|
|
128
|
+
/** [M-1] 单 turn 估算耗时:5 分钟(含 LLM 响应 + tool 执行)。
|
|
123
129
|
* 5 分钟是经验值——复杂 tool(大文件读写/长 bash)+ 长 LLM 响应约 3-4 分钟,
|
|
124
130
|
* 留 1-2 分钟余量。下限与按 turn 计算取 max,避免 maxTurns 过小时 watchdog 紧到误杀。 */
|
|
125
|
-
const
|
|
131
|
+
const WATCHDOG_MINUTES_PER_TURN = 5;
|
|
132
|
+
const WATCHDOG_MS_PER_TURN = WATCHDOG_MINUTES_PER_TURN * SECONDS_PER_MINUTE * MS_PER_SECOND;
|
|
126
133
|
|
|
127
134
|
// [recursive-orchestration] agent_end keep-alive 的两类等待超时(不 kill 分支的兑底)。
|
|
128
135
|
// 层主 subagent 空闲等待后代完成(steer 唤醒)期间不产生 turn,原 watchdog 已清;
|
|
@@ -157,13 +164,17 @@ export const WAKEUP_GRACE_MS = 15_000;
|
|
|
157
164
|
*
|
|
158
165
|
* @param maxTurns 调用方指定的 turn 上限;undefined/null/0 视为默认 10 turns
|
|
159
166
|
*/
|
|
167
|
+
/** maxTurns 缺省(undefined/null/0)时的估算 turn 数(computeWatchdogMs 兜底)。 */
|
|
168
|
+
const DEFAULT_MAX_TURNS_ESTIMATE = 10;
|
|
169
|
+
|
|
160
170
|
export function computeWatchdogMs(maxTurns: number | undefined | null): number {
|
|
161
|
-
const effectiveTurns = maxTurns && maxTurns > 0 ? maxTurns :
|
|
171
|
+
const effectiveTurns = maxTurns && maxTurns > 0 ? maxTurns : DEFAULT_MAX_TURNS_ESTIMATE;
|
|
162
172
|
return Math.max(SPAWN_WATCHDOG_FLOOR_MS, effectiveTurns * WATCHDOG_MS_PER_TURN);
|
|
163
173
|
}
|
|
164
174
|
|
|
165
|
-
/** stderr
|
|
166
|
-
|
|
175
|
+
/** stderr 累积上限——按字符截断(.slice 语义),非字节;64K 规模沿自原实现。
|
|
176
|
+
* 防止失控子进程打满父进程内存。保留尾部便于诊断。 */
|
|
177
|
+
const STDERR_MAX_CHARS = 65_536;
|
|
167
178
|
|
|
168
179
|
/**
|
|
169
180
|
* 跨包契约 env 名:workflow 子进程把权威 JSON Schema 通过此 env 传给 structured-output 扩展。
|
|
@@ -275,8 +286,14 @@ export function killAllSpawnedChildren(signal: NodeJS.Signals = "SIGTERM"): numb
|
|
|
275
286
|
try {
|
|
276
287
|
child.kill(signal);
|
|
277
288
|
n++;
|
|
278
|
-
} catch {
|
|
279
|
-
// best-effort:单个 kill
|
|
289
|
+
} catch (err) {
|
|
290
|
+
// best-effort:单个 kill 失败不影响其他子进程(常见于进程刚退出、句柄竞态),
|
|
291
|
+
// debug 级留诊断线索即可,不刷 info/warn
|
|
292
|
+
logger.debug(
|
|
293
|
+
`[session-runner] killAllSpawnedChildren: kill failed (best-effort continue): ${
|
|
294
|
+
err instanceof Error ? err.message : String(err)
|
|
295
|
+
}`,
|
|
296
|
+
);
|
|
280
297
|
}
|
|
281
298
|
}
|
|
282
299
|
// dispose 全量清理;正常路径的 close/error 事件 delete 保留作 per-child 精细清理,
|
|
@@ -338,8 +355,10 @@ export interface SessionRunnerContext {
|
|
|
338
355
|
* worktree 子进程 pid 就绪回调(first header 时触发)。
|
|
339
356
|
* Runtime 层接线为 WorktreeManager.registerPid,用于注册表补全 pid。
|
|
340
357
|
* 解耦 Core 与 Runtime——session-runner 不直接依赖 WorktreeManager。
|
|
358
|
+
* [D5a] 返回 Promise(注册表 pid 补全走跨进程锁内 RMW);实现方保证不 reject
|
|
359
|
+
* (WorktreeRegistry.mutate 内部降级兜底),本侧 fire-and-forget 安全。
|
|
341
360
|
*/
|
|
342
|
-
onWorktreePid?: (branch: string, pid: number, sessionFile?: string) => void
|
|
361
|
+
onWorktreePid?: (branch: string, pid: number, sessionFile?: string) => void | Promise<void>;
|
|
343
362
|
/**
|
|
344
363
|
* UI 请求处理回调。子进程发 extension_ui_request 时调用。
|
|
345
364
|
*
|
|
@@ -663,7 +682,7 @@ export function buildSpawnArgs(
|
|
|
663
682
|
if (mf) {
|
|
664
683
|
if (mf.noExtensions) args.push("--no-extensions");
|
|
665
684
|
if (mf.approve) args.push("--approve");
|
|
666
|
-
// 镜像 --no-context-files
|
|
685
|
+
// 镜像 --no-context-files:@zhushanwen/pi-system-prompt(经 --extension 镜像进入
|
|
667
686
|
// 子进程)靠子进程 argv 检测此 flag 守卫全局 AGENTS.md 注入——不镜像则用户的
|
|
668
687
|
// context files opt-out 只对主进程生效,每个 subagent 仍被注入。
|
|
669
688
|
if (mf.noContextFiles) args.push("--no-context-files");
|
|
@@ -675,27 +694,69 @@ export function buildSpawnArgs(
|
|
|
675
694
|
}
|
|
676
695
|
|
|
677
696
|
/**
|
|
678
|
-
*
|
|
697
|
+
* [持久化 C] best-effort 写 alive marker(running 期间崩溃恢复用,子进程 pid + session id)。
|
|
679
698
|
*
|
|
680
|
-
*
|
|
681
|
-
*
|
|
699
|
+
* 两处调用点(header 分支 / get_state 握手回填)原为两段相同的空 catch try/catch——
|
|
700
|
+
* 收敛为单函数后失败走 debug 日志(marker 是崩溃恢复的增强信号,缺失只降低可恢复性,
|
|
701
|
+
* 不影响执行主流程),消除 taste/no-silent-catch。
|
|
682
702
|
*/
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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;
|
|
703
|
+
function writeAliveMarkerBestEffort(sessionFile: string, pid: number, id: string): void {
|
|
704
|
+
try {
|
|
705
|
+
writeAliveMarker(sessionFile, { pid, id, startedAt: Date.now() });
|
|
706
|
+
} catch (err) {
|
|
707
|
+
// best-effort:alive marker 失败不影响执行;debug 级留诊断线索即可,不刷 info/warn
|
|
708
|
+
logger.debug(
|
|
709
|
+
`[session-runner] alive marker write failed (best-effort continue): ${
|
|
710
|
+
err instanceof Error ? err.message : String(err)
|
|
711
|
+
}`,
|
|
712
|
+
);
|
|
698
713
|
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// ============================================================
|
|
717
|
+
// [SPAWN 改造] runSpawn 的阶段拆分(max-lines-per-function 383 > 300)
|
|
718
|
+
// ============================================================
|
|
719
|
+
//
|
|
720
|
+
// runSpawn 原为单函数内联全部闭包(事件累积器 / 参数准备 / stdout pump / 退出等待),
|
|
721
|
+
// 按执行阶段拆为下方模块级私有函数,闭包状态收拢进 SpawnRunState 经参数传递:
|
|
722
|
+
// - createSpawnEventHandlers:a/b. pendingTools 寄存器 + turnLimiter + handleSdkEvent
|
|
723
|
+
// - writeAppendSystemPromptFile:g. appendSystemPrompt 片段组装与落盘
|
|
724
|
+
// - buildChildEnv:h. 子进程环境变量组装
|
|
725
|
+
// - buildSpawnInvocation:i. pi CLI 参数组装与入口解析
|
|
726
|
+
// - attachStdoutPump:stdout 逐行解析 + get_state 握手状态机 + agent_end keep-alive
|
|
727
|
+
// - waitForChildExit:close/error → exitCode(含统一 cleanup)
|
|
728
|
+
// 拆分只移动代码不改行为——runSpawn 导出签名与事件语义不变。
|
|
729
|
+
|
|
730
|
+
/** runSpawn 各阶段共享的可变状态(原闭包变量收拢,经参数在阶段函数间传递)。 */
|
|
731
|
+
interface SpawnRunState {
|
|
732
|
+
record: ExecutionRecord;
|
|
733
|
+
opts: RunOptions;
|
|
734
|
+
ctx: SessionRunnerContext;
|
|
735
|
+
/** 当前子进程句柄(limiter abort 经此 kill)。 */
|
|
736
|
+
proc: ChildProcess | undefined;
|
|
737
|
+
/** watchdog timer(stdout handler 的 agent_end keep-alive 分支重挂,收尾统一 clearTimeout)。 */
|
|
738
|
+
watchdog: NodeJS.Timeout | undefined;
|
|
739
|
+
/** stdout 首行 header(json mode 才有;RPC mode 恒 undefined)——收尾 sessionFile 兜底查找用。 */
|
|
740
|
+
sessionHeader: SpawnSessionHeader | undefined;
|
|
741
|
+
/** get_state 握手结果(RPC mode)——收尾 sessionFile 兜底查找用。 */
|
|
742
|
+
handshakeResult: GetStateResult | undefined;
|
|
743
|
+
/**
|
|
744
|
+
* [V2 决策 2] chatMode 首轮 resolveRun:agent_settled 时提前 resolve runSpawn 的
|
|
745
|
+
* exitCode promise(waitForChildExit 装配指向)。非 chatMode 不触发(agent_end
|
|
746
|
+
* handler 一次性 kill → close resolve)。chatMode 后续轮次到达时 resolve(code)
|
|
747
|
+
* 是 no-op(Promise 只 resolve 一次)。
|
|
748
|
+
*/
|
|
749
|
+
resolveRun: ((code: number) => void) | undefined;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* 事件累积器工厂(原 runSpawn 内联的 a/b 两段 + handleSdkEvent/agentEvent 闭包)。
|
|
754
|
+
*
|
|
755
|
+
* pendingTools 寄存器 / turnLimiter / accumulateMessageEnd 均闭包在工厂内部,
|
|
756
|
+
* 对外只暴露 handleSdkEvent(stdout 解析出的 SdkEvent 的唯一喂入口)。
|
|
757
|
+
*/
|
|
758
|
+
function createSpawnEventHandlers(state: SpawnRunState): (raw: SdkEvent) => void {
|
|
759
|
+
const { record, opts, ctx } = state;
|
|
699
760
|
|
|
700
761
|
// a. transient 寄存器(同 run():tool_end 缺 args 时回填)
|
|
701
762
|
const pendingTools = new Map<string, { toolName: string; args?: unknown }>();
|
|
@@ -703,8 +764,7 @@ export async function runSpawn(
|
|
|
703
764
|
// b. turnLimiter(spawn 版:abort = proc.kill;steer 是 no-op)
|
|
704
765
|
// [M1] rpc mode 是长驻进程(agent_end 后不自动退出),maxTurns soft limit 依赖
|
|
705
766
|
// graceTurns 后的 abort(proc.kill SIGTERM)兑现。agent 自然结束时由 stdout pump
|
|
706
|
-
// 的 agent_end 拦截 kill
|
|
707
|
-
let proc: ChildProcess | undefined;
|
|
767
|
+
// 的 agent_end 拦截 kill(见 attachStdoutPump)。steer 通道当前未接通(见下方 steer no-op 注释)。
|
|
708
768
|
const limiter = createTurnLimiter({
|
|
709
769
|
maxTurns: opts.maxTurns ?? 0,
|
|
710
770
|
graceTurns: opts.graceTurns ?? DEFAULT_GRACE_TURNS,
|
|
@@ -713,13 +773,20 @@ export async function runSpawn(
|
|
|
713
773
|
// 但未实现写入逻辑)。补偿已在启动时注入 WRAP_UP_HINT 让 agent 主动收尾。
|
|
714
774
|
},
|
|
715
775
|
abort: () => {
|
|
716
|
-
proc?.kill("SIGTERM");
|
|
776
|
+
state.proc?.kill("SIGTERM");
|
|
717
777
|
},
|
|
718
778
|
});
|
|
719
779
|
|
|
720
|
-
//
|
|
721
|
-
const
|
|
722
|
-
|
|
780
|
+
// agentEvent 统一出口:updateFromEvent + onTurnEnd(limiter)+ opts.onEvent
|
|
781
|
+
const agentEvent = (event: AgentEvent): void => {
|
|
782
|
+
updateFromEvent(record, event);
|
|
783
|
+
if (event.type === "turn_end") limiter.onTurnEnd(record.turnCount);
|
|
784
|
+
// text_delta 分流到 stream 通道(在 onEvent 之前)。
|
|
785
|
+
// 双通道互斥设计:background 路径 stream 有值、onEvent=undefined;
|
|
786
|
+
// workflow 路径 onEvent 有值、stream=undefined。详见 W3 注释。
|
|
787
|
+
if (event.type === "text_delta") opts.stream?.onDelta(event.delta);
|
|
788
|
+
opts.onEvent?.(event);
|
|
789
|
+
};
|
|
723
790
|
|
|
724
791
|
// ── SDK 事件累积器(闭包模式与 run() 完全相同)──
|
|
725
792
|
const accumulateMessageEnd = (raw: SdkEvent): void => {
|
|
@@ -736,17 +803,9 @@ export async function runSpawn(
|
|
|
736
803
|
}
|
|
737
804
|
};
|
|
738
805
|
|
|
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
|
-
|
|
747
806
|
const handleSdkEvent = (raw: SdkEvent): void => {
|
|
748
807
|
// [V2 模块 3] agent_settled:真空闲边界(agent_end 之后、post-run 完成后才 emit)。
|
|
749
|
-
// chatMode:arm idle timer(超时 SIGTERM 回收)+ 通知本轮完成(onRoundSettled
|
|
808
|
+
// chatMode:arm idle timer(超时 SIGTERM 回收)+ 通知本轮完成(onRoundSettled)。
|
|
750
809
|
// 非 chatMode:忽略(agent_end handler 的一次性 kill 已处理,进程不会活到 agent_settled)。
|
|
751
810
|
if (isAgentSettledEvt(raw)) {
|
|
752
811
|
if (record.chatMode) {
|
|
@@ -766,7 +825,7 @@ export async function runSpawn(
|
|
|
766
825
|
// [V2 决策 2] chatMode 首轮:agent_settled = 本轮真空闲,提前 resolve runSpawn
|
|
767
826
|
//(exit code 0,进程仍保活 idle timer armed)。runAndFinalize 拿到 result 后走
|
|
768
827
|
// chatMode 首轮分支(不进 finalize 分流),onRoundSettled 已 notify 主 agent。
|
|
769
|
-
resolveRun?.(0);
|
|
828
|
+
state.resolveRun?.(0);
|
|
770
829
|
}
|
|
771
830
|
return;
|
|
772
831
|
}
|
|
@@ -818,37 +877,22 @@ export async function runSpawn(
|
|
|
818
877
|
}
|
|
819
878
|
};
|
|
820
879
|
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
updateFromEvent(record, event);
|
|
824
|
-
if (event.type === "turn_end") limiter.onTurnEnd(record.turnCount);
|
|
825
|
-
// text_delta 分流到 stream 通道(在 onEvent 之前)。
|
|
826
|
-
// 双通道互斥设计:background 路径 stream 有值、onEvent=undefined;
|
|
827
|
-
// workflow 路径 onEvent 有值、stream=undefined。详见 W3 注释。
|
|
828
|
-
if (event.type === "text_delta") opts.stream?.onDelta(event.delta);
|
|
829
|
-
opts.onEvent?.(event);
|
|
830
|
-
};
|
|
831
|
-
|
|
832
|
-
// d. session 目录(与 in-process 一致:list/恢复可发现同一目录)
|
|
833
|
-
// [MF-3] 用 ctx.rootCwd(贯穿真 ROOT)而非 ctx.mainCwd 编码:worktree 模式下 mainCwd 是
|
|
834
|
-
// 子进程的 checkout 路径,按它编码会让深层 record 落到 enc(worktree) 段,ROOT 磁盘重建
|
|
835
|
-
// 扫不到 → 全树可见性深度 ≥ 2 断裂。rootCwd 与 store 构造同源(subagent-service 同键),
|
|
836
|
-
// 保证 runSpawn 写入的 session 文件就在本进程/ROOT store 扫描的目录里。
|
|
837
|
-
const sessionDir = getSubagentSessionDir(ctx.agentDir, ctx.rootCwd);
|
|
838
|
-
fs.mkdirSync(sessionDir, { recursive: true });
|
|
839
|
-
|
|
840
|
-
// e. worktree 模式:checkout 路径作为 spawn cwd(隔离文件系统)
|
|
841
|
-
// worktree checkout 已由 worktree-manager 在 execute 前创建,此处只取路径。
|
|
842
|
-
const spawnCwd = opts.worktree?.path ?? ctx.cwd;
|
|
843
|
-
|
|
844
|
-
// f. fork source:父 session 文件路径(--fork 参数)
|
|
845
|
-
const forkSource = opts.fork ? ctx.mainSessionFile : undefined;
|
|
880
|
+
return handleSdkEvent;
|
|
881
|
+
}
|
|
846
882
|
|
|
847
|
-
|
|
883
|
+
/**
|
|
884
|
+
* g. appendSystemPrompt 落盘:env block + agent body + 调用方片段拼成
|
|
885
|
+
* --append-system-prompt 文件。返回临时文件句柄(无片段时 undefined,
|
|
886
|
+
* runSpawn finally 统一清理)。
|
|
887
|
+
*/
|
|
888
|
+
async function writeAppendSystemPromptFile(
|
|
889
|
+
record: ExecutionRecord,
|
|
890
|
+
opts: RunOptions,
|
|
891
|
+
ctx: SessionRunnerContext,
|
|
892
|
+
): Promise<{ dir: string; filePath: string } | undefined> {
|
|
848
893
|
// [M1 恢复] 环境块(cwd / fork depth / git branch)拼在最前面,与旧 in-process
|
|
849
894
|
// buildAppendSystemPrompt 顺序一致——parts[0] 是环境块,其后 agent systemPrompt、再后调用方片段。
|
|
850
895
|
const ownForkDepth = opts.fork ? (opts.parentForkDepth ?? 0) + 1 : undefined;
|
|
851
|
-
let tempPromptFile: { dir: string; filePath: string } | undefined;
|
|
852
896
|
// [M9] buildEnvBlock 取 max(forkDepth, nestingDepth):record.depth === nestingDepth(都从
|
|
853
897
|
// execCtxAls 派生,见 createRecordForMode L425-427 与 execute L257-258),传它让 env block
|
|
854
898
|
// 展示更严的约束(混合嵌套链下通用护栏可能先于 fork 护栏拒绝)。
|
|
@@ -869,10 +913,20 @@ export async function runSpawn(
|
|
|
869
913
|
appendParts.push(WORKTREE_GUIDANCE_PROMPT);
|
|
870
914
|
}
|
|
871
915
|
if (appendParts.length > 0) {
|
|
872
|
-
|
|
916
|
+
return writePromptToTempFile(record.agent, appendParts.join("\n\n"));
|
|
873
917
|
}
|
|
918
|
+
return undefined;
|
|
919
|
+
}
|
|
874
920
|
|
|
875
|
-
|
|
921
|
+
/**
|
|
922
|
+
* h. 子进程环境变量组装:继承 process.env + fork depth + 跨进程身份贯穿 4 元组 +
|
|
923
|
+
* identity 专属字段 + worktree 隔离标志 + schemaEnv bridge。
|
|
924
|
+
*/
|
|
925
|
+
function buildChildEnv(
|
|
926
|
+
record: ExecutionRecord,
|
|
927
|
+
opts: RunOptions,
|
|
928
|
+
ctx: SessionRunnerContext,
|
|
929
|
+
): Record<string, string | undefined> {
|
|
876
930
|
const childEnv: Record<string, string | undefined> = { ...process.env };
|
|
877
931
|
if (opts.fork && opts.parentForkDepth !== undefined) {
|
|
878
932
|
childEnv.PI_SUBAGENT_FORK_DEPTH = String(opts.parentForkDepth + 1);
|
|
@@ -911,8 +965,20 @@ export async function runSpawn(
|
|
|
911
965
|
childEnv.PI_SUBAGENT_WORKTREE = opts.worktree !== undefined ? "true" : undefined;
|
|
912
966
|
// D-A6 bridge: schema 激活 structured-output 扩展注册 tool(workflow 编排层需要)
|
|
913
967
|
applySchemaEnvToChildEnv(childEnv, opts.schemaEnv);
|
|
968
|
+
return childEnv;
|
|
969
|
+
}
|
|
914
970
|
|
|
915
|
-
|
|
971
|
+
/**
|
|
972
|
+
* i. 组装 spawn args 并解析 pi 可执行入口。
|
|
973
|
+
*/
|
|
974
|
+
function buildSpawnInvocation(
|
|
975
|
+
opts: RunOptions,
|
|
976
|
+
ctx: SessionRunnerContext,
|
|
977
|
+
resume: SpawnResumeOpts | undefined,
|
|
978
|
+
tempPromptFile: { dir: string; filePath: string } | undefined,
|
|
979
|
+
sessionDir: string,
|
|
980
|
+
forkSource: string | undefined,
|
|
981
|
+
): ReturnType<typeof getPiInvocation> {
|
|
916
982
|
// [M3 恢复] skillPaths: 主 session 的 skillDirs + 调用方传入的 skillPath。
|
|
917
983
|
// ADR-031 后 skillDirs 固定为空,仅 opts.skillPath 生效(agent({skill}) 解析)。
|
|
918
984
|
const skillPaths = [...ctx.skillDirs, opts.skillPath].filter(
|
|
@@ -937,10 +1003,332 @@ export async function runSpawn(
|
|
|
937
1003
|
mirrorFlags: mirrorMainProcessFlags(process.argv),
|
|
938
1004
|
},
|
|
939
1005
|
);
|
|
940
|
-
|
|
1006
|
+
return getPiInvocation(spawnArgs);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/** attachStdoutPump 返回的共享句柄(waitForChildExit / get_state 握手启动消费)。 */
|
|
1010
|
+
interface StdoutPumpHandles {
|
|
1011
|
+
/** get_state RPC response 监听器注册(performGetStateHandshake 经此挂 resolver)。 */
|
|
1012
|
+
registerGetStateListener(id: string, resolver: (data: unknown) => void): void;
|
|
1013
|
+
/** 握手完成统一入口:记录结果 + 回填 sessionFile + 写 alive marker + settle。 */
|
|
1014
|
+
finishHandshake(r: GetStateResult): void;
|
|
1015
|
+
/** 立即放弃握手(close handler 用:子进程已退出,response 不会再来)。 */
|
|
1016
|
+
abandonHandshake(): void;
|
|
1017
|
+
/** 握手是否仍未 settle(header 加速路径 / get_state then 分支的覆盖守卫)。 */
|
|
1018
|
+
isHandshakePending(): boolean;
|
|
1019
|
+
/** 握手 settle promise(close handler await,保证回填结果对后续 identity 写入可见)。 */
|
|
1020
|
+
readonly handshakeSettled: Promise<void>;
|
|
1021
|
+
/** 处理 stdout 末尾残留行(无换行结尾的最后一段,close handler 用)。 */
|
|
1022
|
+
processTrailingLine(): void;
|
|
1023
|
+
/** 清空 get_state 监听器(子进程已退出,无更多 response)。 */
|
|
1024
|
+
clearGetStateListeners(): void;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* stdout pump + get_state 握手状态机(原 runSpawn 内联的 stdout data handler 整体迁入)。
|
|
1029
|
+
*
|
|
1030
|
+
* 逐行解析 stdout:header(json mode)/ SdkEvent / RPC response / UI 请求分发;
|
|
1031
|
+
* agent_end(willRetry=false)的条件 kill(keep-alive 分支重挂 state.watchdog)也在此。
|
|
1032
|
+
* 返回 StdoutPumpHandles 供 close handler 与握手启动方消费。
|
|
1033
|
+
*/
|
|
1034
|
+
function attachStdoutPump(
|
|
1035
|
+
child: ChildProcessWithoutNullStreams,
|
|
1036
|
+
state: SpawnRunState,
|
|
1037
|
+
sessionDir: string,
|
|
1038
|
+
handleSdkEvent: (raw: SdkEvent) => void,
|
|
1039
|
+
): StdoutPumpHandles {
|
|
1040
|
+
const { record, opts, ctx } = state;
|
|
1041
|
+
|
|
1042
|
+
// stdout pump:逐行解析 → handleSdkEvent / enqueueUiRequest
|
|
1043
|
+
const enqueueUiRequest = createUiRequestQueue(child, ctx);
|
|
1044
|
+
// FR-4: get_state RPC response 监听器(id → resolver)。
|
|
1045
|
+
// parseSpawnLine 返回 kind:"response" 时,按 command+id 匹配 resolver。
|
|
1046
|
+
const get_stateListeners = new Map<string, (data: unknown) => void>();
|
|
1047
|
+
let stdoutBuffer = "";
|
|
1048
|
+
|
|
1049
|
+
// [#18] 握手状态变量在 stdout handler 注册之前定义,消除"handler 闭包依赖同 tick
|
|
1050
|
+
// 后续 const 初始化"的隐式顺序假设——handler 现在直接引用已初始化的变量,不靠
|
|
1051
|
+
// "data 事件必然在下一 tick 才触发"的运行时不变式兜底。
|
|
1052
|
+
let settleHandshake: (() => void) | undefined;
|
|
1053
|
+
const handshakeSettled: Promise<void> = new Promise((resolveSettled) => {
|
|
1054
|
+
settleHandshake = resolveSettled;
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
const settleHandshakeNow = (): void => {
|
|
1058
|
+
settleHandshake?.();
|
|
1059
|
+
settleHandshake = undefined;
|
|
1060
|
+
};
|
|
1061
|
+
|
|
1062
|
+
/** 握手完成统一入口:记录结果 + 回填 sessionFile + 写 alive marker + settle。 */
|
|
1063
|
+
const finishHandshake = (r: GetStateResult): void => {
|
|
1064
|
+
state.handshakeResult = r;
|
|
1065
|
+
// 仅当 header 未先行设置 record.sessionFile 时回填(RPC mode 路径)。
|
|
1066
|
+
if (r.sessionFile && !record.sessionFile) {
|
|
1067
|
+
record.sessionFile = r.sessionFile;
|
|
1068
|
+
if (child.pid) {
|
|
1069
|
+
writeAliveMarkerBestEffort(r.sessionFile, child.pid, r.sessionId ?? record.id);
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
settleHandshakeNow();
|
|
1073
|
+
};
|
|
1074
|
+
|
|
1075
|
+
child.stdout.on("data", (data: string) => {
|
|
1076
|
+
stdoutBuffer += data;
|
|
1077
|
+
const lines = stdoutBuffer.split("\n");
|
|
1078
|
+
stdoutBuffer = lines.pop() ?? ""; // 保留最后未完整行
|
|
1079
|
+
for (const line of lines) {
|
|
1080
|
+
const parsed = parseSpawnLine(line);
|
|
1081
|
+
if (!parsed) continue;
|
|
1082
|
+
if (parsed.kind === "header") {
|
|
1083
|
+
state.sessionHeader = parsed.header;
|
|
1084
|
+
// 回填 record.sessionFile(deriveSessionFilePath 推导路径)
|
|
1085
|
+
record.sessionFile = deriveSessionFilePath(parsed.header, sessionDir);
|
|
1086
|
+
// [持久化 C] alive marker:running 期间崩溃恢复用。子进程 pid + session id。
|
|
1087
|
+
// 与 in-process 逻辑对齐(记 sessionFile + pid),改为子进程 pid。
|
|
1088
|
+
if (record.sessionFile && child.pid) {
|
|
1089
|
+
writeAliveMarkerBestEffort(record.sessionFile, child.pid, parsed.header.id);
|
|
1090
|
+
}
|
|
1091
|
+
// [全局注册表] worktree 模式:补全注册表条目的 pid。
|
|
1092
|
+
// create 时 pid 未知写 0 占位,此处拿到 child.pid 后回调 WorktreeManager.registerPid。
|
|
1093
|
+
// 取代旧的 .session mapping sidecar——注册表是 reaper 的唯一数据源。
|
|
1094
|
+
// [D5a] fire-and-forget:回调内部走跨进程锁(毫秒级),且实现方保证不
|
|
1095
|
+
// reject(锁降级兜底);stdout data 回调是同步上下文,不 await。
|
|
1096
|
+
if (opts.worktree && child.pid) {
|
|
1097
|
+
// 透传 record.sessionFile:填入 registry entry(reaper 据 pid 死活判孤儿),
|
|
1098
|
+
// first header 时 sessionFile 已回填(deriveSessionFilePath 在本分支上方)。
|
|
1099
|
+
try {
|
|
1100
|
+
void ctx.onWorktreePid?.(opts.worktree.branch, child.pid, record.sessionFile);
|
|
1101
|
+
} catch (err) {
|
|
1102
|
+
// 同步段异常(回调本身 throw)不阻断 stdout 解析;锁内错误由回调内部 warn。
|
|
1103
|
+
bestEffort(err, "onWorktreePid callback (first header)");
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
// FR-4 加速路径:header 到达即 finishHandshake(header 已提供 sessionId,
|
|
1107
|
+
// 足以推导 sessionFile + 兜底查找,无需等 get_state response)。
|
|
1108
|
+
// [#25] buildSpawnArgs 固定 --mode rpc,RPC mode 不发 header——此分支当前不触发,
|
|
1109
|
+
// 仅为未来 mode 回切(如 json mode 调试)保留:届时 header 先到可省去 get_state 握手等待。
|
|
1110
|
+
if (settleHandshake) {
|
|
1111
|
+
finishHandshake({
|
|
1112
|
+
...(record.sessionFile ? { sessionFile: record.sessionFile } : {}),
|
|
1113
|
+
sessionId: parsed.header.id,
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
} else if (parsed.kind === "event") {
|
|
1117
|
+
const evt = parsed.event;
|
|
1118
|
+
// agent_end(willRetry=false)= agent 自然完成。rpc mode 子进程不自动退出
|
|
1119
|
+
//(runRpcMode 末尾 return new Promise(() => {}) 长驻等命令),需主动 kill
|
|
1120
|
+
// 触发 close → runSpawn resolve。willRetry=true 时 agent 会重试,不能 kill。
|
|
1121
|
+
if (isAgentEndEvt(evt)) {
|
|
1122
|
+
if (evt.willRetry) {
|
|
1123
|
+
// agent 会重试,不能 kill。
|
|
1124
|
+
} else {
|
|
1125
|
+
// [V2 决策 1] chatMode:对话模式进程不因轮次死。agent_end 不 kill、不 MF-3/MF-4。
|
|
1126
|
+
// 等待 agent_settled(真空闲信号)arm idle timer + notify(onRoundSettled)。
|
|
1127
|
+
// 用 continue 而非 return:return 会跳出 stdout data handler 的 for(line) 循环,
|
|
1128
|
+
// 丢弃同一 flush 内 agent_end 之后的事件(如紧随的 agent_settled)。continue 只
|
|
1129
|
+
// 跳过当前行剩余(handleSdkEvent 对 agent_end 是 no-op),继续处理后续行。
|
|
1130
|
+
if (record.chatMode) {
|
|
1131
|
+
continue;
|
|
1132
|
+
}
|
|
1133
|
+
// [recursive-orchestration] 条件 kill:读子进程 session 文件算活跃后代
|
|
1134
|
+
// (pending:register − unregister 差集)。有活跃后代(background subagent /
|
|
1135
|
+
// workflow)→ 保持进程 idle,等后代完成时 notifier triggerTurn steer 唤醒;
|
|
1136
|
+
// 无 → 正常完成,kill 触发 close → runSpawn resolve。
|
|
1137
|
+
const pending = readActivePendingFromSessionFile(record.sessionFile);
|
|
1138
|
+
if (pending.count > 0 || pending.error) {
|
|
1139
|
+
if (pending.error) {
|
|
1140
|
+
logger.warn(
|
|
1141
|
+
`[session-runner] agent_end: keep alive (sessionFile unreadable, conservative): ${pending.error}`,
|
|
1142
|
+
);
|
|
1143
|
+
} else {
|
|
1144
|
+
logger.debug(
|
|
1145
|
+
`[session-runner] agent_end: keep alive, ${pending.count} active descendant(s) pending`,
|
|
1146
|
+
);
|
|
1147
|
+
}
|
|
1148
|
+
// 空闲等待期间不消耗 turn:清原 watchdog,换等待后代超时(每次 agent_end 重新计时)。
|
|
1149
|
+
// [MF-4] 动态超时 = computeWatchdogMs(maxTurns):真实后代在跑,慢任务(wave 开发
|
|
1150
|
+
// 数小时)不能被固定 2h 误杀——2h 到点 kill 会连坐 SubagentService.dispose 的
|
|
1151
|
+
// killAllSpawnedChildren 杀全部子进程,L2 重派丢在途工作。maxTurns 大则超时长。
|
|
1152
|
+
clearTimeout(state.watchdog);
|
|
1153
|
+
state.watchdog = setTimeout(() => child.kill("SIGTERM"), computeWatchdogMs(opts.maxTurns));
|
|
1154
|
+
state.watchdog.unref();
|
|
1155
|
+
} else if (pending.recentUnregister) {
|
|
1156
|
+
// 差集 0 但最近有 unregister:后代刚完成,notify 唤醒可能在路上(竞态窗口),
|
|
1157
|
+
// 保持进程——父被唤醒后的下一次 agent_end 会正常判定。
|
|
1158
|
+
// [MF-3] 秒级宽限:此分支在每层「最终 turn」必命中(closeout 的 agent_end 距
|
|
1159
|
+
// 最后一次 unregister <60s),挂长超时 = 空等 2h 才 kill + 冒牌完成通知级联。
|
|
1160
|
+
// 15s 内无新 agent_end(未被唤醒)即 kill;被唤醒后下一次 agent_end 重新评估。
|
|
1161
|
+
logger.debug(
|
|
1162
|
+
"[session-runner] agent_end: keep alive, recent descendant completion (wake-up in flight)",
|
|
1163
|
+
);
|
|
1164
|
+
clearTimeout(state.watchdog);
|
|
1165
|
+
state.watchdog = setTimeout(() => child.kill("SIGTERM"), WAKEUP_GRACE_MS);
|
|
1166
|
+
state.watchdog.unref();
|
|
1167
|
+
} else {
|
|
1168
|
+
child.kill("SIGTERM");
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
if (isSdkEvent(parsed.event)) handleSdkEvent(parsed.event);
|
|
1173
|
+
} else if (parsed.kind === "response") {
|
|
1174
|
+
// FR-4: RPC response handling — 匹配 get_state 响应
|
|
1175
|
+
if (parsed.command === "get_state" && parsed.success && parsed.id) {
|
|
1176
|
+
const resolver = get_stateListeners.get(parsed.id);
|
|
1177
|
+
if (resolver) {
|
|
1178
|
+
get_stateListeners.delete(parsed.id);
|
|
1179
|
+
resolver(parsed.data);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
} else if (parsed.kind === "extension_ui_request") {
|
|
1183
|
+
// W3: 子进程发 UI 请求(ask_user)。入队 FIFO 串行处理,防止并发询问用户。
|
|
1184
|
+
enqueueUiRequest(parsed.id, parsed.request);
|
|
1185
|
+
}
|
|
1186
|
+
// invalid 行忽略(stdout 可能有调试输出)
|
|
1187
|
+
}
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
return {
|
|
1191
|
+
registerGetStateListener: (id, resolver) => {
|
|
1192
|
+
get_stateListeners.set(id, resolver);
|
|
1193
|
+
},
|
|
1194
|
+
finishHandshake,
|
|
1195
|
+
abandonHandshake: settleHandshakeNow,
|
|
1196
|
+
isHandshakePending: () => settleHandshake !== undefined,
|
|
1197
|
+
handshakeSettled,
|
|
1198
|
+
processTrailingLine: () => {
|
|
1199
|
+
// 处理 stdout 末尾残留行
|
|
1200
|
+
if (stdoutBuffer.trim()) {
|
|
1201
|
+
const parsed = parseSpawnLine(stdoutBuffer);
|
|
1202
|
+
if (parsed?.kind === "event" && isSdkEvent(parsed.event)) {
|
|
1203
|
+
handleSdkEvent(parsed.event);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
},
|
|
1207
|
+
clearGetStateListeners: () => {
|
|
1208
|
+
get_stateListeners.clear();
|
|
1209
|
+
},
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/**
|
|
1214
|
+
* 等待子进程退出(close/error → exitCode,原 runSpawn 内联的 exit promise 迁入)。
|
|
1215
|
+
*
|
|
1216
|
+
* [V2 决策 2] resolveRun 指向本 promise 的 resolve:chatMode 首轮 agent_settled 时
|
|
1217
|
+
* 由 handleSdkEvent 提前调 resolveRun(0),close 最终到达时 resolve(code) 是 no-op。
|
|
1218
|
+
* close handler 统一执行 cleanup(句柄移除 / 握手放弃 / 残留行处理)。
|
|
1219
|
+
*/
|
|
1220
|
+
function waitForChildExit(
|
|
1221
|
+
child: ChildProcessWithoutNullStreams,
|
|
1222
|
+
state: SpawnRunState,
|
|
1223
|
+
spawnCwd: string,
|
|
1224
|
+
pump: StdoutPumpHandles,
|
|
1225
|
+
): Promise<number> {
|
|
1226
|
+
return new Promise<number>((resolve) => {
|
|
1227
|
+
state.resolveRun = resolve;
|
|
1228
|
+
child.on("close", async (code: number | null) => {
|
|
1229
|
+
// [C1] 子进程已退出,从 orphan-tracking Map 移除(dispose 兜底无需再 kill 它)。
|
|
1230
|
+
// [M4] 按值守卫:close 事件晚于 resume spawn 到达时不误删新 child 注册。
|
|
1231
|
+
removeChildRegistration(state.record.id, child);
|
|
1232
|
+
// FR-4: 清理 get_state 监听器(子进程已退出,无更多 response)
|
|
1233
|
+
pump.clearGetStateListeners();
|
|
1234
|
+
// FR-4: 子进程已退出,get_state response 不会再来。若握手仍未 settle,立即放弃
|
|
1235
|
+
//(record.sessionFile 未回填则走 findSessionFileByHeaderId 兜底)。避免子进程快速
|
|
1236
|
+
// 失败/退出场景下 close handler 阻塞等待握手内部 6s 超时。
|
|
1237
|
+
pump.abandonHandshake();
|
|
1238
|
+
// await 立即返回(上方已 settle):保证 header 加速路径或 get_state response 已
|
|
1239
|
+
// 完成的回填结果对后续 identity 写入可见。
|
|
1240
|
+
await pump.handshakeSettled;
|
|
1241
|
+
// 处理 stdout 末尾残留行
|
|
1242
|
+
pump.processTrailingLine();
|
|
1243
|
+
resolve(code ?? 0);
|
|
1244
|
+
});
|
|
1245
|
+
child.on("error", (err: Error) => {
|
|
1246
|
+
// spawn 本身失败(command not found 等)
|
|
1247
|
+
// [worktree-reaper-fix] 拼 spawnCwd 进错误消息:ENOENT 的 err.message 只含 command 名,
|
|
1248
|
+
// 无 cwd 线索(worktree 被 reaper 误删后 cwd 指向虚空)会导致误诊——2026-08-11 事故
|
|
1249
|
+
// AI 误判"node 被卸载"的直接原因。
|
|
1250
|
+
// [M4] 按值守卫:error 事件晚于 resume spawn 到达时不误删新 child 注册。
|
|
1251
|
+
removeChildRegistration(state.record.id, child);
|
|
1252
|
+
// [S3] code 读取带运行时 guard:非 ErrnoException(普通 Error)时 code 为 undefined,
|
|
1253
|
+
// 不加 cwd hint(行为与修复前一致);仅 ENOENT 才拼 cwd。
|
|
1254
|
+
const errno = err as NodeJS.ErrnoException;
|
|
1255
|
+
const errCode = "code" in err ? errno.code : undefined;
|
|
1256
|
+
const cwdHint = errCode === "ENOENT" ? ` (cwd: ${spawnCwd})` : "";
|
|
1257
|
+
state.record.lastError = `${err.message}${cwdHint}`;
|
|
1258
|
+
resolve(SIGNAL_EXIT_CODE_THRESHOLD); // 非零退出
|
|
1259
|
+
});
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
/**
|
|
1264
|
+
* spawn pi 子进程执行 session。
|
|
1265
|
+
*
|
|
1266
|
+
* 契约与 run() 一致:正常路径不抛错(prompt 失败/turn-limit abort/子进程崩溃
|
|
1267
|
+
* 均合成 failed AgentResult 返回)。创建期异常(spawn 本身失败)会抛。
|
|
1268
|
+
*
|
|
1269
|
+
* [拆分] 各执行阶段(事件累积 / 参数准备 / stdout pump / 退出等待)拆到上方
|
|
1270
|
+
* 模块级函数,闭包状态经 SpawnRunState 传递——只移动代码不改行为。
|
|
1271
|
+
*/
|
|
1272
|
+
export async function runSpawn(
|
|
1273
|
+
record: ExecutionRecord,
|
|
1274
|
+
task: string,
|
|
1275
|
+
opts: RunOptions,
|
|
1276
|
+
ctx: SessionRunnerContext,
|
|
1277
|
+
/** resume 选项(M1 基建):重开已结束的 session 继续对话。undefined = 新 session。 */
|
|
1278
|
+
resume?: SpawnResumeOpts,
|
|
1279
|
+
): Promise<AgentResult> {
|
|
1280
|
+
const startTime = Date.now();
|
|
1281
|
+
|
|
1282
|
+
// [M1 resume 基建] resume 时提前锁定 record.sessionFile:spawn 前已知目标文件,
|
|
1283
|
+
// handshake 的 finishHandshake 内 `!record.sessionFile` 守卫天然跳过回填,
|
|
1284
|
+
// sessionFile 用 resume.sessionFile 不被覆盖(RPC mode 无 header,header 分支也不触发)。
|
|
1285
|
+
if (resume) {
|
|
1286
|
+
record.sessionFile = resume.sessionFile;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// 各阶段共享状态(原闭包变量收拢:proc / watchdog / sessionHeader / handshakeResult / resolveRun)
|
|
1290
|
+
const state: SpawnRunState = {
|
|
1291
|
+
record,
|
|
1292
|
+
opts,
|
|
1293
|
+
ctx,
|
|
1294
|
+
proc: undefined,
|
|
1295
|
+
watchdog: undefined,
|
|
1296
|
+
sessionHeader: undefined,
|
|
1297
|
+
handshakeResult: undefined,
|
|
1298
|
+
resolveRun: undefined,
|
|
1299
|
+
};
|
|
1300
|
+
|
|
1301
|
+
// a/b. 事件累积器(pendingTools 寄存器 + turnLimiter + handleSdkEvent/agentEvent 闭包)
|
|
1302
|
+
const handleSdkEvent = createSpawnEventHandlers(state);
|
|
1303
|
+
|
|
1304
|
+
// c. schema 指令拼到 task 末尾(替代 in-process 的 turn_end steer 循环)
|
|
1305
|
+
const instruction = opts.schema ? formatSchemaInstruction(opts.schema) : "";
|
|
1306
|
+
const fullTask = task + instruction;
|
|
1307
|
+
|
|
1308
|
+
// d. session 目录(与 in-process 一致:list/恢复可发现同一目录)
|
|
1309
|
+
// [MF-3] 用 ctx.rootCwd(贯穿真 ROOT)而非 ctx.mainCwd 编码:worktree 模式下 mainCwd 是
|
|
1310
|
+
// 子进程的 checkout 路径,按它编码会让深层 record 落到 enc(worktree) 段,ROOT 磁盘重建
|
|
1311
|
+
// 扫不到 → 全树可见性深度 ≥ 2 断裂。rootCwd 与 store 构造同源(subagent-service 同键),
|
|
1312
|
+
// 保证 runSpawn 写入的 session 文件就在本进程/ROOT store 扫描的目录里。
|
|
1313
|
+
const sessionDir = getSubagentSessionDir(ctx.agentDir, ctx.rootCwd);
|
|
1314
|
+
fs.mkdirSync(sessionDir, { recursive: true });
|
|
1315
|
+
|
|
1316
|
+
// e. worktree 模式:checkout 路径作为 spawn cwd(隔离文件系统)
|
|
1317
|
+
// worktree checkout 已由 worktree-manager 在 execute 前创建,此处只取路径。
|
|
1318
|
+
const spawnCwd = opts.worktree?.path ?? ctx.cwd;
|
|
1319
|
+
|
|
1320
|
+
// f. fork source:父 session 文件路径(--fork 参数)
|
|
1321
|
+
const forkSource = opts.fork ? ctx.mainSessionFile : undefined;
|
|
1322
|
+
|
|
1323
|
+
// g. appendSystemPrompt 落盘(env block + agent body + 调用方片段)
|
|
1324
|
+
const tempPromptFile = await writeAppendSystemPromptFile(record, opts, ctx);
|
|
1325
|
+
|
|
1326
|
+
// h. 子进程环境变量(fork depth / 身份贯穿 / identity / worktree 标志 / schemaEnv)
|
|
1327
|
+
const childEnv = buildChildEnv(record, opts, ctx);
|
|
1328
|
+
|
|
1329
|
+
// i. 组装 args + 解析 pi 可执行入口
|
|
1330
|
+
const invocation = buildSpawnInvocation(opts, ctx, resume, tempPromptFile, sessionDir, forkSource);
|
|
941
1331
|
|
|
942
|
-
// 解析出的 session header(stdout 首行,含 session id)
|
|
943
|
-
let sessionHeader: SpawnSessionHeader | undefined;
|
|
944
1332
|
// 累积 stderr(错误诊断用)
|
|
945
1333
|
let stderrBuffer = "";
|
|
946
1334
|
|
|
@@ -951,14 +1339,15 @@ export async function runSpawn(
|
|
|
951
1339
|
stdio: ["pipe", "pipe", "pipe"],
|
|
952
1340
|
env: childEnv,
|
|
953
1341
|
});
|
|
954
|
-
proc = child;
|
|
1342
|
+
state.proc = child;
|
|
955
1343
|
// [worktree-reaper-fix] 同步补全注册表 pid:spawn 返回后 child.pid 立即可得(Node.js
|
|
956
1344
|
// 同步属性),无需等任何 stdout 事件。原补全点挂在 header 分支(下方 stdout handler 内),
|
|
957
1345
|
// 而 RPC mode(buildSpawnArgs 固定 --mode rpc)不输出 header 行——pid 恒为 0,超
|
|
958
1346
|
// SPAWN_GRACE_MS 后被 reaper 当孤儿误删活 worktree(2026-08-11 cw 递归编排整树失活事故)。
|
|
959
1347
|
// header 分支调用保留:json mode 回切时仍能补全,updatePid 同 branch 覆盖写幂等,无副作用。
|
|
960
1348
|
if (opts.worktree && child.pid) {
|
|
961
|
-
|
|
1349
|
+
// [D5a] void:回调可能返回 Promise(锁内 RMW),契约保证不 reject,fire-and-forget。
|
|
1350
|
+
void ctx.onWorktreePid?.(opts.worktree.branch, child.pid);
|
|
962
1351
|
}
|
|
963
1352
|
// [C1] track 子进程供 dispose 兜底 kill(sync + background 均注册——sync 无 controller,
|
|
964
1353
|
// abortRunningControllers 跳过它,靠本 Map 兜底)。close/error 后按句守卫移除(已退出无需再 kill)。
|
|
@@ -977,7 +1366,8 @@ export async function runSpawn(
|
|
|
977
1366
|
// 注册表写失败最坏后果是条目停留 pid=0,由 reaper 宽限回收兜底)。
|
|
978
1367
|
if (opts.worktree && child.pid) {
|
|
979
1368
|
try {
|
|
980
|
-
|
|
1369
|
+
// [D5a] void:回调可能返回 Promise(锁内 RMW),契约保证不 reject,fire-and-forget。
|
|
1370
|
+
void ctx.onWorktreePid?.(opts.worktree.branch, child.pid);
|
|
981
1371
|
} catch (err) {
|
|
982
1372
|
logger.warn("[worktree] worktree pid registration failed (defensive)", {
|
|
983
1373
|
branch: opts.worktree.branch,
|
|
@@ -997,7 +1387,7 @@ export async function runSpawn(
|
|
|
997
1387
|
// [v4 A-1] 异步 stdin 'error' listener。writeStdinLine 的 try/catch 只覆盖同步 write 抛错;
|
|
998
1388
|
// 子进程退出后内核回写 EPIPE 会以异步 stream 'error' event 到达,若无 listener 会让 Node
|
|
999
1389
|
// 抛 unhandled 'error' 崩主进程(P1)。必须在首次 stdin 写入(下方 sendPromptCommand)前注册。
|
|
1000
|
-
// handler 行为:①移出 spawnedChildren 标记 dead(与 child.on('error')
|
|
1390
|
+
// handler 行为:①移出 spawnedChildren 标记 dead(与 child.on('error') 同模式);
|
|
1001
1391
|
// ②recordEpipeFailure 合并同步/异步计数;③logger.warn 记录一次。
|
|
1002
1392
|
// [v4 A-1 裁决] handler 内**不 throw**——stream 'error' listener 内 throw 会经 Node 内部
|
|
1003
1393
|
// emit() 传播为 uncaughtException 崩主进程,违背 A-1 防崩核心目标。达阈值的 throw 留给
|
|
@@ -1041,166 +1431,16 @@ export async function runSpawn(
|
|
|
1041
1431
|
// 主进程退出时(session_shutdown reason=quit)dispose 会 abort running controller
|
|
1042
1432
|
// → 本监听器 kill 子进程。无此 unref,watchdog timer 会拖住 event loop 阻止退出。
|
|
1043
1433
|
const watchdogMs = computeWatchdogMs(opts.maxTurns);
|
|
1044
|
-
|
|
1045
|
-
watchdog.unref();
|
|
1434
|
+
state.watchdog = setTimeout(() => child.kill("SIGTERM"), watchdogMs);
|
|
1435
|
+
state.watchdog.unref();
|
|
1046
1436
|
|
|
1047
1437
|
// [recursive-orchestration] agent_end 有活跃后代时的等待超时(不 kill 分支的兑底)。
|
|
1048
1438
|
// 层主 subagent 空闲等待后代完成(steer 唤醒)期间不产生 turn,原 watchdog 已清;
|
|
1049
1439
|
// 此后代卡死(永不完成)时此 timer 保证进程最终回收。超时 kill → finalize 视为正常
|
|
1050
1440
|
// 完成 → 通知父(父查 cw status 发现未 closed 会走 L2/L3 重派,见 planning-agent 模板)。
|
|
1051
1441
|
|
|
1052
|
-
// stdout pump:逐行解析 → handleSdkEvent / enqueueUiRequest
|
|
1053
|
-
const
|
|
1054
|
-
// FR-4: get_state RPC response 监听器(id → resolver)。
|
|
1055
|
-
// parseSpawnLine 返回 kind:"response" 时,按 command+id 匹配 resolver。
|
|
1056
|
-
const get_stateListeners = new Map<string, (data: unknown) => void>();
|
|
1057
|
-
let stdoutBuffer = "";
|
|
1058
|
-
|
|
1059
|
-
// [#18] 握手状态变量在 stdout handler 注册之前定义,消除"handler 闭包依赖同 tick
|
|
1060
|
-
// 后续 const 初始化"的隐式顺序假设——handler 现在直接引用已初始化的变量,不靠
|
|
1061
|
-
// "data 事件必然在下一 tick 才触发”的运行时不变式兜底。
|
|
1062
|
-
const handshakeResultRef: { current?: GetStateResult } = {};
|
|
1063
|
-
let settleHandshake: (() => void) | undefined;
|
|
1064
|
-
const handshakeSettled: Promise<void> = new Promise((resolveSettled) => {
|
|
1065
|
-
settleHandshake = resolveSettled;
|
|
1066
|
-
});
|
|
1067
|
-
/** 握手完成统一入口:记录结果 + 回填 sessionFile + 写 alive marker + settle。 */
|
|
1068
|
-
const finishHandshake = (r: GetStateResult): void => {
|
|
1069
|
-
handshakeResultRef.current = r;
|
|
1070
|
-
// 仅当 header 未先行设置 record.sessionFile 时回填(RPC mode 路径)。
|
|
1071
|
-
if (r.sessionFile && !record.sessionFile) {
|
|
1072
|
-
record.sessionFile = r.sessionFile;
|
|
1073
|
-
if (child.pid) {
|
|
1074
|
-
try {
|
|
1075
|
-
writeAliveMarker(r.sessionFile, {
|
|
1076
|
-
pid: child.pid,
|
|
1077
|
-
id: r.sessionId ?? record.id,
|
|
1078
|
-
startedAt: Date.now(),
|
|
1079
|
-
});
|
|
1080
|
-
} catch {
|
|
1081
|
-
// best-effort:alive marker 失败不影响执行
|
|
1082
|
-
}
|
|
1083
|
-
}
|
|
1084
|
-
}
|
|
1085
|
-
settleHandshake?.();
|
|
1086
|
-
settleHandshake = undefined;
|
|
1087
|
-
};
|
|
1088
|
-
|
|
1089
|
-
child.stdout.on("data", (data: string) => {
|
|
1090
|
-
stdoutBuffer += data;
|
|
1091
|
-
const lines = stdoutBuffer.split("\n");
|
|
1092
|
-
stdoutBuffer = lines.pop() ?? ""; // 保留最后未完整行
|
|
1093
|
-
for (const line of lines) {
|
|
1094
|
-
const parsed = parseSpawnLine(line);
|
|
1095
|
-
if (!parsed) continue;
|
|
1096
|
-
if (parsed.kind === "header") {
|
|
1097
|
-
sessionHeader = parsed.header;
|
|
1098
|
-
// 回填 record.sessionFile(deriveSessionFilePath 推导路径)
|
|
1099
|
-
record.sessionFile = deriveSessionFilePath(parsed.header, sessionDir);
|
|
1100
|
-
// [持久化 C] alive marker:running 期间崩溃恢复用。子进程 pid + session id。
|
|
1101
|
-
// 与 in-process 逻辑对齐(记 sessionFile + pid),改为子进程 pid。
|
|
1102
|
-
if (record.sessionFile && child.pid) {
|
|
1103
|
-
try {
|
|
1104
|
-
writeAliveMarker(record.sessionFile, {
|
|
1105
|
-
pid: child.pid,
|
|
1106
|
-
id: parsed.header.id,
|
|
1107
|
-
startedAt: Date.now(),
|
|
1108
|
-
});
|
|
1109
|
-
} catch {
|
|
1110
|
-
// best-effort:alive marker 失败不影响执行
|
|
1111
|
-
}
|
|
1112
|
-
}
|
|
1113
|
-
// [全局注册表] worktree 模式:补全注册表条目的 pid。
|
|
1114
|
-
// create 时 pid 未知写 0 占位,此处拿到 child.pid 后回调 WorktreeManager.registerPid。
|
|
1115
|
-
// 取代旧的 .session mapping sidecar——注册表是 reaper 的唯一数据源。
|
|
1116
|
-
if (opts.worktree && child.pid) {
|
|
1117
|
-
// 透传 record.sessionFile:填入 registry entry(reaper 据 pid 死活判孤儿),
|
|
1118
|
-
// first header 时 sessionFile 已回填(deriveSessionFilePath 在本分支上方)。
|
|
1119
|
-
ctx.onWorktreePid?.(opts.worktree.branch, child.pid, record.sessionFile);
|
|
1120
|
-
}
|
|
1121
|
-
// FR-4 加速路径:header 到达即 finishHandshake(header 已提供 sessionId,
|
|
1122
|
-
// 足以推导 sessionFile + 兜底查找,无需等 get_state response)。
|
|
1123
|
-
// [#25] buildSpawnArgs 固定 --mode rpc,RPC mode 不发 header——此分支当前不触发,
|
|
1124
|
-
// 仅为未来 mode 回切(如 json mode 调试)保留:届时 header 先到可省去 get_state 握手等待。
|
|
1125
|
-
if (settleHandshake) {
|
|
1126
|
-
finishHandshake({
|
|
1127
|
-
...(record.sessionFile ? { sessionFile: record.sessionFile } : {}),
|
|
1128
|
-
sessionId: parsed.header.id,
|
|
1129
|
-
});
|
|
1130
|
-
}
|
|
1131
|
-
} else if (parsed.kind === "event") {
|
|
1132
|
-
const evt = parsed.event;
|
|
1133
|
-
// agent_end(willRetry=false)= agent 自然完成。rpc mode 子进程不自动退出
|
|
1134
|
-
//(runRpcMode 末尾 return new Promise(() => {}) 长驻等命令),需主动 kill
|
|
1135
|
-
// 触发 close → runSpawn resolve。willRetry=true 时 agent 会重试,不能 kill。
|
|
1136
|
-
if (isAgentEndEvt(evt)) {
|
|
1137
|
-
if (evt.willRetry) {
|
|
1138
|
-
// agent 会重试,不能 kill。
|
|
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
|
-
}
|
|
1148
|
-
// [recursive-orchestration] 条件 kill:读子进程 session 文件算活跃后代
|
|
1149
|
-
// (pending:register − unregister 差集)。有活跃后代(background subagent /
|
|
1150
|
-
// workflow)→ 保持进程 idle,等后代完成时 notifier triggerTurn steer 唤醒;
|
|
1151
|
-
// 无 → 正常完成,kill 触发 close → runSpawn resolve。
|
|
1152
|
-
const pending = readActivePendingFromSessionFile(record.sessionFile);
|
|
1153
|
-
if (pending.count > 0 || pending.error) {
|
|
1154
|
-
if (pending.error) {
|
|
1155
|
-
logger.warn(
|
|
1156
|
-
`[session-runner] agent_end: keep alive (sessionFile unreadable, conservative): ${pending.error}`,
|
|
1157
|
-
);
|
|
1158
|
-
} else {
|
|
1159
|
-
logger.debug(
|
|
1160
|
-
`[session-runner] agent_end: keep alive, ${pending.count} active descendant(s) pending`,
|
|
1161
|
-
);
|
|
1162
|
-
}
|
|
1163
|
-
// 空闲等待期间不消耗 turn:清原 watchdog,换等待后代超时(每次 agent_end 重新计时)。
|
|
1164
|
-
// [MF-4] 动态超时 = computeWatchdogMs(maxTurns):真实后代在跑,慢任务(wave 开发
|
|
1165
|
-
// 数小时)不能被固定 2h 误杀——2h 到点 kill 会连坐 SubagentService.dispose 的
|
|
1166
|
-
// killAllSpawnedChildren 杀全部子进程,L2 重派丢在途工作。maxTurns 大则超时长。
|
|
1167
|
-
clearTimeout(watchdog);
|
|
1168
|
-
watchdog = setTimeout(() => child.kill("SIGTERM"), computeWatchdogMs(opts.maxTurns));
|
|
1169
|
-
watchdog.unref();
|
|
1170
|
-
} else if (pending.recentUnregister) {
|
|
1171
|
-
// 差集 0 但最近有 unregister:后代刚完成,notify 唤醒可能在路上(竞态窗口),
|
|
1172
|
-
// 保持进程——父被唤醒后的下一次 agent_end 会正常判定。
|
|
1173
|
-
// [MF-3] 秒级宽限:此分支在每层「最终 turn」必命中(closeout 的 agent_end 距
|
|
1174
|
-
// 最后一次 unregister <60s),挂长超时 = 空等 2h 才 kill + 冒牌完成通知级联。
|
|
1175
|
-
// 15s 内无新 agent_end(未被唤醒)即 kill;被唤醒后下一次 agent_end 重新评估。
|
|
1176
|
-
logger.debug(
|
|
1177
|
-
"[session-runner] agent_end: keep alive, recent descendant completion (wake-up in flight)",
|
|
1178
|
-
);
|
|
1179
|
-
clearTimeout(watchdog);
|
|
1180
|
-
watchdog = setTimeout(() => child.kill("SIGTERM"), WAKEUP_GRACE_MS);
|
|
1181
|
-
watchdog.unref();
|
|
1182
|
-
} else {
|
|
1183
|
-
child.kill("SIGTERM");
|
|
1184
|
-
}
|
|
1185
|
-
}
|
|
1186
|
-
}
|
|
1187
|
-
if (isSdkEvent(parsed.event)) handleSdkEvent(parsed.event);
|
|
1188
|
-
} else if (parsed.kind === "response") {
|
|
1189
|
-
// FR-4: RPC response handling — 匹配 get_state 响应
|
|
1190
|
-
if (parsed.command === "get_state" && parsed.success && parsed.id) {
|
|
1191
|
-
const resolver = get_stateListeners.get(parsed.id);
|
|
1192
|
-
if (resolver) {
|
|
1193
|
-
get_stateListeners.delete(parsed.id);
|
|
1194
|
-
resolver(parsed.data);
|
|
1195
|
-
}
|
|
1196
|
-
}
|
|
1197
|
-
} else if (parsed.kind === "extension_ui_request") {
|
|
1198
|
-
// W3: 子进程发 UI 请求(ask_user)。入队 FIFO 串行处理,防止并发询问用户。
|
|
1199
|
-
enqueueUiRequest(parsed.id, parsed.request);
|
|
1200
|
-
}
|
|
1201
|
-
// invalid 行忽略(stdout 可能有调试输出)
|
|
1202
|
-
}
|
|
1203
|
-
});
|
|
1442
|
+
// stdout pump:逐行解析 → handleSdkEvent / enqueueUiRequest + get_state 握手状态机
|
|
1443
|
+
const pump = attachStdoutPump(child, state, sessionDir, handleSdkEvent);
|
|
1204
1444
|
|
|
1205
1445
|
// FR-4: get_state RPC 握手——spawn 后无条件启动。
|
|
1206
1446
|
// RPC mode(pi --mode rpc)不向 stdout 输出 header,record.sessionFile 无法靠 header
|
|
@@ -1211,14 +1451,11 @@ export async function runSpawn(
|
|
|
1211
1451
|
// 时序:握手在 stdout pump(get_stateListeners 已就绪)之后启动。get_state 命令写入
|
|
1212
1452
|
// stdin,pi rebindSession 后读取并返回 response,经 stdout pump 匹配 resolver 触发 resolve。
|
|
1213
1453
|
// close handler await handshakeSettled,保证无论 task 多快结束,close 时 sessionFile 已回填。
|
|
1214
|
-
// [#18]
|
|
1215
|
-
|
|
1216
|
-
void performGetStateHandshake(child, (id, resolver) => {
|
|
1217
|
-
get_stateListeners.set(id, resolver);
|
|
1218
|
-
}).then((r) => {
|
|
1454
|
+
// [#18] 握手状态变量已在 attachStdoutPump 内部(stdout handler 注册前)定义,此处直接发起握手。
|
|
1455
|
+
void performGetStateHandshake(child, pump.registerGetStateListener).then((r) => {
|
|
1219
1456
|
// header 加速路径下 settleHandshake 已 undefined,跳过(避免覆盖 header 结果)。
|
|
1220
1457
|
// 超时兜底(r 为空对象)也经此分支 settle,但 record.sessionFile 不回填。
|
|
1221
|
-
if (
|
|
1458
|
+
if (pump.isHandshakePending()) pump.finishHandshake(r);
|
|
1222
1459
|
});
|
|
1223
1460
|
|
|
1224
1461
|
child.stderr.on("data", (data: string) => {
|
|
@@ -1227,52 +1464,10 @@ export async function runSpawn(
|
|
|
1227
1464
|
});
|
|
1228
1465
|
|
|
1229
1466
|
// 等待子进程退出
|
|
1230
|
-
const exitCode = await
|
|
1231
|
-
// [V2 决策 2] resolveRun 指向本 promise 的 resolve:chatMode 首轮 agent_settled 时
|
|
1232
|
-
// 由 handleSdkEvent 提前调 resolveRun(0),close 最终到达时 resolve(code) 是 no-op。
|
|
1233
|
-
resolveRun = resolve;
|
|
1234
|
-
child.on("close", async (code: number | null) => {
|
|
1235
|
-
// [C1] 子进程已退出,从 orphan-tracking Map 移除(dispose 兜底无需再 kill 它)。
|
|
1236
|
-
// [M4] 按值守卫:close 事件晚于 resume spawn 到达时不误删新 child 注册。
|
|
1237
|
-
removeChildRegistration(record.id, child);
|
|
1238
|
-
// FR-4: 清理 get_state 监听器(子进程已退出,无更多 response)
|
|
1239
|
-
get_stateListeners.clear();
|
|
1240
|
-
// FR-4: 子进程已退出,get_state response 不会再来。若握手仍未 settle,立即放弃
|
|
1241
|
-
//(record.sessionFile 未回填则走 findSessionFileByHeaderId 兜底)。避免子进程快速
|
|
1242
|
-
// 失败/退出场景下 close handler 阻塞等待握手内部 6s 超时。
|
|
1243
|
-
settleHandshake?.();
|
|
1244
|
-
settleHandshake = undefined;
|
|
1245
|
-
// await 立即返回(上方已 settle):保证 header 加速路径或 get_state response 已
|
|
1246
|
-
// 完成的回填结果对后续 identity 写入可见。
|
|
1247
|
-
await handshakeSettled;
|
|
1248
|
-
// 处理 stdout 末尾残留行
|
|
1249
|
-
if (stdoutBuffer.trim()) {
|
|
1250
|
-
const parsed = parseSpawnLine(stdoutBuffer);
|
|
1251
|
-
if (parsed?.kind === "event" && isSdkEvent(parsed.event)) {
|
|
1252
|
-
handleSdkEvent(parsed.event);
|
|
1253
|
-
}
|
|
1254
|
-
}
|
|
1255
|
-
resolve(code ?? 0);
|
|
1256
|
-
});
|
|
1257
|
-
child.on("error", (err: Error) => {
|
|
1258
|
-
// spawn 本身失败(command not found 等)
|
|
1259
|
-
// [worktree-reaper-fix] 拼 spawnCwd 进错误消息:ENOENT 的 err.message 只含 command 名,
|
|
1260
|
-
// 无 cwd 线索(worktree 被 reaper 误删后 cwd 指向虚空)会导致误诊——2026-08-11 事故
|
|
1261
|
-
// AI 误判"node 被卸载"的直接原因。
|
|
1262
|
-
// [M4] 按值守卫:error 事件晚于 resume spawn 到达时不误删新 child 注册。
|
|
1263
|
-
removeChildRegistration(record.id, child);
|
|
1264
|
-
// [S3] code 读取带运行时 guard:非 ErrnoException(普通 Error)时 code 为 undefined,
|
|
1265
|
-
// 不加 cwd hint(行为与修复前一致);仅 ENOENT 才拼 cwd。
|
|
1266
|
-
const errno = err as NodeJS.ErrnoException;
|
|
1267
|
-
const errCode = "code" in err ? errno.code : undefined;
|
|
1268
|
-
const cwdHint = errCode === "ENOENT" ? ` (cwd: ${spawnCwd})` : "";
|
|
1269
|
-
record.lastError = `${err.message}${cwdHint}`;
|
|
1270
|
-
resolve(SIGNAL_EXIT_CODE_THRESHOLD); // 非零退出
|
|
1271
|
-
});
|
|
1272
|
-
});
|
|
1467
|
+
const exitCode = await waitForChildExit(child, state, spawnCwd, pump);
|
|
1273
1468
|
|
|
1274
1469
|
opts.signal?.removeEventListener("abort", onAbort);
|
|
1275
|
-
clearTimeout(watchdog);
|
|
1470
|
+
clearTimeout(state.watchdog);
|
|
1276
1471
|
|
|
1277
1472
|
// [持久化 A] sessionFile 兜底校验。
|
|
1278
1473
|
// identity custom entry 已改由子进程 session_start hook 写(M4 / V2 决策 5),
|
|
@@ -1283,7 +1478,7 @@ export async function runSpawn(
|
|
|
1283
1478
|
// 用 sessionId 后缀匹配实际文件。匹配到则修正 record.sessionFile。
|
|
1284
1479
|
// sessionId 来源:header(json mode)优先,其次握手结果(RPC mode)。
|
|
1285
1480
|
if (!fs.existsSync(record.sessionFile)) {
|
|
1286
|
-
const lookupId = sessionHeader?.id ??
|
|
1481
|
+
const lookupId = state.sessionHeader?.id ?? state.handshakeResult?.sessionId;
|
|
1287
1482
|
if (lookupId) {
|
|
1288
1483
|
const actual = findSessionFileByHeaderId(sessionDir, lookupId);
|
|
1289
1484
|
if (actual) record.sessionFile = actual;
|
|
@@ -1317,7 +1512,7 @@ export async function runSpawn(
|
|
|
1317
1512
|
startTime,
|
|
1318
1513
|
success,
|
|
1319
1514
|
error,
|
|
1320
|
-
sessionId: sessionHeader?.id ?? record.id,
|
|
1515
|
+
sessionId: state.sessionHeader?.id ?? record.id,
|
|
1321
1516
|
sessionFile: record.sessionFile,
|
|
1322
1517
|
});
|
|
1323
1518
|
} finally {
|