@zhushanwen/pi-subagent-workflow 0.4.1 → 0.4.2
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 +1 -1
- package/src/execution/__tests__/execute-nesting.test.ts +16 -0
- package/src/interface/__tests__/workflow-tool-prompt.test.ts +13 -2
- package/src/interface/tool-workflow-script.ts +4 -3
- package/src/interface/tool-workflow.ts +7 -4
- package/src/orchestration/__tests__/error-recovery-postmessage-defense.test.ts +390 -0
- package/src/orchestration/__tests__/worker-script-builder.test.ts +74 -0
- package/src/orchestration/error-recovery.ts +77 -13
- package/src/orchestration/worker-script-builder.ts +49 -8
- package/workflows/README.md +5 -3
- package/workflows/chain.js +2 -2
- package/workflows/map-reduce.js +10 -6
- package/workflows/parallel.js +17 -27
- package/workflows/scatter-gather.js +15 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-subagent-workflow",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"description": "Unified subagent execution and multi-agent workflow orchestration for Pi — spawned-process agent runtime with sync/background modes, stateful workflow management with persistence, state machine, and execution tracing.",
|
|
@@ -105,6 +105,22 @@ vi.mock("../finalized-marker.ts", () => ({
|
|
|
105
105
|
readFinalized: vi.fn(() => false),
|
|
106
106
|
}));
|
|
107
107
|
|
|
108
|
+
// manifest-store mock:writeManifest 用真实 fs.promises 写盘,但本文件 fs 同步方法已 mock
|
|
109
|
+
// (目录从不真实创建)→ open/rename/unlink 全 ENOENT → bestEffort 异步 console.debug。
|
|
110
|
+
// 这些延迟 console 通过 worker RPC(onUserConsoleLog)回流,与 vitest teardown 形成 race:
|
|
111
|
+
// "Closing rpc while onUserConsoleLog was pending" → unhandled rejection / exit 1(flaky)。
|
|
112
|
+
// 本组用例测编排逻辑(pool / depth / throttle),不测 manifest 持久化(有 manifest-store.test.ts
|
|
113
|
+
// 独立覆盖)。故 mock 成 no-op,消除 teardown race 的根因——异步 console 输出。
|
|
114
|
+
vi.mock("../manifest-store.ts", () => {
|
|
115
|
+
class FakeManifestStore {
|
|
116
|
+
writeManifest = vi.fn(async () => {});
|
|
117
|
+
readManifest = vi.fn(async () => null);
|
|
118
|
+
listAllSync = vi.fn(() => []);
|
|
119
|
+
recoverTmpFiles = vi.fn(async () => []);
|
|
120
|
+
}
|
|
121
|
+
return { ManifestStore: FakeManifestStore };
|
|
122
|
+
});
|
|
123
|
+
|
|
108
124
|
// temp-prompt:mock 掉真实 fs.promises I/O,消除 fake-timers 下的 flaky 竞态
|
|
109
125
|
// (详见 run-spawn-integration.test.ts 同名 mock 的注释)。
|
|
110
126
|
vi.mock("../temp-prompt.ts", () => ({
|
|
@@ -40,8 +40,8 @@ describe("U1: workflow tool prompt mentions built-in workflows", () => {
|
|
|
40
40
|
});
|
|
41
41
|
|
|
42
42
|
it("tool-workflow.ts promptGuidelines 含 run action 的正例", () => {
|
|
43
|
-
// 给出
|
|
44
|
-
expect(TOOL_WORKFLOW_SRC).
|
|
43
|
+
// 给出 run 调用的 JSON 示例,LLM 才知道参数格式(action/name/args 嵌套)。
|
|
44
|
+
expect(TOOL_WORKFLOW_SRC).toContain('{"action":"run","name":"');
|
|
45
45
|
});
|
|
46
46
|
|
|
47
47
|
it("promptGuidelines 含 JSON 调用正例(run/status/lifecycle)", () => {
|
|
@@ -69,4 +69,15 @@ describe("U1: workflow tool prompt mentions built-in workflows", () => {
|
|
|
69
69
|
// 反向交叉引用:list 的指引里要提到用 workflow tool 的 run action 启动脚本。
|
|
70
70
|
expect(TOOL_WORKFLOW_SCRIPT_SRC).toMatch(/workflow.*tool.*run|run.*workflow.*tool/i);
|
|
71
71
|
});
|
|
72
|
+
|
|
73
|
+
it("tool-workflow.ts promptGuidelines 强化 anti-generate(直接 run,不要 generate)", () => {
|
|
74
|
+
// session 证据:弱模型看到 workflow list 后倾向 workflow-script generate 而非直接 run。
|
|
75
|
+
// 提示词必须显式禁止对内置编排使用 generate。
|
|
76
|
+
expect(TOOL_WORKFLOW_SRC).toContain("NEVER use workflow-script action:generate");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("tool-workflow-script.ts promptGuidelines 强化 anti-generate(CRITICAL ANTI-PATTERN)", () => {
|
|
80
|
+
expect(TOOL_WORKFLOW_SCRIPT_SRC).toContain("CRITICAL ANTI-PATTERN");
|
|
81
|
+
expect(TOOL_WORKFLOW_SCRIPT_SRC).toContain("NEVER generate");
|
|
82
|
+
});
|
|
72
83
|
});
|
|
@@ -178,9 +178,10 @@ export function registerWorkflowScriptTool(
|
|
|
178
178
|
"Use this to discover built-in workflows (chain/parallel/scatter-gather/map-reduce) " +
|
|
179
179
|
"and user-generated scripts before starting a run. After listing, start a script via " +
|
|
180
180
|
"the workflow tool with action:run and the script name.",
|
|
181
|
-
"ANTI-PATTERN:
|
|
182
|
-
"
|
|
183
|
-
"
|
|
181
|
+
"CRITICAL ANTI-PATTERN: NEVER generate scripts for chain/parallel/scatter-gather/map-reduce. " +
|
|
182
|
+
"These are BUILT-IN — use the workflow tool with action:run directly. " +
|
|
183
|
+
"generate is for NOVEL orchestration patterns ONLY. When in doubt, action:list first, " +
|
|
184
|
+
"then action:run — not action:generate.",
|
|
184
185
|
],
|
|
185
186
|
parameters: WorkflowScriptParams,
|
|
186
187
|
|
|
@@ -235,12 +235,12 @@ export function registerWorkflowTool(
|
|
|
235
235
|
promptSnippet: "Run, pause, resume, abort, or check workflow status",
|
|
236
236
|
promptGuidelines: [
|
|
237
237
|
"PRIORITY: When user says 'workflow', 'run workflow', try run action FIRST.",
|
|
238
|
-
"BUILT-IN workflows
|
|
239
|
-
"chain (analyze→transform→synthesize
|
|
238
|
+
"BUILT-IN workflows — run DIRECTLY with action:run, do NOT use workflow-script generate for these: " +
|
|
239
|
+
"chain (sequential 3-step: analyze→transform→synthesize; args: task), " +
|
|
240
240
|
"parallel (multi-perspective analysis; args: target, optional perspectives), " +
|
|
241
|
-
"scatter-gather (split→parallel
|
|
241
|
+
"scatter-gather (split→parallel→merge; args: task), " +
|
|
242
242
|
"map-reduce (parallel map→reduce; args: items/itemsJson + operation). " +
|
|
243
|
-
"Example:
|
|
243
|
+
"Example: {\"action\":\"run\",\"name\":\"parallel\",\"args\":{\"target\":\"src/auth.ts\"}}.",
|
|
244
244
|
"DISCOVERY: If unsure what workflows exist, call the workflow-script tool with " +
|
|
245
245
|
"action:list first — it returns all available scripts (built-in + user-generated) " +
|
|
246
246
|
"with source tags and descriptions. Then use this tool's run action to start one.",
|
|
@@ -251,6 +251,9 @@ export function registerWorkflowTool(
|
|
|
251
251
|
"- status: {\"action\":\"status\"}. " +
|
|
252
252
|
"- pause/resume/abort: {\"action\":\"pause\",\"runId\":\"<id>\"} (abort optional: ,\"error\":\"<reason>\"}).",
|
|
253
253
|
"Anti-patterns: Flattening args sub-fields (task/items/...) to the top level — they belong inside args. Calling {\"action\":\"run\"} without name.",
|
|
254
|
+
"CRITICAL: For chain/parallel/scatter-gather/map-reduce orchestration, ALWAYS use action:run with the built-in name. " +
|
|
255
|
+
"NEVER use workflow-script action:generate to create these patterns — they already exist. " +
|
|
256
|
+
"workflow-script generate is ONLY for novel patterns not covered by built-ins.",
|
|
254
257
|
],
|
|
255
258
|
parameters: WorkflowParams,
|
|
256
259
|
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
// src/orchestration/__tests__/error-recovery-postmessage-defense.test.ts
|
|
2
|
+
//
|
|
3
|
+
// W2: 主线程层 postMessage 防御测试。
|
|
4
|
+
//
|
|
5
|
+
// 背景:主线程有 3 处 run.runtime?.worker.postMessage(...) 调用(经 helper 函数),
|
|
6
|
+
// 全部没有 try/catch。若 result 对象含不可克隆成员(function/Symbol/循环引用),
|
|
7
|
+
// postMessage 同步抛 DataCloneError:
|
|
8
|
+
// - postAgentResult:result 是 agent 返回值,不可克隆概率最高。DataCloneError 冒泡到
|
|
9
|
+
// dispatchAgentCall 的 .then 回调,中断后续 postBudgetUpdate/store.save/budget 检查,
|
|
10
|
+
// run 卡在 running。
|
|
11
|
+
// - postBudgetUpdate:payload 是 number,风险低但无兜底。
|
|
12
|
+
// - dispatchWorkflowCall 的 postResult:result 是子 workflow 任意返回值。
|
|
13
|
+
//
|
|
14
|
+
// 修复后三处均包 try/catch + fallback。本测试通过 mock worker.postMessage 抛
|
|
15
|
+
// DataCloneError,验证:
|
|
16
|
+
// 1. 调用方不抛错(流程不中断)
|
|
17
|
+
// 2. fallback result(纯字符串,必可克隆)被发送
|
|
18
|
+
// 3. console.error 记录诊断
|
|
19
|
+
|
|
20
|
+
import { readFileSync } from "node:fs";
|
|
21
|
+
import { dirname, join } from "node:path";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
|
|
24
|
+
import { describe, expect, it, vi } from "vitest";
|
|
25
|
+
|
|
26
|
+
import { handleWorkerMessage, postBudgetUpdate } from "../error-recovery.ts";
|
|
27
|
+
import type { LifecycleDeps, WorkerHandlers } from "../models/ports.ts";
|
|
28
|
+
import type { WorkflowRun } from "../models/workflow-run.ts";
|
|
29
|
+
|
|
30
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
31
|
+
const ERROR_RECOVERY_SRC = readFileSync(
|
|
32
|
+
join(__dirname, "../error-recovery.ts"),
|
|
33
|
+
"utf-8",
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
// ── helpers ──────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
/** flush microtask 队列,让 void .then().catch() 链路跑完。 */
|
|
39
|
+
async function flushMicrotasks(): Promise<void> {
|
|
40
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 构造一个 postMessage mock:前 n 次抛 DataCloneError,之后正常(模拟 fallback 成功)。 */
|
|
44
|
+
function makeFailingPostMessage(failTimes: number): ReturnType<typeof vi.fn> {
|
|
45
|
+
let calls = 0;
|
|
46
|
+
return vi.fn(() => {
|
|
47
|
+
calls += 1;
|
|
48
|
+
if (calls <= failTimes) {
|
|
49
|
+
const err = new Error("Could not clone object: function found");
|
|
50
|
+
err.name = "DataCloneError";
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
// fallback 成功路径:no-op
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 构造一个永远抛 DataCloneError 的 postMessage mock(fallback 也失败)。 */
|
|
58
|
+
function makeAlwaysFailingPostMessage(): ReturnType<typeof vi.fn> {
|
|
59
|
+
return vi.fn(() => {
|
|
60
|
+
const err = new Error("Could not clone object");
|
|
61
|
+
err.name = "DataCloneError";
|
|
62
|
+
throw err;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 构造 status="running" 的 mock WorkflowRun,postMessage 由调用方注入。 */
|
|
67
|
+
function makeRunningRun(postMessage: ReturnType<typeof vi.fn>): WorkflowRun {
|
|
68
|
+
return {
|
|
69
|
+
state: { status: "running" },
|
|
70
|
+
runtime: { worker: { postMessage } },
|
|
71
|
+
} as unknown as WorkflowRun;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** LifecycleDeps 只需 onWorkflowCall(dispatchWorkflowCall 唯一消费的 dep)。 */
|
|
75
|
+
function makeDeps(onWorkflowCall?: LifecycleDeps["onWorkflowCall"]): LifecycleDeps {
|
|
76
|
+
return { onWorkflowCall } as unknown as LifecycleDeps;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** WorkerHandlers 占位(workflow-call 路径不触发 handler 回调)。 */
|
|
80
|
+
function makeHandlers(): WorkerHandlers {
|
|
81
|
+
return {
|
|
82
|
+
onMessage: vi.fn(async () => {}),
|
|
83
|
+
onError: vi.fn(async () => {}),
|
|
84
|
+
onExit: vi.fn(async () => {}),
|
|
85
|
+
} as unknown as WorkerHandlers;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface PostedMsg {
|
|
89
|
+
type: string;
|
|
90
|
+
callId?: number;
|
|
91
|
+
result?: { content: string; error?: string };
|
|
92
|
+
budget?: { usedTokens: number; usedCost: number };
|
|
93
|
+
cached?: boolean;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 从 postMessage mock 取第 idx 次调用的第 0 参。 */
|
|
97
|
+
function postedAt(postMessage: ReturnType<typeof vi.fn>, idx: number): PostedMsg {
|
|
98
|
+
return postMessage.mock.calls[idx]![0] as PostedMsg;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 静默 console.error(防御路径会打印诊断,避免污染测试输出)。 */
|
|
102
|
+
function silenceConsoleError(): () => void {
|
|
103
|
+
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
104
|
+
return () => spy.mockRestore();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── W2a: postBudgetUpdate try/catch ──
|
|
108
|
+
|
|
109
|
+
describe("W2a: postBudgetUpdate 防御 DataCloneError", () => {
|
|
110
|
+
it("postMessage 抛错时不向上传播(流程不中断)", () => {
|
|
111
|
+
const restore = silenceConsoleError();
|
|
112
|
+
try {
|
|
113
|
+
const postMessage = makeAlwaysFailingPostMessage();
|
|
114
|
+
const run = {
|
|
115
|
+
state: {
|
|
116
|
+
status: "running",
|
|
117
|
+
budget: { usedTokens: 42, usedCost: 0.5 },
|
|
118
|
+
},
|
|
119
|
+
runtime: { worker: { postMessage } },
|
|
120
|
+
} as unknown as WorkflowRun;
|
|
121
|
+
|
|
122
|
+
// 不应抛错——防御性兜底
|
|
123
|
+
expect(() => postBudgetUpdate(run)).not.toThrow();
|
|
124
|
+
} finally {
|
|
125
|
+
restore();
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("postMessage 失败时记录 console.error 诊断", () => {
|
|
130
|
+
const restore = silenceConsoleError();
|
|
131
|
+
try {
|
|
132
|
+
const postMessage = makeAlwaysFailingPostMessage();
|
|
133
|
+
const run = {
|
|
134
|
+
state: {
|
|
135
|
+
status: "running",
|
|
136
|
+
budget: { usedTokens: 42, usedCost: 0.5 },
|
|
137
|
+
},
|
|
138
|
+
runtime: { worker: { postMessage } },
|
|
139
|
+
} as unknown as WorkflowRun;
|
|
140
|
+
|
|
141
|
+
postBudgetUpdate(run);
|
|
142
|
+
|
|
143
|
+
expect(console.error).toHaveBeenCalledTimes(1);
|
|
144
|
+
const diag = (console.error as ReturnType<typeof vi.fn>).mock.calls[0]![0] as string;
|
|
145
|
+
expect(diag).toContain("postBudgetUpdate failed");
|
|
146
|
+
expect(diag).toContain("Could not clone object");
|
|
147
|
+
} finally {
|
|
148
|
+
restore();
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("正常 payload(纯 number)成功发送", () => {
|
|
153
|
+
const postMessage = vi.fn();
|
|
154
|
+
const run = {
|
|
155
|
+
state: {
|
|
156
|
+
status: "running",
|
|
157
|
+
budget: { usedTokens: 42, usedCost: 0.5 },
|
|
158
|
+
},
|
|
159
|
+
runtime: { worker: { postMessage } },
|
|
160
|
+
} as unknown as WorkflowRun;
|
|
161
|
+
|
|
162
|
+
postBudgetUpdate(run);
|
|
163
|
+
|
|
164
|
+
expect(postMessage).toHaveBeenCalledTimes(1);
|
|
165
|
+
expect(postedAt(postMessage, 0)).toEqual({
|
|
166
|
+
type: "budget-update",
|
|
167
|
+
budget: { usedTokens: 42, usedCost: 0.5 },
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ── W2b: dispatchWorkflowCall postResult 闭包防御 ──
|
|
173
|
+
|
|
174
|
+
describe("W2b: dispatchWorkflowCall postResult 防御 DataCloneError", () => {
|
|
175
|
+
it("result 不可克隆 → 回发纯字符串 fallback result", async () => {
|
|
176
|
+
const restore = silenceConsoleError();
|
|
177
|
+
try {
|
|
178
|
+
// 第 1 次 postMessage 抛 DataCloneError(原始 result 不可克隆),
|
|
179
|
+
// 第 2 次(fallback)成功。
|
|
180
|
+
const postMessage = makeFailingPostMessage(1);
|
|
181
|
+
const onWorkflowCall = vi.fn(async () => ({ nonCloneable: () => {} }));
|
|
182
|
+
const run = makeRunningRun(postMessage);
|
|
183
|
+
const deps = makeDeps(onWorkflowCall);
|
|
184
|
+
|
|
185
|
+
await handleWorkerMessage(
|
|
186
|
+
run,
|
|
187
|
+
{ type: "workflow-call", callId: 9, name: "sub", args: {} },
|
|
188
|
+
deps,
|
|
189
|
+
makeHandlers(),
|
|
190
|
+
);
|
|
191
|
+
await flushMicrotasks();
|
|
192
|
+
|
|
193
|
+
// 调用两次:1 原始(失败)+ 1 fallback(成功)
|
|
194
|
+
expect(postMessage).toHaveBeenCalledTimes(2);
|
|
195
|
+
const fallback = postedAt(postMessage, 1);
|
|
196
|
+
expect(fallback.type).toBe("workflow-result");
|
|
197
|
+
expect(fallback.callId).toBe(9);
|
|
198
|
+
expect(fallback.result?.content).toBe("");
|
|
199
|
+
expect(fallback.result?.error).toContain("Workflow result serialization failed");
|
|
200
|
+
} finally {
|
|
201
|
+
restore();
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("postResult 不抛错到调用方(不中断 onWorkflowCall 链路)", async () => {
|
|
206
|
+
const restore = silenceConsoleError();
|
|
207
|
+
try {
|
|
208
|
+
const postMessage = makeAlwaysFailingPostMessage();
|
|
209
|
+
const onWorkflowCall = vi.fn(async () => ({ bad: () => {} }));
|
|
210
|
+
const run = makeRunningRun(postMessage);
|
|
211
|
+
const deps = makeDeps(onWorkflowCall);
|
|
212
|
+
|
|
213
|
+
// handleWorkerMessage 自身不应抛——postResult 内部已 catch
|
|
214
|
+
await expect(
|
|
215
|
+
handleWorkerMessage(
|
|
216
|
+
run,
|
|
217
|
+
{ type: "workflow-call", callId: 10, name: "sub", args: {} },
|
|
218
|
+
deps,
|
|
219
|
+
makeHandlers(),
|
|
220
|
+
),
|
|
221
|
+
).resolves.toBeUndefined();
|
|
222
|
+
await flushMicrotasks();
|
|
223
|
+
} finally {
|
|
224
|
+
restore();
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("fallback 也失败 → console.error 记录 worker pending 将挂起", async () => {
|
|
229
|
+
const restore = silenceConsoleError();
|
|
230
|
+
try {
|
|
231
|
+
const postMessage = makeAlwaysFailingPostMessage();
|
|
232
|
+
const onWorkflowCall = vi.fn(async () => ({ bad: () => {} }));
|
|
233
|
+
const run = makeRunningRun(postMessage);
|
|
234
|
+
const deps = makeDeps(onWorkflowCall);
|
|
235
|
+
|
|
236
|
+
await handleWorkerMessage(
|
|
237
|
+
run,
|
|
238
|
+
{ type: "workflow-call", callId: 11, name: "sub", args: {} },
|
|
239
|
+
deps,
|
|
240
|
+
makeHandlers(),
|
|
241
|
+
);
|
|
242
|
+
await flushMicrotasks();
|
|
243
|
+
|
|
244
|
+
// 至少 2 次尝试(原始 + fallback),fallback 失败也记日志
|
|
245
|
+
expect(postMessage.mock.calls.length).toBeGreaterThanOrEqual(2);
|
|
246
|
+
const errorCalls = (console.error as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0] as string);
|
|
247
|
+
expect(errorCalls.some((s) => s.includes("fallback also failed"))).toBe(true);
|
|
248
|
+
} finally {
|
|
249
|
+
restore();
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// ── W2c: postAgentResult 防御(经 handleWorkerMessage 触发) ──
|
|
255
|
+
//
|
|
256
|
+
// postAgentResult 是私有函数,通过 workflow-call 路径无法触发。但它可经 agent-call
|
|
257
|
+
// 路径的「cached replay」分支触发:dispatchAgentCall 发现 run.state.calls.get(callId)
|
|
258
|
+
// 已是 done 时,直接 postAgentResult(run, callId, cached.result, true)(无需 spawn
|
|
259
|
+
// 子进程)。利用这一点构造行为测试——往 cached.result 里塞不可克隆值(function),
|
|
260
|
+
// mock postMessage 在首次发送 agent-result 时抛 DataCloneError,验证 fallback 路径。
|
|
261
|
+
//
|
|
262
|
+
// 另外补充:验证 error-recovery.ts 源码中三处 postMessage 调用点都有 try/catch 包裹,
|
|
263
|
+
// 防止未来重构误删防御(类似 worker-script-builder.test.ts 的源码字符串断言模式)。
|
|
264
|
+
|
|
265
|
+
/** 构造 status="running" 且 calls 已含一个 done 结果(含不可克隆成员)的 mock run。
|
|
266
|
+
* 触发 dispatchAgentCall 的 cached replay 分支(line ~234),直接走 postAgentResult。 */
|
|
267
|
+
function makeRunningRunWithCachedDone(
|
|
268
|
+
postMessage: ReturnType<typeof vi.fn>,
|
|
269
|
+
callId: number,
|
|
270
|
+
result: unknown,
|
|
271
|
+
): WorkflowRun {
|
|
272
|
+
const calls = new Map();
|
|
273
|
+
calls.set(callId, { status: "done", result });
|
|
274
|
+
return {
|
|
275
|
+
state: {
|
|
276
|
+
status: "running",
|
|
277
|
+
calls,
|
|
278
|
+
trace: { append: vi.fn(), update: vi.fn() },
|
|
279
|
+
budget: { isExceeded: vi.fn(() => false) },
|
|
280
|
+
},
|
|
281
|
+
runtime: { worker: { postMessage } },
|
|
282
|
+
} as unknown as WorkflowRun;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
describe("W2c: postAgentResult 行为测试(cached replay 路径)", () => {
|
|
286
|
+
it("result 不可克隆 → 回发纯字符串 fallback result(cached: false)", async () => {
|
|
287
|
+
const restore = silenceConsoleError();
|
|
288
|
+
try {
|
|
289
|
+
// 第 1 次 postMessage 抛 DataCloneError(原始 cached.result 含 function 不可克隆),
|
|
290
|
+
// 第 2 次(fallback)成功。
|
|
291
|
+
const postMessage = makeFailingPostMessage(1);
|
|
292
|
+
// cached.result 含 function 成员 → 模拟 agent 返回不可克隆值({ bad: () => {} })
|
|
293
|
+
const run = makeRunningRunWithCachedDone(postMessage, 7, { bad: () => {} });
|
|
294
|
+
const deps = makeDeps();
|
|
295
|
+
|
|
296
|
+
await handleWorkerMessage(
|
|
297
|
+
run,
|
|
298
|
+
{ type: "agent-call", callId: 7, opts: { prompt: "noop" } },
|
|
299
|
+
deps,
|
|
300
|
+
makeHandlers(),
|
|
301
|
+
);
|
|
302
|
+
await flushMicrotasks();
|
|
303
|
+
|
|
304
|
+
// 调用两次:1 原始 agent-result(失败)+ 1 fallback(成功)
|
|
305
|
+
expect(postMessage).toHaveBeenCalledTimes(2);
|
|
306
|
+
|
|
307
|
+
// 第 1 次:原始 agent-result,result 透传原值,cached 为 replay 的 true
|
|
308
|
+
const original = postedAt(postMessage, 0);
|
|
309
|
+
expect(original.type).toBe("agent-result");
|
|
310
|
+
expect(original.callId).toBe(7);
|
|
311
|
+
expect(original.cached).toBe(true);
|
|
312
|
+
|
|
313
|
+
// 第 2 次:fallback agent-result,result 形状为 {content:"", error:"Result serialization failed: ..."},
|
|
314
|
+
// 且 cached 固定为 false(fallback result 非缓存命中,透传原值含义失真)
|
|
315
|
+
const fallback = postedAt(postMessage, 1);
|
|
316
|
+
expect(fallback.type).toBe("agent-result");
|
|
317
|
+
expect(fallback.callId).toBe(7);
|
|
318
|
+
expect(fallback.result?.content).toBe("");
|
|
319
|
+
expect(fallback.result?.error).toContain("Result serialization failed");
|
|
320
|
+
expect(fallback.result?.error).toContain("Could not clone object");
|
|
321
|
+
expect(fallback.cached).toBe(false);
|
|
322
|
+
} finally {
|
|
323
|
+
restore();
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("fallback 也失败 → 不向上抛错,记录 worker pending 将挂起", async () => {
|
|
328
|
+
const restore = silenceConsoleError();
|
|
329
|
+
try {
|
|
330
|
+
// postMessage 永远抛 DataCloneError(原始 + fallback 均失败)
|
|
331
|
+
const postMessage = makeAlwaysFailingPostMessage();
|
|
332
|
+
const run = makeRunningRunWithCachedDone(postMessage, 8, { bad: () => {} });
|
|
333
|
+
const deps = makeDeps();
|
|
334
|
+
|
|
335
|
+
// handleWorkerMessage 自身不应抛——postAgentResult 内部已 catch
|
|
336
|
+
await expect(
|
|
337
|
+
handleWorkerMessage(
|
|
338
|
+
run,
|
|
339
|
+
{ type: "agent-call", callId: 8, opts: { prompt: "noop" } },
|
|
340
|
+
deps,
|
|
341
|
+
makeHandlers(),
|
|
342
|
+
),
|
|
343
|
+
).resolves.toBeUndefined();
|
|
344
|
+
await flushMicrotasks();
|
|
345
|
+
|
|
346
|
+
// 至少 2 次尝试(原始 + fallback),fallback 失败也记日志
|
|
347
|
+
expect(postMessage.mock.calls.length).toBeGreaterThanOrEqual(2);
|
|
348
|
+
const errorCalls = (console.error as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0] as string);
|
|
349
|
+
expect(errorCalls.some((s) => s.includes("postAgentResult failed"))).toBe(true);
|
|
350
|
+
expect(errorCalls.some((s) => s.includes("fallback also failed"))).toBe(true);
|
|
351
|
+
} finally {
|
|
352
|
+
restore();
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
describe("W2c: postAgentResult 防御(源码结构断言)", () => {
|
|
358
|
+
/** 校验:给定函数体内的 postMessage 调用被 try/catch 包裹。
|
|
359
|
+
* 简化策略——在函数体片段中确认存在 try { ... postMessage ... } catch。 */
|
|
360
|
+
function hasTryCatchAroundPostMessage(funcBody: string): boolean {
|
|
361
|
+
return /try\s*\{[\s\S]*?postMessage[\s\S]*?\}\s*catch/.test(funcBody);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
it("postAgentResult 包含 try/catch + fallback result", () => {
|
|
365
|
+
const match = ERROR_RECOVERY_SRC.match(/function postAgentResult\([\s\S]*?\n\}/);
|
|
366
|
+
expect(match, "postAgentResult 函数定义应存在").toBeTruthy();
|
|
367
|
+
expect(hasTryCatchAroundPostMessage(match![0])).toBe(true);
|
|
368
|
+
// fallback 通过共享工厂 makeSerializeFailedResult 构造纯字符串 result(必可克隆)
|
|
369
|
+
expect(match![0]).toContain('result: makeSerializeFailedResult("Result serialization failed"');
|
|
370
|
+
expect(match![0]).toContain("Result serialization failed");
|
|
371
|
+
// 原 result 不可克隆时 cached 透传原值含义失真 → fallback 固定 cached: false
|
|
372
|
+
expect(match![0]).toContain("cached: false");
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("postBudgetUpdate 包含 try/catch", () => {
|
|
376
|
+
const match = ERROR_RECOVERY_SRC.match(/export function postBudgetUpdate\([\s\S]*?\n\}/);
|
|
377
|
+
expect(match, "postBudgetUpdate 函数定义应存在").toBeTruthy();
|
|
378
|
+
expect(hasTryCatchAroundPostMessage(match![0])).toBe(true);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("dispatchWorkflowCall postResult 闭包包含 try/catch + fallback", () => {
|
|
382
|
+
// postResult 是 const 闭包,匹配到下一个 }; 结尾
|
|
383
|
+
const match = ERROR_RECOVERY_SRC.match(/const postResult = \(result[\s\S]*?\n \};/);
|
|
384
|
+
expect(match, "postResult 闭包定义应存在").toBeTruthy();
|
|
385
|
+
expect(hasTryCatchAroundPostMessage(match![0])).toBe(true);
|
|
386
|
+
expect(match![0]).toContain("Workflow result serialization failed");
|
|
387
|
+
// 防御变量名遮蔽:错误变量用 err 而非 msg(外层参数名 msg)
|
|
388
|
+
expect(match![0]).toMatch(/catch \(err\)/);
|
|
389
|
+
});
|
|
390
|
+
});
|
|
@@ -55,3 +55,77 @@ describe("buildWorkerScript — agent() skill field in task/agent branch", () =>
|
|
|
55
55
|
expect(taskAgentBranch![0]).toContain("skill: firstArg.skill");
|
|
56
56
|
});
|
|
57
57
|
});
|
|
58
|
+
|
|
59
|
+
// ── W1: postMessage 防御 + parallel() 降级类型安全 ──
|
|
60
|
+
|
|
61
|
+
describe("buildWorkerScript — W1 postMessage defense & parallel degrade", () => {
|
|
62
|
+
const script = buildWorkerScript("// noop user script");
|
|
63
|
+
|
|
64
|
+
describe("_safePost wrapper", () => {
|
|
65
|
+
it("injects _safePost function", () => {
|
|
66
|
+
expect(script).toContain("function _safePost(msg, context)");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("_safePost wraps postMessage in try/catch", () => {
|
|
70
|
+
expect(script).toMatch(/_safePost[\s\S]*?try \{ parentPort\.postMessage\(msg\)/);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("_safePost logs failure with context to workerLogs", () => {
|
|
74
|
+
expect(script).toContain('_pushWorkerLog("error"');
|
|
75
|
+
expect(script).toContain('"[postMessage failed:" + context + "]"');
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("agent() uses _safePost", () => {
|
|
80
|
+
it("agent-call postMessage guarded by _safePost", () => {
|
|
81
|
+
expect(script).toContain("_safePost({ type: \"agent-call\"");
|
|
82
|
+
expect(script).toContain('"agent-call"');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("agent() throws on postMessage failure", () => {
|
|
86
|
+
expect(script).toContain("postMessage failed for agent-call");
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("workflow() uses _safePost", () => {
|
|
91
|
+
it("workflow-call postMessage guarded by _safePost", () => {
|
|
92
|
+
expect(script).toContain("_safePost({ type: \"workflow-call\"");
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("return/error use _safePost", () => {
|
|
97
|
+
it("return postMessage uses _safePost", () => {
|
|
98
|
+
expect(script).toContain('_safePost({ type: "return"');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("error postMessage uses _safePost", () => {
|
|
102
|
+
expect(script).toContain('_safePost({ type: "error"');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("parallel() degrade returns object", () => {
|
|
107
|
+
it("rejected results become {status:failed,error} objects", () => {
|
|
108
|
+
expect(script).toContain('status: "failed"');
|
|
109
|
+
expect(script).toMatch(/parallel[\s\S]*?status: "failed"/);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("non-object fulfilled values wrapped as failed", () => {
|
|
113
|
+
expect(script).toContain("agent returned non-object result");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("object fulfilled values pass through unchanged", () => {
|
|
117
|
+
expect(script).toContain("!Array.isArray(v)");
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("pipeline() error observability", () => {
|
|
122
|
+
it("single-arg mode logs stage errors before re-throwing", () => {
|
|
123
|
+
expect(script).toContain("[pipeline stage ");
|
|
124
|
+
expect(script).toContain("_pushWorkerLog(\"error\"");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("cartesian mode logs stage errors instead of silent swallow", () => {
|
|
128
|
+
expect(script).toContain("[pipeline cartesian stage failed");
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -427,6 +427,19 @@ function dispatchAgentCall(
|
|
|
427
427
|
});
|
|
428
428
|
}
|
|
429
429
|
|
|
430
|
+
/**
|
|
431
|
+
* postMessage 序列化失败时回发的 fallback result(必可克隆),让 worker pending resolve。
|
|
432
|
+
*
|
|
433
|
+
* postResult(workflow-call)与 postAgentResult(agent-call)各自前缀不同,故 prefix 参数化,
|
|
434
|
+
* 共享返回类型与构造逻辑,避免字面量重复导致形状漂移。
|
|
435
|
+
*/
|
|
436
|
+
function makeSerializeFailedResult(
|
|
437
|
+
prefix: string,
|
|
438
|
+
errMsg: string,
|
|
439
|
+
): { content: string; error: string } {
|
|
440
|
+
return { content: "", error: `${prefix}: ${errMsg}` };
|
|
441
|
+
}
|
|
442
|
+
|
|
430
443
|
/**
|
|
431
444
|
* 派发 workflow 嵌套调用:调 deps.onWorkflowCall 获取子 workflow 结果,
|
|
432
445
|
* 异步 postMessage(workflow-result) 回 worker。
|
|
@@ -449,11 +462,31 @@ function dispatchWorkflowCall(
|
|
|
449
462
|
|
|
450
463
|
const postResult = (result: unknown): void => {
|
|
451
464
|
if (run.state.status !== "running") return;
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
465
|
+
// W2 主线程防御:result 是子 workflow 任意返回值,可能含不可克隆成员(function/
|
|
466
|
+
// Symbol/循环引用)→ postMessage 同步抛 DataCloneError。内部 try/catch + 回发
|
|
467
|
+
// 纯字符串 fallback result,让 worker 内 workflow() pending Promise resolve。
|
|
468
|
+
// 注意:错误变量用 err(外层 dispatchWorkflowCall 参数名为 msg,避免遮蔽)。
|
|
469
|
+
try {
|
|
470
|
+
run.runtime?.worker.postMessage({
|
|
471
|
+
type: "workflow-result",
|
|
472
|
+
callId: msg.callId,
|
|
473
|
+
result,
|
|
474
|
+
});
|
|
475
|
+
} catch (err) {
|
|
476
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
477
|
+
console.error(`[workflow] postResult (workflow-call callId=${msg.callId}) failed: ${errMsg}. Sending error fallback.`);
|
|
478
|
+
// 回发纯字符串 fallback result(必可克隆),让 worker pending resolve
|
|
479
|
+
try {
|
|
480
|
+
run.runtime?.worker.postMessage({
|
|
481
|
+
type: "workflow-result",
|
|
482
|
+
callId: msg.callId,
|
|
483
|
+
result: makeSerializeFailedResult("Workflow result serialization failed", errMsg),
|
|
484
|
+
});
|
|
485
|
+
} catch {
|
|
486
|
+
// fallback 也失败——worker 此 callId 的 pending 只能靠 timeout 兜底
|
|
487
|
+
console.error(`[workflow] postResult fallback also failed (callId=${msg.callId}): worker pending will hang until timeout`);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
457
490
|
};
|
|
458
491
|
|
|
459
492
|
if (!deps.onWorkflowCall) {
|
|
@@ -477,6 +510,12 @@ function dispatchWorkflowCall(
|
|
|
477
510
|
|
|
478
511
|
/**
|
|
479
512
|
* 回发 agent-result 给 worker(worker 内 pending Promise 据此 resolve)。
|
|
513
|
+
*
|
|
514
|
+
* W2 主线程防御:result 是 agent 返回值,含不可克隆成员(function/Symbol/循环引用)时
|
|
515
|
+
* postMessage 同步抛 DataCloneError。若冒泡到 dispatchAgentCall 的 .then 回调,会中断
|
|
516
|
+
* 后续 postBudgetUpdate/store.save/budget 检查,run 卡在 running。故内部 try/catch:
|
|
517
|
+
* 失败时记录诊断 + 回发纯字符串 fallback result(必可克隆),让 worker pending resolve。
|
|
518
|
+
* 函数签名不变(所有调用点无需改动),仅用 console.error 记日志(deps 不在手边)。
|
|
480
519
|
*/
|
|
481
520
|
function postAgentResult(
|
|
482
521
|
run: WorkflowRun,
|
|
@@ -484,7 +523,25 @@ function postAgentResult(
|
|
|
484
523
|
result: AgentResult,
|
|
485
524
|
cached: boolean,
|
|
486
525
|
): void {
|
|
487
|
-
|
|
526
|
+
try {
|
|
527
|
+
run.runtime?.worker.postMessage({ type: "agent-result", callId, result, cached });
|
|
528
|
+
} catch (err) {
|
|
529
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
530
|
+
console.error(`[workflow] postAgentResult failed (callId=${callId}): ${msg}. Result likely contains non-cloneable value.`);
|
|
531
|
+
// 回发纯字符串 fallback result(必可克隆),让 worker pending resolve(避免永久挂起)
|
|
532
|
+
try {
|
|
533
|
+
run.runtime?.worker.postMessage({
|
|
534
|
+
type: "agent-result",
|
|
535
|
+
callId,
|
|
536
|
+
result: makeSerializeFailedResult("Result serialization failed", msg),
|
|
537
|
+
// 原 result 不可克隆时 cached 透传原值含义失真(fallback result 非缓存命中)→ 固定 false
|
|
538
|
+
cached: false,
|
|
539
|
+
});
|
|
540
|
+
} catch {
|
|
541
|
+
// fallback 也失败——worker 此 callId 的 pending 只能靠 timeout/exit 兜底
|
|
542
|
+
console.error(`[workflow] postAgentResult fallback also failed (callId=${callId}): worker pending will hang until timeout`);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
488
545
|
}
|
|
489
546
|
|
|
490
547
|
/**
|
|
@@ -496,13 +553,20 @@ function postAgentResult(
|
|
|
496
553
|
* (dispatch 后同步 worker $BUDGET)——单一实现,避免消息形状漂移。
|
|
497
554
|
*/
|
|
498
555
|
export function postBudgetUpdate(run: WorkflowRun): void {
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
556
|
+
try {
|
|
557
|
+
run.runtime?.worker.postMessage({
|
|
558
|
+
type: "budget-update",
|
|
559
|
+
budget: {
|
|
560
|
+
usedTokens: run.state.budget.usedTokens,
|
|
561
|
+
usedCost: run.state.budget.usedCost,
|
|
562
|
+
},
|
|
563
|
+
});
|
|
564
|
+
} catch (err) {
|
|
565
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
566
|
+
// budget 是纯 number 不太可能失败,但防御性兜底——budget 同步非关键(worker 仍可
|
|
567
|
+
// 基于 $BUDGET.spent() 自行累计),失败仅记日志,不中断调用方流程。
|
|
568
|
+
console.error(`[workflow] postBudgetUpdate failed: ${msg}. Budget sync to worker skipped (non-critical).`);
|
|
569
|
+
}
|
|
506
570
|
}
|
|
507
571
|
|
|
508
572
|
/**
|
|
@@ -69,6 +69,17 @@ export function buildWorkerScript(userScript: string): string {
|
|
|
69
69
|
' console.error = function (...args) { _pushWorkerLog("error", args); };',
|
|
70
70
|
' console.info = function (...args) { _pushWorkerLog("info", args); };',
|
|
71
71
|
'',
|
|
72
|
+
' // ── safePostMessage wrapper: 统一 postMessage 防御(DataCloneError 等)──',
|
|
73
|
+
' function _safePost(msg, context) {',
|
|
74
|
+
' try { parentPort.postMessage(msg); return true; }',
|
|
75
|
+
' catch (e) {',
|
|
76
|
+
' const errMsg = e && e.message ? e.message : String(e);',
|
|
77
|
+
' const stack = e && e.stack ? e.stack : "";',
|
|
78
|
+
' _pushWorkerLog("error", ["[postMessage failed:" + context + "]", errMsg, stack]);',
|
|
79
|
+
' return false;',
|
|
80
|
+
' }',
|
|
81
|
+
' }',
|
|
82
|
+
'',
|
|
72
83
|
' // ── Internal state ──',
|
|
73
84
|
' let _callIdCounter = 0;',
|
|
74
85
|
' let _agentCallCount = 0;',
|
|
@@ -201,7 +212,9 @@ export function buildWorkerScript(userScript: string): string {
|
|
|
201
212
|
' const _effectivePhase = opts.phase || _currentPhase;\n' +
|
|
202
213
|
' delete opts.phase;\n' +
|
|
203
214
|
'\n' +
|
|
204
|
-
'
|
|
215
|
+
' if (!_safePost({ type: "agent-call", callId, opts, phase: _effectivePhase }, "agent-call")) {',
|
|
216
|
+
' return Promise.reject(new Error("postMessage failed for agent-call (callId=" + callId + "): see workerLogs"));',
|
|
217
|
+
' }',
|
|
205
218
|
' return new Promise((resolve, reject) => {',
|
|
206
219
|
' _pendingCalls.set(callId, { resolve, reject });',
|
|
207
220
|
' });',
|
|
@@ -219,7 +232,21 @@ export function buildWorkerScript(userScript: string): string {
|
|
|
219
232
|
' if (typeof c === "object" && c !== null && (c.task || c.agent)) { return agent(c); }',
|
|
220
233
|
' return agent(c);',
|
|
221
234
|
' }));',
|
|
222
|
-
' return settled.map((r) =>
|
|
235
|
+
' return settled.map((r) => {',
|
|
236
|
+
' if (r.status === "fulfilled") {',
|
|
237
|
+
' const v = r.value;',
|
|
238
|
+
' if (v !== null && typeof v === "object" && !Array.isArray(v)) {',
|
|
239
|
+
' // 主线程 fallback(postAgentResult/postResult serialization failed)回发的对象含 error 字段',
|
|
240
|
+
' // → 归一化为 failed 形状,与脚本侧 r.status === "failed" 检查统一',
|
|
241
|
+
' if (typeof v.error === "string" && v.error.length > 0) return { status: "failed", error: v.error };',
|
|
242
|
+
' return v;',
|
|
243
|
+
' }',
|
|
244
|
+
' return { status: "failed", error: "agent returned non-object result (type=" + typeof v + ")" };',
|
|
245
|
+
' }',
|
|
246
|
+
' const reason = r.reason;',
|
|
247
|
+
' const errMsg = reason instanceof Error ? reason.message : String(reason);',
|
|
248
|
+
' return { status: "failed", error: errMsg };',
|
|
249
|
+
' });',
|
|
223
250
|
' }',
|
|
224
251
|
'',
|
|
225
252
|
// ── pipeline global ──
|
|
@@ -227,19 +254,31 @@ export function buildWorkerScript(userScript: string): string {
|
|
|
227
254
|
' // Single-arg mode: pipeline([stage1, stage2, ...])',
|
|
228
255
|
' if (Array.isArray(firstArg) && restStages.length === 0) {',
|
|
229
256
|
' let result;',
|
|
230
|
-
' for (
|
|
257
|
+
' for (let i = 0; i < firstArg.length; i++) {',
|
|
258
|
+
' try { result = await firstArg[i](result); }',
|
|
259
|
+
' catch (e) {',
|
|
260
|
+
' const msg = e && e.message ? e.message : String(e);',
|
|
261
|
+
' _pushWorkerLog("error", ["[pipeline stage " + i + " failed]", msg]);',
|
|
262
|
+
' throw e;',
|
|
263
|
+
' }',
|
|
264
|
+
' }',
|
|
231
265
|
' return result;',
|
|
232
266
|
' }',
|
|
233
267
|
' // Cartesian product mode: pipeline([items], stage1, stage2, ...)',
|
|
234
268
|
' if (Array.isArray(firstArg) && restStages.length > 0 && typeof restStages[0] === "function") {',
|
|
235
269
|
' const results = [];',
|
|
236
|
-
' for (
|
|
270
|
+
' for (let idx = 0; idx < firstArg.length; idx++) {',
|
|
271
|
+
' const item = firstArg[idx];',
|
|
237
272
|
' let val = item;',
|
|
238
273
|
' let failed = false;',
|
|
239
274
|
' for (const stage of restStages) {',
|
|
240
275
|
' if (failed) break;',
|
|
241
276
|
' try { val = await stage(val); }',
|
|
242
|
-
' catch (e) {
|
|
277
|
+
' catch (e) {',
|
|
278
|
+
' const msg = e && e.message ? e.message : String(e);',
|
|
279
|
+
' _pushWorkerLog("error", ["[pipeline cartesian stage failed for item " + (idx + 1) + "]", msg]);',
|
|
280
|
+
' val = null; failed = true;',
|
|
281
|
+
' }',
|
|
243
282
|
' }',
|
|
244
283
|
' results.push(val);',
|
|
245
284
|
' }',
|
|
@@ -256,7 +295,9 @@ export function buildWorkerScript(userScript: string): string {
|
|
|
256
295
|
' const workflowArgs = (typeof args === "object" && args !== null) ? args : {};',
|
|
257
296
|
' const callId = _callIdCounter;',
|
|
258
297
|
' _callIdCounter++;',
|
|
259
|
-
'
|
|
298
|
+
' if (!_safePost({ type: "workflow-call", callId, name, args: workflowArgs }, "workflow-call")) {',
|
|
299
|
+
' return Promise.reject(new Error("postMessage failed for workflow-call (name=" + name + "): see workerLogs"));',
|
|
300
|
+
' }',
|
|
260
301
|
' return new Promise((resolve, reject) => {',
|
|
261
302
|
' _pendingCalls.set(callId, { resolve, reject });',
|
|
262
303
|
' });',
|
|
@@ -272,11 +313,11 @@ export function buildWorkerScript(userScript: string): string {
|
|
|
272
313
|
'})().then((result) => {',
|
|
273
314
|
' const { parentPort, workerData } = require("node:worker_threads");',
|
|
274
315
|
' const runId = (workerData.args && typeof workerData.args === "object" && workerData.args._runId) || "";',
|
|
275
|
-
'
|
|
316
|
+
' _safePost({ type: "return", runId, result, workerLogs: _workerLogs }, "return");',
|
|
276
317
|
'}).catch((err) => {',
|
|
277
318
|
' const { parentPort, workerData } = require("node:worker_threads");',
|
|
278
319
|
' const runId = (workerData.args && typeof workerData.args === "object" && workerData.args._runId) || "";',
|
|
279
|
-
'
|
|
320
|
+
' _safePost({ type: "error", runId, error: err.message || String(err), workerLogs: _workerLogs }, "error");',
|
|
280
321
|
'});',
|
|
281
322
|
].join("\n");
|
|
282
323
|
}
|
package/workflows/README.md
CHANGED
|
@@ -28,7 +28,9 @@ workflow run parallel --args target="src/auth/login.ts"
|
|
|
28
28
|
workflow run parallel --args target="..." --args 'perspectives=["security","readability"]'
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
`perspectives` 默认 `["security","performance","maintainability"]`。每个视角一个并行 agent
|
|
31
|
+
`perspectives` 默认 `["security","performance","maintainability"]`。每个视角一个并行 agent,各自返回评分+发现的问题,最后纯代码拼接各视角的 findings。
|
|
32
|
+
|
|
33
|
+
> **Note (breaking)**: `outcome.aggregate` is now a concatenated string of each perspective's findings (format: `[perspective] finding1; finding2`, joined by newlines). Previously it was an LLM-produced object `{overallScore, topIssues, consensus}`. If you have generated workflows or downstream tools parsing the old object shape, update them to read `outcome.per_perspective` for structured per-perspective scores/findings, or treat `outcome.aggregate` as plain text.
|
|
32
34
|
|
|
33
35
|
### scatter-gather — 分发-收集
|
|
34
36
|
|
|
@@ -36,7 +38,7 @@ workflow run parallel --args target="..." --args 'perspectives=["security","read
|
|
|
36
38
|
workflow run scatter-gather --args task="重构认证模块,涉及 session/jwt/oauth 三块"
|
|
37
39
|
```
|
|
38
40
|
|
|
39
|
-
三段:第一个 agent 把大任务拆成 2-4 个可并行子任务 → `parallel()` 并行处理每个子任务 →
|
|
41
|
+
三段:第一个 agent 把大任务拆成 2-4 个可并行子任务 → `parallel()` 并行处理每个子任务 → gather 阶段用 `agent()` 把各子任务结果合并成最终结论(LLM 合并,非纯代码拼接)。
|
|
40
42
|
|
|
41
43
|
### map-reduce — 映射-归约
|
|
42
44
|
|
|
@@ -45,7 +47,7 @@ workflow run map-reduce --args 'items=["file1.ts","file2.ts","file3.ts"]' --args
|
|
|
45
47
|
workflow run map-reduce --args itemsJson=/path/to/items.json --args operation="..."
|
|
46
48
|
```
|
|
47
49
|
|
|
48
|
-
`items` 直接传 JSON 数组,或 `itemsJson` 传 JSON 文件路径(二选一)。`parallel()` 对每个 item 并行执行 `operation` →
|
|
50
|
+
`items` 直接传 JSON 数组,或 `itemsJson` 传 JSON 文件路径(二选一)。`parallel()` 对每个 item 并行执行 `operation` → reduce 阶段用 `agent()` 把各 item 的 map 结果归约成单一结论(LLM 归约,非纯代码拼接)。
|
|
49
51
|
|
|
50
52
|
## 编排 API
|
|
51
53
|
|
package/workflows/chain.js
CHANGED
|
@@ -19,8 +19,8 @@ const meta = {
|
|
|
19
19
|
|
|
20
20
|
// ── 入参($ARGS)──────────────────────────────────────────────────
|
|
21
21
|
const task = $ARGS.task;
|
|
22
|
-
if (
|
|
23
|
-
throw new Error("chain 缺少必需参数 task
|
|
22
|
+
if (typeof task !== "string" || task.trim() === "") {
|
|
23
|
+
throw new Error("chain 缺少必需参数 task(非空字符串)。用法:workflow run chain --args task=\"<描述>\"");
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
log("chain 开始,task=" + task);
|
package/workflows/map-reduce.js
CHANGED
|
@@ -82,12 +82,12 @@ try {
|
|
|
82
82
|
let mapFailed = 0;
|
|
83
83
|
for (let i = 0; i < mappedRaw.length; i++) {
|
|
84
84
|
const r = mappedRaw[i];
|
|
85
|
-
if (!r || r.error) {
|
|
85
|
+
if (!r || r.status === "failed" || r.error) {
|
|
86
86
|
mapped.push({
|
|
87
87
|
itemIndex: i,
|
|
88
88
|
item: items[i],
|
|
89
89
|
status: "failed",
|
|
90
|
-
error: r ? r.error : "agent 无返回",
|
|
90
|
+
error: r ? (r.error || "agent 返回 failed 状态") : "agent 无返回",
|
|
91
91
|
});
|
|
92
92
|
mapFailed++;
|
|
93
93
|
} else {
|
|
@@ -95,7 +95,7 @@ try {
|
|
|
95
95
|
itemIndex: i,
|
|
96
96
|
item: items[i],
|
|
97
97
|
status: "ok",
|
|
98
|
-
mapped: r.mapped,
|
|
98
|
+
mapped: (typeof r.mapped === "string" ? r.mapped : "(无结果)"),
|
|
99
99
|
});
|
|
100
100
|
}
|
|
101
101
|
}
|
|
@@ -104,10 +104,11 @@ try {
|
|
|
104
104
|
}
|
|
105
105
|
log("map 完成:ok=" + (items.length - mapFailed) + " failed=" + mapFailed);
|
|
106
106
|
|
|
107
|
-
// ── 段 2:reduce(agent
|
|
107
|
+
// ── 段 2:reduce(agent 归约所有 map 结果)──────────────────────
|
|
108
108
|
phase("reduce");
|
|
109
109
|
currentPhase = "reduce";
|
|
110
|
-
|
|
110
|
+
|
|
111
|
+
const reducedResult = await agent({
|
|
111
112
|
prompt:
|
|
112
113
|
"以下是对 " + items.length + " 个 item 执行「" + operation + "」的结果,请归约成单一结论:\n\n" +
|
|
113
114
|
JSON.stringify(mapped, null, 2),
|
|
@@ -127,7 +128,10 @@ try {
|
|
|
127
128
|
phases_run: ["map", "reduce"],
|
|
128
129
|
items_total: items.length,
|
|
129
130
|
items_mapped: items.length - mapFailed,
|
|
130
|
-
reduced: {
|
|
131
|
+
reduced: {
|
|
132
|
+
reduced: (reducedResult?.reduced ?? "(归约无结果)"),
|
|
133
|
+
stats: (reducedResult?.stats ?? "(归约无结果)"),
|
|
134
|
+
},
|
|
131
135
|
message: "map-reduce 完成:map " + items.length + " 项(失败 " + mapFailed + ")→ reduce",
|
|
132
136
|
};
|
|
133
137
|
} catch (err) {
|
package/workflows/parallel.js
CHANGED
|
@@ -28,6 +28,9 @@ if (!target) {
|
|
|
28
28
|
const perspectives = Array.isArray($ARGS.perspectives) && $ARGS.perspectives.length > 0
|
|
29
29
|
? $ARGS.perspectives
|
|
30
30
|
: ["security", "performance", "maintainability"];
|
|
31
|
+
if (perspectives.some((p) => typeof p !== "string")) {
|
|
32
|
+
throw new Error("parallel 参数 perspectives 必须是字符串数组,实际含非字符串元素");
|
|
33
|
+
}
|
|
31
34
|
|
|
32
35
|
log("parallel 开始,target=" + target + " perspectives=" + JSON.stringify(perspectives));
|
|
33
36
|
|
|
@@ -68,15 +71,20 @@ try {
|
|
|
68
71
|
let failedCount = 0;
|
|
69
72
|
for (let i = 0; i < perPerspectiveRaw.length; i++) {
|
|
70
73
|
const r = perPerspectiveRaw[i];
|
|
71
|
-
if (!r || r.error) {
|
|
74
|
+
if (!r || r.status === "failed" || r.error) {
|
|
72
75
|
perPerspective.push({
|
|
73
76
|
perspective: perspectives[i],
|
|
74
77
|
status: "failed",
|
|
75
|
-
error: r ? r.error : "agent 无返回",
|
|
78
|
+
error: r ? (r.error || "agent 返回 failed 状态") : "agent 无返回",
|
|
76
79
|
});
|
|
77
80
|
failedCount++;
|
|
78
81
|
} else {
|
|
79
|
-
perPerspective.push({
|
|
82
|
+
perPerspective.push({
|
|
83
|
+
perspective: perspectives[i],
|
|
84
|
+
status: "ok",
|
|
85
|
+
score: typeof r.score === "number" ? r.score : undefined,
|
|
86
|
+
findings: Array.isArray(r.findings) ? r.findings : [],
|
|
87
|
+
});
|
|
80
88
|
}
|
|
81
89
|
}
|
|
82
90
|
if (failedCount === perspectives.length) {
|
|
@@ -87,36 +95,18 @@ try {
|
|
|
87
95
|
// ── 段 2:aggregate(汇总多视角结果)────────────────────────────
|
|
88
96
|
phase("aggregate");
|
|
89
97
|
currentPhase = "aggregate";
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
type: "object",
|
|
96
|
-
properties: {
|
|
97
|
-
overallScore: { type: "number", description: "综合评分 0-10" },
|
|
98
|
-
topIssues: {
|
|
99
|
-
type: "array",
|
|
100
|
-
items: { type: "string" },
|
|
101
|
-
description: "最关键的问题(按严重度排序)",
|
|
102
|
-
},
|
|
103
|
-
consensus: { type: "string", description: "多视角共识总结" },
|
|
104
|
-
},
|
|
105
|
-
required: ["overallScore", "topIssues", "consensus"],
|
|
106
|
-
},
|
|
107
|
-
description: "parallel-aggregate",
|
|
108
|
-
});
|
|
98
|
+
|
|
99
|
+
// 纯代码合并:拼接各视角发现的问题(不调用 LLM)
|
|
100
|
+
const aggregateResult = perPerspective
|
|
101
|
+
.map((p) => "[" + (p.perspective || "?") + "] " + (p.findings ? p.findings.join("; ") : "(no findings)"))
|
|
102
|
+
.join("\n");
|
|
109
103
|
|
|
110
104
|
outcome = {
|
|
111
105
|
status: failedCount > 0 ? "partial" : "ok",
|
|
112
106
|
phases_run: ["parallel-analyze", "aggregate"],
|
|
113
107
|
perspectives_analyzed: perspectives.length,
|
|
114
108
|
per_perspective: perPerspective,
|
|
115
|
-
aggregate:
|
|
116
|
-
overallScore: (aggregate?.overallScore ?? "(聚合无结果)"),
|
|
117
|
-
topIssues: (aggregate?.topIssues ?? []),
|
|
118
|
-
consensus: (aggregate?.consensus ?? "(聚合无结果)"),
|
|
119
|
-
},
|
|
109
|
+
aggregate: aggregateResult,
|
|
120
110
|
message: "parallel 完成:" + perspectives.length + " 视角(失败 " + failedCount + ")→ 聚合",
|
|
121
111
|
};
|
|
122
112
|
} catch (err) {
|
|
@@ -62,6 +62,9 @@ try {
|
|
|
62
62
|
if (subtasks.length === 0) {
|
|
63
63
|
throw new Error("scatter 返回的 subtasks 为空");
|
|
64
64
|
}
|
|
65
|
+
if (subtasks.some((s) => !s || typeof s.name !== "string")) {
|
|
66
|
+
throw new Error("scatter 返回的 subtasks 每项需含 name 字符串字段");
|
|
67
|
+
}
|
|
65
68
|
log("scatter 出 " + subtasks.length + " 个子任务");
|
|
66
69
|
|
|
67
70
|
// ── 段 2:process(parallel 并行处理每个子任务)──────────────────
|
|
@@ -89,15 +92,19 @@ try {
|
|
|
89
92
|
let failedCount = 0;
|
|
90
93
|
for (let i = 0; i < processedRaw.length; i++) {
|
|
91
94
|
const r = processedRaw[i];
|
|
92
|
-
if (!r || r.error) {
|
|
95
|
+
if (!r || r.status === "failed" || r.error) {
|
|
93
96
|
processed.push({
|
|
94
97
|
subtask: subtasks[i].name,
|
|
95
98
|
status: "failed",
|
|
96
|
-
error: r ? r.error : "agent 无返回",
|
|
99
|
+
error: r ? (r.error || "agent 返回 failed 状态") : "agent 无返回",
|
|
97
100
|
});
|
|
98
101
|
failedCount++;
|
|
99
102
|
} else {
|
|
100
|
-
processed.push({
|
|
103
|
+
processed.push({
|
|
104
|
+
subtask: subtasks[i].name,
|
|
105
|
+
status: "ok",
|
|
106
|
+
result: (typeof r.result === "string" ? r.result : "(无结果)"),
|
|
107
|
+
});
|
|
101
108
|
}
|
|
102
109
|
}
|
|
103
110
|
if (failedCount === subtasks.length) {
|
|
@@ -105,10 +112,11 @@ try {
|
|
|
105
112
|
}
|
|
106
113
|
log("process 完成:ok=" + (subtasks.length - failedCount) + " failed=" + failedCount);
|
|
107
114
|
|
|
108
|
-
// ── 段 3:gather
|
|
115
|
+
// ── 段 3:gather(agent 合并所有子任务结果)─────────────────────
|
|
109
116
|
phase("gather");
|
|
110
117
|
currentPhase = "gather";
|
|
111
|
-
|
|
118
|
+
|
|
119
|
+
const gatheredResult = await agent({
|
|
112
120
|
prompt:
|
|
113
121
|
"以下是各子任务的处理结果,请合并成一个完整、一致的最终结果:\n\n" +
|
|
114
122
|
JSON.stringify(processed, null, 2),
|
|
@@ -129,8 +137,8 @@ try {
|
|
|
129
137
|
subtasks_total: subtasks.length,
|
|
130
138
|
subtasks_processed: subtasks.length - failedCount,
|
|
131
139
|
gathered: {
|
|
132
|
-
mergedResult: (
|
|
133
|
-
completeness: (
|
|
140
|
+
mergedResult: (gatheredResult?.mergedResult ?? "(合并无结果)"),
|
|
141
|
+
completeness: (gatheredResult?.completeness ?? "(合并无结果)"),
|
|
134
142
|
},
|
|
135
143
|
message: "scatter-gather 完成:split " + subtasks.length + " → process(失败 " + failedCount + ")→ merge",
|
|
136
144
|
};
|