@zhushanwen/pi-subagent-workflow 8.5.0 → 8.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -6
- package/src/execution/__tests__/bg-notify-render.test.ts +73 -0
- package/src/execution/__tests__/chat-engine-routing.test.ts +6 -2
- package/src/execution/__tests__/delivery-methods.test.ts +38 -1
- package/src/execution/__tests__/execute-options-mapper.test.ts +11 -0
- package/src/execution/__tests__/execution-record.test.ts +110 -0
- package/src/execution/__tests__/explicit-agent-ref-guard.test.ts +171 -0
- package/src/execution/__tests__/format-schema-instruction.test.ts +63 -32
- package/src/execution/__tests__/helpers/spawn-mock.ts +4 -0
- package/src/execution/__tests__/index-session-start.test.ts +86 -7
- package/src/execution/__tests__/lifecycle-manager.test.ts +46 -0
- package/src/execution/__tests__/list-fields.test.ts +45 -14
- package/src/execution/__tests__/model-resolver.test.ts +57 -5
- package/src/execution/__tests__/notifier-flush.test.ts +64 -26
- package/src/execution/__tests__/notify-ledger.test.ts +826 -0
- package/src/execution/__tests__/output-collector.test.ts +299 -2
- package/src/execution/__tests__/rpc-mode.test.ts +1 -1
- package/src/execution/__tests__/run-spawn-edges.test.ts +44 -1
- package/src/execution/__tests__/run-spawn-stdout-callback-throw.test.ts +199 -0
- package/src/execution/__tests__/session-runner-schema-env.test.ts +39 -0
- package/src/execution/__tests__/spawn-args.test.ts +37 -26
- package/src/execution/__tests__/start-sync-model-guard.test.ts +150 -0
- package/src/execution/__tests__/subprocess-agent-runner.test.ts +94 -1
- package/src/execution/__tests__/timeout-integration.test.ts +220 -2
- package/src/execution/__tests__/tool-action.test.ts +92 -1
- package/src/execution/agent-registry.ts +6 -0
- package/src/execution/argv-mirror.ts +5 -1
- package/src/execution/concurrency-pool.ts +1 -1
- package/src/execution/engine/engines/zcode/__tests__/zcode-engine.test.ts +13 -0
- package/src/execution/engine/engines/zcode/zcode-engine.ts +11 -1
- package/src/execution/engine/types.ts +6 -1
- package/src/execution/execute-options-mapper.ts +8 -7
- package/src/execution/execution-record.ts +60 -1
- package/src/execution/lifecycle-manager.ts +23 -1
- package/src/execution/model-config-service.ts +16 -1
- package/src/execution/model-resolver.ts +31 -59
- package/src/execution/notifier.ts +105 -35
- package/src/execution/notify-ledger.ts +580 -0
- package/src/execution/output-collector.ts +143 -3
- package/src/execution/session-runner.ts +304 -71
- package/src/execution/subagent-service.ts +24 -2
- package/src/execution/subprocess-agent-runner.ts +14 -0
- package/src/execution/types.ts +68 -5
- package/src/execution/ui-request-queue.ts +14 -4
- package/src/index.ts +54 -1
- package/src/interface/__tests__/subagent-tool-path-guard.test.ts +157 -0
- package/src/interface/__tests__/subagent-tool-prompt.test.ts +12 -0
- package/src/interface/bg-notify-render.ts +33 -12
- package/src/interface/helpers.ts +2 -2
- package/src/interface/subagent-actions.ts +26 -9
- package/src/interface/subagent-tool-schema.ts +156 -0
- package/src/interface/subagent-tool.ts +56 -125
- package/src/interface/subagents.ts +2 -2
- package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +16 -3
- package/src/orchestration/__tests__/agent-call-catch-fallback.test.ts +0 -6
- package/src/orchestration/__tests__/agent-call-stream.test.ts +0 -5
- package/src/orchestration/__tests__/error-recovery-handlers.test.ts +89 -4
- package/src/orchestration/__tests__/execute-agent-call.test.ts +137 -0
- package/src/orchestration/__tests__/jsonl-run-store-corrupt-entry.test.ts +150 -0
- package/src/orchestration/__tests__/jsonl-run-store-retention.test.ts +202 -0
- package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +326 -3
- package/src/orchestration/__tests__/lifecycle.test.ts +41 -7
- package/src/orchestration/__tests__/non-cloneable-return-e2e.test.ts +95 -0
- package/src/orchestration/__tests__/review-fix-loop-e2e.test.ts +57 -3
- package/src/orchestration/__tests__/skill-discovery.test.ts +44 -0
- package/src/orchestration/__tests__/worker-exit-without-result.test.ts +368 -0
- package/src/orchestration/__tests__/worker-script-builder-runtime.test.ts +43 -0
- package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +21 -2
- package/src/orchestration/agent-opts-resolver.ts +104 -23
- package/src/orchestration/error-recovery.ts +189 -33
- package/src/orchestration/execute-agent-call.ts +39 -0
- package/src/orchestration/jsonl-run-store.ts +121 -7
- package/src/orchestration/launcher.ts +60 -15
- package/src/orchestration/lifecycle.ts +10 -7
- package/src/orchestration/models/__tests__/budget.test.ts +1 -61
- package/src/orchestration/models/budget.ts +5 -35
- package/src/orchestration/models/run-runtime.ts +24 -9
- package/src/orchestration/models/types.ts +9 -0
- package/src/orchestration/script-lint.ts +1 -1
- package/src/orchestration/skill-discovery.ts +31 -8
- package/src/orchestration/worker-script-builder.ts +16 -3
- package/src/shared/__tests__/model-ref.test.ts +306 -0
- package/src/shared/__tests__/schema-jsonify.test.ts +1 -1
- package/src/shared/__tests__/timer-delay.test.ts +61 -0
- package/src/shared/model-ref.ts +286 -0
- package/src/shared/schema-env.ts +44 -0
- package/src/shared/schema-jsonify.ts +6 -4
- package/src/shared/timer-delay.ts +54 -0
- package/workflows/review-fix-loop-utils.cjs +9 -7
- package/workflows/review-fix-loop.js +20 -12
- package/src/orchestration/__tests__/concurrency-gate.test.ts +0 -125
- package/src/orchestration/concurrency-gate.ts +0 -69
|
@@ -26,7 +26,13 @@ import { collectResult } from "./output-collector.ts";
|
|
|
26
26
|
import { getSubagentSessionDir } from "./path-encoding.ts";
|
|
27
27
|
import { getPiInvocation } from "./pi-invocation.ts";
|
|
28
28
|
import { isRelayActive, RELAY_ENV_RECORD_ID, RELAY_ENV_SESSION_ID } from "./relay-env.ts";
|
|
29
|
-
import {
|
|
29
|
+
import { assertThinkingLevel, type ThinkingLevel } from "../shared/model-ref";
|
|
30
|
+
import {
|
|
31
|
+
SCHEMA_ENV_MAX_BYTES,
|
|
32
|
+
SCHEMA_ENV_VAR,
|
|
33
|
+
schemaEnvByteLength,
|
|
34
|
+
} from "../shared/schema-env.ts";
|
|
35
|
+
import { assertSafeTimerDelay } from "../shared/timer-delay.ts";
|
|
30
36
|
import { MAX_FORK_DEPTH } from "./session-context-resolver.ts";
|
|
31
37
|
import { EPIPE_FAILURE_THRESHOLD, recordEpipeFailure, sendPromptCommand } from "./stdin-writer.ts";
|
|
32
38
|
import {
|
|
@@ -137,7 +143,8 @@ const WATCHDOG_MS_PER_TURN = WATCHDOG_MINUTES_PER_TURN * SECONDS_PER_MINUTE * MS
|
|
|
137
143
|
// 此后代卡死(永不完成)时此 timer 保证进程最终回收。超时 kill → finalize 视为正常
|
|
138
144
|
// 完成 → 通知父(父查 cw status 发现未 closed 会走 L2/L3 重派,见 planning-agent 模板)。
|
|
139
145
|
// 两类超时分挂不同分支(见 agent_end handler):
|
|
140
|
-
// - 有活跃后代(count>0 / error)→
|
|
146
|
+
// - 有活跃后代(count>0 / error)→ resolveSpawnWatchdogMs(maxTurns) 动态超时
|
|
147
|
+
// (MF-4,不误杀慢后代;maxTurns 未传且无兑底 env 时不挂 timer,不限时等待)
|
|
141
148
|
// - 仅 recentUnregister 竞态 → WAKEUP_GRACE_MS 秒级宽限(MF-3,不空等 2h)
|
|
142
149
|
|
|
143
150
|
/** [MF-3] agent_end keep-alive 的 recentUnregister 竞态宽限(ms)。
|
|
@@ -155,22 +162,83 @@ export const WAKEUP_GRACE_MS = 15_000;
|
|
|
155
162
|
* (全量重构/大规模迁移)正常需数小时,30 分钟到达即被误杀,limiter 机制形同虚设。
|
|
156
163
|
*
|
|
157
164
|
* 现按 maxTurns 线性估算:每 turn 约 5 分钟,下限 30 分钟。
|
|
158
|
-
* - maxTurns 缺省(undefined/null/0)按 10 turns 估 → 50 分钟
|
|
159
165
|
* - maxTurns=20 → 100 分钟
|
|
160
166
|
* - maxTurns=100 → 500 分钟(8 小时+,覆盖全量重构)
|
|
161
|
-
*
|
|
167
|
+
*
|
|
168
|
+
* [预算语义对齐 2026-08] maxTurns 未传/<=0 → 不挂 watchdog(不限)。旧实现按 10 turns
|
|
169
|
+
* 估算出 50min 默认 SIGTERM,违背「默认不限制,显式传参才触发」的项目裁决;且显式
|
|
170
|
+
* maxTurns:0 也被落回估算,任何参数都关不掉。用户须知风险:watchdog 防的是 pi 子进程
|
|
171
|
+
* hang 泄漏(卡死在单个 tool 内 turn_end 永不触发,limiter 失效),默认关闭意味着
|
|
172
|
+
* 无 maxTurns 的 spawn 若 hang 将永不自动回收——须用下方 SPAWN_WATCHDOG_ENV 显式兑底。
|
|
173
|
+
*
|
|
162
174
|
* [MF-4] 同时是 agent_end keep-alive 的「有活跃后代」等待超时(不 kill 分支),
|
|
163
175
|
* 替代旧固定 2h(WAIT_DESCENDANT_TIMEOUT_MS,已删除)——wave 开发 >2h 不被误杀。
|
|
164
176
|
* [export] 测试可观测(run-spawn-edges MF-4 用例断言 keep-alive 等待超时 = 动态值)。
|
|
165
|
-
*
|
|
166
|
-
* @param maxTurns 调用方指定的 turn
|
|
177
|
+
*
|
|
178
|
+
* @param maxTurns 调用方指定的 turn 上限;调用方保证 > 0(否则走 resolveSpawnWatchdogMs)
|
|
167
179
|
*/
|
|
168
|
-
|
|
169
|
-
|
|
180
|
+
export function computeWatchdogMs(maxTurns: number): number {
|
|
181
|
+
return Math.max(SPAWN_WATCHDOG_FLOOR_MS, maxTurns * WATCHDOG_MS_PER_TURN);
|
|
182
|
+
}
|
|
170
183
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
184
|
+
/**
|
|
185
|
+
* spawn watchdog 毫秒兑底 env(可选,默认未设 = 不挂 watchdog)。
|
|
186
|
+
*
|
|
187
|
+
* 仅当 maxTurns 未传(undefined/null)时生效:设置后按该绝对时限挂 watchdog,
|
|
188
|
+
* 未设则完全不挂(不限)。显式传 maxTurns(含 0/负数)压过 env(U5,SP-6 参数 > env)
|
|
189
|
+
* ——旧实现 maxTurns:0 落到 env 兑底,参数显式关不掉 watchdog。
|
|
190
|
+
* 前缀用 XYZ_SUBAGENT_*:本 env 是父侧(pi 进程内)读的配置
|
|
191
|
+
* env,xyz-agent 桌面 spawn 链会按 ENV_WHITELIST_PREFIXES(只有 XYZ_ 等,无 PI_)过滤,
|
|
192
|
+
* PI_ 前缀在桌面场景被静默丢弃——同 XYZ_SUBAGENT_IDLE_TIMEOUT_MS 的改名教训。
|
|
193
|
+
* 注意与 PI_SUBAGENT_* 系(extension spawn 子进程时直接注入 childEnv)的机制区别。
|
|
194
|
+
* 与 launcher 的 XYZ_SUBAGENT_RUN_WATCHDOG_MS(workflow run 轮询兑底)对称:
|
|
195
|
+
* 两者都是「默认关、显式设置才挂绝对时限」的 hang 兑底通道(U7)。
|
|
196
|
+
*/
|
|
197
|
+
export const SPAWN_WATCHDOG_ENV = "XYZ_SUBAGENT_SPAWN_WATCHDOG_MS";
|
|
198
|
+
|
|
199
|
+
/** 解析 spawn watchdog 毫秒数;env 未设/非法返回 undefined(调用方不挂 timer)。 */
|
|
200
|
+
function getEnvSpawnWatchdogMs(): number | undefined {
|
|
201
|
+
const raw = process.env[SPAWN_WATCHDOG_ENV];
|
|
202
|
+
if (!raw) return undefined;
|
|
203
|
+
const parsed = Number(raw);
|
|
204
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return undefined;
|
|
205
|
+
return parsed;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* 解析 spawn watchdog 超时(挂载判定的单一入口)。
|
|
210
|
+
*
|
|
211
|
+
* 优先级(SP-6 参数 > env,U5):maxTurns 显式传参时压过 env——有效(>0)按 turns
|
|
212
|
+
* 估算(computeWatchdogMs);显式 0/负 = 显式不限(不挂 watchdog);仅 undefined/null
|
|
213
|
+
* (未传)才落 SPAWN_WATCHDOG_ENV 兑底;env 也未设 → undefined(不挂 watchdog,不限)。
|
|
214
|
+
*
|
|
215
|
+
* [U1] 返回值(env 兑底或 turns 估算两条路径)流入 setTimeout 前校验安全域:
|
|
216
|
+
* >2^31-1 的 delay 被 Node 置 1ms 立即触发(watchdog 变「启动即杀」),fail-fast。
|
|
217
|
+
*
|
|
218
|
+
* [export] 测试可观测(timeout-integration 用例断言 maxTurns 缺省/env 兑底分支)。
|
|
219
|
+
*/
|
|
220
|
+
export function resolveSpawnWatchdogMs(maxTurns: number | undefined | null): number | undefined {
|
|
221
|
+
if (maxTurns === undefined || maxTurns === null) {
|
|
222
|
+
const envMs = getEnvSpawnWatchdogMs();
|
|
223
|
+
if (envMs !== undefined) assertSafeTimerDelay(envMs, SPAWN_WATCHDOG_ENV);
|
|
224
|
+
return envMs;
|
|
225
|
+
}
|
|
226
|
+
// [F-R3] 显式传参先数值规范化 + 有限性校验。旧实现直接走 maxTurns>0 判断:
|
|
227
|
+
// NaN / ""(Number("")===0)等垃圾值比较为 false 落到 return undefined——既绕过
|
|
228
|
+
// Number.isFinite 校验,又压过 SPAWN_WATCHDOG_ENV 兑底,把调用方 bug 静默变成
|
|
229
|
+
// 「显式不限」。现非有限数(NaN/±Infinity)与 timer-delay 同语义 fail-fast(U1,
|
|
230
|
+
// 非有限 delay 在 Node setTimeout 塔缩为 1ms 立即触发,比静默不限更危险,必须暴露);
|
|
231
|
+
// Number("") === 0 → 与显式 0 同路径(显式不限,压过 env,U5 语义不回归)。
|
|
232
|
+
const turns = Number(maxTurns);
|
|
233
|
+
if (!Number.isFinite(turns)) {
|
|
234
|
+
assertSafeTimerDelay(turns, `maxTurns=${String(maxTurns)}`);
|
|
235
|
+
}
|
|
236
|
+
if (turns > 0) {
|
|
237
|
+
const estimated = computeWatchdogMs(turns);
|
|
238
|
+
assertSafeTimerDelay(estimated, `computeWatchdogMs(maxTurns=${maxTurns})`);
|
|
239
|
+
return estimated;
|
|
240
|
+
}
|
|
241
|
+
return undefined;
|
|
174
242
|
}
|
|
175
243
|
|
|
176
244
|
/** stderr 累积上限——按字符截断(.slice 语义),非字节;64K 规模沿自原实现。
|
|
@@ -178,15 +246,18 @@ export function computeWatchdogMs(maxTurns: number | undefined | null): number {
|
|
|
178
246
|
const STDERR_MAX_CHARS = 65_536;
|
|
179
247
|
|
|
180
248
|
/**
|
|
181
|
-
* 跨包契约 env
|
|
249
|
+
* 跨包契约 env 名与注入上限(SCHEMA_ENV_VAR / SCHEMA_ENV_MAX_BYTES):
|
|
250
|
+
* 实装已抽到 shared/schema-env.ts(零依赖叶子,S5),顶部 import + 下方 re-export。
|
|
182
251
|
*
|
|
183
252
|
* [跨包契约 SSOT] 此字面量是两个独立 npm 包(@zhushanwen/pi-subagent-workflow 与
|
|
184
253
|
* @zhushanwen/pi-structured-output)之间的隐式 env 契约。structured-output 包内同名常量为
|
|
185
254
|
* `ENV_SCHEMA = "PI_WORKFLOW_SCHEMA"`(见 extensions/universal/structured-output/src/index.ts)。
|
|
186
255
|
* 两包是独立 npm 包不能直接 import,故各自保留常量但显式标注此契约关系——
|
|
187
256
|
* 任一端改名必须同步另一端,否则权威 schema 注入会静默断桥(子进程不注册 tool/hook)。
|
|
257
|
+
* 抽叶子的目的:跨包契约测试从 session-runner import 会拖入整条 spawn/pi SDK 依赖树,
|
|
258
|
+
* 叶子模块提供稳定 import 点(导出名与值不变)。
|
|
188
259
|
*/
|
|
189
|
-
|
|
260
|
+
export { SCHEMA_ENV_MAX_BYTES, SCHEMA_ENV_VAR };
|
|
190
261
|
|
|
191
262
|
// ============================================================
|
|
192
263
|
// W4: ask_user RPC 系统提示词
|
|
@@ -493,44 +564,30 @@ export interface SpawnResumeOpts {
|
|
|
493
564
|
* [模块内直调] —— 纯 env 赋值。从 runSpawn 的 childEnv 构造块调用。
|
|
494
565
|
* 存在时设 childEnv[SCHEMA_ENV_VAR] → 子进程 structured-output 扩展读取并注册 tool。
|
|
495
566
|
* 不存在时 childEnv 不变(BC-6:tool 层不传 schemaEnv → 行为与合并前一致)。
|
|
567
|
+
*
|
|
568
|
+
* [SO-DATA-4] 注入前按 UTF-8 字节长度校验,超 SCHEMA_ENV_MAX_BYTES(256KiB)fail-fast
|
|
569
|
+
* 拒绝:env 值过大叠加全量继承的 process.env 可能触发 execve 的 E2BIG(ARG_MAX 约束),
|
|
570
|
+
* spawn 直接失败且错误难归因。提前在注入点报错,消息含实际大小与精简指引。
|
|
571
|
+
*
|
|
572
|
+
* @throws Error schemaEnv 序列化后超过 SCHEMA_ENV_MAX_BYTES
|
|
496
573
|
*/
|
|
497
574
|
export function applySchemaEnvToChildEnv(
|
|
498
575
|
childEnv: Record<string, string | undefined>,
|
|
499
576
|
schemaEnv?: string,
|
|
500
577
|
): void {
|
|
501
578
|
if (schemaEnv) {
|
|
579
|
+
const sizeBytes = schemaEnvByteLength(schemaEnv);
|
|
580
|
+
if (sizeBytes > SCHEMA_ENV_MAX_BYTES) {
|
|
581
|
+
throw new Error(
|
|
582
|
+
`[subagent-workflow] schema env too large: ${sizeBytes} bytes exceeds the ${SCHEMA_ENV_MAX_BYTES}-byte limit for ${SCHEMA_ENV_VAR}. ` +
|
|
583
|
+
"Oversized env values can overflow the execve ARG_MAX budget (E2BIG) once combined with the inherited process.env, failing the spawn with a hard-to-attribute error. " +
|
|
584
|
+
"Recovery: simplify the schema (drop verbose descriptions/examples, use $defs instead of inline repetition) or split it across multiple smaller agent() calls, then retry.",
|
|
585
|
+
);
|
|
586
|
+
}
|
|
502
587
|
childEnv[SCHEMA_ENV_VAR] = schemaEnv;
|
|
503
588
|
}
|
|
504
589
|
}
|
|
505
590
|
|
|
506
|
-
// ============================================================
|
|
507
|
-
// Schema 指令
|
|
508
|
-
// ============================================================
|
|
509
|
-
|
|
510
|
-
/**
|
|
511
|
-
* 构造 schema 指令模板(拼入 task 末尾 + steer reminder 复用)。
|
|
512
|
-
* 指令明确要求 agent 调用 structured-output tool,而非直接输出 JSON 文本。
|
|
513
|
-
*
|
|
514
|
-
* schema JSON 的 pretty-print(indent=2)由 shared/schema-jsonify.ts 的
|
|
515
|
-
* stringifySchemaCached(schema, "pretty") 产出(IF7:与 resolver 的 compact 版
|
|
516
|
-
* 共享 WeakMap 缓存条目,输出与 JSON.stringify(schema, null, 2) 逐字节一致)。
|
|
517
|
-
*/
|
|
518
|
-
export function formatSchemaInstruction(schema: Record<string, unknown>): string {
|
|
519
|
-
return [
|
|
520
|
-
"MANDATORY: Structured Output Requirement",
|
|
521
|
-
"You MUST call the `structured-output` tool with your final answer.",
|
|
522
|
-
"Do NOT output the JSON directly in your text response — you MUST use the structured-output tool.",
|
|
523
|
-
"The schema is enforced by the system — call structured-output with ONLY the `data` parameter.",
|
|
524
|
-
"Do NOT pass a `schema` parameter; the system validates `data` against the authoritative schema automatically.",
|
|
525
|
-
"The schema for your `data` is:",
|
|
526
|
-
"```json",
|
|
527
|
-
// IF7(#13):同 schema 对象引用的 pretty stringify 走 WeakMap 缓存
|
|
528
|
-
// (与 agent-opts-resolver 的 compact 版共享缓存条目,输出逐字节不变)
|
|
529
|
-
stringifySchemaCached(schema, "pretty"),
|
|
530
|
-
"```",
|
|
531
|
-
].join("\n");
|
|
532
|
-
}
|
|
533
|
-
|
|
534
591
|
// ============================================================
|
|
535
592
|
// 环境信息块(M1 恢复)
|
|
536
593
|
// ============================================================
|
|
@@ -621,7 +678,10 @@ export async function buildEnvBlock(
|
|
|
621
678
|
// b. handleSdkEvent switch(SdkEvent → AgentEvent)
|
|
622
679
|
// c. turnLimiter:maxTurns 用事件计数 turn_end + proc.kill 替代 session.abort
|
|
623
680
|
// d. signal → proc.kill 监听(替代 signal → session.abort)
|
|
624
|
-
// e. schema enforcement
|
|
681
|
+
// e. schema enforcement:经 resolver 注入 appendSystemPrompt(ASP 单点,不再拼
|
|
682
|
+
// task 后缀——工具 parameters 是 pi 必然注入的权威展示,机制登记 PS-21:
|
|
683
|
+
// pi-ai provider 层 convertTools 把 tool.parameters 随请求下发(anthropic
|
|
684
|
+
// :1000/:1017 input_schema、openai :1099 parameters),见 agent-opts-resolver)
|
|
625
685
|
// f. spawn + pump stdout(替代 session.prompt)
|
|
626
686
|
// g. collectResult → AgentResult(完全复用)
|
|
627
687
|
// h. proc cleanup(替代 session.dispose)
|
|
@@ -632,15 +692,29 @@ export async function buildEnvBlock(
|
|
|
632
692
|
/** 子进程退出码阈值:>=128 表示被信号终止(SIGTERM=143 等)。 */
|
|
633
693
|
const SIGNAL_EXIT_CODE_THRESHOLD = 128;
|
|
634
694
|
|
|
695
|
+
/**
|
|
696
|
+
* spawn 侧已裁决的模型身份(U1 D2):buildSpawnArgs 只接受经 assertCanonicalModelRef /
|
|
697
|
+
* modelRefFromVerified 裁决的 {provider, id},拼接值 = `${provider}/${id}`。
|
|
698
|
+
* 类型层面裸字符串不可达——任何未经 D1 裁决的模型串无法流入 `--model`。
|
|
699
|
+
*/
|
|
700
|
+
export interface SpawnModelRef {
|
|
701
|
+
provider: string;
|
|
702
|
+
id: string;
|
|
703
|
+
}
|
|
704
|
+
|
|
635
705
|
/**
|
|
636
706
|
* 组装 pi CLI 参数(不含 task 本身,task 作为最后一个位置参数)。
|
|
637
707
|
*
|
|
638
708
|
* 抽取自 runSpawn 便于单测(纯函数,不依赖进程状态)。
|
|
709
|
+
*
|
|
710
|
+
* [U1 D2 spawn 前置守卫] 入参收窄:model 字符串 → modelRef(SpawnModelRef),
|
|
711
|
+
* thinkingLevel → ThinkingLevel 白名单字面量联合。`--model` 值恒为
|
|
712
|
+
* `${modelRef.provider}/${modelRef.id}`(+ 可选白名单 `:level` 后缀)。
|
|
639
713
|
*/
|
|
640
714
|
export function buildSpawnArgs(
|
|
641
715
|
params: {
|
|
642
|
-
|
|
643
|
-
thinkingLevel:
|
|
716
|
+
modelRef: SpawnModelRef;
|
|
717
|
+
thinkingLevel: ThinkingLevel | undefined;
|
|
644
718
|
agentTools: string[] | undefined;
|
|
645
719
|
appendSystemPromptPath: string | undefined;
|
|
646
720
|
sessionDir: string;
|
|
@@ -662,13 +736,27 @@ export function buildSpawnArgs(
|
|
|
662
736
|
// task 不通过命令行传——pi 的 runRpcMode 只消费 stdin RpcCommand,
|
|
663
737
|
// positional task arg / -p flag 在 rpc mode 下被 resolveAppMode 无视。
|
|
664
738
|
// task 由 runSpawn 内 sendPromptCommand 写 child.stdin 驱动。
|
|
739
|
+
//
|
|
740
|
+
// [单写者不变量·MF-8|第五轮元审查结论] session JSONL 完整性依赖「每 session
|
|
741
|
+
// 单写进程」架构不变量:子进程写独立 subagent sessionDir(getSubagentSessionDir
|
|
742
|
+
// 编码隔离),主 session 仅本进程单线程写。pi 0.84.1 写入原语(dist/core/
|
|
743
|
+
// session-manager.js,机制登记 PS-18)只在「唯一写者」前提下原子:_persist
|
|
744
|
+
//(:724-753)首写用 wx flag 整体落盘缓冲 entry(:739),此后一律 appendFileSync
|
|
745
|
+
// 追加(:730/:751);运行时 compaction 走 appendCompaction(:803-818)→
|
|
746
|
+
// _appendEntry → _persist 的 append-only 追加(agent-session.js:1432 手动 /
|
|
747
|
+
// :1670 自动),不重写文件;截断重写 _rewriteFile(openSync(path,"w"),:693-705)
|
|
748
|
+
// 仅在加载期触发:空文件归一(:627)/ 版本迁移(:634)/ branch 换新文件(:1143)。
|
|
749
|
+
// 引入第二写进程(如父进程补写/双进程同 sessionDir)则全部失守:appendFileSync
|
|
750
|
+
// 无 O_APPEND 与对方交错截断,加载期重写吞掉并发追加的尾部 entry,历史上已造成
|
|
751
|
+
// 双写者事故(v4 A-5/P7)。任何改动不得让两个进程指向同一 session 文件写路径。
|
|
665
752
|
const args: string[] = ["--mode", "rpc", "--session-dir", params.sessionDir];
|
|
666
753
|
// resume:紧跟 --session-dir 追加 --session <file>,pi 续写原 session 文件(P-8)。
|
|
667
754
|
if (params.sessionFile) {
|
|
668
755
|
args.push("--session", params.sessionFile);
|
|
669
756
|
}
|
|
670
|
-
|
|
671
|
-
|
|
757
|
+
// [U1 D2] 只拼接已裁决 ModelRef;thinkingLevel 类型已收窄为白名单字面量联合。
|
|
758
|
+
args.push("--model", `${params.modelRef.provider}/${params.modelRef.id}`);
|
|
759
|
+
if (params.thinkingLevel) {
|
|
672
760
|
// thinking level 通过 model 后缀 :level 传递(pi CLI 约定)
|
|
673
761
|
// model 已 push,这里只补后缀到同一 token
|
|
674
762
|
const lastIdx = args.length - 1;
|
|
@@ -751,6 +839,9 @@ interface SpawnRunState {
|
|
|
751
839
|
proc: ChildProcess | undefined;
|
|
752
840
|
/** watchdog timer(stdout handler 的 agent_end keep-alive 分支重挂,收尾统一 clearTimeout)。 */
|
|
753
841
|
watchdog: NodeJS.Timeout | undefined;
|
|
842
|
+
/** [race-F4] SIGKILL 升级 timer(killChildWithEscalation 挂载;exit 事件自动 clear,
|
|
843
|
+
* 收尾统一 clearTimeout 兑底)。 */
|
|
844
|
+
escalationTimer: NodeJS.Timeout | undefined;
|
|
754
845
|
/** stdout 首行 header(json mode 才有;RPC mode 恒 undefined)——收尾 sessionFile 兜底查找用。 */
|
|
755
846
|
sessionHeader: SpawnSessionHeader | undefined;
|
|
756
847
|
/** get_state 握手结果(RPC mode)——收尾 sessionFile 兜底查找用。 */
|
|
@@ -764,6 +855,55 @@ interface SpawnRunState {
|
|
|
764
855
|
resolveRun: ((code: number) => void) | undefined;
|
|
765
856
|
}
|
|
766
857
|
|
|
858
|
+
/**
|
|
859
|
+
* 事件累积器工厂(原 runSpawn 内联的 a/b 两段 + handleSdkEvent/agentEvent 闭包)。
|
|
860
|
+
*
|
|
861
|
+
* pendingTools 寄存器 / turnLimiter / accumulateMessageEnd 均闭包在工厂内部,
|
|
862
|
+
* 对外只暴露 handleSdkEvent(stdout 解析出的 SdkEvent 的唯一喂入口)。
|
|
863
|
+
*/
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* [race-F4] SIGTERM 后升级 SIGKILL 的等待窗口:30s 未见 exit 视为 SIGTERM 被无视,强杀。
|
|
867
|
+
*/
|
|
868
|
+
const SIGKILL_ESCALATION_SECONDS = 30;
|
|
869
|
+
const SIGKILL_ESCALATION_MS = SIGKILL_ESCALATION_SECONDS * MS_PER_SECOND;
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* [race-F4] 发 SIGTERM 并武装 SIGKILL 升级 timer:SIGKILL_ESCALATION_MS 后子进程
|
|
873
|
+
* 仍未退出(exitCode/signalCode 双 null)则 SIGKILL。
|
|
874
|
+
*
|
|
875
|
+
* 背景:watchdog/limiter/idle timer/abort 触发只发一次 SIGTERM——子进程若无视
|
|
876
|
+
* SIGTERM(卡死在不可中断的 native 调用 / SIGTERM handler 挂死),close 永不触发
|
|
877
|
+
* → runSpawn 悬挂、background 槽位/worktree/alive marker 泄漏,旧实现永不回收。
|
|
878
|
+
*
|
|
879
|
+
* - 升级 timer unref(不阻止主进程退出;dispose 路径 killAllSpawnedChildren 兜底)
|
|
880
|
+
* - assertSafeTimerDelay:包内 timer 挂载入口统一校验(常量 30s 恒通过,防未来改
|
|
881
|
+
* 可配置时静默引入 1ms 溢出语义反转)
|
|
882
|
+
* - child exit 事件 clear 升级 timer(自然退出/被 SIGTERM 杀死均不升级)
|
|
883
|
+
* - state.escalationTimer 记录句柄:watchdog re-arm 场景先清旧升级窗口,收尾兜底 clear
|
|
884
|
+
*
|
|
885
|
+
* @param source 升级来源标识(warn 日志定位用,如 "spawn watchdog" / "turn limiter")
|
|
886
|
+
*/
|
|
887
|
+
function killChildWithEscalation(state: SpawnRunState, child: ChildProcess, source: string): void {
|
|
888
|
+
child.kill("SIGTERM");
|
|
889
|
+
if (state.escalationTimer) clearTimeout(state.escalationTimer);
|
|
890
|
+
assertSafeTimerDelay(SIGKILL_ESCALATION_MS, `SIGKILL escalation (${source})`);
|
|
891
|
+
const escalation = setTimeout(
|
|
892
|
+
() => {
|
|
893
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
894
|
+
logger.warn(
|
|
895
|
+
`[session-runner] child ${state.record.id} still alive ${SIGKILL_ESCALATION_MS / MS_PER_SECOND}s after SIGTERM, escalating to SIGKILL (source: ${source})`,
|
|
896
|
+
);
|
|
897
|
+
child.kill("SIGKILL");
|
|
898
|
+
}
|
|
899
|
+
},
|
|
900
|
+
SIGKILL_ESCALATION_MS,
|
|
901
|
+
);
|
|
902
|
+
escalation.unref();
|
|
903
|
+
child.once("exit", () => clearTimeout(escalation));
|
|
904
|
+
state.escalationTimer = escalation;
|
|
905
|
+
}
|
|
906
|
+
|
|
767
907
|
/**
|
|
768
908
|
* 事件累积器工厂(原 runSpawn 内联的 a/b 两段 + handleSdkEvent/agentEvent 闭包)。
|
|
769
909
|
*
|
|
@@ -788,7 +928,8 @@ function createSpawnEventHandlers(state: SpawnRunState): (raw: SdkEvent) => void
|
|
|
788
928
|
// 但未实现写入逻辑)。补偿已在启动时注入 WRAP_UP_HINT 让 agent 主动收尾。
|
|
789
929
|
},
|
|
790
930
|
abort: () => {
|
|
791
|
-
|
|
931
|
+
// [race-F4] 升级路径:SIGTERM 后 30s 未 exit 则 SIGKILL(挂住子进程永不回收防线)
|
|
932
|
+
if (state.proc) killChildWithEscalation(state, state.proc, "turn limiter abort");
|
|
792
933
|
},
|
|
793
934
|
});
|
|
794
935
|
|
|
@@ -824,13 +965,23 @@ function createSpawnEventHandlers(state: SpawnRunState): (raw: SdkEvent) => void
|
|
|
824
965
|
// 非 chatMode:忽略(agent_end handler 的一次性 kill 已处理,进程不会活到 agent_settled)。
|
|
825
966
|
if (isAgentSettledEvt(raw)) {
|
|
826
967
|
if (record.chatMode) {
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
968
|
+
// [F-R2] 本闭包经 stdout data 回调同步调用(handleSdkEvent ← attachStdoutPump):
|
|
969
|
+
// armIdleTimer → assertSafeTimerDelay fail-fast 的 throw 若逃出回调 = uncaughtException
|
|
970
|
+
// 崩宿主。包 try/catch 降级为「不挂 idle timer」(进程回收由外部 signal / dispose 驱动),
|
|
971
|
+
// 错误经 bestEffort("error") 可见但不升级为进程崩溃;后续 limiter.reset /
|
|
972
|
+
// onRoundSettled / resolveRun 照常执行(本轮完成通知不因 GC timer 故障丢失)。
|
|
973
|
+
try {
|
|
974
|
+
armIdleTimer(record.id, () => {
|
|
975
|
+
// onTimeout 复用现有 kill 路径:child.kill("SIGTERM") 触发 close → close handler
|
|
976
|
+
// 统一 cleanup(spawnedChildren.delete / get_stateListeners.clear / resolve)。
|
|
977
|
+
// 与 agent_end handler 现有 SIGTERM 分支一致,不新造 cleanup。
|
|
978
|
+
// [race-F4] 升级:idle timer SIGTERM 后挂住 → 30s 后 SIGKILL。
|
|
979
|
+
const child = getChildByRecord(record.id);
|
|
980
|
+
if (child && !child.killed) killChildWithEscalation(state, child, "idle timer");
|
|
981
|
+
}, record.idleTimeoutMs);
|
|
982
|
+
} catch (err) {
|
|
983
|
+
bestEffort(err, "armIdleTimer (agent_settled chatMode)", "error");
|
|
984
|
+
}
|
|
834
985
|
// [SP-9] chatMode 每轮 reset turn-limiter:新一轮开始(续聊)时,
|
|
835
986
|
// maxTurns/graceTurns 不跨轮累计(续聊本质是无限轮,累计上限违背 G1)。
|
|
836
987
|
// reset steered/aborted 标志 + turnCount 归零,下一轮独立计数。
|
|
@@ -1008,14 +1159,22 @@ function buildSpawnInvocation(
|
|
|
1008
1159
|
const skillPaths = [...ctx.skillDirs, opts.skillPath].filter(
|
|
1009
1160
|
(p): p is string => typeof p === "string" && p.length > 0,
|
|
1010
1161
|
);
|
|
1011
|
-
|
|
1162
|
+
// [U1 D2] modelRef 来源:
|
|
1163
|
+
// - 非 resume:opts.resolved.model(resolveModel 裁决放行的 registry 全等条目)。
|
|
1164
|
+
// - resume:resume.model 是 SpawnResumeOpts 回显(record.model,"provider/id" 系统自产
|
|
1165
|
+
// 已裁决形态,P-10 防漂移),按第一个 / 拆分(与 subagent-service record 回读同构),
|
|
1166
|
+
// 不再经 assertCanonicalModelRef(registry 快照可能已刷新,拒绝回显会破坏续聊)。
|
|
1167
|
+
const modelRef: SpawnModelRef = resume?.model
|
|
1168
|
+
? splitRecordModelRef(resume.model)
|
|
1169
|
+
: { provider: opts.resolved.model.provider, id: opts.resolved.model.id };
|
|
1012
1170
|
// [M1 resume] resume 时 model/thinkingLevel 优先用 resume 值(防多轮对话模型漂移,P-10),
|
|
1013
|
-
// 否则回退 opts.resolved。
|
|
1014
|
-
const
|
|
1015
|
-
|
|
1171
|
+
// 否则回退 opts.resolved。thinkingLevel 经白名单断言收窄(非法值同步拒,不静默降级)。
|
|
1172
|
+
const effectiveThinkingLevel = assertThinkingLevel(
|
|
1173
|
+
resume?.thinkingLevel ?? opts.resolved.thinkingLevel,
|
|
1174
|
+
);
|
|
1016
1175
|
const spawnArgs = buildSpawnArgs(
|
|
1017
1176
|
{
|
|
1018
|
-
|
|
1177
|
+
modelRef,
|
|
1019
1178
|
thinkingLevel: effectiveThinkingLevel,
|
|
1020
1179
|
agentTools: opts.agentConfig?.tools,
|
|
1021
1180
|
appendSystemPromptPath: tempPromptFile?.filePath,
|
|
@@ -1030,6 +1189,20 @@ function buildSpawnInvocation(
|
|
|
1030
1189
|
return getPiInvocation(spawnArgs);
|
|
1031
1190
|
}
|
|
1032
1191
|
|
|
1192
|
+
/**
|
|
1193
|
+
* 拆分 record 回显的 "provider/id" 模型串(resume 路径专用)。
|
|
1194
|
+
*
|
|
1195
|
+
* [U1 D2 豁免依据] 输入是 SubagentService 从 record.model 读出的系统自产回显
|
|
1196
|
+
* (createRecordForMode 写入 `${provider}/${id}`,源头已裁决),非用户自由字符串——
|
|
1197
|
+
* 不经 assertCanonicalModelRef。modelId 可含 /,按第一个 / 分割(与 lookup 同构)。
|
|
1198
|
+
* 异常形态(无 /)兜底 provider="unknown"(与 subagent-service.ts record 回读同构)。
|
|
1199
|
+
*/
|
|
1200
|
+
function splitRecordModelRef(model: string): SpawnModelRef {
|
|
1201
|
+
const slashIdx = model.indexOf("/");
|
|
1202
|
+
if (slashIdx <= 0) return { provider: "unknown", id: model };
|
|
1203
|
+
return { provider: model.slice(0, slashIdx), id: model.slice(slashIdx + 1) };
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1033
1206
|
/** attachStdoutPump 返回的共享句柄(waitForChildExit / get_state 握手启动消费)。 */
|
|
1034
1207
|
interface StdoutPumpHandles {
|
|
1035
1208
|
/** get_state RPC response 监听器注册(performGetStateHandshake 经此挂 resolver)。 */
|
|
@@ -1173,9 +1346,28 @@ function attachStdoutPump(
|
|
|
1173
1346
|
// [MF-4] 动态超时 = computeWatchdogMs(maxTurns):真实后代在跑,慢任务(wave 开发
|
|
1174
1347
|
// 数小时)不能被固定 2h 误杀——2h 到点 kill 会连坐 SubagentService.dispose 的
|
|
1175
1348
|
// killAllSpawnedChildren 杀全部子进程,L2 重派丢在途工作。maxTurns 大则超时长。
|
|
1349
|
+
// [预算语义对齐 + U5] maxTurns 未传且 env 未设,或显式 <=0(压过 env)→
|
|
1350
|
+
// 不 re-arm(等待后代不限时);回收由外部 signal / dispose / 后代自然完成驱动。
|
|
1351
|
+
// [F-R2] 本分支在 child.stdout.on("data") 同步回调链内:resolveSpawnWatchdogMs →
|
|
1352
|
+
// assertSafeTimerDelay fail-fast 的 throw 若逃出回调 = Node uncaughtException 崩宿主。
|
|
1353
|
+
// 此处包 try/catch 降级为「不 re-arm」(与 keepAliveMs === undefined 的既有降级语义
|
|
1354
|
+
// 一致——回收由外部 signal / dispose / 后代自然完成驱动):fail-fast 语义保留
|
|
1355
|
+
// (错误经 bestEffort("error") 可见、行为明确),但不升级为进程崩溃。
|
|
1176
1356
|
clearTimeout(state.watchdog);
|
|
1177
|
-
|
|
1178
|
-
|
|
1357
|
+
let keepAliveMs: number | undefined;
|
|
1358
|
+
try {
|
|
1359
|
+
keepAliveMs = resolveSpawnWatchdogMs(opts.maxTurns);
|
|
1360
|
+
} catch (err) {
|
|
1361
|
+
bestEffort(err, "resolveSpawnWatchdogMs (agent_end keep-alive re-arm)", "error");
|
|
1362
|
+
keepAliveMs = undefined;
|
|
1363
|
+
}
|
|
1364
|
+
if (keepAliveMs !== undefined) {
|
|
1365
|
+
state.watchdog = setTimeout(
|
|
1366
|
+
() => killChildWithEscalation(state, child, "keep-alive watchdog"),
|
|
1367
|
+
keepAliveMs,
|
|
1368
|
+
);
|
|
1369
|
+
state.watchdog.unref();
|
|
1370
|
+
}
|
|
1179
1371
|
} else if (pending.recentUnregister) {
|
|
1180
1372
|
// 差集 0 但最近有 unregister:后代刚完成,notify 唤醒可能在路上(竞态窗口),
|
|
1181
1373
|
// 保持进程——父被唤醒后的下一次 agent_end 会正常判定。
|
|
@@ -1186,10 +1378,13 @@ function attachStdoutPump(
|
|
|
1186
1378
|
"[session-runner] agent_end: keep alive, recent descendant completion (wake-up in flight)",
|
|
1187
1379
|
);
|
|
1188
1380
|
clearTimeout(state.watchdog);
|
|
1189
|
-
state.watchdog = setTimeout(
|
|
1381
|
+
state.watchdog = setTimeout(
|
|
1382
|
+
() => killChildWithEscalation(state, child, "wakeup grace timer"),
|
|
1383
|
+
WAKEUP_GRACE_MS,
|
|
1384
|
+
);
|
|
1190
1385
|
state.watchdog.unref();
|
|
1191
1386
|
} else {
|
|
1192
|
-
child
|
|
1387
|
+
killChildWithEscalation(state, child, "agent_end final kill");
|
|
1193
1388
|
}
|
|
1194
1389
|
}
|
|
1195
1390
|
}
|
|
@@ -1317,6 +1512,7 @@ export async function runSpawn(
|
|
|
1317
1512
|
ctx,
|
|
1318
1513
|
proc: undefined,
|
|
1319
1514
|
watchdog: undefined,
|
|
1515
|
+
escalationTimer: undefined,
|
|
1320
1516
|
sessionHeader: undefined,
|
|
1321
1517
|
handshakeResult: undefined,
|
|
1322
1518
|
resolveRun: undefined,
|
|
@@ -1325,15 +1521,25 @@ export async function runSpawn(
|
|
|
1325
1521
|
// a/b. 事件累积器(pendingTools 寄存器 + turnLimiter + handleSdkEvent/agentEvent 闭包)
|
|
1326
1522
|
const handleSdkEvent = createSpawnEventHandlers(state);
|
|
1327
1523
|
|
|
1328
|
-
// c
|
|
1329
|
-
|
|
1330
|
-
const fullTask = task + instruction;
|
|
1524
|
+
// [审查项#2] 原 c.(schema 指令拼 task 末尾)已删:resolver 经 appendSystemPrompt
|
|
1525
|
+
// 单点注入,task 不再被每 agent 变化的指令后缀污染(可缓存 + 省 ~730 tokens/子进程)。
|
|
1331
1526
|
|
|
1332
1527
|
// d. session 目录(与 in-process 一致:list/恢复可发现同一目录)
|
|
1333
1528
|
// [MF-3] 用 ctx.rootCwd(贯穿真 ROOT)而非 ctx.mainCwd 编码:worktree 模式下 mainCwd 是
|
|
1334
1529
|
// 子进程的 checkout 路径,按它编码会让深层 record 落到 enc(worktree) 段,ROOT 磁盘重建
|
|
1335
1530
|
// 扫不到 → 全树可见性深度 ≥ 2 断裂。rootCwd 与 store 构造同源(subagent-service 同键),
|
|
1336
1531
|
// 保证 runSpawn 写入的 session 文件就在本进程/ROOT store 扫描的目录里。
|
|
1532
|
+
//
|
|
1533
|
+
// [单写者不变量·MF-8|第五轮元审查结论] session JSONL 完整性依赖「每 session 单写
|
|
1534
|
+
// 进程」架构不变量:本目录是子进程专属 sessionDir,session 文件写入方仅此子进程
|
|
1535
|
+
//(单进程单线程);主进程只读(扫描/重建/统计),绝不写。pi 0.84.1 的写入原语
|
|
1536
|
+
//(session-manager.js,完整锚点见 buildSpawnArgs 注释 / PS-18):_persist
|
|
1537
|
+
//(:724-753)首写 wx flag + 后续 appendFileSync 追加;compaction 为 append-only
|
|
1538
|
+
// 追加(appendCompaction :803-818),截断重写 _rewriteFile(:693-705)只在加载期
|
|
1539
|
+
//(归一 :627 / 迁移 :634 / branch :1143)触发——第二写进程会破坏全部这些写入的
|
|
1540
|
+
// 原子性(尾部丢 entry / 交错截断,见 v4 A-5/P7 双写者事故)。 resume/
|
|
1541
|
+
// 续聊走冷路径重开同一文件时也必须先确认旧进程已死(resumesInFlight 守卫),
|
|
1542
|
+
// 本质仍是单写者。
|
|
1337
1543
|
const sessionDir = getSubagentSessionDir(ctx.agentDir, ctx.rootCwd);
|
|
1338
1544
|
fs.mkdirSync(sessionDir, { recursive: true });
|
|
1339
1545
|
|
|
@@ -1436,11 +1642,12 @@ export async function runSpawn(
|
|
|
1436
1642
|
// 喂 prompt 命令驱动子进程开始处理 task。pi runRpcMode 只消费 stdin RpcCommand,
|
|
1437
1643
|
// 不读 positional arg;必须在 spawn 后主动写,否则子进程阻塞、totalTokens 恒 0。
|
|
1438
1644
|
// 时机安全:pipe 内核缓冲不丢;pi 在 rebindSession 后才挂 stdin reader。
|
|
1439
|
-
sendPromptCommand(child,
|
|
1645
|
+
sendPromptCommand(child, task);
|
|
1440
1646
|
|
|
1441
1647
|
// d. signal → proc.kill 监听(一次性,替代 session.abort)
|
|
1648
|
+
// [race-F4] 用户取消同样升级:SIGTERM 后挂住 → 30s 后 SIGKILL 兑现取消语义。
|
|
1442
1649
|
const onAbort = (): void => {
|
|
1443
|
-
child
|
|
1650
|
+
killChildWithEscalation(state, child, "abort signal");
|
|
1444
1651
|
};
|
|
1445
1652
|
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
1446
1653
|
// 前置检查:signal 在 spawn 前已 aborted 时 addEventListener 不会触发 onAbort,
|
|
@@ -1451,12 +1658,20 @@ export async function runSpawn(
|
|
|
1451
1658
|
// limiter 失效,此 timer 保证最终 SIGTERM,防止 background 槽位/资源泄漏。
|
|
1452
1659
|
// [M-1] timeout 基于 maxTurns 动态计算(computeWatchdogMs):旧实现固定 30 分钟
|
|
1453
1660
|
// 误杀长任务,现按 maxTurns 线性估算(每 turn ~5 分钟,下限 30 分钟)。
|
|
1661
|
+
// [预算语义对齐] maxTurns 未传/<=0 → 不挂 watchdog(不限,用户明确裁决——旧实现按
|
|
1662
|
+
// 10 turns 估出 50min 默认 SIGTERM 且 maxTurns:0 也关不掉,已废);
|
|
1663
|
+
// SPAWN_WATCHDOG_ENV 设置时按绝对时限兑底挂载(hang 泄漏防线 opt-in)。
|
|
1454
1664
|
// [R0] unref:不阻止 Node 进程退出。安全性由 SubagentService.dispose 保证——
|
|
1455
1665
|
// 主进程退出时(session_shutdown reason=quit)dispose 会 abort running controller
|
|
1456
1666
|
// → 本监听器 kill 子进程。无此 unref,watchdog timer 会拖住 event loop 阻止退出。
|
|
1457
|
-
const watchdogMs =
|
|
1458
|
-
|
|
1459
|
-
|
|
1667
|
+
const watchdogMs = resolveSpawnWatchdogMs(opts.maxTurns);
|
|
1668
|
+
if (watchdogMs !== undefined) {
|
|
1669
|
+
state.watchdog = setTimeout(
|
|
1670
|
+
() => killChildWithEscalation(state, child, "spawn watchdog"),
|
|
1671
|
+
watchdogMs,
|
|
1672
|
+
);
|
|
1673
|
+
state.watchdog.unref();
|
|
1674
|
+
}
|
|
1460
1675
|
|
|
1461
1676
|
// [recursive-orchestration] agent_end 有活跃后代时的等待超时(不 kill 分支的兑底)。
|
|
1462
1677
|
// 层主 subagent 空闲等待后代完成(steer 唤醒)期间不产生 turn,原 watchdog 已清;
|
|
@@ -1476,10 +1691,21 @@ export async function runSpawn(
|
|
|
1476
1691
|
// stdin,pi rebindSession 后读取并返回 response,经 stdout pump 匹配 resolver 触发 resolve。
|
|
1477
1692
|
// close handler await handshakeSettled,保证无论 task 多快结束,close 时 sessionFile 已回填。
|
|
1478
1693
|
// [#18] 握手状态变量已在 attachStdoutPump 内部(stdout handler 注册前)定义,此处直接发起握手。
|
|
1694
|
+
// [F2] .catch 兜底:performGetStateHandshake 的 Promise executor 同步调 tryOnce →
|
|
1695
|
+
// sendGetStateCommand → writeStdinLine 在 stdin 已断(EPIPE/ERR_STREAM_DESTROYED)时同步
|
|
1696
|
+
// throw → executor 内同步异常被 Promise 构造器转为 reject。若无人接(旧实现只有 .then),
|
|
1697
|
+
// reject 无人消费 → unhandledRejection(Node 15+ 默认 mode=throw)可崩父 pi 进程。
|
|
1698
|
+
// catch 内:logger.error 留证 + pump.abandonHandshake 记录握手失败终态——close handler
|
|
1699
|
+
// await 的 handshakeSettled 立即 settle,isHandshakePending() 归 false,不阻塞收尾链路
|
|
1700
|
+
//(sessionFile 兜底由收尾时的 existsSync 校验 + findSessionFileByHeaderId 承担)。
|
|
1479
1701
|
void performGetStateHandshake(child, pump.registerGetStateListener).then((r) => {
|
|
1480
1702
|
// header 加速路径下 settleHandshake 已 undefined,跳过(避免覆盖 header 结果)。
|
|
1481
1703
|
// 超时兜底(r 为空对象)也经此分支 settle,但 record.sessionFile 不回填。
|
|
1482
1704
|
if (pump.isHandshakePending()) pump.finishHandshake(r);
|
|
1705
|
+
}).catch((err: unknown) => {
|
|
1706
|
+
const m = err instanceof Error ? err.message : String(err);
|
|
1707
|
+
logger.error(`[session-runner] get_state handshake failed: ${m}`);
|
|
1708
|
+
pump.abandonHandshake();
|
|
1483
1709
|
});
|
|
1484
1710
|
|
|
1485
1711
|
child.stderr.on("data", (data: string) => {
|
|
@@ -1492,6 +1718,9 @@ export async function runSpawn(
|
|
|
1492
1718
|
|
|
1493
1719
|
opts.signal?.removeEventListener("abort", onAbort);
|
|
1494
1720
|
clearTimeout(state.watchdog);
|
|
1721
|
+
// [race-F4] 兑底清升级 timer(exit 事件自动 clear 的双保险:close 先于升级触发的
|
|
1722
|
+
// 竞态窗口内不误杀下一个占用同 state 的子进程)
|
|
1723
|
+
clearTimeout(state.escalationTimer);
|
|
1495
1724
|
|
|
1496
1725
|
// [持久化 A] sessionFile 兜底校验。
|
|
1497
1726
|
// identity custom entry 已改由子进程 session_start hook 写(M4 / V2 决策 5),
|
|
@@ -1532,12 +1761,16 @@ export async function runSpawn(
|
|
|
1532
1761
|
}
|
|
1533
1762
|
|
|
1534
1763
|
// g. collectResult(完全复用——全部从 record 读)
|
|
1764
|
+
// [F-1] schemaExpected:schema/schemaEnv 任一存在即要求结构化产出(耦合形态
|
|
1765
|
+
// 两者同设;解耦形态仅 schemaEnv 也要——tool 已注册,产出预期相同)。无有效
|
|
1766
|
+
// parsedOutput 时由 collectResult 标注失败,不再静默 success。
|
|
1535
1767
|
return collectResult(record, {
|
|
1536
1768
|
startTime,
|
|
1537
1769
|
success,
|
|
1538
1770
|
error,
|
|
1539
1771
|
sessionId: state.sessionHeader?.id ?? record.id,
|
|
1540
1772
|
sessionFile: record.sessionFile,
|
|
1773
|
+
schemaExpected: opts.schema !== undefined || opts.schemaEnv !== undefined,
|
|
1541
1774
|
});
|
|
1542
1775
|
} finally {
|
|
1543
1776
|
// h. 清理临时 prompt 文件
|
|
@@ -111,7 +111,7 @@ interface PiLike {
|
|
|
111
111
|
events: { emit(channel: string, data: unknown): void };
|
|
112
112
|
sendMessage(
|
|
113
113
|
message: { customType: string; content: string; display: boolean; details?: unknown },
|
|
114
|
-
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
|
|
114
|
+
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }, // g4-allow: 类型注解——PiLike 接口形状(pi.sendMessage 签面子集),非投递调用
|
|
115
115
|
): void;
|
|
116
116
|
/** 订阅 pi 事件(D8:notifier 的 settled 边沿订阅用 'agent_settled')。
|
|
117
117
|
* pi 0.84.1 的 on 返回 void 且无 off——退订语义由调用侧 disposed 标志包装兑现。
|
|
@@ -967,6 +967,20 @@ export class SubagentService {
|
|
|
967
967
|
sendPromptCommand(child, text, { streamingBehavior: interrupt ? "steer" : "followUp" });
|
|
968
968
|
// 热路径成功,清零 EPIPE 连续失败计数([v4 A-1] 计数器已迁移到 stdin-writer)
|
|
969
969
|
clearEpipeFailure(record.id);
|
|
970
|
+
// [race-F5] 写后死进程检测:write 同步成功只代表数据进了内核 pipe 缓冲,子进程
|
|
971
|
+
// 可能在读取前死亡(gate/idle kill 竞速)。exitCode/signalCode 已非 null = 进程已死
|
|
972
|
+
//(close 事件可能尚未到达),缓冲中的消息将被静默丢弃。只 warn 留证(含 runId 与
|
|
973
|
+
// 消息类型),不抛错不重试:终态回收已由 kill 路径保证,对死进程重试反而可能二次写。
|
|
974
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
975
|
+
logger.warn(
|
|
976
|
+
`[subagents] deliverMessage: child ${record.id} died around stdin write, message may be lost`,
|
|
977
|
+
{
|
|
978
|
+
msgType: interrupt ? "steer" : "followUp",
|
|
979
|
+
exitCode: child.exitCode,
|
|
980
|
+
signalCode: child.signalCode,
|
|
981
|
+
},
|
|
982
|
+
);
|
|
983
|
+
}
|
|
970
984
|
// 轮始执行态信号清除 + 迁移上报(residual-fixes U3 补全,与冷路径 resumeRound
|
|
971
985
|
// 对称):新一轮开跑 = 无轮终信号——清上一轮 result(§5.4 isStreaming 公式要求
|
|
972
986
|
// result undefined 才显示 streaming)与 resumable,appendEntry 让 runtime/W18
|
|
@@ -1392,7 +1406,15 @@ export class SubagentService {
|
|
|
1392
1406
|
// 直接用 override → 主 agent model。DEFAULT_AGENT_NAME 仅作 record 显示名
|
|
1393
1407
|
// (TUI 层 extractAgentName 共用,保证显示一致)。
|
|
1394
1408
|
const agent = opts.agent ?? DEFAULT_AGENT_NAME;
|
|
1395
|
-
|
|
1409
|
+
// 显式 agent ref(用户点名)失败必须报错,不静默降级:无 require 的 loadByPath
|
|
1410
|
+
// 对相对路径/裸名/文件缺失都返回 undefined → agentConfig undefined → resolveModel
|
|
1411
|
+
// 静默回落 override→主 agent model,用户拿到的 subagent 无 systemPrompt/工具白名单
|
|
1412
|
+
// 且零反馈。require:true 让失败抛出带 <available_subagents> 指引的错误(对齐
|
|
1413
|
+
// workflow name not found 反馈风格);不传 agent = 默认 general-purpose 语义,
|
|
1414
|
+
// agentConfig 保持 undefined(合法缺省,走 override → ctxModel 兑底)。
|
|
1415
|
+
const agentConfig = opts.agent
|
|
1416
|
+
? this.modelService.getRequiredAgentConfig(opts.agent)
|
|
1417
|
+
: undefined;
|
|
1396
1418
|
|
|
1397
1419
|
const resolved = this.modelService.resolveModel(
|
|
1398
1420
|
opts.agent ?? "",
|