@zhushanwen/pi-subagent-workflow 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/agents/context-builder.md +1 -3
  2. package/agents/oracle.md +2 -2
  3. package/agents/planner.md +1 -3
  4. package/agents/researcher.md +0 -2
  5. package/agents/reviewer.md +2 -2
  6. package/agents/scout.md +13 -3
  7. package/agents/worker.md +0 -2
  8. package/package.json +5 -3
  9. package/skills/workflow-script-format/SKILL.md +6 -6
  10. package/src/execution/__tests__/agent-result-mapper.test.ts +24 -2
  11. package/src/execution/__tests__/concurrency-pool.test.ts +33 -0
  12. package/src/execution/__tests__/execute-options-mapper.test.ts +40 -8
  13. package/src/execution/__tests__/gui-mode-dispatch.test.ts +60 -0
  14. package/src/execution/__tests__/sdk-contract.test.ts +5 -2
  15. package/src/execution/__tests__/session-reconstructor.test.ts +20 -0
  16. package/src/execution/__tests__/subagent-service-abort.test.ts +60 -0
  17. package/src/execution/__tests__/subprocess-agent-runner.test.ts +72 -3
  18. package/src/execution/__tests__/tool-action.test.ts +26 -4
  19. package/src/execution/agent-result-mapper.ts +4 -1
  20. package/src/execution/concurrency-pool.ts +38 -6
  21. package/src/execution/execute-options-mapper.ts +21 -4
  22. package/src/execution/execution-record.ts +5 -0
  23. package/src/execution/record-store.ts +2 -0
  24. package/src/execution/session-reconstructor.ts +11 -0
  25. package/src/execution/session-runner.ts +12 -0
  26. package/src/execution/stream-sink.ts +83 -0
  27. package/src/execution/subagent-service.ts +68 -43
  28. package/src/execution/subprocess-agent-runner.ts +16 -4
  29. package/src/execution/types.ts +23 -3
  30. package/src/index.ts +15 -2
  31. package/src/interface/__tests__/subagent-tool-prompt.test.ts +84 -0
  32. package/src/interface/__tests__/workflow-state-file-exposure.test.ts +38 -0
  33. package/src/interface/__tests__/workflow-tool-prompt.test.ts +50 -0
  34. package/src/interface/command-actions.ts +77 -0
  35. package/src/interface/commands.ts +40 -4
  36. package/src/interface/gui-mappers.ts +83 -0
  37. package/src/interface/helpers.ts +52 -9
  38. package/src/interface/list-component.ts +3 -1
  39. package/src/interface/subagent-actions.ts +35 -22
  40. package/src/interface/subagent-tool.ts +54 -23
  41. package/src/interface/subagents.ts +45 -5
  42. package/src/interface/tool-render.ts +16 -5
  43. package/src/interface/tool-workflow-script.ts +113 -15
  44. package/src/interface/tool-workflow.ts +92 -34
  45. package/src/interface/views/WorkflowsView.ts +13 -4
  46. package/src/interface/views/__tests__/detail-content-session-file.test.ts +70 -0
  47. package/src/interface/views/detail-content.ts +20 -0
  48. package/src/orchestration/__tests__/agent-call-catch-fallback.test.ts +208 -0
  49. package/src/orchestration/__tests__/agent-call-stream.test.ts +157 -0
  50. package/src/orchestration/__tests__/error-recovery-handlers.test.ts +2 -0
  51. package/src/orchestration/__tests__/execute-agent-call.test.ts +171 -0
  52. package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +177 -0
  53. package/src/orchestration/__tests__/worker-script-builder.test.ts +15 -0
  54. package/src/orchestration/agent-opts-resolver.ts +11 -2
  55. package/src/orchestration/error-recovery.ts +131 -23
  56. package/src/orchestration/execute-agent-call.ts +12 -3
  57. package/src/orchestration/jsonl-run-store.ts +10 -0
  58. package/src/orchestration/lifecycle.ts +1 -1
  59. package/src/orchestration/models/agent-call.ts +7 -0
  60. package/src/orchestration/models/ports.ts +15 -2
  61. package/src/orchestration/models/run-spec.ts +6 -0
  62. package/src/orchestration/models/trace.ts +1 -0
  63. package/src/orchestration/models/types.ts +19 -0
  64. package/src/orchestration/node-ops.ts +2 -0
  65. package/src/orchestration/worker-script-builder.ts +1 -0
  66. package/workflows/README.md +58 -0
  67. package/workflows/chain.js +107 -0
  68. package/workflows/map-reduce.js +142 -0
  69. package/workflows/parallel.js +131 -0
  70. package/workflows/scatter-gather.js +146 -0
  71. package/examples/README.md +0 -43
  72. package/examples/chain.example.js +0 -92
  73. package/examples/map-reduce.example.js +0 -99
  74. package/examples/parallel.example.js +0 -82
  75. package/examples/scatter-gather.example.js +0 -106
  76. package/src/interface/gui-adapter.ts +0 -136
@@ -0,0 +1,177 @@
1
+ // src/orchestration/__tests__/jsonl-run-store-session-file.test.ts
2
+ //
3
+ // W1: jsonl-run-store 序列化/反序列化 sessionFile round-trip 测试
4
+ //
5
+ // 防的 bug:sessionFile 加入 AgentCall + ExecutionTraceNode 后,序列化时必须写入快照,
6
+ // 反序列化时必须恢复——否则 pause/resume 或跨 session 重水合后 agent 的 session jsonl
7
+ // 路径丢失,overlay 无法定位。
8
+
9
+ import * as fs from "node:fs";
10
+ import * as os from "node:os";
11
+ import * as path from "node:path";
12
+
13
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
14
+
15
+ import { AgentCall } from "../models/agent-call.ts";
16
+ import { Budget } from "../models/budget.ts";
17
+ import { Trace } from "../models/trace.ts";
18
+ import type { ExecutionTraceNode } from "../models/types.ts";
19
+ import type { RunSpec } from "../models/run-spec.ts";
20
+ import { WorkflowRun } from "../models/workflow-run.ts";
21
+ import { JsonlRunStore } from "../jsonl-run-store.ts";
22
+
23
+ function makeSpec(): RunSpec {
24
+ return {
25
+ scriptSource: "module.exports = async () => {};",
26
+ args: {},
27
+ scriptName: "test-script",
28
+ scriptPath: "/tmp/test.js",
29
+ description: "test",
30
+ };
31
+ }
32
+
33
+ function makeTraceNode(stepIndex: number): ExecutionTraceNode {
34
+ return {
35
+ stepIndex,
36
+ agent: "worker",
37
+ task: "do thing",
38
+ model: "default",
39
+ status: "pending",
40
+ };
41
+ }
42
+
43
+ function makeRunWithDoneCall(): WorkflowRun {
44
+ const trace = new Trace();
45
+ const node = makeTraceNode(0);
46
+ trace.append(node);
47
+ const call = new AgentCall(0, {
48
+ prompt: "task",
49
+ agent: "worker",
50
+ cwd: "/tmp",
51
+ } as never, node);
52
+ // 模拟已完成 agent call:带 sessionId + sessionFile
53
+ call.markRunning();
54
+ call.markDone({
55
+ content: "done",
56
+ sessionId: "session-abc",
57
+ sessionFile: "/abs/.pi/agent/subagents/enc/sessions/2026-07-15T_session-abc.jsonl",
58
+ });
59
+ call.setSessionId("session-abc");
60
+ call.setSessionFile("/abs/.pi/agent/subagents/enc/sessions/2026-07-15T_session-abc.jsonl");
61
+ trace.update(0, {
62
+ status: "completed",
63
+ result: call.result,
64
+ completedAt: new Date().toISOString(),
65
+ sessionId: "session-abc",
66
+ sessionFile: "/abs/.pi/agent/subagents/enc/sessions/2026-07-15T_session-abc.jsonl",
67
+ });
68
+
69
+ return new WorkflowRun(
70
+ "run-test-001",
71
+ makeSpec(),
72
+ {
73
+ status: "done",
74
+ reason: "completed",
75
+ budget: new Budget(),
76
+ calls: new Map([[0, call]]),
77
+ trace,
78
+ errorLogs: [],
79
+ },
80
+ { startedAt: new Date().toISOString(), completedAt: new Date().toISOString() },
81
+ );
82
+ }
83
+
84
+ describe("W1: JsonlRunStore sessionFile 序列化 round-trip", () => {
85
+ let tmpDir: string;
86
+ let store: JsonlRunStore;
87
+
88
+ beforeEach(() => {
89
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-store-test-"));
90
+ store = new JsonlRunStore({ sessionDir: tmpDir });
91
+ });
92
+
93
+ afterEach(() => {
94
+ fs.rmSync(tmpDir, { recursive: true, force: true });
95
+ });
96
+
97
+ it("save + loadAll round-trip: AgentCall.sessionFile 保留", async () => {
98
+ const run = makeRunWithDoneCall();
99
+ await store.save(run);
100
+
101
+ // 从磁盘直接读快照验证 sessionFile 写入了序列化
102
+ const stateDir = path.join(tmpDir, "workflow-state");
103
+ const files = fs.readdirSync(stateDir).filter((f) => f.endsWith(".jsonl"));
104
+ expect(files).toHaveLength(1);
105
+ const raw = fs.readFileSync(path.join(stateDir, files[0]!), "utf8");
106
+ const snapshot = JSON.parse(raw.trim());
107
+ const serializedCall = snapshot.state.calls[0];
108
+ expect(serializedCall.sessionFile).toBe(
109
+ "/abs/.pi/agent/subagents/enc/sessions/2026-07-15T_session-abc.jsonl",
110
+ );
111
+ });
112
+
113
+ it("save + loadAll round-trip: ExecutionTraceNode.sessionFile 保留", async () => {
114
+ const run = makeRunWithDoneCall();
115
+ await store.save(run);
116
+
117
+ const raw = fs.readFileSync(
118
+ path.join(tmpDir, "workflow-state", "run-test-001.jsonl"),
119
+ "utf8",
120
+ );
121
+ const snapshot = JSON.parse(raw.trim());
122
+ const traceNode = snapshot.state.trace[0];
123
+ expect(traceNode.sessionFile).toBe(
124
+ "/abs/.pi/agent/subagents/enc/sessions/2026-07-15T_session-abc.jsonl",
125
+ );
126
+ });
127
+
128
+ it("save → loadAll 完整 round-trip: 反序列化后 AgentCall.sessionFile 可读", async () => {
129
+ // 闭环测试:serialize(save)→ deserialize(loadAll)→ 验证 run.state.calls 的 AgentCall.sessionFile
130
+ const sessionFilePath = "/abs/.pi/agent/subagents/enc/sessions/2026-07-15T_session-abc.jsonl";
131
+
132
+ // mock pi + ctx:save 写 pointer entry,loadAll 读同一组 entries
133
+ const entries: Array<{ type: string; customType?: string; data?: unknown }> = [];
134
+ const mockPi = {
135
+ appendEntry: vi.fn((type: string, data: unknown) => {
136
+ entries.push({ type: "custom", customType: type, data });
137
+ }),
138
+ };
139
+ const mockCtx = {
140
+ sessionManager: { getEntries: () => entries },
141
+ };
142
+
143
+ const storeWithCtx = new JsonlRunStore({
144
+ sessionDir: tmpDir,
145
+ pi: mockPi as never,
146
+ ctx: mockCtx as never,
147
+ });
148
+
149
+ const run = makeRunWithDoneCall();
150
+ await storeWithCtx.save(run);
151
+
152
+ const loaded = await storeWithCtx.loadAll();
153
+ expect(loaded).toHaveLength(1);
154
+ const restoredCall = loaded[0]!.state.calls.get(0);
155
+ expect(restoredCall).toBeDefined();
156
+ expect(restoredCall!.sessionFile).toBe(sessionFilePath);
157
+ });
158
+ });
159
+
160
+ describe("W2: RunStore.stateFilePath 暴露 run 状态文件路径", () => {
161
+ let tmpDir: string;
162
+ let store: JsonlRunStore;
163
+
164
+ beforeEach(() => {
165
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-store-test-"));
166
+ store = new JsonlRunStore({ sessionDir: tmpDir });
167
+ });
168
+
169
+ afterEach(() => {
170
+ fs.rmSync(tmpDir, { recursive: true, force: true });
171
+ });
172
+
173
+ it("stateFilePath(runId) 返回 <sessionDir>/workflow-state/<runId>.jsonl", () => {
174
+ const result = store.stateFilePath("run-xyz");
175
+ expect(result).toBe(path.join(tmpDir, "workflow-state", "run-xyz.jsonl"));
176
+ });
177
+ });
@@ -40,3 +40,18 @@ describe("buildWorkerScript — workflow() global injection", () => {
40
40
  );
41
41
  });
42
42
  });
43
+
44
+ // ── H3: agent() task/agent 分支 skill 字段传递 ──
45
+
46
+ describe("buildWorkerScript — agent() skill field in task/agent branch", () => {
47
+ const script = buildWorkerScript("// noop user script");
48
+
49
+ it("task/agent branch includes skill in opts whitelist", () => {
50
+ // H3: agent({task, agent, skill}) 的 skill 在 task/agent 分支被丢弃。
51
+ // 验证生成的 worker 源码中,task/agent 分支的 opts 构造含 skill 字段。
52
+ // 找到 task/agent 分支的 opts 构造代码(含 firstArg.task || firstArg.agent)
53
+ const taskAgentBranch = script.match(/firstArg\.task \|\| firstArg\.agent[\s\S]*?\};/);
54
+ expect(taskAgentBranch).toBeTruthy();
55
+ expect(taskAgentBranch![0]).toContain("skill: firstArg.skill");
56
+ });
57
+ });
@@ -50,7 +50,10 @@ export function resolveAgentOpts(
50
50
  // Resolve agent system prompt
51
51
  if (opts.agent) {
52
52
  const discovered = agentRegistry.get(opts.agent); // 新 API: get() 替代 resolve(),返回 AgentConfig(含 systemPrompt+model)
53
- if (!discovered) return { opts, error: `Agent not found: ${opts.agent}` };
53
+ if (!discovered) {
54
+ const available = agentRegistry.list().join(", ");
55
+ return { opts, error: `Agent not found: ${opts.agent}. Available: ${available || "(none)"}` };
56
+ }
54
57
 
55
58
  const hasSystemPrompt = discovered.systemPrompt.trim().length > 0;
56
59
  if (hasSystemPrompt) {
@@ -67,7 +70,13 @@ export function resolveAgentOpts(
67
70
  }
68
71
  }
69
72
 
70
- opts = { ...opts, model: opts.model || discovered.model };
73
+ // M3: === undefined 而非 ||,避免空串被当 falsy 替换成 frontmatter model
74
+ opts = {
75
+ ...opts,
76
+ model: opts.model === undefined ? discovered.model : opts.model,
77
+ // M2: 传播 agent .md frontmatter 的 thinkingLevel(之前 AgentCallOpts 无此字段导致丢失)
78
+ thinkingLevel: opts.thinkingLevel ?? discovered.thinkingLevel,
79
+ };
71
80
  }
72
81
 
73
82
  // Resolve skill name to SKILL.md path
@@ -25,28 +25,40 @@
25
25
  * 参考:domain-models.md §失败处理矩阵。
26
26
  */
27
27
 
28
+ import { SLUG_MAX_LENGTH } from "../execution/execute-options-mapper.ts";
29
+ import { createRecord, updateFromEvent } from "../execution/execution-record.ts";
30
+ import { SubagentStream } from "../execution/stream-sink.ts";
28
31
  import type { AgentEvent } from "../shared/agent-event.ts";
29
32
  import { resolveAgentOpts } from "./agent-opts-resolver.ts";
30
33
  import { ConcurrencyGate, DEFAULT_CONCURRENCY } from "./concurrency-gate.ts";
31
- import type { WorkerHandle } from "./worker-handle.ts";
32
34
  import { executeAgentCall } from "./execute-agent-call.ts";
33
- import { createRecord, updateFromEvent } from "../execution/execution-record.ts";
34
35
  import { AgentCall } from "./models/agent-call.ts";
35
36
  import type { LifecycleDeps, WorkerHandlers } from "./models/ports.ts";
36
37
  import { RunRuntime } from "./models/run-runtime.ts";
37
38
  import type { WorkerLogEntry } from "./models/types.ts";
38
39
  import type { AgentCallOpts, AgentResult, ExecutionTraceNode } from "./models/types.ts";
39
40
  import type { WorkflowRun } from "./models/workflow-run.ts";
41
+ import type { WorkerHandle } from "./worker-handle.ts";
40
42
 
41
43
  // ── 常量 ─────────────────────────────────────────────────────
42
44
 
43
- /** Worker/script 错误最大重试次数(domain-models.md §失败处理矩阵)。 */
45
+ /**
46
+ * 单类错误最大重试次数(domain-models.md §失败处理矩阵)。
47
+ *
48
+ * 注意:workerErrorCount 和 scriptErrorCount 是两个独立计数器,各自上限 MAX_WORKER_RETRIES。
49
+ * 最坏情况(先连续 worker error 3 次 + 再连续 script error 3 次)= 6 次 rebuild。
50
+ * 这是有意设计——两类错误的根因不同(worker 崩溃 vs 脚本逻辑),合并计数会导致
51
+ * 不同根因的失败被过早判 failed。scheduleRebuild 的 retryIndex 取 max(两计数)。
52
+ */
44
53
  const MAX_WORKER_RETRIES = 3;
45
54
 
46
55
  /** 指数退避基数(ms)。 */
47
56
  const RETRY_BACKOFF_BASE_MS = 1000;
48
57
  const EXPONENTIAL_BACKOFF_BASE = 2;
49
58
 
59
+ /** errorLogs 最大保留条数(防止超长 session 中日志无界增长)。 */
60
+ const MAX_ERROR_LOGS = 500;
61
+
50
62
  // ── Worker 消息类型(与 infra/worker-script-builder.ts WorkerInMsg 对齐) ──
51
63
 
52
64
  interface AgentCallMsg {
@@ -99,7 +111,10 @@ function backoffDelay(retryIndex: number): number {
99
111
  }
100
112
 
101
113
  function delay(ms: number): Promise<void> {
102
- return new Promise((resolve) => setTimeout(resolve, ms));
114
+ return new Promise((resolve) => {
115
+ const timer = setTimeout(resolve, ms);
116
+ timer.unref();
117
+ });
103
118
  }
104
119
 
105
120
  // ── rebuildRuntime(G3-001 整重建) ─────────────────────────
@@ -158,6 +173,8 @@ export async function handleWorkerMessage(
158
173
  // 终态/paused 状态丢弃 stale 消息(P0-1)
159
174
  if (isTerminal(run) || run.state.status === "paused") return;
160
175
 
176
+ // M7: 形状校验——防畸形 IPC 消息(worker 崩溃/发非对象)导致下游 TypeError
177
+ if (typeof raw !== "object" || raw === null) return;
161
178
  const msg = raw as WorkerMsg;
162
179
  switch (msg.type) {
163
180
  case "agent-call":
@@ -202,6 +219,16 @@ function dispatchAgentCall(
202
219
  msg: AgentCallMsg,
203
220
  deps: LifecycleDeps,
204
221
  ): void {
222
+ // M4: IPC 字段校验——畸形 agent-call 消息(opts 非对象/缺失、callId 非数字、prompt 缺失)
223
+ // 不写 trace / 不 postAgentResult——这类消息通常意味着 worker 模块版本不匹配或内存损坏,
224
+ // 回发结果给 worker 也没意义(worker 可能已崩)。仅记日志,让 worker timeout/exit 路径接管。
225
+ if (typeof msg.callId !== "number" || !Number.isFinite(msg.callId) ||
226
+ typeof msg.opts !== "object" || msg.opts === null ||
227
+ typeof msg.opts.prompt !== "string") {
228
+ console.error(`[workflow] malformed agent-call message: callId=${JSON.stringify(msg.callId)}, opts=${JSON.stringify(msg.opts)?.slice(0, 200)}`);
229
+ return;
230
+ }
231
+
205
232
  // 已缓存的调用直接 replay(跨 pause/resume)
206
233
  const cached = run.state.calls.get(msg.callId);
207
234
  if (cached && cached.status === "done") {
@@ -209,8 +236,10 @@ function dispatchAgentCall(
209
236
  return;
210
237
  }
211
238
 
212
- // 构建 trace 节点 + live record(TUI 实时进度)
239
+ // 构建 trace 节点 + live record(TUI 实时进度)
213
240
  const agentName = msg.opts.description ?? msg.opts.agent ?? "unknown";
241
+ // slug 复用 agentName(超长截断),live record 的 slug 仅用于 TUI 展示。
242
+ const liveSlug = agentName.length > SLUG_MAX_LENGTH ? agentName.slice(0, SLUG_MAX_LENGTH) : agentName;
214
243
  const now = new Date().toISOString();
215
244
  // live record:收口 agent 执行过程中的 text/thinking/toolCalls/usage,
216
245
  // 供 TUI 在 agent 运行期间显示进度(getEventLog/getCurrentActivity)。
@@ -220,6 +249,7 @@ function dispatchAgentCall(
220
249
  model: msg.opts.model ?? "default",
221
250
  mode: "background",
222
251
  task: msg.opts.prompt,
252
+ slug: liveSlug,
223
253
  startedAt: Date.now(),
224
254
  });
225
255
  const node: ExecutionTraceNode = {
@@ -268,7 +298,9 @@ function dispatchAgentCall(
268
298
  completedAt: new Date().toISOString(),
269
299
  });
270
300
  postAgentResult(run, msg.callId, errorResult, false);
271
- void deps.store.save(run);
301
+ deps.store.save(run).catch((e: unknown) => {
302
+ console.error(`[workflow] store.save failed (resolveAgentOpts): ${e instanceof Error ? e.message : String(e)}`);
303
+ });
272
304
  return;
273
305
  }
274
306
 
@@ -290,19 +322,38 @@ function dispatchAgentCall(
290
322
  const onEvent = (event: AgentEvent): void => {
291
323
  updateFromEvent(liveRecord, event);
292
324
  };
325
+ // 创建 streaming sink:widgetKey = subagent-stream-<runId>-<stepIndex>。
326
+ // 复用 background subagent 的 SubagentStream → setWidget → RPC 链路(agent-call-streaming-extension.md)。
327
+ // streamSink 缺失(无 UI 模式)时 stream=undefined,executeAgentCall 正常执行不 streaming。
328
+ const stream = deps.streamSink
329
+ ? new SubagentStream(`${run.runId}-${msg.callId}`, deps.streamSink)
330
+ : undefined;
293
331
  void runtime.gate
294
- .withSlot(() => executeAgentCall(call, deps.runner, run.state.budget, signal, run.state.trace, onEvent), signal)
332
+ .withSlot(
333
+ async () => {
334
+ try {
335
+ await executeAgentCall(call, deps.runner, run.state.budget, signal, run.state.trace, onEvent, stream);
336
+ } finally {
337
+ stream?.dispose();
338
+ }
339
+ },
340
+ signal,
341
+ )
295
342
  .then(() => {
343
+ // 清除 live record:终态已由 executeAgentCall → finalizeCall 写入 node.result,
344
+ // live 不再需要(且含可变状态,不保留)。无论 stale 与否都清,避免内存泄漏。
345
+ // M4: 必须在 stale guard 之前清,否则 pause/resume 循环下 live record 累积。
346
+ node.live = undefined;
296
347
  // pause/abort 后到达的 stale completion 不写 state(pause 是干净快照)
297
348
  if (run.state.status !== "running") return;
298
- // 清除 live record:终态已由 executeAgentCall → finalizeCall 写入 node.result,
299
- // live 不再需要(且含可变状态,不保留)。无论 stale 与否都清,避免内存泄漏。
300
- node.live = undefined;
301
349
  if (call.result) postAgentResult(run, msg.callId, call.result, false);
302
350
  // D-12 regression fix (round-2 #1):executeAgentCall 内 consume/incrementCallCount
303
351
  // 后同步 worker $BUDGET(否则 $BUDGET.spent()/remaining() 恒为 0)
304
352
  postBudgetUpdate(run);
305
- void deps.store.save(run);
353
+ deps.store.save(run).catch((e: unknown) => {
354
+ const m = e instanceof Error ? e.message : String(e);
355
+ console.error(`[workflow] store.save failed (agent call ${msg.callId}): ${m}`);
356
+ });
306
357
 
307
358
  // C-2:budget 超限 → 终止整个 run(避免继续 spawn 烧预算)
308
359
  // 内联 terminate(不调 lifecycle.abortRun 避免 engine 内循环依赖):
@@ -311,27 +362,68 @@ function dispatchAgentCall(
311
362
  if (run.state.budget.isExceeded()) {
312
363
  run.state.error = run.state.error ?? "Budget exceeded";
313
364
  deps.log?.("debug", "workflow:error-recovery", "budget exceeded, transition done", { runId: run.runId });
365
+ // M12: transition 单独 try——并发 abort 导致 illegal-transition 是预期的,可忽略
366
+ let transitioned = false;
314
367
  try {
315
368
  run.transition("done", "budget_limited");
316
- void deps.store.save(run);
369
+ transitioned = true;
370
+ } catch (te: unknown) {
371
+ // run 可能在 budget 检查后、transition 前被并发 abort——预期,不记错
372
+ void te;
373
+ }
374
+ if (transitioned) {
375
+ deps.store.save(run).catch((e: unknown) => {
376
+ const m = e instanceof Error ? e.message : String(e);
377
+ console.error(`[workflow] store.save failed (budget done): ${m}`);
378
+ });
317
379
  deps.log?.("debug", "workflow:error-recovery", "run saved after budget done", { runId: run.runId, reason: run.state.reason });
318
- // C-4: budget 终止也触发完成通知
319
- deps.log?.("debug", "workflow:error-recovery", "emit pending:unregister", { runId: run.runId, reason: run.state.reason });
320
- deps.eventBus?.emit("pending:unregister", { id: run.runId, reason: run.state.reason ?? "completed" });
321
- deps.log?.("debug", "workflow:error-recovery", "emit pending:unregister done", { runId: run.runId });
322
- deps.onRunDone?.(run);
323
- } catch (err) {
324
- // run 可能在 budget 检查后、transition 前被并发 abort——忽略
325
- void err;
380
+ // M12: onRunDone/emit 单独 try——这些是真实副作用,错误不应被静默吞掉
381
+ try {
382
+ deps.log?.("debug", "workflow:error-recovery", "emit pending:unregister", { runId: run.runId, reason: run.state.reason });
383
+ deps.eventBus?.emit("pending:unregister", { id: run.runId, reason: run.state.reason ?? "completed" });
384
+ deps.log?.("debug", "workflow:error-recovery", "emit pending:unregister done", { runId: run.runId });
385
+ deps.onRunDone?.(run);
386
+ } catch (err) {
387
+ const m = err instanceof Error ? err.message : String(err);
388
+ console.error(`[workflow] onRunDone/emit failed (budget done): ${m}`);
389
+ }
326
390
  }
327
391
  }
328
392
  })
329
393
  .catch((err: unknown) => {
330
394
  // withSlot 在 queued + signal-aborted 时 reject AbortError——预期,不记错。
331
- // executeAgentCall 本身不 reject(runner.run 不 reject)。
332
395
  if (err instanceof Error && err.name === "AbortError") return;
333
396
  const message = err instanceof Error ? err.message : String(err);
334
397
  console.error(`[workflow] agent call ${msg.callId} failed: ${message}`);
398
+ // 兜底回发:executeAgentCall 抛非 Abort 异常时(如 runner undefined 的 TypeError、
399
+ // gate.withSlot 内部 bug)原 catch 仅 console.error,worker 内对 callId 的 pending
400
+ // Promise 永不 resolve → agent() 永久 await → worker 脚本挂死。构造 failed AgentResult
401
+ //(与 resolveAgentOpts 失败路径 L262-275 一致的模式)postAgentResult 回 worker,
402
+ // 让 pending Promise resolve(结果为 error),脚本可继续或失败退出。
403
+ const errorResult: AgentResult = { content: "", error: message };
404
+ // call 已 done(executeAgentCall 内 finalizeCall 已 markDone)时跳过,避免重复 markDone。
405
+ // status 理论上必为 running(executeAgentCall L130 markRunning 先于 reject),pending
406
+ // 分支为防御性保护。非 running/done 意外态:跳过 markDone(markDone 要求 running)。
407
+ if (call.status !== "done") {
408
+ if (call.status === "pending") call.markRunning();
409
+ call.markDone(errorResult);
410
+ }
411
+ // state 一致性三件套(与 resolveAgentOpts 失败 L268-276 / .then L319-325 对等):
412
+ // trace 标 failed + 清 live record(防泄漏)+ 持久化(catch 恰是最需留证的场景)。
413
+ // stale 终态(run 已 paused/done)时 run.runtime 为 undefined,postAgentResult 用
414
+ // optional chaining 跳过 worker 回发;trace/state 写入仍执行(无害,pause 快照已存)。
415
+ node.live = undefined;
416
+ run.state.trace.update(msg.callId, {
417
+ status: "failed",
418
+ result: errorResult,
419
+ completedAt: new Date().toISOString(),
420
+ });
421
+ postAgentResult(run, msg.callId, errorResult, false);
422
+ // S2: 与 .then 对称——catch 路径也同步 worker $BUDGET(幂等)
423
+ postBudgetUpdate(run);
424
+ deps.store.save(run).catch((e: unknown) => {
425
+ console.error(`[workflow] store.save failed (catch fallback): ${e instanceof Error ? e.message : String(e)}`);
426
+ });
335
427
  });
336
428
  }
337
429
 
@@ -347,6 +439,14 @@ function dispatchWorkflowCall(
347
439
  msg: WorkflowCallMsg,
348
440
  deps: LifecycleDeps,
349
441
  ): void {
442
+ // M4: IPC 字段校验——畸形 workflow-call 消息
443
+ if (typeof msg.callId !== "number" || !Number.isFinite(msg.callId) ||
444
+ typeof msg.name !== "string" ||
445
+ typeof msg.args !== "object" || msg.args === null) {
446
+ console.error(`[workflow] malformed workflow-call message: callId=${JSON.stringify(msg.callId)}, name=${JSON.stringify(msg.name)}`);
447
+ return;
448
+ }
449
+
350
450
  const postResult = (result: unknown): void => {
351
451
  if (run.state.status !== "running") return;
352
452
  run.runtime?.worker.postMessage({
@@ -415,8 +515,12 @@ async function handleReturn(
415
515
  ): Promise<void> {
416
516
  deps.log?.("debug", "workflow:error-recovery", "handleReturn", { runId: run.runId, status: run.state.status });
417
517
  // 捕获 worker 诊断日志(P2-2)
518
+ // L9: 追加而非覆盖——保留重试历史的诊断日志(各 worker 实例的 console 输出)
418
519
  if (msg.workerLogs && msg.workerLogs.length > 0) {
419
- run.state.errorLogs = msg.workerLogs;
520
+ run.state.errorLogs.push(...msg.workerLogs);
521
+ if (run.state.errorLogs.length > MAX_ERROR_LOGS) {
522
+ run.state.errorLogs = run.state.errorLogs.slice(-MAX_ERROR_LOGS);
523
+ }
420
524
  }
421
525
  run.state.scriptResult = msg.result;
422
526
  run.transition("done", "completed");
@@ -526,8 +630,12 @@ export async function handleScriptError(
526
630
  if (isTerminal(run) || run.state.status === "paused") return;
527
631
 
528
632
  // P2-2: 捕获 worker 诊断日志
633
+ // L9: 追加而非覆盖
529
634
  if (workerLogs.length > 0) {
530
- run.state.errorLogs = workerLogs;
635
+ run.state.errorLogs.push(...workerLogs);
636
+ if (run.state.errorLogs.length > MAX_ERROR_LOGS) {
637
+ run.state.errorLogs = run.state.errorLogs.slice(-MAX_ERROR_LOGS);
638
+ }
531
639
  }
532
640
 
533
641
  const count = (run.meta.scriptErrorCount ?? 0) + 1;
@@ -23,6 +23,7 @@
23
23
  * 参考:domain-models.md §5 + §失败处理矩阵。
24
24
  */
25
25
 
26
+ import type { SubagentStream } from "../execution/stream-sink.ts";
26
27
  import type { AgentEvent } from "../shared/agent-event.ts";
27
28
  import type { AgentCall } from "./models/agent-call.ts";
28
29
  import type { Budget } from "./models/budget.ts";
@@ -81,11 +82,15 @@ function backoffDelay(retryIndex: number): number {
81
82
  function finalizeCall(call: AgentCall, result: AgentResult, trace: Trace): void {
82
83
  call.markDone(result);
83
84
  const status = result.error === undefined ? "completed" : "failed";
85
+ // 同步 AgentCall 的 sessionId/sessionFile(对齐 trace 节点,持久化 + reset 用)
86
+ if (result.sessionId !== undefined) call.setSessionId(result.sessionId);
87
+ if (result.sessionFile !== undefined) call.setSessionFile(result.sessionFile);
84
88
  trace.update(call.id, {
85
89
  status,
86
90
  result,
87
91
  completedAt: new Date().toISOString(),
88
92
  sessionId: result.sessionId,
93
+ sessionFile: result.sessionFile,
89
94
  });
90
95
  }
91
96
 
@@ -93,7 +98,10 @@ function finalizeCall(call: AgentCall, result: AgentResult, trace: Trace): void
93
98
  * 延迟工具(testable —— 测试可通过 fake timers 推进)。
94
99
  */
95
100
  function delay(ms: number): Promise<void> {
96
- return new Promise((resolve) => setTimeout(resolve, ms));
101
+ return new Promise((resolve) => {
102
+ const timer = setTimeout(resolve, ms);
103
+ timer.unref();
104
+ });
97
105
  }
98
106
 
99
107
  // ── executeAgentCall ─────────────────────────────────────────
@@ -124,10 +132,11 @@ export async function executeAgentCall(
124
132
  signal: AbortSignal,
125
133
  trace: Trace,
126
134
  onEvent?: (event: AgentEvent) => void,
135
+ stream?: SubagentStream,
127
136
  ): Promise<void> {
128
137
  call.markRunning();
129
138
 
130
- const result = await runner.run(call.opts, signal, onEvent);
139
+ const result = await runner.run(call.opts, signal, onEvent, stream);
131
140
 
132
141
  // 累加 usage(加权由 budget.consume 内部按权重常量处理,见 budget.ts)
133
142
  if (result.usage) {
@@ -164,7 +173,7 @@ export async function executeAgentCall(
164
173
  budget.incrementCallCount();
165
174
  return;
166
175
  }
167
- await executeAgentCall(call, runner, budget, signal, trace, onEvent);
176
+ await executeAgentCall(call, runner, budget, signal, trace, onEvent, stream);
168
177
  return;
169
178
  }
170
179
 
@@ -75,6 +75,7 @@ interface RunSnapshot {
75
75
  attempts: number;
76
76
  result?: AgentResult;
77
77
  sessionId?: string;
78
+ sessionFile?: string;
78
79
  traceNode: ExecutionTraceNode;
79
80
  }>;
80
81
  trace: ExecutionTraceNode[];
@@ -119,6 +120,7 @@ function serializeRun(run: WorkflowRun): RunSnapshot {
119
120
  attempts: c.attempts,
120
121
  result: c.result,
121
122
  sessionId: c.sessionId,
123
+ sessionFile: c.sessionFile,
122
124
  traceNode: traceNodeRest,
123
125
  };
124
126
  }),
@@ -162,6 +164,9 @@ function deserializeRun(snapshot: RunSnapshot): WorkflowRun | null {
162
164
  if (c.sessionId !== undefined) {
163
165
  call.setSessionId(c.sessionId);
164
166
  }
167
+ if (c.sessionFile !== undefined) {
168
+ call.setSessionFile(c.sessionFile);
169
+ }
165
170
  calls.set(c.id, call);
166
171
  }
167
172
 
@@ -224,6 +229,11 @@ export class JsonlRunStore {
224
229
  return path.join(this.stateDir, `${runId}.jsonl`);
225
230
  }
226
231
 
232
+ /** Public accessor: run 状态快照文件绝对路径(RunStore port 实现)。 */
233
+ stateFilePath(runId: string): string {
234
+ return this.filePathFor(runId);
235
+ }
236
+
227
237
  /**
228
238
  * Persist a single run: rewrite mode (overwrite) — file always contains the
229
239
  * latest complete snapshot on a single line. Appends a workflow-state-link
@@ -208,7 +208,7 @@ export async function runWorkflow(
208
208
  deps.eventBus?.emit("pending:register", {
209
209
  id: runId,
210
210
  type: "workflow",
211
- name: spec.scriptName || runId,
211
+ name: spec.slug || spec.scriptName || runId,
212
212
  });
213
213
  deps.log?.("debug", "workflow:lifecycle", "emit pending:register done", { runId });
214
214
 
@@ -36,6 +36,8 @@ export class AgentCall {
36
36
  result?: AgentResult;
37
37
  /** Pi subprocess session ID(uuidv7,G-017 归此)。 */
38
38
  sessionId?: string;
39
+ /** Session JSONL 绝对路径(finalizeCall 后从 result.sessionFile 填入,对齐 sessionId 模式)。 */
40
+ sessionFile?: string;
39
41
  /** 与 Trace 共享的节点引用(D-10 单源)。AgentCall 不直接改其字段。 */
40
42
  readonly traceNode: ExecutionTraceNode;
41
43
 
@@ -73,4 +75,9 @@ export class AgentCall {
73
75
  setSessionId(sessionId: string): void {
74
76
  this.sessionId = sessionId;
75
77
  }
78
+
79
+ /** 记录 session JSONL 绝对路径(finalizeCall 后,对齐 setSessionId 模式)。 */
80
+ setSessionFile(sessionFile: string): void {
81
+ this.sessionFile = sessionFile;
82
+ }
76
83
  }
@@ -10,8 +10,9 @@
10
10
  *
11
11
  * 层归属:Engine。零 infra 依赖(AC-1)。
12
12
  */
13
- import type { AgentEvent } from "../../shared/agent-event.ts";
14
13
  import type { AgentRegistry } from "../../execution/agent-registry.ts";
14
+ import type { StreamSink, SubagentStream } from "../../execution/stream-sink.ts";
15
+ import type { AgentEvent } from "../../shared/agent-event.ts";
15
16
  import type { WorkerHandle } from "../worker-handle.ts";
16
17
  import type { RunSpec } from "./run-spec.ts";
17
18
  import type { AgentCallOpts, AgentResult } from "./types.ts";
@@ -32,7 +33,7 @@ import type { WorkflowRun } from "./workflow-run.ts";
32
33
  * raw JSONL 中间层(executeAndAwait 直接出 AgentEvent,session-runner handleSdkEvent 出口)。
33
34
  */
34
35
  export interface AgentRunner {
35
- run(opts: AgentCallOpts, signal: AbortSignal, onEvent?: (event: AgentEvent) => void): Promise<AgentResult>;
36
+ run(opts: AgentCallOpts, signal: AbortSignal, onEvent?: (event: AgentEvent) => void, stream?: SubagentStream): Promise<AgentResult>;
36
37
  }
37
38
 
38
39
  // ── Port 2: RunStore ──────────────────────────────────────────
@@ -42,10 +43,13 @@ export interface AgentRunner {
42
43
  *
43
44
  * save 在每次状态变更后持久化整个 WorkflowRun(聚合根);
44
45
  * loadAll 在 session_start 时重水合(D-5:JSONL 不向后兼容旧 session,旧格式返回空)。
46
+ * stateFilePath 返回 run 状态文件的绝对路径(供 overlay/GUI 暴露给用户)。
45
47
  */
46
48
  export interface RunStore {
47
49
  save(run: WorkflowRun): Promise<void>;
48
50
  loadAll(): Promise<WorkflowRun[]>;
51
+ /** 返回 run 状态快照文件的绝对路径:<sessionDir>/workflow-state/<runId>.jsonl */
52
+ stateFilePath(runId: string): string;
49
53
  }
50
54
 
51
55
  // ── Port 3: WorkerHost ────────────────────────────────────────
@@ -162,4 +166,13 @@ export interface LifecycleDeps {
162
166
  args: Record<string, unknown>,
163
167
  parentRun: WorkflowRun,
164
168
  ) => Promise<unknown>;
169
+ /**
170
+ * UI streaming sink(ctx.ui.setWidget),workflow agent call 创建 SubagentStream 用。
171
+ *
172
+ * 由 Interface 层 makeDeps 注入(从 SubagentService.getStreamSink() 取)。
173
+ * dispatchAgentCall 用它创建 SubagentStream(widgetKey=subagent-stream-<runId>-<stepIndex>),
174
+ * 使 workflow agent call 的 text_delta 走与 background subagent 相同的 streaming 链路。
175
+ * 可选——无 UI 模式(TUI/RPC 无 setWidget)时为 undefined,dispatchAgentCall 不创建 stream。
176
+ */
177
+ streamSink?: StreamSink;
165
178
  }