@zhushanwen/pi-subagent-workflow 0.1.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 (143) hide show
  1. package/agents/context-builder.md +17 -0
  2. package/agents/general-purpose.md +16 -0
  3. package/agents/oracle.md +17 -0
  4. package/agents/planner.md +17 -0
  5. package/agents/researcher.md +17 -0
  6. package/agents/reviewer.md +17 -0
  7. package/agents/scout.md +17 -0
  8. package/agents/worker.md +16 -0
  9. package/examples/README.md +43 -0
  10. package/examples/chain.example.js +92 -0
  11. package/examples/map-reduce.example.js +99 -0
  12. package/examples/parallel.example.js +82 -0
  13. package/examples/scatter-gather.example.js +106 -0
  14. package/index.ts +1 -0
  15. package/package.json +66 -0
  16. package/skills/workflow-script-format/SKILL.md +328 -0
  17. package/src/execution/__tests__/agent-registry.test.ts +164 -0
  18. package/src/execution/__tests__/agent-result-mapper.test.ts +128 -0
  19. package/src/execution/__tests__/alive-store.test.ts +147 -0
  20. package/src/execution/__tests__/bg-notify-render.test.ts +256 -0
  21. package/src/execution/__tests__/concurrency-pool.test.ts +217 -0
  22. package/src/execution/__tests__/config.test.ts +110 -0
  23. package/src/execution/__tests__/crash-recovery.test.ts +311 -0
  24. package/src/execution/__tests__/execute-nesting.test.ts +359 -0
  25. package/src/execution/__tests__/execute-options-mapper.test.ts +138 -0
  26. package/src/execution/__tests__/execution-record.test.ts +959 -0
  27. package/src/execution/__tests__/finalized-marker.test.ts +82 -0
  28. package/src/execution/__tests__/format-schema-instruction.test.ts +135 -0
  29. package/src/execution/__tests__/format.test.ts +320 -0
  30. package/src/execution/__tests__/helpers/mock-extension-api.ts +30 -0
  31. package/src/execution/__tests__/list-component.test.ts +347 -0
  32. package/src/execution/__tests__/model-resolver.test.ts +356 -0
  33. package/src/execution/__tests__/output-collector.test.ts +61 -0
  34. package/src/execution/__tests__/path-encoding.test.ts +75 -0
  35. package/src/execution/__tests__/pi-invocation.test.ts +73 -0
  36. package/src/execution/__tests__/record-store.test.ts +545 -0
  37. package/src/execution/__tests__/run-spawn-edges.test.ts +439 -0
  38. package/src/execution/__tests__/run-spawn-integration.test.ts +897 -0
  39. package/src/execution/__tests__/sdk-contract.test.ts +272 -0
  40. package/src/execution/__tests__/session-context-resolver.test.ts +167 -0
  41. package/src/execution/__tests__/session-file-gc.test.ts +247 -0
  42. package/src/execution/__tests__/session-reconstructor.test.ts +359 -0
  43. package/src/execution/__tests__/session-runner-schema-env.test.ts +314 -0
  44. package/src/execution/__tests__/session-start-reaper.test.ts +227 -0
  45. package/src/execution/__tests__/spawn-args.test.ts +244 -0
  46. package/src/execution/__tests__/spawn-event-adapter.test.ts +167 -0
  47. package/src/execution/__tests__/subagent-service.test.ts +678 -0
  48. package/src/execution/__tests__/subprocess-agent-runner.test.ts +389 -0
  49. package/src/execution/__tests__/temp-prompt.test.ts +53 -0
  50. package/src/execution/__tests__/timeout-integration.test.ts +381 -0
  51. package/src/execution/__tests__/tombstone-store.test.ts +73 -0
  52. package/src/execution/__tests__/tool-action.test.ts +330 -0
  53. package/src/execution/__tests__/turn-limiter.test.ts +65 -0
  54. package/src/execution/__tests__/worktree-manager.test.ts +423 -0
  55. package/src/execution/__tests__/worktree-registry.test.ts +161 -0
  56. package/src/execution/agent-registry.ts +252 -0
  57. package/src/execution/agent-result-mapper.ts +84 -0
  58. package/src/execution/alive-store.ts +92 -0
  59. package/src/execution/best-effort.ts +30 -0
  60. package/src/execution/concurrency-pool.ts +84 -0
  61. package/src/execution/config.ts +73 -0
  62. package/src/execution/execute-options-mapper.ts +86 -0
  63. package/src/execution/execution-record.ts +778 -0
  64. package/src/execution/finalized-marker.ts +51 -0
  65. package/src/execution/model-config-service.ts +225 -0
  66. package/src/execution/model-resolver.ts +247 -0
  67. package/src/execution/notifier.ts +168 -0
  68. package/src/execution/output-collector.ts +88 -0
  69. package/src/execution/path-encoding.ts +34 -0
  70. package/src/execution/pi-invocation.ts +70 -0
  71. package/src/execution/record-store.ts +350 -0
  72. package/src/execution/session-context-resolver.ts +64 -0
  73. package/src/execution/session-file-gc.ts +98 -0
  74. package/src/execution/session-reconstructor.ts +450 -0
  75. package/src/execution/session-runner.ts +725 -0
  76. package/src/execution/spawn-event-adapter.ts +150 -0
  77. package/src/execution/subagent-service.ts +973 -0
  78. package/src/execution/subprocess-agent-runner.ts +108 -0
  79. package/src/execution/temp-prompt.ts +57 -0
  80. package/src/execution/tombstone-store.ts +72 -0
  81. package/src/execution/turn-limiter.ts +88 -0
  82. package/src/execution/types.ts +634 -0
  83. package/src/execution/worktree-manager.ts +285 -0
  84. package/src/execution/worktree-registry.ts +144 -0
  85. package/src/index.ts +454 -0
  86. package/src/interface/bg-notify-render.ts +286 -0
  87. package/src/interface/commands.ts +157 -0
  88. package/src/interface/format.ts +501 -0
  89. package/src/interface/gui-adapter.ts +136 -0
  90. package/src/interface/helpers.ts +110 -0
  91. package/src/interface/list-component.ts +643 -0
  92. package/src/interface/list-shared.ts +84 -0
  93. package/src/interface/list-view.ts +373 -0
  94. package/src/interface/reentry-guard.ts +30 -0
  95. package/src/interface/subagent-actions.ts +294 -0
  96. package/src/interface/subagent-tool.ts +294 -0
  97. package/src/interface/subagents.ts +30 -0
  98. package/src/interface/tool-render.ts +333 -0
  99. package/src/interface/tool-workflow-script.ts +351 -0
  100. package/src/interface/tool-workflow.ts +485 -0
  101. package/src/interface/views/WorkflowsView.ts +944 -0
  102. package/src/interface/views/detail-content.ts +298 -0
  103. package/src/interface/views/format.ts +320 -0
  104. package/src/orchestration/__tests__/concurrency-gate.test.ts +125 -0
  105. package/src/orchestration/__tests__/config-loader.test.ts +381 -0
  106. package/src/orchestration/__tests__/error-recovery-handlers.test.ts +332 -0
  107. package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +166 -0
  108. package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +248 -0
  109. package/src/orchestration/__tests__/lifecycle.test.ts +385 -0
  110. package/src/orchestration/__tests__/script-lint.test.ts +347 -0
  111. package/src/orchestration/__tests__/worker-script-builder.test.ts +42 -0
  112. package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +319 -0
  113. package/src/orchestration/agent-opts-resolver.ts +128 -0
  114. package/src/orchestration/concurrency-gate.ts +69 -0
  115. package/src/orchestration/config-loader.ts +313 -0
  116. package/src/orchestration/error-recovery.ts +578 -0
  117. package/src/orchestration/execute-agent-call.ts +174 -0
  118. package/src/orchestration/jsonl-run-store.ts +292 -0
  119. package/src/orchestration/launcher.ts +368 -0
  120. package/src/orchestration/lifecycle.ts +373 -0
  121. package/src/orchestration/models/__tests__/budget.test.ts +367 -0
  122. package/src/orchestration/models/agent-call.ts +76 -0
  123. package/src/orchestration/models/budget.ts +148 -0
  124. package/src/orchestration/models/ports.ts +165 -0
  125. package/src/orchestration/models/run-runtime.ts +91 -0
  126. package/src/orchestration/models/run-spec.ts +54 -0
  127. package/src/orchestration/models/run-state.ts +44 -0
  128. package/src/orchestration/models/trace.ts +102 -0
  129. package/src/orchestration/models/types.ts +242 -0
  130. package/src/orchestration/models/workflow-run.ts +275 -0
  131. package/src/orchestration/models/workflow-script-registry.ts +32 -0
  132. package/src/orchestration/models/workflow-script.ts +90 -0
  133. package/src/orchestration/node-ops.ts +192 -0
  134. package/src/orchestration/script-lint.ts +387 -0
  135. package/src/orchestration/skill-discovery.ts +60 -0
  136. package/src/orchestration/worker-handle.ts +115 -0
  137. package/src/orchestration/worker-host.ts +93 -0
  138. package/src/orchestration/worker-script-builder.ts +281 -0
  139. package/src/orchestration/workflow-files.ts +85 -0
  140. package/src/orchestration/workflow-script-registry-impl.ts +128 -0
  141. package/src/shared/__tests__/resource-discovery.test.ts +226 -0
  142. package/src/shared/agent-event.ts +13 -0
  143. package/src/shared/resource-discovery.ts +535 -0
@@ -0,0 +1,332 @@
1
+ /**
2
+ * error-recovery handlers — handleWorkerExit/Error/ScriptError + postBudgetUpdate 测试。
3
+ *
4
+ * 参考 error-recovery-workflow-call.test.ts 的 mock 构建。通过 vi.useFakeTimers() 跳过
5
+ * scheduleRebuild 的指数退避(1s/2s/4s)。
6
+ *
7
+ * 覆盖:
8
+ * - handleWorkerExit:code=0 正常退出(no-op) / code!=0 委托 handleWorkerError / stale handle 过滤
9
+ * - handleWorkerError:超限(count > MAX=3)→ transition done,failed + emit pending:unregister
10
+ * / 未超限 → rebuildRuntime(workerHost.start 重建)
11
+ * - handleScriptError:超限 → transition done,failed / workerLogs 捕获
12
+ * - postBudgetUpdate:postMessage budget-update(usedTokens/usedCost)
13
+ * - stale handle 过滤(handle.isCurrent=false)+ paused/terminal stale 守卫
14
+ * - rebuildRuntime:worker 崩溃后 workerHost.start + scheduleTimeBudget 重排 + replaceRuntime
15
+ */
16
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
17
+
18
+ import {
19
+ handleScriptError,
20
+ handleWorkerError,
21
+ handleWorkerExit,
22
+ postBudgetUpdate,
23
+ rebuildRuntime,
24
+ } from "../error-recovery.ts";
25
+ import type { LifecycleDeps, WorkerHandlers } from "../models/ports.ts";
26
+ import type { WorkflowRun } from "../models/workflow-run.ts";
27
+ import type { WorkerHandle } from "../worker-handle.ts";
28
+
29
+ // ── helpers ──────────────────────────────────────────────────
30
+
31
+ /** 构造一个 status="running" 的 mock WorkflowRun,meta 可配置。 */
32
+ function makeRunningRun(opts: {
33
+ workerErrorCount?: number;
34
+ scriptErrorCount?: number;
35
+ budgetTimeMs?: number;
36
+ postMessage?: ReturnType<typeof vi.fn>;
37
+ } = {}): WorkflowRun {
38
+ return {
39
+ state: {
40
+ status: "running",
41
+ budget: { usedTokens: 50, usedCost: 0.1 },
42
+ },
43
+ meta: {
44
+ startedAt: new Date().toISOString(),
45
+ workerErrorCount: opts.workerErrorCount,
46
+ scriptErrorCount: opts.scriptErrorCount,
47
+ },
48
+ spec: {
49
+ scriptName: "test-wf",
50
+ scriptSource: "execute() {}",
51
+ args: {},
52
+ budgetTimeMs: opts.budgetTimeMs,
53
+ },
54
+ runtime: {
55
+ worker: { postMessage: opts.postMessage ?? vi.fn() },
56
+ },
57
+ // transition 副作用——run.state.status 由调用方通过 mock 控制后再次断言
58
+ transition(target: string, reason?: string): void {
59
+ this.state.status = target;
60
+ if (target === "done") this.state.reason = reason;
61
+ },
62
+ replaceRuntime(rt: unknown): void {
63
+ this.runtime = rt;
64
+ },
65
+ } as unknown as WorkflowRun;
66
+ }
67
+
68
+ /** LifecycleDeps mock:store/workerHost/runner/eventBus/scheduleTimeBudget 可观察。 */
69
+ function makeDeps(opts: {
70
+ scheduleTimeBudget?: LifecycleDeps["scheduleTimeBudget"];
71
+ } = {}): LifecycleDeps & {
72
+ store: { save: ReturnType<typeof vi.fn> };
73
+ workerHost: { start: ReturnType<typeof vi.fn> };
74
+ eventBus: { emit: ReturnType<typeof vi.fn> };
75
+ onRunDone: ReturnType<typeof vi.fn>;
76
+ log: ReturnType<typeof vi.fn>;
77
+ } {
78
+ return {
79
+ store: { save: vi.fn(async () => {}) },
80
+ workerHost: { start: vi.fn(() => ({ postMessage: vi.fn() })) },
81
+ runner: { run: vi.fn(async () => ({})) },
82
+ runs: new Map(),
83
+ eventBus: { emit: vi.fn() },
84
+ onRunDone: vi.fn(),
85
+ log: vi.fn(),
86
+ scheduleTimeBudget: opts.scheduleTimeBudget,
87
+ } as unknown as ReturnType<typeof makeDeps>;
88
+ }
89
+
90
+ /** WorkerHandlers 占位(handler 路径递归调本对象上的回调,但测试场景不触发)。 */
91
+ function makeHandlers(): WorkerHandlers {
92
+ return {
93
+ onMessage: vi.fn(async () => {}),
94
+ onError: vi.fn(async () => {}),
95
+ onExit: vi.fn(async () => {}),
96
+ } as unknown as WorkerHandlers;
97
+ }
98
+
99
+ /** 构造 mock WorkerHandle(isCurrent 可配)。 */
100
+ function makeHandle(isCurrent = true): WorkerHandle {
101
+ return { isCurrent } as unknown as WorkerHandle;
102
+ }
103
+
104
+ beforeEach(() => {
105
+ vi.useFakeTimers();
106
+ });
107
+
108
+ afterEach(() => {
109
+ vi.useRealTimers();
110
+ });
111
+
112
+ // ── handleWorkerExit ─────────────────────────────────────────
113
+
114
+ describe("handleWorkerExit", () => {
115
+ it("code=0 正常退出:no-op(不 transition、不 save)", async () => {
116
+ const run = makeRunningRun();
117
+ const deps = makeDeps();
118
+ const handle = makeHandle(true);
119
+
120
+ await handleWorkerExit(run, 0, handle, deps, makeHandlers());
121
+
122
+ expect(run.state.status).toBe("running"); // 未改
123
+ expect(deps.store.save).not.toHaveBeenCalled();
124
+ expect(deps.eventBus.emit).not.toHaveBeenCalled();
125
+ });
126
+
127
+ it("code!=0 异常退出:委托 handleWorkerError → 超 MAX 重试 → transition done,failed", async () => {
128
+ // workerErrorCount 已达 MAX=3 → handleWorkerError 内 count=4 > 3 → failed
129
+ const run = makeRunningRun({ workerErrorCount: 3 });
130
+ const deps = makeDeps();
131
+ const handle = makeHandle(true);
132
+
133
+ await handleWorkerExit(run, 1, handle, deps, makeHandlers());
134
+
135
+ expect(run.state.status).toBe("done");
136
+ expect(run.state.reason).toBe("failed");
137
+ expect(run.state.error).toContain("Worker exited with code 1");
138
+ // 持久化 + 完成通知
139
+ expect(deps.store.save).toHaveBeenCalledTimes(1);
140
+ expect(deps.onRunDone).toHaveBeenCalledTimes(1);
141
+ });
142
+
143
+ it("stale handle(isCurrent=false):丢弃 exit 事件,不处理", async () => {
144
+ const run = makeRunningRun();
145
+ const deps = makeDeps();
146
+ const staleHandle = makeHandle(false);
147
+
148
+ await handleWorkerExit(run, 1, staleHandle, deps, makeHandlers());
149
+
150
+ // 状态未变,store 未 save
151
+ expect(run.state.status).toBe("running");
152
+ expect(deps.store.save).not.toHaveBeenCalled();
153
+ });
154
+
155
+ it("run 已终态(done):stale 守卫前置丢弃", async () => {
156
+ const run = makeRunningRun();
157
+ run.state.status = "done";
158
+ (run.state as { reason?: string }).reason = "completed";
159
+ const deps = makeDeps();
160
+ const handle = makeHandle(true);
161
+
162
+ await handleWorkerExit(run, 1, handle, deps, makeHandlers());
163
+
164
+ expect(deps.store.save).not.toHaveBeenCalled();
165
+ });
166
+ });
167
+
168
+ // ── handleWorkerError ────────────────────────────────────────
169
+
170
+ describe("handleWorkerError", () => {
171
+ it("count > MAX(3):transition done,failed + save + emit pending:unregister", async () => {
172
+ // workerErrorCount=3 → count=4 > MAX
173
+ const run = makeRunningRun({ workerErrorCount: 3 });
174
+ const deps = makeDeps();
175
+
176
+ await handleWorkerError(run, new Error("worker boom"), deps, makeHandlers());
177
+
178
+ expect(run.meta.workerErrorCount).toBe(4);
179
+ expect(run.state.status).toBe("done");
180
+ expect(run.state.reason).toBe("failed");
181
+ expect(run.state.error).toBe("worker boom");
182
+ expect(deps.store.save).toHaveBeenCalledTimes(1);
183
+ expect(deps.eventBus.emit).toHaveBeenCalledWith("pending:unregister", {
184
+ id: undefined, // mock run 无 runId
185
+ reason: "failed",
186
+ });
187
+ expect(deps.onRunDone).toHaveBeenCalledTimes(1);
188
+ });
189
+
190
+ it("count <= MAX:退避 + rebuildRuntime(workerHost.start 重建新 runtime)", async () => {
191
+ const run = makeRunningRun({ workerErrorCount: 0 }); // count=1 <= MAX
192
+ const deps = makeDeps();
193
+
194
+ const promise = handleWorkerError(run, new Error("transient"), deps, makeHandlers());
195
+
196
+ // 推进指数退避(第 1 次重试:1s)
197
+ await vi.advanceTimersByTimeAsync(1000);
198
+ await promise;
199
+
200
+ expect(run.meta.workerErrorCount).toBe(1);
201
+ // 状态仍 running(重试不改 status)
202
+ expect(run.state.status).toBe("running");
203
+ // workerHost.start 被调(rebuildRuntime 内重建 worker)
204
+ expect(deps.workerHost.start).toHaveBeenCalledTimes(1);
205
+ });
206
+
207
+ it("paused 状态:stale 守卫前置丢弃(不递增 workerErrorCount)", async () => {
208
+ const run = makeRunningRun();
209
+ run.state.status = "paused";
210
+ const deps = makeDeps();
211
+
212
+ await handleWorkerError(run, new Error("stale"), deps, makeHandlers());
213
+
214
+ expect(run.meta.workerErrorCount).toBeUndefined(); // 未递增
215
+ expect(deps.store.save).not.toHaveBeenCalled();
216
+ });
217
+ });
218
+
219
+ // ── handleScriptError ────────────────────────────────────────
220
+
221
+ describe("handleScriptError", () => {
222
+ it("count > MAX(3):transition done,failed + 捕获 workerLogs", async () => {
223
+ const run = makeRunningRun({ scriptErrorCount: 3 }); // count=4 > MAX
224
+ const deps = makeDeps();
225
+ const workerLogs = [
226
+ { level: "error" as const, message: "line 5 boom" },
227
+ ];
228
+
229
+ await handleScriptError(run, "TypeError: x is undefined", workerLogs, deps, makeHandlers());
230
+
231
+ expect(run.meta.scriptErrorCount).toBe(4);
232
+ expect(run.state.status).toBe("done");
233
+ expect(run.state.reason).toBe("failed");
234
+ expect(run.state.error).toContain("Workflow failed after 3 retries");
235
+ expect(run.state.error).toContain("TypeError: x is undefined");
236
+ // workerLogs 捕获到 errorLogs
237
+ expect(run.state.errorLogs).toEqual(workerLogs);
238
+ expect(deps.store.save).toHaveBeenCalledTimes(1);
239
+ expect(deps.onRunDone).toHaveBeenCalledTimes(1);
240
+ });
241
+
242
+ it("count <= MAX:退避 + rebuildRuntime", async () => {
243
+ const run = makeRunningRun({ scriptErrorCount: 1 }); // count=2 <= MAX
244
+ const deps = makeDeps();
245
+
246
+ const promise = handleScriptError(run, "ReferenceError", [], deps, makeHandlers());
247
+
248
+ // 第 2 次重试退避:2s
249
+ await vi.advanceTimersByTimeAsync(2000);
250
+ await promise;
251
+
252
+ expect(run.meta.scriptErrorCount).toBe(2);
253
+ expect(run.state.status).toBe("running");
254
+ expect(deps.workerHost.start).toHaveBeenCalledTimes(1);
255
+ });
256
+
257
+ it("terminal 状态:stale 守卫前置丢弃", async () => {
258
+ const run = makeRunningRun();
259
+ run.state.status = "done";
260
+ (run.state as { reason?: string }).reason = "completed";
261
+ const deps = makeDeps();
262
+
263
+ await handleScriptError(run, "late error", [], deps, makeHandlers());
264
+
265
+ expect(run.meta.scriptErrorCount).toBeUndefined();
266
+ expect(deps.store.save).not.toHaveBeenCalled();
267
+ });
268
+ });
269
+
270
+ // ── postBudgetUpdate ─────────────────────────────────────────
271
+
272
+ describe("postBudgetUpdate", () => {
273
+ it("向 worker postMessage budget-update(usedTokens/usedCost)", () => {
274
+ const postMessage = vi.fn();
275
+ const run = makeRunningRun({ postMessage });
276
+
277
+ postBudgetUpdate(run);
278
+
279
+ expect(postMessage).toHaveBeenCalledWith({
280
+ type: "budget-update",
281
+ budget: { usedTokens: 50, usedCost: 0.1 },
282
+ });
283
+ });
284
+
285
+ it("runtime 不存在时 no-op(不抛错)", () => {
286
+ const run = makeRunningRun();
287
+ // runtime.worker.postMessage 为 undefined 时应安全
288
+ run.runtime = undefined;
289
+
290
+ expect(() => postBudgetUpdate(run)).not.toThrow();
291
+ });
292
+ });
293
+
294
+ // ── rebuildRuntime ───────────────────────────────────────────
295
+
296
+ describe("rebuildRuntime", () => {
297
+ it("worker 崩溃后重建:workerHost.start + replaceRuntime(保持 running)", () => {
298
+ const run = makeRunningRun({ budgetTimeMs: 0 }); // 无时间预算
299
+ const deps = makeDeps();
300
+
301
+ rebuildRuntime(run, deps, makeHandlers());
302
+
303
+ // workerHost.start 被调(构造新 worker)
304
+ expect(deps.workerHost.start).toHaveBeenCalledTimes(1);
305
+ // replaceRuntime 被调(新 runtime 绑定,mock 内仅替换 runtime 字段)
306
+ expect(run.runtime).toBeDefined();
307
+ // status 仍 running(replaceRuntime 不改 status)
308
+ expect(run.state.status).toBe("running");
309
+ });
310
+
311
+ it("带 budgetTimeMs 时重排 scheduleTimeBudget 计时器", () => {
312
+ const run = makeRunningRun({ budgetTimeMs: 5000 });
313
+ const scheduleTimeBudget = vi.fn(() => undefined);
314
+ const deps = makeDeps({ scheduleTimeBudget });
315
+
316
+ rebuildRuntime(run, deps, makeHandlers());
317
+
318
+ // D-12 regression fix (round-2 #2):replaceRuntime 后重排时间预算
319
+ expect(scheduleTimeBudget).toHaveBeenCalledTimes(1);
320
+ // 第 1 参 = runId(mock run 无 runId),第 2 参 = budgetTimeMs
321
+ const args = scheduleTimeBudget.mock.calls[0]!;
322
+ expect(args[1]).toBe(5000);
323
+ });
324
+
325
+ it("无 scheduleTimeBudget 注入时不重排(向后兼容,不抛错)", () => {
326
+ const run = makeRunningRun({ budgetTimeMs: 5000 });
327
+ const deps = makeDeps({ scheduleTimeBudget: undefined });
328
+
329
+ expect(() => rebuildRuntime(run, deps, makeHandlers())).not.toThrow();
330
+ expect(deps.workerHost.start).toHaveBeenCalledTimes(1);
331
+ });
332
+ });
@@ -0,0 +1,166 @@
1
+ /**
2
+ * dispatchWorkflowCall — workflow-call 消息路由测试。
3
+ *
4
+ * 通过 handleWorkerMessage 触发 workflow-call case,验证:
5
+ * - onWorkflowCall 回调被正确调用(name + args + parentRun)
6
+ * - 成功时 postMessage(workflow-result, result)
7
+ * - onWorkflowCall reject 时 postMessage 含 error
8
+ * - onWorkflowCall 未注入时 postMessage 含 error(向后兼容)
9
+ * - stale 完成守卫(resolve 前 run 已 paused → 不 postMessage)
10
+ */
11
+ import { describe, expect, it, vi } from "vitest";
12
+
13
+ import { handleWorkerMessage } from "../error-recovery.ts";
14
+ import type { LifecycleDeps, WorkerHandlers } from "../models/ports.ts";
15
+ import type { WorkflowRun } from "../models/workflow-run.ts";
16
+
17
+ // ── helpers ──────────────────────────────────────────────────
18
+
19
+ /** flush microtask 队列,让 void .then().catch() 链路跑完。 */
20
+ async function flushMicrotasks(): Promise<void> {
21
+ await new Promise((resolve) => setTimeout(resolve, 0));
22
+ }
23
+
24
+ /** 构造一个 status="running" 的 mock WorkflowRun,postMessage 可观测。 */
25
+ function makeRunningRun(postMessage: ReturnType<typeof vi.fn>): WorkflowRun {
26
+ return {
27
+ state: { status: "running" },
28
+ runtime: { worker: { postMessage } },
29
+ } as unknown as WorkflowRun;
30
+ }
31
+
32
+ /** LifecycleDeps 只需 onWorkflowCall(dispatchWorkflowCall 唯一消费的 dep)。 */
33
+ function makeDeps(onWorkflowCall?: LifecycleDeps["onWorkflowCall"]): LifecycleDeps {
34
+ return { onWorkflowCall } as unknown as LifecycleDeps;
35
+ }
36
+
37
+ /** WorkerHandlers 占位(workflow-call 路径不触发 handler 回调)。 */
38
+ function makeHandlers(): WorkerHandlers {
39
+ return {
40
+ onMessage: vi.fn(async () => {}),
41
+ onError: vi.fn(async () => {}),
42
+ onExit: vi.fn(async () => {}),
43
+ } as unknown as WorkerHandlers;
44
+ }
45
+
46
+ interface PostedMsg {
47
+ type: string;
48
+ callId: number;
49
+ result: { content: string; error?: string };
50
+ }
51
+
52
+ /** 从 postMessage mock 取第 0 次调用的第 0 参,类型安全窄化。 */
53
+ function firstPosted(postMessage: ReturnType<typeof vi.fn>): PostedMsg {
54
+ return postMessage.mock.calls[0]![0] as PostedMsg;
55
+ }
56
+
57
+ // ── tests ────────────────────────────────────────────────────
58
+
59
+ describe("dispatchWorkflowCall (workflow-call routing)", () => {
60
+ it("calls onWorkflowCall with name and args", async () => {
61
+ const postMessage = vi.fn();
62
+ const onWorkflowCall = vi.fn(async () => ({ content: "ok" }));
63
+ const run = makeRunningRun(postMessage);
64
+ const deps = makeDeps(onWorkflowCall);
65
+
66
+ await handleWorkerMessage(
67
+ run,
68
+ { type: "workflow-call", callId: 1, name: "sub", args: { k: 1 } },
69
+ deps,
70
+ makeHandlers(),
71
+ );
72
+ await flushMicrotasks();
73
+
74
+ expect(onWorkflowCall).toHaveBeenCalledTimes(1);
75
+ expect(onWorkflowCall).toHaveBeenCalledWith("sub", { k: 1 }, run);
76
+ });
77
+
78
+ it("posts workflow-result on success", async () => {
79
+ const postMessage = vi.fn();
80
+ const onWorkflowCall = vi.fn(async () => ({ content: "result-data" }));
81
+ const run = makeRunningRun(postMessage);
82
+ const deps = makeDeps(onWorkflowCall);
83
+
84
+ await handleWorkerMessage(
85
+ run,
86
+ { type: "workflow-call", callId: 2, name: "sub", args: {} },
87
+ deps,
88
+ makeHandlers(),
89
+ );
90
+ await flushMicrotasks();
91
+
92
+ expect(postMessage).toHaveBeenCalledWith({
93
+ type: "workflow-result",
94
+ callId: 2,
95
+ result: { content: "result-data" },
96
+ });
97
+ });
98
+
99
+ it("posts error result when onWorkflowCall rejects", async () => {
100
+ const postMessage = vi.fn();
101
+ const onWorkflowCall = vi.fn(async () => {
102
+ throw new Error("boom");
103
+ });
104
+ const run = makeRunningRun(postMessage);
105
+ const deps = makeDeps(onWorkflowCall);
106
+
107
+ await handleWorkerMessage(
108
+ run,
109
+ { type: "workflow-call", callId: 3, name: "sub", args: {} },
110
+ deps,
111
+ makeHandlers(),
112
+ );
113
+ await flushMicrotasks();
114
+
115
+ expect(postMessage).toHaveBeenCalledTimes(1);
116
+ const sent = firstPosted(postMessage);
117
+ expect(sent.type).toBe("workflow-result");
118
+ expect(sent.callId).toBe(3);
119
+ expect(sent.result.error).toBe("boom");
120
+ });
121
+
122
+ it("posts error result when onWorkflowCall not injected", async () => {
123
+ const postMessage = vi.fn();
124
+ const run = makeRunningRun(postMessage);
125
+ const deps = makeDeps(undefined);
126
+
127
+ await handleWorkerMessage(
128
+ run,
129
+ { type: "workflow-call", callId: 4, name: "sub", args: {} },
130
+ deps,
131
+ makeHandlers(),
132
+ );
133
+ await flushMicrotasks();
134
+
135
+ expect(postMessage).toHaveBeenCalledTimes(1);
136
+ const sent = firstPosted(postMessage);
137
+ expect(sent.type).toBe("workflow-result");
138
+ expect(sent.result.error).toContain("onWorkflowCall not injected");
139
+ });
140
+
141
+ it("does not post when run is paused before result arrives", async () => {
142
+ const postMessage = vi.fn();
143
+ let resolveWorkflow: (value: unknown) => void = () => {};
144
+ const workflowPromise = new Promise<unknown>((r) => {
145
+ resolveWorkflow = r;
146
+ });
147
+ const onWorkflowCall = vi.fn(() => workflowPromise);
148
+ const run = makeRunningRun(postMessage);
149
+ const deps = makeDeps(onWorkflowCall);
150
+
151
+ await handleWorkerMessage(
152
+ run,
153
+ { type: "workflow-call", callId: 5, name: "sub", args: {} },
154
+ deps,
155
+ makeHandlers(),
156
+ );
157
+
158
+ // dispatchWorkflowCall 已触发,onWorkflowCall pending。
159
+ // 在 resolve 前 pause run —— stale 完成守卫应阻止 postMessage。
160
+ run.state.status = "paused";
161
+ resolveWorkflow({ content: "late" });
162
+ await flushMicrotasks();
163
+
164
+ expect(postMessage).not.toHaveBeenCalled();
165
+ });
166
+ });