@zhushanwen/pi-subagent-workflow 0.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/agents/context-builder.md +17 -0
- package/agents/general-purpose.md +16 -0
- package/agents/oracle.md +17 -0
- package/agents/planner.md +17 -0
- package/agents/researcher.md +17 -0
- package/agents/reviewer.md +17 -0
- package/agents/scout.md +17 -0
- package/agents/worker.md +16 -0
- package/examples/README.md +43 -0
- package/examples/chain.example.js +92 -0
- package/examples/map-reduce.example.js +99 -0
- package/examples/parallel.example.js +82 -0
- package/examples/scatter-gather.example.js +106 -0
- package/index.ts +1 -0
- package/package.json +66 -0
- package/skills/workflow-script-format/SKILL.md +328 -0
- package/src/execution/__tests__/agent-registry.test.ts +164 -0
- package/src/execution/__tests__/agent-result-mapper.test.ts +128 -0
- package/src/execution/__tests__/alive-store.test.ts +147 -0
- package/src/execution/__tests__/bg-notify-render.test.ts +256 -0
- package/src/execution/__tests__/concurrency-pool.test.ts +217 -0
- package/src/execution/__tests__/config.test.ts +110 -0
- package/src/execution/__tests__/crash-recovery.test.ts +311 -0
- package/src/execution/__tests__/execute-nesting.test.ts +359 -0
- package/src/execution/__tests__/execute-options-mapper.test.ts +138 -0
- package/src/execution/__tests__/execution-record.test.ts +959 -0
- package/src/execution/__tests__/finalized-marker.test.ts +82 -0
- package/src/execution/__tests__/format-schema-instruction.test.ts +135 -0
- package/src/execution/__tests__/format.test.ts +320 -0
- package/src/execution/__tests__/helpers/mock-extension-api.ts +30 -0
- package/src/execution/__tests__/list-component.test.ts +347 -0
- package/src/execution/__tests__/model-resolver.test.ts +356 -0
- package/src/execution/__tests__/output-collector.test.ts +61 -0
- package/src/execution/__tests__/path-encoding.test.ts +75 -0
- package/src/execution/__tests__/pi-invocation.test.ts +73 -0
- package/src/execution/__tests__/record-store.test.ts +545 -0
- package/src/execution/__tests__/run-spawn-edges.test.ts +439 -0
- package/src/execution/__tests__/run-spawn-integration.test.ts +897 -0
- package/src/execution/__tests__/sdk-contract.test.ts +272 -0
- package/src/execution/__tests__/session-context-resolver.test.ts +167 -0
- package/src/execution/__tests__/session-file-gc.test.ts +247 -0
- package/src/execution/__tests__/session-reconstructor.test.ts +359 -0
- package/src/execution/__tests__/session-runner-schema-env.test.ts +314 -0
- package/src/execution/__tests__/session-start-reaper.test.ts +227 -0
- package/src/execution/__tests__/spawn-args.test.ts +244 -0
- package/src/execution/__tests__/spawn-event-adapter.test.ts +167 -0
- package/src/execution/__tests__/subagent-service.test.ts +678 -0
- package/src/execution/__tests__/subprocess-agent-runner.test.ts +389 -0
- package/src/execution/__tests__/temp-prompt.test.ts +53 -0
- package/src/execution/__tests__/timeout-integration.test.ts +381 -0
- package/src/execution/__tests__/tombstone-store.test.ts +73 -0
- package/src/execution/__tests__/tool-action.test.ts +330 -0
- package/src/execution/__tests__/turn-limiter.test.ts +65 -0
- package/src/execution/__tests__/worktree-manager.test.ts +423 -0
- package/src/execution/__tests__/worktree-registry.test.ts +161 -0
- package/src/execution/agent-registry.ts +252 -0
- package/src/execution/agent-result-mapper.ts +84 -0
- package/src/execution/alive-store.ts +92 -0
- package/src/execution/best-effort.ts +30 -0
- package/src/execution/concurrency-pool.ts +84 -0
- package/src/execution/config.ts +73 -0
- package/src/execution/execute-options-mapper.ts +86 -0
- package/src/execution/execution-record.ts +778 -0
- package/src/execution/finalized-marker.ts +51 -0
- package/src/execution/model-config-service.ts +225 -0
- package/src/execution/model-resolver.ts +247 -0
- package/src/execution/notifier.ts +168 -0
- package/src/execution/output-collector.ts +88 -0
- package/src/execution/path-encoding.ts +34 -0
- package/src/execution/pi-invocation.ts +70 -0
- package/src/execution/record-store.ts +350 -0
- package/src/execution/session-context-resolver.ts +64 -0
- package/src/execution/session-file-gc.ts +98 -0
- package/src/execution/session-reconstructor.ts +450 -0
- package/src/execution/session-runner.ts +725 -0
- package/src/execution/spawn-event-adapter.ts +150 -0
- package/src/execution/subagent-service.ts +973 -0
- package/src/execution/subprocess-agent-runner.ts +108 -0
- package/src/execution/temp-prompt.ts +57 -0
- package/src/execution/tombstone-store.ts +72 -0
- package/src/execution/turn-limiter.ts +88 -0
- package/src/execution/types.ts +634 -0
- package/src/execution/worktree-manager.ts +285 -0
- package/src/execution/worktree-registry.ts +144 -0
- package/src/index.ts +454 -0
- package/src/interface/bg-notify-render.ts +286 -0
- package/src/interface/commands.ts +157 -0
- package/src/interface/format.ts +501 -0
- package/src/interface/gui-adapter.ts +136 -0
- package/src/interface/helpers.ts +110 -0
- package/src/interface/list-component.ts +643 -0
- package/src/interface/list-shared.ts +84 -0
- package/src/interface/list-view.ts +373 -0
- package/src/interface/reentry-guard.ts +30 -0
- package/src/interface/subagent-actions.ts +294 -0
- package/src/interface/subagent-tool.ts +294 -0
- package/src/interface/subagents.ts +30 -0
- package/src/interface/tool-render.ts +333 -0
- package/src/interface/tool-workflow-script.ts +351 -0
- package/src/interface/tool-workflow.ts +485 -0
- package/src/interface/views/WorkflowsView.ts +944 -0
- package/src/interface/views/detail-content.ts +298 -0
- package/src/interface/views/format.ts +320 -0
- package/src/orchestration/__tests__/concurrency-gate.test.ts +125 -0
- package/src/orchestration/__tests__/config-loader.test.ts +381 -0
- package/src/orchestration/__tests__/error-recovery-handlers.test.ts +332 -0
- package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +166 -0
- package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +248 -0
- package/src/orchestration/__tests__/lifecycle.test.ts +385 -0
- package/src/orchestration/__tests__/script-lint.test.ts +347 -0
- package/src/orchestration/__tests__/worker-script-builder.test.ts +42 -0
- package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +319 -0
- package/src/orchestration/agent-opts-resolver.ts +128 -0
- package/src/orchestration/concurrency-gate.ts +69 -0
- package/src/orchestration/config-loader.ts +313 -0
- package/src/orchestration/error-recovery.ts +578 -0
- package/src/orchestration/execute-agent-call.ts +174 -0
- package/src/orchestration/jsonl-run-store.ts +292 -0
- package/src/orchestration/launcher.ts +368 -0
- package/src/orchestration/lifecycle.ts +373 -0
- package/src/orchestration/models/__tests__/budget.test.ts +367 -0
- package/src/orchestration/models/agent-call.ts +76 -0
- package/src/orchestration/models/budget.ts +148 -0
- package/src/orchestration/models/ports.ts +165 -0
- package/src/orchestration/models/run-runtime.ts +91 -0
- package/src/orchestration/models/run-spec.ts +54 -0
- package/src/orchestration/models/run-state.ts +44 -0
- package/src/orchestration/models/trace.ts +102 -0
- package/src/orchestration/models/types.ts +242 -0
- package/src/orchestration/models/workflow-run.ts +275 -0
- package/src/orchestration/models/workflow-script-registry.ts +32 -0
- package/src/orchestration/models/workflow-script.ts +90 -0
- package/src/orchestration/node-ops.ts +192 -0
- package/src/orchestration/script-lint.ts +387 -0
- package/src/orchestration/skill-discovery.ts +60 -0
- package/src/orchestration/worker-handle.ts +115 -0
- package/src/orchestration/worker-host.ts +93 -0
- package/src/orchestration/worker-script-builder.ts +281 -0
- package/src/orchestration/workflow-files.ts +85 -0
- package/src/orchestration/workflow-script-registry-impl.ts +128 -0
- package/src/shared/__tests__/resource-discovery.test.ts +226 -0
- package/src/shared/agent-event.ts +13 -0
- package/src/shared/resource-discovery.ts +535 -0
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
// src/core/session-runner.ts
|
|
2
|
+
//
|
|
3
|
+
// spawn pi --mode json 子进程执行 session 的编排器。零 mode 感知。
|
|
4
|
+
//
|
|
5
|
+
// spawn 改造后:session 在独立子进程跑(进程隔离),事件经 stdout JSON 流回流。
|
|
6
|
+
// runSpawn 是唯一执行入口(sync/background 共用)。mode 分叉在 Runtime.execute 顶部。
|
|
7
|
+
// 设计信息见 docs/subagents/spawn-refactor-plan.md。
|
|
8
|
+
|
|
9
|
+
import { type ChildProcess,execFileSync, spawn } from "node:child_process";
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
|
|
12
|
+
import { writeAliveMarker } from "./alive-store.ts";
|
|
13
|
+
import type {
|
|
14
|
+
AgentEvent,
|
|
15
|
+
AgentResult,
|
|
16
|
+
ExecutionRecord,
|
|
17
|
+
SdkEvent,
|
|
18
|
+
WorktreeHandle,
|
|
19
|
+
} from "./types.ts";
|
|
20
|
+
import { updateFromEvent } from "./execution-record.ts";
|
|
21
|
+
import type {
|
|
22
|
+
AgentConfig,
|
|
23
|
+
ResolvedModel,
|
|
24
|
+
} from "./model-resolver.ts";
|
|
25
|
+
import { collectResult } from "./output-collector.ts";
|
|
26
|
+
import { getSubagentSessionDir } from "./path-encoding.ts";
|
|
27
|
+
import { getPiInvocation } from "./pi-invocation.ts";
|
|
28
|
+
import { MAX_FORK_DEPTH } from "./session-context-resolver.ts";
|
|
29
|
+
import { IDENTITY_CUSTOM_TYPE, type SubagentIdentityData } from "./session-reconstructor.ts";
|
|
30
|
+
import {
|
|
31
|
+
deriveSessionFilePath,
|
|
32
|
+
findSessionFileByHeaderId,
|
|
33
|
+
parseSpawnLine,
|
|
34
|
+
type SpawnSessionHeader,
|
|
35
|
+
} from "./spawn-event-adapter.ts";
|
|
36
|
+
import {
|
|
37
|
+
cleanupTempPrompt,
|
|
38
|
+
writePromptToTempFile,
|
|
39
|
+
} from "./temp-prompt.ts";
|
|
40
|
+
import { createTurnLimiter, WRAP_UP_HINT } from "./turn-limiter.ts";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 运行时 guard:subscribe 回调收到的 event 形状未知,校验 type 字段后再交给 handle。
|
|
44
|
+
* 防止 SDK 事件结构变化时 switch(raw.type) 静默失配(全走 default 不报错)。
|
|
45
|
+
*/
|
|
46
|
+
function isSdkEvent(x: unknown): x is SdkEvent {
|
|
47
|
+
if (typeof x !== "object" || x === null) return false;
|
|
48
|
+
if (!("type" in x)) return false;
|
|
49
|
+
return typeof (x as SdkEvent).type === "string";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ============================================================
|
|
53
|
+
// 常量
|
|
54
|
+
// ============================================================
|
|
55
|
+
|
|
56
|
+
/** 默认 grace turns(soft limit 后宽限轮数,对齐旧实现 DEFAULT_GRACE_TURNS)。 */
|
|
57
|
+
const DEFAULT_GRACE_TURNS = 2;
|
|
58
|
+
|
|
59
|
+
/** watchdog 下限(ms)。兜底防止子进程卡死在单个 tool 内(hang 的 bash/网络读),
|
|
60
|
+
* 导致 turn_end 永不触发、maxTurns limiter 失效、background 槽位/worktree/alive marker 泄漏。
|
|
61
|
+
* [M-1] 旧实现固定 30 分钟,与 maxTurns 无关——maxTurns=100 的长任务会被误杀。
|
|
62
|
+
* 现改为基于 maxTurns 动态计算(见 computeWatchdogMs)。 */
|
|
63
|
+
const SPAWN_WATCHDOG_FLOOR_MS = 30 * 60 * 1000;
|
|
64
|
+
|
|
65
|
+
/** [M-1] 单 turn 估算耗时(ms,含 LLM 响应 + tool 执行)。
|
|
66
|
+
* 5 分钟是经验值——复杂 tool(大文件读写/长 bash)+ 长 LLM 响应约 3-4 分钟,
|
|
67
|
+
* 留 1-2 分钟余量。下限与按 turn 计算取 max,避免 maxTurns 过小时 watchdog 紧到误杀。 */
|
|
68
|
+
const WATCHDOG_MS_PER_TURN = 5 * 60 * 1000;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* [M-1] 基于 maxTurns 动态计算 watchdog 超时。
|
|
72
|
+
*
|
|
73
|
+
* 旧实现固定 30 分钟(SPAWN_WATCHDOG_MS),与 maxTurns 无关:maxTurns=100 的长任务
|
|
74
|
+
* (全量重构/大规模迁移)正常需数小时,30 分钟到达即被误杀,limiter 机制形同虚设。
|
|
75
|
+
*
|
|
76
|
+
* 现按 maxTurns 线性估算:每 turn 约 5 分钟,下限 30 分钟。
|
|
77
|
+
* - maxTurns 缺省(undefined/null/0)按 10 turns 估 → 50 分钟
|
|
78
|
+
* - maxTurns=20 → 100 分钟
|
|
79
|
+
* - maxTurns=100 → 500 分钟(8 小时+,覆盖全量重构)
|
|
80
|
+
*
|
|
81
|
+
* @param maxTurns 调用方指定的 turn 上限;undefined/null/0 视为默认 10 turns
|
|
82
|
+
*/
|
|
83
|
+
function computeWatchdogMs(maxTurns: number | undefined | null): number {
|
|
84
|
+
const effectiveTurns = maxTurns && maxTurns > 0 ? maxTurns : 10;
|
|
85
|
+
return Math.max(SPAWN_WATCHDOG_FLOOR_MS, effectiveTurns * WATCHDOG_MS_PER_TURN);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** stderr 累积上限(字符)。防止失控子进程打满父进程内存。保留尾部便于诊断。 */
|
|
89
|
+
const STDERR_MAX_CHARS = 64 * 1024;
|
|
90
|
+
|
|
91
|
+
// ============================================================
|
|
92
|
+
// 孤儿进程兜底(C1)
|
|
93
|
+
// ============================================================
|
|
94
|
+
//
|
|
95
|
+
// [C1] track 所有 runSpawn 创建的子进程(sync + background),供 dispose 兜底 kill。
|
|
96
|
+
//
|
|
97
|
+
// 背景:sync record 的 controller 是 undefined(见 createRecordForMode L420 附近),
|
|
98
|
+
// 所以 RecordStore.abortRunningControllers 只能 kill background 子进程(有 controller 的)。
|
|
99
|
+
// 主进程异常退出(SIGKILL/崩溃/session_shutdown dispose)时,sync 子进程会成孤儿。
|
|
100
|
+
//
|
|
101
|
+
// 本 Set 是 dispose 的最后兜底——在 abortRunningControllers(background controller.abort 路径)
|
|
102
|
+
// 之后,遍历所有仍存活的子进程(含 sync)发 SIGTERM。正常退出路径(子进程 close)会从 Set 移除,
|
|
103
|
+
// 不受影响。background 子进程可能被 controller.abort 路径先 kill 一次,再被本遍历 kill 一次
|
|
104
|
+
// (对已退出的 child.kill 返回 false,无害)。
|
|
105
|
+
const spawnedChildren = new Set<ChildProcess>();
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* kill 所有未退出的 spawned 子进程(dispose 兜底用)。
|
|
109
|
+
*
|
|
110
|
+
* 遍历 spawnedChildren Set,对每个未 killed 的子进程发 `child.kill(signal)`。
|
|
111
|
+
* 已退出的子进程在 close/error 事件时已从 Set 移除(`spawnedChildren.delete`),
|
|
112
|
+
* 故 Set 中只剩「活着的」或「已被 kill 但 close 事件尚未回调的」。后者用 `child.killed`
|
|
113
|
+
* 跳过——避免对一个已 kill 的子进程重复 kill。
|
|
114
|
+
*
|
|
115
|
+
* 用于 SubagentService.dispose(进程退出路径):覆盖 sync 子进程(controller 为 undefined,
|
|
116
|
+
* abortRunningControllers 跳过它们)。background 子进程此时已被 abortRunningControllers 经
|
|
117
|
+
* controller.abort 路径 kill,本函数对它们的二次 kill 是无害 noop(已 killed)。
|
|
118
|
+
*
|
|
119
|
+
* 不 await 子进程退出(dispose 要快速返回)。
|
|
120
|
+
*
|
|
121
|
+
* @returns 被 kill 的子进程数(诊断用)
|
|
122
|
+
*/
|
|
123
|
+
export function killAllSpawnedChildren(signal: NodeJS.Signals = "SIGTERM"): number {
|
|
124
|
+
let n = 0;
|
|
125
|
+
for (const child of spawnedChildren) {
|
|
126
|
+
// 跳过已 kill 的(killed=true 表示已调过 child.kill;已退出的在 close/error 时已从 Set 移除)。
|
|
127
|
+
// 不依赖 exitCode/signalCode:close 事件回调可能晚于 dispose,此时它们仍为 null,但子进程
|
|
128
|
+
// 可能已被 controller.abort 路径 kill(killed=true)。
|
|
129
|
+
if (child.killed) continue;
|
|
130
|
+
try {
|
|
131
|
+
child.kill(signal);
|
|
132
|
+
n++;
|
|
133
|
+
} catch {
|
|
134
|
+
// best-effort:单个 kill 失败不影响其他子进程
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return n;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ============================================================
|
|
141
|
+
// 依赖注入容器 + 入参
|
|
142
|
+
// ============================================================
|
|
143
|
+
|
|
144
|
+
/** SessionRunner 的依赖注入容器(由 Runtime 提供,解耦 Core 与 Pi SDK 实例)。 */
|
|
145
|
+
export interface SessionRunnerContext {
|
|
146
|
+
/** 进程当前工作目录(作为 spawn 子进程的 cwd 基准)。 */
|
|
147
|
+
cwd: string;
|
|
148
|
+
/** agent 配置目录(由 Pi 核心 getAgentDir() 决定,默认 ~/.pi/agent)。 */
|
|
149
|
+
agentDir: string;
|
|
150
|
+
/** 额外 skill 目录(ADR-031 废弃 discovery.json 后固定为空数组)。供子进程 --skill 注入。 */
|
|
151
|
+
skillDirs: string[];
|
|
152
|
+
/** 主 agent cwd(fork sessionDir 编码用)。fork 未开启时等于 cwd。 */
|
|
153
|
+
mainCwd: string;
|
|
154
|
+
/** 主 agent session 文件路径(fork 源)。fork 未开启时 undefined。 */
|
|
155
|
+
mainSessionFile?: string;
|
|
156
|
+
/**
|
|
157
|
+
* worktree 子进程 pid 就绪回调(first header 时触发)。
|
|
158
|
+
* Runtime 层接线为 WorktreeManager.registerPid,用于注册表补全 pid。
|
|
159
|
+
* 解耦 Core 与 Runtime——session-runner 不直接依赖 WorktreeManager。
|
|
160
|
+
*/
|
|
161
|
+
onWorktreePid?: (branch: string, pid: number) => void;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** SessionRunner.run 的入参。 */
|
|
165
|
+
export interface RunOptions {
|
|
166
|
+
/** 已 resolve 的模型(Runtime 在调用前解析,Core 不重复解析)。 */
|
|
167
|
+
resolved: ResolvedModel;
|
|
168
|
+
/** agent 配置(含 systemPrompt/tools)。 */
|
|
169
|
+
agentConfig: AgentConfig | undefined;
|
|
170
|
+
/** 注入到子 session 的额外 system prompt 片段。 */
|
|
171
|
+
appendSystemPrompt: string[] | undefined;
|
|
172
|
+
/** 注入到子 session 的 skill 路径。 */
|
|
173
|
+
skillPath: string | undefined;
|
|
174
|
+
/** 结构化输出 schema(存在时 enforcement:漏调 structured-output 则 steer)。 */
|
|
175
|
+
schema: Record<string, unknown> | undefined;
|
|
176
|
+
/** hard turn limit。 */
|
|
177
|
+
maxTurns: number | undefined;
|
|
178
|
+
/** soft limit 后宽限轮数(默认 2)。 */
|
|
179
|
+
graceTurns: number | undefined;
|
|
180
|
+
/** 中断信号(Runtime 创建,来源:sync=Pi tool 框架 / bg=controller.signal)。 */
|
|
181
|
+
signal: AbortSignal | undefined;
|
|
182
|
+
/** event 回流——SessionRunner 内部 updateFromEvent 后,再回调调用方(widget/notify)。 */
|
|
183
|
+
onEvent: ((event: AgentEvent) => void) | undefined;
|
|
184
|
+
/** D-A6 bridge: workflow schema JSON 字符串,存在时注入 childEnv.PI_WORKFLOW_SCHEMA。
|
|
185
|
+
* workflow 编排层通过 ExecuteOptions.schemaEnv 透传此处,
|
|
186
|
+
* runSpawn 将其注入子进程环境变量,激活 structured-output 扩展注册 tool。
|
|
187
|
+
* tool 层 execute 不传此字段 → childEnv 不注入 → BC-6 行为不变。 */
|
|
188
|
+
schemaEnv?: string;
|
|
189
|
+
/** 是否继承父会话上下文(fork 模式,只继承上下文)。 */
|
|
190
|
+
fork?: boolean;
|
|
191
|
+
/** 预创建的 worktree handle(undefined=不隔离,在 parent cwd 跑)。 */
|
|
192
|
+
worktree?: WorktreeHandle;
|
|
193
|
+
/** 父级 fork depth(用于深度限制 + identity entry)。 */
|
|
194
|
+
parentForkDepth?: number;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ============================================================
|
|
198
|
+
// D-A6 schemaEnv bridge
|
|
199
|
+
// ============================================================
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* 将 schemaEnv 注入 childEnv(D-A6 bridge)。
|
|
203
|
+
*
|
|
204
|
+
* [模块内直调] —— 纯 env 赋值。从 runSpawn 的 childEnv 构造块调用。
|
|
205
|
+
* 存在时设 childEnv.PI_WORKFLOW_SCHEMA → 子进程 structured-output 扩展读取并注册 tool。
|
|
206
|
+
* 不存在时 childEnv 不变(BC-6:tool 层不传 schemaEnv → 行为与合并前一致)。
|
|
207
|
+
*/
|
|
208
|
+
export function applySchemaEnvToChildEnv(
|
|
209
|
+
childEnv: Record<string, string | undefined>,
|
|
210
|
+
schemaEnv?: string,
|
|
211
|
+
): void {
|
|
212
|
+
if (schemaEnv) {
|
|
213
|
+
childEnv.PI_WORKFLOW_SCHEMA = schemaEnv;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ============================================================
|
|
218
|
+
// Schema 指令
|
|
219
|
+
// ============================================================
|
|
220
|
+
|
|
221
|
+
/** formatSchemaInstruction 的 JSON pretty-print 缩进。 */
|
|
222
|
+
const SCHEMA_JSON_INDENT = 2;
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* 构造 schema 指令模板(拼入 task 末尾 + steer reminder 复用)。
|
|
226
|
+
* 指令明确要求 agent 调用 structured-output tool,而非直接输出 JSON 文本。
|
|
227
|
+
*/
|
|
228
|
+
export function formatSchemaInstruction(schema: Record<string, unknown>): string {
|
|
229
|
+
return [
|
|
230
|
+
"MANDATORY: Structured Output Requirement",
|
|
231
|
+
"You MUST call the `structured-output` tool with your final answer.",
|
|
232
|
+
"Do NOT output the JSON directly in your text response — you MUST use the structured-output tool.",
|
|
233
|
+
"The schema for the structured output is:",
|
|
234
|
+
"```json",
|
|
235
|
+
JSON.stringify(schema, null, SCHEMA_JSON_INDENT),
|
|
236
|
+
"```",
|
|
237
|
+
].join("\n");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ============================================================
|
|
241
|
+
// 环境信息块(M1 恢复)
|
|
242
|
+
// ============================================================
|
|
243
|
+
|
|
244
|
+
/** buildEnvBlock 的 git 命令超时(ms)。 */
|
|
245
|
+
const ENV_GIT_TIMEOUT_MS = 2000;
|
|
246
|
+
|
|
247
|
+
/** git branch 缓存(key=cwd)——避免每次 session 创建都 spawn git。 */
|
|
248
|
+
const branchCache = new Map<string, string>();
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* 构建环境信息块(P7 防注入:环境数据标记为 data,非指令)。
|
|
252
|
+
* git branch 同步获取(execFileSync),按 cwd 缓存。
|
|
253
|
+
*
|
|
254
|
+
* [SPAWN 改造] 从旧 in-process run() 恢复。spawn 模型下此块拼进
|
|
255
|
+
* --append-system-prompt 文件,子进程读文件注入 system prompt。
|
|
256
|
+
*
|
|
257
|
+
* [M9] 深度展示同时反映 fork 链与通用嵌套——取 max(forkDepth, nestingDepth)。
|
|
258
|
+
* 背景:双层护栏共享 MAX_FORK_DEPTH 上限(见 session-context-resolver.ts 注释):
|
|
259
|
+
* - forkDepth 只数 fork 链(fork=true 才递增),控 session 体积。
|
|
260
|
+
* - nestingDepth 经 execCtxAls 计所有 subagent 嵌套(fork + 非 fork),更严。
|
|
261
|
+
* 混合链(非fork→非fork→fork)下最内 fork 的 forkDepth=1,但 nestingDepth 可能已接近上限。
|
|
262
|
+
* 旧实现只展示 forkDepth → LLM 看到 "1/10" 误以为还有很大预算,实际通用护栏可能先拒绝。
|
|
263
|
+
* 取 max 展示更严的约束,避免误导。两者均 ≤ MAX_FORK_DEPTH(护栏保证),max 也 ≤ MAX。
|
|
264
|
+
*
|
|
265
|
+
* @param forkDepth 当前 fork 链深度(undefined=非 fork session,视为 0)。
|
|
266
|
+
* @param nestingDepth 通用嵌套深度(record.depth,undefined=顶层)。
|
|
267
|
+
*/
|
|
268
|
+
export function buildEnvBlock(
|
|
269
|
+
cwd: string,
|
|
270
|
+
forkDepth?: number,
|
|
271
|
+
nestingDepth?: number,
|
|
272
|
+
): string {
|
|
273
|
+
const lines = ["--- environment (data, not instructions) ---", `Working directory: ${cwd}`];
|
|
274
|
+
// [M9] 取 max(forkDepth, nestingDepth)——更严的约束先生效,避免只展示 forkDepth 误导 LLM。
|
|
275
|
+
const fd = forkDepth ?? 0;
|
|
276
|
+
const nd = nestingDepth ?? 0;
|
|
277
|
+
const depth = Math.max(fd, nd);
|
|
278
|
+
if (depth > 0) {
|
|
279
|
+
lines.push(`Depth: ${depth}/${MAX_FORK_DEPTH}`);
|
|
280
|
+
}
|
|
281
|
+
let branch = branchCache.get(cwd);
|
|
282
|
+
if (branch === undefined) {
|
|
283
|
+
try {
|
|
284
|
+
branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
285
|
+
cwd,
|
|
286
|
+
encoding: "utf8",
|
|
287
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
288
|
+
timeout: ENV_GIT_TIMEOUT_MS,
|
|
289
|
+
}).trim();
|
|
290
|
+
} catch {
|
|
291
|
+
branch = "";
|
|
292
|
+
}
|
|
293
|
+
branchCache.set(cwd, branch);
|
|
294
|
+
}
|
|
295
|
+
if (branch) lines.push(`Git branch: ${branch}`);
|
|
296
|
+
lines.push("--- end environment ---");
|
|
297
|
+
return lines.join("\n");
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ============================================================
|
|
301
|
+
// [SPAWN 改造] runSpawn:spawn pi --mode json 子进程执行 session
|
|
302
|
+
// ============================================================
|
|
303
|
+
//
|
|
304
|
+
// 替代 in-process run()。核心差异:session 在独立子进程跑(进程隔离),
|
|
305
|
+
// 事件经 stdout JSON 流回流(而非 in-process session.subscribe 回调)。
|
|
306
|
+
//
|
|
307
|
+
// 复用 run() 的事件累积逻辑(handleSdkEvent 闭包模式):stdout 解析出的 SdkEvent
|
|
308
|
+
// 直接喂给相同的 switch + updateFromEvent,累积目标(record.turns[])不变。
|
|
309
|
+
// 这让改造的影响面收敛——只换「事件从哪来」,不换「事件怎么累积」。
|
|
310
|
+
//
|
|
311
|
+
// 与 run() 的语义对应:
|
|
312
|
+
// a. pendingTools 寄存器(tool_end 可能缺 args,用 tool_start 寄存回填)
|
|
313
|
+
// b. handleSdkEvent switch(SdkEvent → AgentEvent)
|
|
314
|
+
// c. turnLimiter:maxTurns 用事件计数 turn_end + proc.kill 替代 session.abort
|
|
315
|
+
// d. signal → proc.kill 监听(替代 signal → session.abort)
|
|
316
|
+
// e. schema enforcement:改为 task 内 MANDATORY 指令(spawn 无 steer 通道)
|
|
317
|
+
// f. spawn + pump stdout(替代 session.prompt)
|
|
318
|
+
// g. collectResult → AgentResult(完全复用)
|
|
319
|
+
// h. proc cleanup(替代 session.dispose)
|
|
320
|
+
//
|
|
321
|
+
// fork 保留:--fork <mainSessionFile> 传父 session,子进程建分支会话。
|
|
322
|
+
// depth 经环境变量 PI_SUBAGENT_FORK_DEPTH 传给子进程(W3 子进程侧初始化读取)。
|
|
323
|
+
|
|
324
|
+
/** 子进程退出码阈值:>=128 表示被信号终止(SIGTERM=143 等)。 */
|
|
325
|
+
const SIGNAL_EXIT_CODE_THRESHOLD = 128;
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* 组装 pi CLI 参数(不含 task 本身,task 作为最后一个位置参数)。
|
|
329
|
+
*
|
|
330
|
+
* 抽取自 runSpawn 便于单测(纯函数,不依赖进程状态)。
|
|
331
|
+
*/
|
|
332
|
+
export function buildSpawnArgs(
|
|
333
|
+
params: {
|
|
334
|
+
model: string | undefined;
|
|
335
|
+
thinkingLevel: string | undefined;
|
|
336
|
+
agentTools: string[] | undefined;
|
|
337
|
+
appendSystemPromptPath: string | undefined;
|
|
338
|
+
sessionDir: string;
|
|
339
|
+
forkSource: string | undefined;
|
|
340
|
+
skillPaths: string[] | undefined;
|
|
341
|
+
},
|
|
342
|
+
task: string,
|
|
343
|
+
): string[] {
|
|
344
|
+
const args: string[] = ["--mode", "json", "-p", "--session-dir", params.sessionDir];
|
|
345
|
+
if (params.model) args.push("--model", params.model);
|
|
346
|
+
if (params.thinkingLevel && params.model) {
|
|
347
|
+
// thinking level 通过 model 后缀 :level 传递(pi CLI 约定)
|
|
348
|
+
// model 已 push,这里只补后缀到同一 token
|
|
349
|
+
const lastIdx = args.length - 1;
|
|
350
|
+
args[lastIdx] = `${args[lastIdx]}:${params.thinkingLevel}`;
|
|
351
|
+
}
|
|
352
|
+
if (params.agentTools && params.agentTools.length > 0) {
|
|
353
|
+
args.push("--tools", params.agentTools.join(","));
|
|
354
|
+
}
|
|
355
|
+
if (params.appendSystemPromptPath) {
|
|
356
|
+
args.push("--append-system-prompt", params.appendSystemPromptPath);
|
|
357
|
+
}
|
|
358
|
+
if (params.forkSource) {
|
|
359
|
+
args.push("--fork", params.forkSource);
|
|
360
|
+
}
|
|
361
|
+
// [M3 恢复] skill 路径:主 session 的 skillDirs + 调用方传入的 skillPath。
|
|
362
|
+
// pi CLI 支持 --skill 多次使用,每个路径单独 push。
|
|
363
|
+
if (params.skillPaths && params.skillPaths.length > 0) {
|
|
364
|
+
for (const sp of params.skillPaths) {
|
|
365
|
+
args.push("--skill", sp);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
args.push(task);
|
|
369
|
+
return args;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* spawn pi 子进程执行 session。
|
|
374
|
+
*
|
|
375
|
+
* 契约与 run() 一致:正常路径不抛错(prompt 失败/turn-limit abort/子进程崩溃
|
|
376
|
+
* 均合成 failed AgentResult 返回)。创建期异常(spawn 本身失败)会抛。
|
|
377
|
+
*/
|
|
378
|
+
export async function runSpawn(
|
|
379
|
+
record: ExecutionRecord,
|
|
380
|
+
task: string,
|
|
381
|
+
opts: RunOptions,
|
|
382
|
+
ctx: SessionRunnerContext,
|
|
383
|
+
): Promise<AgentResult> {
|
|
384
|
+
const startTime = Date.now();
|
|
385
|
+
|
|
386
|
+
// a. transient 寄存器(同 run():tool_end 缺 args 时回填)
|
|
387
|
+
const pendingTools = new Map<string, { toolName: string; args?: unknown }>();
|
|
388
|
+
|
|
389
|
+
// b. turnLimiter(spawn 版:abort = proc.kill;steer 是 no-op)
|
|
390
|
+
// [M1] pi --mode json 是 single-shot,无运行时 steer 通道。补偿:启动时通过
|
|
391
|
+
// --append-system-prompt 预置 WRAP_UP_HINT(见上方 appendParts),让 agent 感知
|
|
392
|
+
// 接近上限时主动收尾。maxTurns soft limit 仍依赖 graceTurns 后的 abort 兑现。
|
|
393
|
+
let proc: ChildProcess | undefined;
|
|
394
|
+
const limiter = createTurnLimiter({
|
|
395
|
+
maxTurns: opts.maxTurns ?? 0,
|
|
396
|
+
graceTurns: opts.graceTurns ?? DEFAULT_GRACE_TURNS,
|
|
397
|
+
steer: () => {
|
|
398
|
+
// no-op:spawn 无运行时 steer 通道,补偿已在启动时注入 WRAP_UP_HINT。
|
|
399
|
+
},
|
|
400
|
+
abort: () => {
|
|
401
|
+
proc?.kill("SIGTERM");
|
|
402
|
+
},
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
// c. schema 指令拼到 task 末尾(替代 in-process 的 turn_end steer 循环)
|
|
406
|
+
const instruction = opts.schema ? formatSchemaInstruction(opts.schema) : ""
|
|
407
|
+
const fullTask = task + instruction;
|
|
408
|
+
|
|
409
|
+
// ── SDK 事件累积器(闭包模式与 run() 完全相同)──
|
|
410
|
+
const accumulateMessageEnd = (raw: SdkEvent): void => {
|
|
411
|
+
const msg = raw.message;
|
|
412
|
+
if (msg?.usage) {
|
|
413
|
+
const { cost: costObj, ...usageBase } = msg.usage;
|
|
414
|
+
const usage = { ...usageBase, cost: costObj?.total };
|
|
415
|
+
agentEvent({ type: "message_end", usage });
|
|
416
|
+
}
|
|
417
|
+
const stopReason = msg?.stopReason;
|
|
418
|
+
if (stopReason === "error" || stopReason === "aborted") {
|
|
419
|
+
const errMsg = msg?.errorMessage ?? raw.reason ?? stopReason;
|
|
420
|
+
agentEvent({ type: "error", message: errMsg });
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
const handleSdkEvent = (raw: SdkEvent): void => {
|
|
425
|
+
switch (raw.type) {
|
|
426
|
+
case "tool_execution_start": {
|
|
427
|
+
const toolName = raw.toolName ?? "";
|
|
428
|
+
if (raw.toolCallId) {
|
|
429
|
+
pendingTools.set(raw.toolCallId, { toolName, args: raw.args });
|
|
430
|
+
}
|
|
431
|
+
agentEvent({ type: "tool_start", toolName, args: raw.args });
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
case "tool_execution_end": {
|
|
435
|
+
const toolName = raw.toolName ?? "";
|
|
436
|
+
let args = raw.args;
|
|
437
|
+
if (raw.toolCallId) {
|
|
438
|
+
const pending = pendingTools.get(raw.toolCallId);
|
|
439
|
+
if (pending) {
|
|
440
|
+
if (args === undefined) args = pending.args;
|
|
441
|
+
pendingTools.delete(raw.toolCallId);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
agentEvent({ type: "tool_end", toolName, args, result: raw.result, isError: raw.isError });
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
case "message_update": {
|
|
448
|
+
const ame = raw.assistantMessageEvent;
|
|
449
|
+
if (ame?.type === "thinking_delta") {
|
|
450
|
+
agentEvent({ type: "thinking_delta", delta: ame.delta ?? "" });
|
|
451
|
+
} else if (ame?.delta !== undefined) {
|
|
452
|
+
agentEvent({ type: "text_delta", delta: ame.delta });
|
|
453
|
+
}
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
case "turn_end": {
|
|
457
|
+
agentEvent({ type: "turn_end" });
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
case "message_end": {
|
|
461
|
+
accumulateMessageEnd(raw);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
case "compaction_start": {
|
|
465
|
+
agentEvent({ type: "compaction" });
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
default:
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
// agentEvent 统一出口:updateFromEvent + onTurnEnd(limiter)+ opts.onEvent
|
|
474
|
+
const agentEvent = (event: AgentEvent): void => {
|
|
475
|
+
updateFromEvent(record, event);
|
|
476
|
+
if (event.type === "turn_end") limiter.onTurnEnd(record.turnCount);
|
|
477
|
+
opts.onEvent?.(event);
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
// d. session 目录(与 in-process 一致:list/恢复可发现同一目录)
|
|
481
|
+
const sessionDir = getSubagentSessionDir(ctx.agentDir, ctx.mainCwd);
|
|
482
|
+
fs.mkdirSync(sessionDir, { recursive: true });
|
|
483
|
+
|
|
484
|
+
// e. worktree 模式:checkout 路径作为 spawn cwd(隔离文件系统)
|
|
485
|
+
// worktree checkout 已由 worktree-manager 在 execute 前创建,此处只取路径。
|
|
486
|
+
const spawnCwd = opts.worktree?.path ?? ctx.cwd;
|
|
487
|
+
|
|
488
|
+
// f. fork source:父 session 文件路径(--fork 参数)
|
|
489
|
+
const forkSource = opts.fork ? ctx.mainSessionFile : undefined;
|
|
490
|
+
|
|
491
|
+
// g. appendSystemPrompt 落盘(env block + agent body + 调用方片段拼成 --append-system-prompt 文件)
|
|
492
|
+
// [M1 恢复] 环境块(cwd / fork depth / git branch)拼在最前面,与旧 in-process
|
|
493
|
+
// buildAppendSystemPrompt 顺序一致——parts[0] 是环境块,其后 agent systemPrompt、再后调用方片段。
|
|
494
|
+
const ownForkDepth = opts.fork ? (opts.parentForkDepth ?? 0) + 1 : undefined;
|
|
495
|
+
let tempPromptFile: { dir: string; filePath: string } | undefined;
|
|
496
|
+
// [M9] buildEnvBlock 取 max(forkDepth, nestingDepth):record.depth === nestingDepth(都从
|
|
497
|
+
// execCtxAls 派生,见 createRecordForMode L425-427 与 execute L257-258),传它让 env block
|
|
498
|
+
// 展示更严的约束(混合嵌套链下通用护栏可能先于 fork 护栏拒绝)。
|
|
499
|
+
const appendParts: string[] = [buildEnvBlock(ctx.cwd, ownForkDepth, record.depth)];
|
|
500
|
+
if (opts.agentConfig?.systemPrompt) appendParts.push(opts.agentConfig.systemPrompt);
|
|
501
|
+
if (opts.appendSystemPrompt) appendParts.push(...opts.appendSystemPrompt);
|
|
502
|
+
// [M1 补偿] spawn 模式无运行时 steer 通道(pi --mode json 是 single-shot),
|
|
503
|
+
// 改为启动时预置 wrap-up 提示——agent 感知接近上限时主动收尾。
|
|
504
|
+
// 长期方案:切到 pi --mode rpc(支持运行时 steer),见 follow-up。
|
|
505
|
+
if (opts.maxTurns && opts.maxTurns > 0) appendParts.push(WRAP_UP_HINT);
|
|
506
|
+
if (appendParts.length > 0) {
|
|
507
|
+
tempPromptFile = await writePromptToTempFile(record.agent, appendParts.join("\n\n"));
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// h. fork depth 经环境变量传给子进程(子进程 subagents 扩展 W3 读取)
|
|
511
|
+
const childEnv: Record<string, string | undefined> = { ...process.env };
|
|
512
|
+
if (opts.fork && opts.parentForkDepth !== undefined) {
|
|
513
|
+
childEnv.PI_SUBAGENT_FORK_DEPTH = String(opts.parentForkDepth + 1);
|
|
514
|
+
}
|
|
515
|
+
// D-A6 bridge: schema 激活 structured-output 扩展注册 tool(workflow 编排层需要)
|
|
516
|
+
applySchemaEnvToChildEnv(childEnv, opts.schemaEnv);
|
|
517
|
+
|
|
518
|
+
// i. 组装 args + spawn
|
|
519
|
+
// [M3 恢复] skillPaths: 主 session 的 skillDirs + 调用方传入的 skillPath。
|
|
520
|
+
// ADR-031 后 skillDirs 固定为空,仅 opts.skillPath 生效(agent({skill}) 解析)。
|
|
521
|
+
const skillPaths = [...ctx.skillDirs, opts.skillPath].filter(
|
|
522
|
+
(p): p is string => typeof p === "string" && p.length > 0,
|
|
523
|
+
);
|
|
524
|
+
const modelId = opts.resolved.model.id;
|
|
525
|
+
const spawnArgs = buildSpawnArgs(
|
|
526
|
+
{
|
|
527
|
+
model: `${opts.resolved.model.provider}/${modelId}`,
|
|
528
|
+
thinkingLevel: opts.resolved.thinkingLevel,
|
|
529
|
+
agentTools: opts.agentConfig?.tools,
|
|
530
|
+
appendSystemPromptPath: tempPromptFile?.filePath,
|
|
531
|
+
sessionDir,
|
|
532
|
+
forkSource,
|
|
533
|
+
skillPaths: skillPaths.length > 0 ? skillPaths : undefined,
|
|
534
|
+
},
|
|
535
|
+
fullTask,
|
|
536
|
+
);
|
|
537
|
+
const invocation = getPiInvocation(spawnArgs);
|
|
538
|
+
|
|
539
|
+
// 解析出的 session header(stdout 首行,含 session id)
|
|
540
|
+
let sessionHeader: SpawnSessionHeader | undefined;
|
|
541
|
+
// 累积 stderr(错误诊断用)
|
|
542
|
+
let stderrBuffer = "";
|
|
543
|
+
|
|
544
|
+
try {
|
|
545
|
+
const child = spawn(invocation.command, invocation.args, {
|
|
546
|
+
cwd: spawnCwd,
|
|
547
|
+
shell: false,
|
|
548
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
549
|
+
env: childEnv,
|
|
550
|
+
});
|
|
551
|
+
proc = child;
|
|
552
|
+
// [C1] track 子进程供 dispose 兜底 kill(sync + background 均注册——sync 无 controller,
|
|
553
|
+
// abortRunningControllers 跳过它,靠本 Set 兜底)。close/error 后移除(已退出无需再 kill)。
|
|
554
|
+
spawnedChildren.add(child);
|
|
555
|
+
|
|
556
|
+
// stdout/stderr 用 utf8 编码:stream 自动按字符边界切分,避免多字节
|
|
557
|
+
// UTF-8(CJK/emoji)跨 chunk 时 toString() 产生 U+FFFD 替换符导致 JSON.parse 失败。
|
|
558
|
+
// [m2] 先 setEncoding 再注册 signal listener/watchdog:若 setEncoding 抛错,try/finally
|
|
559
|
+
//(下方)只清理 tempPromptFile,watchdog/signal listener 尚未注册则无需清理——避免泄漏。
|
|
560
|
+
child.stdout.setEncoding("utf8");
|
|
561
|
+
child.stderr.setEncoding("utf8");
|
|
562
|
+
|
|
563
|
+
// d. signal → proc.kill 监听(一次性,替代 session.abort)
|
|
564
|
+
const onAbort = (): void => {
|
|
565
|
+
child.kill("SIGTERM");
|
|
566
|
+
};
|
|
567
|
+
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
568
|
+
// 前置检查:signal 在 spawn 前已 aborted 时 addEventListener 不会触发 onAbort,
|
|
569
|
+
// 子进程会跑到自然结束。立即 kill 兑现取消语义。
|
|
570
|
+
if (opts.signal?.aborted) onAbort();
|
|
571
|
+
|
|
572
|
+
// e. watchdog:子进程整体超时兜底。卡死在单 tool 内(turn_end 永不触发)时
|
|
573
|
+
// limiter 失效,此 timer 保证最终 SIGTERM,防止 background 槽位/资源泄漏。
|
|
574
|
+
// [M-1] timeout 基于 maxTurns 动态计算(computeWatchdogMs):旧实现固定 30 分钟
|
|
575
|
+
// 误杀长任务,现按 maxTurns 线性估算(每 turn ~5 分钟,下限 30 分钟)。
|
|
576
|
+
// [R0] unref:不阻止 Node 进程退出。安全性由 SubagentService.dispose 保证——
|
|
577
|
+
// 主进程退出时(session_shutdown reason=quit)dispose 会 abort running controller
|
|
578
|
+
// → 本监听器 kill 子进程。无此 unref,watchdog timer 会拖住 event loop 阻止退出。
|
|
579
|
+
const watchdogMs = computeWatchdogMs(opts.maxTurns);
|
|
580
|
+
const watchdog = setTimeout(() => child.kill("SIGTERM"), watchdogMs);
|
|
581
|
+
watchdog.unref();
|
|
582
|
+
|
|
583
|
+
// stdout pump:逐行解析 → handleSdkEvent
|
|
584
|
+
let stdoutBuffer = "";
|
|
585
|
+
child.stdout.on("data", (data: string) => {
|
|
586
|
+
stdoutBuffer += data;
|
|
587
|
+
const lines = stdoutBuffer.split("\n");
|
|
588
|
+
stdoutBuffer = lines.pop() ?? ""; // 保留最后未完整行
|
|
589
|
+
for (const line of lines) {
|
|
590
|
+
const parsed = parseSpawnLine(line);
|
|
591
|
+
if (!parsed) continue;
|
|
592
|
+
if (parsed.kind === "header") {
|
|
593
|
+
sessionHeader = parsed.header;
|
|
594
|
+
// 回填 record.sessionFile(deriveSessionFilePath 推导路径)
|
|
595
|
+
record.sessionFile = deriveSessionFilePath(parsed.header, sessionDir);
|
|
596
|
+
// [持久化 C] alive marker:running 期间崩溃恢复用。子进程 pid + session id。
|
|
597
|
+
// 与 in-process 逻辑对齐(记 sessionFile + pid),改为子进程 pid。
|
|
598
|
+
if (record.sessionFile && child.pid) {
|
|
599
|
+
try {
|
|
600
|
+
writeAliveMarker(record.sessionFile, {
|
|
601
|
+
pid: child.pid,
|
|
602
|
+
id: parsed.header.id,
|
|
603
|
+
startedAt: Date.now(),
|
|
604
|
+
});
|
|
605
|
+
} catch {
|
|
606
|
+
// best-effort:alive marker 失败不影响执行
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
// [全局注册表] worktree 模式:补全注册表条目的 pid。
|
|
610
|
+
// create 时 pid 未知写 0 占位,此处拿到 child.pid 后回调 WorktreeManager.registerPid。
|
|
611
|
+
// 取代旧的 .session mapping sidecar——注册表是 reaper 的唯一数据源。
|
|
612
|
+
if (opts.worktree && child.pid) {
|
|
613
|
+
ctx.onWorktreePid?.(opts.worktree.branch, child.pid);
|
|
614
|
+
}
|
|
615
|
+
} else if (parsed.kind === "event") {
|
|
616
|
+
if (isSdkEvent(parsed.event)) handleSdkEvent(parsed.event);
|
|
617
|
+
}
|
|
618
|
+
// invalid 行忽略(stdout 可能有调试输出)
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
child.stderr.on("data", (data: string) => {
|
|
623
|
+
// 截断防 OOM:失控子进程持续打 stderr 会耗尽父进程内存。保留尾部便于诊断。
|
|
624
|
+
stderrBuffer = (stderrBuffer + data).slice(-STDERR_MAX_CHARS);
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
// 等待子进程退出
|
|
628
|
+
const exitCode = await new Promise<number>((resolve) => {
|
|
629
|
+
child.on("close", (code: number | null) => {
|
|
630
|
+
// [C1] 子进程已退出,从 orphan-tracking Set 移除(dispose 兜底无需再 kill 它)
|
|
631
|
+
spawnedChildren.delete(child);
|
|
632
|
+
// 处理 stdout 末尾残留行
|
|
633
|
+
if (stdoutBuffer.trim()) {
|
|
634
|
+
const parsed = parseSpawnLine(stdoutBuffer);
|
|
635
|
+
if (parsed?.kind === "event" && isSdkEvent(parsed.event)) {
|
|
636
|
+
handleSdkEvent(parsed.event);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
resolve(code ?? 0);
|
|
640
|
+
});
|
|
641
|
+
child.on("error", (err: Error) => {
|
|
642
|
+
// spawn 本身失败(command not found 等)
|
|
643
|
+
spawnedChildren.delete(child);
|
|
644
|
+
record.lastError = err.message;
|
|
645
|
+
resolve(SIGNAL_EXIT_CODE_THRESHOLD); // 非零退出
|
|
646
|
+
});
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
650
|
+
clearTimeout(watchdog);
|
|
651
|
+
|
|
652
|
+
// [持久化 A] sessionFile 兜底校验 + identity entry。
|
|
653
|
+
// session.jsonl 由子进程写入,父进程在子进程退出后(写入完成)補写身份条目。
|
|
654
|
+
// reconstructFromFile 依赖 IDENTITY_CUSTOM_TYPE custom entry 重建 record 身份,
|
|
655
|
+
// 缺失则 /subagents list 磁盘源为空(终态 record 全丢失)。[回归修复]
|
|
656
|
+
if (sessionHeader && record.sessionFile) {
|
|
657
|
+
// 兜底:deriveSessionFilePath 推导的路径可能不存在(pi 命名规则变化),
|
|
658
|
+
// 用 sessionId 后缀匹配实际文件。匹配到则修正 record.sessionFile。
|
|
659
|
+
if (!fs.existsSync(record.sessionFile)) {
|
|
660
|
+
const actual = findSessionFileByHeaderId(sessionDir, sessionHeader.id);
|
|
661
|
+
if (actual) record.sessionFile = actual;
|
|
662
|
+
}
|
|
663
|
+
// 补写 identity custom entry(子进程已退出,append 安全)。
|
|
664
|
+
if (fs.existsSync(record.sessionFile)) {
|
|
665
|
+
const identity: SubagentIdentityData = {
|
|
666
|
+
id: record.id,
|
|
667
|
+
agent: record.agent,
|
|
668
|
+
mode: record.mode,
|
|
669
|
+
task: record.task,
|
|
670
|
+
startedAt: record.startedAt,
|
|
671
|
+
rootSessionId: record.rootSessionId,
|
|
672
|
+
parentRecordId: record.parentRecordId,
|
|
673
|
+
depth: record.depth,
|
|
674
|
+
forkDepth: opts.fork ? (opts.parentForkDepth ?? 0) + 1 : undefined,
|
|
675
|
+
};
|
|
676
|
+
try {
|
|
677
|
+
fs.appendFileSync(
|
|
678
|
+
record.sessionFile,
|
|
679
|
+
`${JSON.stringify({ type: "custom", customType: IDENTITY_CUSTOM_TYPE, data: identity })}\n`,
|
|
680
|
+
"utf-8",
|
|
681
|
+
);
|
|
682
|
+
} catch (err) {
|
|
683
|
+
// best-effort:identity 写入失败不影响执行结果,但会影响 /subagents list 重建。
|
|
684
|
+
// 记录到 stderr(非阻断)—— 终态 record 会从 list 消失,这是可观测的退化信号。
|
|
685
|
+
console.error(`[subagents] identity append failed for ${record.sessionFile}:`, err);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// 判定成功/失败(三来源:exitCode + record.lastError + abort 原因)
|
|
691
|
+
let success: boolean;
|
|
692
|
+
let error: string | undefined;
|
|
693
|
+
if (record.lastError) {
|
|
694
|
+
// LLM/provider error 或 abort error 已收口进 record.lastError
|
|
695
|
+
success = false;
|
|
696
|
+
error = record.lastError;
|
|
697
|
+
} else if (exitCode !== 0 && exitCode < SIGNAL_EXIT_CODE_THRESHOLD) {
|
|
698
|
+
// 非信号退出的非零 exit code = 子进程自身报错
|
|
699
|
+
success = false;
|
|
700
|
+
error = stderrBuffer.trim() || `pi subprocess exited with code ${exitCode}`;
|
|
701
|
+
} else if (opts.signal?.aborted) {
|
|
702
|
+
// 用户/调用方 signal 取消(非 maxTurns)——不算成功,但也不算 error
|
|
703
|
+
success = false;
|
|
704
|
+
error = undefined;
|
|
705
|
+
} else {
|
|
706
|
+
// exitCode === 0 或被信号终止(maxTurns 达限 kill)——均视为正常完成
|
|
707
|
+
success = true;
|
|
708
|
+
error = record.lastError;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// g. collectResult(完全复用——全部从 record 读)
|
|
712
|
+
return collectResult(record, {
|
|
713
|
+
startTime,
|
|
714
|
+
success,
|
|
715
|
+
error,
|
|
716
|
+
sessionId: sessionHeader?.id ?? record.id,
|
|
717
|
+
sessionFile: record.sessionFile,
|
|
718
|
+
});
|
|
719
|
+
} finally {
|
|
720
|
+
// h. 清理临时 prompt 文件
|
|
721
|
+
if (tempPromptFile) {
|
|
722
|
+
await cleanupTempPrompt(tempPromptFile);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
}
|