@zhushanwen/pi-subagent-workflow 2.0.1 → 4.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/agents/reviewer.md +1 -1
- package/package.json +3 -2
- package/skills/workflow-script-format/SKILL.md +2 -1
- package/src/execution/__tests__/agent-registry.test.ts +1 -1
- package/src/execution/__tests__/channel-registry-handshake.test.ts +18 -8
- package/src/execution/__tests__/execute-and-await-worktree.test.ts +219 -0
- package/src/execution/__tests__/finalize-record.test.ts +19 -6
- package/src/execution/__tests__/stdin-writer.test.ts +18 -5
- package/src/execution/__tests__/ui-request-observability.test.ts +21 -8
- package/src/execution/agent-registry.ts +10 -2
- package/src/execution/best-effort.ts +13 -5
- package/src/execution/channel-registry-access.ts +7 -3
- package/src/execution/execute-options-mapper.ts +2 -0
- package/src/execution/finalize-record.ts +5 -1
- package/src/execution/record-store.ts +10 -4
- package/src/execution/session-runner.ts +8 -2
- package/src/execution/stdin-writer.ts +8 -2
- package/src/execution/subagent-service.ts +56 -5
- package/src/execution/ui-request-handler-factory.ts +10 -10
- package/src/execution/ui-request-observability.ts +6 -2
- package/src/execution/ui-request-queue.ts +7 -1
- package/src/index.ts +21 -8
- package/src/interface/__tests__/detectors.test.ts +14 -0
- package/src/interface/subagent-tool.ts +10 -11
- package/src/interface/tool-workflow.ts +17 -4
- package/src/orchestration/__tests__/error-recovery-postmessage-defense.test.ts +22 -7
- package/src/orchestration/__tests__/error-recovery-serialize-failed-result.test.ts +56 -0
- package/src/orchestration/__tests__/worker-script-builder-runtime.test.ts +105 -1
- package/src/orchestration/__tests__/worker-script-builder.test.ts +126 -0
- package/src/orchestration/error-recovery.ts +23 -15
- package/src/orchestration/lifecycle.ts +6 -2
- package/src/orchestration/models/types.ts +18 -0
- package/src/orchestration/worker-script-builder.ts +34 -5
- package/workflows/README.md +18 -0
- package/workflows/review-fix-loop.js +677 -0
|
@@ -99,6 +99,10 @@ interface RunOptions {
|
|
|
99
99
|
args?: Record<string, unknown>;
|
|
100
100
|
/** 按 agent-call 顺序回发的 parsedOutput(默认每个回发 {ok:true})。 */
|
|
101
101
|
agentResults?: unknown[];
|
|
102
|
+
/** 按 agent-call 顺序回发的完整 agent-result 对象(覆盖 agentResults 的 content/parsedOutput 默认形)。
|
|
103
|
+
* 用于 returnMeta 测试:回发包含 sessionFile/worktreePath/error 的完整 result,
|
|
104
|
+
* worker handler 会原样取这些字段 resolve。未提供该项的索引退回 {content:"fallback", parsedOutput: agentResults[idx]}。 */
|
|
105
|
+
agentResultObjects?: Record<string, unknown>[];
|
|
102
106
|
/** 主线程对收到的 workflow-call 的处理:回发 workflow-result。 */
|
|
103
107
|
handleWorkflowCall?: (msg: WorkflowCallMsg) => unknown;
|
|
104
108
|
/** 是否在收到首个 agent-call 后立即发 abort(测 abort 路径)。 */
|
|
@@ -156,11 +160,14 @@ function runWorker(userScript: string, opts: RunOptions = {}): Promise<RunResult
|
|
|
156
160
|
return;
|
|
157
161
|
}
|
|
158
162
|
const parsed = opts.agentResults?.[agentCallIdx] ?? { ok: true };
|
|
163
|
+
// returnMeta 测试路径:若调用方提供完整 result 对象(含 sessionFile/worktreePath/error),
|
|
164
|
+
// 原样回发;否则用既有 {content, parsedOutput} 默认形。
|
|
165
|
+
const fullResult = opts.agentResultObjects?.[agentCallIdx];
|
|
159
166
|
agentCallIdx++;
|
|
160
167
|
worker.postMessage({
|
|
161
168
|
type: "agent-result",
|
|
162
169
|
callId: raw.callId,
|
|
163
|
-
result: { content: "fallback", parsedOutput: parsed },
|
|
170
|
+
result: fullResult ?? { content: "fallback", parsedOutput: parsed },
|
|
164
171
|
cached: false,
|
|
165
172
|
});
|
|
166
173
|
} else if (isWorkflowCall(raw)) {
|
|
@@ -345,3 +352,100 @@ describe("buildWorkerScript runtime — 之前缺失的路径覆盖", () => {
|
|
|
345
352
|
expect(res.exitCode).not.toBe(1);
|
|
346
353
|
});
|
|
347
354
|
});
|
|
355
|
+
|
|
356
|
+
// ── W2: agent() returnMeta 模式运行时验证 ───────────────────────────
|
|
357
|
+
// 现有 worker-script-builder.test.ts 的 16 条 returnMeta 断言全是字符串 toContain,
|
|
358
|
+
// 无法验证「真实 Worker 线程执行时 agent({returnMeta:true}) resolve 出对象、
|
|
359
|
+
// 不设 returnMeta resolve 单值」。此处补运行时验证(对称 handler 9b 分支)。
|
|
360
|
+
|
|
361
|
+
describe("buildWorkerScript runtime — W2 agent() returnMeta mode", () => {
|
|
362
|
+
it("agent({prompt, returnMeta:true}) resolve 出 {value, sessionFile, worktreePath, error}(非单值)", async () => {
|
|
363
|
+
// returnMeta:true → worker handler 走 9b 分支,resolve 包含 4 字段的对象。
|
|
364
|
+
// 主线程回发的 result 带 sessionFile/worktreePath/error,验证它们被原样透传。
|
|
365
|
+
const script = `
|
|
366
|
+
const r = await agent({ prompt: "with-meta", returnMeta: true });
|
|
367
|
+
return r;
|
|
368
|
+
`;
|
|
369
|
+
const res = await runWorker(script, {
|
|
370
|
+
agentResultObjects: [
|
|
371
|
+
{
|
|
372
|
+
content: "raw-text",
|
|
373
|
+
parsedOutput: { ok: true },
|
|
374
|
+
sessionFile: "/tmp/sess-1.jsonl",
|
|
375
|
+
worktreePath: "/tmp/wt-abc",
|
|
376
|
+
error: undefined,
|
|
377
|
+
},
|
|
378
|
+
],
|
|
379
|
+
});
|
|
380
|
+
expect(res.agentCalls).toHaveLength(1);
|
|
381
|
+
// agent-call 消息应透传 returnMeta(验证 m1 修复:prompt 分支本就透传)
|
|
382
|
+
expect(res.agentCalls[0]!.opts.returnMeta).toBe(true);
|
|
383
|
+
expect(res.workerError).toBeUndefined();
|
|
384
|
+
// value = parsedOutput ?? content = {ok:true}(结构化输出优先)
|
|
385
|
+
expect(res.returnValue).toEqual({
|
|
386
|
+
value: { ok: true },
|
|
387
|
+
sessionFile: "/tmp/sess-1.jsonl",
|
|
388
|
+
worktreePath: "/tmp/wt-abc",
|
|
389
|
+
error: undefined,
|
|
390
|
+
});
|
|
391
|
+
expect(res.exitCode).not.toBe(1);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("不设 returnMeta 时 agent() resolve 单值(向后兼容)", async () => {
|
|
395
|
+
// 无 returnMeta → handler 走 else 分支,resolve 裸 _value(向后兼容)。
|
|
396
|
+
const script = `
|
|
397
|
+
const r = await agent({ prompt: "no-meta" });
|
|
398
|
+
return r;
|
|
399
|
+
`;
|
|
400
|
+
const res = await runWorker(script, {
|
|
401
|
+
agentResultObjects: [
|
|
402
|
+
{
|
|
403
|
+
content: "raw-text",
|
|
404
|
+
parsedOutput: { ok: true },
|
|
405
|
+
sessionFile: "/tmp/sess-2.jsonl",
|
|
406
|
+
worktreePath: "/tmp/wt-def",
|
|
407
|
+
},
|
|
408
|
+
],
|
|
409
|
+
});
|
|
410
|
+
expect(res.agentCalls).toHaveLength(1);
|
|
411
|
+
expect(res.agentCalls[0]!.opts.returnMeta).toBeUndefined();
|
|
412
|
+
expect(res.workerError).toBeUndefined();
|
|
413
|
+
// 单值:parsedOutput 优先({ok:true}),sessionFile/worktreePath 被丢弃
|
|
414
|
+
expect(res.returnValue).toEqual({ ok: true });
|
|
415
|
+
expect(res.exitCode).not.toBe(1);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it("agent({task, returnMeta:true}) task/agent 快捷分支也透传 returnMeta(m1 修复)", async () => {
|
|
419
|
+
// m1 修复:task/agent 快捷分支现在透传 returnMeta(之前丢弃)。
|
|
420
|
+
// 用 task 而非 prompt 触发快捷分支,验证 returnMeta 生效。
|
|
421
|
+
const script = `
|
|
422
|
+
const r = await agent({ task: "via-task-branch", returnMeta: true });
|
|
423
|
+
return r;
|
|
424
|
+
`;
|
|
425
|
+
const res = await runWorker(script, {
|
|
426
|
+
agentResultObjects: [
|
|
427
|
+
{
|
|
428
|
+
content: "task-raw",
|
|
429
|
+
parsedOutput: "task-value",
|
|
430
|
+
sessionFile: "/tmp/sess-3.jsonl",
|
|
431
|
+
worktreePath: "/tmp/wt-ghi",
|
|
432
|
+
error: "soft-fail-msg",
|
|
433
|
+
},
|
|
434
|
+
],
|
|
435
|
+
});
|
|
436
|
+
expect(res.agentCalls).toHaveLength(1);
|
|
437
|
+
// 快捷分支透传 returnMeta(m1 修复点)
|
|
438
|
+
expect(res.agentCalls[0]!.opts.returnMeta).toBe(true);
|
|
439
|
+
expect(res.agentCalls[0]!.opts.prompt).toBe("via-task-branch");
|
|
440
|
+
expect(res.workerError).toBeUndefined();
|
|
441
|
+
// returnMeta 生效 → resolve 对象(非单值)
|
|
442
|
+
expect(res.returnValue).toEqual({
|
|
443
|
+
value: "task-value",
|
|
444
|
+
sessionFile: "/tmp/sess-3.jsonl",
|
|
445
|
+
worktreePath: "/tmp/wt-ghi",
|
|
446
|
+
error: "soft-fail-msg",
|
|
447
|
+
});
|
|
448
|
+
expect(res.exitCode).not.toBe(1);
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
|
|
@@ -131,3 +131,129 @@ describe("buildWorkerScript — W1 postMessage defense & parallel degrade", () =
|
|
|
131
131
|
});
|
|
132
132
|
});
|
|
133
133
|
});
|
|
134
|
+
|
|
135
|
+
// ── W2: agent() returnMeta 模式 ──
|
|
136
|
+
// 验证 returnMeta 标志:known fields 放行 + handler resolve 分支 + 缓存重放分支,
|
|
137
|
+
// 且两处分支结构对称(returnMeta===true → {value, sessionFile, worktreePath, error})。
|
|
138
|
+
|
|
139
|
+
describe("buildWorkerScript — W2 agent() returnMeta mode", () => {
|
|
140
|
+
const script = buildWorkerScript("// noop user script");
|
|
141
|
+
|
|
142
|
+
describe("returnMeta known field whitelist", () => {
|
|
143
|
+
it("includes returnMeta in _knownFields Set", () => {
|
|
144
|
+
expect(script).toContain('"returnMeta"');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("includes returnMeta in unknown-fields warning text", () => {
|
|
148
|
+
expect(script).toContain("cwd, fork, worktree, returnMeta");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("does NOT warn about returnMeta when only returnMeta is passed (parity with fork/worktree)", () => {
|
|
152
|
+
// fork/worktree 已是 known fields;returnMeta 加入后不应触发 warn 文案中的 returnMeta。
|
|
153
|
+
// 简化断言:warning 文案里 Known 列表含 returnMeta(即被识别)。
|
|
154
|
+
expect(script).toMatch(/Known fields:.*returnMeta/);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("_pendingCalls stores returnMeta flag", () => {
|
|
159
|
+
it("stores returnMeta: opts.returnMeta === true in _pendingCalls.set", () => {
|
|
160
|
+
expect(script).toContain(
|
|
161
|
+
"returnMeta: opts.returnMeta === true",
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("agent-result handler resolve branch (改动 9b)", () => {
|
|
167
|
+
it("branches on pending.returnMeta", () => {
|
|
168
|
+
expect(script).toContain("if (pending.returnMeta)");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("resolve branch returns {value, sessionFile, worktreePath, error}", () => {
|
|
172
|
+
const handlerBlock = script.match(
|
|
173
|
+
/if \(pending\.returnMeta\) \{[\s\S]*?\} else \{[\s\S]*?pending\.resolve\(_value\);[\s\S]*?\}/,
|
|
174
|
+
);
|
|
175
|
+
expect(handlerBlock).toBeTruthy();
|
|
176
|
+
expect(handlerBlock![0]).toContain("value: _value");
|
|
177
|
+
expect(handlerBlock![0]).toContain("sessionFile: msg.result.sessionFile");
|
|
178
|
+
expect(handlerBlock![0]).toContain("worktreePath: msg.result.worktreePath");
|
|
179
|
+
expect(handlerBlock![0]).toContain("error: msg.result.error");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("fallback resolve single value uses msg.result.parsedOutput ?? msg.result.content", () => {
|
|
183
|
+
expect(script).toContain(
|
|
184
|
+
"const _value = msg.result.parsedOutput ?? msg.result.content;",
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe("cache replay returnMeta branch (改动 9c)", () => {
|
|
190
|
+
it("early-returns undefined when !cached", () => {
|
|
191
|
+
expect(script).toContain("if (!cached) return undefined;");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("branches on opts.returnMeta === true", () => {
|
|
195
|
+
expect(script).toContain("if (opts.returnMeta === true)");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("cache replay returns {value, sessionFile, worktreePath, error} from cached", () => {
|
|
199
|
+
const cacheBlock = script.match(
|
|
200
|
+
/if \(opts\.returnMeta === true\) \{[\s\S]*?return _cachedValue;/,
|
|
201
|
+
);
|
|
202
|
+
expect(cacheBlock).toBeTruthy();
|
|
203
|
+
expect(cacheBlock![0]).toContain("value: _cachedValue");
|
|
204
|
+
expect(cacheBlock![0]).toContain("sessionFile: cached.sessionFile");
|
|
205
|
+
expect(cacheBlock![0]).toContain("worktreePath: cached.worktreePath");
|
|
206
|
+
expect(cacheBlock![0]).toContain("error: cached.error");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("cache replay fallback uses cached.parsedOutput ?? cached.content", () => {
|
|
210
|
+
expect(script).toContain(
|
|
211
|
+
"const _cachedValue = cached.parsedOutput ?? cached.content;",
|
|
212
|
+
);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe("handler (9b) and cache replay (9c) branch symmetry", () => {
|
|
217
|
+
it("both expose worktreePath field (CL-1/DEC-2 core)", () => {
|
|
218
|
+
// handler 读 msg.result.worktreePath;cache replay 读 cached.worktreePath。
|
|
219
|
+
expect(script).toContain("msg.result.worktreePath");
|
|
220
|
+
expect(script).toContain("cached.worktreePath");
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// ── thinkingLevel 参数透传(agent() 三分支) ──
|
|
226
|
+
// 验证 thinkingLevel 在 string / task-agent / object.prompt 三个分支均被透传至
|
|
227
|
+
// agent-call 的 opts,且加入 _knownFields 白名单(object.prompt 分支整体透传,
|
|
228
|
+
// 不被 unknown-fields warn 误报)。底层 AgentCallOpts→mapToExecuteOptions→
|
|
229
|
+
// buildSpawnArgs 拼 pi CLI --model provider/modelId:thinkingLevel 已打通,
|
|
230
|
+
// 此处只验入口层 wiring。
|
|
231
|
+
|
|
232
|
+
describe("buildWorkerScript — agent() thinkingLevel passthrough (3 branches)", () => {
|
|
233
|
+
const script = buildWorkerScript("// noop user script");
|
|
234
|
+
|
|
235
|
+
it("string branch extracts thinkingLevel from secondArg (parity with model/scene/phase)", () => {
|
|
236
|
+
// agent("prompt", {thinkingLevel:"high"}) → string 分支 opts 提取 thinkingLevel
|
|
237
|
+
const stringBranch = script.match(/typeof firstArg === "string"[\s\S]*?\};/);
|
|
238
|
+
expect(stringBranch).toBeTruthy();
|
|
239
|
+
expect(stringBranch![0]).toContain(
|
|
240
|
+
"thinkingLevel: (secondArg && typeof secondArg === \"object\" && secondArg.thinkingLevel) || undefined",
|
|
241
|
+
);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("task/agent branch includes thinkingLevel in opts (parity with model/skill/timeoutMs)", () => {
|
|
245
|
+
// agent({task, agent, thinkingLevel}) → task/agent 快捷分支透传 thinkingLevel
|
|
246
|
+
const taskAgentBranch = script.match(/firstArg\.task \|\| firstArg\.agent[\s\S]*?\};/);
|
|
247
|
+
expect(taskAgentBranch).toBeTruthy();
|
|
248
|
+
expect(taskAgentBranch![0]).toContain("thinkingLevel: firstArg.thinkingLevel");
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("thinkingLevel is a known field (object.prompt branch passes through without unknown-fields warn)", () => {
|
|
252
|
+
// object.prompt 分支 opts = firstArg 整体透传,thinkingLevel 自然带入;
|
|
253
|
+
// 但必须进 _knownFields 白名单,否则 Object.keys(opts) 含 thinkingLevel 会触发
|
|
254
|
+
// unknown-fields warn(workerLogs 污染)。验证 Set 与 warn 文案均识别 thinkingLevel。
|
|
255
|
+
expect(script).toContain('"thinkingLevel"');
|
|
256
|
+
expect(script).toMatch(/Known fields:.*thinkingLevel/);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
* 参考:domain-models.md §失败处理矩阵。
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
+
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
29
|
+
|
|
28
30
|
import { SLUG_MAX_LENGTH } from "../execution/execute-options-mapper.ts";
|
|
29
31
|
import { createRecord, updateFromEvent } from "../execution/execution-record.ts";
|
|
30
32
|
import { SubagentStream } from "../execution/stream-sink.ts";
|
|
@@ -40,6 +42,8 @@ import type { AgentCallOpts, AgentResult, ExecutionTraceNode } from "./models/ty
|
|
|
40
42
|
import type { WorkflowRun } from "./models/workflow-run.ts";
|
|
41
43
|
import type { WorkerHandle } from "./worker-handle.ts";
|
|
42
44
|
|
|
45
|
+
const logger = getLogger("subagents");
|
|
46
|
+
|
|
43
47
|
// ── 常量 ─────────────────────────────────────────────────────
|
|
44
48
|
|
|
45
49
|
/**
|
|
@@ -225,7 +229,7 @@ function dispatchAgentCall(
|
|
|
225
229
|
if (typeof msg.callId !== "number" || !Number.isFinite(msg.callId) ||
|
|
226
230
|
typeof msg.opts !== "object" || msg.opts === null ||
|
|
227
231
|
typeof msg.opts.prompt !== "string") {
|
|
228
|
-
|
|
232
|
+
logger.error(`[workflow] malformed agent-call message: callId=${JSON.stringify(msg.callId)}, opts=${JSON.stringify(msg.opts)?.slice(0, 200)}`);
|
|
229
233
|
return;
|
|
230
234
|
}
|
|
231
235
|
|
|
@@ -299,7 +303,7 @@ function dispatchAgentCall(
|
|
|
299
303
|
});
|
|
300
304
|
postAgentResult(run, msg.callId, errorResult, false);
|
|
301
305
|
deps.store.save(run).catch((e: unknown) => {
|
|
302
|
-
|
|
306
|
+
logger.error(`[workflow] store.save failed (resolveAgentOpts): ${e instanceof Error ? e.message : String(e)}`);
|
|
303
307
|
});
|
|
304
308
|
return;
|
|
305
309
|
}
|
|
@@ -352,7 +356,7 @@ function dispatchAgentCall(
|
|
|
352
356
|
postBudgetUpdate(run);
|
|
353
357
|
deps.store.save(run).catch((e: unknown) => {
|
|
354
358
|
const m = e instanceof Error ? e.message : String(e);
|
|
355
|
-
|
|
359
|
+
logger.error(`[workflow] store.save failed (agent call ${msg.callId}): ${m}`);
|
|
356
360
|
});
|
|
357
361
|
|
|
358
362
|
// C-2:budget 超限 → 终止整个 run(避免继续 spawn 烧预算)
|
|
@@ -374,7 +378,7 @@ function dispatchAgentCall(
|
|
|
374
378
|
if (transitioned) {
|
|
375
379
|
deps.store.save(run).catch((e: unknown) => {
|
|
376
380
|
const m = e instanceof Error ? e.message : String(e);
|
|
377
|
-
|
|
381
|
+
logger.error(`[workflow] store.save failed (budget done): ${m}`);
|
|
378
382
|
});
|
|
379
383
|
deps.log?.("debug", "workflow:error-recovery", "run saved after budget done", { runId: run.runId, reason: run.state.reason });
|
|
380
384
|
// M12: onRunDone/emit 单独 try——这些是真实副作用,错误不应被静默吞掉
|
|
@@ -385,7 +389,7 @@ function dispatchAgentCall(
|
|
|
385
389
|
deps.onRunDone?.(run);
|
|
386
390
|
} catch (err) {
|
|
387
391
|
const m = err instanceof Error ? err.message : String(err);
|
|
388
|
-
|
|
392
|
+
logger.error(`[workflow] onRunDone/emit failed (budget done): ${m}`);
|
|
389
393
|
}
|
|
390
394
|
}
|
|
391
395
|
}
|
|
@@ -394,7 +398,7 @@ function dispatchAgentCall(
|
|
|
394
398
|
// withSlot 在 queued + signal-aborted 时 reject AbortError——预期,不记错。
|
|
395
399
|
if (err instanceof Error && err.name === "AbortError") return;
|
|
396
400
|
const message = err instanceof Error ? err.message : String(err);
|
|
397
|
-
|
|
401
|
+
logger.error(`[workflow] agent call ${msg.callId} failed: ${message}`);
|
|
398
402
|
// 兜底回发:executeAgentCall 抛非 Abort 异常时(如 runner undefined 的 TypeError、
|
|
399
403
|
// gate.withSlot 内部 bug)原 catch 仅 console.error,worker 内对 callId 的 pending
|
|
400
404
|
// Promise 永不 resolve → agent() 永久 await → worker 脚本挂死。构造 failed AgentResult
|
|
@@ -422,7 +426,7 @@ function dispatchAgentCall(
|
|
|
422
426
|
// S2: 与 .then 对称——catch 路径也同步 worker $BUDGET(幂等)
|
|
423
427
|
postBudgetUpdate(run);
|
|
424
428
|
deps.store.save(run).catch((e: unknown) => {
|
|
425
|
-
|
|
429
|
+
logger.error(`[workflow] store.save failed (catch fallback): ${e instanceof Error ? e.message : String(e)}`);
|
|
426
430
|
});
|
|
427
431
|
});
|
|
428
432
|
}
|
|
@@ -432,8 +436,12 @@ function dispatchAgentCall(
|
|
|
432
436
|
*
|
|
433
437
|
* postResult(workflow-call)与 postAgentResult(agent-call)各自前缀不同,故 prefix 参数化,
|
|
434
438
|
* 共享返回类型与构造逻辑,避免字面量重复导致形状漂移。
|
|
439
|
+
*
|
|
440
|
+
* W2 防御关键纯函数——export 供独立单测(error-recovery-serialize-failed-result.test.ts)验证
|
|
441
|
+
* 返回 shape `{content:"", error:"<prefix>: <errMsg>"}`,确保两条 fallback 路径(workflow-call /
|
|
442
|
+
* agent-call)共享同一构造逻辑不漂移。
|
|
435
443
|
*/
|
|
436
|
-
function makeSerializeFailedResult(
|
|
444
|
+
export function makeSerializeFailedResult(
|
|
437
445
|
prefix: string,
|
|
438
446
|
errMsg: string,
|
|
439
447
|
): { content: string; error: string } {
|
|
@@ -456,7 +464,7 @@ function dispatchWorkflowCall(
|
|
|
456
464
|
if (typeof msg.callId !== "number" || !Number.isFinite(msg.callId) ||
|
|
457
465
|
typeof msg.name !== "string" ||
|
|
458
466
|
typeof msg.args !== "object" || msg.args === null) {
|
|
459
|
-
|
|
467
|
+
logger.error(`[workflow] malformed workflow-call message: callId=${JSON.stringify(msg.callId)}, name=${JSON.stringify(msg.name)}`);
|
|
460
468
|
return;
|
|
461
469
|
}
|
|
462
470
|
|
|
@@ -474,7 +482,7 @@ function dispatchWorkflowCall(
|
|
|
474
482
|
});
|
|
475
483
|
} catch (err) {
|
|
476
484
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
477
|
-
|
|
485
|
+
logger.error(`[workflow] postResult (workflow-call callId=${msg.callId}) failed: ${errMsg}. Sending error fallback.`);
|
|
478
486
|
// 回发纯字符串 fallback result(必可克隆),让 worker pending resolve
|
|
479
487
|
try {
|
|
480
488
|
run.runtime?.worker.postMessage({
|
|
@@ -484,7 +492,7 @@ function dispatchWorkflowCall(
|
|
|
484
492
|
});
|
|
485
493
|
} catch {
|
|
486
494
|
// fallback 也失败——worker 此 callId 的 pending 只能靠 timeout 兜底
|
|
487
|
-
|
|
495
|
+
logger.error(`[workflow] postResult fallback also failed (callId=${msg.callId}): worker pending will hang until timeout`);
|
|
488
496
|
}
|
|
489
497
|
}
|
|
490
498
|
};
|
|
@@ -515,7 +523,7 @@ function dispatchWorkflowCall(
|
|
|
515
523
|
* postMessage 同步抛 DataCloneError。若冒泡到 dispatchAgentCall 的 .then 回调,会中断
|
|
516
524
|
* 后续 postBudgetUpdate/store.save/budget 检查,run 卡在 running。故内部 try/catch:
|
|
517
525
|
* 失败时记录诊断 + 回发纯字符串 fallback result(必可克隆),让 worker pending resolve。
|
|
518
|
-
*
|
|
526
|
+
* 函数签名不变(所有调用点无需改动),仅用共享 logger 记日志(deps 不在手边)。
|
|
519
527
|
*/
|
|
520
528
|
function postAgentResult(
|
|
521
529
|
run: WorkflowRun,
|
|
@@ -527,7 +535,7 @@ function postAgentResult(
|
|
|
527
535
|
run.runtime?.worker.postMessage({ type: "agent-result", callId, result, cached });
|
|
528
536
|
} catch (err) {
|
|
529
537
|
const msg = err instanceof Error ? err.message : String(err);
|
|
530
|
-
|
|
538
|
+
logger.error(`[workflow] postAgentResult failed (callId=${callId}): ${msg}. Result likely contains non-cloneable value.`);
|
|
531
539
|
// 回发纯字符串 fallback result(必可克隆),让 worker pending resolve(避免永久挂起)
|
|
532
540
|
try {
|
|
533
541
|
run.runtime?.worker.postMessage({
|
|
@@ -539,7 +547,7 @@ function postAgentResult(
|
|
|
539
547
|
});
|
|
540
548
|
} catch {
|
|
541
549
|
// fallback 也失败——worker 此 callId 的 pending 只能靠 timeout/exit 兜底
|
|
542
|
-
|
|
550
|
+
logger.error(`[workflow] postAgentResult fallback also failed (callId=${callId}): worker pending will hang until timeout`);
|
|
543
551
|
}
|
|
544
552
|
}
|
|
545
553
|
}
|
|
@@ -565,7 +573,7 @@ export function postBudgetUpdate(run: WorkflowRun): void {
|
|
|
565
573
|
const msg = err instanceof Error ? err.message : String(err);
|
|
566
574
|
// budget 是纯 number 不太可能失败,但防御性兜底——budget 同步非关键(worker 仍可
|
|
567
575
|
// 基于 $BUDGET.spent() 自行累计),失败仅记日志,不中断调用方流程。
|
|
568
|
-
|
|
576
|
+
logger.error(`[workflow] postBudgetUpdate failed: ${msg}. Budget sync to worker skipped (non-critical).`);
|
|
569
577
|
}
|
|
570
578
|
}
|
|
571
579
|
|
|
@@ -30,6 +30,8 @@
|
|
|
30
30
|
* 参考:domain-models.md §1(聚合根状态机)。
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
|
+
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
34
|
+
|
|
33
35
|
import { ConcurrencyGate, DEFAULT_CONCURRENCY } from "./concurrency-gate.ts";
|
|
34
36
|
import {
|
|
35
37
|
handleWorkerError,
|
|
@@ -45,6 +47,8 @@ import type { DoneReason } from "./models/types.ts";
|
|
|
45
47
|
import { WorkflowRun } from "./models/workflow-run.ts";
|
|
46
48
|
import type { WorkerHandle } from "./worker-handle.ts";
|
|
47
49
|
|
|
50
|
+
const logger = getLogger("subagents");
|
|
51
|
+
|
|
48
52
|
// ── 常量 ─────────────────────────────────────────────────────
|
|
49
53
|
|
|
50
54
|
/** runId 生成:wf-<timestamp>-<base36 random 6 chars>。 */
|
|
@@ -115,7 +119,7 @@ export function scheduleTimeBudget(
|
|
|
115
119
|
void abortRun(runId, deps, "Time budget exceeded", "time_limited").catch(
|
|
116
120
|
(err: unknown) => {
|
|
117
121
|
const msg = err instanceof Error ? err.message : String(err);
|
|
118
|
-
|
|
122
|
+
logger.error(`[workflow] time budget abort failed: ${msg}`);
|
|
119
123
|
},
|
|
120
124
|
);
|
|
121
125
|
}, budgetTimeMs);
|
|
@@ -174,7 +178,7 @@ export async function runWorkflow(
|
|
|
174
178
|
() => {
|
|
175
179
|
void abortRun(runId, deps, "External signal aborted").catch((err: unknown) => {
|
|
176
180
|
const msg = err instanceof Error ? err.message : String(err);
|
|
177
|
-
|
|
181
|
+
logger.error(`[workflow] abortRun on signal failed: ${msg}`);
|
|
178
182
|
});
|
|
179
183
|
},
|
|
180
184
|
{ once: true },
|
|
@@ -135,6 +135,13 @@ export interface AgentCallOpts {
|
|
|
135
135
|
* undefined 时 spawn 继承 workflow 进程的 cwd(向后兼容)。
|
|
136
136
|
*/
|
|
137
137
|
cwd?: string;
|
|
138
|
+
/** Inherit parent session context (fork mode). Required when worktree isolation is enabled. */
|
|
139
|
+
fork?: boolean;
|
|
140
|
+
/** Filesystem isolation: when true, creates a new git worktree for the agent (requires fork: true). */
|
|
141
|
+
worktree?: boolean;
|
|
142
|
+
/** When true, agent() resolves {value, sessionFile, worktreePath, error} instead of a bare value.
|
|
143
|
+
* Worker-layer flag only — not forwarded to ExecuteOptions (mapToExecuteOptions drops it). */
|
|
144
|
+
returnMeta?: boolean;
|
|
138
145
|
}
|
|
139
146
|
|
|
140
147
|
/**
|
|
@@ -194,6 +201,17 @@ export interface AgentResult {
|
|
|
194
201
|
* 窗口期内可能 undefined(session 尚未创建成功)。
|
|
195
202
|
*/
|
|
196
203
|
sessionFile?: string;
|
|
204
|
+
/**
|
|
205
|
+
* Absolute path of the git worktree used for filesystem isolation (set when
|
|
206
|
+
* worktree isolation is active). Injected by executeAndAwait from record.worktreeHandle.path.
|
|
207
|
+
*
|
|
208
|
+
* ⚠️ Diagnostic only, may not exist: executeAndAwait's finalizeRecord cleans up the
|
|
209
|
+
* worktree (git worktree remove --force) before returning, so by the time this field
|
|
210
|
+
* reaches the caller the directory has typically been deleted. Use it only for log/trace
|
|
211
|
+
* correlation (e.g. attributing a session jsonl to its worktree origin) — never as a cwd
|
|
212
|
+
* for a subsequent agent or filesystem operation (would ENOENT).
|
|
213
|
+
*/
|
|
214
|
+
worktreePath?: string;
|
|
197
215
|
/** All tool calls collected from JSONL stream (FR-7). */
|
|
198
216
|
toolCalls?: ToolCallEntry[];
|
|
199
217
|
}
|
|
@@ -141,7 +141,18 @@ export function buildWorkerScript(userScript: string): string {
|
|
|
141
141
|
' // 不丢失。失败 resolve 为空字符串是既定容错策略。',
|
|
142
142
|
' // parsedOutput: validated data object from structured-output execute().',
|
|
143
143
|
' // Fallback to content (raw text) when no schema was requested or on error.',
|
|
144
|
-
'
|
|
144
|
+
' // W2 改动 9(b):returnMeta===true 时 resolve {value,sessionFile,worktreePath,error}(对称缓存重放 9c);否则单值。',
|
|
145
|
+
' const _value = msg.result.parsedOutput ?? msg.result.content;',
|
|
146
|
+
' if (pending.returnMeta) {',
|
|
147
|
+
' pending.resolve({',
|
|
148
|
+
' value: _value,',
|
|
149
|
+
' sessionFile: msg.result.sessionFile,',
|
|
150
|
+
' worktreePath: msg.result.worktreePath,',
|
|
151
|
+
' error: msg.result.error,',
|
|
152
|
+
' });',
|
|
153
|
+
' } else {',
|
|
154
|
+
' pending.resolve(_value);',
|
|
155
|
+
' }',
|
|
145
156
|
' }',
|
|
146
157
|
' } else if (msg.type === "workflow-result") {',
|
|
147
158
|
' const pending = _pendingCalls.get(msg.callId);',
|
|
@@ -180,11 +191,14 @@ export function buildWorkerScript(userScript: string): string {
|
|
|
180
191
|
' model: (secondArg && typeof secondArg === "object" && secondArg.model) || undefined,',
|
|
181
192
|
' scene: (secondArg && typeof secondArg === "object" && secondArg.scene) || undefined,\n' +
|
|
182
193
|
' phase: (secondArg && typeof secondArg === "object" && secondArg.phase) || undefined,',
|
|
194
|
+
' thinkingLevel: (secondArg && typeof secondArg === "object" && secondArg.thinkingLevel) || undefined,',
|
|
183
195
|
' };',
|
|
184
196
|
' } else if (typeof firstArg === "object" && firstArg !== null) {',
|
|
185
197
|
' if (firstArg.prompt) {',
|
|
186
198
|
' opts = firstArg;',
|
|
187
199
|
' } else if (firstArg.task || firstArg.agent) {',
|
|
200
|
+
' // fork/worktree/returnMeta forwarded here (parity with agent({prompt,...}) and direct-pass',
|
|
201
|
+
' // branches). Previously this shortcut dropped them (w1 only noted, not wired).',
|
|
188
202
|
' opts = {',
|
|
189
203
|
' prompt: firstArg.task || firstArg.prompt || "",',
|
|
190
204
|
' description: firstArg.label || firstArg.description,',
|
|
@@ -195,6 +209,10 @@ export function buildWorkerScript(userScript: string): string {
|
|
|
195
209
|
' skill: firstArg.skill,',
|
|
196
210
|
' timeoutMs: firstArg.timeoutMs,',
|
|
197
211
|
' cwd: firstArg.cwd,',
|
|
212
|
+
' fork: firstArg.fork,',
|
|
213
|
+
' worktree: firstArg.worktree,',
|
|
214
|
+
' returnMeta: firstArg.returnMeta,',
|
|
215
|
+
' thinkingLevel: firstArg.thinkingLevel,',
|
|
198
216
|
' };',
|
|
199
217
|
' } else {',
|
|
200
218
|
' opts = firstArg;',
|
|
@@ -204,10 +222,10 @@ export function buildWorkerScript(userScript: string): string {
|
|
|
204
222
|
' }',
|
|
205
223
|
'',
|
|
206
224
|
' // Validate known agent() fields to catch API misuse early',
|
|
207
|
-
' const _knownFields = new Set(["prompt", "description", "schema", "model", "scene", "label", "task", "agent", "phase", "skill", "timeoutMs", "cwd"]);',
|
|
225
|
+
' const _knownFields = new Set(["prompt", "description", "schema", "model", "scene", "label", "task", "agent", "phase", "skill", "timeoutMs", "cwd", "fork", "worktree", "returnMeta", "thinkingLevel"]);',
|
|
208
226
|
' const _unknownFields = Object.keys(opts).filter((k) => !_knownFields.has(k));',
|
|
209
227
|
' if (_unknownFields.length > 0) {',
|
|
210
|
-
' _pushWorkerLog("warn", ["[workflow] agent() received unknown fields: " + _unknownFields.join(", ") + ". Known fields: prompt, description, schema, model, scene, label, task, agent, phase, skill, timeoutMs, cwd"]);',
|
|
228
|
+
' _pushWorkerLog("warn", ["[workflow] agent() received unknown fields: " + _unknownFields.join(", ") + ". Known fields: prompt, description, schema, model, scene, label, task, agent, phase, skill, timeoutMs, cwd, fork, worktree, returnMeta, thinkingLevel"]);',
|
|
211
229
|
' }',
|
|
212
230
|
'',
|
|
213
231
|
' const callId = _callIdCounter;',
|
|
@@ -217,7 +235,18 @@ export function buildWorkerScript(userScript: string): string {
|
|
|
217
235
|
' const cached = _callCache.get(callId);',
|
|
218
236
|
' // 与 live handler 对齐:失败也 resolve(回退 content),不 throw。',
|
|
219
237
|
' // 见 agent-result 消息处理的注释:拒绝传播失败到 agent 外部。',
|
|
220
|
-
'
|
|
238
|
+
' // W2 改动 9(c):returnMeta 分支(对称 handler 9b),opts 仍在闭包作用域。',
|
|
239
|
+
' if (!cached) return undefined;',
|
|
240
|
+
' const _cachedValue = cached.parsedOutput ?? cached.content;',
|
|
241
|
+
' if (opts.returnMeta === true) {',
|
|
242
|
+
' return {',
|
|
243
|
+
' value: _cachedValue,',
|
|
244
|
+
' sessionFile: cached.sessionFile,',
|
|
245
|
+
' worktreePath: cached.worktreePath,',
|
|
246
|
+
' error: cached.error,',
|
|
247
|
+
' };',
|
|
248
|
+
' }',
|
|
249
|
+
' return _cachedValue;',
|
|
221
250
|
' }',
|
|
222
251
|
'',
|
|
223
252
|
' const _effectivePhase = opts.phase || _currentPhase;\n' +
|
|
@@ -227,7 +256,7 @@ export function buildWorkerScript(userScript: string): string {
|
|
|
227
256
|
' return Promise.reject(new Error("postMessage failed for agent-call (callId=" + callId + "): see workerLogs"));',
|
|
228
257
|
' }',
|
|
229
258
|
' return new Promise((resolve, reject) => {',
|
|
230
|
-
' _pendingCalls.set(callId, { resolve, reject });',
|
|
259
|
+
' _pendingCalls.set(callId, { resolve, reject, returnMeta: opts.returnMeta === true });',
|
|
231
260
|
' });',
|
|
232
261
|
' }',
|
|
233
262
|
'',
|
package/workflows/README.md
CHANGED
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
| `parallel.js` | 多视角并行分析 → 聚合汇总 | `target`(可选 `perspectives`) | 多维度评估同一目标(安全/性能/可维护性等) |
|
|
11
11
|
| `scatter-gather.js` | scatter 拆分 → parallel 处理 → gather 合并 | `task` | 大任务先拆成子任务再并行处理 |
|
|
12
12
|
| `map-reduce.js` | parallel map → reduce 归约 | `items`/`itemsJson` + `operation` | 对已知数组批量变换后归约成单一结果 |
|
|
13
|
+
| `review-fix-loop.js` | 多批串行(批内并行 review → aggregate → fix → 重审) | `targetType` + `target`(可选 `batch1..batchN`) | 代码/文档审查并修复直到 clean;前置检查先行(fallow 等) |
|
|
14
|
+
|
|
15
|
+
> ⚠️ **review-fix-loop 是唯一带写操作的内置 workflow**(fix 阶段修改文件,`autoCommit=true` 才 commit)。其他 4 个均为只读分析。
|
|
13
16
|
|
|
14
17
|
## 用法
|
|
15
18
|
|
|
@@ -49,6 +52,21 @@ workflow run map-reduce --args itemsJson=/path/to/items.json --args operation=".
|
|
|
49
52
|
|
|
50
53
|
`items` 直接传 JSON 数组,或 `itemsJson` 传 JSON 文件路径(二选一)。`parallel()` 对每个 item 并行执行 `operation` → reduce 阶段用 `agent()` 把各 item 的 map 结果归约成单一结论(LLM 归约,非纯代码拼接)。
|
|
51
54
|
|
|
55
|
+
### review-fix-loop — 多批审查-修复循环
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
workflow run review-fix-loop --args targetType=git-diff target=main \
|
|
59
|
+
--args batch1=fallow-scan --args batch2=reviewer --args autoCommit=true
|
|
60
|
+
workflow run review-fix-loop --args targetType=file target=/path/to/doc.md \
|
|
61
|
+
--args batch1=reviewer
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
- `targetType` 枚举:`git-diff`(target=base ref)/ `file`(target=路径)/ `dir`(target=目录)/ `text`(target=自由描述)
|
|
65
|
+
- `batch1..batchN`:批串行,批内并行 review → aggregate → fix → 重审直到 clean;批次用于前置依赖(如 `fallow-scan` 静态分析先行,后续审查才有意义)
|
|
66
|
+
- 批内某 agent 无 must-fix 后后续轮跳过(`skipCleanAgents`,默认 true);`recheckAfterFix=true` 可在 fix 后重派全批做回归防护
|
|
67
|
+
- agent 项支持:AgentRegistry 名(如 `reviewer`)/ 自定义 .md 文件路径(如 `batch1=/path/to/reviewer.md`)/ 内置 `fallow-scan`
|
|
68
|
+
- ⚠️ **fix 阶段会修改文件;`autoCommit` 默认 false(不 commit)**,需要提交时显式 `autoCommit=true`
|
|
69
|
+
|
|
52
70
|
## 编排 API
|
|
53
71
|
|
|
54
72
|
这些 workflow 内部使用的编排函数(`agent()` / `parallel()` / `pipeline()` / `workflow()`)由 worker 线程注入,完整 API 参考见 `skills/workflow-script-format/SKILL.md`。
|