@zhushanwen/pi-subagent-workflow 7.4.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/README.md +15 -0
- package/package.json +7 -4
- package/scripts/rfl.mjs +308 -0
- package/skills/workflow-script-format/SKILL.md +1 -1
- package/src/execution/__tests__/__fixtures__/truncline.snapshot.json +1 -0
- 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 +197 -15
- 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 +37 -10
- 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 +442 -54
- 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 +629 -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 +300 -90
- package/src/execution/__tests__/worktree-pid-registration.integration.test.ts +17 -13
- 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 +21 -2
- package/src/execution/channel-registry-access.ts +3 -1
- package/src/execution/execution-record.ts +126 -9
- package/src/execution/finalize-record.ts +99 -13
- package/src/execution/idle-gc.ts +47 -0
- package/src/execution/lifecycle-manager.ts +491 -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-entry.ts +118 -0
- package/src/execution/record-store.ts +844 -108
- package/src/execution/session-pending.ts +121 -49
- package/src/execution/session-reconstructor.ts +224 -7
- package/src/execution/session-runner.ts +713 -316
- 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 +917 -138
- package/src/execution/temp-prompt.ts +8 -3
- package/src/execution/turn-limiter.ts +14 -0
- package/src/execution/types.ts +218 -19
- package/src/execution/worktree-manager.ts +449 -59
- package/src/execution/worktree-registry.ts +97 -29
- package/src/index.ts +318 -20
- 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 +17 -4
- package/src/interface/__tests__/tool-render.test.ts +10 -8
- 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/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 +53 -20
- 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 +81 -16
- package/src/interface/subagents.ts +2 -1
- package/src/interface/tool-render.ts +21 -27
- package/src/interface/tool-workflow-script.ts +29 -33
- package/src/interface/tool-workflow.ts +67 -100
- package/src/interface/views/WorkflowsView.ts +89 -32
- package/src/interface/views/__tests__/WorkflowsView-signature.test.ts +264 -0
- package/src/interface/views/detail-content.ts +1 -1
- package/src/interface/views/format.ts +3 -3
- package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +333 -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 +144 -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 +820 -19
- package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +0 -2
- package/src/orchestration/__tests__/lifecycle-runid-injection.test.ts +96 -0
- package/src/orchestration/__tests__/lifecycle.test.ts +332 -149
- package/src/orchestration/__tests__/review-fix-loop-e2e.test.ts +1108 -19
- package/src/orchestration/__tests__/skill-discovery.test.ts +130 -61
- package/src/orchestration/__tests__/test-mocks.ts +197 -0
- package/src/orchestration/__tests__/worker-returnmeta-passthrough.test.ts +164 -0
- package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +110 -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 +38 -40
- 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 +137 -30
- package/src/orchestration/execute-agent-call.ts +44 -10
- package/src/orchestration/jsonl-run-store.ts +397 -71
- package/src/orchestration/launcher.ts +7 -1
- package/src/orchestration/lifecycle.ts +145 -133
- 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 +38 -11
- 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 +79 -0
- package/src/shared/__tests__/schema-jsonify.test.ts +81 -0
- package/src/shared/agent-ref.ts +22 -1
- package/src/shared/resource-discovery.ts +162 -59
- package/src/shared/schema-jsonify.ts +56 -0
- package/workflows/review-fix-loop-utils.cjs +542 -32
- package/workflows/review-fix-loop.js +462 -109
|
@@ -5,10 +5,12 @@
|
|
|
5
5
|
// spawn 改造后:session 在独立子进程跑(进程隔离),事件经 stdout JSON 流回流。
|
|
6
6
|
// runSpawn 是唯一执行入口(sync/background 共用)。mode 分叉在 Runtime.execute 顶部。
|
|
7
7
|
|
|
8
|
-
import { type ChildProcess,
|
|
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";
|
|
13
|
+
import { armIdleTimer } from "./lifecycle-manager.ts";
|
|
12
14
|
import { readActivePendingFromSessionFile } from "./session-pending.ts";
|
|
13
15
|
|
|
14
16
|
import type { ExtensionMode } from "./host-mode.ts";
|
|
@@ -23,9 +25,9 @@ import type { AgentConfig, ResolvedModel } from "./model-resolver.ts";
|
|
|
23
25
|
import { collectResult } from "./output-collector.ts";
|
|
24
26
|
import { getSubagentSessionDir } from "./path-encoding.ts";
|
|
25
27
|
import { getPiInvocation } from "./pi-invocation.ts";
|
|
28
|
+
import { stringifySchemaCached } from "../shared/schema-jsonify.ts";
|
|
26
29
|
import { MAX_FORK_DEPTH } from "./session-context-resolver.ts";
|
|
27
|
-
import {
|
|
28
|
-
import { sendPromptCommand } from "./stdin-writer.ts";
|
|
30
|
+
import { EPIPE_FAILURE_THRESHOLD, recordEpipeFailure, sendPromptCommand } from "./stdin-writer.ts";
|
|
29
31
|
import {
|
|
30
32
|
deriveSessionFilePath,
|
|
31
33
|
findSessionFileByHeaderId,
|
|
@@ -74,6 +76,19 @@ function isAgentEndEvt(
|
|
|
74
76
|
return x.type === "agent_end";
|
|
75
77
|
}
|
|
76
78
|
|
|
79
|
+
/**
|
|
80
|
+
* [V2 模块 3] agent_settled 事件守卫。对齐 isAgentEndEvt 模式(type 字段窄化,无需 cast)。
|
|
81
|
+
*
|
|
82
|
+
* agent_settled 是 V2 持续对话的「真空闲边界」:agent_end 之后、post-run(compact 检查等)
|
|
83
|
+
* 完成后才 emit(pi `agent-session.js` `_runAgentPrompt` finally 块,约 L744-755)。chatMode 在
|
|
84
|
+
* 此 arm idle timer + 通知本轮完成;非 chatMode 忽略(agent_end handler 已处理一次性 kill)。
|
|
85
|
+
*/
|
|
86
|
+
function isAgentSettledEvt(x: unknown): x is { type: "agent_settled" } {
|
|
87
|
+
if (typeof x !== "object" || x === null) return false;
|
|
88
|
+
if (!("type" in x)) return false;
|
|
89
|
+
return x.type === "agent_settled";
|
|
90
|
+
}
|
|
91
|
+
|
|
77
92
|
/**
|
|
78
93
|
* 把 pi assistantMessageEvent 分流为 text_delta / thinking_delta AgentEvent,供 streaming 通道。
|
|
79
94
|
*
|
|
@@ -96,19 +111,25 @@ export function mapAssistantMessageDelta(
|
|
|
96
111
|
// 常量
|
|
97
112
|
// ============================================================
|
|
98
113
|
|
|
114
|
+
/** 时间单位换算常量(命名后供 watchdog 系列常量组合,消除裸乘法字面量)。 */
|
|
115
|
+
const MS_PER_SECOND = 1000;
|
|
116
|
+
const SECONDS_PER_MINUTE = 60;
|
|
117
|
+
|
|
99
118
|
/** 默认 grace turns(soft limit 后宽限轮数,对齐旧实现 DEFAULT_GRACE_TURNS)。 */
|
|
100
119
|
const DEFAULT_GRACE_TURNS = 2;
|
|
101
120
|
|
|
102
|
-
/** watchdog
|
|
121
|
+
/** watchdog 下限:30 分钟。兜底防止子进程卡死在单个 tool 内(hang 的 bash/网络读),
|
|
103
122
|
* 导致 turn_end 永不触发、maxTurns limiter 失效、background 槽位/worktree/alive marker 泄漏。
|
|
104
123
|
* [M-1] 旧实现固定 30 分钟,与 maxTurns 无关——maxTurns=100 的长任务会被误杀。
|
|
105
124
|
* 现改为基于 maxTurns 动态计算(见 computeWatchdogMs)。 */
|
|
106
|
-
const
|
|
125
|
+
const WATCHDOG_FLOOR_MINUTES = 30;
|
|
126
|
+
const SPAWN_WATCHDOG_FLOOR_MS = WATCHDOG_FLOOR_MINUTES * SECONDS_PER_MINUTE * MS_PER_SECOND;
|
|
107
127
|
|
|
108
|
-
/** [M-1] 单 turn
|
|
128
|
+
/** [M-1] 单 turn 估算耗时:5 分钟(含 LLM 响应 + tool 执行)。
|
|
109
129
|
* 5 分钟是经验值——复杂 tool(大文件读写/长 bash)+ 长 LLM 响应约 3-4 分钟,
|
|
110
130
|
* 留 1-2 分钟余量。下限与按 turn 计算取 max,避免 maxTurns 过小时 watchdog 紧到误杀。 */
|
|
111
|
-
const
|
|
131
|
+
const WATCHDOG_MINUTES_PER_TURN = 5;
|
|
132
|
+
const WATCHDOG_MS_PER_TURN = WATCHDOG_MINUTES_PER_TURN * SECONDS_PER_MINUTE * MS_PER_SECOND;
|
|
112
133
|
|
|
113
134
|
// [recursive-orchestration] agent_end keep-alive 的两类等待超时(不 kill 分支的兑底)。
|
|
114
135
|
// 层主 subagent 空闲等待后代完成(steer 唤醒)期间不产生 turn,原 watchdog 已清;
|
|
@@ -143,13 +164,17 @@ export const WAKEUP_GRACE_MS = 15_000;
|
|
|
143
164
|
*
|
|
144
165
|
* @param maxTurns 调用方指定的 turn 上限;undefined/null/0 视为默认 10 turns
|
|
145
166
|
*/
|
|
167
|
+
/** maxTurns 缺省(undefined/null/0)时的估算 turn 数(computeWatchdogMs 兜底)。 */
|
|
168
|
+
const DEFAULT_MAX_TURNS_ESTIMATE = 10;
|
|
169
|
+
|
|
146
170
|
export function computeWatchdogMs(maxTurns: number | undefined | null): number {
|
|
147
|
-
const effectiveTurns = maxTurns && maxTurns > 0 ? maxTurns :
|
|
171
|
+
const effectiveTurns = maxTurns && maxTurns > 0 ? maxTurns : DEFAULT_MAX_TURNS_ESTIMATE;
|
|
148
172
|
return Math.max(SPAWN_WATCHDOG_FLOOR_MS, effectiveTurns * WATCHDOG_MS_PER_TURN);
|
|
149
173
|
}
|
|
150
174
|
|
|
151
|
-
/** stderr
|
|
152
|
-
|
|
175
|
+
/** stderr 累积上限——按字符截断(.slice 语义),非字节;64K 规模沿自原实现。
|
|
176
|
+
* 防止失控子进程打满父进程内存。保留尾部便于诊断。 */
|
|
177
|
+
const STDERR_MAX_CHARS = 65_536;
|
|
153
178
|
|
|
154
179
|
/**
|
|
155
180
|
* 跨包契约 env 名:workflow 子进程把权威 JSON Schema 通过此 env 传给 structured-output 扩展。
|
|
@@ -226,19 +251,21 @@ Your working directory (the "Working directory" in the environment block above)
|
|
|
226
251
|
// 主进程异常退出(SIGKILL/崩溃/session_shutdown dispose)时,sync 子进程会成孤儿。
|
|
227
252
|
//
|
|
228
253
|
// 本 Set 是 dispose 的最后兜底——在 abortRunningControllers(background controller.abort 路径)
|
|
229
|
-
// 之后,遍历所有仍存活的子进程(含 sync)发 SIGTERM。正常退出路径(子进程 close)会从
|
|
254
|
+
// 之后,遍历所有仍存活的子进程(含 sync)发 SIGTERM。正常退出路径(子进程 close)会从 Map 移除,
|
|
230
255
|
// 不受影响。background 子进程可能被 controller.abort 路径先 kill 一次,再被本遍历 kill 一次
|
|
231
256
|
// (对已退出的 child.kill 返回 false,无害)。
|
|
232
257
|
//
|
|
258
|
+
// [M2-B1] key = record.id(record→child 映射,busy 投递定位活进程用,见 getChildByRecord)。
|
|
233
259
|
// [export] 测试可观测(断言 dispose 后 size===0)。业务代码误外部修改。
|
|
234
|
-
export const spawnedChildren = new
|
|
260
|
+
export const spawnedChildren = new Map<string, ChildProcess>();
|
|
235
261
|
|
|
236
262
|
/**
|
|
237
263
|
* kill 所有未退出的 spawned 子进程(dispose 兜底用)。
|
|
238
264
|
*
|
|
239
|
-
* 遍历 spawnedChildren
|
|
240
|
-
* 已退出的子进程在 close/error 事件时已从
|
|
241
|
-
*
|
|
265
|
+
* 遍历 spawnedChildren Map 的 values(),对每个未 killed 的子进程发 `child.kill(signal)`。
|
|
266
|
+
* 已退出的子进程在 close/error 事件时已从 Map 移除(按句守卫 removeChildRegistration——
|
|
267
|
+
* Map 当前值仍是该 child 才删,防误删 resume spawn 的新注册),故 Map 中只剩「活着的」
|
|
268
|
+
* 或「已被 kill 但 close 事件尚未回调的」。后者用 `child.killed`
|
|
242
269
|
* 跳过——避免对一个已 kill 的子进程重复 kill。
|
|
243
270
|
*
|
|
244
271
|
* 用于 SubagentService.dispose(进程退出路径):覆盖 sync 子进程(controller 为 undefined,
|
|
@@ -251,7 +278,7 @@ export const spawnedChildren = new Set<ChildProcess>();
|
|
|
251
278
|
*/
|
|
252
279
|
export function killAllSpawnedChildren(signal: NodeJS.Signals = "SIGTERM"): number {
|
|
253
280
|
let n = 0;
|
|
254
|
-
for (const child of spawnedChildren) {
|
|
281
|
+
for (const child of spawnedChildren.values()) {
|
|
255
282
|
// 跳过已 kill 的(killed=true 表示已调过 child.kill;已退出的在 close/error 时已从 Set 移除)。
|
|
256
283
|
// 不依赖 exitCode/signalCode:close 事件回调可能晚于 dispose,此时它们仍为 null,但子进程
|
|
257
284
|
// 可能已被 controller.abort 路径 kill(killed=true)。
|
|
@@ -259,8 +286,14 @@ export function killAllSpawnedChildren(signal: NodeJS.Signals = "SIGTERM"): numb
|
|
|
259
286
|
try {
|
|
260
287
|
child.kill(signal);
|
|
261
288
|
n++;
|
|
262
|
-
} catch {
|
|
263
|
-
// 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
|
+
);
|
|
264
297
|
}
|
|
265
298
|
}
|
|
266
299
|
// dispose 全量清理;正常路径的 close/error 事件 delete 保留作 per-child 精细清理,
|
|
@@ -271,6 +304,37 @@ export function killAllSpawnedChildren(signal: NodeJS.Signals = "SIGTERM"): numb
|
|
|
271
304
|
return n;
|
|
272
305
|
}
|
|
273
306
|
|
|
307
|
+
/**
|
|
308
|
+
* 按 record id 查活子进程(busy 投递定位用,设计决策 6)。
|
|
309
|
+
*
|
|
310
|
+
* spawnedChildren 的 record→child 映射查询入口。busy 投递(follow_up/steer)需定位
|
|
311
|
+
* record 对应的活 ChildProcess 写 stdin;进程 close/error 后已从 Map 移除,返回 undefined。
|
|
312
|
+
*
|
|
313
|
+
* @param recordId ExecutionRecord.id
|
|
314
|
+
* @returns 活子进程;record 无活进程(未注册 / 已退出)时 undefined
|
|
315
|
+
*/
|
|
316
|
+
export function getChildByRecord(recordId: string): ChildProcess | undefined {
|
|
317
|
+
return spawnedChildren.get(recordId);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* [M4 记账竞态守卫] 仅当 Map 当前值仍是本 child 句柄时才删除注册。
|
|
322
|
+
*
|
|
323
|
+
* 背景:spawnedChildren 是 Map<recordId, ChildProcess>,resume spawn 会 set 覆盖旧句柄。
|
|
324
|
+
* 旧 child 的 close/error 事件异步到达(kill 后 close 回调可能晚数 tick),若 close/error
|
|
325
|
+
* handler 无条件 delete(record.id),会误删 resume spawn 刚注册的**新** child——
|
|
326
|
+
* 时序:idle timer 对旧 child SIGTERM(killed=true 但 close 未到)→ deliverMessage 判
|
|
327
|
+
* child.killed 走冷路径 resumeRound → spawn 新 child set 覆盖 → 旧 child close 此刻到达 →
|
|
328
|
+
* 误删新注册。后果:新活进程脱离记账(killAllSpawnedChildren 漏杀孤儿 + getChildByRecord
|
|
329
|
+
* undefined → busy 投递再走冷路径二次 resume → 两进程写同一 session 文件,v4 A-5 注释
|
|
330
|
+
* 自述的 P7 双写者事故模式)。按句相等守卫:set 覆盖后旧句柄 ≠ Map 当前值,天然跳过。
|
|
331
|
+
*/
|
|
332
|
+
function removeChildRegistration(recordId: string, child: ChildProcess): void {
|
|
333
|
+
if (spawnedChildren.get(recordId) === child) {
|
|
334
|
+
spawnedChildren.delete(recordId);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
274
338
|
// ============================================================
|
|
275
339
|
// 依赖注入容器 + 入参
|
|
276
340
|
// ============================================================
|
|
@@ -291,8 +355,10 @@ export interface SessionRunnerContext {
|
|
|
291
355
|
* worktree 子进程 pid 就绪回调(first header 时触发)。
|
|
292
356
|
* Runtime 层接线为 WorktreeManager.registerPid,用于注册表补全 pid。
|
|
293
357
|
* 解耦 Core 与 Runtime——session-runner 不直接依赖 WorktreeManager。
|
|
358
|
+
* [D5a] 返回 Promise(注册表 pid 补全走跨进程锁内 RMW);实现方保证不 reject
|
|
359
|
+
* (WorktreeRegistry.mutate 内部降级兜底),本侧 fire-and-forget 安全。
|
|
294
360
|
*/
|
|
295
|
-
onWorktreePid?: (branch: string, pid: number) => void
|
|
361
|
+
onWorktreePid?: (branch: string, pid: number, sessionFile?: string) => void | Promise<void>;
|
|
296
362
|
/**
|
|
297
363
|
* UI 请求处理回调。子进程发 extension_ui_request 时调用。
|
|
298
364
|
*
|
|
@@ -322,6 +388,22 @@ export interface SessionRunnerContext {
|
|
|
322
388
|
* mainCwd=checkout 路径,rootCwd 贯穿真 ROOT——session 文件落盘统一用 ROOT cwd 编码,
|
|
323
389
|
* 主进程磁盘重建才能看到全树(设计 recursive-subagent-visibility.md)。 */
|
|
324
390
|
rootCwd: string;
|
|
391
|
+
/**
|
|
392
|
+
* [V2 模块 3] chatMode 本轮完成通知挂载点。
|
|
393
|
+
*
|
|
394
|
+
* V2 决策:chatMode 进程长驻(agent_end 不 kill),「本轮完成」的真空闲边界是
|
|
395
|
+
* `agent_settled`(agent_end 之后、post-run 完成后 emit,见 pi `agent-session.js`
|
|
396
|
+
* `_runAgentPrompt` finally 块),而非 agent_end(agent_end 时 post-run 可能仍在跑)。
|
|
397
|
+
* session-runner 在 agent_settled 时调本回调,调用方(subagent-service)注入
|
|
398
|
+
* notifyComplete 通知父 agent。
|
|
399
|
+
*
|
|
400
|
+
* **本步只定义挂载点,不接线**:subagent-service 未改,回调未注入 = no-op。
|
|
401
|
+
* notify 端到端 + runSpawn resolve 语义重构留 Step 4(与统一投递 + subagent-service
|
|
402
|
+
* 一起系统处理)。非 chatMode 路径不触发 agent_settled handler,本字段无影响。
|
|
403
|
+
*
|
|
404
|
+
* @param record 当前 ExecutionRecord(chatMode、已完成本轮)
|
|
405
|
+
*/
|
|
406
|
+
onRoundSettled?: (record: ExecutionRecord) => void;
|
|
325
407
|
}
|
|
326
408
|
|
|
327
409
|
/** SessionRunner.run 的入参。 */
|
|
@@ -363,6 +445,29 @@ export interface RunOptions {
|
|
|
363
445
|
parentForkDepth?: number;
|
|
364
446
|
}
|
|
365
447
|
|
|
448
|
+
/**
|
|
449
|
+
* resume spawn 选项——重开已结束的 session 文件继续对话(M1 基建)。
|
|
450
|
+
*
|
|
451
|
+
* resume 时 pi 子进程用 `--session <sessionFile> --mode rpc` 续写原文件(探针 P-1/P-8
|
|
452
|
+
* 实测:路径不变、entry 续写、上下文保留)。runSpawn 收到此参数后:
|
|
453
|
+
* - buildSpawnArgs 追加 `--session <sessionFile>`
|
|
454
|
+
* - record.sessionFile 提前设为 resume.sessionFile(handshake 只验证 spawn 成功,不覆盖)
|
|
455
|
+
* - model/thinkingLevel 优先用此处的值(防多轮对话模型漂移,探针 P-10),否则回退 opts.resolved
|
|
456
|
+
*
|
|
457
|
+
* 注意:M1 只暴露能力,messageHandler(M2)才会真正调用。
|
|
458
|
+
*/
|
|
459
|
+
export interface SpawnResumeOpts {
|
|
460
|
+
/** resume 目标 session 文件绝对路径(pi `--session` 参数值)。 */
|
|
461
|
+
sessionFile: string;
|
|
462
|
+
/**
|
|
463
|
+
* resume 时覆盖的 model(`"provider/id"` 格式,防漂移,探针 P-10 证明必须传);
|
|
464
|
+
* 不传则回退 opts.resolved。
|
|
465
|
+
*/
|
|
466
|
+
model?: string;
|
|
467
|
+
/** resume 时覆盖的 thinkingLevel;不传则回退 opts.resolved。 */
|
|
468
|
+
thinkingLevel?: string;
|
|
469
|
+
}
|
|
470
|
+
|
|
366
471
|
// ============================================================
|
|
367
472
|
// D-A6 schemaEnv bridge
|
|
368
473
|
// ============================================================
|
|
@@ -387,12 +492,13 @@ export function applySchemaEnvToChildEnv(
|
|
|
387
492
|
// Schema 指令
|
|
388
493
|
// ============================================================
|
|
389
494
|
|
|
390
|
-
/** formatSchemaInstruction 的 JSON pretty-print 缩进。 */
|
|
391
|
-
const SCHEMA_JSON_INDENT = 2;
|
|
392
|
-
|
|
393
495
|
/**
|
|
394
496
|
* 构造 schema 指令模板(拼入 task 末尾 + steer reminder 复用)。
|
|
395
497
|
* 指令明确要求 agent 调用 structured-output tool,而非直接输出 JSON 文本。
|
|
498
|
+
*
|
|
499
|
+
* schema JSON 的 pretty-print(indent=2)由 shared/schema-jsonify.ts 的
|
|
500
|
+
* stringifySchemaCached(schema, "pretty") 产出(IF7:与 resolver 的 compact 版
|
|
501
|
+
* 共享 WeakMap 缓存条目,输出与 JSON.stringify(schema, null, 2) 逐字节一致)。
|
|
396
502
|
*/
|
|
397
503
|
export function formatSchemaInstruction(schema: Record<string, unknown>): string {
|
|
398
504
|
return [
|
|
@@ -403,7 +509,9 @@ export function formatSchemaInstruction(schema: Record<string, unknown>): string
|
|
|
403
509
|
"Do NOT pass a `schema` parameter; the system validates `data` against the authoritative schema automatically.",
|
|
404
510
|
"The schema for your `data` is:",
|
|
405
511
|
"```json",
|
|
406
|
-
|
|
512
|
+
// IF7(#13):同 schema 对象引用的 pretty stringify 走 WeakMap 缓存
|
|
513
|
+
// (与 agent-opts-resolver 的 compact 版共享缓存条目,输出逐字节不变)
|
|
514
|
+
stringifySchemaCached(schema, "pretty"),
|
|
407
515
|
"```",
|
|
408
516
|
].join("\n");
|
|
409
517
|
}
|
|
@@ -420,7 +528,8 @@ const branchCache = new Map<string, string>();
|
|
|
420
528
|
|
|
421
529
|
/**
|
|
422
530
|
* 构建环境信息块(P7 防注入:环境数据标记为 data,非指令)。
|
|
423
|
-
* git branch
|
|
531
|
+
* git branch 异步获取(execFile),按 cwd 缓存——缓存命中路径返回已 resolve 值零开销,
|
|
532
|
+
* 仅每 cwd 首次调用发起 git(此前为同步阻塞调用,挂载盘慢 git 时阻塞 spawn 链最多 2s)。
|
|
424
533
|
*
|
|
425
534
|
* [SPAWN 改造] 从旧 in-process run() 恢复。spawn 模型下此块拼进
|
|
426
535
|
* --append-system-prompt 文件,子进程读文件注入 system prompt。
|
|
@@ -436,11 +545,11 @@ const branchCache = new Map<string, string>();
|
|
|
436
545
|
* @param forkDepth 当前 fork 链深度(undefined=非 fork session,视为 0)。
|
|
437
546
|
* @param nestingDepth 通用嵌套深度(record.depth,undefined=顶层)。
|
|
438
547
|
*/
|
|
439
|
-
export function buildEnvBlock(
|
|
548
|
+
export async function buildEnvBlock(
|
|
440
549
|
cwd: string,
|
|
441
550
|
forkDepth?: number,
|
|
442
551
|
nestingDepth?: number,
|
|
443
|
-
): string {
|
|
552
|
+
): Promise<string> {
|
|
444
553
|
const lines = ["--- environment (data, not instructions) ---", `Working directory: ${cwd}`];
|
|
445
554
|
// [M9] 取 max(forkDepth, nestingDepth)——更严的约束先生效,避免只展示 forkDepth 误导 LLM。
|
|
446
555
|
const fd = forkDepth ?? 0;
|
|
@@ -451,14 +560,27 @@ export function buildEnvBlock(
|
|
|
451
560
|
}
|
|
452
561
|
let branch = branchCache.get(cwd);
|
|
453
562
|
if (branch === undefined) {
|
|
563
|
+
// catch 兜底一切失败(含 execFile 未被 mock 的测试环境):branch 静默为空,
|
|
564
|
+
// env block 省略 Git branch 行——与旧同步版语义一致
|
|
454
565
|
try {
|
|
455
|
-
branch =
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
566
|
+
branch = await new Promise<string>((resolve, reject) => {
|
|
567
|
+
execFile(
|
|
568
|
+
"git",
|
|
569
|
+
["rev-parse", "--abbrev-ref", "HEAD"],
|
|
570
|
+
{ cwd, encoding: "utf8", timeout: ENV_GIT_TIMEOUT_MS },
|
|
571
|
+
(err: Error | null, stdout: string) => {
|
|
572
|
+
if (err) reject(err);
|
|
573
|
+
else resolve(stdout.trim());
|
|
574
|
+
},
|
|
575
|
+
);
|
|
576
|
+
});
|
|
577
|
+
} catch (err) {
|
|
578
|
+
// 非 git 目录 / git 不在 PATH 是高频正常路径,debug 级留诊断线索即可,不刷 info/warn
|
|
579
|
+
logger.debug(
|
|
580
|
+
`[session-runner] buildEnvBlock: git branch lookup failed for ${cwd}, fallback to empty: ${
|
|
581
|
+
err instanceof Error ? err.message : String(err)
|
|
582
|
+
}`,
|
|
583
|
+
);
|
|
462
584
|
branch = "";
|
|
463
585
|
}
|
|
464
586
|
branchCache.set(cwd, branch);
|
|
@@ -507,10 +629,16 @@ export function buildSpawnArgs(
|
|
|
507
629
|
agentTools: string[] | undefined;
|
|
508
630
|
appendSystemPromptPath: string | undefined;
|
|
509
631
|
sessionDir: string;
|
|
632
|
+
/**
|
|
633
|
+
* resume 目标 session 文件路径。存在时紧跟 `--session-dir <dir>` 追加
|
|
634
|
+
* `--session <file>`,pi 续写原 session 文件而非新建(探针 P-1/P-8 实测续写成立)。
|
|
635
|
+
* undefined = 新 session(当前行为,向后兼容)。
|
|
636
|
+
*/
|
|
637
|
+
sessionFile?: string;
|
|
510
638
|
forkSource: string | undefined;
|
|
511
639
|
skillPaths: string[] | undefined;
|
|
512
640
|
/**
|
|
513
|
-
* 镜像自主进程 argv 的 flag(--no-extensions/--approve/--extension)。
|
|
641
|
+
* 镜像自主进程 argv 的 flag(--no-extensions/--approve/--extension/--no-context-files)。
|
|
514
642
|
* undefined 或全空/全 false 时行为不变(向后兼容)。
|
|
515
643
|
*/
|
|
516
644
|
mirrorFlags?: MirrorFlags;
|
|
@@ -520,6 +648,10 @@ export function buildSpawnArgs(
|
|
|
520
648
|
// positional task arg / -p flag 在 rpc mode 下被 resolveAppMode 无视。
|
|
521
649
|
// task 由 runSpawn 内 sendPromptCommand 写 child.stdin 驱动。
|
|
522
650
|
const args: string[] = ["--mode", "rpc", "--session-dir", params.sessionDir];
|
|
651
|
+
// resume:紧跟 --session-dir 追加 --session <file>,pi 续写原 session 文件(P-8)。
|
|
652
|
+
if (params.sessionFile) {
|
|
653
|
+
args.push("--session", params.sessionFile);
|
|
654
|
+
}
|
|
523
655
|
if (params.model) args.push("--model", params.model);
|
|
524
656
|
if (params.thinkingLevel && params.model) {
|
|
525
657
|
// thinking level 通过 model 后缀 :level 传递(pi CLI 约定)
|
|
@@ -543,12 +675,17 @@ export function buildSpawnArgs(
|
|
|
543
675
|
args.push("--skill", sp);
|
|
544
676
|
}
|
|
545
677
|
}
|
|
546
|
-
// 镜像主进程的 extension/approve flag:让子进程 extension
|
|
547
|
-
// undefined/空值时不追加(向后兼容)。顺序紧跟
|
|
678
|
+
// 镜像主进程的 extension/approve/context-files flag:让子进程 extension 加载与
|
|
679
|
+
// context files 行为与主进程一致。undefined/空值时不追加(向后兼容)。顺序紧跟
|
|
680
|
+
// skill 之后,注入类 flag 集中。
|
|
548
681
|
const mf = params.mirrorFlags;
|
|
549
682
|
if (mf) {
|
|
550
683
|
if (mf.noExtensions) args.push("--no-extensions");
|
|
551
684
|
if (mf.approve) args.push("--approve");
|
|
685
|
+
// 镜像 --no-context-files:@zhushanwen/pi-system-prompt(经 --extension 镜像进入
|
|
686
|
+
// 子进程)靠子进程 argv 检测此 flag 守卫全局 AGENTS.md 注入——不镜像则用户的
|
|
687
|
+
// context files opt-out 只对主进程生效,每个 subagent 仍被注入。
|
|
688
|
+
if (mf.noContextFiles) args.push("--no-context-files");
|
|
552
689
|
for (const ep of mf.extensionPaths) {
|
|
553
690
|
args.push("--extension", ep);
|
|
554
691
|
}
|
|
@@ -557,18 +694,69 @@ export function buildSpawnArgs(
|
|
|
557
694
|
}
|
|
558
695
|
|
|
559
696
|
/**
|
|
560
|
-
*
|
|
697
|
+
* [持久化 C] best-effort 写 alive marker(running 期间崩溃恢复用,子进程 pid + session id)。
|
|
561
698
|
*
|
|
562
|
-
*
|
|
563
|
-
*
|
|
699
|
+
* 两处调用点(header 分支 / get_state 握手回填)原为两段相同的空 catch try/catch——
|
|
700
|
+
* 收敛为单函数后失败走 debug 日志(marker 是崩溃恢复的增强信号,缺失只降低可恢复性,
|
|
701
|
+
* 不影响执行主流程),消除 taste/no-silent-catch。
|
|
564
702
|
*/
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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
|
+
);
|
|
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;
|
|
572
760
|
|
|
573
761
|
// a. transient 寄存器(同 run():tool_end 缺 args 时回填)
|
|
574
762
|
const pendingTools = new Map<string, { toolName: string; args?: unknown }>();
|
|
@@ -576,8 +764,7 @@ export async function runSpawn(
|
|
|
576
764
|
// b. turnLimiter(spawn 版:abort = proc.kill;steer 是 no-op)
|
|
577
765
|
// [M1] rpc mode 是长驻进程(agent_end 后不自动退出),maxTurns soft limit 依赖
|
|
578
766
|
// graceTurns 后的 abort(proc.kill SIGTERM)兑现。agent 自然结束时由 stdout pump
|
|
579
|
-
// 的 agent_end 拦截 kill
|
|
580
|
-
let proc: ChildProcess | undefined;
|
|
767
|
+
// 的 agent_end 拦截 kill(见 attachStdoutPump)。steer 通道当前未接通(见下方 steer no-op 注释)。
|
|
581
768
|
const limiter = createTurnLimiter({
|
|
582
769
|
maxTurns: opts.maxTurns ?? 0,
|
|
583
770
|
graceTurns: opts.graceTurns ?? DEFAULT_GRACE_TURNS,
|
|
@@ -586,13 +773,20 @@ export async function runSpawn(
|
|
|
586
773
|
// 但未实现写入逻辑)。补偿已在启动时注入 WRAP_UP_HINT 让 agent 主动收尾。
|
|
587
774
|
},
|
|
588
775
|
abort: () => {
|
|
589
|
-
proc?.kill("SIGTERM");
|
|
776
|
+
state.proc?.kill("SIGTERM");
|
|
590
777
|
},
|
|
591
778
|
});
|
|
592
779
|
|
|
593
|
-
//
|
|
594
|
-
const
|
|
595
|
-
|
|
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
|
+
};
|
|
596
790
|
|
|
597
791
|
// ── SDK 事件累积器(闭包模式与 run() 完全相同)──
|
|
598
792
|
const accumulateMessageEnd = (raw: SdkEvent): void => {
|
|
@@ -610,6 +804,31 @@ export async function runSpawn(
|
|
|
610
804
|
};
|
|
611
805
|
|
|
612
806
|
const handleSdkEvent = (raw: SdkEvent): void => {
|
|
807
|
+
// [V2 模块 3] agent_settled:真空闲边界(agent_end 之后、post-run 完成后才 emit)。
|
|
808
|
+
// chatMode:arm idle timer(超时 SIGTERM 回收)+ 通知本轮完成(onRoundSettled)。
|
|
809
|
+
// 非 chatMode:忽略(agent_end handler 的一次性 kill 已处理,进程不会活到 agent_settled)。
|
|
810
|
+
if (isAgentSettledEvt(raw)) {
|
|
811
|
+
if (record.chatMode) {
|
|
812
|
+
armIdleTimer(record.id, () => {
|
|
813
|
+
// onTimeout 复用现有 kill 路径:child.kill("SIGTERM") 触发 close → close handler
|
|
814
|
+
// 统一 cleanup(spawnedChildren.delete / get_stateListeners.clear / resolve)。
|
|
815
|
+
// 与 agent_end handler 现有 SIGTERM 分支一致,不新造 cleanup。
|
|
816
|
+
const child = getChildByRecord(record.id);
|
|
817
|
+
if (child && !child.killed) child.kill("SIGTERM");
|
|
818
|
+
}, record.idleTimeoutMs);
|
|
819
|
+
// [SP-9] chatMode 每轮 reset turn-limiter:新一轮开始(续聊)时,
|
|
820
|
+
// maxTurns/graceTurns 不跨轮累计(续聊本质是无限轮,累计上限违背 G1)。
|
|
821
|
+
// reset steered/aborted 标志 + turnCount 归零,下一轮独立计数。
|
|
822
|
+
limiter.reset();
|
|
823
|
+
record.turnCount = 0;
|
|
824
|
+
ctx.onRoundSettled?.(record);
|
|
825
|
+
// [V2 决策 2] chatMode 首轮:agent_settled = 本轮真空闲,提前 resolve runSpawn
|
|
826
|
+
//(exit code 0,进程仍保活 idle timer armed)。runAndFinalize 拿到 result 后走
|
|
827
|
+
// chatMode 首轮分支(不进 finalize 分流),onRoundSettled 已 notify 主 agent。
|
|
828
|
+
state.resolveRun?.(0);
|
|
829
|
+
}
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
613
832
|
switch (raw.type) {
|
|
614
833
|
case "tool_execution_start": {
|
|
615
834
|
const toolName = raw.toolName ?? "";
|
|
@@ -649,46 +868,35 @@ export async function runSpawn(
|
|
|
649
868
|
agentEvent({ type: "compaction" });
|
|
650
869
|
return;
|
|
651
870
|
}
|
|
871
|
+
// [review 修复] 已删除 turn_start / message_start 两 case 的 pendingMessages
|
|
872
|
+
// 消费确认 shift(MF-5):三段消费链(deliverToRunning push / message_start(user)
|
|
873
|
+
// shift / redeliverPending 补投)随 deliverToRunning 一并移除——SP-5 upgrade 后
|
|
874
|
+
// 无生产调用方,整条链路不可达。两 case 均为 no-op,落 default 即可。
|
|
652
875
|
default:
|
|
653
876
|
return;
|
|
654
877
|
}
|
|
655
878
|
};
|
|
656
879
|
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
updateFromEvent(record, event);
|
|
660
|
-
if (event.type === "turn_end") limiter.onTurnEnd(record.turnCount);
|
|
661
|
-
// text_delta 分流到 stream 通道(在 onEvent 之前)。
|
|
662
|
-
// 双通道互斥设计:background 路径 stream 有值、onEvent=undefined;
|
|
663
|
-
// workflow 路径 onEvent 有值、stream=undefined。详见 W3 注释。
|
|
664
|
-
if (event.type === "text_delta") opts.stream?.onDelta(event.delta);
|
|
665
|
-
opts.onEvent?.(event);
|
|
666
|
-
};
|
|
667
|
-
|
|
668
|
-
// d. session 目录(与 in-process 一致:list/恢复可发现同一目录)
|
|
669
|
-
// [MF-3] 用 ctx.rootCwd(贯穿真 ROOT)而非 ctx.mainCwd 编码:worktree 模式下 mainCwd 是
|
|
670
|
-
// 子进程的 checkout 路径,按它编码会让深层 record 落到 enc(worktree) 段,ROOT 磁盘重建
|
|
671
|
-
// 扫不到 → 全树可见性深度 ≥ 2 断裂。rootCwd 与 store 构造同源(subagent-service 同键),
|
|
672
|
-
// 保证 runSpawn 写入的 session 文件就在本进程/ROOT store 扫描的目录里。
|
|
673
|
-
const sessionDir = getSubagentSessionDir(ctx.agentDir, ctx.rootCwd);
|
|
674
|
-
fs.mkdirSync(sessionDir, { recursive: true });
|
|
675
|
-
|
|
676
|
-
// e. worktree 模式:checkout 路径作为 spawn cwd(隔离文件系统)
|
|
677
|
-
// worktree checkout 已由 worktree-manager 在 execute 前创建,此处只取路径。
|
|
678
|
-
const spawnCwd = opts.worktree?.path ?? ctx.cwd;
|
|
679
|
-
|
|
680
|
-
// f. fork source:父 session 文件路径(--fork 参数)
|
|
681
|
-
const forkSource = opts.fork ? ctx.mainSessionFile : undefined;
|
|
880
|
+
return handleSdkEvent;
|
|
881
|
+
}
|
|
682
882
|
|
|
683
|
-
|
|
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> {
|
|
684
893
|
// [M1 恢复] 环境块(cwd / fork depth / git branch)拼在最前面,与旧 in-process
|
|
685
894
|
// buildAppendSystemPrompt 顺序一致——parts[0] 是环境块,其后 agent systemPrompt、再后调用方片段。
|
|
686
895
|
const ownForkDepth = opts.fork ? (opts.parentForkDepth ?? 0) + 1 : undefined;
|
|
687
|
-
let tempPromptFile: { dir: string; filePath: string } | undefined;
|
|
688
896
|
// [M9] buildEnvBlock 取 max(forkDepth, nestingDepth):record.depth === nestingDepth(都从
|
|
689
897
|
// execCtxAls 派生,见 createRecordForMode L425-427 与 execute L257-258),传它让 env block
|
|
690
898
|
// 展示更严的约束(混合嵌套链下通用护栏可能先于 fork 护栏拒绝)。
|
|
691
|
-
const appendParts: string[] = [buildEnvBlock(ctx.cwd, ownForkDepth, record.depth)];
|
|
899
|
+
const appendParts: string[] = [await buildEnvBlock(ctx.cwd, ownForkDepth, record.depth)];
|
|
692
900
|
if (opts.agentConfig?.systemPrompt) appendParts.push(opts.agentConfig.systemPrompt);
|
|
693
901
|
if (opts.appendSystemPrompt) appendParts.push(...opts.appendSystemPrompt);
|
|
694
902
|
// [M1 补偿] rpc mode 的 steer 通道当前未接通,改为启动时预置 wrap-up 提示——
|
|
@@ -705,10 +913,20 @@ export async function runSpawn(
|
|
|
705
913
|
appendParts.push(WORKTREE_GUIDANCE_PROMPT);
|
|
706
914
|
}
|
|
707
915
|
if (appendParts.length > 0) {
|
|
708
|
-
|
|
916
|
+
return writePromptToTempFile(record.agent, appendParts.join("\n\n"));
|
|
709
917
|
}
|
|
918
|
+
return undefined;
|
|
919
|
+
}
|
|
710
920
|
|
|
711
|
-
|
|
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> {
|
|
712
930
|
const childEnv: Record<string, string | undefined> = { ...process.env };
|
|
713
931
|
if (opts.fork && opts.parentForkDepth !== undefined) {
|
|
714
932
|
childEnv.PI_SUBAGENT_FORK_DEPTH = String(opts.parentForkDepth + 1);
|
|
@@ -727,33 +945,390 @@ export async function runSpawn(
|
|
|
727
945
|
// 子进程的 store/runSpawn 落盘目录须统一编码在 enc(ROOT cwd) 段(与身份贯穿同构),
|
|
728
946
|
// 否则 ROOT 磁盘重建扫不到深层 record(见 subagent-service ENV_ROOT_CWD 注释)。
|
|
729
947
|
childEnv.PI_SUBAGENT_ROOT_CWD = ctx.rootCwd;
|
|
948
|
+
// [M4 identity 子进程写] 子进程 session_start hook 读这些 env → pi.appendEntry 写
|
|
949
|
+
// subagent-identity custom entry(V2 决策 5)。旧实现父进程 fs.appendFileSync 补写的
|
|
950
|
+
// custom entry 缺 id/parentId → 污染 pi _buildIndex leafId → message tree 断成两棵
|
|
951
|
+
// → 多轮对话丢上下文。改由子进程(session 文件所有者)用 appendEntry 写,pi 自动生成 id/parentId。
|
|
952
|
+
// id/rootSessionId/depth/forkDepth 复用上方身份贯穿 env(SELF_RECORD_ID/ROOT_SESSION_ID/DEPTH/FORK_DEPTH),
|
|
953
|
+
// 此处补 identity 专属字段:agent/mode/task/slug/startedAt/parentRecordId/chatMode。
|
|
954
|
+
childEnv.PI_SUBAGENT_AGENT = record.agent;
|
|
955
|
+
childEnv.PI_SUBAGENT_MODE = record.mode;
|
|
956
|
+
childEnv.PI_SUBAGENT_TASK = record.task;
|
|
957
|
+
childEnv.PI_SUBAGENT_SLUG = record.slug;
|
|
958
|
+
childEnv.PI_SUBAGENT_STARTED_AT = String(record.startedAt);
|
|
959
|
+
childEnv.PI_SUBAGENT_PARENT_RECORD_ID = record.parentRecordId;
|
|
960
|
+
childEnv.PI_SUBAGENT_CHAT_MODE =
|
|
961
|
+
record.chatMode !== undefined ? String(record.chatMode) : undefined;
|
|
962
|
+
// [review round2] worktree 隔离标志贯穿:resume 轮 opts.worktree 来自 record.worktreeHandle
|
|
963
|
+
//(同进程内保留),子进程 identity entry 据此记 worktree:true——跨重启重建时据此拒绝续聊
|
|
964
|
+
//(WorktreeHandle 不可序列化,reattach 不可行,静默回落主 repo 会破坏隔离)。
|
|
965
|
+
childEnv.PI_SUBAGENT_WORKTREE = opts.worktree !== undefined ? "true" : undefined;
|
|
730
966
|
// D-A6 bridge: schema 激活 structured-output 扩展注册 tool(workflow 编排层需要)
|
|
731
967
|
applySchemaEnvToChildEnv(childEnv, opts.schemaEnv);
|
|
968
|
+
return childEnv;
|
|
969
|
+
}
|
|
732
970
|
|
|
733
|
-
|
|
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> {
|
|
734
982
|
// [M3 恢复] skillPaths: 主 session 的 skillDirs + 调用方传入的 skillPath。
|
|
735
983
|
// ADR-031 后 skillDirs 固定为空,仅 opts.skillPath 生效(agent({skill}) 解析)。
|
|
736
984
|
const skillPaths = [...ctx.skillDirs, opts.skillPath].filter(
|
|
737
985
|
(p): p is string => typeof p === "string" && p.length > 0,
|
|
738
986
|
);
|
|
739
987
|
const modelId = opts.resolved.model.id;
|
|
988
|
+
// [M1 resume] resume 时 model/thinkingLevel 优先用 resume 值(防多轮对话模型漂移,P-10),
|
|
989
|
+
// 否则回退 opts.resolved。resume.model 已是 "provider/id" 格式,与 buildSpawnArgs 约定一致。
|
|
990
|
+
const effectiveModel = resume?.model ?? `${opts.resolved.model.provider}/${modelId}`;
|
|
991
|
+
const effectiveThinkingLevel = resume?.thinkingLevel ?? opts.resolved.thinkingLevel;
|
|
740
992
|
const spawnArgs = buildSpawnArgs(
|
|
741
993
|
{
|
|
742
|
-
model:
|
|
743
|
-
thinkingLevel:
|
|
994
|
+
model: effectiveModel,
|
|
995
|
+
thinkingLevel: effectiveThinkingLevel,
|
|
744
996
|
agentTools: opts.agentConfig?.tools,
|
|
745
997
|
appendSystemPromptPath: tempPromptFile?.filePath,
|
|
746
998
|
sessionDir,
|
|
999
|
+
sessionFile: resume?.sessionFile,
|
|
747
1000
|
forkSource,
|
|
748
1001
|
skillPaths: skillPaths.length > 0 ? skillPaths : undefined,
|
|
749
1002
|
// 镜像主进程 argv 的 extension/approve flag,让子进程加载行为对齐主进程
|
|
750
1003
|
mirrorFlags: mirrorMainProcessFlags(process.argv),
|
|
751
1004
|
},
|
|
752
1005
|
);
|
|
753
|
-
|
|
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);
|
|
754
1331
|
|
|
755
|
-
// 解析出的 session header(stdout 首行,含 session id)
|
|
756
|
-
let sessionHeader: SpawnSessionHeader | undefined;
|
|
757
1332
|
// 累积 stderr(错误诊断用)
|
|
758
1333
|
let stderrBuffer = "";
|
|
759
1334
|
|
|
@@ -764,18 +1339,22 @@ export async function runSpawn(
|
|
|
764
1339
|
stdio: ["pipe", "pipe", "pipe"],
|
|
765
1340
|
env: childEnv,
|
|
766
1341
|
});
|
|
767
|
-
proc = child;
|
|
1342
|
+
state.proc = child;
|
|
768
1343
|
// [worktree-reaper-fix] 同步补全注册表 pid:spawn 返回后 child.pid 立即可得(Node.js
|
|
769
1344
|
// 同步属性),无需等任何 stdout 事件。原补全点挂在 header 分支(下方 stdout handler 内),
|
|
770
1345
|
// 而 RPC mode(buildSpawnArgs 固定 --mode rpc)不输出 header 行——pid 恒为 0,超
|
|
771
1346
|
// SPAWN_GRACE_MS 后被 reaper 当孤儿误删活 worktree(2026-08-11 cw 递归编排整树失活事故)。
|
|
772
1347
|
// header 分支调用保留:json mode 回切时仍能补全,updatePid 同 branch 覆盖写幂等,无副作用。
|
|
773
1348
|
if (opts.worktree && child.pid) {
|
|
774
|
-
|
|
1349
|
+
// [D5a] void:回调可能返回 Promise(锁内 RMW),契约保证不 reject,fire-and-forget。
|
|
1350
|
+
void ctx.onWorktreePid?.(opts.worktree.branch, child.pid);
|
|
775
1351
|
}
|
|
776
1352
|
// [C1] track 子进程供 dispose 兜底 kill(sync + background 均注册——sync 无 controller,
|
|
777
|
-
// abortRunningControllers 跳过它,靠本
|
|
778
|
-
spawnedChildren.
|
|
1353
|
+
// abortRunningControllers 跳过它,靠本 Map 兜底)。close/error 后按句守卫移除(已退出无需再 kill)。
|
|
1354
|
+
spawnedChildren.set(record.id, child);
|
|
1355
|
+
// [V2 决策 3] spawn 后 child.pid 同步立即可得,记录到 record 内存(lifecycle-manager
|
|
1356
|
+
// 孤儿扫描用,Step 5 接入持久化)。resume spawn 时此处同样覆盖更新(pid 可能已变)。
|
|
1357
|
+
if (child.pid !== undefined) record.pid = child.pid;
|
|
779
1358
|
|
|
780
1359
|
// [worktree-reaper-fix] 同步补全注册表 pid:spawn 返回后 child.pid 立即可得(Node.js
|
|
781
1360
|
// 同步属性),无需等任何 stdout 事件。原补全点挂在 header 分支(下方 stdout handler 内),
|
|
@@ -787,7 +1366,8 @@ export async function runSpawn(
|
|
|
787
1366
|
// 注册表写失败最坏后果是条目停留 pid=0,由 reaper 宽限回收兜底)。
|
|
788
1367
|
if (opts.worktree && child.pid) {
|
|
789
1368
|
try {
|
|
790
|
-
|
|
1369
|
+
// [D5a] void:回调可能返回 Promise(锁内 RMW),契约保证不 reject,fire-and-forget。
|
|
1370
|
+
void ctx.onWorktreePid?.(opts.worktree.branch, child.pid);
|
|
791
1371
|
} catch (err) {
|
|
792
1372
|
logger.warn("[worktree] worktree pid registration failed (defensive)", {
|
|
793
1373
|
branch: opts.worktree.branch,
|
|
@@ -804,6 +1384,31 @@ export async function runSpawn(
|
|
|
804
1384
|
child.stdout.setEncoding("utf8");
|
|
805
1385
|
child.stderr.setEncoding("utf8");
|
|
806
1386
|
|
|
1387
|
+
// [v4 A-1] 异步 stdin 'error' listener。writeStdinLine 的 try/catch 只覆盖同步 write 抛错;
|
|
1388
|
+
// 子进程退出后内核回写 EPIPE 会以异步 stream 'error' event 到达,若无 listener 会让 Node
|
|
1389
|
+
// 抛 unhandled 'error' 崩主进程(P1)。必须在首次 stdin 写入(下方 sendPromptCommand)前注册。
|
|
1390
|
+
// handler 行为:①移出 spawnedChildren 标记 dead(与 child.on('error') 同模式);
|
|
1391
|
+
// ②recordEpipeFailure 合并同步/异步计数;③logger.warn 记录一次。
|
|
1392
|
+
// [v4 A-1 裁决] handler 内**不 throw**——stream 'error' listener 内 throw 会经 Node 内部
|
|
1393
|
+
// emit() 传播为 uncaughtException 崩主进程,违背 A-1 防崩核心目标。达阈值的 throw 留给
|
|
1394
|
+
// 同步路径(deliverMessage catch 合并计数达 EPIPE_FAILURE_THRESHOLD 时同步 throw,不崩)。
|
|
1395
|
+
// async handler 只移句柄 + 计数 + warn;进程已 dead(移句柄),下次 deliverMessage 检测
|
|
1396
|
+
// dead 走冷路径,冷路径 write EPIPE 同步计数达阈值同步 throw——防死循环且不崩。
|
|
1397
|
+
child.stdin.on("error", (err: Error) => {
|
|
1398
|
+
// [M4] 按值守卫:resume spawn 已覆盖注册时不误删新 child(见 removeChildRegistration)
|
|
1399
|
+
removeChildRegistration(record.id, child);
|
|
1400
|
+
const count = recordEpipeFailure(record.id);
|
|
1401
|
+
logger.warn(`[subagents] async stdin error for ${record.id}`, {
|
|
1402
|
+
detail: err.message,
|
|
1403
|
+
epipeCount: count,
|
|
1404
|
+
threshold: EPIPE_FAILURE_THRESHOLD,
|
|
1405
|
+
hint:
|
|
1406
|
+
count >= EPIPE_FAILURE_THRESHOLD
|
|
1407
|
+
? "sync path will throw on next write EPIPE"
|
|
1408
|
+
: undefined,
|
|
1409
|
+
});
|
|
1410
|
+
});
|
|
1411
|
+
|
|
807
1412
|
// 喂 prompt 命令驱动子进程开始处理 task。pi runRpcMode 只消费 stdin RpcCommand,
|
|
808
1413
|
// 不读 positional arg;必须在 spawn 后主动写,否则子进程阻塞、totalTokens 恒 0。
|
|
809
1414
|
// 时机安全:pipe 内核缓冲不丢;pi 在 rebindSession 后才挂 stdin reader。
|
|
@@ -826,156 +1431,16 @@ export async function runSpawn(
|
|
|
826
1431
|
// 主进程退出时(session_shutdown reason=quit)dispose 会 abort running controller
|
|
827
1432
|
// → 本监听器 kill 子进程。无此 unref,watchdog timer 会拖住 event loop 阻止退出。
|
|
828
1433
|
const watchdogMs = computeWatchdogMs(opts.maxTurns);
|
|
829
|
-
|
|
830
|
-
watchdog.unref();
|
|
1434
|
+
state.watchdog = setTimeout(() => child.kill("SIGTERM"), watchdogMs);
|
|
1435
|
+
state.watchdog.unref();
|
|
831
1436
|
|
|
832
1437
|
// [recursive-orchestration] agent_end 有活跃后代时的等待超时(不 kill 分支的兑底)。
|
|
833
1438
|
// 层主 subagent 空闲等待后代完成(steer 唤醒)期间不产生 turn,原 watchdog 已清;
|
|
834
1439
|
// 此后代卡死(永不完成)时此 timer 保证进程最终回收。超时 kill → finalize 视为正常
|
|
835
1440
|
// 完成 → 通知父(父查 cw status 发现未 closed 会走 L2/L3 重派,见 planning-agent 模板)。
|
|
836
1441
|
|
|
837
|
-
// stdout pump:逐行解析 → handleSdkEvent / enqueueUiRequest
|
|
838
|
-
const
|
|
839
|
-
// FR-4: get_state RPC response 监听器(id → resolver)。
|
|
840
|
-
// parseSpawnLine 返回 kind:"response" 时,按 command+id 匹配 resolver。
|
|
841
|
-
const get_stateListeners = new Map<string, (data: unknown) => void>();
|
|
842
|
-
let stdoutBuffer = "";
|
|
843
|
-
|
|
844
|
-
// [#18] 握手状态变量在 stdout handler 注册之前定义,消除"handler 闭包依赖同 tick
|
|
845
|
-
// 后续 const 初始化"的隐式顺序假设——handler 现在直接引用已初始化的变量,不靠
|
|
846
|
-
// "data 事件必然在下一 tick 才触发”的运行时不变式兜底。
|
|
847
|
-
const handshakeResultRef: { current?: GetStateResult } = {};
|
|
848
|
-
let settleHandshake: (() => void) | undefined;
|
|
849
|
-
const handshakeSettled: Promise<void> = new Promise((resolveSettled) => {
|
|
850
|
-
settleHandshake = resolveSettled;
|
|
851
|
-
});
|
|
852
|
-
/** 握手完成统一入口:记录结果 + 回填 sessionFile + 写 alive marker + settle。 */
|
|
853
|
-
const finishHandshake = (r: GetStateResult): void => {
|
|
854
|
-
handshakeResultRef.current = r;
|
|
855
|
-
// 仅当 header 未先行设置 record.sessionFile 时回填(RPC mode 路径)。
|
|
856
|
-
if (r.sessionFile && !record.sessionFile) {
|
|
857
|
-
record.sessionFile = r.sessionFile;
|
|
858
|
-
if (child.pid) {
|
|
859
|
-
try {
|
|
860
|
-
writeAliveMarker(r.sessionFile, {
|
|
861
|
-
pid: child.pid,
|
|
862
|
-
id: r.sessionId ?? record.id,
|
|
863
|
-
startedAt: Date.now(),
|
|
864
|
-
});
|
|
865
|
-
} catch {
|
|
866
|
-
// best-effort:alive marker 失败不影响执行
|
|
867
|
-
}
|
|
868
|
-
}
|
|
869
|
-
}
|
|
870
|
-
settleHandshake?.();
|
|
871
|
-
settleHandshake = undefined;
|
|
872
|
-
};
|
|
873
|
-
|
|
874
|
-
child.stdout.on("data", (data: string) => {
|
|
875
|
-
stdoutBuffer += data;
|
|
876
|
-
const lines = stdoutBuffer.split("\n");
|
|
877
|
-
stdoutBuffer = lines.pop() ?? ""; // 保留最后未完整行
|
|
878
|
-
for (const line of lines) {
|
|
879
|
-
const parsed = parseSpawnLine(line);
|
|
880
|
-
if (!parsed) continue;
|
|
881
|
-
if (parsed.kind === "header") {
|
|
882
|
-
sessionHeader = parsed.header;
|
|
883
|
-
// 回填 record.sessionFile(deriveSessionFilePath 推导路径)
|
|
884
|
-
record.sessionFile = deriveSessionFilePath(parsed.header, sessionDir);
|
|
885
|
-
// [持久化 C] alive marker:running 期间崩溃恢复用。子进程 pid + session id。
|
|
886
|
-
// 与 in-process 逻辑对齐(记 sessionFile + pid),改为子进程 pid。
|
|
887
|
-
if (record.sessionFile && child.pid) {
|
|
888
|
-
try {
|
|
889
|
-
writeAliveMarker(record.sessionFile, {
|
|
890
|
-
pid: child.pid,
|
|
891
|
-
id: parsed.header.id,
|
|
892
|
-
startedAt: Date.now(),
|
|
893
|
-
});
|
|
894
|
-
} catch {
|
|
895
|
-
// best-effort:alive marker 失败不影响执行
|
|
896
|
-
}
|
|
897
|
-
}
|
|
898
|
-
// [全局注册表] worktree 模式:补全注册表条目的 pid。
|
|
899
|
-
// create 时 pid 未知写 0 占位,此处拿到 child.pid 后回调 WorktreeManager.registerPid。
|
|
900
|
-
// 取代旧的 .session mapping sidecar——注册表是 reaper 的唯一数据源。
|
|
901
|
-
if (opts.worktree && child.pid) {
|
|
902
|
-
ctx.onWorktreePid?.(opts.worktree.branch, child.pid);
|
|
903
|
-
}
|
|
904
|
-
// FR-4 加速路径:header 到达即 finishHandshake(header 已提供 sessionId,
|
|
905
|
-
// 足以推导 sessionFile + 兜底查找,无需等 get_state response)。
|
|
906
|
-
// [#25] buildSpawnArgs 固定 --mode rpc,RPC mode 不发 header——此分支当前不触发,
|
|
907
|
-
// 仅为未来 mode 回切(如 json mode 调试)保留:届时 header 先到可省去 get_state 握手等待。
|
|
908
|
-
if (settleHandshake) {
|
|
909
|
-
finishHandshake({
|
|
910
|
-
...(record.sessionFile ? { sessionFile: record.sessionFile } : {}),
|
|
911
|
-
sessionId: parsed.header.id,
|
|
912
|
-
});
|
|
913
|
-
}
|
|
914
|
-
} else if (parsed.kind === "event") {
|
|
915
|
-
const evt = parsed.event;
|
|
916
|
-
// agent_end(willRetry=false)= agent 自然完成。rpc mode 子进程不自动退出
|
|
917
|
-
//(runRpcMode 末尾 return new Promise(() => {}) 长驻等命令),需主动 kill
|
|
918
|
-
// 触发 close → runSpawn resolve。willRetry=true 时 agent 会重试,不能 kill。
|
|
919
|
-
if (isAgentEndEvt(evt)) {
|
|
920
|
-
if (evt.willRetry) {
|
|
921
|
-
// agent 会重试,不能 kill。
|
|
922
|
-
} else {
|
|
923
|
-
// [recursive-orchestration] 条件 kill:读子进程 session 文件算活跃后代
|
|
924
|
-
// (pending:register − unregister 差集)。有活跃后代(background subagent /
|
|
925
|
-
// workflow)→ 保持进程 idle,等后代完成时 notifier triggerTurn steer 唤醒;
|
|
926
|
-
// 无 → 正常完成,kill 触发 close → runSpawn resolve。
|
|
927
|
-
const pending = readActivePendingFromSessionFile(record.sessionFile);
|
|
928
|
-
if (pending.count > 0 || pending.error) {
|
|
929
|
-
if (pending.error) {
|
|
930
|
-
logger.warn(
|
|
931
|
-
`[session-runner] agent_end: keep alive (sessionFile unreadable, conservative): ${pending.error}`,
|
|
932
|
-
);
|
|
933
|
-
} else {
|
|
934
|
-
logger.debug(
|
|
935
|
-
`[session-runner] agent_end: keep alive, ${pending.count} active descendant(s) pending`,
|
|
936
|
-
);
|
|
937
|
-
}
|
|
938
|
-
// 空闲等待期间不消耗 turn:清原 watchdog,换等待后代超时(每次 agent_end 重新计时)。
|
|
939
|
-
// [MF-4] 动态超时 = computeWatchdogMs(maxTurns):真实后代在跑,慢任务(wave 开发
|
|
940
|
-
// 数小时)不能被固定 2h 误杀——2h 到点 kill 会连坐 SubagentService.dispose 的
|
|
941
|
-
// killAllSpawnedChildren 杀全部子进程,L2 重派丢在途工作。maxTurns 大则超时长。
|
|
942
|
-
clearTimeout(watchdog);
|
|
943
|
-
watchdog = setTimeout(() => child.kill("SIGTERM"), computeWatchdogMs(opts.maxTurns));
|
|
944
|
-
watchdog.unref();
|
|
945
|
-
} else if (pending.recentUnregister) {
|
|
946
|
-
// 差集 0 但最近有 unregister:后代刚完成,notify 唤醒可能在路上(竞态窗口),
|
|
947
|
-
// 保持进程——父被唤醒后的下一次 agent_end 会正常判定。
|
|
948
|
-
// [MF-3] 秒级宽限:此分支在每层「最终 turn」必命中(closeout 的 agent_end 距
|
|
949
|
-
// 最后一次 unregister <60s),挂长超时 = 空等 2h 才 kill + 冒牌完成通知级联。
|
|
950
|
-
// 15s 内无新 agent_end(未被唤醒)即 kill;被唤醒后下一次 agent_end 重新评估。
|
|
951
|
-
logger.debug(
|
|
952
|
-
"[session-runner] agent_end: keep alive, recent descendant completion (wake-up in flight)",
|
|
953
|
-
);
|
|
954
|
-
clearTimeout(watchdog);
|
|
955
|
-
watchdog = setTimeout(() => child.kill("SIGTERM"), WAKEUP_GRACE_MS);
|
|
956
|
-
watchdog.unref();
|
|
957
|
-
} else {
|
|
958
|
-
child.kill("SIGTERM");
|
|
959
|
-
}
|
|
960
|
-
}
|
|
961
|
-
}
|
|
962
|
-
if (isSdkEvent(parsed.event)) handleSdkEvent(parsed.event);
|
|
963
|
-
} else if (parsed.kind === "response") {
|
|
964
|
-
// FR-4: RPC response handling — 匹配 get_state 响应
|
|
965
|
-
if (parsed.command === "get_state" && parsed.success && parsed.id) {
|
|
966
|
-
const resolver = get_stateListeners.get(parsed.id);
|
|
967
|
-
if (resolver) {
|
|
968
|
-
get_stateListeners.delete(parsed.id);
|
|
969
|
-
resolver(parsed.data);
|
|
970
|
-
}
|
|
971
|
-
}
|
|
972
|
-
} else if (parsed.kind === "extension_ui_request") {
|
|
973
|
-
// W3: 子进程发 UI 请求(ask_user)。入队 FIFO 串行处理,防止并发询问用户。
|
|
974
|
-
enqueueUiRequest(parsed.id, parsed.request);
|
|
975
|
-
}
|
|
976
|
-
// invalid 行忽略(stdout 可能有调试输出)
|
|
977
|
-
}
|
|
978
|
-
});
|
|
1442
|
+
// stdout pump:逐行解析 → handleSdkEvent / enqueueUiRequest + get_state 握手状态机
|
|
1443
|
+
const pump = attachStdoutPump(child, state, sessionDir, handleSdkEvent);
|
|
979
1444
|
|
|
980
1445
|
// FR-4: get_state RPC 握手——spawn 后无条件启动。
|
|
981
1446
|
// RPC mode(pi --mode rpc)不向 stdout 输出 header,record.sessionFile 无法靠 header
|
|
@@ -986,14 +1451,11 @@ export async function runSpawn(
|
|
|
986
1451
|
// 时序:握手在 stdout pump(get_stateListeners 已就绪)之后启动。get_state 命令写入
|
|
987
1452
|
// stdin,pi rebindSession 后读取并返回 response,经 stdout pump 匹配 resolver 触发 resolve。
|
|
988
1453
|
// close handler await handshakeSettled,保证无论 task 多快结束,close 时 sessionFile 已回填。
|
|
989
|
-
// [#18]
|
|
990
|
-
|
|
991
|
-
void performGetStateHandshake(child, (id, resolver) => {
|
|
992
|
-
get_stateListeners.set(id, resolver);
|
|
993
|
-
}).then((r) => {
|
|
1454
|
+
// [#18] 握手状态变量已在 attachStdoutPump 内部(stdout handler 注册前)定义,此处直接发起握手。
|
|
1455
|
+
void performGetStateHandshake(child, pump.registerGetStateListener).then((r) => {
|
|
994
1456
|
// header 加速路径下 settleHandshake 已 undefined,跳过(避免覆盖 header 结果)。
|
|
995
1457
|
// 超时兜底(r 为空对象)也经此分支 settle,但 record.sessionFile 不回填。
|
|
996
|
-
if (
|
|
1458
|
+
if (pump.isHandshakePending()) pump.finishHandshake(r);
|
|
997
1459
|
});
|
|
998
1460
|
|
|
999
1461
|
child.stderr.on("data", (data: string) => {
|
|
@@ -1002,91 +1464,26 @@ export async function runSpawn(
|
|
|
1002
1464
|
});
|
|
1003
1465
|
|
|
1004
1466
|
// 等待子进程退出
|
|
1005
|
-
const exitCode = await
|
|
1006
|
-
child.on("close", async (code: number | null) => {
|
|
1007
|
-
// [C1] 子进程已退出,从 orphan-tracking Set 移除(dispose 兜底无需再 kill 它)
|
|
1008
|
-
spawnedChildren.delete(child);
|
|
1009
|
-
// FR-4: 清理 get_state 监听器(子进程已退出,无更多 response)
|
|
1010
|
-
get_stateListeners.clear();
|
|
1011
|
-
// FR-4: 子进程已退出,get_state response 不会再来。若握手仍未 settle,立即放弃
|
|
1012
|
-
//(record.sessionFile 未回填则走 findSessionFileByHeaderId 兜底)。避免子进程快速
|
|
1013
|
-
// 失败/退出场景下 close handler 阻塞等待握手内部 6s 超时。
|
|
1014
|
-
settleHandshake?.();
|
|
1015
|
-
settleHandshake = undefined;
|
|
1016
|
-
// await 立即返回(上方已 settle):保证 header 加速路径或 get_state response 已
|
|
1017
|
-
// 完成的回填结果对后续 identity 写入可见。
|
|
1018
|
-
await handshakeSettled;
|
|
1019
|
-
// 处理 stdout 末尾残留行
|
|
1020
|
-
if (stdoutBuffer.trim()) {
|
|
1021
|
-
const parsed = parseSpawnLine(stdoutBuffer);
|
|
1022
|
-
if (parsed?.kind === "event" && isSdkEvent(parsed.event)) {
|
|
1023
|
-
handleSdkEvent(parsed.event);
|
|
1024
|
-
}
|
|
1025
|
-
}
|
|
1026
|
-
resolve(code ?? 0);
|
|
1027
|
-
});
|
|
1028
|
-
child.on("error", (err: Error) => {
|
|
1029
|
-
// spawn 本身失败(command not found 等)
|
|
1030
|
-
// [worktree-reaper-fix] 拼 spawnCwd 进错误消息:ENOENT 的 err.message 只含 command 名,
|
|
1031
|
-
// 无 cwd 线索(worktree 被 reaper 误删后 cwd 指向虚空)会导致误诊——2026-08-11 事故
|
|
1032
|
-
// AI 误判"node 被卸载"的直接原因。
|
|
1033
|
-
// [S3] code 读取带运行时 guard:非 ErrnoException(普通 Error)时 code 为 undefined,
|
|
1034
|
-
// 不加 cwd hint(行为与修复前一致);仅 ENOENT 才拼 cwd。
|
|
1035
|
-
spawnedChildren.delete(child);
|
|
1036
|
-
const errno = err as NodeJS.ErrnoException;
|
|
1037
|
-
const errCode = "code" in err ? errno.code : undefined;
|
|
1038
|
-
const cwdHint = errCode === "ENOENT" ? ` (cwd: ${spawnCwd})` : "";
|
|
1039
|
-
record.lastError = `${err.message}${cwdHint}`;
|
|
1040
|
-
resolve(SIGNAL_EXIT_CODE_THRESHOLD); // 非零退出
|
|
1041
|
-
});
|
|
1042
|
-
});
|
|
1467
|
+
const exitCode = await waitForChildExit(child, state, spawnCwd, pump);
|
|
1043
1468
|
|
|
1044
1469
|
opts.signal?.removeEventListener("abort", onAbort);
|
|
1045
|
-
clearTimeout(watchdog);
|
|
1470
|
+
clearTimeout(state.watchdog);
|
|
1046
1471
|
|
|
1047
|
-
// [持久化 A] sessionFile
|
|
1048
|
-
//
|
|
1049
|
-
//
|
|
1050
|
-
//
|
|
1472
|
+
// [持久化 A] sessionFile 兜底校验。
|
|
1473
|
+
// identity custom entry 已改由子进程 session_start hook 写(M4 / V2 决策 5),
|
|
1474
|
+
// 父进程不再 fs 补写——fs 补写的 entry 缺 id/parentId 污染 pi _buildIndex。
|
|
1475
|
+
// 此处仅保留 sessionFile 路径兜底(deriveSessionFilePath/握手路径可能不准)。
|
|
1051
1476
|
if (record.sessionFile) {
|
|
1052
1477
|
// 兜底:deriveSessionFilePath 推导或握手返回的路径可能不存在(pi 命名规则变化),
|
|
1053
1478
|
// 用 sessionId 后缀匹配实际文件。匹配到则修正 record.sessionFile。
|
|
1054
1479
|
// sessionId 来源:header(json mode)优先,其次握手结果(RPC mode)。
|
|
1055
1480
|
if (!fs.existsSync(record.sessionFile)) {
|
|
1056
|
-
const lookupId = sessionHeader?.id ??
|
|
1481
|
+
const lookupId = state.sessionHeader?.id ?? state.handshakeResult?.sessionId;
|
|
1057
1482
|
if (lookupId) {
|
|
1058
1483
|
const actual = findSessionFileByHeaderId(sessionDir, lookupId);
|
|
1059
1484
|
if (actual) record.sessionFile = actual;
|
|
1060
1485
|
}
|
|
1061
1486
|
}
|
|
1062
|
-
// 补写 identity custom entry(子进程已退出,append 安全)。
|
|
1063
|
-
if (fs.existsSync(record.sessionFile)) {
|
|
1064
|
-
const identity: SubagentIdentityData = {
|
|
1065
|
-
id: record.id,
|
|
1066
|
-
agent: record.agent,
|
|
1067
|
-
mode: record.mode,
|
|
1068
|
-
task: record.task,
|
|
1069
|
-
slug: record.slug,
|
|
1070
|
-
startedAt: record.startedAt,
|
|
1071
|
-
rootSessionId: record.rootSessionId,
|
|
1072
|
-
parentRecordId: record.parentRecordId,
|
|
1073
|
-
depth: record.depth,
|
|
1074
|
-
forkDepth: opts.fork ? (opts.parentForkDepth ?? 0) + 1 : undefined,
|
|
1075
|
-
};
|
|
1076
|
-
try {
|
|
1077
|
-
fs.appendFileSync(
|
|
1078
|
-
record.sessionFile,
|
|
1079
|
-
`${JSON.stringify({ type: "custom", customType: IDENTITY_CUSTOM_TYPE, data: identity })}\n`,
|
|
1080
|
-
"utf-8",
|
|
1081
|
-
);
|
|
1082
|
-
} catch (err) {
|
|
1083
|
-
// best-effort:identity 写入失败不影响执行结果,但会影响 /subagents list 重建。
|
|
1084
|
-
// 记录到 logger(非阻断)—— 终态 record 会从 list 消失,这是可观测的退化信号。
|
|
1085
|
-
logger.error(`[subagents] identity append failed for ${record.sessionFile}`, {
|
|
1086
|
-
detail: err instanceof Error ? err.message : String(err),
|
|
1087
|
-
});
|
|
1088
|
-
}
|
|
1089
|
-
}
|
|
1090
1487
|
}
|
|
1091
1488
|
|
|
1092
1489
|
// 判定成功/失败(三来源:exitCode + record.lastError + abort 原因)
|
|
@@ -1115,7 +1512,7 @@ export async function runSpawn(
|
|
|
1115
1512
|
startTime,
|
|
1116
1513
|
success,
|
|
1117
1514
|
error,
|
|
1118
|
-
sessionId: sessionHeader?.id ?? record.id,
|
|
1515
|
+
sessionId: state.sessionHeader?.id ?? record.id,
|
|
1119
1516
|
sessionFile: record.sessionFile,
|
|
1120
1517
|
});
|
|
1121
1518
|
} finally {
|