@zhushanwen/pi-subagent-workflow 8.4.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 +22 -7
- package/relay/relay.mjs +390 -0
- package/skills/subagent-ext-config/SKILL.md +80 -0
- package/src/execution/__tests__/agent-registry.test.ts +110 -0
- package/src/execution/__tests__/bg-notify-render.test.ts +73 -0
- package/src/execution/__tests__/chat-engine-routing.test.ts +601 -0
- 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 +237 -1
- 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__/pi-invocation.test.ts +62 -1
- package/src/execution/__tests__/relay-agent.test.ts +448 -0
- package/src/execution/__tests__/relay-env.test.ts +42 -0
- 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__/startup-config-declaration.test.ts +35 -0
- package/src/execution/__tests__/stream-sink-retirement.test.ts +261 -0
- package/src/execution/__tests__/subprocess-agent-runner-routing.test.ts +310 -0
- package/src/execution/__tests__/subprocess-agent-runner.test.ts +147 -6
- 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 +16 -0
- package/src/execution/argv-mirror.ts +5 -1
- package/src/execution/concurrency-pool.ts +1 -1
- package/src/execution/config.ts +25 -2
- package/src/execution/engine/__tests__/common/data-dir.test.ts +53 -0
- package/src/execution/engine/__tests__/common/errors.test.ts +132 -0
- package/src/execution/engine/__tests__/common/event-journal.test.ts +177 -0
- package/src/execution/engine/__tests__/common/kill-chain.test.ts +192 -0
- package/src/execution/engine/__tests__/common/nesting-guard.test.ts +81 -0
- package/src/execution/engine/__tests__/common/persona-router.test.ts +123 -0
- package/src/execution/engine/__tests__/common/pool-manager.test.ts +154 -0
- package/src/execution/engine/__tests__/common/schema-emulation.test.ts +128 -0
- package/src/execution/engine/__tests__/conformance/__fixtures__/pi-golden-events.json +28 -0
- package/src/execution/engine/__tests__/conformance/agent-event-invariants.ts +141 -0
- package/src/execution/engine/__tests__/conformance/contract.abort.test.ts +109 -0
- package/src/execution/engine/__tests__/conformance/contract.agent-events.test.ts +101 -0
- package/src/execution/engine/__tests__/conformance/contract.probe.test.ts +77 -0
- package/src/execution/engine/__tests__/conformance/contract.read-degradation.test.ts +104 -0
- package/src/execution/engine/__tests__/conformance/contract.relay.test.ts +342 -0
- package/src/execution/engine/__tests__/conformance/engine-conformance.live.test.ts +201 -0
- package/src/execution/engine/__tests__/conformance/golden-replay.pi.test.ts +76 -0
- package/src/execution/engine/__tests__/conformance/golden-replay.zcode.test.ts +79 -0
- package/src/execution/engine/__tests__/engine-discovery.test.ts +87 -0
- package/src/execution/engine/__tests__/engines-declaration.test.ts +36 -0
- package/src/execution/engine/__tests__/model-prompt.test.ts +85 -0
- package/src/execution/engine/__tests__/paths.test.ts +39 -0
- package/src/execution/engine/__tests__/registry.test.ts +120 -0
- package/src/execution/engine/__tests__/routing.test.ts +231 -0
- package/src/execution/engine/common/data-dir.ts +62 -0
- package/src/execution/engine/common/errors.ts +183 -0
- package/src/execution/engine/common/event-journal.ts +254 -0
- package/src/execution/engine/common/journal-replay.ts +62 -0
- package/src/execution/engine/common/kill-chain.ts +221 -0
- package/src/execution/engine/common/nesting-guard.ts +50 -0
- package/src/execution/engine/common/persona-router.ts +108 -0
- package/src/execution/engine/common/pool-manager.ts +226 -0
- package/src/execution/engine/common/schema-emulation.ts +189 -0
- package/src/execution/engine/common/session-view-projection.ts +51 -0
- package/src/execution/engine/engine-discovery.ts +65 -0
- package/src/execution/engine/engines/pi/__tests__/pi-engine.test.ts +469 -0
- package/src/execution/engine/engines/pi/__tests__/reader.test.ts +155 -0
- package/src/execution/engine/engines/pi/__tests__/task-spec-mapper.test.ts +164 -0
- package/src/execution/engine/engines/pi/pi-engine.ts +415 -0
- package/src/execution/engine/engines/pi/reader.ts +48 -0
- package/src/execution/engine/engines/pi/registration.ts +35 -0
- package/src/execution/engine/engines/pi/task-spec-mapper.ts +100 -0
- package/src/execution/engine/engines/zcode/__tests__/__fixtures__/zcode-golden-spawn.json +39 -0
- package/src/execution/engine/engines/zcode/__tests__/launcher.test.ts +150 -0
- package/src/execution/engine/engines/zcode/__tests__/parser.test.ts +246 -0
- package/src/execution/engine/engines/zcode/__tests__/preparer.test.ts +228 -0
- package/src/execution/engine/engines/zcode/__tests__/reader.test.ts +210 -0
- package/src/execution/engine/engines/zcode/__tests__/registration.test.ts +64 -0
- package/src/execution/engine/engines/zcode/__tests__/zcode-engine.live.test.ts +127 -0
- package/src/execution/engine/engines/zcode/__tests__/zcode-engine.test.ts +580 -0
- package/src/execution/engine/engines/zcode/constants.ts +43 -0
- package/src/execution/engine/engines/zcode/golden-sample.ts +39 -0
- package/src/execution/engine/engines/zcode/launcher.ts +161 -0
- package/src/execution/engine/engines/zcode/parser.ts +436 -0
- package/src/execution/engine/engines/zcode/preparer.ts +363 -0
- package/src/execution/engine/engines/zcode/reader.ts +381 -0
- package/src/execution/engine/engines/zcode/registration.ts +37 -0
- package/src/execution/engine/engines/zcode/zcode-engine.ts +658 -0
- package/src/execution/engine/host-task-spec.ts +47 -0
- package/src/execution/engine/model-prompt.ts +59 -0
- package/src/execution/engine/paths.ts +42 -0
- package/src/execution/engine/port.ts +153 -0
- package/src/execution/engine/registry.ts +123 -0
- package/src/execution/engine/routing.ts +218 -0
- package/src/execution/engine/types.ts +309 -0
- package/src/execution/execute-options-mapper.ts +13 -8
- package/src/execution/execution-record.ts +66 -1
- package/src/execution/lifecycle-manager.ts +23 -1
- package/src/execution/model-config-service.ts +16 -1
- package/src/execution/model-resolver.ts +37 -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/pi-invocation.ts +32 -2
- package/src/execution/record-entry.ts +14 -0
- package/src/execution/record-store.ts +34 -0
- package/src/execution/relay-env.ts +37 -0
- package/src/execution/session-runner.ts +328 -71
- package/src/execution/stream-sink.ts +26 -0
- package/src/execution/subagent-service.ts +273 -13
- package/src/execution/subprocess-agent-runner.ts +210 -14
- package/src/execution/types.ts +124 -5
- package/src/execution/ui-request-queue.ts +14 -4
- package/src/index.ts +99 -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 +29 -9
- package/src/interface/subagent-tool-schema.ts +156 -0
- package/src/interface/subagent-tool.ts +56 -119
- package/src/interface/subagents.ts +2 -2
- package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +19 -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 +22 -3
- 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 +16 -0
- package/src/orchestration/script-lint.ts +1 -1
- package/src/orchestration/skill-discovery.ts +31 -8
- package/src/orchestration/worker-script-builder.ts +19 -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/meta-parser.ts +5 -1
- package/src/shared/model-ref.ts +286 -0
- package/src/shared/resource-meta.ts +5 -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
|
@@ -25,7 +25,14 @@ import type { AgentConfig, ResolvedModel } from "./model-resolver.ts";
|
|
|
25
25
|
import { collectResult } from "./output-collector.ts";
|
|
26
26
|
import { getSubagentSessionDir } from "./path-encoding.ts";
|
|
27
27
|
import { getPiInvocation } from "./pi-invocation.ts";
|
|
28
|
-
import {
|
|
28
|
+
import { isRelayActive, RELAY_ENV_RECORD_ID, RELAY_ENV_SESSION_ID } from "./relay-env.ts";
|
|
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";
|
|
29
36
|
import { MAX_FORK_DEPTH } from "./session-context-resolver.ts";
|
|
30
37
|
import { EPIPE_FAILURE_THRESHOLD, recordEpipeFailure, sendPromptCommand } from "./stdin-writer.ts";
|
|
31
38
|
import {
|
|
@@ -136,7 +143,8 @@ const WATCHDOG_MS_PER_TURN = WATCHDOG_MINUTES_PER_TURN * SECONDS_PER_MINUTE * MS
|
|
|
136
143
|
// 此后代卡死(永不完成)时此 timer 保证进程最终回收。超时 kill → finalize 视为正常
|
|
137
144
|
// 完成 → 通知父(父查 cw status 发现未 closed 会走 L2/L3 重派,见 planning-agent 模板)。
|
|
138
145
|
// 两类超时分挂不同分支(见 agent_end handler):
|
|
139
|
-
// - 有活跃后代(count>0 / error)→
|
|
146
|
+
// - 有活跃后代(count>0 / error)→ resolveSpawnWatchdogMs(maxTurns) 动态超时
|
|
147
|
+
// (MF-4,不误杀慢后代;maxTurns 未传且无兑底 env 时不挂 timer,不限时等待)
|
|
140
148
|
// - 仅 recentUnregister 竞态 → WAKEUP_GRACE_MS 秒级宽限(MF-3,不空等 2h)
|
|
141
149
|
|
|
142
150
|
/** [MF-3] agent_end keep-alive 的 recentUnregister 竞态宽限(ms)。
|
|
@@ -154,22 +162,83 @@ export const WAKEUP_GRACE_MS = 15_000;
|
|
|
154
162
|
* (全量重构/大规模迁移)正常需数小时,30 分钟到达即被误杀,limiter 机制形同虚设。
|
|
155
163
|
*
|
|
156
164
|
* 现按 maxTurns 线性估算:每 turn 约 5 分钟,下限 30 分钟。
|
|
157
|
-
* - maxTurns 缺省(undefined/null/0)按 10 turns 估 → 50 分钟
|
|
158
165
|
* - maxTurns=20 → 100 分钟
|
|
159
166
|
* - maxTurns=100 → 500 分钟(8 小时+,覆盖全量重构)
|
|
160
|
-
*
|
|
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
|
+
*
|
|
161
174
|
* [MF-4] 同时是 agent_end keep-alive 的「有活跃后代」等待超时(不 kill 分支),
|
|
162
175
|
* 替代旧固定 2h(WAIT_DESCENDANT_TIMEOUT_MS,已删除)——wave 开发 >2h 不被误杀。
|
|
163
176
|
* [export] 测试可观测(run-spawn-edges MF-4 用例断言 keep-alive 等待超时 = 动态值)。
|
|
164
|
-
*
|
|
165
|
-
* @param maxTurns 调用方指定的 turn
|
|
177
|
+
*
|
|
178
|
+
* @param maxTurns 调用方指定的 turn 上限;调用方保证 > 0(否则走 resolveSpawnWatchdogMs)
|
|
166
179
|
*/
|
|
167
|
-
|
|
168
|
-
|
|
180
|
+
export function computeWatchdogMs(maxTurns: number): number {
|
|
181
|
+
return Math.max(SPAWN_WATCHDOG_FLOOR_MS, maxTurns * WATCHDOG_MS_PER_TURN);
|
|
182
|
+
}
|
|
169
183
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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;
|
|
173
242
|
}
|
|
174
243
|
|
|
175
244
|
/** stderr 累积上限——按字符截断(.slice 语义),非字节;64K 规模沿自原实现。
|
|
@@ -177,15 +246,18 @@ export function computeWatchdogMs(maxTurns: number | undefined | null): number {
|
|
|
177
246
|
const STDERR_MAX_CHARS = 65_536;
|
|
178
247
|
|
|
179
248
|
/**
|
|
180
|
-
* 跨包契约 env
|
|
249
|
+
* 跨包契约 env 名与注入上限(SCHEMA_ENV_VAR / SCHEMA_ENV_MAX_BYTES):
|
|
250
|
+
* 实装已抽到 shared/schema-env.ts(零依赖叶子,S5),顶部 import + 下方 re-export。
|
|
181
251
|
*
|
|
182
252
|
* [跨包契约 SSOT] 此字面量是两个独立 npm 包(@zhushanwen/pi-subagent-workflow 与
|
|
183
253
|
* @zhushanwen/pi-structured-output)之间的隐式 env 契约。structured-output 包内同名常量为
|
|
184
254
|
* `ENV_SCHEMA = "PI_WORKFLOW_SCHEMA"`(见 extensions/universal/structured-output/src/index.ts)。
|
|
185
255
|
* 两包是独立 npm 包不能直接 import,故各自保留常量但显式标注此契约关系——
|
|
186
256
|
* 任一端改名必须同步另一端,否则权威 schema 注入会静默断桥(子进程不注册 tool/hook)。
|
|
257
|
+
* 抽叶子的目的:跨包契约测试从 session-runner import 会拖入整条 spawn/pi SDK 依赖树,
|
|
258
|
+
* 叶子模块提供稳定 import 点(导出名与值不变)。
|
|
187
259
|
*/
|
|
188
|
-
|
|
260
|
+
export { SCHEMA_ENV_MAX_BYTES, SCHEMA_ENV_VAR };
|
|
189
261
|
|
|
190
262
|
// ============================================================
|
|
191
263
|
// W4: ask_user RPC 系统提示词
|
|
@@ -335,6 +407,20 @@ function removeChildRegistration(recordId: string, child: ChildProcess): void {
|
|
|
335
407
|
}
|
|
336
408
|
}
|
|
337
409
|
|
|
410
|
+
/**
|
|
411
|
+
* [U0 D10] 非 pi 引擎路径的子进程记账注册入口(宿主经 RunContext.onChildSpawned 调用)。
|
|
412
|
+
*
|
|
413
|
+
* 与 pi runSpawn 内联注册同构:set 进 Map + close/error 按句守卫移除(M4 竞态守卫语义
|
|
414
|
+
* 复用 removeChildRegistration)。pi 路径保持内联不动(其 close/error handler 还承担
|
|
415
|
+
* EPIPE 记账等 pi 专属副作用,不适合收敛到本函数)——两入口写同一 Map,dispose 的
|
|
416
|
+
* killAllSpawnedChildren / cancelBackground 的 getChildByRecord 对两域 record 均生效。
|
|
417
|
+
*/
|
|
418
|
+
export function registerSpawnedChildForRecord(recordId: string, child: ChildProcess): void {
|
|
419
|
+
spawnedChildren.set(recordId, child);
|
|
420
|
+
child.once("close", () => removeChildRegistration(recordId, child));
|
|
421
|
+
child.once("error", () => removeChildRegistration(recordId, child));
|
|
422
|
+
}
|
|
423
|
+
|
|
338
424
|
// ============================================================
|
|
339
425
|
// 依赖注入容器 + 入参
|
|
340
426
|
// ============================================================
|
|
@@ -478,44 +564,30 @@ export interface SpawnResumeOpts {
|
|
|
478
564
|
* [模块内直调] —— 纯 env 赋值。从 runSpawn 的 childEnv 构造块调用。
|
|
479
565
|
* 存在时设 childEnv[SCHEMA_ENV_VAR] → 子进程 structured-output 扩展读取并注册 tool。
|
|
480
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
|
|
481
573
|
*/
|
|
482
574
|
export function applySchemaEnvToChildEnv(
|
|
483
575
|
childEnv: Record<string, string | undefined>,
|
|
484
576
|
schemaEnv?: string,
|
|
485
577
|
): void {
|
|
486
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
|
+
}
|
|
487
587
|
childEnv[SCHEMA_ENV_VAR] = schemaEnv;
|
|
488
588
|
}
|
|
489
589
|
}
|
|
490
590
|
|
|
491
|
-
// ============================================================
|
|
492
|
-
// Schema 指令
|
|
493
|
-
// ============================================================
|
|
494
|
-
|
|
495
|
-
/**
|
|
496
|
-
* 构造 schema 指令模板(拼入 task 末尾 + steer reminder 复用)。
|
|
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) 逐字节一致)。
|
|
502
|
-
*/
|
|
503
|
-
export function formatSchemaInstruction(schema: Record<string, unknown>): string {
|
|
504
|
-
return [
|
|
505
|
-
"MANDATORY: Structured Output Requirement",
|
|
506
|
-
"You MUST call the `structured-output` tool with your final answer.",
|
|
507
|
-
"Do NOT output the JSON directly in your text response — you MUST use the structured-output tool.",
|
|
508
|
-
"The schema is enforced by the system — call structured-output with ONLY the `data` parameter.",
|
|
509
|
-
"Do NOT pass a `schema` parameter; the system validates `data` against the authoritative schema automatically.",
|
|
510
|
-
"The schema for your `data` is:",
|
|
511
|
-
"```json",
|
|
512
|
-
// IF7(#13):同 schema 对象引用的 pretty stringify 走 WeakMap 缓存
|
|
513
|
-
// (与 agent-opts-resolver 的 compact 版共享缓存条目,输出逐字节不变)
|
|
514
|
-
stringifySchemaCached(schema, "pretty"),
|
|
515
|
-
"```",
|
|
516
|
-
].join("\n");
|
|
517
|
-
}
|
|
518
|
-
|
|
519
591
|
// ============================================================
|
|
520
592
|
// 环境信息块(M1 恢复)
|
|
521
593
|
// ============================================================
|
|
@@ -606,7 +678,10 @@ export async function buildEnvBlock(
|
|
|
606
678
|
// b. handleSdkEvent switch(SdkEvent → AgentEvent)
|
|
607
679
|
// c. turnLimiter:maxTurns 用事件计数 turn_end + proc.kill 替代 session.abort
|
|
608
680
|
// d. signal → proc.kill 监听(替代 signal → session.abort)
|
|
609
|
-
// 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)
|
|
610
685
|
// f. spawn + pump stdout(替代 session.prompt)
|
|
611
686
|
// g. collectResult → AgentResult(完全复用)
|
|
612
687
|
// h. proc cleanup(替代 session.dispose)
|
|
@@ -617,15 +692,29 @@ export async function buildEnvBlock(
|
|
|
617
692
|
/** 子进程退出码阈值:>=128 表示被信号终止(SIGTERM=143 等)。 */
|
|
618
693
|
const SIGNAL_EXIT_CODE_THRESHOLD = 128;
|
|
619
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
|
+
|
|
620
705
|
/**
|
|
621
706
|
* 组装 pi CLI 参数(不含 task 本身,task 作为最后一个位置参数)。
|
|
622
707
|
*
|
|
623
708
|
* 抽取自 runSpawn 便于单测(纯函数,不依赖进程状态)。
|
|
709
|
+
*
|
|
710
|
+
* [U1 D2 spawn 前置守卫] 入参收窄:model 字符串 → modelRef(SpawnModelRef),
|
|
711
|
+
* thinkingLevel → ThinkingLevel 白名单字面量联合。`--model` 值恒为
|
|
712
|
+
* `${modelRef.provider}/${modelRef.id}`(+ 可选白名单 `:level` 后缀)。
|
|
624
713
|
*/
|
|
625
714
|
export function buildSpawnArgs(
|
|
626
715
|
params: {
|
|
627
|
-
|
|
628
|
-
thinkingLevel:
|
|
716
|
+
modelRef: SpawnModelRef;
|
|
717
|
+
thinkingLevel: ThinkingLevel | undefined;
|
|
629
718
|
agentTools: string[] | undefined;
|
|
630
719
|
appendSystemPromptPath: string | undefined;
|
|
631
720
|
sessionDir: string;
|
|
@@ -647,13 +736,27 @@ export function buildSpawnArgs(
|
|
|
647
736
|
// task 不通过命令行传——pi 的 runRpcMode 只消费 stdin RpcCommand,
|
|
648
737
|
// positional task arg / -p flag 在 rpc mode 下被 resolveAppMode 无视。
|
|
649
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 文件写路径。
|
|
650
752
|
const args: string[] = ["--mode", "rpc", "--session-dir", params.sessionDir];
|
|
651
753
|
// resume:紧跟 --session-dir 追加 --session <file>,pi 续写原 session 文件(P-8)。
|
|
652
754
|
if (params.sessionFile) {
|
|
653
755
|
args.push("--session", params.sessionFile);
|
|
654
756
|
}
|
|
655
|
-
|
|
656
|
-
|
|
757
|
+
// [U1 D2] 只拼接已裁决 ModelRef;thinkingLevel 类型已收窄为白名单字面量联合。
|
|
758
|
+
args.push("--model", `${params.modelRef.provider}/${params.modelRef.id}`);
|
|
759
|
+
if (params.thinkingLevel) {
|
|
657
760
|
// thinking level 通过 model 后缀 :level 传递(pi CLI 约定)
|
|
658
761
|
// model 已 push,这里只补后缀到同一 token
|
|
659
762
|
const lastIdx = args.length - 1;
|
|
@@ -736,6 +839,9 @@ interface SpawnRunState {
|
|
|
736
839
|
proc: ChildProcess | undefined;
|
|
737
840
|
/** watchdog timer(stdout handler 的 agent_end keep-alive 分支重挂,收尾统一 clearTimeout)。 */
|
|
738
841
|
watchdog: NodeJS.Timeout | undefined;
|
|
842
|
+
/** [race-F4] SIGKILL 升级 timer(killChildWithEscalation 挂载;exit 事件自动 clear,
|
|
843
|
+
* 收尾统一 clearTimeout 兑底)。 */
|
|
844
|
+
escalationTimer: NodeJS.Timeout | undefined;
|
|
739
845
|
/** stdout 首行 header(json mode 才有;RPC mode 恒 undefined)——收尾 sessionFile 兜底查找用。 */
|
|
740
846
|
sessionHeader: SpawnSessionHeader | undefined;
|
|
741
847
|
/** get_state 握手结果(RPC mode)——收尾 sessionFile 兜底查找用。 */
|
|
@@ -749,6 +855,55 @@ interface SpawnRunState {
|
|
|
749
855
|
resolveRun: ((code: number) => void) | undefined;
|
|
750
856
|
}
|
|
751
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
|
+
|
|
752
907
|
/**
|
|
753
908
|
* 事件累积器工厂(原 runSpawn 内联的 a/b 两段 + handleSdkEvent/agentEvent 闭包)。
|
|
754
909
|
*
|
|
@@ -773,7 +928,8 @@ function createSpawnEventHandlers(state: SpawnRunState): (raw: SdkEvent) => void
|
|
|
773
928
|
// 但未实现写入逻辑)。补偿已在启动时注入 WRAP_UP_HINT 让 agent 主动收尾。
|
|
774
929
|
},
|
|
775
930
|
abort: () => {
|
|
776
|
-
|
|
931
|
+
// [race-F4] 升级路径:SIGTERM 后 30s 未 exit 则 SIGKILL(挂住子进程永不回收防线)
|
|
932
|
+
if (state.proc) killChildWithEscalation(state, state.proc, "turn limiter abort");
|
|
777
933
|
},
|
|
778
934
|
});
|
|
779
935
|
|
|
@@ -809,13 +965,23 @@ function createSpawnEventHandlers(state: SpawnRunState): (raw: SdkEvent) => void
|
|
|
809
965
|
// 非 chatMode:忽略(agent_end handler 的一次性 kill 已处理,进程不会活到 agent_settled)。
|
|
810
966
|
if (isAgentSettledEvt(raw)) {
|
|
811
967
|
if (record.chatMode) {
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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
|
+
}
|
|
819
985
|
// [SP-9] chatMode 每轮 reset turn-limiter:新一轮开始(续聊)时,
|
|
820
986
|
// maxTurns/graceTurns 不跨轮累计(续聊本质是无限轮,累计上限违背 G1)。
|
|
821
987
|
// reset steered/aborted 标志 + turnCount 归零,下一轮独立计数。
|
|
@@ -963,6 +1129,15 @@ function buildChildEnv(
|
|
|
963
1129
|
//(同进程内保留),子进程 identity entry 据此记 worktree:true——跨重启重建时据此拒绝续聊
|
|
964
1130
|
//(WorktreeHandle 不可序列化,reattach 不可行,静默回落主 repo 会破坏隔离)。
|
|
965
1131
|
childEnv.PI_SUBAGENT_WORKTREE = opts.worktree !== undefined ? "true" : undefined;
|
|
1132
|
+
// [E 方案 §5.2-2] relay 帧归属 env:tee 帧路由键(→ 虚拟分区 subagent:<sid>:<rid>)。
|
|
1133
|
+
// 仅 relay 激活时写入实际值——未激活环境下子 pi 进程携带 record 值 env 是误导噪声
|
|
1134
|
+
//(归属 env 无消费者);{...process.env} 继承值照旧保持。同源性对齐上方 PI_SUBAGENT_*
|
|
1135
|
+
// 四元组:SESSION_ID = ctx.sessionRootId(嵌套 spawn 时孙进程仍归属真 ROOT 会话),
|
|
1136
|
+
// RECORD_ID = record.id。
|
|
1137
|
+
if (isRelayActive(process.env)) {
|
|
1138
|
+
childEnv[RELAY_ENV_SESSION_ID] = ctx.sessionRootId;
|
|
1139
|
+
childEnv[RELAY_ENV_RECORD_ID] = record.id;
|
|
1140
|
+
}
|
|
966
1141
|
// D-A6 bridge: schema 激活 structured-output 扩展注册 tool(workflow 编排层需要)
|
|
967
1142
|
applySchemaEnvToChildEnv(childEnv, opts.schemaEnv);
|
|
968
1143
|
return childEnv;
|
|
@@ -984,14 +1159,22 @@ function buildSpawnInvocation(
|
|
|
984
1159
|
const skillPaths = [...ctx.skillDirs, opts.skillPath].filter(
|
|
985
1160
|
(p): p is string => typeof p === "string" && p.length > 0,
|
|
986
1161
|
);
|
|
987
|
-
|
|
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 };
|
|
988
1170
|
// [M1 resume] resume 时 model/thinkingLevel 优先用 resume 值(防多轮对话模型漂移,P-10),
|
|
989
|
-
// 否则回退 opts.resolved。
|
|
990
|
-
const
|
|
991
|
-
|
|
1171
|
+
// 否则回退 opts.resolved。thinkingLevel 经白名单断言收窄(非法值同步拒,不静默降级)。
|
|
1172
|
+
const effectiveThinkingLevel = assertThinkingLevel(
|
|
1173
|
+
resume?.thinkingLevel ?? opts.resolved.thinkingLevel,
|
|
1174
|
+
);
|
|
992
1175
|
const spawnArgs = buildSpawnArgs(
|
|
993
1176
|
{
|
|
994
|
-
|
|
1177
|
+
modelRef,
|
|
995
1178
|
thinkingLevel: effectiveThinkingLevel,
|
|
996
1179
|
agentTools: opts.agentConfig?.tools,
|
|
997
1180
|
appendSystemPromptPath: tempPromptFile?.filePath,
|
|
@@ -1006,6 +1189,20 @@ function buildSpawnInvocation(
|
|
|
1006
1189
|
return getPiInvocation(spawnArgs);
|
|
1007
1190
|
}
|
|
1008
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
|
+
|
|
1009
1206
|
/** attachStdoutPump 返回的共享句柄(waitForChildExit / get_state 握手启动消费)。 */
|
|
1010
1207
|
interface StdoutPumpHandles {
|
|
1011
1208
|
/** get_state RPC response 监听器注册(performGetStateHandshake 经此挂 resolver)。 */
|
|
@@ -1149,9 +1346,28 @@ function attachStdoutPump(
|
|
|
1149
1346
|
// [MF-4] 动态超时 = computeWatchdogMs(maxTurns):真实后代在跑,慢任务(wave 开发
|
|
1150
1347
|
// 数小时)不能被固定 2h 误杀——2h 到点 kill 会连坐 SubagentService.dispose 的
|
|
1151
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") 可见、行为明确),但不升级为进程崩溃。
|
|
1152
1356
|
clearTimeout(state.watchdog);
|
|
1153
|
-
|
|
1154
|
-
|
|
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
|
+
}
|
|
1155
1371
|
} else if (pending.recentUnregister) {
|
|
1156
1372
|
// 差集 0 但最近有 unregister:后代刚完成,notify 唤醒可能在路上(竞态窗口),
|
|
1157
1373
|
// 保持进程——父被唤醒后的下一次 agent_end 会正常判定。
|
|
@@ -1162,10 +1378,13 @@ function attachStdoutPump(
|
|
|
1162
1378
|
"[session-runner] agent_end: keep alive, recent descendant completion (wake-up in flight)",
|
|
1163
1379
|
);
|
|
1164
1380
|
clearTimeout(state.watchdog);
|
|
1165
|
-
state.watchdog = setTimeout(
|
|
1381
|
+
state.watchdog = setTimeout(
|
|
1382
|
+
() => killChildWithEscalation(state, child, "wakeup grace timer"),
|
|
1383
|
+
WAKEUP_GRACE_MS,
|
|
1384
|
+
);
|
|
1166
1385
|
state.watchdog.unref();
|
|
1167
1386
|
} else {
|
|
1168
|
-
child
|
|
1387
|
+
killChildWithEscalation(state, child, "agent_end final kill");
|
|
1169
1388
|
}
|
|
1170
1389
|
}
|
|
1171
1390
|
}
|
|
@@ -1293,6 +1512,7 @@ export async function runSpawn(
|
|
|
1293
1512
|
ctx,
|
|
1294
1513
|
proc: undefined,
|
|
1295
1514
|
watchdog: undefined,
|
|
1515
|
+
escalationTimer: undefined,
|
|
1296
1516
|
sessionHeader: undefined,
|
|
1297
1517
|
handshakeResult: undefined,
|
|
1298
1518
|
resolveRun: undefined,
|
|
@@ -1301,15 +1521,25 @@ export async function runSpawn(
|
|
|
1301
1521
|
// a/b. 事件累积器(pendingTools 寄存器 + turnLimiter + handleSdkEvent/agentEvent 闭包)
|
|
1302
1522
|
const handleSdkEvent = createSpawnEventHandlers(state);
|
|
1303
1523
|
|
|
1304
|
-
// c
|
|
1305
|
-
|
|
1306
|
-
const fullTask = task + instruction;
|
|
1524
|
+
// [审查项#2] 原 c.(schema 指令拼 task 末尾)已删:resolver 经 appendSystemPrompt
|
|
1525
|
+
// 单点注入,task 不再被每 agent 变化的指令后缀污染(可缓存 + 省 ~730 tokens/子进程)。
|
|
1307
1526
|
|
|
1308
1527
|
// d. session 目录(与 in-process 一致:list/恢复可发现同一目录)
|
|
1309
1528
|
// [MF-3] 用 ctx.rootCwd(贯穿真 ROOT)而非 ctx.mainCwd 编码:worktree 模式下 mainCwd 是
|
|
1310
1529
|
// 子进程的 checkout 路径,按它编码会让深层 record 落到 enc(worktree) 段,ROOT 磁盘重建
|
|
1311
1530
|
// 扫不到 → 全树可见性深度 ≥ 2 断裂。rootCwd 与 store 构造同源(subagent-service 同键),
|
|
1312
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
|
+
// 本质仍是单写者。
|
|
1313
1543
|
const sessionDir = getSubagentSessionDir(ctx.agentDir, ctx.rootCwd);
|
|
1314
1544
|
fs.mkdirSync(sessionDir, { recursive: true });
|
|
1315
1545
|
|
|
@@ -1412,11 +1642,12 @@ export async function runSpawn(
|
|
|
1412
1642
|
// 喂 prompt 命令驱动子进程开始处理 task。pi runRpcMode 只消费 stdin RpcCommand,
|
|
1413
1643
|
// 不读 positional arg;必须在 spawn 后主动写,否则子进程阻塞、totalTokens 恒 0。
|
|
1414
1644
|
// 时机安全:pipe 内核缓冲不丢;pi 在 rebindSession 后才挂 stdin reader。
|
|
1415
|
-
sendPromptCommand(child,
|
|
1645
|
+
sendPromptCommand(child, task);
|
|
1416
1646
|
|
|
1417
1647
|
// d. signal → proc.kill 监听(一次性,替代 session.abort)
|
|
1648
|
+
// [race-F4] 用户取消同样升级:SIGTERM 后挂住 → 30s 后 SIGKILL 兑现取消语义。
|
|
1418
1649
|
const onAbort = (): void => {
|
|
1419
|
-
child
|
|
1650
|
+
killChildWithEscalation(state, child, "abort signal");
|
|
1420
1651
|
};
|
|
1421
1652
|
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
1422
1653
|
// 前置检查:signal 在 spawn 前已 aborted 时 addEventListener 不会触发 onAbort,
|
|
@@ -1427,12 +1658,20 @@ export async function runSpawn(
|
|
|
1427
1658
|
// limiter 失效,此 timer 保证最终 SIGTERM,防止 background 槽位/资源泄漏。
|
|
1428
1659
|
// [M-1] timeout 基于 maxTurns 动态计算(computeWatchdogMs):旧实现固定 30 分钟
|
|
1429
1660
|
// 误杀长任务,现按 maxTurns 线性估算(每 turn ~5 分钟,下限 30 分钟)。
|
|
1661
|
+
// [预算语义对齐] maxTurns 未传/<=0 → 不挂 watchdog(不限,用户明确裁决——旧实现按
|
|
1662
|
+
// 10 turns 估出 50min 默认 SIGTERM 且 maxTurns:0 也关不掉,已废);
|
|
1663
|
+
// SPAWN_WATCHDOG_ENV 设置时按绝对时限兑底挂载(hang 泄漏防线 opt-in)。
|
|
1430
1664
|
// [R0] unref:不阻止 Node 进程退出。安全性由 SubagentService.dispose 保证——
|
|
1431
1665
|
// 主进程退出时(session_shutdown reason=quit)dispose 会 abort running controller
|
|
1432
1666
|
// → 本监听器 kill 子进程。无此 unref,watchdog timer 会拖住 event loop 阻止退出。
|
|
1433
|
-
const watchdogMs =
|
|
1434
|
-
|
|
1435
|
-
|
|
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
|
+
}
|
|
1436
1675
|
|
|
1437
1676
|
// [recursive-orchestration] agent_end 有活跃后代时的等待超时(不 kill 分支的兑底)。
|
|
1438
1677
|
// 层主 subagent 空闲等待后代完成(steer 唤醒)期间不产生 turn,原 watchdog 已清;
|
|
@@ -1452,10 +1691,21 @@ export async function runSpawn(
|
|
|
1452
1691
|
// stdin,pi rebindSession 后读取并返回 response,经 stdout pump 匹配 resolver 触发 resolve。
|
|
1453
1692
|
// close handler await handshakeSettled,保证无论 task 多快结束,close 时 sessionFile 已回填。
|
|
1454
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 承担)。
|
|
1455
1701
|
void performGetStateHandshake(child, pump.registerGetStateListener).then((r) => {
|
|
1456
1702
|
// header 加速路径下 settleHandshake 已 undefined,跳过(避免覆盖 header 结果)。
|
|
1457
1703
|
// 超时兜底(r 为空对象)也经此分支 settle,但 record.sessionFile 不回填。
|
|
1458
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();
|
|
1459
1709
|
});
|
|
1460
1710
|
|
|
1461
1711
|
child.stderr.on("data", (data: string) => {
|
|
@@ -1468,6 +1718,9 @@ export async function runSpawn(
|
|
|
1468
1718
|
|
|
1469
1719
|
opts.signal?.removeEventListener("abort", onAbort);
|
|
1470
1720
|
clearTimeout(state.watchdog);
|
|
1721
|
+
// [race-F4] 兑底清升级 timer(exit 事件自动 clear 的双保险:close 先于升级触发的
|
|
1722
|
+
// 竞态窗口内不误杀下一个占用同 state 的子进程)
|
|
1723
|
+
clearTimeout(state.escalationTimer);
|
|
1471
1724
|
|
|
1472
1725
|
// [持久化 A] sessionFile 兜底校验。
|
|
1473
1726
|
// identity custom entry 已改由子进程 session_start hook 写(M4 / V2 决策 5),
|
|
@@ -1508,12 +1761,16 @@ export async function runSpawn(
|
|
|
1508
1761
|
}
|
|
1509
1762
|
|
|
1510
1763
|
// g. collectResult(完全复用——全部从 record 读)
|
|
1764
|
+
// [F-1] schemaExpected:schema/schemaEnv 任一存在即要求结构化产出(耦合形态
|
|
1765
|
+
// 两者同设;解耦形态仅 schemaEnv 也要——tool 已注册,产出预期相同)。无有效
|
|
1766
|
+
// parsedOutput 时由 collectResult 标注失败,不再静默 success。
|
|
1511
1767
|
return collectResult(record, {
|
|
1512
1768
|
startTime,
|
|
1513
1769
|
success,
|
|
1514
1770
|
error,
|
|
1515
1771
|
sessionId: state.sessionHeader?.id ?? record.id,
|
|
1516
1772
|
sessionFile: record.sessionFile,
|
|
1773
|
+
schemaExpected: opts.schema !== undefined || opts.schemaEnv !== undefined,
|
|
1517
1774
|
});
|
|
1518
1775
|
} finally {
|
|
1519
1776
|
// h. 清理临时 prompt 文件
|