@zhushanwen/pi-subagent-workflow 5.0.2 → 7.0.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 (77) hide show
  1. package/agents/{reviewer.md → code-reviewer.md} +20 -3
  2. package/agents/context-builder.md +5 -0
  3. package/agents/doc-reviewer.md +9 -2
  4. package/agents/explorer.md +5 -0
  5. package/agents/general-purpose.md +5 -0
  6. package/agents/oracle.md +20 -4
  7. package/agents/orchestrator.md +6 -1
  8. package/agents/planner.md +5 -0
  9. package/agents/researcher.md +5 -0
  10. package/agents/worker.md +5 -0
  11. package/package.json +6 -4
  12. package/src/execution/__tests__/agent-registry.test.ts +189 -119
  13. package/src/execution/__tests__/crash-recovery.test.ts +0 -1
  14. package/src/execution/__tests__/execute-options-mapper.test.ts +4 -4
  15. package/src/execution/__tests__/index-session-start.test.ts +0 -1
  16. package/src/execution/__tests__/model-resolver.test.ts +20 -0
  17. package/src/execution/__tests__/session-start-reaper.test.ts +0 -2
  18. package/src/execution/__tests__/subprocess-agent-runner.test.ts +1 -1
  19. package/src/execution/agent-registry.ts +92 -169
  20. package/src/execution/execute-options-mapper.ts +2 -2
  21. package/src/execution/model-config-service.ts +13 -34
  22. package/src/execution/model-resolver.ts +5 -3
  23. package/src/execution/subagent-service.ts +9 -6
  24. package/src/execution/subprocess-agent-runner.ts +3 -2
  25. package/src/index.ts +4 -25
  26. package/src/injectors/__tests__/subagent-list-injector.test.ts +266 -14
  27. package/src/injectors/__tests__/workflow-list-injector.test.ts +236 -32
  28. package/src/injectors/subagent-list-injector.ts +99 -48
  29. package/src/injectors/workflow-list-injector.ts +65 -50
  30. package/src/interface/__tests__/detectors.test.ts +100 -43
  31. package/src/interface/__tests__/subagent-tool-prompt.test.ts +8 -12
  32. package/src/interface/__tests__/tool-workflow-script-generate.test.ts +163 -0
  33. package/src/interface/__tests__/workflow-tool-prompt.test.ts +55 -9
  34. package/src/interface/subagent-tool.ts +7 -4
  35. package/src/interface/tool-workflow-script.ts +27 -10
  36. package/src/interface/tool-workflow.ts +174 -81
  37. package/src/orchestration/__tests__/args-validator.test.ts +143 -0
  38. package/src/orchestration/__tests__/config-loader.test.ts +124 -40
  39. package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +33 -2
  40. package/src/orchestration/__tests__/lifecycle.test.ts +59 -2
  41. package/src/orchestration/__tests__/review-fix-loop-e2e.test.ts +116 -51
  42. package/src/orchestration/__tests__/script-lint.test.ts +167 -1
  43. package/src/orchestration/__tests__/worker-host.test.ts +120 -0
  44. package/src/orchestration/__tests__/worker-script-builder-runtime.test.ts +69 -0
  45. package/src/orchestration/__tests__/worker-script-builder.test.ts +51 -1
  46. package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +2 -2
  47. package/src/orchestration/__tests__/workflows-e2e.test.ts +177 -24
  48. package/src/orchestration/agent-opts-resolver.ts +51 -101
  49. package/src/orchestration/args-validator.ts +127 -0
  50. package/src/orchestration/config-loader.ts +63 -94
  51. package/src/orchestration/error-recovery.ts +6 -9
  52. package/src/orchestration/launcher.ts +77 -41
  53. package/src/orchestration/lifecycle.ts +7 -0
  54. package/src/orchestration/models/ports.ts +0 -14
  55. package/src/orchestration/models/run-spec.ts +24 -1
  56. package/src/orchestration/models/types.ts +15 -8
  57. package/src/orchestration/models/workflow-script-registry.ts +3 -0
  58. package/src/orchestration/models/workflow-script.ts +10 -14
  59. package/src/orchestration/script-lint.ts +159 -0
  60. package/src/orchestration/worker-host.ts +5 -0
  61. package/src/orchestration/worker-script-builder.ts +15 -4
  62. package/src/orchestration/workflow-script-registry-impl.ts +34 -29
  63. package/src/shared/__tests__/meta-parser.test.ts +304 -0
  64. package/src/shared/__tests__/resource-discovery.test.ts +167 -7
  65. package/src/shared/__tests__/resource-meta.test.ts +51 -0
  66. package/src/shared/agent-ref.ts +36 -0
  67. package/src/shared/meta-parser.ts +257 -0
  68. package/src/shared/resource-discovery.ts +88 -2
  69. package/src/shared/resource-meta.ts +60 -0
  70. package/workflows/README.md +2 -2
  71. package/workflows/_shared/agent-refs.cjs +40 -0
  72. package/workflows/chain.js +30 -6
  73. package/workflows/map-reduce.js +33 -5
  74. package/workflows/parallel.js +34 -7
  75. package/workflows/review-fix-loop-utils.cjs +23 -103
  76. package/workflows/review-fix-loop.js +121 -58
  77. package/workflows/scatter-gather.js +30 -6
@@ -14,6 +14,13 @@ import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
14
14
  import { tmpdir } from "node:os";
15
15
  import { join } from "node:path";
16
16
 
17
+ // homedir 只影响 normalizeRef 的 `~/` 展开(getWorkflowByPath 路径),
18
+ // mock 成可写临时目录(测试内动态改),其余 node:os 保持真实。
19
+ vi.mock("node:os", async (importOriginal) => {
20
+ const actual = await importOriginal<typeof import("node:os")>();
21
+ return { ...actual, homedir: vi.fn(() => "/nonexistent-home-for-tests") };
22
+ });
23
+
17
24
  // resource-discovery 位于 src/shared/(从 __tests__/ 看是 ../../shared/)。
18
25
  // 只覆盖 findWorkspaceRoot;其余(discoverResources 等)保持真实实现。
19
26
  vi.mock("../../shared/resource-discovery.ts", async (importOriginal) => {
@@ -29,12 +36,15 @@ vi.mock("../../shared/resource-discovery.ts", async (importOriginal) => {
29
36
  import {
30
37
  discoverWorkflows,
31
38
  getWorkflow,
39
+ getWorkflowByPath,
32
40
  invalidateCache,
33
41
  type WorkflowScanConfig,
34
42
  } from "../config-loader.ts";
35
43
  import { findWorkspaceRoot } from "../../shared/resource-discovery.ts";
44
+ import { homedir } from "node:os";
36
45
 
37
46
  const mockedFindWorkspaceRoot = vi.mocked(findWorkspaceRoot);
47
+ const mockedHomedir = vi.mocked(homedir);
38
48
 
39
49
  // ── 临时工作区工具 ────────────────────────────────────────────
40
50
 
@@ -77,7 +87,7 @@ function validScript(
77
87
  ): string {
78
88
  const description = opts.description ?? `${name} desc`;
79
89
  const phases = JSON.stringify(opts.phases ?? []);
80
- return `const meta = { name: "${name}", description: "${description}", phases: ${phases} };\nagent({ prompt: "x" });\n`;
90
+ return `/* @pi-meta\nname: ${name}\ndescription: ${description}\nphases: ${phases}\n*/\nagent({ prompt: "x" });\n`;
81
91
  }
82
92
 
83
93
  // ── Setup / Teardown ──────────────────────────────────────────
@@ -116,32 +126,16 @@ describe("discoverWorkflows — 加载合法配置", () => {
116
126
  expect(foo!.path).toBe(join(ws.projectDir, "foo.js"));
117
127
  });
118
128
 
119
- it("支持 export const meta 形式", async () => {
120
- await writeScript(
121
- ws.projectDir,
122
- "bar.mjs",
123
- `export const meta = { name: "bar", description: "d", phases: ["p1"] };\nagent({ prompt: "x" });\n`,
124
- );
125
-
126
- const result = inTemp(await discoverWorkflows({ projectDir: ws.projectDir }));
127
- const bar = result.find((w) => w.name === "bar");
128
-
129
- expect(bar).toBeDefined();
130
- expect(bar!.available).toBe(true);
131
- expect(bar!.description).toBe("d");
132
- expect(bar!.phases).toEqual(["p1"]);
133
- });
134
-
135
- it("多行 meta 对象正确解析", async () => {
129
+ it("多行 @pi-meta YAML 正确解析", async () => {
136
130
  await writeScript(
137
131
  ws.projectDir,
138
132
  "multi.js",
139
133
  [
140
- "const meta = {",
141
- ' name: "multi",',
142
- ' description: "multi-line",',
143
- ' phases: ["a", "b"],',
144
- "};",
134
+ "/* @pi-meta",
135
+ "name: multi",
136
+ "description: multi-line",
137
+ "phases: [a, b]",
138
+ "*/",
145
139
  'agent({ prompt: "x" });',
146
140
  "",
147
141
  ].join("\n"),
@@ -156,11 +150,11 @@ describe("discoverWorkflows — 加载合法配置", () => {
156
150
  expect(multi!.phases).toEqual(["a", "b"]);
157
151
  });
158
152
 
159
- it("未声明 phases 时默认为空数组", async () => {
153
+ it("phases 为空数组合法", async () => {
160
154
  await writeScript(
161
155
  ws.projectDir,
162
156
  "nophase.js",
163
- `const meta = { name: "nophase", description: "x" };\n`,
157
+ `/* @pi-meta\nname: nophase\ndescription: x\nphases: []\n*/\n`,
164
158
  );
165
159
 
166
160
  const result = inTemp(await discoverWorkflows({ projectDir: ws.projectDir }));
@@ -169,6 +163,45 @@ describe("discoverWorkflows — 加载合法配置", () => {
169
163
  expect(noPhase).toBeDefined();
170
164
  expect(noPhase!.phases).toEqual([]);
171
165
  });
166
+
167
+ it("parameters/usage 整对象透传不丢(m2 exec-review MAJOR-2 回归护栏)", async () => {
168
+ await writeScript(
169
+ ws.projectDir,
170
+ "params.js",
171
+ [
172
+ "/* @pi-meta",
173
+ "name: params",
174
+ "description: x",
175
+ "phases: [a]",
176
+ "parameters:",
177
+ " type: object",
178
+ " properties:",
179
+ " autoCommit: { type: boolean, default: false }",
180
+ " required: [autoCommit]",
181
+ "usage: |",
182
+ " ## 使用说明",
183
+ " - 示例:workflow run params --args autoCommit=true",
184
+ "*/",
185
+ 'agent({ prompt: "x" });',
186
+ "",
187
+ ].join("\n"),
188
+ );
189
+
190
+ const result = inTemp(await discoverWorkflows({ projectDir: ws.projectDir }));
191
+ const p = result.find((w) => w.name === "params");
192
+
193
+ expect(p).toBeDefined();
194
+ expect(p!.available).toBe(true);
195
+ // 若未来有人重引入 {name,description,phases} 解构重映射(m2 消灭的反模式),
196
+ // 此断言会失败——parameters/usage 是 m3 args-validator 与 m4 meta 消费的依赖。
197
+ const params = p!.parameters as
198
+ | { type: string; properties: { autoCommit: { type: string } }; required: string[] }
199
+ | undefined;
200
+ expect(params?.type).toBe("object");
201
+ expect(params?.properties?.autoCommit?.type).toBe("boolean");
202
+ expect(params?.required).toEqual(["autoCommit"]);
203
+ expect(p!.usage).toContain("autoCommit=true");
204
+ });
172
205
  });
173
206
 
174
207
  describe("getWorkflow — 按名查找", () => {
@@ -203,7 +236,7 @@ describe("缓存 — invalidateCache 与 TTL", () => {
203
236
  mockedFindWorkspaceRoot.mockReturnValue(ws.root);
204
237
  });
205
238
 
206
- it("invalidateCache getWorkflow 重新读取文件(反映最新内容)", async () => {
239
+ it("TC6: mtime 判变——文件修改后 getWorkflow 立即反映新内容(删 60s TTL)", async () => {
207
240
  const scriptPath = await writeScript(
208
241
  ws.projectDir,
209
242
  "foo.js",
@@ -214,23 +247,21 @@ describe("缓存 — invalidateCache 与 TTL", () => {
214
247
  let foo = await getWorkflow("foo");
215
248
  expect(foo!.description).toBe("v1");
216
249
 
217
- // 修改文件内容;未失效缓存前 getWorkflow 仍返回旧值
250
+ // 修改文件内容(mtime 变)→ getWorkflow 立即反映(不再有 60s 陈旧窗口)
218
251
  await writeFile(
219
252
  scriptPath,
220
253
  validScript("foo", { description: "v2" }),
221
254
  "utf-8",
222
255
  );
223
256
  foo = await getWorkflow("foo");
224
- expect(foo!.description).toBe("v1");
257
+ expect(foo!.description).toBe("v2");
225
258
 
226
- // 失效缓存后重新加载
227
- invalidateCache();
259
+ // 文件未变 → 再次调用命中缓存(同内容)
228
260
  foo = await getWorkflow("foo");
229
261
  expect(foo!.description).toBe("v2");
230
262
  });
231
263
 
232
- it("缓存 TTL 过期后触发重新加载", async () => {
233
- vi.useFakeTimers({ now: 1_000_000 });
264
+ it("invalidateCache 清统一缓存层后 getWorkflow 强制重读(mtime 漏判场景兜底)", async () => {
234
265
  const scriptPath = await writeScript(
235
266
  ws.projectDir,
236
267
  "foo.js",
@@ -240,23 +271,22 @@ describe("缓存 — invalidateCache 与 TTL", () => {
240
271
  await discoverWorkflows({ projectDir: ws.projectDir });
241
272
  expect((await getWorkflow("foo"))!.description).toBe("v1");
242
273
 
243
- // TTL(60s)内修改文件 缓存命中,仍是旧值
274
+ // 修改文件mtime 判变立即反映(不等 invalidateCache)
244
275
  await writeFile(
245
276
  scriptPath,
246
277
  validScript("foo", { description: "v2" }),
247
278
  "utf-8",
248
279
  );
249
- vi.advanceTimersByTime(30_000);
250
- expect((await getWorkflow("foo"))!.description).toBe("v1");
280
+ expect((await getWorkflow("foo"))!.description).toBe("v2");
251
281
 
252
- // 超过 TTL 缓存失效 重新加载
253
- vi.advanceTimersByTime(31_000);
282
+ // invalidateCache 后仍强制重读(内容变 mtime 未变场景的兜底——cp -p 类)
283
+ invalidateCache();
254
284
  expect((await getWorkflow("foo"))!.description).toBe("v2");
255
285
  });
256
286
  });
257
287
 
258
288
  describe("坏配置容错 — 不 crash,标记 available=false", () => {
259
- it("缺少 const meta 声明的脚本被标记不可用", async () => {
289
+ it("缺少 @pi-meta 声明的脚本被标记不可用", async () => {
260
290
  await writeScript(
261
291
  ws.projectDir,
262
292
  "broken.js",
@@ -277,7 +307,7 @@ describe("坏配置容错 — 不 crash,标记 available=false", () => {
277
307
  await writeScript(
278
308
  ws.projectDir,
279
309
  "badname.js",
280
- `const meta = { name: 123, description: "x" };\n`,
310
+ `/* @pi-meta\nname: 123\ndescription: x\nphases: []\n*/\n`,
281
311
  );
282
312
 
283
313
  const result = inTemp(await discoverWorkflows({ projectDir: ws.projectDir }));
@@ -292,7 +322,7 @@ describe("坏配置容错 — 不 crash,标记 available=false", () => {
292
322
  await writeScript(
293
323
  ws.projectDir,
294
324
  "garbage.js",
295
- `const meta = { name: "oops", description: ;;;; };\n`,
325
+ `/* @pi-meta\nname: oops\ndescription: d\nphases: [a\n*/\n`,
296
326
  );
297
327
 
298
328
  const result = inTemp(await discoverWorkflows({ projectDir: ws.projectDir }));
@@ -364,6 +394,60 @@ describe("目录优先级 — 同名资源高优先级覆盖", () => {
364
394
  });
365
395
  });
366
396
 
397
+ describe("getWorkflowByPath — 按绝对路径加载(S2 路径统一核心新入口)", () => {
398
+ it("合法文件:返回完整 CachedWorkflowMeta 结构(available=true)", async () => {
399
+ const scriptPath = await writeScript(
400
+ ws.projectDir,
401
+ "bypath.js",
402
+ validScript("bypath", { description: "path loaded", phases: ["a"] }),
403
+ );
404
+
405
+ const meta = await getWorkflowByPath(scriptPath);
406
+
407
+ expect(meta).toBeDefined();
408
+ expect(meta!.available).toBe(true);
409
+ expect(meta!.name).toBe("bypath");
410
+ expect(meta!.description).toBe("path loaded");
411
+ expect(meta!.phases).toEqual(["a"]);
412
+ expect(meta!.path).toBe(scriptPath);
413
+ // toCachedMeta 的 source 参数 "user-pi" → saved(非 project-pi-tmp)
414
+ expect(meta!.source).toBe("saved");
415
+ });
416
+
417
+ it("相对路径返回 undefined(引用唯一形态 = 绝对路径)", async () => {
418
+ await expect(getWorkflowByPath("workflows/foo.js")).resolves.toBeUndefined();
419
+ await expect(getWorkflowByPath("./foo.js")).resolves.toBeUndefined();
420
+ });
421
+
422
+ it("非 .js 扩展名返回 undefined", async () => {
423
+ await expect(getWorkflowByPath("/tmp/foo.ts")).resolves.toBeUndefined();
424
+ await expect(getWorkflowByPath("/tmp/foo.js.txt")).resolves.toBeUndefined();
425
+ });
426
+
427
+ it("~/ 前缀展开为 homedir 下绝对路径后正常加载", async () => {
428
+ mockedHomedir.mockReturnValue(ws.root);
429
+ const scriptPath = await writeScript(ws.root, "tilda.js", validScript("tilda"));
430
+
431
+ const meta = await getWorkflowByPath("~/tilda.js");
432
+
433
+ expect(meta).toBeDefined();
434
+ expect(meta!.available).toBe(true);
435
+ expect(meta!.name).toBe("tilda");
436
+ expect(meta!.path).toBe(scriptPath);
437
+ });
438
+
439
+ it("文件不存在:available=false 不抛(fail-safe),name fallback 到文件 stem", async () => {
440
+ const meta = await getWorkflowByPath("/nonexistent/ghost.js");
441
+
442
+ expect(meta).toBeDefined(); // 不是 undefined——normalizeRef 通过,meta 提取失败标不可用
443
+ expect(meta!.available).toBe(false);
444
+ expect(meta!.name).toBe("ghost");
445
+ expect(meta!.description).toBe("");
446
+ expect(meta!.phases).toEqual([]);
447
+ expect(meta!.path).toBe("/nonexistent/ghost.js");
448
+ });
449
+ });
450
+
367
451
  describe("WorkflowScanConfig 类型接口", () => {
368
452
  it("接受完整 WorkflowScanConfig(仅 projectDir 即可触发隔离模式)", async () => {
369
453
  await writeScript(ws.projectDir, "foo.js", validScript("foo"));
@@ -29,6 +29,7 @@ vi.mock("../lifecycle.ts", () => ({
29
29
  // import 在 vi.mock 之后(hoisting 保证拿到 mock 版本)。runWorkflow 从被 mock 的
30
30
  // lifecycle 模块导入,与 launcher.ts 内部引用的是同一 mock 实例。
31
31
  import { runWorkflow } from "../lifecycle.ts";
32
+ import { ArgsValidationError } from "../args-validator.ts";
32
33
  import { executeNestedWorkflow } from "../launcher.ts";
33
34
 
34
35
  const MOCK_RUN_ID = "wf-test-child";
@@ -114,12 +115,12 @@ function makeDoneChildRun(opts: {
114
115
  function makeDeps(opts: {
115
116
  script?: WorkflowScript;
116
117
  childRun?: WorkflowRun;
117
- registry?: { get: ReturnType<typeof vi.fn> };
118
+ registry?: { getPath: ReturnType<typeof vi.fn> };
118
119
  } = {}): LauncherDeps {
119
120
  const runs = new Map<string, WorkflowRun>();
120
121
  if (opts.childRun) runs.set(MOCK_RUN_ID, opts.childRun);
121
122
  const registry = opts.registry ?? {
122
- get: vi.fn(async () => opts.script),
123
+ getPath: vi.fn(async () => opts.script),
123
124
  };
124
125
  return {
125
126
  registry,
@@ -149,6 +150,9 @@ beforeEach(() => {
149
150
  describe("executeNestedWorkflow", () => {
150
151
  it("returns error result when workflow not found", async () => {
151
152
  const parent = makeParentRun();
153
+ const signal = parent.runtime!.controller.signal;
154
+ const addSpy = vi.spyOn(signal, "addEventListener");
155
+ const removeSpy = vi.spyOn(signal, "removeEventListener");
152
156
  const deps = makeDeps({ script: undefined });
153
157
 
154
158
  const result = await executeNestedWorkflow("missing", {}, parent, deps);
@@ -156,6 +160,9 @@ describe("executeNestedWorkflow", () => {
156
160
  expect(result.error).toBe("Workflow 'missing' not found");
157
161
  expect(result.content).toBe("");
158
162
  expect(runWorkflow).not.toHaveBeenCalled();
163
+ // E8:早返回也走 finally——Step 2 注册的 listener 被移除(原实现泄漏)
164
+ expect(addSpy).toHaveBeenCalledTimes(1);
165
+ expect(removeSpy).toHaveBeenCalledTimes(1);
159
166
  });
160
167
 
161
168
  it("returns error result on lint failure", async () => {
@@ -170,6 +177,30 @@ describe("executeNestedWorkflow", () => {
170
177
  expect(runWorkflow).not.toHaveBeenCalled();
171
178
  });
172
179
 
180
+ it("TC11: chokepoint 校验失败 → {error} + parentSignal listener 不泄漏(E8)", async () => {
181
+ // 模块级 mock lifecycle:runWorkflow reject ArgsValidationError(真实实例——
182
+ // launcher.ts 未被 mock,instanceof 检查命中同一 class)。
183
+ vi.mocked(runWorkflow).mockRejectedValueOnce(
184
+ new ArgsValidationError(
185
+ "child-wf",
186
+ "Invalid args for workflow 'child-wf': 1 error(s)\n- /target: is required\nRead the workflow script file (location from <available_workflows>) for the parameter schema and usage.",
187
+ ),
188
+ );
189
+ const parent = makeParentRun();
190
+ const signal = parent.runtime!.controller.signal;
191
+ const addSpy = vi.spyOn(signal, "addEventListener");
192
+ const removeSpy = vi.spyOn(signal, "removeEventListener");
193
+ const deps = makeDeps({ script: makeScript() });
194
+
195
+ const result = await executeNestedWorkflow("child-wf", {}, parent, deps);
196
+
197
+ expect(result.content).toBe("");
198
+ expect(result.error).toContain("Invalid args for workflow 'child-wf'");
199
+ // Step 2 注册 1 次 + finally 移除 1 次 → 平衡即无泄漏(E8 修复点)
200
+ expect(addSpy).toHaveBeenCalledTimes(1);
201
+ expect(removeSpy).toHaveBeenCalledTimes(1);
202
+ });
203
+
173
204
  it("detects circular call chain", async () => {
174
205
  // parent chain ["a"], parent scriptName "b", target name "a" → a→b→a 循环
175
206
  const parent = makeParentRun({
@@ -22,6 +22,7 @@ import {
22
22
  runWorkflow,
23
23
  scheduleTimeBudget,
24
24
  } from "../lifecycle.ts";
25
+ import { ArgsValidationError } from "../args-validator.ts";
25
26
  import { Budget } from "../models/budget.ts";
26
27
  import { RunRuntime } from "../models/run-runtime.ts";
27
28
  import { Trace } from "../models/trace.ts";
@@ -32,10 +33,16 @@ import { WorkflowRun } from "../models/workflow-run.ts";
32
33
  // ── helpers ──────────────────────────────────────────────────
33
34
 
34
35
  /** 构造一个最小 RunSpec(满足 WorkflowRun 构造的字段需求)。 */
35
- function makeSpec(opts: { budgetTimeMs?: number; budgetTokens?: number } = {}): RunSpec {
36
+ function makeSpec(opts: {
37
+ budgetTimeMs?: number;
38
+ budgetTokens?: number;
39
+ parameters?: Record<string, unknown>;
40
+ args?: Record<string, unknown>;
41
+ } = {}): RunSpec {
36
42
  return {
37
43
  scriptSource: "execute() {}",
38
- args: {},
44
+ args: opts.args ?? {},
45
+ parameters: opts.parameters,
39
46
  scriptName: "test-wf",
40
47
  scriptPath: "/fake/test.js",
41
48
  budgetTimeMs: opts.budgetTimeMs,
@@ -199,6 +206,34 @@ describe("resumeRun", () => {
199
206
  expect(deps.store.save).toHaveBeenCalledTimes(1);
200
207
  });
201
208
 
209
+ it("TC13: resume 重建 worker 收到 coerce 后 args(WQ2 一致性不变式)", async () => {
210
+ // runWorkflow 首行校验 + coerceTypes 原地规范化 spec.args → pause → resume 重建
211
+ // worker 用同一 args 对象(run.spec === spec)→ start 收到 boolean false。
212
+ const deps = makeDeps();
213
+ const spec = makeSpec({
214
+ parameters: {
215
+ type: "object",
216
+ properties: { autoCommit: { type: "boolean" } },
217
+ required: [],
218
+ },
219
+ args: { autoCommit: "false" },
220
+ });
221
+ const runId = await runWorkflow(spec, deps);
222
+ expect(spec.args.autoCommit).toBe(false); // 原地 coerce 生效
223
+ const run = deps.runs.get(runId)!;
224
+ run.transition("paused");
225
+ deps.runs.set(runId, run);
226
+ deps.workerHost.start.mockClear();
227
+
228
+ await resumeRun(runId, deps);
229
+
230
+ const startArgs = deps.workerHost.start.mock.calls[0]?.[1] as
231
+ | Record<string, unknown>
232
+ | undefined;
233
+ expect(startArgs).toBeDefined();
234
+ expect(startArgs!.autoCommit).toBe(false);
235
+ });
236
+
202
237
  it("无 budgetTimeMs 时 resume 不调度时间预算计时器", async () => {
203
238
  const { run } = makeRunningRealRun("wf-resume-no-budget");
204
239
  run.transition("paused");
@@ -331,6 +366,28 @@ describe("runWorkflow", () => {
331
366
  expect(deps.runs.size).toBe(0);
332
367
  expect(deps.workerHost.start).not.toHaveBeenCalled();
333
368
  });
369
+
370
+ it("TC8: 参数校验失败 → throw ArgsValidationError + zero side effects(E9)", async () => {
371
+ const deps = makeDeps();
372
+ const controller = new AbortController();
373
+ const addSpy = vi.spyOn(controller.signal, "addEventListener");
374
+ const spec = makeSpec({
375
+ parameters: {
376
+ type: "object",
377
+ properties: { target: { type: "string" } },
378
+ required: ["target"],
379
+ },
380
+ args: {},
381
+ });
382
+
383
+ await expect(runWorkflow(spec, deps, controller.signal)).rejects.toThrow(ArgsValidationError);
384
+ expect(deps.runs.size).toBe(0);
385
+ expect(deps.workerHost.start).not.toHaveBeenCalled();
386
+ expect(deps.store.save).not.toHaveBeenCalled();
387
+ expect(deps.eventBus.emit).not.toHaveBeenCalled();
388
+ expect(addSpy).not.toHaveBeenCalled();
389
+ expect(deps.log).not.toHaveBeenCalled(); // 校验在 deps.log 前 throw(无日志痕迹)
390
+ });
334
391
  });
335
392
 
336
393
  // ── abortRun ─────────────────────────────────────────────────