@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
package/src/execution/config.ts
CHANGED
|
@@ -20,15 +20,16 @@ import type { SubagentsGlobalConfig } from "./types.ts";
|
|
|
20
20
|
* 但 config.json 被 .gitignore 排除且不应随 npm 包分发用户私有配置——导致
|
|
21
21
|
* npm pack 后读不到文件,catch 兜底用空字段,pi install 后首次执行抛错。
|
|
22
22
|
* 修复:默认值内联在代码里,不依赖任何包内文件。
|
|
23
|
+
*
|
|
24
|
+
* export 供守护测试断言 package.json startupConfig 声明与此深相等(防漂移)。
|
|
23
25
|
*/
|
|
24
|
-
const DEFAULT_CONFIG: SubagentsGlobalConfig = {
|
|
26
|
+
export const DEFAULT_CONFIG: SubagentsGlobalConfig = {
|
|
25
27
|
version: 1,
|
|
26
28
|
maxConcurrent: 6,
|
|
27
29
|
};
|
|
28
30
|
|
|
29
31
|
/** 默认 maxConcurrent(DEFAULT_CONFIG 的镜像,sanitize 用)。 */
|
|
30
32
|
const DEFAULT_MAX_CONCURRENT = 6;
|
|
31
|
-
|
|
32
33
|
// ============================================================
|
|
33
34
|
// 路径
|
|
34
35
|
// ============================================================
|
|
@@ -56,9 +57,15 @@ export function loadGlobalConfig(agentDir: string): SubagentsGlobalConfig {
|
|
|
56
57
|
try {
|
|
57
58
|
const raw = fs.readFileSync(configPath, "utf-8");
|
|
58
59
|
const parsed = JSON.parse(raw) as Partial<SubagentsGlobalConfig>;
|
|
60
|
+
// P4 引擎路由(D9):全局默认引擎 + strict 模式。非法值静默回缺省('pi' /
|
|
61
|
+
// false)——config.json 是用户手编文件,坏值不炸启动(与 maxConcurrent 同判)
|
|
62
|
+
const defaultEngine = sanitizeDefaultEngine(parsed.defaultEngine);
|
|
63
|
+
const engineRouting = sanitizeEngineRouting(parsed.engineRouting);
|
|
59
64
|
return {
|
|
60
65
|
version: parsed.version ?? DEFAULT_CONFIG.version,
|
|
61
66
|
maxConcurrent: sanitizeMaxConcurrent(parsed.maxConcurrent),
|
|
67
|
+
...(defaultEngine !== undefined ? { defaultEngine } : {}),
|
|
68
|
+
...(engineRouting !== undefined ? { engineRouting } : {}),
|
|
62
69
|
};
|
|
63
70
|
} catch {
|
|
64
71
|
return { ...DEFAULT_CONFIG };
|
|
@@ -71,3 +78,19 @@ function sanitizeMaxConcurrent(value: unknown): number {
|
|
|
71
78
|
? value
|
|
72
79
|
: DEFAULT_MAX_CONCURRENT;
|
|
73
80
|
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* defaultEngine 校验:非空字符串透传,其余 undefined(缺省引擎由路由层落 'pi')。
|
|
84
|
+
* 为什么不在加载期对注册表校验 hasEngine:加载早于组合根注册(agentDir 解析在
|
|
85
|
+
* session_start),注册表此刻可能为空——校验归路由层(getEngine 抛 EngineNotFoundError)。
|
|
86
|
+
*/
|
|
87
|
+
function sanitizeDefaultEngine(value: unknown): string | undefined {
|
|
88
|
+
return typeof value === "string" && value.trim() !== "" ? value : undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** engineRouting 校验:仅认 strict 布尔,其余键忽略(向前兼容追加)。 */
|
|
92
|
+
function sanitizeEngineRouting(value: unknown): { strict: boolean } | undefined {
|
|
93
|
+
if (typeof value !== "object" || value === null) return undefined;
|
|
94
|
+
const strict = (value as Record<string, unknown>).strict;
|
|
95
|
+
return typeof strict === "boolean" ? { strict } : undefined;
|
|
96
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// data-dir.test.ts —— dataDir 通道解析(XYZ_AGENT_DATA_DIR 权威通道 + piAgentDir 回退)。
|
|
2
|
+
//
|
|
3
|
+
// 三视角:①构建者——两级解析顺序正确;②使用者——独立 pi 用户(无 xyz env)拿到
|
|
4
|
+
// piAgentDir 作根;③观察者——回退路径 warn 一次(不留静默漂移),不刷屏。
|
|
5
|
+
//
|
|
6
|
+
// 注意:getAgentDir 在 vitest 下 alias 到 mocks/pi-coding-agent.ts(返回
|
|
7
|
+
// /home/user/.pi/agent)——本测试只断言两级解析顺序与 warn 行为,不测 pi SDK 内部。
|
|
8
|
+
|
|
9
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
10
|
+
|
|
11
|
+
import { getEngineDataDir, resetDataDirWarnForTests, XYZ_DATA_DIR_ENV } from "../../common/data-dir.ts";
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
resetDataDirWarnForTests();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("getEngineDataDir", () => {
|
|
18
|
+
it("XYZ_AGENT_DATA_DIR 存在 → 权威通道直取,不 warn", () => {
|
|
19
|
+
const warnings: string[] = [];
|
|
20
|
+
const dir = getEngineDataDir({ [XYZ_DATA_DIR_ENV]: "/data/xyz-agent" }, (m) => warnings.push(m));
|
|
21
|
+
expect(dir).toBe("/data/xyz-agent");
|
|
22
|
+
expect(warnings).toEqual([]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("空串/空白串视为缺失(truthy 语义与 shared getDataDir 的 || 归一对齐)", () => {
|
|
26
|
+
const warnings: string[] = [];
|
|
27
|
+
const dir = getEngineDataDir({ [XYZ_DATA_DIR_ENV]: " " }, (m) => warnings.push(m));
|
|
28
|
+
expect(dir).not.toBe(" ");
|
|
29
|
+
expect(warnings.length).toBe(1);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("缺 env → 回退 piAgentDir(getAgentDir(),mock 值)并 warn 一次", () => {
|
|
33
|
+
const warnings: string[] = [];
|
|
34
|
+
const warn = (m: string): void => warnings.push(m);
|
|
35
|
+
const dir1 = getEngineDataDir({}, warn);
|
|
36
|
+
expect(dir1).toBe("/home/user/.pi/agent"); // vitest mock 的 getAgentDir
|
|
37
|
+
expect(warnings.length).toBe(1);
|
|
38
|
+
expect(warnings[0]).toContain("XYZ_AGENT_DATA_DIR");
|
|
39
|
+
|
|
40
|
+
// warn once:第二次调用不再刷
|
|
41
|
+
const dir2 = getEngineDataDir({}, warn);
|
|
42
|
+
expect(dir2).toBe(dir1);
|
|
43
|
+
expect(warnings.length).toBe(1);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("reset 后再次回退会重新 warn(测试隔离钩子可用)", () => {
|
|
47
|
+
const warnings: string[] = [];
|
|
48
|
+
getEngineDataDir({}, (m) => warnings.push(m));
|
|
49
|
+
resetDataDirWarnForTests();
|
|
50
|
+
getEngineDataDir({}, (m) => warnings.push(m));
|
|
51
|
+
expect(warnings.length).toBe(2);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// errors.test.ts —— 引擎层错误 SSOT 的结构锁定。
|
|
2
|
+
//
|
|
3
|
+
// 三视角:①构建者——11 条 code 与设计 §3.3.3 全表一致;②使用者——错误消息
|
|
4
|
+
// code 前缀格式可被字符串匹配分流;③观察者——每条错误必有非空恢复指引(可操作)。
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_RECOVERY_HINTS,
|
|
10
|
+
ENGINE_ERROR_CODES,
|
|
11
|
+
EngineError,
|
|
12
|
+
engineRunFailedDetail,
|
|
13
|
+
engineTimeoutDetail,
|
|
14
|
+
isEngineErrorCode,
|
|
15
|
+
nestedSpawnRejectedError,
|
|
16
|
+
promptTooLargeError,
|
|
17
|
+
schemaEmulationFailedDetail,
|
|
18
|
+
STDOUT_TAIL_ECHO_CHARS,
|
|
19
|
+
} from "../../common/errors.ts";
|
|
20
|
+
|
|
21
|
+
describe("ENGINE_ERROR_CODES(§3.3.3 全表)", () => {
|
|
22
|
+
it("11 条错误码与设计文档错误规格表逐条一致", () => {
|
|
23
|
+
expect([...ENGINE_ERROR_CODES]).toEqual([
|
|
24
|
+
"engine_not_found",
|
|
25
|
+
"engine_probe_failed",
|
|
26
|
+
"engine_credential_missing",
|
|
27
|
+
"nested_spawn_rejected",
|
|
28
|
+
"schema_emulation_failed",
|
|
29
|
+
"engine_timeout",
|
|
30
|
+
"engine_capability_unsupported",
|
|
31
|
+
"engine_session_not_resumable",
|
|
32
|
+
"model_not_available",
|
|
33
|
+
"prompt_too_large",
|
|
34
|
+
"engine_run_failed",
|
|
35
|
+
]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("isEngineErrorCode 收窄:表内 code 为 true,表外/非字符串为 false", () => {
|
|
39
|
+
expect(isEngineErrorCode("engine_timeout")).toBe(true);
|
|
40
|
+
expect(isEngineErrorCode("engine_not_found")).toBe(true);
|
|
41
|
+
expect(isEngineErrorCode("engine_oops")).toBe(false);
|
|
42
|
+
expect(isEngineErrorCode(42)).toBe(false);
|
|
43
|
+
expect(isEngineErrorCode(undefined)).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("DEFAULT_RECOVERY_HINTS(恢复指引全集)", () => {
|
|
48
|
+
it("11 条 code 每条都有非空恢复指引(可操作,非安慰性文案)", () => {
|
|
49
|
+
for (const code of ENGINE_ERROR_CODES) {
|
|
50
|
+
const hint = DEFAULT_RECOVERY_HINTS[code];
|
|
51
|
+
expect(hint, `recovery hint for ${code}`).toMatch(/\S/);
|
|
52
|
+
expect(hint.length).toBeGreaterThan(20);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("关键恢复动作在指引中(抽查三条错误规格的载体系数)", () => {
|
|
57
|
+
expect(DEFAULT_RECOVERY_HINTS.engine_timeout).toMatch(/stdout tail/i);
|
|
58
|
+
expect(DEFAULT_RECOVERY_HINTS.prompt_too_large).toMatch(/stdin/);
|
|
59
|
+
expect(DEFAULT_RECOVERY_HINTS.nested_spawn_rejected).toMatch(/inside the current task/);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("EngineError", () => {
|
|
64
|
+
it("message 为 <code>: <detail> 前缀格式(outcome.error 分流依据)", () => {
|
|
65
|
+
const err = new EngineError("engine_timeout", "chain exhausted", "retry with pi");
|
|
66
|
+
expect(err.message).toBe("engine_timeout: chain exhausted");
|
|
67
|
+
expect(err.code).toBe("engine_timeout");
|
|
68
|
+
expect(err.recovery).toBe("retry with pi");
|
|
69
|
+
expect(err.name).toBe("EngineError");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("toStructured 投影 code/message/recovery 三字段", () => {
|
|
73
|
+
const structured = new EngineError("model_not_available", "no such model", "pick another").toStructured();
|
|
74
|
+
expect(structured).toEqual({
|
|
75
|
+
code: "model_not_available",
|
|
76
|
+
message: "model_not_available: no such model",
|
|
77
|
+
recovery: "pick another",
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("具名构造器", () => {
|
|
83
|
+
it("promptTooLargeError:detail 含实际/上限字节数,recovery 含三条恢复建议", () => {
|
|
84
|
+
const err = promptTooLargeError(200_000, 131_072);
|
|
85
|
+
expect(err.code).toBe("prompt_too_large");
|
|
86
|
+
expect(err.message).toContain("200000");
|
|
87
|
+
expect(err.message).toContain("131072");
|
|
88
|
+
expect(err.recovery).toMatch(/shorten the task/i);
|
|
89
|
+
expect(err.recovery).toMatch(/file channel/);
|
|
90
|
+
expect(err.recovery).toMatch(/stdin/);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("nestedSpawnRejectedError:说明防护规则 + 指向 task 内自行完成", () => {
|
|
94
|
+
const err = nestedSpawnRejectedError();
|
|
95
|
+
expect(err.code).toBe("nested_spawn_rejected");
|
|
96
|
+
expect(err.message).toContain("XYZ_AGENT_SUBAGENT");
|
|
97
|
+
expect(err.recovery).toMatch(/inside the current task/);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("engineTimeoutDetail:含 stdout 尾部 + engine: pi 重跑建议", () => {
|
|
101
|
+
const detail = engineTimeoutDetail("partial stdout output");
|
|
102
|
+
expect(detail).toContain("partial stdout output");
|
|
103
|
+
expect(detail).toMatch(/SIGTERM/);
|
|
104
|
+
expect(detail).toMatch(/SIGKILL/);
|
|
105
|
+
expect(detail).toMatch(/`engine: pi`/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it(`engineTimeoutDetail:stdout 尾部截断到 ${STDOUT_TAIL_ECHO_CHARS} 字符`, () => {
|
|
109
|
+
const longTail = "x".repeat(STDOUT_TAIL_ECHO_CHARS + 500);
|
|
110
|
+
const detail = engineTimeoutDetail(longTail);
|
|
111
|
+
// 截断后含省略号标记,总长有界(不含前后固定文案不超过 2000 + 常量开销)
|
|
112
|
+
expect(detail).toContain("...");
|
|
113
|
+
expect(detail.length).toBeLessThan(STDOUT_TAIL_ECHO_CHARS + 600);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("engineRunFailedDetail:含 reason / exit code / 尾部 / 恢复指引", () => {
|
|
117
|
+
const detail = engineRunFailedDetail("stdout parse failed", 3, "some output");
|
|
118
|
+
expect(detail).toContain("stdout parse failed");
|
|
119
|
+
expect(detail).toContain("exit code 3");
|
|
120
|
+
expect(detail).toContain("some output");
|
|
121
|
+
expect(detail).toMatch(/probe/);
|
|
122
|
+
// exitCode=null(被信号杀死)的形态
|
|
123
|
+
expect(engineRunFailedDetail("crash", null, "t")).toContain("killed by signal");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("schemaEmulationFailedDetail:含错误明细 + 原始输出尾部 + 重试一次语义", () => {
|
|
127
|
+
const detail = schemaEmulationFailedDetail("Schema validation failed: /a must be number", '{"a":"x"}');
|
|
128
|
+
expect(detail).toContain("Schema validation failed");
|
|
129
|
+
expect(detail).toContain('{"a":"x"}');
|
|
130
|
+
expect(detail).toMatch(/retry once/i);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// event-journal.test.ts —— JournalWriter 写入-重放往返一致 + 中立格式字段断言。
|
|
2
|
+
//
|
|
3
|
+
// 三视角:①构建者——行格式 v/ts/taskId/engineId/seq/event 逐字段、seq 单调;②使用者
|
|
4
|
+
// ——replayJournal 重放即得事件流(read 第②级),文件不存在/坏行不 throw;③观察者
|
|
5
|
+
// ——写失败 warn 留痕且 close 不抛(journal 是尽力而为的②级数据源)。
|
|
6
|
+
|
|
7
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
|
|
11
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
12
|
+
|
|
13
|
+
import { JournalWriter, replayJournal } from "../../common/event-journal.ts";
|
|
14
|
+
import type { JournalFsDeps } from "../../common/event-journal.ts";
|
|
15
|
+
import type { AgentEvent } from "../../../types.ts";
|
|
16
|
+
|
|
17
|
+
let tmpRoot: string | undefined;
|
|
18
|
+
|
|
19
|
+
function tmpPath(name: string): string {
|
|
20
|
+
tmpRoot ??= mkdtempSync(join(tmpdir(), "engine-journal-test-"));
|
|
21
|
+
const path = join(tmpRoot, name);
|
|
22
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
23
|
+
return path;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
if (tmpRoot !== undefined) {
|
|
28
|
+
rmSync(tmpRoot, { recursive: true, force: true });
|
|
29
|
+
tmpRoot = undefined;
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const EVENTS: AgentEvent[] = [
|
|
34
|
+
{ type: "text_delta", delta: "hello " },
|
|
35
|
+
{ type: "text_delta", delta: "world" },
|
|
36
|
+
{ type: "tool_start", toolName: "bash", args: { cmd: "ls" } },
|
|
37
|
+
{ type: "tool_end", toolName: "bash", isError: false },
|
|
38
|
+
{ type: "turn_end" },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
describe("JournalWriter 写入 + replayJournal 重放", () => {
|
|
42
|
+
it("写入-重放往返一致:append N 事件 → close → replay 深等事件流", async () => {
|
|
43
|
+
const path = tmpPath("roundtrip/journal-bg-1.jsonl");
|
|
44
|
+
const writer = new JournalWriter({ path, taskId: "bg-1", engineId: "zcode" });
|
|
45
|
+
for (const ev of EVENTS) writer.append(ev);
|
|
46
|
+
await writer.close();
|
|
47
|
+
|
|
48
|
+
expect(replayJournal(path)).toEqual(EVENTS);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("中立格式字段断言:每行 {v:1, ts, taskId, engineId, seq, event},seq 单调递增", async () => {
|
|
52
|
+
const path = tmpPath("format/journal-bg-2.jsonl");
|
|
53
|
+
const writer = new JournalWriter({ path, taskId: "bg-2", engineId: "pi" });
|
|
54
|
+
const before = Date.now();
|
|
55
|
+
for (const ev of EVENTS) writer.append(ev);
|
|
56
|
+
await writer.close();
|
|
57
|
+
|
|
58
|
+
const lines = readFileSync(path, "utf8").trim().split("\n");
|
|
59
|
+
expect(lines.length).toBe(EVENTS.length);
|
|
60
|
+
const parsed = lines.map((l) => JSON.parse(l) as Record<string, unknown>);
|
|
61
|
+
let prevSeq = -1;
|
|
62
|
+
for (const [i, row] of parsed.entries()) {
|
|
63
|
+
expect(row.v).toBe(1);
|
|
64
|
+
expect(typeof row.ts).toBe("number");
|
|
65
|
+
expect(row.ts).toBeGreaterThanOrEqual(before);
|
|
66
|
+
expect(row.taskId).toBe("bg-2");
|
|
67
|
+
expect(row.engineId).toBe("pi");
|
|
68
|
+
expect(row.seq).toBe(i);
|
|
69
|
+
expect((row.seq as number) > prevSeq).toBe(true);
|
|
70
|
+
prevSeq = row.seq as number;
|
|
71
|
+
expect(row.event).toEqual(EVENTS[i]);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("close 幂等(二次 close 不追加不抛错);close 后 append 丢弃", async () => {
|
|
76
|
+
const path = tmpPath("idempotent/journal-bg-3.jsonl");
|
|
77
|
+
const writer = new JournalWriter({ path, taskId: "bg-3", engineId: "pi" });
|
|
78
|
+
writer.append({ type: "turn_end" });
|
|
79
|
+
await writer.close();
|
|
80
|
+
writer.append({ type: "error", message: "late" });
|
|
81
|
+
await writer.close();
|
|
82
|
+
expect(replayJournal(path)).toEqual([{ type: "turn_end" }]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("无事件任务不产生空 journal 文件(惰性创建)", async () => {
|
|
86
|
+
const path = tmpPath("empty/journal-bg-4.jsonl");
|
|
87
|
+
const writer = new JournalWriter({ path, taskId: "bg-4", engineId: "pi" });
|
|
88
|
+
await writer.close();
|
|
89
|
+
expect(existsSync(path)).toBe(false);
|
|
90
|
+
expect(replayJournal(path)).toEqual([]);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("JournalWriter 写失败(尽力而为语义)", () => {
|
|
95
|
+
function failingFs(): { fs: JournalFsDeps; warnings: string[] } {
|
|
96
|
+
const warnings: string[] = [];
|
|
97
|
+
const fs: JournalFsDeps = {
|
|
98
|
+
mkdir: async () => undefined,
|
|
99
|
+
appendFile: async () => {
|
|
100
|
+
throw new Error("disk full");
|
|
101
|
+
},
|
|
102
|
+
open: async () => {
|
|
103
|
+
throw new Error("unreachable in failed path");
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
return { fs, warnings };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
it("appendFile 失败 → warn 留痕 + failed,close 不抛,后续 append 丢弃", async () => {
|
|
110
|
+
const path = tmpPath("failing/journal-bg-5.jsonl");
|
|
111
|
+
const { fs } = failingFs();
|
|
112
|
+
const warnings: string[] = [];
|
|
113
|
+
const writer = new JournalWriter(
|
|
114
|
+
{ path, taskId: "bg-5", engineId: "zcode" },
|
|
115
|
+
fs,
|
|
116
|
+
(msg) => warnings.push(msg),
|
|
117
|
+
);
|
|
118
|
+
writer.append({ type: "turn_end" });
|
|
119
|
+
await writer.flush();
|
|
120
|
+
expect(writer.isFailed).toBe(true);
|
|
121
|
+
expect(warnings.length).toBe(1);
|
|
122
|
+
expect(warnings[0]).toContain("bg-5");
|
|
123
|
+
expect(warnings[0]).toContain("disk full");
|
|
124
|
+
|
|
125
|
+
writer.append({ type: "error", message: "dropped" });
|
|
126
|
+
await writer.close(); // 不抛
|
|
127
|
+
expect(existsSync(path)).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("replayJournal 容错", () => {
|
|
132
|
+
it("文件不存在 → [](②级不可达不算错误,降级链语义)", () => {
|
|
133
|
+
expect(replayJournal(tmpPath("missing/journal-none.jsonl"))).toEqual([]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("损坏行跳过(追加写末行可能截断),好行保留", () => {
|
|
137
|
+
const path = tmpPath("corrupt/journal-bg-6.jsonl");
|
|
138
|
+
const good = JSON.stringify({
|
|
139
|
+
v: 1, ts: 1, taskId: "bg-6", engineId: "pi", seq: 0, event: { type: "turn_end" },
|
|
140
|
+
});
|
|
141
|
+
writeFileSync(path, `${good}\n{"v":1,"truncated...\n`, "utf8");
|
|
142
|
+
expect(replayJournal(path)).toEqual([{ type: "turn_end" }]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("行序乱 → 按 seq 稳定排序返回(重放顺序权威是 seq)", () => {
|
|
146
|
+
const path = tmpPath("unordered/journal-bg-7.jsonl");
|
|
147
|
+
const line = (seq: number, delta: string): string =>
|
|
148
|
+
JSON.stringify({ v: 1, ts: seq, taskId: "bg-7", engineId: "pi", seq, event: { type: "text_delta", delta } });
|
|
149
|
+
writeFileSync(path, `${line(2, "c")}\n${line(0, "a")}\n${line(1, "b")}\n`, "utf8");
|
|
150
|
+
expect(replayJournal(path)).toEqual([
|
|
151
|
+
{ type: "text_delta", delta: "a" },
|
|
152
|
+
{ type: "text_delta", delta: "b" },
|
|
153
|
+
{ type: "text_delta", delta: "c" },
|
|
154
|
+
]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("空文件 → []", () => {
|
|
158
|
+
const path = tmpPath("blank/journal-bg-8.jsonl");
|
|
159
|
+
writeFileSync(path, "\n\n", "utf8");
|
|
160
|
+
expect(replayJournal(path)).toEqual([]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("结构不符的行(缺 event / v≠1)跳过", () => {
|
|
164
|
+
const path = tmpPath("shape/journal-bg-9.jsonl");
|
|
165
|
+
writeFileSync(
|
|
166
|
+
path,
|
|
167
|
+
[
|
|
168
|
+
JSON.stringify({ v: 1, ts: 1, taskId: "t", engineId: "pi", seq: 0, event: { type: "compaction" } }),
|
|
169
|
+
JSON.stringify({ v: 1, ts: 2, taskId: "t", engineId: "pi", seq: 1 }), // 无 event
|
|
170
|
+
JSON.stringify({ v: 2, ts: 3, taskId: "t", engineId: "pi", seq: 2, event: { type: "turn_end" } }), // v≠1
|
|
171
|
+
JSON.stringify({ v: 1, ts: 4, taskId: "t", engineId: "pi", seq: 3, event: "not-an-object" }),
|
|
172
|
+
].join("\n") + "\n",
|
|
173
|
+
"utf8",
|
|
174
|
+
);
|
|
175
|
+
expect(replayJournal(path)).toEqual([{ type: "compaction" }]);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// kill-chain.test.ts —— 杀链两分支 + 超时终态合成 + abort 两级编排(fake timers)。
|
|
2
|
+
//
|
|
3
|
+
// 三视角:①构建者——SIGTERM 优雅 / 超时 SIGKILL 两分支的信号序列正确;②使用者——
|
|
4
|
+
// abortWithFallback 对 CLI-only 与 native-interrupt 引擎都收敛不悬挂;③观察者——
|
|
5
|
+
// 合成终态的 error 含 stdout 尾部与 exitCode=null(被信号杀死判据)。
|
|
6
|
+
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
abortWithFallback,
|
|
11
|
+
killChain,
|
|
12
|
+
synthesizeTimeoutOutcome,
|
|
13
|
+
} from "../../common/kill-chain.ts";
|
|
14
|
+
import type { KillableChild } from "../../common/kill-chain.ts";
|
|
15
|
+
|
|
16
|
+
/** fake 子进程:记录 kill 信号序列,emitExit 模拟退出(置退出态 + 触发 exit listener)。 */
|
|
17
|
+
function makeFakeChild(): {
|
|
18
|
+
child: KillableChild;
|
|
19
|
+
signals: string[];
|
|
20
|
+
emitExit(code: number | null, signal: NodeJS.Signals | null): void;
|
|
21
|
+
} {
|
|
22
|
+
const listeners: Array<(code: number | null, signal: NodeJS.Signals | null) => void> = [];
|
|
23
|
+
const signals: string[] = [];
|
|
24
|
+
const child: KillableChild = {
|
|
25
|
+
exitCode: null,
|
|
26
|
+
signalCode: null,
|
|
27
|
+
kill(signal?: NodeJS.Signals | number): boolean {
|
|
28
|
+
signals.push(String(signal ?? "SIGTERM"));
|
|
29
|
+
return true;
|
|
30
|
+
},
|
|
31
|
+
once(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): KillableChild {
|
|
32
|
+
if (event === "exit") listeners.push(listener);
|
|
33
|
+
return child;
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
return {
|
|
37
|
+
child,
|
|
38
|
+
signals,
|
|
39
|
+
emitExit(code, signal) {
|
|
40
|
+
child.exitCode = code;
|
|
41
|
+
child.signalCode = signal;
|
|
42
|
+
for (const l of listeners.splice(0)) l(code, signal);
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
vi.useFakeTimers();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
vi.useRealTimers();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("killChain", () => {
|
|
56
|
+
it("SIGTERM 后进程优雅退出 → 'terminated',无 SIGKILL", async () => {
|
|
57
|
+
const { child, signals, emitExit } = makeFakeChild();
|
|
58
|
+
const p = killChain(child, { graceMs: 5_000 });
|
|
59
|
+
emitExit(0, null); // SIGTERM 被 trap 后正常退出
|
|
60
|
+
expect(await p).toBe("terminated");
|
|
61
|
+
expect(signals).toEqual(["SIGTERM"]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("SIGTERM 超时未退 → SIGKILL 兜底 → 'killed'(fake timers 推进两级窗口)", async () => {
|
|
65
|
+
const { child, signals } = makeFakeChild();
|
|
66
|
+
const p = killChain(child, { graceMs: 5_000 });
|
|
67
|
+
expect(signals).toEqual(["SIGTERM"]); // kill 同步发出
|
|
68
|
+
await vi.advanceTimersByTimeAsync(5_000); // grace 窗口走完,进程仍活
|
|
69
|
+
expect(signals).toEqual(["SIGTERM", "SIGKILL"]);
|
|
70
|
+
await vi.advanceTimersByTimeAsync(10_000); // SIGKILL 收尸窗口(进程不退也返回)
|
|
71
|
+
expect(await p).toBe("killed");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("进程已退出(调用前)→ 不发任何信号,'terminated'", async () => {
|
|
75
|
+
const { child, signals, emitExit } = makeFakeChild();
|
|
76
|
+
emitExit(1, null);
|
|
77
|
+
expect(await killChain(child, { graceMs: 5_000 })).toBe("terminated");
|
|
78
|
+
expect(signals).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("grace 窗口内恰在超时前一毫秒退出 → 复核退出态,不误发 SIGKILL", async () => {
|
|
82
|
+
const { child, signals, emitExit } = makeFakeChild();
|
|
83
|
+
const p = killChain(child, { graceMs: 5_000 });
|
|
84
|
+
emitExit(null, "SIGTERM"); // 被 SIGTERM 杀死(signalCode 形态)
|
|
85
|
+
await vi.advanceTimersByTimeAsync(5_000);
|
|
86
|
+
expect(await p).toBe("terminated");
|
|
87
|
+
expect(signals).toEqual(["SIGTERM"]);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("synthesizeTimeoutOutcome", () => {
|
|
92
|
+
it("合成 engine_timeout 终态:error 含 slug + stdout 尾部 + engine: pi 重跑建议;exitCode=null", () => {
|
|
93
|
+
const outcome = synthesizeTimeoutOutcome(
|
|
94
|
+
{ task: "review files", slug: "review-files" },
|
|
95
|
+
"last stdout lines...",
|
|
96
|
+
);
|
|
97
|
+
expect(outcome.error).toContain("engine_timeout");
|
|
98
|
+
expect(outcome.error).toContain("review-files");
|
|
99
|
+
expect(outcome.error).toContain("last stdout lines...");
|
|
100
|
+
expect(outcome.error).toMatch(/`engine: pi`/);
|
|
101
|
+
expect(outcome.exitCode).toBe(null);
|
|
102
|
+
expect(outcome.content).toBe("");
|
|
103
|
+
expect(outcome.engineId).toBe("pi");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("stdout 尾部超 2000 字截断;engineId 可指定", () => {
|
|
107
|
+
const outcome = synthesizeTimeoutOutcome(
|
|
108
|
+
{ task: "t", slug: "s" },
|
|
109
|
+
"y".repeat(3_000),
|
|
110
|
+
"zcode",
|
|
111
|
+
);
|
|
112
|
+
expect(outcome.engineId).toBe("zcode");
|
|
113
|
+
expect(outcome.error).toContain("...");
|
|
114
|
+
expect(outcome.error.length).toBeLessThan(3_000);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("abortWithFallback", () => {
|
|
119
|
+
it("CLI-only 引擎(无原生中断):abort → 直接杀链,SIGTERM 退 → 'terminated'", async () => {
|
|
120
|
+
const { child, signals, emitExit } = makeFakeChild();
|
|
121
|
+
const controller = new AbortController();
|
|
122
|
+
const p = abortWithFallback(child, controller.signal);
|
|
123
|
+
controller.abort();
|
|
124
|
+
emitExit(0, null); // SIGTERM 生效
|
|
125
|
+
expect(await p).toBe("terminated");
|
|
126
|
+
expect(signals).toEqual(["SIGTERM"]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("原生中断生效:abort → native interrupt → 进程退出,不走杀链(零 kill 信号)", async () => {
|
|
130
|
+
const { child, signals, emitExit } = makeFakeChild();
|
|
131
|
+
const controller = new AbortController();
|
|
132
|
+
const interrupt = vi.fn(async () => {
|
|
133
|
+
emitExit(0, null); // 引擎原生中断让进程优雅退出
|
|
134
|
+
});
|
|
135
|
+
const p = abortWithFallback(child, controller.signal, interrupt);
|
|
136
|
+
controller.abort();
|
|
137
|
+
expect(await p).toBe("terminated");
|
|
138
|
+
expect(interrupt).toHaveBeenCalledTimes(1);
|
|
139
|
+
expect(signals).toEqual([]); // 从未发信号——原生中断足量
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("原生中断无效(进程未停)→ 宽限窗口后落杀链 SIGKILL → 'killed'", async () => {
|
|
143
|
+
const { child, signals } = makeFakeChild();
|
|
144
|
+
const controller = new AbortController();
|
|
145
|
+
const interrupt = vi.fn(async () => undefined); // 中断送达但进程不退
|
|
146
|
+
const p = abortWithFallback(child, controller.signal, interrupt, {
|
|
147
|
+
nativeGraceMs: 3_000,
|
|
148
|
+
graceMs: 5_000,
|
|
149
|
+
});
|
|
150
|
+
controller.abort();
|
|
151
|
+
expect(interrupt).toHaveBeenCalledTimes(1);
|
|
152
|
+
await vi.advanceTimersByTimeAsync(3_000); // native 宽限窗口走完
|
|
153
|
+
expect(signals).toEqual(["SIGTERM"]);
|
|
154
|
+
await vi.advanceTimersByTimeAsync(5_000); // 杀链 grace 窗口走完
|
|
155
|
+
expect(signals).toEqual(["SIGTERM", "SIGKILL"]);
|
|
156
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
157
|
+
expect(await p).toBe("killed");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("原生中断 throw(协议错)→ 不阻断兜底,继续杀链", async () => {
|
|
161
|
+
const { child, signals, emitExit } = makeFakeChild();
|
|
162
|
+
const controller = new AbortController();
|
|
163
|
+
const interrupt = vi.fn(async () => {
|
|
164
|
+
throw new Error("rpc broken");
|
|
165
|
+
});
|
|
166
|
+
const p = abortWithFallback(child, controller.signal, interrupt, { nativeGraceMs: 100, graceMs: 5_000 });
|
|
167
|
+
controller.abort();
|
|
168
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
169
|
+
emitExit(0, null); // 后续 SIGTERM 生效
|
|
170
|
+
expect(await p).toBe("terminated");
|
|
171
|
+
expect(signals).toEqual(["SIGTERM"]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("进程自然退出(signal 从未 abort)→ 'terminated',promise 不悬挂", async () => {
|
|
175
|
+
const { child, signals, emitExit } = makeFakeChild();
|
|
176
|
+
const controller = new AbortController();
|
|
177
|
+
const p = abortWithFallback(child, controller.signal);
|
|
178
|
+
emitExit(0, null);
|
|
179
|
+
expect(await p).toBe("terminated");
|
|
180
|
+
expect(signals).toEqual([]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("signal 已 abort 的场景(先 abort 后接线)→ 立即执行两级中断", async () => {
|
|
184
|
+
const { child, signals, emitExit } = makeFakeChild();
|
|
185
|
+
const controller = new AbortController();
|
|
186
|
+
controller.abort(); // 先 abort
|
|
187
|
+
const p = abortWithFallback(child, controller.signal);
|
|
188
|
+
emitExit(0, null);
|
|
189
|
+
expect(await p).toBe("terminated");
|
|
190
|
+
expect(signals).toEqual(["SIGTERM"]);
|
|
191
|
+
});
|
|
192
|
+
});
|