@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.
Files changed (92) hide show
  1. package/package.json +7 -6
  2. package/src/execution/__tests__/bg-notify-render.test.ts +73 -0
  3. package/src/execution/__tests__/chat-engine-routing.test.ts +6 -2
  4. package/src/execution/__tests__/delivery-methods.test.ts +38 -1
  5. package/src/execution/__tests__/execute-options-mapper.test.ts +11 -0
  6. package/src/execution/__tests__/execution-record.test.ts +110 -0
  7. package/src/execution/__tests__/explicit-agent-ref-guard.test.ts +171 -0
  8. package/src/execution/__tests__/format-schema-instruction.test.ts +63 -32
  9. package/src/execution/__tests__/helpers/spawn-mock.ts +4 -0
  10. package/src/execution/__tests__/index-session-start.test.ts +86 -7
  11. package/src/execution/__tests__/lifecycle-manager.test.ts +46 -0
  12. package/src/execution/__tests__/list-fields.test.ts +45 -14
  13. package/src/execution/__tests__/model-resolver.test.ts +57 -5
  14. package/src/execution/__tests__/notifier-flush.test.ts +64 -26
  15. package/src/execution/__tests__/notify-ledger.test.ts +826 -0
  16. package/src/execution/__tests__/output-collector.test.ts +299 -2
  17. package/src/execution/__tests__/rpc-mode.test.ts +1 -1
  18. package/src/execution/__tests__/run-spawn-edges.test.ts +44 -1
  19. package/src/execution/__tests__/run-spawn-stdout-callback-throw.test.ts +199 -0
  20. package/src/execution/__tests__/session-runner-schema-env.test.ts +39 -0
  21. package/src/execution/__tests__/spawn-args.test.ts +37 -26
  22. package/src/execution/__tests__/start-sync-model-guard.test.ts +150 -0
  23. package/src/execution/__tests__/subprocess-agent-runner.test.ts +94 -1
  24. package/src/execution/__tests__/timeout-integration.test.ts +220 -2
  25. package/src/execution/__tests__/tool-action.test.ts +92 -1
  26. package/src/execution/agent-registry.ts +6 -0
  27. package/src/execution/argv-mirror.ts +5 -1
  28. package/src/execution/concurrency-pool.ts +1 -1
  29. package/src/execution/engine/engines/zcode/__tests__/zcode-engine.test.ts +13 -0
  30. package/src/execution/engine/engines/zcode/zcode-engine.ts +11 -1
  31. package/src/execution/engine/types.ts +6 -1
  32. package/src/execution/execute-options-mapper.ts +8 -7
  33. package/src/execution/execution-record.ts +60 -1
  34. package/src/execution/lifecycle-manager.ts +23 -1
  35. package/src/execution/model-config-service.ts +16 -1
  36. package/src/execution/model-resolver.ts +31 -59
  37. package/src/execution/notifier.ts +105 -35
  38. package/src/execution/notify-ledger.ts +580 -0
  39. package/src/execution/output-collector.ts +143 -3
  40. package/src/execution/session-runner.ts +304 -71
  41. package/src/execution/subagent-service.ts +24 -2
  42. package/src/execution/subprocess-agent-runner.ts +14 -0
  43. package/src/execution/types.ts +68 -5
  44. package/src/execution/ui-request-queue.ts +14 -4
  45. package/src/index.ts +54 -1
  46. package/src/interface/__tests__/subagent-tool-path-guard.test.ts +157 -0
  47. package/src/interface/__tests__/subagent-tool-prompt.test.ts +12 -0
  48. package/src/interface/bg-notify-render.ts +33 -12
  49. package/src/interface/helpers.ts +2 -2
  50. package/src/interface/subagent-actions.ts +26 -9
  51. package/src/interface/subagent-tool-schema.ts +156 -0
  52. package/src/interface/subagent-tool.ts +56 -125
  53. package/src/interface/subagents.ts +2 -2
  54. package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +16 -3
  55. package/src/orchestration/__tests__/agent-call-catch-fallback.test.ts +0 -6
  56. package/src/orchestration/__tests__/agent-call-stream.test.ts +0 -5
  57. package/src/orchestration/__tests__/error-recovery-handlers.test.ts +89 -4
  58. package/src/orchestration/__tests__/execute-agent-call.test.ts +137 -0
  59. package/src/orchestration/__tests__/jsonl-run-store-corrupt-entry.test.ts +150 -0
  60. package/src/orchestration/__tests__/jsonl-run-store-retention.test.ts +202 -0
  61. package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +326 -3
  62. package/src/orchestration/__tests__/lifecycle.test.ts +41 -7
  63. package/src/orchestration/__tests__/non-cloneable-return-e2e.test.ts +95 -0
  64. package/src/orchestration/__tests__/review-fix-loop-e2e.test.ts +57 -3
  65. package/src/orchestration/__tests__/skill-discovery.test.ts +44 -0
  66. package/src/orchestration/__tests__/worker-exit-without-result.test.ts +368 -0
  67. package/src/orchestration/__tests__/worker-script-builder-runtime.test.ts +43 -0
  68. package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +21 -2
  69. package/src/orchestration/agent-opts-resolver.ts +104 -23
  70. package/src/orchestration/error-recovery.ts +189 -33
  71. package/src/orchestration/execute-agent-call.ts +39 -0
  72. package/src/orchestration/jsonl-run-store.ts +121 -7
  73. package/src/orchestration/launcher.ts +60 -15
  74. package/src/orchestration/lifecycle.ts +10 -7
  75. package/src/orchestration/models/__tests__/budget.test.ts +1 -61
  76. package/src/orchestration/models/budget.ts +5 -35
  77. package/src/orchestration/models/run-runtime.ts +24 -9
  78. package/src/orchestration/models/types.ts +9 -0
  79. package/src/orchestration/script-lint.ts +1 -1
  80. package/src/orchestration/skill-discovery.ts +31 -8
  81. package/src/orchestration/worker-script-builder.ts +16 -3
  82. package/src/shared/__tests__/model-ref.test.ts +306 -0
  83. package/src/shared/__tests__/schema-jsonify.test.ts +1 -1
  84. package/src/shared/__tests__/timer-delay.test.ts +61 -0
  85. package/src/shared/model-ref.ts +286 -0
  86. package/src/shared/schema-env.ts +44 -0
  87. package/src/shared/schema-jsonify.ts +6 -4
  88. package/src/shared/timer-delay.ts +54 -0
  89. package/workflows/review-fix-loop-utils.cjs +9 -7
  90. package/workflows/review-fix-loop.js +20 -12
  91. package/src/orchestration/__tests__/concurrency-gate.test.ts +0 -125
  92. package/src/orchestration/concurrency-gate.ts +0 -69
@@ -11,8 +11,9 @@ import { MAX_FORK_DEPTH } from "../session-context-resolver.ts";
11
11
  import { buildEnvBlock, buildSpawnArgs } from "../session-runner.ts";
12
12
 
13
13
  describe("buildSpawnArgs", () => {
14
+ // [U1 D2] modelRef 为必填(spawn 前置守卫:未经裁决的裸字符串类型层面不可达)。
14
15
  const baseParams = {
15
- model: undefined as string | undefined,
16
+ modelRef: { provider: "openai", id: "gpt-4o" },
16
17
  thinkingLevel: undefined as string | undefined,
17
18
  agentTools: undefined as string[] | undefined,
18
19
  appendSystemPromptPath: undefined as string | undefined,
@@ -21,9 +22,11 @@ describe("buildSpawnArgs", () => {
21
22
  skillPaths: undefined as string[] | undefined,
22
23
  };
23
24
 
24
- it("基础参数:--mode rpc --session-dir,不含 -p 也不含 task(task 经 stdin 传)", () => {
25
+ it("基础参数:--mode rpc --session-dir + --model provider/id,不含 -p 也不含 task(task 经 stdin 传)", () => {
25
26
  const args = buildSpawnArgs(baseParams);
26
- expect(args).toEqual(["--mode", "rpc", "--session-dir", "/sessions/dir"]);
27
+ expect(args).toEqual([
28
+ "--mode", "rpc", "--session-dir", "/sessions/dir", "--model", "openai/gpt-4o",
29
+ ]);
27
30
  });
28
31
 
29
32
  it("不含 -p / --print(rpc mode 下 -p 被 resolveAppMode 无视,是死代码)", () => {
@@ -32,28 +35,22 @@ describe("buildSpawnArgs", () => {
32
35
  expect(args).not.toContain("--print");
33
36
  });
34
37
 
35
- it(" model 追加 --model provider/id", () => {
36
- const args = buildSpawnArgs(
37
- { ...baseParams, model: "openai/gpt-4o" },
38
- );
39
- expect(args).toContain("--model");
38
+ it("modelRef → --model 值恒为 `${provider}/${id}`(全等拼接,不重写输入)", () => {
39
+ // 含大小写差异的 provider/id 原样拼接(大小写敏感规则:放行即全等回显)
40
+ const args = buildSpawnArgs({
41
+ ...baseParams,
42
+ modelRef: { provider: "zai-coding-cn", id: "GLM-5.3-Flash" },
43
+ });
40
44
  const idx = args.indexOf("--model");
41
- expect(args[idx + 1]).toBe("openai/gpt-4o");
45
+ expect(args[idx + 1]).toBe("zai-coding-cn/GLM-5.3-Flash");
42
46
  });
43
47
 
44
- it("model + thinkingLevel → model 后缀 :level", () => {
48
+ it("modelRef + thinkingLevel → model 后缀 :level", () => {
45
49
  const args = buildSpawnArgs(
46
- { ...baseParams, model: "anthropic/claude", thinkingLevel: "high" },
50
+ { ...baseParams, thinkingLevel: "high" },
47
51
  );
48
52
  const idx = args.indexOf("--model");
49
- expect(args[idx + 1]).toBe("anthropic/claude:high");
50
- });
51
-
52
- it("thinkingLevel 无 model → 不追加(thinking 依赖 model 后缀)", () => {
53
- const args = buildSpawnArgs(
54
- { ...baseParams, model: undefined, thinkingLevel: "high" },
55
- );
56
- expect(args).not.toContain("--model");
53
+ expect(args[idx + 1]).toBe("openai/gpt-4o:high");
57
54
  });
58
55
 
59
56
  it("agentTools → --tools 逗号分隔", () => {
@@ -109,7 +106,7 @@ describe("buildSpawnArgs", () => {
109
106
  it("全参数组合:所有 flag 存在,不含 -p 也不含 positional task", () => {
110
107
  const args = buildSpawnArgs(
111
108
  {
112
- model: "openai/gpt-4o",
109
+ ...baseParams,
113
110
  thinkingLevel: "low",
114
111
  agentTools: ["read"],
115
112
  appendSystemPromptPath: "/tmp/p.md",
@@ -179,12 +176,12 @@ describe("buildSpawnArgs", () => {
179
176
  expect(args).not.toContain("--extension");
180
177
  expect(args).not.toContain("--no-context-files");
181
178
  // 仅基础参数
182
- expect(args).toEqual(["--mode", "rpc", "--session-dir", "/sessions/dir"]);
179
+ expect(args).toEqual(["--mode", "rpc", "--session-dir", "/sessions/dir", "--model", "openai/gpt-4o"]);
183
180
  });
184
181
 
185
182
  it("mirrorFlags undefined → 行为等同旧版(TC7)", () => {
186
183
  const args = buildSpawnArgs(baseParams);
187
- expect(args).toEqual(["--mode", "rpc", "--session-dir", "/sessions/dir"]);
184
+ expect(args).toEqual(["--mode", "rpc", "--session-dir", "/sessions/dir", "--model", "openai/gpt-4o"]);
188
185
  expect(args).not.toContain("--extension");
189
186
  expect(args).not.toContain("--no-extensions");
190
187
  expect(args).not.toContain("--approve");
@@ -234,14 +231,13 @@ describe("buildSpawnArgs", () => {
234
231
  it("sessionFile undefined → 不含 --session(向后兼容)", () => {
235
232
  const args = buildSpawnArgs(baseParams);
236
233
  expect(args).not.toContain("--session");
237
- expect(args).toEqual(["--mode", "rpc", "--session-dir", "/sessions/dir"]);
234
+ expect(args).toEqual(["--mode", "rpc", "--session-dir", "/sessions/dir", "--model", "openai/gpt-4o"]);
238
235
  });
239
236
 
240
- it("sessionFile + model + thinkingLevel → 三者都进 args(resume 全参数)", () => {
237
+ it("sessionFile + modelRef + thinkingLevel → 三者都进 args(resume 全参数)", () => {
241
238
  const args = buildSpawnArgs({
242
239
  ...baseParams,
243
240
  sessionFile: "/sessions/sub/resume.jsonl",
244
- model: "anthropic/claude",
245
241
  thinkingLevel: "high",
246
242
  });
247
243
  // --session <file>
@@ -251,7 +247,7 @@ describe("buildSpawnArgs", () => {
251
247
  // --model provider/id:level(thinkingLevel 作 model 后缀)
252
248
  const modelIdx = args.indexOf("--model");
253
249
  expect(modelIdx).toBeGreaterThan(-1);
254
- expect(args[modelIdx + 1]).toBe("anthropic/claude:high");
250
+ expect(args[modelIdx + 1]).toBe("openai/gpt-4o:high");
255
251
  });
256
252
  });
257
253
 
@@ -332,6 +328,21 @@ describe("mirrorMainProcessFlags", () => {
332
328
  const r = mirrorMainProcessFlags(["bun", "/pi", "-e", "/a", "-e=/b", "-e", "/c"]);
333
329
  expect(r.extensionPaths).toEqual(["/a", "/b", "/c"]);
334
330
  });
331
+
332
+ // [MF-7a] flag 判定收窄为 startsWith("--"):单 - 开头的合法路径不被误判为 flag。
333
+ it("[MF-7a] - 开头的路径被当值镜像(不误判 flag)", () => {
334
+ const r = mirrorMainProcessFlags(["bun", "/pi", "--extension", "-weird-dir/ext.js"]);
335
+ expect(r.extensionPaths).toEqual(["-weird-dir/ext.js"]);
336
+ });
337
+
338
+ // [MF-7a] -- 开头的真 flag 仍正确跳过:--extension 后跟 --flag 时不吃值、不越界。
339
+ it("[MF-7a] --extension 后跟 -- 开头真 flag → 不吃值", () => {
340
+ const r = mirrorMainProcessFlags([
341
+ "bun", "/pi", "--extension", "--approve", "--extension", "/real.js",
342
+ ]);
343
+ expect(r.extensionPaths).toEqual(["/real.js"]);
344
+ expect(r.approve).toBe(true);
345
+ });
335
346
  });
336
347
 
337
348
  // ============================================================
@@ -0,0 +1,150 @@
1
+ // src/execution/__tests__/start-sync-model-guard.test.ts
2
+ //
3
+ // [U1 验收 3] start 同步期裁决:非全等 model 入参在 start 工具调用同步期 isError——
4
+ // 无 spawn、无子 session、record 不落盘。
5
+ //
6
+ // 用真实 SubagentService(tmpdir + mock pi)走 execute 全链路,断言:
7
+ // 1. execute 在 IDENTITY 解析(resolveModel 裁决)阶段 reject(问句式报错);
8
+ // 2. buildSpawnArgs 从未被调用(vi.mock spy 包装真实实现)——spawn 前置守卫的结构性证据;
9
+ // 3. node:child_process.spawn 从未被调用——无子进程;
10
+ // 4. pi.appendEntry 从未被调用——record 未创建(emitPendingRegister 未发生)。
11
+ //
12
+ // 放行路径(全等 → 裁决通过)由 model-ref.test.ts / model-resolver.test.ts 锁定,
13
+ // 全链路放行的 spawn 行为由 run-spawn-* 契约测试覆盖。
14
+
15
+ import { spawn } from "node:child_process";
16
+ import * as fs from "node:fs";
17
+ import * as os from "node:os";
18
+ import * as path from "node:path";
19
+
20
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
21
+
22
+ vi.mock("node:child_process", async () => {
23
+ const actual = await import("node:child_process");
24
+ return {
25
+ ...actual,
26
+ spawn: vi.fn(() => {
27
+ throw new Error("spawn must not be called on sync-model rejection");
28
+ }),
29
+ };
30
+ });
31
+
32
+ // buildSpawnArgs 包装为 spy(保留真实实现)——「非全等拒单不触达 spawn 参数组装」的承重断言。
33
+ vi.mock("../session-runner.ts", async (importOriginal) => {
34
+ const actual = await importOriginal<typeof import("../session-runner.ts")>();
35
+ return { ...actual, buildSpawnArgs: vi.fn(actual.buildSpawnArgs) };
36
+ });
37
+
38
+ import { ModelConfigService } from "../model-config-service.ts";
39
+ import type { ModelInfo, ModelRegistryLike } from "../model-resolver.ts";
40
+ import { buildSpawnArgs } from "../session-runner.ts";
41
+ import type { PiLike } from "../subagent-service.ts";
42
+ import { SubagentService } from "../subagent-service.ts";
43
+
44
+ const mockSpawn = vi.mocked(spawn);
45
+ const mockBuildSpawnArgs = vi.mocked(buildSpawnArgs);
46
+
47
+ // ── 工具 ──
48
+
49
+ function makeModel(over: Partial<ModelInfo> = {}): ModelInfo {
50
+ return {
51
+ id: over.id ?? "GLM-5.3-Flash",
52
+ name: over.name ?? "GLM 5.3 Flash",
53
+ provider: over.provider ?? "zai-coding-cn",
54
+ reasoning: over.reasoning ?? false,
55
+ ...over,
56
+ };
57
+ }
58
+
59
+ /** registry 快照:真实形态(大小写混合 id),无孪生(P-A2 放行快照形态)。 */
60
+ function makeRegistry(models: ModelInfo[]): ModelRegistryLike {
61
+ return {
62
+ getAvailable: () => models,
63
+ find: (provider, modelId) => models.find((m) => m.provider === provider && m.id === modelId),
64
+ hasConfiguredAuth: () => true,
65
+ };
66
+ }
67
+
68
+ function makePi(): PiLike {
69
+ return {
70
+ appendEntry: vi.fn(),
71
+ events: { emit: vi.fn() },
72
+ sendMessage: vi.fn(),
73
+ } as unknown as PiLike;
74
+ }
75
+
76
+ describe("start 同步期裁决(U1 验收 3:非全等 → isError 且无 spawn 无子 session)", () => {
77
+ let tmpDir: string;
78
+ let service: SubagentService;
79
+ let pi: PiLike;
80
+
81
+ beforeEach(() => {
82
+ vi.clearAllMocks();
83
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "sw-start-guard-"));
84
+ const modelService = new ModelConfigService({ agentDir: tmpDir, cwd: tmpDir });
85
+ modelService.initModel({
86
+ modelRegistry: makeRegistry([makeModel()]),
87
+ sessionId: "guard-session",
88
+ });
89
+ service = new SubagentService({ cwd: tmpDir, modelService });
90
+ pi = makePi();
91
+ service.initSession({ pi, sessionId: "guard-session" });
92
+ });
93
+
94
+ afterEach(() => {
95
+ vi.restoreAllMocks();
96
+ fs.rmSync(tmpDir, { recursive: true, force: true });
97
+ });
98
+
99
+ it("小写变体入参 → execute 同步 reject(问句式报错),buildSpawnArgs/spawn/appendEntry 均未被调", async () => {
100
+ await expect(
101
+ service.execute({
102
+ task: "probe task",
103
+ slug: "guard",
104
+ model: "zai-coding-cn/glm-5.3-flash",
105
+ }),
106
+ ).rejects.toThrow(/is not a registry entry.*Did you mean one of these\?/s);
107
+
108
+ // spawn 链路零触达:参数组装(buildSpawnArgs)与进程创建(spawn)都未发生
109
+ expect(mockBuildSpawnArgs).not.toHaveBeenCalled();
110
+ expect(mockSpawn).not.toHaveBeenCalled();
111
+ // record 未创建(emitPendingRegister 未发生)→ 无子 session 前置状态
112
+ expect(pi.appendEntry).not.toHaveBeenCalled();
113
+ });
114
+
115
+ it("错误含 case variant 首位建议 + 继承指引(可直接重发的合法串)", async () => {
116
+ let msg = "";
117
+ try {
118
+ await service.execute({
119
+ task: "probe task",
120
+ slug: "guard",
121
+ model: "zai-coding-cn/glm-5.3-flash",
122
+ });
123
+ } catch (e) {
124
+ msg = (e as Error).message;
125
+ }
126
+ expect(msg).toContain('zai-coding-cn/GLM-5.3-Flash ← case variant of "glm-5.3-flash"');
127
+ expect(msg).toMatch(/Or omit the `model` param to inherit the main agent model\./);
128
+ });
129
+
130
+ it("全等入参通过裁决(reject 不发生,放行链路触达 spawn 参数组装)——守卫零误伤对照", async () => {
131
+ // 放行路径会进入 kickOffBackground(detached);execute resolve 即视为裁决放行。
132
+ // spawn 本身由 FakeChild 缺失抛错收口——本断言只关心「越过了同步裁决」:
133
+ // execute 不 reject,且 buildSpawnArgs 已被调(或即将被调的 record 已创建)。
134
+ const promise = service.execute({
135
+ task: "probe task",
136
+ slug: "guard-exact",
137
+ model: "zai-coding-cn/GLM-5.3-Flash",
138
+ });
139
+ // 放行路径:裁决不抛(execute 最终可能因 mock 环境子进程收尾而 reject,
140
+ // 但错误不能是模型裁决错误)
141
+ try {
142
+ await promise;
143
+ } catch (e) {
144
+ expect((e as Error).message).not.toMatch(/is not a registry entry/);
145
+ expect((e as Error).message).not.toMatch(/ambiguous case variants/);
146
+ }
147
+ // 全等放行 → record 已创建(emitPendingRegister 发生)——与拒单路径形成对照
148
+ expect(pi.appendEntry).toHaveBeenCalled();
149
+ });
150
+ });
@@ -17,9 +17,11 @@ import { mkdtempSync, readdirSync, rmSync } from "node:fs";
17
17
  import { tmpdir } from "node:os";
18
18
  import { join } from "node:path";
19
19
 
20
- import { describe, expect, it, vi } from "vitest";
20
+ import { afterEach, describe, expect, it, vi } from "vitest";
21
21
 
22
22
  import type { AgentCallOpts, AgentResult } from "../../orchestration/models/types.ts";
23
+ import { ModelConfigService, setModelConfigService } from "../model-config-service.ts";
24
+ import type { ModelRegistryLike } from "../model-resolver.ts";
23
25
  import { replayJournal } from "../engine/common/event-journal.ts";
24
26
  import type { SubprocessAgentRunnerDeps } from "../subprocess-agent-runner.ts";
25
27
  import { SubprocessAgentRunner } from "../subprocess-agent-runner.ts";
@@ -187,6 +189,97 @@ describe("SubprocessAgentRunner (wave-4 delegate)", () => {
187
189
  });
188
190
  });
189
191
 
192
+ // ────────────────────────────────────────────────
193
+ // [U1 D2] RunContext.modelRef 接入:ctxModel 继承路径孪生守卫
194
+ // ────────────────────────────────────────────────
195
+ describe("U1 ctxModel twin guard (RunContext.modelRef 接入)", () => {
196
+ const prevDataDir = process.env["XYZ_AGENT_DATA_DIR"];
197
+ let tmpRoot = "";
198
+
199
+ /** 装配含指定快照的 ModelConfigService 单例(initModel 注入 registry)。 */
200
+ function installModelService(registry: ModelRegistryLike): void {
201
+ tmpRoot = mkdtempSync(join(tmpdir(), "sar-model-ref-"));
202
+ process.env["XYZ_AGENT_DATA_DIR"] = join(tmpRoot, "engine-data");
203
+ const svc = new ModelConfigService({ agentDir: join(tmpRoot, "agent"), cwd: tmpRoot });
204
+ svc.initModel({ modelRegistry: registry, sessionId: "sar-guard" });
205
+ setModelConfigService(svc);
206
+ }
207
+
208
+ afterEach(() => {
209
+ // 单例 slot 无 reset API,重装一个空注册服务隔离后续用例(同文件后续
210
+ // describe 不消费 ctxModel 守卫,getGlobalConfig 对空目录返回默认值)
211
+ if (tmpRoot) {
212
+ const svc = new ModelConfigService({ agentDir: join(tmpRoot, "agent"), cwd: tmpRoot });
213
+ svc.initModel({ modelRegistry: { getAvailable: () => [], find: () => undefined, hasConfiguredAuth: () => false }, sessionId: "cleanup" });
214
+ setModelConfigService(svc);
215
+ rmSync(tmpRoot, { recursive: true, force: true });
216
+ tmpRoot = "";
217
+ }
218
+ if (prevDataDir === undefined) delete process.env["XYZ_AGENT_DATA_DIR"];
219
+ else process.env["XYZ_AGENT_DATA_DIR"] = prevDataDir;
220
+ });
221
+
222
+ it("P-A2 拒单路径:ctxModel 命中但 registry 含孪生 → run 返回 error(ambiguous),executeAndAwait 未被调", async () => {
223
+ const models = [
224
+ { id: "GLM-5.3-Flash", name: "flash", provider: "zai-coding-cn", reasoning: false },
225
+ { id: "glm-5.3-flash", name: "flash lower", provider: "zai-coding-cn", reasoning: false },
226
+ ];
227
+ installModelService({
228
+ getAvailable: () => models,
229
+ find: (p, id) => models.find((m) => m.provider === p && m.id === id),
230
+ hasConfiguredAuth: () => true,
231
+ });
232
+ const mockService = createMockService();
233
+ const sar = new SubprocessAgentRunner({
234
+ subagentService: mockService as unknown as SubprocessAgentRunnerDeps["subagentService"],
235
+ ctxModel: { id: "GLM-5.3-Flash", name: "flash", provider: "zai-coding-cn", reasoning: false },
236
+ });
237
+
238
+ const result = await sar.run(makeBaseOpts(), new AbortController().signal);
239
+
240
+ expect(result.error).toMatch(/ambiguous case variants/);
241
+ expect(result.content).toBe("");
242
+ // 守卫在 engine.run 之前:不产生任何 record/spawn(委托零发生)
243
+ expect(mockService.executeAndAwait).not.toHaveBeenCalled();
244
+ });
245
+
246
+ it("P-A2 放行路径:无孪生 registry 下同 ctxModel 正常委托(守卫零误伤)", async () => {
247
+ const models = [
248
+ { id: "GLM-5.3-Flash", name: "flash", provider: "zai-coding-cn", reasoning: false },
249
+ ];
250
+ installModelService({
251
+ getAvailable: () => models,
252
+ find: (p, id) => models.find((m) => m.provider === p && m.id === id),
253
+ hasConfiguredAuth: () => true,
254
+ });
255
+ const mockService = createMockService(
256
+ vi.fn().mockResolvedValue(makeMockResult({ content: "ok" })),
257
+ );
258
+ const sar = new SubprocessAgentRunner({
259
+ subagentService: mockService as unknown as SubprocessAgentRunnerDeps["subagentService"],
260
+ ctxModel: { id: "GLM-5.3-Flash", name: "flash", provider: "zai-coding-cn", reasoning: false },
261
+ });
262
+
263
+ const result = await sar.run(makeBaseOpts(), new AbortController().signal);
264
+
265
+ expect(result.error).toBeUndefined();
266
+ expect(result.content).toBe("ok");
267
+ expect(mockService.executeAndAwait).toHaveBeenCalledTimes(1);
268
+ });
269
+
270
+ it("ctxModel 未指定时守卫跳过(无单例也不阻断)", async () => {
271
+ const mockService = createMockService(
272
+ vi.fn().mockResolvedValue(makeMockResult({ content: "ok" })),
273
+ );
274
+ const sar = new SubprocessAgentRunner({
275
+ subagentService: mockService as unknown as SubprocessAgentRunnerDeps["subagentService"],
276
+ });
277
+ const result = await sar.run(makeBaseOpts(), new AbortController().signal);
278
+ expect(result.content).toBe("ok");
279
+ expect(mockService.executeAndAwait).toHaveBeenCalledTimes(1);
280
+ });
281
+ });
282
+
190
283
  // ────────────────────────────────────────────────
191
284
  // H1: SAR ctxModel 刷新(model_select 后不再 stale)
192
285
  // ────────────────────────────────────────────────
@@ -40,6 +40,10 @@ vi.mock("node:child_process", async () => {
40
40
  stderr = new PassThrough();
41
41
  killed = false;
42
42
  killSignal: string | undefined;
43
+ // [race-F4] SIGKILL 升级判定读 exitCode/signalCode(真 ChildProcess 未退出时均 null),
44
+ // FakeChild 同形提供才能驱动升级路径测试(undefined === null 为 false 会误判已退出)。
45
+ exitCode: number | null = null;
46
+ signalCode: string | null = null;
43
47
  kill(sig?: string): boolean {
44
48
  this.killed = true;
45
49
  this.killSignal = sig;
@@ -97,7 +101,14 @@ import { spawn } from "node:child_process";
97
101
  import * as fs from "node:fs";
98
102
 
99
103
  import { createRecord } from "../execution-record.ts";
100
- import { type RunOptions, runSpawn, type SessionRunnerContext } from "../session-runner.ts";
104
+ import {
105
+ computeWatchdogMs,
106
+ type RunOptions,
107
+ resolveSpawnWatchdogMs,
108
+ runSpawn,
109
+ SPAWN_WATCHDOG_ENV,
110
+ type SessionRunnerContext,
111
+ } from "../session-runner.ts";
101
112
 
102
113
  const mockSpawn = vi.mocked(spawn);
103
114
  const mockExistsSync = vi.mocked(fs.existsSync);
@@ -244,10 +255,17 @@ describe("timeoutMs / signal abort → child.kill 端到端路径", () => {
244
255
  beforeEach(() => {
245
256
  vi.clearAllMocks();
246
257
  mockExistsSync.mockReturnValue(false);
258
+ // [F-4 假红源修复] env 隔离:多数用例默认 maxTurns=undefined →
259
+ // resolveSpawnWatchdogMs 走 env 兑底分支,宿主 export SPAWN_WATCHDOG 即假红
260
+ //(极端值 3e9 还会在 assertSafeTimerDelay fail-fast 直接炸掉 runSpawn)。
261
+ // 用例内显式 stubEnv 的值照常叠加生效(inner describe 的 afterEach
262
+ // unstubAllEnvs 与本处兼容)。
263
+ vi.stubEnv(SPAWN_WATCHDOG_ENV, "");
247
264
  });
248
265
 
249
266
  afterEach(() => {
250
267
  vi.restoreAllMocks();
268
+ vi.unstubAllEnvs();
251
269
  });
252
270
 
253
271
  // ── 1. watchdog 到期 → child.kill ──
@@ -291,9 +309,209 @@ describe("timeoutMs / signal abort → child.kill 端到端路径", () => {
291
309
  // 信号终止(>=128)视为正常完成
292
310
  expect(result.success).toBe(true);
293
311
  });
312
+
313
+ // [race-F4] SIGKILL 升级:watchdog SIGTERM 后子进程无视信号(exitCode/signalCode
314
+ // 保持 null = 未退出)→ 30s 后升级 SIGKILL。挂住子进程永不回收的兑底防线。
315
+ it("[race-F4] watchdog SIGTERM 后 30s 未 exit → 升级 SIGKILL", async () => {
316
+ const record = makeRecord();
317
+ const promise = runSpawn(record, "Task: hang ignores SIGTERM", makeOpts({ maxTurns: 6 }), makeCtx());
318
+
319
+ const child = await waitForSpawnFake();
320
+
321
+ // watchdog 到期 → SIGTERM(升级路径入口)
322
+ await vi.advanceTimersByTimeAsync(30 * 60 * 1000 + 100);
323
+ expect(child.killSignal).toBe("SIGTERM");
324
+
325
+ // 子进程无视 SIGTERM:不 emit exit/close,exitCode/signalCode 保持 null。
326
+ // 推进升级窗口:非贴边断言(waitForSpawnFake 每步 10ms 推进,升级 timer 挂载
327
+ // 时刻有 ≤100ms 抖动)——先推 29s(余量 > 抖动)断言未升级,再推 2s 越过窗口。
328
+ await vi.advanceTimersByTimeAsync(29 * 1000);
329
+ expect(child.killSignal).toBe("SIGTERM"); // 尚未升级
330
+ await vi.advanceTimersByTimeAsync(2 * 1000);
331
+ expect(child.killSignal).toBe("SIGKILL"); // 已升级
332
+
333
+ // 收尾:SIGKILL 后子进程退出 → runSpawn resolve(信号终止视为完成)
334
+ emitStdoutLine(child, sessionHeader());
335
+ child.stdout.end();
336
+ child.emit("close", 137); // SIGKILL = 128+9
337
+ const result = await promise;
338
+ expect(result.success).toBe(true);
339
+ });
340
+
341
+ // [race-F4] 升级 timer 的 exit 自动 clear:SIGTERM 后子进程正常退出(exit 事件到
342
+ // 达),升级 timer 被清,不误发 SIGKILL(killSignal 停留在 SIGTERM)。
343
+ it("[race-F4] SIGTERM 后子进程 exit → 升级 timer 被 clear,不误发 SIGKILL", async () => {
344
+ const record = makeRecord();
345
+ const promise = runSpawn(record, "Task: dies on SIGTERM", makeOpts({ maxTurns: 6 }), makeCtx());
346
+
347
+ const child = await waitForSpawnFake();
348
+
349
+ // watchdog 到期 → SIGTERM
350
+ await vi.advanceTimersByTimeAsync(30 * 60 * 1000 + 100);
351
+ expect(child.killSignal).toBe("SIGTERM");
352
+
353
+ // 子进程响应 SIGTERM 退出:exitCode 143(SIGTERM 终止形态)+ exit + close。
354
+ // exit 事件触发升级 timer clear(killChildWithEscalation 内 once("exit") 挂钩)。
355
+ child.exitCode = 143;
356
+ child.emit("exit", 143);
357
+ child.stdout.end();
358
+ child.emit("close", 143);
359
+ const result = await promise;
360
+
361
+ // 越过升级窗口:无 SIGKILL(timer 已被 exit clear),killSignal 停留在 SIGTERM
362
+ await vi.advanceTimersByTimeAsync(60 * 1000);
363
+ expect(child.killSignal).toBe("SIGTERM");
364
+ expect(result.success).toBe(true);
365
+ });
366
+ });
367
+
368
+ // ── 2. watchdog 默认不挂载 + env 兑底(预算语义对齐 2026-08)──
369
+ //
370
+ // 语义:maxTurns 未传 → env 兑底;显式 0/负 → 压过 env 不挂(U5,SP-6 参数 > env,
371
+ // 旧实现 maxTurns:0 落到 env 兑底,参数显式关不掉 watchdog,已废);
372
+ // env XYZ_SUBAGENT_SPAWN_WATCHDOG_MS 设置时按绝对时限兑底挂载(hang 泄漏防线
373
+ // opt-in)。resolveSpawnWatchdogMs 是挂载判定唯一入口。
374
+ describe("watchdog 默认不挂载 + env 兑底(预算语义对齐)", () => {
375
+ beforeEach(() => {
376
+ vi.useFakeTimers();
377
+ });
378
+
379
+ afterEach(() => {
380
+ vi.useRealTimers();
381
+ vi.unstubAllEnvs();
382
+ });
383
+
384
+ it("resolveSpawnWatchdogMs:maxTurns 未传/0/负数且 env 未设 → undefined(不挂)", () => {
385
+ vi.stubEnv(SPAWN_WATCHDOG_ENV, ""); // 空串 = 未设(raw falsy)
386
+ expect(resolveSpawnWatchdogMs(undefined)).toBeUndefined();
387
+ expect(resolveSpawnWatchdogMs(null)).toBeUndefined();
388
+ expect(resolveSpawnWatchdogMs(0)).toBeUndefined();
389
+ expect(resolveSpawnWatchdogMs(-5)).toBeUndefined();
390
+ });
391
+
392
+ it("resolveSpawnWatchdogMs:env 设置 → 绝对时限;非法值 → undefined;maxTurns 有效优先", () => {
393
+ vi.stubEnv(SPAWN_WATCHDOG_ENV, "60000");
394
+ expect(resolveSpawnWatchdogMs(undefined)).toBe(60_000);
395
+ // [U5] SP-6 参数 > env:显式 0 压过 env 兑底 → 不挂(旧实现落成 60000)
396
+ expect(resolveSpawnWatchdogMs(0)).toBeUndefined();
397
+ expect(resolveSpawnWatchdogMs(-5)).toBeUndefined();
398
+ // maxTurns 有效(>0)→ turns 估算优先于 env 兑底
399
+ expect(resolveSpawnWatchdogMs(6)).toBe(computeWatchdogMs(6));
400
+ vi.stubEnv(SPAWN_WATCHDOG_ENV, "abc");
401
+ expect(resolveSpawnWatchdogMs(undefined)).toBeUndefined();
402
+ vi.stubEnv(SPAWN_WATCHDOG_ENV, "-1");
403
+ expect(resolveSpawnWatchdogMs(undefined)).toBeUndefined();
404
+ });
405
+
406
+ // [U1] setTimeout 2^31-1 溢出 fail-fast:溢出 delay 被 Node 置 1ms 立即触发
407
+ //(watchdog 变「启动即杀」),两路径(env 兑底 / turns 估算)都必须在入口拦截。
408
+ it("resolveSpawnWatchdogMs:env 值溢出(>2^31-1)→ fail-fast throw(不静默 clamp/不挂)", () => {
409
+ vi.stubEnv(SPAWN_WATCHDOG_ENV, "3000000000");
410
+ expect(() => resolveSpawnWatchdogMs(undefined)).toThrowError(/2147483647/);
411
+ expect(() => resolveSpawnWatchdogMs(undefined)).toThrowError(/Recovery/);
412
+ });
413
+
414
+ it("resolveSpawnWatchdogMs:maxTurns 估算值溢出 → fail-fast throw", () => {
415
+ vi.stubEnv(SPAWN_WATCHDOG_ENV, "");
416
+ // maxTurns=1e6 → 估算 5e9 ms > 2^31-1,入口拦截
417
+ expect(() => resolveSpawnWatchdogMs(1_000_000)).toThrowError(/2147483647/);
418
+ });
419
+
420
+ // [F-R3] maxTurns 垃圾值网格:NaN/""/null/0/负数(含 ±Infinity)。
421
+ // 旧实现 NaN/"" 绕过 Number.isFinite 且压过 env 兑底成「显式不限」。
422
+ it("resolveSpawnWatchdogMs:maxTurns NaN → fail-fast throw(压过 env);\"\"→Number(\"\")=0 显式不限;null/0/负数网格(F-R3)", () => {
423
+ vi.stubEnv(SPAWN_WATCHDOG_ENV, "60000");
424
+ // NaN:非有限数 fail-fast(与 timer-delay U1 同语义),不再压过 env 兑底成「显式不限」
425
+ expect(() => resolveSpawnWatchdogMs(NaN)).toThrowError(/not a finite number/);
426
+ expect(() => resolveSpawnWatchdogMs(NaN)).toThrowError(/Recovery/);
427
+ // ±Infinity:同非有限数 fail-fast
428
+ expect(() => resolveSpawnWatchdogMs(Infinity)).toThrowError(/not a finite number/);
429
+ // "":Number("") === 0 → 与显式 0 同路径(显式不限,压过 env,U5 语义不回归)
430
+ expect(resolveSpawnWatchdogMs("" as unknown as number)).toBeUndefined();
431
+ // null → 未传语义,env 兑底生效
432
+ expect(resolveSpawnWatchdogMs(null)).toBe(60_000);
433
+ // 0 / 负数 → 显式不限,压过 env(既有 U5 网格不回归)
434
+ expect(resolveSpawnWatchdogMs(0)).toBeUndefined();
435
+ expect(resolveSpawnWatchdogMs(-5)).toBeUndefined();
436
+ });
437
+
438
+ it("maxTurns 未传 → 推进 56min 不 kill(旧实现 50min 估算会误杀)", async () => {
439
+ vi.stubEnv(SPAWN_WATCHDOG_ENV, "");
440
+ const record = makeRecord();
441
+ const promise = runSpawn(record, "Task: no-turns", makeOpts({ maxTurns: undefined }), makeCtx());
442
+
443
+ const child = await waitForSpawnFake();
444
+ expect(child.killed).toBe(false);
445
+
446
+ // 旧实现的估算默认(10 turns → 50min)已过,仍不 kill = watchdog 未挂载
447
+ await vi.advanceTimersByTimeAsync(56 * 60 * 1000);
448
+ expect(child.killed).toBe(false);
449
+
450
+ emitStdoutLine(child, sessionHeader());
451
+ child.stdout.end();
452
+ child.emit("close", 0);
453
+ const result = await promise;
454
+ expect(result.success).toBe(true);
455
+ });
456
+
457
+ it("maxTurns: 0(显式关不掉的旧默认已废)→ 同样不挂 watchdog", async () => {
458
+ vi.stubEnv(SPAWN_WATCHDOG_ENV, "");
459
+ const record = makeRecord();
460
+ const promise = runSpawn(record, "Task: zero-turns", makeOpts({ maxTurns: 0 }), makeCtx());
461
+
462
+ const child = await waitForSpawnFake();
463
+ await vi.advanceTimersByTimeAsync(56 * 60 * 1000);
464
+ expect(child.killed).toBe(false);
465
+
466
+ emitStdoutLine(child, sessionHeader());
467
+ child.stdout.end();
468
+ child.emit("close", 0);
469
+ const result = await promise;
470
+ expect(result.success).toBe(true);
471
+ });
472
+
473
+ // [U5] SP-6 参数 > env:显式 maxTurns:0 压过 SPAWN_WATCHDOG_ENV 兑底——
474
+ // 旧实现落成 env 时限 kill,参数显式关不掉 watchdog。
475
+ it("maxTurns: 0 + env=60000 → 显式 0 压过 env,61s 不 kill(U5)", async () => {
476
+ vi.stubEnv(SPAWN_WATCHDOG_ENV, "60000");
477
+ const record = makeRecord();
478
+ const promise = runSpawn(record, "Task: zero-turns-override", makeOpts({ maxTurns: 0 }), makeCtx());
479
+
480
+ const child = await waitForSpawnFake();
481
+ // 推进越过 env 兑底时限(60s):若 maxTurns:0 未压过 env,此处已被 kill
482
+ await vi.advanceTimersByTimeAsync(61 * 1000);
483
+ expect(child.killed).toBe(false);
484
+
485
+ emitStdoutLine(child, sessionHeader());
486
+ child.stdout.end();
487
+ child.emit("close", 0);
488
+ const result = await promise;
489
+ expect(result.success).toBe(true);
490
+ });
491
+
492
+ it("env SPAWN_WATCHDOG_MS=60000 + maxTurns 未传 → 1min 兑底 kill", async () => {
493
+ vi.stubEnv(SPAWN_WATCHDOG_ENV, "60000");
494
+ const record = makeRecord();
495
+ const promise = runSpawn(record, "Task: env-watchdog", makeOpts(), makeCtx());
496
+
497
+ const child = await waitForSpawnFake();
498
+ // 非贴边断言:waitForSpawnFake 每步 10ms 推进,watchdog 挂载时刻有 ≤10ms 抖动,
499
+ // 用 55s / 61s 两个窗口避开 60s 贴边比较
500
+ await vi.advanceTimersByTimeAsync(55_000);
501
+ expect(child.killed).toBe(false);
502
+ await vi.advanceTimersByTimeAsync(6_000);
503
+ expect(child.killed).toBe(true);
504
+ expect(child.killSignal).toBe("SIGTERM");
505
+
506
+ emitStdoutLine(child, sessionHeader());
507
+ child.stdout.end();
508
+ child.emit("close", 143);
509
+ const result = await promise;
510
+ expect(result.success).toBe(true);
511
+ });
294
512
  });
295
513
 
296
- // ── 2. watchdog 到期前正常完成 → clearTimeout 生效,不 kill ──
514
+ // ── 3. watchdog 到期前正常完成 → clearTimeout 生效,不 kill ──
297
515
  describe("watchdog 到期前正常完成 → 不 kill", () => {
298
516
  beforeEach(() => {
299
517
  vi.useFakeTimers();