@zhushanwen/pi-subagent-workflow 8.4.0 → 8.6.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 (171) hide show
  1. package/package.json +22 -7
  2. package/relay/relay.mjs +390 -0
  3. package/skills/subagent-ext-config/SKILL.md +80 -0
  4. package/src/execution/__tests__/agent-registry.test.ts +110 -0
  5. package/src/execution/__tests__/bg-notify-render.test.ts +73 -0
  6. package/src/execution/__tests__/chat-engine-routing.test.ts +601 -0
  7. package/src/execution/__tests__/delivery-methods.test.ts +38 -1
  8. package/src/execution/__tests__/execute-options-mapper.test.ts +11 -0
  9. package/src/execution/__tests__/execution-record.test.ts +237 -1
  10. package/src/execution/__tests__/explicit-agent-ref-guard.test.ts +171 -0
  11. package/src/execution/__tests__/format-schema-instruction.test.ts +63 -32
  12. package/src/execution/__tests__/helpers/spawn-mock.ts +4 -0
  13. package/src/execution/__tests__/index-session-start.test.ts +86 -7
  14. package/src/execution/__tests__/lifecycle-manager.test.ts +46 -0
  15. package/src/execution/__tests__/list-fields.test.ts +45 -14
  16. package/src/execution/__tests__/model-resolver.test.ts +57 -5
  17. package/src/execution/__tests__/notifier-flush.test.ts +64 -26
  18. package/src/execution/__tests__/notify-ledger.test.ts +826 -0
  19. package/src/execution/__tests__/output-collector.test.ts +299 -2
  20. package/src/execution/__tests__/pi-invocation.test.ts +62 -1
  21. package/src/execution/__tests__/relay-agent.test.ts +448 -0
  22. package/src/execution/__tests__/relay-env.test.ts +42 -0
  23. package/src/execution/__tests__/rpc-mode.test.ts +1 -1
  24. package/src/execution/__tests__/run-spawn-edges.test.ts +44 -1
  25. package/src/execution/__tests__/run-spawn-stdout-callback-throw.test.ts +199 -0
  26. package/src/execution/__tests__/session-runner-schema-env.test.ts +39 -0
  27. package/src/execution/__tests__/spawn-args.test.ts +37 -26
  28. package/src/execution/__tests__/start-sync-model-guard.test.ts +150 -0
  29. package/src/execution/__tests__/startup-config-declaration.test.ts +35 -0
  30. package/src/execution/__tests__/stream-sink-retirement.test.ts +261 -0
  31. package/src/execution/__tests__/subprocess-agent-runner-routing.test.ts +310 -0
  32. package/src/execution/__tests__/subprocess-agent-runner.test.ts +147 -6
  33. package/src/execution/__tests__/timeout-integration.test.ts +220 -2
  34. package/src/execution/__tests__/tool-action.test.ts +92 -1
  35. package/src/execution/agent-registry.ts +16 -0
  36. package/src/execution/argv-mirror.ts +5 -1
  37. package/src/execution/concurrency-pool.ts +1 -1
  38. package/src/execution/config.ts +25 -2
  39. package/src/execution/engine/__tests__/common/data-dir.test.ts +53 -0
  40. package/src/execution/engine/__tests__/common/errors.test.ts +132 -0
  41. package/src/execution/engine/__tests__/common/event-journal.test.ts +177 -0
  42. package/src/execution/engine/__tests__/common/kill-chain.test.ts +192 -0
  43. package/src/execution/engine/__tests__/common/nesting-guard.test.ts +81 -0
  44. package/src/execution/engine/__tests__/common/persona-router.test.ts +123 -0
  45. package/src/execution/engine/__tests__/common/pool-manager.test.ts +154 -0
  46. package/src/execution/engine/__tests__/common/schema-emulation.test.ts +128 -0
  47. package/src/execution/engine/__tests__/conformance/__fixtures__/pi-golden-events.json +28 -0
  48. package/src/execution/engine/__tests__/conformance/agent-event-invariants.ts +141 -0
  49. package/src/execution/engine/__tests__/conformance/contract.abort.test.ts +109 -0
  50. package/src/execution/engine/__tests__/conformance/contract.agent-events.test.ts +101 -0
  51. package/src/execution/engine/__tests__/conformance/contract.probe.test.ts +77 -0
  52. package/src/execution/engine/__tests__/conformance/contract.read-degradation.test.ts +104 -0
  53. package/src/execution/engine/__tests__/conformance/contract.relay.test.ts +342 -0
  54. package/src/execution/engine/__tests__/conformance/engine-conformance.live.test.ts +201 -0
  55. package/src/execution/engine/__tests__/conformance/golden-replay.pi.test.ts +76 -0
  56. package/src/execution/engine/__tests__/conformance/golden-replay.zcode.test.ts +79 -0
  57. package/src/execution/engine/__tests__/engine-discovery.test.ts +87 -0
  58. package/src/execution/engine/__tests__/engines-declaration.test.ts +36 -0
  59. package/src/execution/engine/__tests__/model-prompt.test.ts +85 -0
  60. package/src/execution/engine/__tests__/paths.test.ts +39 -0
  61. package/src/execution/engine/__tests__/registry.test.ts +120 -0
  62. package/src/execution/engine/__tests__/routing.test.ts +231 -0
  63. package/src/execution/engine/common/data-dir.ts +62 -0
  64. package/src/execution/engine/common/errors.ts +183 -0
  65. package/src/execution/engine/common/event-journal.ts +254 -0
  66. package/src/execution/engine/common/journal-replay.ts +62 -0
  67. package/src/execution/engine/common/kill-chain.ts +221 -0
  68. package/src/execution/engine/common/nesting-guard.ts +50 -0
  69. package/src/execution/engine/common/persona-router.ts +108 -0
  70. package/src/execution/engine/common/pool-manager.ts +226 -0
  71. package/src/execution/engine/common/schema-emulation.ts +189 -0
  72. package/src/execution/engine/common/session-view-projection.ts +51 -0
  73. package/src/execution/engine/engine-discovery.ts +65 -0
  74. package/src/execution/engine/engines/pi/__tests__/pi-engine.test.ts +469 -0
  75. package/src/execution/engine/engines/pi/__tests__/reader.test.ts +155 -0
  76. package/src/execution/engine/engines/pi/__tests__/task-spec-mapper.test.ts +164 -0
  77. package/src/execution/engine/engines/pi/pi-engine.ts +415 -0
  78. package/src/execution/engine/engines/pi/reader.ts +48 -0
  79. package/src/execution/engine/engines/pi/registration.ts +35 -0
  80. package/src/execution/engine/engines/pi/task-spec-mapper.ts +100 -0
  81. package/src/execution/engine/engines/zcode/__tests__/__fixtures__/zcode-golden-spawn.json +39 -0
  82. package/src/execution/engine/engines/zcode/__tests__/launcher.test.ts +150 -0
  83. package/src/execution/engine/engines/zcode/__tests__/parser.test.ts +246 -0
  84. package/src/execution/engine/engines/zcode/__tests__/preparer.test.ts +228 -0
  85. package/src/execution/engine/engines/zcode/__tests__/reader.test.ts +210 -0
  86. package/src/execution/engine/engines/zcode/__tests__/registration.test.ts +64 -0
  87. package/src/execution/engine/engines/zcode/__tests__/zcode-engine.live.test.ts +127 -0
  88. package/src/execution/engine/engines/zcode/__tests__/zcode-engine.test.ts +580 -0
  89. package/src/execution/engine/engines/zcode/constants.ts +43 -0
  90. package/src/execution/engine/engines/zcode/golden-sample.ts +39 -0
  91. package/src/execution/engine/engines/zcode/launcher.ts +161 -0
  92. package/src/execution/engine/engines/zcode/parser.ts +436 -0
  93. package/src/execution/engine/engines/zcode/preparer.ts +363 -0
  94. package/src/execution/engine/engines/zcode/reader.ts +381 -0
  95. package/src/execution/engine/engines/zcode/registration.ts +37 -0
  96. package/src/execution/engine/engines/zcode/zcode-engine.ts +658 -0
  97. package/src/execution/engine/host-task-spec.ts +47 -0
  98. package/src/execution/engine/model-prompt.ts +59 -0
  99. package/src/execution/engine/paths.ts +42 -0
  100. package/src/execution/engine/port.ts +153 -0
  101. package/src/execution/engine/registry.ts +123 -0
  102. package/src/execution/engine/routing.ts +218 -0
  103. package/src/execution/engine/types.ts +309 -0
  104. package/src/execution/execute-options-mapper.ts +13 -8
  105. package/src/execution/execution-record.ts +66 -1
  106. package/src/execution/lifecycle-manager.ts +23 -1
  107. package/src/execution/model-config-service.ts +16 -1
  108. package/src/execution/model-resolver.ts +37 -59
  109. package/src/execution/notifier.ts +105 -35
  110. package/src/execution/notify-ledger.ts +580 -0
  111. package/src/execution/output-collector.ts +143 -3
  112. package/src/execution/pi-invocation.ts +32 -2
  113. package/src/execution/record-entry.ts +14 -0
  114. package/src/execution/record-store.ts +34 -0
  115. package/src/execution/relay-env.ts +37 -0
  116. package/src/execution/session-runner.ts +328 -71
  117. package/src/execution/stream-sink.ts +26 -0
  118. package/src/execution/subagent-service.ts +273 -13
  119. package/src/execution/subprocess-agent-runner.ts +210 -14
  120. package/src/execution/types.ts +124 -5
  121. package/src/execution/ui-request-queue.ts +14 -4
  122. package/src/index.ts +99 -1
  123. package/src/interface/__tests__/subagent-tool-path-guard.test.ts +157 -0
  124. package/src/interface/__tests__/subagent-tool-prompt.test.ts +12 -0
  125. package/src/interface/bg-notify-render.ts +33 -12
  126. package/src/interface/helpers.ts +2 -2
  127. package/src/interface/subagent-actions.ts +29 -9
  128. package/src/interface/subagent-tool-schema.ts +156 -0
  129. package/src/interface/subagent-tool.ts +56 -119
  130. package/src/interface/subagents.ts +2 -2
  131. package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +19 -3
  132. package/src/orchestration/__tests__/agent-call-catch-fallback.test.ts +0 -6
  133. package/src/orchestration/__tests__/agent-call-stream.test.ts +0 -5
  134. package/src/orchestration/__tests__/error-recovery-handlers.test.ts +89 -4
  135. package/src/orchestration/__tests__/execute-agent-call.test.ts +137 -0
  136. package/src/orchestration/__tests__/jsonl-run-store-corrupt-entry.test.ts +150 -0
  137. package/src/orchestration/__tests__/jsonl-run-store-retention.test.ts +202 -0
  138. package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +326 -3
  139. package/src/orchestration/__tests__/lifecycle.test.ts +41 -7
  140. package/src/orchestration/__tests__/non-cloneable-return-e2e.test.ts +95 -0
  141. package/src/orchestration/__tests__/review-fix-loop-e2e.test.ts +57 -3
  142. package/src/orchestration/__tests__/skill-discovery.test.ts +44 -0
  143. package/src/orchestration/__tests__/worker-exit-without-result.test.ts +368 -0
  144. package/src/orchestration/__tests__/worker-script-builder-runtime.test.ts +43 -0
  145. package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +22 -3
  146. package/src/orchestration/agent-opts-resolver.ts +104 -23
  147. package/src/orchestration/error-recovery.ts +189 -33
  148. package/src/orchestration/execute-agent-call.ts +39 -0
  149. package/src/orchestration/jsonl-run-store.ts +121 -7
  150. package/src/orchestration/launcher.ts +60 -15
  151. package/src/orchestration/lifecycle.ts +10 -7
  152. package/src/orchestration/models/__tests__/budget.test.ts +1 -61
  153. package/src/orchestration/models/budget.ts +5 -35
  154. package/src/orchestration/models/run-runtime.ts +24 -9
  155. package/src/orchestration/models/types.ts +16 -0
  156. package/src/orchestration/script-lint.ts +1 -1
  157. package/src/orchestration/skill-discovery.ts +31 -8
  158. package/src/orchestration/worker-script-builder.ts +19 -3
  159. package/src/shared/__tests__/model-ref.test.ts +306 -0
  160. package/src/shared/__tests__/schema-jsonify.test.ts +1 -1
  161. package/src/shared/__tests__/timer-delay.test.ts +61 -0
  162. package/src/shared/meta-parser.ts +5 -1
  163. package/src/shared/model-ref.ts +286 -0
  164. package/src/shared/resource-meta.ts +5 -0
  165. package/src/shared/schema-env.ts +44 -0
  166. package/src/shared/schema-jsonify.ts +6 -4
  167. package/src/shared/timer-delay.ts +54 -0
  168. package/workflows/review-fix-loop-utils.cjs +9 -7
  169. package/workflows/review-fix-loop.js +20 -12
  170. package/src/orchestration/__tests__/concurrency-gate.test.ts +0 -125
  171. package/src/orchestration/concurrency-gate.ts +0 -69
@@ -0,0 +1,368 @@
1
+ /**
2
+ * [F1] worker exit 无终态消息 → run failed(非悬挂)+ [SW-DATA-3] store.save 抛错不产生
3
+ * unhandledRejection 的 handler 级回归测试。
4
+ *
5
+ * F1 背景:execute() 返回不可克隆值(function/Symbol/循环引用)→ worker 侧 _safePost 吞掉
6
+ * DataCloneError → return 消息从未发出 → worker exit(0)。旧实现 handleWorkerExit 对
7
+ * code===0 no-op → run 永久 running、runAndWait 无限挂起。
8
+ *
9
+ * 修复语义(本文件锚定):
10
+ * - exit(0) 且本 runtime 代际未收到 return/error 消息 → transition done,failed +
11
+ * WORKER_EXITED_WITHOUT_RESULT_MSG 归因 + pending:unregister + onRunDone
12
+ * - exit(0) 但已收到终态消息(script-error 重试退避窗口)→ no-op(rebuild 即将发生)
13
+ * - handleWorkerMessage 的 return/error 分支必须标记 receivedTerminalMessage(判定的依据)
14
+ * - 非零 exit 行为不变(委托 handleWorkerError 重试矩阵)
15
+ *
16
+ * SW-DATA-3 背景:handleReturn / handleWorkerError / handleScriptError 的
17
+ * `await deps.store.save(run)` 未捕获——ENOSPC 等落盘失败时 rejection 经 worker-host 的
18
+ * `void handlers.onXxx(...)` 无人接 → unhandledRejection + pending:unregister / onRunDone
19
+ * 不执行(pending 通知幽灵注销)。修复后 save 失败仅 logger.error,状态机继续推进。
20
+ *
21
+ * mock 构建参考 error-recovery-handlers.test.ts(plain-object WorkflowRun mock)。
22
+ */
23
+ import { describe, expect, it, vi } from "vitest";
24
+
25
+ import {
26
+ handleWorkerExit,
27
+ handleWorkerMessage,
28
+ handleScriptError,
29
+ handleWorkerError,
30
+ } from "../error-recovery.ts";
31
+ import type { LifecycleDeps, WorkerHandlers } from "../models/ports.ts";
32
+ import type { WorkflowRun } from "../models/workflow-run.ts";
33
+ import type { WorkerHandle } from "../worker-handle.ts";
34
+
35
+ /** [F1] 归因文案——与 error-recovery.ts 常量一致(不直接 import 常量以锚定对外文案)。 */
36
+ const EXITED_WITHOUT_RESULT_MSG =
37
+ "worker exited before delivering a result (return value may not be structured-cloneable)";
38
+
39
+ // ── helpers(对齐 error-recovery-handlers.test.ts)─────────────────
40
+
41
+ interface RunMockOpts {
42
+ workerErrorCount?: number;
43
+ scriptErrorCount?: number;
44
+ /** 预置 receivedTerminalMessage(模拟 return/error 消息已送达)。 */
45
+ receivedTerminalMessage?: boolean;
46
+ }
47
+
48
+ /** 构造一个 status="running" 的 mock WorkflowRun。 */
49
+ function makeRunningRun(opts: RunMockOpts = {}): WorkflowRun {
50
+ return {
51
+ runId: "wf-test",
52
+ state: {
53
+ status: "running",
54
+ budget: { usedTokens: 0, usedCost: 0, isExceeded: () => false },
55
+ errorLogs: [],
56
+ calls: new Map(),
57
+ trace: { removeByStepIndex: vi.fn(), append: vi.fn(), update: vi.fn() },
58
+ },
59
+ meta: {
60
+ startedAt: new Date().toISOString(),
61
+ workerErrorCount: opts.workerErrorCount,
62
+ scriptErrorCount: opts.scriptErrorCount,
63
+ },
64
+ spec: {
65
+ scriptName: "test-wf",
66
+ scriptSource: "execute() {}",
67
+ args: {},
68
+ },
69
+ runtime: {
70
+ worker: { postMessage: vi.fn() },
71
+ receivedTerminalMessage: opts.receivedTerminalMessage,
72
+ },
73
+ transition(target: string, reason?: string): void {
74
+ this.state.status = target;
75
+ if (target === "done") this.state.reason = reason;
76
+ },
77
+ replaceRuntime(rt: unknown): void {
78
+ this.runtime = rt;
79
+ },
80
+ } as unknown as WorkflowRun;
81
+ }
82
+
83
+ /** LifecycleDeps mock:store/workerHost/eventBus/onRunDone 可观察。 */
84
+ function makeDeps(): LifecycleDeps & {
85
+ store: { save: ReturnType<typeof vi.fn> };
86
+ eventBus: { emit: ReturnType<typeof vi.fn> };
87
+ onRunDone: ReturnType<typeof vi.fn>;
88
+ } {
89
+ return {
90
+ store: { save: vi.fn(async () => {}) },
91
+ workerHost: { start: vi.fn(() => ({ postMessage: vi.fn() })) },
92
+ runner: { run: vi.fn(async () => ({})) },
93
+ runs: new Map(),
94
+ eventBus: { emit: vi.fn() },
95
+ onRunDone: vi.fn(),
96
+ log: vi.fn(),
97
+ } as unknown as ReturnType<typeof makeDeps>;
98
+ }
99
+
100
+ function makeHandlers(): WorkerHandlers {
101
+ return {
102
+ onMessage: vi.fn(async () => {}),
103
+ onError: vi.fn(async () => {}),
104
+ onExit: vi.fn(async () => {}),
105
+ } as unknown as WorkerHandlers;
106
+ }
107
+
108
+ function makeHandle(isCurrent = true): WorkerHandle {
109
+ return { isCurrent } as unknown as WorkerHandle;
110
+ }
111
+
112
+ // ── [F1] handleWorkerExit:exit(0) 无终态消息 → failed ──────────────
113
+
114
+ describe("handleWorkerExit — [F1] exit(0) 无终态消息", () => {
115
+ it("exit(0) 且未收到 return/error → run 转 done,failed,归因 structured-cloneable,unregister + onRunDone", async () => {
116
+ const run = makeRunningRun();
117
+ const deps = makeDeps();
118
+
119
+ await handleWorkerExit(run, 0, makeHandle(), deps, makeHandlers());
120
+
121
+ expect(run.state.status).toBe("done");
122
+ expect(run.state.reason).toBe("failed");
123
+ expect(run.state.error).toBe(EXITED_WITHOUT_RESULT_MSG);
124
+ expect(deps.eventBus.emit).toHaveBeenCalledWith(
125
+ "pending:unregister",
126
+ expect.objectContaining({ id: "wf-test", reason: "failed" }),
127
+ );
128
+ expect(deps.onRunDone).toHaveBeenCalledTimes(1);
129
+ expect(deps.store.save).toHaveBeenCalledTimes(1);
130
+ });
131
+
132
+ it("exit(0) 但已收到终态消息(script-error 重试退避窗口)→ no-op,不被误判 failed", async () => {
133
+ const run = makeRunningRun({ receivedTerminalMessage: true });
134
+ const deps = makeDeps();
135
+
136
+ await handleWorkerExit(run, 0, makeHandle(), deps, makeHandlers());
137
+
138
+ expect(run.state.status).toBe("running");
139
+ expect(deps.store.save).not.toHaveBeenCalled();
140
+ expect(deps.eventBus.emit).not.toHaveBeenCalled();
141
+ expect(deps.onRunDone).not.toHaveBeenCalled();
142
+ });
143
+
144
+ it("stale handle(isCurrent=false)仍被丢弃——修复不破坏 G-025", async () => {
145
+ const run = makeRunningRun();
146
+ const deps = makeDeps();
147
+
148
+ await handleWorkerExit(run, 0, makeHandle(false), deps, makeHandlers());
149
+
150
+ expect(run.state.status).toBe("running");
151
+ expect(deps.onRunDone).not.toHaveBeenCalled();
152
+ });
153
+
154
+ it("已终态(done)的 run 不受影响", async () => {
155
+ const run = makeRunningRun();
156
+ run.transition("done", "completed");
157
+ const deps = makeDeps();
158
+
159
+ await handleWorkerExit(run, 0, makeHandle(), deps, makeHandlers());
160
+
161
+ expect(run.state.reason).toBe("completed");
162
+ expect(deps.onRunDone).not.toHaveBeenCalled();
163
+ });
164
+
165
+ it("非零 exit 行为不变:委托 handleWorkerError(未超限 → 退避后 rebuild 重试,不直接 failed)", async () => {
166
+ // fake timers 跳过 scheduleRebuild 的真实 1s 退避
167
+ vi.useFakeTimers();
168
+ try {
169
+ const run = makeRunningRun();
170
+ const deps = makeDeps();
171
+ const handlers = makeHandlers();
172
+
173
+ const pending = handleWorkerExit(run, 1, makeHandle(), deps, handlers);
174
+ await vi.advanceTimersByTimeAsync(1000); // 退避 1s → 触发 rebuildRuntime
175
+ await pending;
176
+
177
+ // 未超限 → rebuild(workerHost.start 重建),run 保持 running、不判 failed
178
+ expect(run.state.status).toBe("running");
179
+ expect(run.meta.workerErrorCount).toBe(1);
180
+ expect(deps.workerHost.start).toHaveBeenCalledTimes(1);
181
+ expect(deps.onRunDone).not.toHaveBeenCalled();
182
+ } finally {
183
+ vi.useRealTimers();
184
+ }
185
+ });
186
+ });
187
+
188
+ // ── [R4-F1] handleWorkerError:error + exit(1) 同代际双派发只计一次 ────
189
+
190
+ describe("handleWorkerError — [R4-F1] 同代际双事件幂等", () => {
191
+ it("worker 崩溃:error 事件先到(退避窗口内)+ exit(1) 委托二次到达 → 只计一次、只 rebuild 一次", async () => {
192
+ // 真实时序:worker 崩溃 → onError 与 exit 几乎同时触发。handleWorkerError 进入
193
+ // scheduleRebuild 的退避 delay(未完成)时 exit(1) 到达 handleWorkerExit → 委托
194
+ // handleWorkerError 二次进入。旧实现在此处重复计数 + 第二个 scheduleRebuild
195
+ //(单次崩溃 workerErrorCount +2、双 rebuild 交错)。
196
+ vi.useFakeTimers();
197
+ try {
198
+ const run = makeRunningRun();
199
+ const deps = makeDeps();
200
+ const handlers = makeHandlers();
201
+
202
+ // 第一次:uncaught error 事件(进入退避 delay,不 await 完成)
203
+ const p1 = handleWorkerError(run, new Error("worker crashed"), deps, handlers);
204
+ // 第二次:exit(1) 在退避窗口内到达(runtime 尚未被 replace,同代际)
205
+ const p2 = handleWorkerExit(run, 1, makeHandle(), deps, handlers);
206
+ await vi.advanceTimersByTimeAsync(1000); // 跳过退避 → 第一个事件的 rebuild 执行
207
+ await Promise.all([p1, p2]);
208
+
209
+ // 双事件只处理一次:计数 +1(非 +2)、单次 rebuild、run 保持 running
210
+ expect(run.meta.workerErrorCount).toBe(1);
211
+ expect(deps.workerHost.start).toHaveBeenCalledTimes(1);
212
+ expect(run.state.status).toBe("running");
213
+ expect(deps.onRunDone).not.toHaveBeenCalled();
214
+ } finally {
215
+ vi.useRealTimers();
216
+ }
217
+ });
218
+
219
+ it("幂等守卫不误伤新代际:rebuild 后新 worker 的 error 正常处理(计数/重建各自 +1)", async () => {
220
+ vi.useFakeTimers();
221
+ try {
222
+ const run = makeRunningRun();
223
+ const deps = makeDeps();
224
+ const handlers = makeHandlers();
225
+
226
+ // 第一代:error → 标记本代际 → 退避 → rebuild(新 RunRuntime,标志重置 false)
227
+ const p1 = handleWorkerError(run, new Error("crash gen-1"), deps, handlers);
228
+ await vi.advanceTimersByTimeAsync(1000);
229
+ await p1;
230
+ expect(deps.workerHost.start).toHaveBeenCalledTimes(1);
231
+
232
+ // 新代际(rebuildRuntime 构造的真 RunRuntime 实例)标志为 false
233
+ expect((run.runtime as { receivedTerminalMessage?: boolean }).receivedTerminalMessage).toBe(false);
234
+
235
+ // 新代际再崩 → 正常走重试矩阵(计数 2、第二次 rebuild)
236
+ const p2 = handleWorkerError(run, new Error("crash gen-2"), deps, handlers);
237
+ // 第二次重试退避是指数值 backoffDelay(2) = 1000×2 = 2000ms,非 1000
238
+ await vi.advanceTimersByTimeAsync(2000);
239
+ await p2;
240
+
241
+ expect(run.meta.workerErrorCount).toBe(2);
242
+ expect(deps.workerHost.start).toHaveBeenCalledTimes(2);
243
+ } finally {
244
+ vi.useRealTimers();
245
+ }
246
+ });
247
+
248
+ it("超限路径同样幂等:error + exit(1) 双到达只转一次 done,failed(onRunDone 只调一次)", async () => {
249
+ vi.useFakeTimers();
250
+ try {
251
+ // 预置 workerErrorCount = MAX(3):本次 error 计数到 4 → 超限 → done,failed
252
+ const run = makeRunningRun({ workerErrorCount: 3 });
253
+ const deps = makeDeps();
254
+ const handlers = makeHandlers();
255
+
256
+ const p1 = handleWorkerError(run, new Error("worker crashed"), deps, handlers);
257
+ const p2 = handleWorkerExit(run, 1, makeHandle(), deps, handlers);
258
+ await Promise.all([p1, p2]);
259
+
260
+ expect(run.meta.workerErrorCount).toBe(4); // 只 +1
261
+ expect(run.state.status).toBe("done");
262
+ expect(run.state.reason).toBe("failed");
263
+ expect(deps.onRunDone).toHaveBeenCalledTimes(1);
264
+ expect(deps.eventBus.emit).toHaveBeenCalledTimes(1); // 单次 unregister,无重复
265
+ } finally {
266
+ vi.useRealTimers();
267
+ }
268
+ });
269
+ });
270
+
271
+ // ── [F1] handleWorkerMessage:return/error 标记 receivedTerminalMessage ────
272
+
273
+ describe("handleWorkerMessage — [F1] 终态消息标记", () => {
274
+ it("return 消息将 runtime.receivedTerminalMessage 置 true,随后正常 transition done,completed", async () => {
275
+ const run = makeRunningRun();
276
+ const deps = makeDeps();
277
+
278
+ await handleWorkerMessage(run, { type: "return", result: { ok: 1 } }, deps, makeHandlers());
279
+
280
+ expect((run.runtime as { receivedTerminalMessage?: boolean }).receivedTerminalMessage).toBe(true);
281
+ expect(run.state.status).toBe("done");
282
+ expect(run.state.reason).toBe("completed");
283
+ });
284
+
285
+ it("error 消息同样置 true——在 rebuild 前的退避窗口内捕获(replaceRuntime 前 start 时刻)", async () => {
286
+ // 断言时机说明:标记的生命周期是「本 runtime 代际」。await 整个 handleWorkerMessage
287
+ // 后 scheduleRebuild 已完成 replaceRuntime,runtime 已换新代际(flag=false 属正确语义)。
288
+ // 真正需要标记保护的窗口是退避期间(旧 worker exit(0) 到达、run.runtime 仍指向旧代际),
289
+ // 故在 rebuildRuntime 调 workerHost.start 的时刻(replaceRuntime 之前)捕获。
290
+ vi.useFakeTimers();
291
+ try {
292
+ const run = makeRunningRun();
293
+ const deps = makeDeps();
294
+ let flagAtRebuildStart: boolean | undefined;
295
+ (deps.workerHost.start as ReturnType<typeof vi.fn>).mockImplementation(() => {
296
+ flagAtRebuildStart = (run.runtime as { receivedTerminalMessage?: boolean }).receivedTerminalMessage;
297
+ return { postMessage: vi.fn() };
298
+ });
299
+
300
+ const pending = handleWorkerMessage(run, { type: "error", error: "boom" }, deps, makeHandlers());
301
+ await vi.advanceTimersByTimeAsync(1000); // 跳过 scheduleRebuild 真实 1s 退避
302
+ await pending;
303
+
304
+ expect(flagAtRebuildStart).toBe(true);
305
+ } finally {
306
+ vi.useRealTimers();
307
+ }
308
+ });
309
+ });
310
+
311
+ // ── [SW-DATA-3] store.save 抛错 → 不产生 unhandledRejection,状态机继续 ────
312
+
313
+ describe("store.save 抛错(ENOSPC 等)— [SW-DATA-3] 不阻断终态推进", () => {
314
+ it("handleReturn(经 handleWorkerMessage return 分支):save reject 被吸收,unregister + onRunDone 照常", async () => {
315
+ const run = makeRunningRun();
316
+ const deps = makeDeps();
317
+ deps.store.save.mockRejectedValue(new Error("ENOSPC: no space left on device"));
318
+
319
+ // 旧实现:await 裸抛 → handleWorkerMessage reject(worker-host 侧 void 掉)→
320
+ // unhandledRejection + 幽灵注销。修复后必须正常 resolve。
321
+ await expect(
322
+ handleWorkerMessage(run, { type: "return", result: { ok: 1 } }, deps, makeHandlers()),
323
+ ).resolves.toBeUndefined();
324
+
325
+ expect(run.state.status).toBe("done");
326
+ expect(deps.eventBus.emit).toHaveBeenCalledWith(
327
+ "pending:unregister",
328
+ expect.objectContaining({ id: "wf-test" }),
329
+ );
330
+ expect(deps.onRunDone).toHaveBeenCalledTimes(1);
331
+ });
332
+
333
+ it("handleWorkerError 重试超限:save reject 被吸收,终态 + 通知照常", async () => {
334
+ const run = makeRunningRun({ workerErrorCount: 3 });
335
+ const deps = makeDeps();
336
+ deps.store.save.mockRejectedValue(new Error("ENOSPC: no space left on device"));
337
+
338
+ await expect(
339
+ handleWorkerError(run, new Error("worker crash"), deps, makeHandlers()),
340
+ ).resolves.toBeUndefined();
341
+
342
+ expect(run.state.status).toBe("done");
343
+ expect(run.state.reason).toBe("failed");
344
+ expect(deps.eventBus.emit).toHaveBeenCalledWith(
345
+ "pending:unregister",
346
+ expect.objectContaining({ id: "wf-test", reason: "failed" }),
347
+ );
348
+ expect(deps.onRunDone).toHaveBeenCalledTimes(1);
349
+ });
350
+
351
+ it("handleScriptError 重试超限:save reject 被吸收,终态 + 通知照常", async () => {
352
+ const run = makeRunningRun({ scriptErrorCount: 3 });
353
+ const deps = makeDeps();
354
+ deps.store.save.mockRejectedValue(new Error("ENOSPC: no space left on device"));
355
+
356
+ await expect(
357
+ handleScriptError(run, "script boom", [], deps, makeHandlers()),
358
+ ).resolves.toBeUndefined();
359
+
360
+ expect(run.state.status).toBe("done");
361
+ expect(run.state.reason).toBe("failed");
362
+ expect(deps.eventBus.emit).toHaveBeenCalledWith(
363
+ "pending:unregister",
364
+ expect.objectContaining({ id: "wf-test", reason: "failed" }),
365
+ );
366
+ expect(deps.onRunDone).toHaveBeenCalledTimes(1);
367
+ });
368
+ });
@@ -280,6 +280,28 @@ describe("buildWorkerScript runtime — _safePost scope regression (exit code 1
280
280
  expect(res.returnValue).toEqual({ count: 2, ok: true });
281
281
  expect(res.exitCode).not.toBe(1);
282
282
  });
283
+
284
+ it("[F1] execute() 返回不可克隆值 → 回发可克隆 error 消息(不再静默 exit(0)),DataCloneError 详情在 workerLogs", async () => {
285
+ // 修复前:return 值含 function → _safePost 吞掉 DataCloneError 返回 false → 无任何
286
+ // 消息发出 → worker 静默 exit(0) → 主线程 handleWorkerExit(0) no-op → run 永久
287
+ // running、runAndWait 悬挂(runWorker 会 2s 超时 reject,即本测试修复前会红)。
288
+ // 修复后:.then 检测 _safePost 失败,回发可克隆 error 消息接管。
289
+ const script = `return { ok: true, fn: () => 1 };`;
290
+ const res = await runWorker(script);
291
+ expect(res.workerError).toBeUndefined();
292
+ expect(res.returnValue).toBeUndefined(); // return 消息未发出(被 DataCloneError 拦下)
293
+ expect(res.errorMessage).toMatch(/structured-clone failed/);
294
+ // DataCloneError 详情由 _safePost 记入 workerLogs 随 error 消息带回(诊断不丢)
295
+ expect((res.errorWorkerLogs ?? []).length).toBeGreaterThan(0);
296
+ expect(res.exitCode).not.toBe(1);
297
+ });
298
+
299
+ it("[F1] 不可克隆 Symbol 成员同样回发 error 消息(循环引用同理,同一 _safePost 失败路径)", async () => {
300
+ const script = `return { sym: Symbol("no-clone") };`;
301
+ const res = await runWorker(script);
302
+ expect(res.errorMessage).toMatch(/structured-clone failed/);
303
+ expect(res.exitCode).not.toBe(1);
304
+ });
283
305
  });
284
306
 
285
307
  // ── S4-S7:覆盖此前缺失的运行时路径 ──────────────────────────────────
@@ -518,3 +540,24 @@ describe("buildWorkerScript runtime — P3/P4 run-level model/thinkingLevel over
518
540
  });
519
541
  });
520
542
 
543
+ describe("buildWorkerScript runtime — string 分支 maxTurns ?? 语义保真(F-2)", () => {
544
+ // 旧实现 `(cond && secondArg.maxTurns) || undefined` 把显式 0 抹成 undefined →
545
+ // 落 runSpawn 的 env 兑底(SPAWN_WATCHDOG env 设置时误挂 watchdog),与对象分支
546
+ // (直接透传保真)语义分裂。锁定运行时行为:string 分支传 0 → postMessage
547
+ // opts.maxTurns === 0。
548
+ it("agent(str, { maxTurns: 0 }) → postMessage opts.maxTurns === 0(不被抹成 undefined)", async () => {
549
+ const script = `await agent("p", { maxTurns: 0 }); return {};`;
550
+ const res = await runWorker(script);
551
+ expect(res.agentCalls).toHaveLength(1);
552
+ expect(res.agentCalls[0]!.opts.maxTurns).toBe(0);
553
+ expect(res.workerError).toBeUndefined();
554
+ });
555
+
556
+ it("agent(str, { maxTurns: 8 }) → 8;不传 → undefined(其余语义不变)", async () => {
557
+ const resA = await runWorker(`await agent("p", { maxTurns: 8 }); return {};`);
558
+ expect(resA.agentCalls[0]!.opts.maxTurns).toBe(8);
559
+ const resB = await runWorker(`await agent("p"); return {};`);
560
+ expect(resB.agentCalls[0]!.opts.maxTurns).toBeUndefined();
561
+ });
562
+ });
563
+
@@ -100,11 +100,30 @@ describe("buildWorkerScript — _KNOWN_FIELDS module scope 提升(IF6)", ()
100
100
  expect(agentBody).toContain("_KNOWN_FIELDS.has(k)");
101
101
  });
102
102
 
103
- it("字段集合内容逐字段一致(16 known fields 不丢失)", () => {
103
+ it("字段集合内容逐字段一致(18 known fields 不丢失——P4 增 engine,预算语义对齐增 maxTurns)", () => {
104
104
  expect(script).toContain(
105
- 'const _KNOWN_FIELDS = new Set(["prompt", "description", "schema", "model", "scene", "label", "task", "agent", "phase", "skill", "timeoutMs", "cwd", "fork", "worktree", "returnMeta", "thinkingLevel"]);',
105
+ 'const _KNOWN_FIELDS = new Set(["prompt", "description", "schema", "model", "scene", "label", "task", "agent", "phase", "skill", "timeoutMs", "maxTurns", "cwd", "fork", "worktree", "returnMeta", "thinkingLevel", "engine"]);',
106
106
  );
107
107
  // unknown-fields 警告文案不变(known 列表仍全量)
108
- expect(script).toMatch(/Known fields:.*returnMeta.*thinkingLevel/);
108
+ expect(script).toMatch(/Known fields:.*returnMeta.*thinkingLevel.*engine/);
109
+ });
110
+
111
+ it("agent() 各分支均透传 maxTurns(预算语义对齐:脚本作者可显式传 turn 上限)", () => {
112
+ // task/agent shortcut 分支:显式转发
113
+ expect(script).toContain("maxTurns: firstArg.maxTurns,");
114
+ // string+secondArg 分支:显式转发(?? 语义保真:显式 0 不被抹成 undefined,F-2/U5)
115
+ expect(script).toContain(
116
+ 'maxTurns: (secondArg && typeof secondArg === "object" ? secondArg.maxTurns : undefined) ?? undefined,',
117
+ );
118
+ // opts = firstArg 直传分支无需处理(整对象透传)
119
+ });
120
+
121
+ // [F-2 maxTurns:0 吞没修复] 旧字符串分支 `&& maxTurns) || undefined` 把显式 0
122
+ // 抹成 undefined → runSpawn 落 env 兑底(SPAWN_WATCHDOG env 设置时误挂 watchdog),
123
+ // 与对象分支保真语义分裂。锁定生成源为 ?? 形态(0/null/undefined 区分处理)。
124
+ it("string 分支 maxTurns 用 ?? 语义(显式 0 不抹成 undefined,F-2)", () => {
125
+ const out = buildWorkerScript("// noop");
126
+ expect(out).not.toContain('secondArg.maxTurns) || undefined');
127
+ expect(out).toContain("?? undefined");
109
128
  });
110
129
  });
@@ -28,6 +28,106 @@ export interface ResolveResult {
28
28
  error?: string;
29
29
  }
30
30
 
31
+ // ── 根类型判定(与 structured-output 同源,本地副本) ──────────────
32
+
33
+ /** plain object 判定(isPlainObject,与 @zhushanwen/pi-structured-output schema-guards.ts 同语义)。 */
34
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
35
+ return typeof value === "object" && value !== null && !Array.isArray(value);
36
+ }
37
+
38
+ /**
39
+ * [U3 本地副本·同源锚定] 判定权威 schema 的根数据形态是否为 object。
40
+ *
41
+ * 与 @zhushanwen/pi-structured-output `src/execute.ts` 的 `isObjectRootSchema`
42
+ * 逐语义一致(该函数是 structured-output 工具 parameters {value} 包装/解包的唯一
43
+ * 判定源)。两包是独立 npm 包不能直接 import——optional peer 依赖在独立安装场景
44
+ * (pi 用户单独装本包)不保证存在,顶层值 import 会让整个 extension 加载崩溃;
45
+ * 且 builtin esbuild bundle 会把 import 的包内联成双实例(先例:session-runner.ts
46
+ * 的 PI_WORKFLOW_SCHEMA env 契约注释)。故本地复制,任一端改动判定逻辑必须同步
47
+ * 另一端(两端 docstring 互相锚定)。
48
+ *
49
+ * draft-07 语义:无 type 时类型关键字按值形态适用——properties/required 等
50
+ * object 特有关键字的存在意味着作者在描述 object 输出,算 object 根;组合根
51
+ * (anyOf/oneOf/allOf/$ref/enum)可能接受非 object 值,保真起见一律按非 object
52
+ * ({value} 包装可容纳任意成员类型)。
53
+ */
54
+ function isObjectRootSchema(schema: unknown): schema is Record<string, unknown> {
55
+ if (!isPlainObject(schema)) return false;
56
+ if (schema.type === "object") return true;
57
+ if (Array.isArray(schema.type) && schema.type.includes("object")) return true;
58
+ const OBJECT_ONLY_KEYS = [
59
+ "properties",
60
+ "required",
61
+ "patternProperties",
62
+ "additionalProperties",
63
+ "minProperties",
64
+ "maxProperties",
65
+ "dependencies",
66
+ "dependentRequired",
67
+ "propertyNames",
68
+ ];
69
+ return OBJECT_ONLY_KEYS.some((k) => k in schema);
70
+ }
71
+
72
+ /**
73
+ * 构造 schema structured-output 指令——resolver 单点注入的唯一文案源。
74
+ *
75
+ * [审查项#2] 消费链:resolveAgentOpts → appendSystemPrompt(ASP 稳定前缀区)。
76
+ * 此前 session-runner 有一份措辞相近的同名函数并把指令拼进 task 末尾,形成
77
+ * ASP + task 双重静态注入;task 后缀已删(task 每 agent 变化不可缓存,工具
78
+ * parameters 是 pi 必然注入的权威展示——注册工具 schema 随每次请求下发进
79
+ * provider 请求体,机制登记 PS-21:pi-ai dist/api/anthropic-messages.js
80
+ * convertTools :1000/:1008 → :1017 input_schema、openai-completions.js :1099
81
+ * parameters——schema 全文不必在 task 重复,文本重复浪费 ~730 tokens/子进程)。
82
+ *
83
+ * [审查项#4] AP 告知:注入侧校验用 additionalProperties:false 收窄后的
84
+ * parameters,模型自带 schema 外字段会被拒——不前置告知,拒绝显得凭空。
85
+ *
86
+ * JSON 序列化用 compact(stringifySchemaCached),与 schemaEnv 复用同串(IF7 #13)。
87
+ */
88
+ export function formatSchemaInstruction(schema: Record<string, unknown>): string {
89
+ const schemaJson = stringifySchemaCached(schema, "compact");
90
+ // [U3] 根类型条件化:判定与 structured-output 的工具 parameters {value} 包装/解包
91
+ // 同源(上方 isObjectRootSchema 本地副本)。object 根 arguments 即 data;非 object
92
+ // 根参数层实为 {value} 包装——ASP 文案必须与工具 description 同语汇告知包装契约
93
+ // ({value: <data>} + value. 错误路径前缀),否则模型直传裸值必首调失败。
94
+ const isObjectRoot = isObjectRootSchema(schema);
95
+ const argsContractLine = isObjectRoot
96
+ ? "Your call arguments ARE the result data itself — the tool's parameter schema IS the required shape of your result."
97
+ : "The tool's single argument must be an object `{value: <data>}` — put the result itself in `value`, and it must conform to the schema below. " +
98
+ "Validation errors may reference paths starting with `value.` (e.g. `value.0`, `value.name`): " +
99
+ "that prefix addresses the wrapper, not your data.";
100
+ const rulesCallLine = isObjectRoot
101
+ ? "- Call the structured-output tool with your result data as its arguments. The system validates them against the schema above automatically."
102
+ : "- Call the structured-output tool with `{value: <your result data>}`. The system validates the `value` field against the schema above automatically.";
103
+ // [AP 告知条件化] 根级 additionalProperties 未声明时 D4 注入 false(见
104
+ // structured-output tool-definition)——额外字段恒拒绝,强承诺成立;作者显式声明
105
+ // true / 子 schema 时 injection 侧尊重不动,额外字段按作者声明放行,无条件
106
+ // 「一律拒绝」文案与参数层行为不符(保守方向误导:模型不敢传合法字段)。
107
+ const apLine = schema.additionalProperties === undefined
108
+ ? "- Fields not defined in this schema are rejected — do not add extra fields."
109
+ : "- Extra fields follow this schema's own additionalProperties declaration.";
110
+ return [
111
+ "## MANDATORY: Structured Output Requirement",
112
+ "",
113
+ "This task requires structured output.",
114
+ "Your FINAL action must be calling the `structured-output` tool.",
115
+ "",
116
+ argsContractLine,
117
+ `Your result must conform to this schema:`,
118
+ "```json",
119
+ schemaJson,
120
+ "```",
121
+ "",
122
+ "Rules:",
123
+ rulesCallLine,
124
+ "- Do NOT output JSON in your text response — use the structured-output tool.",
125
+ "- Do NOT skip this step. The structured-output call IS your result.",
126
+ "- Complete all other work FIRST, then call structured-output as the last action.",
127
+ apLine,
128
+ ].join("\n");
129
+ }
130
+
31
131
  /**
32
132
  * Resolve skill and schema into appendSystemPrompt (content array) + skillPath + schemaEnv.
33
133
  *
@@ -60,31 +160,12 @@ export function resolveAgentOpts(opts: AgentCallOpts): ResolveResult {
60
160
  // which got concatenated into the final append file as path garbage — the SO instruction
61
161
  // never reached the subprocess. Now the instruction content is pushed directly.
62
162
  if (opts.schema) {
63
- // IF7(#13):同 schema 对象引用的 compact stringify 走 WeakMap 缓存
64
- // (与 session-runner formatSchemaInstruction pretty 版共享缓存条目)
65
- const schemaJson = stringifySchemaCached(opts.schema, "compact");
66
- const content = [
67
- "## MANDATORY: Structured Output Requirement",
68
- "",
69
- "This task requires structured output.",
70
- "Your FINAL action must be calling the `structured-output` tool.",
71
- "",
72
- "The schema is enforced by the system (PI_WORKFLOW_SCHEMA). You only pass `data` — do NOT pass a `schema` parameter.",
73
- `Your \`data\` must conform to this schema:`,
74
- "```json",
75
- schemaJson,
76
- "```",
77
- "",
78
- "Rules:",
79
- "- Call structured-output with ONLY the `data` parameter. The system validates it against the schema above automatically.",
80
- "- Do NOT output JSON in your text response — use the structured-output tool.",
81
- "- Do NOT skip this step. The structured-output call IS your result.",
82
- "- Complete all other work FIRST, then call structured-output as the last action.",
83
- ].join("\n");
84
- appendSystemPrompt.push(content);
163
+ // IF7(#13):formatSchemaInstruction schemaEnv 对同一 schema 对象引用共享
164
+ // WeakMap 缓存条目(compact stringify 整个 dispatch 只发生一次)
165
+ appendSystemPrompt.push(formatSchemaInstruction(opts.schema));
85
166
 
86
167
  // Set env var for structured-output extension to activate tool + hook
87
- opts = { ...opts, schemaEnv: schemaJson };
168
+ opts = { ...opts, schemaEnv: stringifySchemaCached(opts.schema, "compact") };
88
169
  }
89
170
 
90
171
  return {