@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
|
@@ -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
|
-
|
|
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([
|
|
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("
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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("
|
|
45
|
+
expect(args[idx + 1]).toBe("zai-coding-cn/GLM-5.3-Flash");
|
|
42
46
|
});
|
|
43
47
|
|
|
44
|
-
it("
|
|
48
|
+
it("modelRef + thinkingLevel → model 后缀 :level", () => {
|
|
45
49
|
const args = buildSpawnArgs(
|
|
46
|
-
{ ...baseParams,
|
|
50
|
+
{ ...baseParams, thinkingLevel: "high" },
|
|
47
51
|
);
|
|
48
52
|
const idx = args.indexOf("--model");
|
|
49
|
-
expect(args[idx + 1]).toBe("
|
|
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
|
-
|
|
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 +
|
|
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("
|
|
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 {
|
|
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
|
-
// ──
|
|
514
|
+
// ── 3. watchdog 到期前正常完成 → clearTimeout 生效,不 kill ──
|
|
297
515
|
describe("watchdog 到期前正常完成 → 不 kill", () => {
|
|
298
516
|
beforeEach(() => {
|
|
299
517
|
vi.useFakeTimers();
|