@zhushanwen/pi-subagent-workflow 0.4.1 → 0.4.3
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/commands.ts +30 -0
- package/src/interface/subagents.ts +33 -0
- 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-runtime.test.ts +347 -0
- package/src/orchestration/__tests__/worker-script-builder.test.ts +76 -0
- package/src/orchestration/__tests__/workflows-e2e.test.ts +386 -0
- package/src/orchestration/error-recovery.ts +77 -13
- package/src/orchestration/worker-script-builder.ts +72 -16
- 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
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 内置 workflow E2E(真实 worker thread + mock LLM runner)
|
|
3
|
+
*
|
|
4
|
+
* 验证 4 个内置 workflow(parallel/chain/map-reduce/scatter-gather)通过真实的编排
|
|
5
|
+
* 链路执行成功。调用真实的 runAndWait(name, args, deps)(src/orchestration/launcher.ts),
|
|
6
|
+
* 它内部会:
|
|
7
|
+
* 1. deps.registry.get(name) 加载真实 .js 脚本
|
|
8
|
+
* 2. 脚本校验(lintScript)
|
|
9
|
+
* 3. runWorkflow(spec, deps) 起真实的 node:worker_threads Worker 执行脚本
|
|
10
|
+
* 4. 脚本内调 agent()/parallel() → worker postMessage(agent-call) →
|
|
11
|
+
* 主线程 deps.runner.run() → 我们 mock 它返回固定结构化数据
|
|
12
|
+
* 5. 脚本聚合结果 → return outcome → runAndWait 返回 WorkflowRunResult
|
|
13
|
+
*
|
|
14
|
+
* 唯一 mock 的是 deps.runner(AgentRunner 接口)——真实 runner 会 spawn pi 子进程调 LLM,
|
|
15
|
+
* mock runner 根据 opts.schema 生成符合脚本 schema 的假数据。
|
|
16
|
+
*
|
|
17
|
+
* 真实 Infra 实现:
|
|
18
|
+
* - WorkerHostImpl(真实 node:worker_threads Worker)
|
|
19
|
+
* - JsonlRunStore(真实持久化,用临时目录)
|
|
20
|
+
*
|
|
21
|
+
* registry 绕过说明(见末尾 notes):
|
|
22
|
+
* WorkflowScriptRegistryImpl(config) 的扫描源是固定约定目录(.pi/workflows 等),
|
|
23
|
+
* 无法指向 extensions/subagent-workflow/workflows/。为不改源码,这里直接读 .js 文件
|
|
24
|
+
* 内容 + 手动构造 WorkflowScript 对象,包装为一个满足 WorkflowScriptRegistry 接口
|
|
25
|
+
* 的自定义 registry(loadWorkflowsFromDir)。
|
|
26
|
+
*/
|
|
27
|
+
import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
28
|
+
import { tmpdir } from "node:os";
|
|
29
|
+
import { dirname, join } from "node:path";
|
|
30
|
+
import { fileURLToPath } from "node:url";
|
|
31
|
+
|
|
32
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
33
|
+
|
|
34
|
+
import { JsonlRunStore } from "../jsonl-run-store.ts";
|
|
35
|
+
import { type LauncherDeps,runAndWait } from "../launcher.ts";
|
|
36
|
+
import type { LifecycleDeps } from "../models/ports.ts";
|
|
37
|
+
import type { AgentRunner } from "../models/ports.ts";
|
|
38
|
+
import type { AgentResult, AgentUsage } from "../models/types.ts";
|
|
39
|
+
import {
|
|
40
|
+
type WorkflowMeta,
|
|
41
|
+
WorkflowScript,
|
|
42
|
+
type WorkflowSource,
|
|
43
|
+
} from "../models/workflow-script.ts";
|
|
44
|
+
import type { WorkflowScriptRegistry } from "../models/workflow-script-registry.ts";
|
|
45
|
+
import { WorkerHostImpl } from "../worker-host.ts";
|
|
46
|
+
|
|
47
|
+
// ── 路径:定位真实 workflows 目录 ─────────────────────────────────────────
|
|
48
|
+
// 本测试文件在 src/orchestration/__tests__/,workflows 目录在 extensions/subagent-workflow/workflows/
|
|
49
|
+
// 即 __dirname → .. (orchestration) → .. (src) → .. (subagent-workflow) → workflows
|
|
50
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
51
|
+
const WORKFLOWS_DIR = join(__dirname, "..", "..", "..", "workflows");
|
|
52
|
+
|
|
53
|
+
// ── 临时 session 目录(RunStore 持久化根),每用例重建 ──────────────────
|
|
54
|
+
let sessionDir: string;
|
|
55
|
+
let createdStores: JsonlRunStore[] = [];
|
|
56
|
+
|
|
57
|
+
// ── 通用 mock usage(AgentResult.usage 可选,给一个固定值便于排查) ──────
|
|
58
|
+
const MOCK_USAGE: AgentUsage = {
|
|
59
|
+
input: 10,
|
|
60
|
+
output: 5,
|
|
61
|
+
cacheRead: 0,
|
|
62
|
+
cacheWrite: 0,
|
|
63
|
+
cost: 0,
|
|
64
|
+
contextTokens: 15,
|
|
65
|
+
turns: 1,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// ── 根据 JSON schema 递归生成符合 schema 的占位值 ─────────────────────────
|
|
69
|
+
|
|
70
|
+
type JsonSchema = {
|
|
71
|
+
type?: string;
|
|
72
|
+
description?: string;
|
|
73
|
+
properties?: Record<string, JsonSchema>;
|
|
74
|
+
required?: string[];
|
|
75
|
+
items?: JsonSchema;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 从 JSON schema 生成占位值。
|
|
80
|
+
*
|
|
81
|
+
* - string → "mock"(脚本只校验存在性/字符串类型,不校验内容)
|
|
82
|
+
* - number → 7(0-10 评分等都能用)
|
|
83
|
+
* - boolean → true
|
|
84
|
+
* - array → [generate(items)](至少 1 项;subtasks 这类对象数组给 2 项让脚本有东西可处理)
|
|
85
|
+
* - object → { properties 递归生成 }
|
|
86
|
+
*
|
|
87
|
+
* schema 缺失时回退 null(agent() 在 parsedOutput 为 null 时回退 content,但本测试
|
|
88
|
+
* 每个 agent() 都带 schema,故不会命中)。
|
|
89
|
+
*/
|
|
90
|
+
function generateFromSchema(schema: JsonSchema | undefined): unknown {
|
|
91
|
+
if (!schema) return null;
|
|
92
|
+
switch (schema.type) {
|
|
93
|
+
case "string":
|
|
94
|
+
return "mock";
|
|
95
|
+
case "number":
|
|
96
|
+
case "integer":
|
|
97
|
+
return 7;
|
|
98
|
+
case "boolean":
|
|
99
|
+
return true;
|
|
100
|
+
case "array": {
|
|
101
|
+
const item = generateFromSchema(schema.items);
|
|
102
|
+
// 对象数组给 2 项(scatter-gather 的 subtasks 需 ≥1 项才能进 process 段;
|
|
103
|
+
// 给 2 项让并行处理有意义),基本类型数组给 1 项。
|
|
104
|
+
return schema.items?.type === "object" ? [item, item] : [item];
|
|
105
|
+
}
|
|
106
|
+
case "object":
|
|
107
|
+
default: {
|
|
108
|
+
const out: Record<string, unknown> = {};
|
|
109
|
+
if (schema.properties) {
|
|
110
|
+
for (const [key, sub] of Object.entries(schema.properties)) {
|
|
111
|
+
out[key] = generateFromSchema(sub);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 构造 mock AgentRunner:根据每次调用的 opts.schema 生成符合 schema 的 AgentResult。
|
|
121
|
+
*
|
|
122
|
+
* runner.run 签名(ports.ts:35):
|
|
123
|
+
* run(opts, signal, onEvent?, stream?) => Promise<AgentResult>
|
|
124
|
+
* signal/onEvent/stream 接受但忽略(mock 不消费)。parsedOutput 为符合 schema 的对象,
|
|
125
|
+
* worker 内 agent()/parallel() 取 parsedOutput ?? content 作为脚本可见值。
|
|
126
|
+
*/
|
|
127
|
+
function makeMockRunner(): AgentRunner & { run: ReturnType<typeof vi.fn> } {
|
|
128
|
+
const run = vi.fn(async (opts: { schema?: unknown }): Promise<AgentResult> => {
|
|
129
|
+
const parsed = generateFromSchema(opts.schema as JsonSchema | undefined);
|
|
130
|
+
return {
|
|
131
|
+
content: "mock",
|
|
132
|
+
parsedOutput: parsed,
|
|
133
|
+
usage: MOCK_USAGE,
|
|
134
|
+
durationMs: 1,
|
|
135
|
+
error: undefined,
|
|
136
|
+
};
|
|
137
|
+
});
|
|
138
|
+
// AgentRunner 接口仅含 run 方法(ports.ts:35),{ run } 已满足结构。
|
|
139
|
+
// 额外标注 run 为 vi.fn 返回类型,便于断言调用次数。
|
|
140
|
+
return { run } as AgentRunner & { run: ReturnType<typeof vi.fn> };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── 自定义 registry:从指定目录加载 .js 脚本为 WorkflowScript ─────────────
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 从源码用 regex 提取 `const meta = { ... }`(与 config-loader.extractMetaViaRegex
|
|
147
|
+
* 同语义,避免执行用户代码)。失败时回落到 name=文件名 stem 的空 meta。
|
|
148
|
+
*/
|
|
149
|
+
function extractMeta(source: string, fallbackName: string): WorkflowMeta {
|
|
150
|
+
const metaPattern = /(?:export\s+)?const\s+meta\s*=\s*(\{[^]*?\});?\s*$/m;
|
|
151
|
+
const match = metaPattern.exec(source);
|
|
152
|
+
if (match) {
|
|
153
|
+
try {
|
|
154
|
+
const fn = new Function(`return (${match[1]});`);
|
|
155
|
+
const obj = fn();
|
|
156
|
+
if (obj && typeof obj === "object" && typeof obj.name === "string") {
|
|
157
|
+
return {
|
|
158
|
+
name: obj.name,
|
|
159
|
+
description: typeof obj.description === "string" ? obj.description : "",
|
|
160
|
+
phases: Array.isArray(obj.phases) ? obj.phases : [],
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
} catch (e) {
|
|
164
|
+
// meta 提取失败(非法 JS / regex 不匹配)→ 回落 fallback name,非测试关注点
|
|
165
|
+
void e;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return { name: fallbackName, description: "", phases: [] };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 从目录扫描 .js 文件,构造 WorkflowScript 实体 map(按 meta.name 索引)。
|
|
173
|
+
*
|
|
174
|
+
* 不依赖 WorkflowScriptRegistryImpl(其扫描源是固定约定目录,无法指向任意路径)。
|
|
175
|
+
* 直接读文件 + 构造 WorkflowScript(其 validate/toExecutable 是纯函数,可直接用)。
|
|
176
|
+
*/
|
|
177
|
+
function loadWorkflowsFromDir(dir: string): Map<string, WorkflowScript> {
|
|
178
|
+
const scripts = new Map<string, WorkflowScript>();
|
|
179
|
+
const files = readdirSync(dir);
|
|
180
|
+
for (const file of files) {
|
|
181
|
+
if (!file.endsWith(".js")) continue;
|
|
182
|
+
const fullPath = join(dir, file);
|
|
183
|
+
const sourceCode = readFileSync(fullPath, "utf-8");
|
|
184
|
+
const stem = file.replace(/\.js$/, "");
|
|
185
|
+
const meta = extractMeta(sourceCode, stem);
|
|
186
|
+
const source: WorkflowSource = "saved";
|
|
187
|
+
scripts.set(
|
|
188
|
+
meta.name,
|
|
189
|
+
new WorkflowScript({
|
|
190
|
+
name: meta.name,
|
|
191
|
+
source,
|
|
192
|
+
path: fullPath,
|
|
193
|
+
sourceCode,
|
|
194
|
+
meta,
|
|
195
|
+
available: true,
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
return scripts;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 包装 scripts map 为 WorkflowScriptRegistry 接口实现。
|
|
204
|
+
*
|
|
205
|
+
* get(name) 返回对应 WorkflowScript(undefined 当不存在);
|
|
206
|
+
* loadAll() 返回全部;invalidate() no-op(内存 map 无缓存概念)。
|
|
207
|
+
*/
|
|
208
|
+
function makeRegistry(scripts: Map<string, WorkflowScript>): WorkflowScriptRegistry {
|
|
209
|
+
return {
|
|
210
|
+
get: async (name: string) => scripts.get(name),
|
|
211
|
+
loadAll: async () => Array.from(scripts.values()),
|
|
212
|
+
invalidate: () => {},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── 构造完整 LauncherDeps(真实 WorkerHost + 真实 RunStore + mock runner) ─
|
|
217
|
+
|
|
218
|
+
function makeDeps(): LauncherDeps {
|
|
219
|
+
const scripts = loadWorkflowsFromDir(WORKFLOWS_DIR);
|
|
220
|
+
const registry = makeRegistry(scripts);
|
|
221
|
+
const store = new JsonlRunStore({ sessionDir });
|
|
222
|
+
createdStores.push(store);
|
|
223
|
+
const runner = makeMockRunner();
|
|
224
|
+
const base: LifecycleDeps = {
|
|
225
|
+
store,
|
|
226
|
+
workerHost: new WorkerHostImpl(),
|
|
227
|
+
runner,
|
|
228
|
+
runs: new Map(),
|
|
229
|
+
};
|
|
230
|
+
return { ...base, registry };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── setup/teardown ──────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
beforeEach(() => {
|
|
236
|
+
sessionDir = mkdtempSync(join(tmpdir(), "wf-e2e-"));
|
|
237
|
+
createdStores = [];
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
afterEach(() => {
|
|
241
|
+
for (const dir of [sessionDir]) {
|
|
242
|
+
try {
|
|
243
|
+
rmSync(dir, { recursive: true, force: true });
|
|
244
|
+
} catch (e) {
|
|
245
|
+
// 临时目录清理失败(CI 偶发 EBUSY)不影响测试结论
|
|
246
|
+
void e;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
sessionDir = "";
|
|
250
|
+
createdStores = [];
|
|
251
|
+
vi.restoreAllMocks();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// ── 断言 helper:对每个 workflow 的 WorkflowRunResult 做统一终态断言 ──────
|
|
255
|
+
|
|
256
|
+
interface ScriptOutcome {
|
|
257
|
+
status: string;
|
|
258
|
+
message?: string;
|
|
259
|
+
error?: string;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* 断言 runAndWait 返回成功完成的终态。
|
|
264
|
+
*
|
|
265
|
+
* - result.status === "done"(runAndWait 恒 done)
|
|
266
|
+
* - result.reason === "completed"(脚本正常 return,非 failed/aborted/time_limited)
|
|
267
|
+
* - result.scriptResult.status 是 "ok" 或 "partial"(脚本层 outcome,非 "error")
|
|
268
|
+
* - result.error 为 undefined
|
|
269
|
+
*/
|
|
270
|
+
function assertCompleted(
|
|
271
|
+
result: { status: string; reason: string; scriptResult?: unknown; error?: string },
|
|
272
|
+
workflowName: string,
|
|
273
|
+
): void {
|
|
274
|
+
expect(result.status, `${workflowName}: status 应为 done`).toBe("done");
|
|
275
|
+
expect(result.reason, `${workflowName}: reason 应为 completed`).toBe("completed");
|
|
276
|
+
expect(result.error, `${workflowName}: error 应为 undefined`).toBeUndefined();
|
|
277
|
+
const outcome = result.scriptResult as ScriptOutcome | undefined;
|
|
278
|
+
expect(outcome, `${workflowName}: scriptResult 应存在`).toBeDefined();
|
|
279
|
+
expect(outcome!.status, `${workflowName}: outcome.status 不应为 error`).toMatch(
|
|
280
|
+
/^(ok|partial)$/,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ── 超时:真实 worker 启动 + 多轮 agent mock,30s 足够 ─────────────────────
|
|
285
|
+
const RUN_TIMEOUT_MS = 30_000;
|
|
286
|
+
|
|
287
|
+
// ── tests ────────────────────────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
describe("内置 workflow E2E(真实 worker thread + mock LLM runner)", () => {
|
|
290
|
+
it(
|
|
291
|
+
"parallel workflow:多视角并行分析 → 聚合,reason=completed, outcome.status != error",
|
|
292
|
+
async () => {
|
|
293
|
+
const deps = makeDeps();
|
|
294
|
+
const result = await runAndWait(
|
|
295
|
+
"parallel",
|
|
296
|
+
{ target: "src/auth/login.ts" },
|
|
297
|
+
deps,
|
|
298
|
+
undefined,
|
|
299
|
+
RUN_TIMEOUT_MS,
|
|
300
|
+
);
|
|
301
|
+
assertCompleted(result, "parallel");
|
|
302
|
+
const outcome = result.scriptResult as {
|
|
303
|
+
status: string;
|
|
304
|
+
perspectives_analyzed: number;
|
|
305
|
+
per_perspective: unknown[];
|
|
306
|
+
};
|
|
307
|
+
expect(outcome.perspectives_analyzed).toBe(3); // 默认 3 视角
|
|
308
|
+
expect(outcome.per_perspective).toHaveLength(3);
|
|
309
|
+
},
|
|
310
|
+
RUN_TIMEOUT_MS,
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
it(
|
|
314
|
+
"chain workflow:analyze → transform → synthesize 顺序三步,reason=completed, outcome.status != error",
|
|
315
|
+
async () => {
|
|
316
|
+
const deps = makeDeps();
|
|
317
|
+
const result = await runAndWait(
|
|
318
|
+
"chain",
|
|
319
|
+
{ task: "把这段需求文档拆成技术任务" },
|
|
320
|
+
deps,
|
|
321
|
+
undefined,
|
|
322
|
+
RUN_TIMEOUT_MS,
|
|
323
|
+
);
|
|
324
|
+
assertCompleted(result, "chain");
|
|
325
|
+
const outcome = result.scriptResult as {
|
|
326
|
+
status: string;
|
|
327
|
+
phases_run: string[];
|
|
328
|
+
final: { summary: string; recommendation: string };
|
|
329
|
+
};
|
|
330
|
+
expect(outcome.phases_run).toEqual(["analyze", "transform", "synthesize"]);
|
|
331
|
+
expect(outcome.final.summary).toBe("mock"); // mock runner 生成 string→"mock"
|
|
332
|
+
},
|
|
333
|
+
RUN_TIMEOUT_MS,
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
it(
|
|
337
|
+
"map-reduce workflow:parallel map → reduce 两段,reason=completed, outcome.status != error",
|
|
338
|
+
async () => {
|
|
339
|
+
const deps = makeDeps();
|
|
340
|
+
const result = await runAndWait(
|
|
341
|
+
"map-reduce",
|
|
342
|
+
{ operation: "审查代码风格", items: ["file1.ts", "file2.ts"] },
|
|
343
|
+
deps,
|
|
344
|
+
undefined,
|
|
345
|
+
RUN_TIMEOUT_MS,
|
|
346
|
+
);
|
|
347
|
+
assertCompleted(result, "map-reduce");
|
|
348
|
+
const outcome = result.scriptResult as {
|
|
349
|
+
status: string;
|
|
350
|
+
phases_run: string[];
|
|
351
|
+
items_total: number;
|
|
352
|
+
items_mapped: number;
|
|
353
|
+
};
|
|
354
|
+
expect(outcome.phases_run).toEqual(["map", "reduce"]);
|
|
355
|
+
expect(outcome.items_total).toBe(2);
|
|
356
|
+
expect(outcome.items_mapped).toBe(2);
|
|
357
|
+
},
|
|
358
|
+
RUN_TIMEOUT_MS,
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
it(
|
|
362
|
+
"scatter-gather workflow:scatter 拆分 → parallel 处理 → gather 合并 三段,reason=completed, outcome.status != error",
|
|
363
|
+
async () => {
|
|
364
|
+
const deps = makeDeps();
|
|
365
|
+
const result = await runAndWait(
|
|
366
|
+
"scatter-gather",
|
|
367
|
+
{ task: "重构认证模块,涉及 session/jwt/oauth 三块" },
|
|
368
|
+
deps,
|
|
369
|
+
undefined,
|
|
370
|
+
RUN_TIMEOUT_MS,
|
|
371
|
+
);
|
|
372
|
+
assertCompleted(result, "scatter-gather");
|
|
373
|
+
const outcome = result.scriptResult as {
|
|
374
|
+
status: string;
|
|
375
|
+
phases_run: string[];
|
|
376
|
+
subtasks_total: number;
|
|
377
|
+
subtasks_processed: number;
|
|
378
|
+
};
|
|
379
|
+
expect(outcome.phases_run).toEqual(["scatter", "process", "gather"]);
|
|
380
|
+
// mock runner 的 subtasks 对象数组给 2 项
|
|
381
|
+
expect(outcome.subtasks_total).toBe(2);
|
|
382
|
+
expect(outcome.subtasks_processed).toBe(2);
|
|
383
|
+
},
|
|
384
|
+
RUN_TIMEOUT_MS,
|
|
385
|
+
);
|
|
386
|
+
});
|
|
@@ -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
|
/**
|
|
@@ -49,21 +49,43 @@
|
|
|
49
49
|
export function buildWorkerScript(userScript: string): string {
|
|
50
50
|
return [
|
|
51
51
|
'"use strict";',
|
|
52
|
-
'// Module-scope: accessible to the
|
|
52
|
+
'// Module-scope helpers: accessible to BOTH the IIFE body AND the outer',
|
|
53
|
+
'// .then()/.catch() handlers (which run outside the IIFE). _workerLogs/',
|
|
54
|
+
'// _pushWorkerLog/_safePost + the parentPort/workerData handles must all live',
|
|
55
|
+
'// here — a previous version declared _safePost inside the IIFE, so the',
|
|
56
|
+
'// .then()/.catch() return/error handlers threw ReferenceError: _safePost is',
|
|
57
|
+
'// not defined, crashing the Worker on EVERY script return (exit code 1) and',
|
|
58
|
+
'// losing all diagnostics. require() is cached, so destructuring once at module',
|
|
59
|
+
'// load and reusing everywhere avoids the redundant require calls that used to',
|
|
60
|
+
'// appear in the IIFE and the outer .then/.catch.',
|
|
61
|
+
'const { parentPort: _parentPort, workerData: _workerData } = require("node:worker_threads");',
|
|
53
62
|
'const _workerLogs = [];',
|
|
54
63
|
'function _pushWorkerLog(level, args) {',
|
|
55
64
|
' try { _workerLogs.push({ level, message: args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ") }); } catch (e) { /* swallow */ }',
|
|
56
65
|
'}',
|
|
66
|
+
'// ── safePostMessage wrapper: 统一 postMessage 防御(DataCloneError 等)──',
|
|
67
|
+
'// Module-scope so the outer .then/.catch return/error handlers can use it.',
|
|
68
|
+
'// context 取值约定(诊断标识):固定为消息类型字面量——',
|
|
69
|
+
'// "agent-call" / "workflow-call" / "return" / "error",调用方据此在日志里',
|
|
70
|
+
'// 一眼定位是哪类 postMessage 失败。新增调用点必须传对应 context。',
|
|
71
|
+
'function _safePost(msg, context) {',
|
|
72
|
+
' try { _parentPort.postMessage(msg); return true; }',
|
|
73
|
+
' catch (e) {',
|
|
74
|
+
' const errMsg = e && e.message ? e.message : String(e);',
|
|
75
|
+
' const stack = e && e.stack ? e.stack : "";',
|
|
76
|
+
' _pushWorkerLog("error", ["[postMessage failed:" + context + "]", errMsg, stack]);',
|
|
77
|
+
' return false;',
|
|
78
|
+
' }',
|
|
79
|
+
'}',
|
|
57
80
|
'(async () => {',
|
|
58
|
-
' const
|
|
81
|
+
' const parentPort = _parentPort;',
|
|
82
|
+
' const workerData = _workerData;',
|
|
59
83
|
'',
|
|
60
84
|
' if (!parentPort) {',
|
|
61
85
|
' throw new Error("Workflow worker: parentPort is null — not running in a Worker thread");',
|
|
62
86
|
' }',
|
|
63
87
|
'',
|
|
64
88
|
' // ── Intercept console.* to avoid leaking worker diagnostics into the input area ──',
|
|
65
|
-
' // _workerLogs + _pushWorkerLog are declared at module scope (above the IIFE)',
|
|
66
|
-
' // so the outer .catch() can include them on script errors.',
|
|
67
89
|
' console.log = function (...args) { _pushWorkerLog("log", args); };',
|
|
68
90
|
' console.warn = function (...args) { _pushWorkerLog("warn", args); };',
|
|
69
91
|
' console.error = function (...args) { _pushWorkerLog("error", args); };',
|
|
@@ -201,7 +223,9 @@ export function buildWorkerScript(userScript: string): string {
|
|
|
201
223
|
' const _effectivePhase = opts.phase || _currentPhase;\n' +
|
|
202
224
|
' delete opts.phase;\n' +
|
|
203
225
|
'\n' +
|
|
204
|
-
'
|
|
226
|
+
' if (!_safePost({ type: "agent-call", callId, opts, phase: _effectivePhase }, "agent-call")) {',
|
|
227
|
+
' return Promise.reject(new Error("postMessage failed for agent-call (callId=" + callId + "): see workerLogs"));',
|
|
228
|
+
' }',
|
|
205
229
|
' return new Promise((resolve, reject) => {',
|
|
206
230
|
' _pendingCalls.set(callId, { resolve, reject });',
|
|
207
231
|
' });',
|
|
@@ -212,14 +236,34 @@ export function buildWorkerScript(userScript: string): string {
|
|
|
212
236
|
// 不拖垮整批。rejected 结果降级为错误消息字符串(与 agent() 的 error→content 回退一致,
|
|
213
237
|
// parseResult(string) → null → 脚本 soft-fail)。B1 之后 agent() 不再因 agent 失败 reject,
|
|
214
238
|
// 这里作为纵深防御保留。
|
|
239
|
+
//
|
|
240
|
+
// **Promise 项处理**:CC-compatible 写法 `parallel([agent({...}), ...])` 传入的是已实例化
|
|
241
|
+
// 的 Promise 数组(agent() 同步返回 Promise)。Promise 是 object 但无 .then 鸭辨分支时
|
|
242
|
+
// 会落到 `agent(c)` 把 Promise 当 opts 传 → postMessage DataCloneError。必须在函数/opts
|
|
243
|
+
// 分支前用 thenable 鸭辨直接返回 in-flight Promise,让 allSettled 接管。
|
|
215
244
|
' async function parallel(calls) {',
|
|
216
245
|
' if (typeof calls === "function") { return calls(); }',
|
|
217
246
|
' const settled = await Promise.allSettled(calls.map((c) => {',
|
|
247
|
+
' if (c && typeof c.then === "function") { return c; }',
|
|
218
248
|
' if (typeof c === "function") { return c(); }',
|
|
219
249
|
' if (typeof c === "object" && c !== null && (c.task || c.agent)) { return agent(c); }',
|
|
220
250
|
' return agent(c);',
|
|
221
251
|
' }));',
|
|
222
|
-
' return settled.map((r) =>
|
|
252
|
+
' return settled.map((r) => {',
|
|
253
|
+
' if (r.status === "fulfilled") {',
|
|
254
|
+
' const v = r.value;',
|
|
255
|
+
' if (v !== null && typeof v === "object" && !Array.isArray(v)) {',
|
|
256
|
+
' // 主线程 fallback(postAgentResult/postResult serialization failed)回发的对象含 error 字段',
|
|
257
|
+
' // → 归一化为 failed 形状,与脚本侧 r.status === "failed" 检查统一',
|
|
258
|
+
' if (typeof v.error === "string" && v.error.length > 0) return { status: "failed", error: v.error };',
|
|
259
|
+
' return v;',
|
|
260
|
+
' }',
|
|
261
|
+
' return { status: "failed", error: "agent returned non-object result (type=" + typeof v + ")" };',
|
|
262
|
+
' }',
|
|
263
|
+
' const reason = r.reason;',
|
|
264
|
+
' const errMsg = reason instanceof Error ? reason.message : String(reason);',
|
|
265
|
+
' return { status: "failed", error: errMsg };',
|
|
266
|
+
' });',
|
|
223
267
|
' }',
|
|
224
268
|
'',
|
|
225
269
|
// ── pipeline global ──
|
|
@@ -227,19 +271,31 @@ export function buildWorkerScript(userScript: string): string {
|
|
|
227
271
|
' // Single-arg mode: pipeline([stage1, stage2, ...])',
|
|
228
272
|
' if (Array.isArray(firstArg) && restStages.length === 0) {',
|
|
229
273
|
' let result;',
|
|
230
|
-
' for (
|
|
274
|
+
' for (let i = 0; i < firstArg.length; i++) {',
|
|
275
|
+
' try { result = await firstArg[i](result); }',
|
|
276
|
+
' catch (e) {',
|
|
277
|
+
' const msg = e && e.message ? e.message : String(e);',
|
|
278
|
+
' _pushWorkerLog("error", ["[pipeline stage " + i + " failed]", msg]);',
|
|
279
|
+
' throw e;',
|
|
280
|
+
' }',
|
|
281
|
+
' }',
|
|
231
282
|
' return result;',
|
|
232
283
|
' }',
|
|
233
284
|
' // Cartesian product mode: pipeline([items], stage1, stage2, ...)',
|
|
234
285
|
' if (Array.isArray(firstArg) && restStages.length > 0 && typeof restStages[0] === "function") {',
|
|
235
286
|
' const results = [];',
|
|
236
|
-
' for (
|
|
287
|
+
' for (let idx = 0; idx < firstArg.length; idx++) {',
|
|
288
|
+
' const item = firstArg[idx];',
|
|
237
289
|
' let val = item;',
|
|
238
290
|
' let failed = false;',
|
|
239
291
|
' for (const stage of restStages) {',
|
|
240
292
|
' if (failed) break;',
|
|
241
293
|
' try { val = await stage(val); }',
|
|
242
|
-
' catch (e) {
|
|
294
|
+
' catch (e) {',
|
|
295
|
+
' const msg = e && e.message ? e.message : String(e);',
|
|
296
|
+
' _pushWorkerLog("error", ["[pipeline cartesian stage failed for item " + (idx + 1) + "]", msg]);',
|
|
297
|
+
' val = null; failed = true;',
|
|
298
|
+
' }',
|
|
243
299
|
' }',
|
|
244
300
|
' results.push(val);',
|
|
245
301
|
' }',
|
|
@@ -256,7 +312,9 @@ export function buildWorkerScript(userScript: string): string {
|
|
|
256
312
|
' const workflowArgs = (typeof args === "object" && args !== null) ? args : {};',
|
|
257
313
|
' const callId = _callIdCounter;',
|
|
258
314
|
' _callIdCounter++;',
|
|
259
|
-
'
|
|
315
|
+
' if (!_safePost({ type: "workflow-call", callId, name, args: workflowArgs }, "workflow-call")) {',
|
|
316
|
+
' return Promise.reject(new Error("postMessage failed for workflow-call (name=" + name + "): see workerLogs"));',
|
|
317
|
+
' }',
|
|
260
318
|
' return new Promise((resolve, reject) => {',
|
|
261
319
|
' _pendingCalls.set(callId, { resolve, reject });',
|
|
262
320
|
' });',
|
|
@@ -270,13 +328,11 @@ export function buildWorkerScript(userScript: string): string {
|
|
|
270
328
|
' return await module.exports.execute({ agent, parallel, pipeline, phase, log, workflow, $ARGS, $WORKSPACE, $BUDGET });',
|
|
271
329
|
' }',
|
|
272
330
|
'})().then((result) => {',
|
|
273
|
-
' const
|
|
274
|
-
'
|
|
275
|
-
' parentPort.postMessage({ type: "return", runId, result, workerLogs: _workerLogs });',
|
|
331
|
+
' const runId = (_workerData.args && typeof _workerData.args === "object" && _workerData.args._runId) || "";',
|
|
332
|
+
' _safePost({ type: "return", runId, result, workerLogs: _workerLogs }, "return");',
|
|
276
333
|
'}).catch((err) => {',
|
|
277
|
-
' const
|
|
278
|
-
'
|
|
279
|
-
' parentPort.postMessage({ type: "error", runId, error: err.message || String(err), workerLogs: _workerLogs });',
|
|
334
|
+
' const runId = (_workerData.args && typeof _workerData.args === "object" && _workerData.args._runId) || "";',
|
|
335
|
+
' _safePost({ type: "error", runId, error: err.message || String(err), workerLogs: _workerLogs }, "error");',
|
|
280
336
|
'});',
|
|
281
337
|
].join("\n");
|
|
282
338
|
}
|