@zhushanwen/pi-subagent-workflow 0.4.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-subagent-workflow",
3
- "version": "0.4.2",
3
+ "version": "2.0.0",
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.",
@@ -46,8 +46,8 @@
46
46
  "@earendil-works/pi-coding-agent": "*",
47
47
  "@earendil-works/pi-ai": "*",
48
48
  "@earendil-works/pi-tui": "*",
49
- "@sinclair/typebox": "*",
50
- "@zhushanwen/pi-structured-output": "0.3.5"
49
+ "typebox": "*",
50
+ "@zhushanwen/pi-structured-output": "2.0.0"
51
51
  },
52
52
  "peerDependenciesMeta": {
53
53
  "@earendil-works/pi-coding-agent": {
@@ -29,7 +29,7 @@ vi.mock("@earendil-works/pi-ai", () => ({
29
29
  vi.mock("@earendil-works/pi-ai", () => ({
30
30
  StringEnum: (values: string[]) => ({ type: "string", enum: values }),
31
31
  }));
32
- vi.mock("@sinclair/typebox", () => ({
32
+ vi.mock("typebox", () => ({
33
33
  Type: {
34
34
  Object: (props: Record<string, unknown>) => ({ type: "object", properties: props }),
35
35
  Optional: (schema: unknown) => ({ ...(schema as object), optional: true }),
@@ -51,7 +51,7 @@ vi.mock("@earendil-works/pi-ai", () => ({
51
51
  vi.mock("@earendil-works/pi-ai", () => ({
52
52
  StringEnum: (values: string[]) => ({ type: "string", enum: values }),
53
53
  }));
54
- vi.mock("@sinclair/typebox", () => ({
54
+ vi.mock("typebox", () => ({
55
55
  Type: {
56
56
  Object: (props: Record<string, unknown>) => ({ type: "object", properties: props }),
57
57
  Optional: (schema: unknown) => ({ ...(schema as object), optional: true }),
@@ -12,19 +12,19 @@
12
12
  // 不导入 index.ts(它经 getAgentDir 值导入触发 alias 解析失败——alias 指向
13
13
  // .d.ts-only stub)。改测叶子注册函数。session_start 的
14
14
  // (event, ctx) 双参数契约由 tsconfig 的精确 stub 类型在编译期强制(见
15
- // shared/types/mariozechner/index.d.ts 注释:modelRegistry/cwd/ui 不在 event 上)。
15
+ // 注释:modelRegistry/cwd/ui 不在 event 上)。
16
16
 
17
17
  import { describe, expect, it, vi } from "vitest";
18
18
 
19
19
  // registerSubagentTool 经 subagent-tool.ts 值导入 StringEnum(pi-ai)+ Type(typebox)。
20
- // shared/types stub 是 .d.ts(仅类型),pnpm optional-peer-dep 插件拦截值导入 → vi.mock 兜底。
20
+ // stub 是 .d.ts(仅类型),pnpm optional-peer-dep 插件拦截值导入 → vi.mock 兜底。
21
21
  vi.mock("@earendil-works/pi-ai", () => ({
22
22
  StringEnum: (values: string[]) => ({ type: "string", enum: values }),
23
23
  }));
24
24
  vi.mock("@earendil-works/pi-ai", () => ({
25
25
  StringEnum: (values: string[]) => ({ type: "string", enum: values }),
26
26
  }));
27
- vi.mock("@sinclair/typebox", () => ({
27
+ vi.mock("typebox", () => ({
28
28
  Type: {
29
29
  Object: (props: Record<string, unknown>) => ({ type: "object", properties: props }),
30
30
  Optional: (schema: unknown) => ({ ...schema as object, optional: true }),
@@ -263,7 +263,7 @@ describe("subagent tool contract [MANDATORY]", () => {
263
263
  // ============================================================
264
264
  describe("session_start handler signature (compile-time guarantee)", () => {
265
265
  it("ExtensionHandler<SessionStartEvent> is (event, ctx) two-param — enforced by stub type", () => {
266
- // 此测试是编译期断言:shared/types/mariozechner/index.d.ts:111 声明
266
+ // 此测试是编译期断言:stub 类型声明
267
267
  // ExtensionHandler<E, R> = (event: E, ctx: ExtensionContext) => Promise<R|void> | R | void;
268
268
  // 且 SessionStartEvent 注释明确 modelRegistry/cwd/ui 不在 event 上(在 ctx)。
269
269
  // index.ts:66 `pi.on("session_start", (_event, ctx) => {...})` 通过此类型检查即证明契约。
@@ -21,7 +21,7 @@ vi.mock("@earendil-works/pi-ai", () => ({
21
21
  vi.mock("@earendil-works/pi-ai", () => ({
22
22
  StringEnum: (values: string[]) => ({ type: "string", enum: values }),
23
23
  }));
24
- vi.mock("@sinclair/typebox", () => ({
24
+ vi.mock("typebox", () => ({
25
25
  Type: {
26
26
  Object: (props: Record<string, unknown>) => ({ type: "object", properties: props }),
27
27
  Optional: (schema: unknown) => ({ ...schema as object, optional: true }),
@@ -144,7 +144,7 @@ interface QueueItem {
144
144
  *
145
145
  * 单 session 假设(M-2,与 index.ts lastSessionId 同源):本队列是进程级单例(实例挂在
146
146
  * globalThis[Symbol.for("@zhushanwen/pi-subagents.dialogQueue")],见 getOrCreateDialogQueue)。
147
- * rejectAll()/clear() 清空所有 pending dialog——无 per-session 隔离。Pi 当前架构保证单进程
147
+ * rejectAll() 清空所有 pending dialog——无 per-session 隔离。Pi 当前架构保证单进程
148
148
  * 单 session 串行(同进程不会并发多个 session),故 session_shutdown 调 rejectAll() 只会清掉
149
149
  * 当前 session 的 pending。若未来 Pi 支持同进程多 session 并发,session A 退出会误清 session B
150
150
  * 的 pending dialog——届时需改为 per-session 隔离(入队项 QueueItem 带 sessionId,rejectAll
@@ -314,9 +314,10 @@ export class DialogGlobalQueue {
314
314
  this.processing = false;
315
315
  }
316
316
 
317
- /** 清空队列(dispose 用)。pending 项的 Promise settle(dispose 时调用方已不关心)。
318
- * 如需 settle,dispose 前应先 rejectChildDialogs rejectAll */
319
- clear(): void {
317
+ /** 清空队列状态(仅在 rejectAll 之后调用)。pending Promise 必须先由 rejectAll settle
318
+ * settle Promise 的纯状态重置——单独调用会导致 Promise 永挂(footgun),故设为 private
319
+ * 外部调用方应使用 rejectAll()(它 settle 所有 pending + 重置状态,是原子操作)。 */
320
+ private resetState(): void {
320
321
  this.queue = [];
321
322
  this.current = undefined;
322
323
  this.processing = false;
@@ -16,7 +16,11 @@
16
16
  // 集中到单一修改点,未来 Pi 新增 mode 值时只改本文件。
17
17
  // - 业务语义命名("gui"/"headless")比原始枚举值更清晰表达意图。
18
18
 
19
- import type { ExtensionMode } from "@earendil-works/pi-coding-agent";
19
+ // ExtensionMode Pi SDK core/extensions/types.d.ts 中定义为
20
+ // `"tui" | "rpc" | "json" | "print"`(ExtensionContext.mode 的类型),但未从包根
21
+ // 导出。这里定义与 SDK 同构的本地别名(ctx.mode 的字面量联合),由同包其他模块复用,
22
+ // 避免依赖未导出的内部类型。
23
+ export type ExtensionMode = "tui" | "rpc" | "json" | "print";
20
24
 
21
25
  /** 主进程运行模式分类。基于 ExtensionMode 聚合为业务语义。
22
26
  * - "tui":纯 Pi TUI,ctx.ui.custom 可用,用户在终端交互
@@ -10,7 +10,7 @@
10
10
 
11
11
  /**
12
12
  * ModelRegistry 的最小接口(duck-typed,测试可 mock)。
13
- * 字段结构与 Pi SDK 的 ctx.modelRegistry 对齐(见 shared/types stub)。
13
+ * 字段结构与 Pi SDK 的 ctx.modelRegistry 对齐。
14
14
  */
15
15
  export interface ModelRegistryLike {
16
16
  /** 返回所有已配置鉴权的可用模型。 */
@@ -9,7 +9,7 @@
9
9
  import { type ChildProcess,execFileSync, spawn } from "node:child_process";
10
10
  import * as fs from "node:fs";
11
11
 
12
- import type { ExtensionMode } from "@earendil-works/pi-coding-agent";
12
+ import type { ExtensionMode } from "./host-mode.ts";
13
13
 
14
14
  import { type MirrorFlags, mirrorMainProcessFlags } from "./argv-mirror.ts";
15
15
  import { writeAliveMarker } from "./alive-store.ts";
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { AsyncLocalStorage } from "node:async_hooks";
6
6
 
7
- import type { ExtensionMode } from "@earendil-works/pi-coding-agent";
7
+ import type { ExtensionMode } from "./host-mode.ts";
8
8
 
9
9
  import type { AgentResult as WorkflowAgentResult } from "../orchestration/models/types.ts";
10
10
  // D-A10: workflow 侧 AgentResult 映射(executeAndAwait 出口)
@@ -3,7 +3,7 @@
3
3
  // UI 请求可观测性状态(从 subagent-service.ts 提取,降低主文件行数)。
4
4
  // 持有 sessionMode + handler 缺失告警去重集合,供 SubagentService 委托调用。
5
5
 
6
- import type { ExtensionMode } from "@earendil-works/pi-coding-agent";
6
+ import type { ExtensionMode } from "./host-mode.ts";
7
7
 
8
8
  // ── 跨模块桥接(ui-request-queue 无 ctx.service 引用时走这里) ──
9
9
  //
package/src/index.ts CHANGED
@@ -17,7 +17,7 @@ import * as fs from "node:fs";
17
17
  import * as os from "node:os";
18
18
  import * as path from "node:path";
19
19
 
20
- import type { ExtensionAPI, ExtensionContext, ModelSelectEvent, ResourcesDiscoverEvent, ResourcesDiscoverResult, SessionShutdownEvent, SessionStartEvent, SessionTreeEvent } from "@earendil-works/pi-coding-agent";
20
+ import type { ExtensionAPI, ExtensionContext, SessionShutdownEvent, SessionStartEvent, SessionTreeEvent } from "@earendil-works/pi-coding-agent";
21
21
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
22
22
 
23
23
  import type { AgentRegistry } from "./execution/agent-registry.ts";
@@ -90,7 +90,7 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
90
90
  // resources_discover:不再注入额外 skill 目录(ADR-031 废弃 discovery.json)。
91
91
  // pi 核心 auto-discovery 已覆盖 .agents/skills 等标准目录,子 session 的
92
92
  // --skill 由 agent({skill}) 调用方显式传入,无需 extension 额外补充。
93
- pi.on("resources_discover", (_event: ResourcesDiscoverEvent, _ctx: ExtensionContext): ResourcesDiscoverResult => {
93
+ pi.on("resources_discover", (_event, _ctx: ExtensionContext) => {
94
94
  return {};
95
95
  });
96
96
 
@@ -342,7 +342,7 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
342
342
  // ════════════════════════════════════════════════════════════
343
343
  // model_select:用户切换 model 时刷新缓存
344
344
  // ════════════════════════════════════════════════════════════
345
- pi.on("model_select", (event: ModelSelectEvent, ctx: ExtensionContext) => {
345
+ pi.on("model_select", (event, ctx: ExtensionContext) => {
346
346
  const service = getModelConfigService();
347
347
  if (service && typeof service.setCtxModel === "function") {
348
348
  service.setCtxModel(event.model);
@@ -396,10 +396,9 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
396
396
  }
397
397
 
398
398
  // M2: 清理 dialog queue 运行时状态(queue/current/processing)。
399
- // [#10] rejectAll() settle 所有 pending dialog Promise(防闭包泄漏:未 settle 的
399
+ // [#10] rejectAll() settle 所有 pending dialog Promise(防闭包泄漏:未 settle 的
400
400
  // Promise 持有 resolve/reject 闭包及 handler 上下文,session 退出后仍挂在全球队列上),
401
- // clear() 重置 queue/current/processing(防异常退出后 processing=true 卡死下次 session)。
402
- // rejectAll() 由 dialog-queue.ts 提供(Group B 新增);若其内部已 reset 状态,此处 clear() 为幂等兜底。
401
+ // 并内部重置 queue/current/processing(原子操作,无 footgun)。
403
402
  // 单 session 假设(M-2,同 lastSessionId):rejectAll() 清空进程级单例的所有 pending,
404
403
  // 依赖 Pi 单进程单 session 串行保证——不会误清其他 session。多 session 并发的迁移策略
405
404
  // 见 DialogGlobalQueue 类注释(rejectAllForSession)。
@@ -407,7 +406,6 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
407
406
  // 在 /new /resume /fork 时不丢失注册)。
408
407
  const dialogQueue = getOrCreateDialogQueue();
409
408
  dialogQueue.rejectAll();
410
- dialogQueue.clear();
411
409
  });
412
410
 
413
411
  // ════════════════════════════════════════════════════════════
@@ -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 通道。
@@ -12,7 +12,7 @@
12
12
  import type { Component } from "@earendil-works/pi-tui";
13
13
  import { StringEnum } from "@earendil-works/pi-ai";
14
14
  import type { AgentToolResult, ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
15
- import { Type } from "@sinclair/typebox";
15
+ import { type Static, Type } from "typebox";
16
16
 
17
17
  import { SLUG_MAX_LENGTH } from "../execution/execute-options-mapper.ts";
18
18
  import { getSubagentService } from "../execution/subagent-service.ts";
@@ -27,44 +27,15 @@ import { type RenderContext,renderSubagentCall, renderSubagentResult } from "./t
27
27
  // ============================================================
28
28
 
29
29
  /**
30
- * execute 回调的 params 类型(手写副本——stub registerTool unknown,
31
- * 无法从 SubagentParams schema 反向推断参数类型)。
30
+ * execute 回调的 params 类型由 SubagentParams schema 经 Static 投影得出(见下方 cb 签名)。
32
31
  * action 与对应 param 不匹配时 handler 内 throw。
33
32
  */
34
- interface ListParam {
35
- includeFinished?: boolean;
36
- limit?: number;
37
- }
38
-
39
- interface CancelParam {
40
- subagentId: string;
41
- }
42
-
43
- interface SubagentExecuteParams {
44
- action: "start" | "list" | "cancel";
45
- // action:"start" 的 13 字段拍平到顶层(弱模型常省略 startParam 嵌套层导致调用失败)。
46
- // 拍平后这些字段直接在顶层(全部 optional——schema flat 无法表达「action 条件必填」,
47
- // 由 startHandler runtime 校验 task/slug 必填)。
48
- task?: string;
49
- slug?: string;
50
- agent?: string;
51
- model?: string;
52
- thinkingLevel?: string;
53
- skillPath?: string;
54
- appendSystemPrompt?: string[];
55
- schema?: Record<string, unknown>;
56
- maxTurns?: number;
57
- graceTurns?: number;
58
- fork?: boolean;
59
- worktree?: boolean;
60
- cwd?: string;
61
- listParam?: ListParam;
62
- cancelParam?: CancelParam;
63
- }
64
-
65
33
  type SubagentExecuteCb = (
66
34
  toolCallId: string,
67
- params: SubagentExecuteParams,
35
+ // Pi SDK 从 parameters schema 反向推断 params 类型。typebox v1 的 StringEnum
36
+ // 在 Static 投影下退化为 string(非字面量联合),因此 action 在 cb 入参里是 string,
37
+ // 由下方 isSubagentAction 类型守卫收窄到字面量联合后再 switch。
38
+ params: Static<typeof SubagentParams>,
68
39
  signal: AbortSignal | undefined,
69
40
  onUpdate?: (partialResult: AgentToolResult<SubagentToolResult>) => void,
70
41
  // ctx 在 SDK 契约里必填;此处保持 optional 以兼容 onUpdate? 在前(TS 参数顺序约束),
@@ -166,6 +137,16 @@ function assertNever(value: never): string {
166
137
  return String(value);
167
138
  }
168
139
 
140
+ /** Subagent action 字面量联合(与 parameters schema 的 StringEnum 取值一致)。 */
141
+ type SubagentAction = "start" | "list" | "cancel";
142
+
143
+ /** 类型守卫:把 schema 投影出的 string 形式 action 收窄回字面量联合。
144
+ * typebox v1 的 StringEnum Static 退化为 string,需运行时校验 + 类型收窄
145
+ * 才能恢复 switch 的 exhaustiveness 约束。 */
146
+ function isSubagentAction(value: string): value is SubagentAction {
147
+ return value === "start" || value === "list" || value === "cancel";
148
+ }
149
+
169
150
  /** unknown 是否为含 model/thinkingLevel 的对象(类型守卫,替代全可选结构 `as`)。 */
170
151
  function isModelOverrideObj(a: unknown): a is { model?: unknown; thinkingLevel?: unknown } {
171
152
  return typeof a === "object" && a !== null;
@@ -324,6 +305,11 @@ const executeSubagent: SubagentExecuteCb = async (
324
305
  const service = getSubagentService();
325
306
  if (!service) throw new Error("subagents runtime not initialized");
326
307
 
308
+ // typebox v1 的 StringEnum 在 Static 投影下退化为 string,此处类型守卫收窄回
309
+ // 字面量联合,恢复 switch 的 exhaustiveness 约束(default 分支 = never)。
310
+ if (!isSubagentAction(params.action)) {
311
+ throw new Error(`Unknown subagent action: ${params.action}`);
312
+ }
327
313
  switch (params.action) {
328
314
  case "start":
329
315
  // 拍平后直接传顶层 params(StartHandlerInput 是 SubagentExecuteParams 子集,
@@ -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) {
@@ -109,7 +109,7 @@ function walled(theme: ThemeLike, content: string, contentWidth: number): string
109
109
  }
110
110
 
111
111
  // ── Minimal TUI duck-types(避免直接 import TUI/KeybindingsManager 类型 ──
112
- // 共享类型 fallback shared/types/mariozechner/index.d.ts 不导出 TUI 类,
112
+ // 共享类型 fallback 不导出 TUI 类,
113
113
  // workspace 跨包 typecheck 会报 "no exported member 'TUI'"。
114
114
  // 此处用结构化接口替代——只声明 view 实际用到的成员(requestRender + terminal)。
115
115
 
@@ -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
- expect(script).toMatch(/_safePost[\s\S]*?try \{ parentPort\.postMessage\(msg\)/);
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 outer .catch() for surfacing logs on errors.',
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 { parentPort, workerData } = require("node:worker_threads");',
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 { parentPort, workerData } = require("node:worker_threads");',
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 { parentPort, workerData } = require("node:worker_threads");',
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");