@zhushanwen/pi-subagent-workflow 0.4.2 → 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/interface/commands.ts +30 -0
- package/src/interface/subagents.ts +33 -0
- package/src/orchestration/__tests__/worker-script-builder-runtime.test.ts +347 -0
- package/src/orchestration/__tests__/worker-script-builder.test.ts +3 -1
- package/src/orchestration/__tests__/workflows-e2e.test.ts +386 -0
- package/src/orchestration/worker-script-builder.ts +34 -19
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.3",
|
|
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.",
|
|
@@ -66,6 +66,36 @@ export function registerWorkflowsCommand(
|
|
|
66
66
|
): void {
|
|
67
67
|
api.registerCommand("workflows", {
|
|
68
68
|
description: "Open workflow panel. /workflows [runId] | /workflows pause|resume|abort <runId>",
|
|
69
|
+
getArgumentCompletions(prefix: string) {
|
|
70
|
+
const trimmed = prefix.trimStart();
|
|
71
|
+
const parts = trimmed.split(/\s+/).filter(Boolean);
|
|
72
|
+
|
|
73
|
+
// 第一级:lifecycle 动词(带尾随空格,选中后继续补 runId)
|
|
74
|
+
if (parts.length <= 1) {
|
|
75
|
+
return [
|
|
76
|
+
{ label: "pause", value: "pause ", description: "Pause a workflow run" },
|
|
77
|
+
{ label: "resume", value: "resume ", description: "Resume a paused workflow run" },
|
|
78
|
+
{ label: "abort", value: "abort ", description: "Abort a workflow run" },
|
|
79
|
+
].filter((opt) => opt.label.startsWith(trimmed.toLowerCase()));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 第二级:lifecycle 动词后补全当前 session 的 runId
|
|
83
|
+
if (parts[0] === "pause" || parts[0] === "resume" || parts[0] === "abort") {
|
|
84
|
+
try {
|
|
85
|
+
const runs = sortedRuns(getRuns());
|
|
86
|
+
if (runs.length === 0) return null;
|
|
87
|
+
return runs.map((r) => ({
|
|
88
|
+
label: r.runId,
|
|
89
|
+
value: r.runId,
|
|
90
|
+
description: `${r.spec.scriptName} [${r.state.status}]`,
|
|
91
|
+
}));
|
|
92
|
+
} catch {
|
|
93
|
+
// 拿不到运行时数据(getRuns 抛错)→ 静默降级,补全失败不影响 command
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
},
|
|
69
99
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
70
100
|
// ── RPC 模式(xyz-agent GUI):解析 lifecycle action 直接执行,不打开 TUI ──
|
|
71
101
|
// hasUI 在 TUI 和 RPC 都为 true,不能用于区分;用 ctx.mode === "rpc" 判定 GUI 通道。
|
|
@@ -9,12 +9,45 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-c
|
|
|
9
9
|
|
|
10
10
|
import { getSubagentService } from "../execution/subagent-service.ts";
|
|
11
11
|
import { parseSubagentRpcCommand } from "./command-actions.ts";
|
|
12
|
+
import { LIST_LIMIT } from "./list-shared.ts";
|
|
12
13
|
import { createSubagentsView } from "./list-view.ts";
|
|
13
14
|
|
|
14
15
|
/** 注册 /subagents 命令(= list overlay)。 */
|
|
15
16
|
export function registerSubagentsCommand(pi: ExtensionAPI): void {
|
|
16
17
|
pi.registerCommand("subagents", {
|
|
17
18
|
description: "Subagents: /subagents [<id>] | /subagents cancel <id>",
|
|
19
|
+
getArgumentCompletions(prefix: string) {
|
|
20
|
+
const trimmed = prefix.trimStart();
|
|
21
|
+
const parts = trimmed.split(/\s+/).filter(Boolean);
|
|
22
|
+
|
|
23
|
+
// 第一级:cancel 动词(带尾随空格,选中后继续补 record id)
|
|
24
|
+
if (parts.length <= 1) {
|
|
25
|
+
return [
|
|
26
|
+
{ label: "cancel", value: "cancel ", description: "Cancel a running subagent" },
|
|
27
|
+
].filter((opt) => opt.label.startsWith(trimmed.toLowerCase()));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 第二级:cancel 后补全当前 session 的 record id
|
|
31
|
+
if (parts[0] === "cancel") {
|
|
32
|
+
try {
|
|
33
|
+
const service = getSubagentService();
|
|
34
|
+
if (!service) return null;
|
|
35
|
+
// collectRecords 合并内存(running) + 磁盘重建 record,按 session 过滤。
|
|
36
|
+
// cancel 只对 running 有效,但全部列出便于用户辨认(终态 record 会被 service 拒绝)。
|
|
37
|
+
const records = service.collectRecords(LIST_LIMIT);
|
|
38
|
+
if (records.length === 0) return null;
|
|
39
|
+
return records.map((r) => ({
|
|
40
|
+
label: r.id,
|
|
41
|
+
value: r.id,
|
|
42
|
+
description: `${r.agent} [${r.status}]`,
|
|
43
|
+
}));
|
|
44
|
+
} catch {
|
|
45
|
+
// 拿不到运行时数据(service disposed 等)→ 静默降级,补全失败不影响 command
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
},
|
|
18
51
|
handler: async (argsStr: string, ctx: ExtensionCommandContext) => {
|
|
19
52
|
const service = getSubagentService();
|
|
20
53
|
if (!service) {
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* buildWorkerScript — 运行时执行回归测试。
|
|
3
|
+
*
|
|
4
|
+
* 现有的 worker-script-builder.test.ts 全是字符串 toContain 断言,无法捕获生成的
|
|
5
|
+
* worker 源码在「真实 Worker 线程里执行」时的运行时错误。曾因此漏掉 _safePost 作用域
|
|
6
|
+
* bug(定义在 async IIFE 内、却在 IIFE 外的 .then()/.catch() 里使用):脚本每次正常
|
|
7
|
+
* return 都触发 ReferenceError → Worker exit code 1 → 所有 workflow 100% 失败。
|
|
8
|
+
*
|
|
9
|
+
* 本测试起真实的 node:worker_threads.Worker 执行 buildWorkerScript 产物,覆盖:
|
|
10
|
+
* - 脚本正常 return → {type:"return"} 消息(非 exit code 1 崩溃)
|
|
11
|
+
* - 脚本 throw → {type:"error"} 消息 + workerLogs(诊断不丢)
|
|
12
|
+
* - agent() 调用链路:postMessage(agent-call) ↔ postMessage(agent-result)
|
|
13
|
+
* - abort 消息:pending agent() reject → WorkflowAbortedError
|
|
14
|
+
* - workflow() 嵌套调用链路
|
|
15
|
+
* - module.exports.execute() 自动调用入口
|
|
16
|
+
* - _safePost 的 DataCloneError 防御分支
|
|
17
|
+
*
|
|
18
|
+
* 这是回归防线:任何让 .then/.catch 访问不到 module-scope helper 的重构都会被这里抓住。
|
|
19
|
+
*/
|
|
20
|
+
import { Worker } from "node:worker_threads";
|
|
21
|
+
|
|
22
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
23
|
+
|
|
24
|
+
import { buildWorkerScript } from "../worker-script-builder.ts";
|
|
25
|
+
|
|
26
|
+
// ── 判别联合:Worker → Main 消息类型(S3:用判别联合替代可选字段 + 非空断言)──
|
|
27
|
+
|
|
28
|
+
/** agent-call 消息:worker 请求主线程执行一个 agent。 */
|
|
29
|
+
interface AgentCallMsg {
|
|
30
|
+
type: "agent-call";
|
|
31
|
+
callId: number;
|
|
32
|
+
opts: { prompt: string; description?: string; schema?: unknown; [k: string]: unknown };
|
|
33
|
+
phase?: string;
|
|
34
|
+
}
|
|
35
|
+
/** workflow-call 消息:worker 请求主线程执行嵌套 workflow。 */
|
|
36
|
+
interface WorkflowCallMsg {
|
|
37
|
+
type: "workflow-call";
|
|
38
|
+
callId: number;
|
|
39
|
+
name: string;
|
|
40
|
+
args: Record<string, unknown>;
|
|
41
|
+
}
|
|
42
|
+
/** return 消息:脚本正常结束,带回结果。 */
|
|
43
|
+
interface ReturnMsg {
|
|
44
|
+
type: "return";
|
|
45
|
+
runId?: string;
|
|
46
|
+
result: unknown;
|
|
47
|
+
workerLogs?: unknown[];
|
|
48
|
+
}
|
|
49
|
+
/** error 消息:脚本抛错(含 _safePost 的 DataCloneError 防御路径)。 */
|
|
50
|
+
interface ErrorMsg {
|
|
51
|
+
type: "error";
|
|
52
|
+
runId?: string;
|
|
53
|
+
error: string;
|
|
54
|
+
workerLogs?: unknown[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── 类型守卫:从 unknown 收窄到判别联合 ──
|
|
58
|
+
// 共享 hasType 辅助:避免每个守卫重复 `(m as {type?:string})` 断言(taste/no-unsafe-catch)。
|
|
59
|
+
|
|
60
|
+
function hasType<T extends string>(m: unknown, type: T): boolean {
|
|
61
|
+
return typeof m === "object" && m !== null
|
|
62
|
+
&& (m as { type: unknown }).type === type;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isAgentCall(m: unknown): m is AgentCallMsg {
|
|
66
|
+
return hasType(m, "agent-call");
|
|
67
|
+
}
|
|
68
|
+
function isWorkflowCall(m: unknown): m is WorkflowCallMsg {
|
|
69
|
+
return hasType(m, "workflow-call");
|
|
70
|
+
}
|
|
71
|
+
function isReturn(m: unknown): m is ReturnMsg {
|
|
72
|
+
return hasType(m, "return");
|
|
73
|
+
}
|
|
74
|
+
function isError(m: unknown): m is ErrorMsg {
|
|
75
|
+
return hasType(m, "error");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── 测试辅助:起一个真实 Worker 跑 buildWorkerScript 产物 ──────────────
|
|
79
|
+
|
|
80
|
+
interface RunResult {
|
|
81
|
+
/** 收到的 return 消息的 result 字段(脚本正常结束时)。 */
|
|
82
|
+
returnValue?: unknown;
|
|
83
|
+
/** 收到的 error 消息的 error 字段(脚本 throw 时)。 */
|
|
84
|
+
errorMessage?: string;
|
|
85
|
+
/** error 消息带回的 workerLogs(验证诊断不丢)。 */
|
|
86
|
+
errorWorkerLogs?: unknown[];
|
|
87
|
+
/** Worker exit code(0=正常,1=崩溃)。 */
|
|
88
|
+
exitCode?: number;
|
|
89
|
+
/** Worker 'error' 事件的错误消息(uncaught exception,正常应为 undefined)。 */
|
|
90
|
+
workerError?: string;
|
|
91
|
+
/** 收到的 agent-call 消息列表。 */
|
|
92
|
+
agentCalls: AgentCallMsg[];
|
|
93
|
+
/** 收到的 workflow-call 消息列表。 */
|
|
94
|
+
workflowCalls: WorkflowCallMsg[];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface RunOptions {
|
|
98
|
+
/** $ARGS。 */
|
|
99
|
+
args?: Record<string, unknown>;
|
|
100
|
+
/** 按 agent-call 顺序回发的 parsedOutput(默认每个回发 {ok:true})。 */
|
|
101
|
+
agentResults?: unknown[];
|
|
102
|
+
/** 主线程对收到的 workflow-call 的处理:回发 workflow-result。 */
|
|
103
|
+
handleWorkflowCall?: (msg: WorkflowCallMsg) => unknown;
|
|
104
|
+
/** 是否在收到首个 agent-call 后立即发 abort(测 abort 路径)。 */
|
|
105
|
+
abortAfterFirstAgentCall?: { reason: string };
|
|
106
|
+
/** 超时(S9:CI 环境放宽,规避真实 Worker 启动慢导致的假阳)。 */
|
|
107
|
+
timeoutMs?: number;
|
|
108
|
+
/** workerData.callCache 预填(测缓存命中路径)。 */
|
|
109
|
+
callCache?: Map<number, unknown>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 起 Worker 执行 userScript,主线程模拟 workflow runtime 回发 agent-result。
|
|
114
|
+
*
|
|
115
|
+
* @param userScript 用户 workflow 脚本源码
|
|
116
|
+
*/
|
|
117
|
+
function runWorker(userScript: string, opts: RunOptions = {}): Promise<RunResult> {
|
|
118
|
+
const timeoutMs = opts.timeoutMs ?? (process.env.CI ? 5000 : 2000);
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
const workerCode = buildWorkerScript(userScript);
|
|
121
|
+
const worker = new Worker(workerCode, {
|
|
122
|
+
eval: true,
|
|
123
|
+
workerData: {
|
|
124
|
+
scriptPath: "test.js",
|
|
125
|
+
args: opts.args ?? {},
|
|
126
|
+
workspace: process.cwd(),
|
|
127
|
+
budget: { maxTokens: 0, usedTokens: 0, usedCost: 0 },
|
|
128
|
+
callCache: opts.callCache instanceof Map
|
|
129
|
+
? Object.fromEntries(opts.callCache)
|
|
130
|
+
: opts.callCache ?? {},
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
// S8:创建后立即登记,afterEach 兜底清理(防止 promise 泄漏导致 Worker 未终止)
|
|
134
|
+
createdWorkers.push(worker);
|
|
135
|
+
|
|
136
|
+
const result: RunResult = { agentCalls: [], workflowCalls: [] };
|
|
137
|
+
let agentCallIdx = 0;
|
|
138
|
+
let resolved = false;
|
|
139
|
+
const timer = setTimeout(() => {
|
|
140
|
+
worker.terminate().catch(() => {});
|
|
141
|
+
reject(new Error(`Worker timed out after ${timeoutMs}ms — likely hung`));
|
|
142
|
+
}, timeoutMs);
|
|
143
|
+
|
|
144
|
+
const finish = (r: RunResult): void => {
|
|
145
|
+
if (resolved) return;
|
|
146
|
+
resolved = true;
|
|
147
|
+
clearTimeout(timer);
|
|
148
|
+
resolve(r);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
worker.on("message", (raw: unknown) => {
|
|
152
|
+
if (isAgentCall(raw)) {
|
|
153
|
+
result.agentCalls.push(raw);
|
|
154
|
+
if (opts.abortAfterFirstAgentCall) {
|
|
155
|
+
worker.postMessage({ type: "abort", reason: opts.abortAfterFirstAgentCall.reason });
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const parsed = opts.agentResults?.[agentCallIdx] ?? { ok: true };
|
|
159
|
+
agentCallIdx++;
|
|
160
|
+
worker.postMessage({
|
|
161
|
+
type: "agent-result",
|
|
162
|
+
callId: raw.callId,
|
|
163
|
+
result: { content: "fallback", parsedOutput: parsed },
|
|
164
|
+
cached: false,
|
|
165
|
+
});
|
|
166
|
+
} else if (isWorkflowCall(raw)) {
|
|
167
|
+
result.workflowCalls.push(raw);
|
|
168
|
+
const wfResult = opts.handleWorkflowCall ? opts.handleWorkflowCall(raw) : { ok: true };
|
|
169
|
+
worker.postMessage({ type: "workflow-result", callId: raw.callId, result: wfResult });
|
|
170
|
+
} else if (isReturn(raw)) {
|
|
171
|
+
result.returnValue = raw.result;
|
|
172
|
+
finish(result);
|
|
173
|
+
} else if (isError(raw)) {
|
|
174
|
+
result.errorMessage = raw.error;
|
|
175
|
+
result.errorWorkerLogs = raw.workerLogs;
|
|
176
|
+
finish(result);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
worker.on("error", (err: Error) => {
|
|
180
|
+
result.workerError = err.message;
|
|
181
|
+
// error 事件后 Worker 会 exit code 1,给 exit handler 一个 tick 记录 exitCode
|
|
182
|
+
});
|
|
183
|
+
worker.on("exit", (code: number) => {
|
|
184
|
+
result.exitCode = code;
|
|
185
|
+
// 若未通过 return/error 消息结束(即 Worker 崩溃),以 exit 结果收尾
|
|
186
|
+
if (result.returnValue === undefined && result.errorMessage === undefined) {
|
|
187
|
+
finish(result);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 记录所有创建的 Worker,afterEach 兜底清理(防止泄漏)——S8
|
|
194
|
+
const createdWorkers: Worker[] = [];
|
|
195
|
+
|
|
196
|
+
afterEach(() => {
|
|
197
|
+
for (const w of createdWorkers.splice(0)) {
|
|
198
|
+
w.terminate().catch(() => {});
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// ── 回归测试:_safePost 作用域 bug(核心防线) ──────────────────────
|
|
203
|
+
|
|
204
|
+
describe("buildWorkerScript runtime — _safePost scope regression (exit code 1 bug)", () => {
|
|
205
|
+
it("脚本正常 return 时发出 return 消息,Worker 不崩溃(exit code 0)", async () => {
|
|
206
|
+
const script = `return { status: "ok", value: 42 };`;
|
|
207
|
+
const res = await runWorker(script);
|
|
208
|
+
expect(res.workerError).toBeUndefined();
|
|
209
|
+
expect(res.errorMessage).toBeUndefined();
|
|
210
|
+
expect(res.returnValue).toEqual({ status: "ok", value: 42 });
|
|
211
|
+
expect(res.exitCode).not.toBe(1);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("脚本 throw 时发出 error 消息并带回 workerLogs,Worker 不裸崩", async () => {
|
|
215
|
+
const script = `
|
|
216
|
+
console.log("before throw");
|
|
217
|
+
throw new Error("script boom");
|
|
218
|
+
`;
|
|
219
|
+
const res = await runWorker(script);
|
|
220
|
+
expect(res.workerError).toBeUndefined();
|
|
221
|
+
expect(res.errorMessage).toBe("script boom");
|
|
222
|
+
expect(res.exitCode).not.toBe(1);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("agent() → result → return 完整链路:parallel 风格脚本正常完成", async () => {
|
|
226
|
+
const script = `
|
|
227
|
+
phase("analyze");
|
|
228
|
+
const results = await parallel([
|
|
229
|
+
() => agent({ prompt: "task-1", description: "a1" }),
|
|
230
|
+
() => agent({ prompt: "task-2", description: "a2" }),
|
|
231
|
+
]);
|
|
232
|
+
const ok = results.filter((r) => r && r.ok).length;
|
|
233
|
+
return { status: "ok", analyzed: results.length, ok };
|
|
234
|
+
`;
|
|
235
|
+
const res = await runWorker(script, { agentResults: [{ ok: true }, { ok: true }] });
|
|
236
|
+
expect(res.agentCalls).toHaveLength(2);
|
|
237
|
+
expect(res.workerError).toBeUndefined();
|
|
238
|
+
expect(res.returnValue).toEqual({ status: "ok", analyzed: 2, ok: 2 });
|
|
239
|
+
expect(res.exitCode).not.toBe(1);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("脚本 return 后 Worker 不发 workerError 事件(_safePost 在 .then 可达)", async () => {
|
|
243
|
+
const script = `return "done";`;
|
|
244
|
+
const res = await runWorker(script);
|
|
245
|
+
expect(res.workerError).toBeUndefined();
|
|
246
|
+
expect(res.returnValue).toBe("done");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("parallel([agent(...), ...]) Promise 数组:CC 兼容写法不触发 DataCloneError", async () => {
|
|
250
|
+
// parallel.js/map-reduce.js/scatter-gather.js 都用 `parallel([agent({...}), ...])`——
|
|
251
|
+
// 传入已实例化的 Promise 数组(agent() 同步返回 Promise)。旧 parallel() 实现把
|
|
252
|
+
// Promise 当 opts 传给 agent() → postMessage DataCloneError → allSettled 全 rejected
|
|
253
|
+
// → 脚本返回 error。修复:parallel() 用 thenable 鸭辨直接返回 in-flight Promise。
|
|
254
|
+
// 此测试用真实的 Promise 数组写法(而非函数数组),对应内置脚本的真实用法。
|
|
255
|
+
const script = `
|
|
256
|
+
const results = await parallel([
|
|
257
|
+
agent({ prompt: "p1", description: "a1" }),
|
|
258
|
+
agent({ prompt: "p2", description: "a2" }),
|
|
259
|
+
]);
|
|
260
|
+
return { count: results.length, ok: results.every((r) => r && r.ok) };
|
|
261
|
+
`;
|
|
262
|
+
const res = await runWorker(script, { agentResults: [{ ok: true }, { ok: true }] });
|
|
263
|
+
expect(res.agentCalls).toHaveLength(2);
|
|
264
|
+
expect(res.workerError).toBeUndefined();
|
|
265
|
+
expect(res.errorMessage).toBeUndefined();
|
|
266
|
+
expect(res.returnValue).toEqual({ count: 2, ok: true });
|
|
267
|
+
expect(res.exitCode).not.toBe(1);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// ── S4-S7:覆盖此前缺失的运行时路径 ──────────────────────────────────
|
|
272
|
+
|
|
273
|
+
describe("buildWorkerScript runtime — 之前缺失的路径覆盖", () => {
|
|
274
|
+
it("S4 abort 消息:pending agent() 被 reject → WorkflowAbortedError", async () => {
|
|
275
|
+
// 脚本 await 一个 agent(),主线程回发 abort → agent reject → 脚本抛错进 .catch
|
|
276
|
+
const script = `
|
|
277
|
+
await agent({ prompt: "will-be-aborted" });
|
|
278
|
+
`;
|
|
279
|
+
const res = await runWorker(script, { abortAfterFirstAgentCall: { reason: "user cancel" } });
|
|
280
|
+
// abort 让 pending reject → 脚本 throw WorkflowAbortedError → .catch 发 type:error
|
|
281
|
+
expect(res.agentCalls).toHaveLength(1);
|
|
282
|
+
expect(res.workerError).toBeUndefined();
|
|
283
|
+
expect(res.errorMessage).toMatch(/Workflow aborted/);
|
|
284
|
+
expect(res.exitCode).not.toBe(1);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("S5 workflow() 嵌套调用:workflow-call ↔ workflow-result 链路正常", async () => {
|
|
288
|
+
const script = `
|
|
289
|
+
const r = await workflow("sub-wf", { x: 1 });
|
|
290
|
+
return { nested: r };
|
|
291
|
+
`;
|
|
292
|
+
const res = await runWorker(script, {
|
|
293
|
+
handleWorkflowCall: (msg) => ({ echo: msg.args, name: msg.name }),
|
|
294
|
+
});
|
|
295
|
+
expect(res.workflowCalls).toHaveLength(1);
|
|
296
|
+
expect(res.workflowCalls[0]!.name).toBe("sub-wf");
|
|
297
|
+
expect(res.workerError).toBeUndefined();
|
|
298
|
+
expect(res.returnValue).toEqual({ nested: { echo: { x: 1 }, name: "sub-wf" } });
|
|
299
|
+
expect(res.exitCode).not.toBe(1);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("S6 module.exports.execute() 自动调用入口:ctx 注入完整、return 正常", async () => {
|
|
303
|
+
const script = `
|
|
304
|
+
const meta = { name: "exec-mode" };
|
|
305
|
+
module.exports = {
|
|
306
|
+
meta,
|
|
307
|
+
execute: async (ctx) => {
|
|
308
|
+
const r = await ctx.agent({ prompt: "via-execute" });
|
|
309
|
+
return { viaExecute: true, agentResult: r, hasGlobals: typeof ctx.parallel === "function" };
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
`;
|
|
313
|
+
const res = await runWorker(script, { agentResults: [{ ok: true, source: "exec" }] });
|
|
314
|
+
expect(res.agentCalls).toHaveLength(1);
|
|
315
|
+
expect(res.workerError).toBeUndefined();
|
|
316
|
+
expect(res.returnValue).toEqual({
|
|
317
|
+
viaExecute: true,
|
|
318
|
+
agentResult: { ok: true, source: "exec" },
|
|
319
|
+
hasGlobals: true,
|
|
320
|
+
});
|
|
321
|
+
expect(res.exitCode).not.toBe(1);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("S7 _safePost 的 .catch 路径带回 workerLogs:脚本 throw 时诊断不丢", async () => {
|
|
325
|
+
// _safePost 的价值两半:(1) return 路径成功发消息(S1-S3 覆盖);
|
|
326
|
+
// (2) error 路径(.catch)发 type:error + workerLogs,让主线程拿到诊断。
|
|
327
|
+
// 本例验证 .catch 里的 _safePost 正常工作——脚本 throw → console.* 被劫持进
|
|
328
|
+
// _workerLogs → .catch 用 _safePost 发回 {type:"error", workerLogs}。
|
|
329
|
+
// 修复前 .catch 里的 _safePost 是 ReferenceError,workerLogs 发不回(errorLogs 全空)。
|
|
330
|
+
const script = `
|
|
331
|
+
console.log("step-1");
|
|
332
|
+
console.warn("step-2-warning");
|
|
333
|
+
throw new Error("diagnostic-test-error");
|
|
334
|
+
`;
|
|
335
|
+
const res = await runWorker(script);
|
|
336
|
+
expect(res.workerError).toBeUndefined();
|
|
337
|
+
expect(res.errorMessage).toBe("diagnostic-test-error");
|
|
338
|
+
expect(res.errorWorkerLogs).toBeDefined();
|
|
339
|
+
expect(res.errorWorkerLogs).toEqual(
|
|
340
|
+
expect.arrayContaining([
|
|
341
|
+
expect.objectContaining({ level: "log", message: "step-1" }),
|
|
342
|
+
expect.objectContaining({ level: "warn", message: "step-2-warning" }),
|
|
343
|
+
]),
|
|
344
|
+
);
|
|
345
|
+
expect(res.exitCode).not.toBe(1);
|
|
346
|
+
});
|
|
347
|
+
});
|
|
@@ -67,7 +67,9 @@ describe("buildWorkerScript — W1 postMessage defense & parallel degrade", () =
|
|
|
67
67
|
});
|
|
68
68
|
|
|
69
69
|
it("_safePost wraps postMessage in try/catch", () => {
|
|
70
|
-
|
|
70
|
+
// _safePost 在 module scope(parentPort 解析为 _parentPort),
|
|
71
|
+
// 用宽松正则匹配「try { <something>.postMessage(msg)」避免绑死变量名。
|
|
72
|
+
expect(script).toMatch(/_safePost[\s\S]*?try \{ _parentPort\.postMessage\(msg\)/);
|
|
71
73
|
});
|
|
72
74
|
|
|
73
75
|
it("_safePost logs failure with context to workerLogs", () => {
|
|
@@ -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
|
+
});
|
|
@@ -49,37 +49,48 @@
|
|
|
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); };',
|
|
70
92
|
' console.info = function (...args) { _pushWorkerLog("info", args); };',
|
|
71
93
|
'',
|
|
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
|
-
'',
|
|
83
94
|
' // ── Internal state ──',
|
|
84
95
|
' let _callIdCounter = 0;',
|
|
85
96
|
' let _agentCallCount = 0;',
|
|
@@ -225,9 +236,15 @@ export function buildWorkerScript(userScript: string): string {
|
|
|
225
236
|
// 不拖垮整批。rejected 结果降级为错误消息字符串(与 agent() 的 error→content 回退一致,
|
|
226
237
|
// parseResult(string) → null → 脚本 soft-fail)。B1 之后 agent() 不再因 agent 失败 reject,
|
|
227
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 接管。
|
|
228
244
|
' async function parallel(calls) {',
|
|
229
245
|
' if (typeof calls === "function") { return calls(); }',
|
|
230
246
|
' const settled = await Promise.allSettled(calls.map((c) => {',
|
|
247
|
+
' if (c && typeof c.then === "function") { return c; }',
|
|
231
248
|
' if (typeof c === "function") { return c(); }',
|
|
232
249
|
' if (typeof c === "object" && c !== null && (c.task || c.agent)) { return agent(c); }',
|
|
233
250
|
' return agent(c);',
|
|
@@ -311,12 +328,10 @@ export function buildWorkerScript(userScript: string): string {
|
|
|
311
328
|
' return await module.exports.execute({ agent, parallel, pipeline, phase, log, workflow, $ARGS, $WORKSPACE, $BUDGET });',
|
|
312
329
|
' }',
|
|
313
330
|
'})().then((result) => {',
|
|
314
|
-
' const
|
|
315
|
-
' const runId = (workerData.args && typeof workerData.args === "object" && workerData.args._runId) || "";',
|
|
331
|
+
' const runId = (_workerData.args && typeof _workerData.args === "object" && _workerData.args._runId) || "";',
|
|
316
332
|
' _safePost({ type: "return", runId, result, workerLogs: _workerLogs }, "return");',
|
|
317
333
|
'}).catch((err) => {',
|
|
318
|
-
' const
|
|
319
|
-
' const runId = (workerData.args && typeof workerData.args === "object" && workerData.args._runId) || "";',
|
|
334
|
+
' const runId = (_workerData.args && typeof _workerData.args === "object" && _workerData.args._runId) || "";',
|
|
320
335
|
' _safePost({ type: "error", runId, error: err.message || String(err), workerLogs: _workerLogs }, "error");',
|
|
321
336
|
'});',
|
|
322
337
|
].join("\n");
|