@zhushanwen/pi-subagent-workflow 8.0.0 → 8.1.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 (55) hide show
  1. package/package.json +7 -4
  2. package/scripts/rfl.mjs +308 -0
  3. package/src/execution/__tests__/finalize-record.test.ts +33 -7
  4. package/src/execution/__tests__/helpers/spawn-mock.ts +12 -3
  5. package/src/execution/__tests__/record-store.test.ts +284 -2
  6. package/src/execution/__tests__/spawn-args.test.ts +5 -5
  7. package/src/execution/__tests__/subagent-service-message-close.test.ts +31 -0
  8. package/src/execution/__tests__/worktree-manager.test.ts +9 -2
  9. package/src/execution/__tests__/worktree-pid-registration.integration.test.ts +4 -1
  10. package/src/execution/__tests__/worktree-reconcile.integration.test.ts +181 -0
  11. package/src/execution/__tests__/worktree-registry.test.ts +72 -34
  12. package/src/execution/agent-result-mapper.ts +4 -1
  13. package/src/execution/argv-mirror.ts +1 -1
  14. package/src/execution/channel-registry-access.ts +3 -1
  15. package/src/execution/finalize-record.ts +16 -7
  16. package/src/execution/idle-gc.ts +47 -0
  17. package/src/execution/lifecycle-manager.ts +9 -2
  18. package/src/execution/record-entry.ts +118 -0
  19. package/src/execution/record-store.ts +290 -1
  20. package/src/execution/session-pending.ts +5 -4
  21. package/src/execution/session-runner.ts +484 -289
  22. package/src/execution/subagent-service.ts +87 -37
  23. package/src/execution/temp-prompt.ts +8 -3
  24. package/src/execution/types.ts +20 -2
  25. package/src/execution/worktree-manager.ts +325 -14
  26. package/src/execution/worktree-registry.ts +90 -33
  27. package/src/index.ts +42 -2
  28. package/src/interface/__tests__/tool-workflow-script-generate.test.ts +103 -26
  29. package/src/interface/__tests__/tool-workflow-throw-paths.test.ts +179 -0
  30. package/src/interface/format.ts +10 -4
  31. package/src/interface/tool-render.ts +10 -3
  32. package/src/interface/tool-workflow-script.ts +29 -33
  33. package/src/interface/tool-workflow.ts +47 -65
  34. package/src/interface/views/detail-content.ts +1 -1
  35. package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +9 -1
  36. package/src/orchestration/__tests__/execute-agent-call.test.ts +49 -1
  37. package/src/orchestration/__tests__/jsonl-run-store-loadall-sources.test.ts +171 -0
  38. package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +181 -19
  39. package/src/orchestration/__tests__/lifecycle-runid-injection.test.ts +96 -0
  40. package/src/orchestration/__tests__/review-fix-loop-e2e.test.ts +1108 -19
  41. package/src/orchestration/__tests__/test-mocks.ts +9 -3
  42. package/src/orchestration/__tests__/worker-returnmeta-passthrough.test.ts +164 -0
  43. package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +12 -0
  44. package/src/orchestration/__tests__/workflows-e2e.test.ts +37 -39
  45. package/src/orchestration/error-recovery.ts +4 -1
  46. package/src/orchestration/execute-agent-call.ts +13 -3
  47. package/src/orchestration/jsonl-run-store.ts +139 -60
  48. package/src/orchestration/lifecycle.ts +10 -1
  49. package/src/orchestration/worker-script-builder.ts +9 -1
  50. package/src/shared/__tests__/resource-discovery.test.ts +24 -0
  51. package/src/shared/agent-ref.ts +6 -1
  52. package/src/shared/resource-discovery.ts +15 -1
  53. package/src/shared/schema-jsonify.ts +4 -1
  54. package/workflows/review-fix-loop-utils.cjs +542 -32
  55. package/workflows/review-fix-loop.js +462 -109
@@ -32,8 +32,14 @@ import type {
32
32
  Theme,
33
33
  } from "@earendil-works/pi-coding-agent";
34
34
 
35
- /** 误用即抛错的占位成员:比静默返回 undefined 的 no-op 更早暴露「缺 mock」。 */
36
- function failLoud<T>(memberPath: string): T {
35
+ /**
36
+ * 误用即抛错的占位成员:比静默返回 undefined 的 no-op 更早暴露「缺 mock」。
37
+ *
38
+ * T 收窄为 object:调用点(ModelRegistry/Theme)均为对象类型;Proxy 静态类型是 `{}`,
39
+ * 目标是 nominal class(private 字段阻断结构构造),单重 `as T` 断言已足够表达
40
+ * 「结构上无法构造,运行时 fail-loud 兜底」的意图,无需 `as unknown as T` 双重断言。
41
+ */
42
+ function failLoud<T extends object>(memberPath: string): T {
37
43
  return new Proxy(
38
44
  {},
39
45
  {
@@ -44,7 +50,7 @@ function failLoud<T>(memberPath: string): T {
44
50
  );
45
51
  },
46
52
  },
47
- ) as unknown as T;
53
+ ) as T;
48
54
  }
49
55
 
50
56
  /**
@@ -0,0 +1,164 @@
1
+ /**
2
+ * rfl 仪表 T1(tier-1 §7.1):returnMeta 透传 usage/durationMs/sessionId。
3
+ *
4
+ * 两个对称点的运行时验证(worker-script-builder 模板字符串断言无法证明
5
+ * 「真实 Worker 线程里 resolve 对象字段齐全」):
6
+ * A1 live resolve——agent-result 消息回发完整 AgentResult,returnMeta:true
7
+ * 时 resolve 对象 = {value, sessionFile, worktreePath, error, usage, durationMs, sessionId}
8
+ * A2 缓存重放——workerData.callCache 命中时重建对象同样含三字段(对称点)
9
+ *
10
+ * 模式与 worker-script-builder-runtime.test.ts 相同:真实 Worker 线程执行
11
+ * buildWorkerScript 产物,主线程模拟 runtime 回发消息。
12
+ */
13
+ import { Worker } from "node:worker_threads";
14
+
15
+ import { afterEach, describe, expect, it } from "vitest";
16
+
17
+ import { buildWorkerScript } from "../worker-script-builder.ts";
18
+
19
+ const USAGE = { input: 100, output: 50, cacheRead: 700, cacheWrite: 30, cost: 0.012, contextTokens: 850, turns: 3 };
20
+
21
+ const createdWorkers: Worker[] = [];
22
+
23
+ afterEach(() => {
24
+ for (const w of createdWorkers.splice(0)) {
25
+ w.terminate().catch(() => {});
26
+ }
27
+ });
28
+
29
+ interface RunOutcome {
30
+ returnValue?: unknown;
31
+ errorMessage?: string;
32
+ exitCode?: number;
33
+ }
34
+
35
+ /**
36
+ * 起 Worker 跑 userScript;收到首个 agent-call 即回发 agentResult(调用方提供),
37
+ * 等脚本 return。callCache 命中路径不会发出 agent-call(直接从缓存重建返回)。
38
+ */
39
+ function runWorker(
40
+ userScript: string,
41
+ opts: { agentResult?: Record<string, unknown>; callCache?: Record<number, unknown>; timeoutMs?: number },
42
+ ): Promise<RunOutcome> {
43
+ const timeoutMs = opts.timeoutMs ?? 4000;
44
+ return new Promise((resolve, reject) => {
45
+ const worker = new Worker(buildWorkerScript(userScript), {
46
+ eval: true,
47
+ workerData: {
48
+ scriptPath: "test.js",
49
+ args: {},
50
+ workspace: process.cwd(),
51
+ budget: { maxTokens: 0, usedTokens: 0, usedCost: 0 },
52
+ callCache: opts.callCache ?? {},
53
+ },
54
+ });
55
+ createdWorkers.push(worker);
56
+ const out: RunOutcome = {};
57
+ let settled = false;
58
+ const timer = setTimeout(() => {
59
+ worker.terminate().catch(() => {});
60
+ reject(new Error("Worker timed out — likely hung"));
61
+ }, timeoutMs);
62
+ const finish = (r: RunOutcome): void => {
63
+ if (settled) return;
64
+ settled = true;
65
+ clearTimeout(timer);
66
+ resolve(r);
67
+ };
68
+ worker.on("message", (raw: unknown) => {
69
+ const msg = raw as { type?: string; callId?: number };
70
+ if (msg.type === "agent-call" && typeof msg.callId === "number") {
71
+ worker.postMessage({
72
+ type: "agent-result",
73
+ callId: msg.callId,
74
+ result: opts.agentResult ?? { content: "fallback", parsedOutput: { ok: true } },
75
+ cached: false,
76
+ });
77
+ } else if (msg.type === "return") {
78
+ out.returnValue = (raw as { result?: unknown }).result;
79
+ finish(out);
80
+ } else if (msg.type === "error") {
81
+ out.errorMessage = (raw as { error?: string }).error;
82
+ finish(out);
83
+ }
84
+ });
85
+ worker.on("exit", (code: number) => {
86
+ out.exitCode = code;
87
+ if (code !== 0) finish(out);
88
+ });
89
+ worker.on("error", (err: Error) => {
90
+ out.errorMessage = err.message;
91
+ finish(out);
92
+ });
93
+ });
94
+ }
95
+
96
+ const META_SCRIPT = "const meta = await agent({ prompt: 'passthrough probe', returnMeta: true }); return meta;";
97
+
98
+ describe("A1 returnMeta live resolve 透传(tier-1 §7.1)", () => {
99
+ it("A1 agent-result 回发完整 AgentResult 时 resolve 对象含 usage/durationMs/sessionId(七字段全集)", async () => {
100
+ const outcome = await runWorker(META_SCRIPT, {
101
+ agentResult: {
102
+ content: "raw text",
103
+ parsedOutput: { ok: true },
104
+ sessionFile: "/tmp/sess.jsonl",
105
+ worktreePath: "/tmp/wt",
106
+ error: undefined,
107
+ usage: USAGE,
108
+ durationMs: 83_000,
109
+ sessionId: "sess-abc-1",
110
+ },
111
+ });
112
+ expect(outcome.errorMessage).toBeUndefined();
113
+ expect(outcome.returnValue).toEqual({
114
+ value: { ok: true },
115
+ sessionFile: "/tmp/sess.jsonl",
116
+ worktreePath: "/tmp/wt",
117
+ error: undefined,
118
+ usage: USAGE,
119
+ durationMs: 83_000,
120
+ sessionId: "sess-abc-1",
121
+ });
122
+ });
123
+
124
+ it("A1 旧主线程(AgentResult 无三字段)时 resolve 对象三字段为 undefined(向后兼容不抛错)", async () => {
125
+ const outcome = await runWorker(META_SCRIPT, {
126
+ agentResult: { content: "raw", parsedOutput: { v: 1 }, sessionFile: "/tmp/s.jsonl" },
127
+ });
128
+ expect(outcome.returnValue).toMatchObject({
129
+ value: { v: 1 },
130
+ sessionFile: "/tmp/s.jsonl",
131
+ usage: undefined,
132
+ durationMs: undefined,
133
+ sessionId: undefined,
134
+ });
135
+ });
136
+ });
137
+
138
+ describe("A2 returnMeta 缓存重放重建透传(对称点,tier-1 §7.1)", () => {
139
+ it("A2 workerData.callCache 命中时重建对象含 usage/durationMs/sessionId(不回发 agent-call)", async () => {
140
+ const outcome = await runWorker(META_SCRIPT, {
141
+ callCache: {
142
+ 0: {
143
+ content: "cached raw",
144
+ parsedOutput: { cached: true },
145
+ sessionFile: "/tmp/cached.jsonl",
146
+ worktreePath: "/tmp/cwt",
147
+ usage: USAGE,
148
+ durationMs: 42_000,
149
+ sessionId: "sess-cache-9",
150
+ },
151
+ },
152
+ });
153
+ expect(outcome.errorMessage).toBeUndefined();
154
+ expect(outcome.returnValue).toEqual({
155
+ value: { cached: true },
156
+ sessionFile: "/tmp/cached.jsonl",
157
+ worktreePath: "/tmp/cwt",
158
+ error: undefined,
159
+ usage: USAGE,
160
+ durationMs: 42_000,
161
+ sessionId: "sess-cache-9",
162
+ });
163
+ });
164
+ });
@@ -62,6 +62,18 @@ describe("buildWorkerScript — 模板 hoisting byte-identical 快照(IF5)",
62
62
  const [preB] = b.split("\n y\n");
63
63
  expect(preA).toBe(preB);
64
64
  });
65
+
66
+ it("A8 rfl 仪表透传字段字面量锚定(tier-1 §7.1):live resolve 与缓存重放两对称点均含三字段", () => {
67
+ const out = buildWorkerScript("// noop");
68
+ // live resolve 分支(agent-result 消息处理)
69
+ expect(out).toContain("usage: msg.result.usage,");
70
+ expect(out).toContain("durationMs: msg.result.durationMs,");
71
+ expect(out).toContain("sessionId: msg.result.sessionId,");
72
+ // 缓存重放分支(_callCache 命中重建,对称点)
73
+ expect(out).toContain("usage: cached.usage,");
74
+ expect(out).toContain("durationMs: cached.durationMs,");
75
+ expect(out).toContain("sessionId: cached.sessionId,");
76
+ });
65
77
  });
66
78
 
67
79
  describe("buildWorkerScript — _KNOWN_FIELDS module scope 提升(IF6)", () => {
@@ -476,63 +476,61 @@ describe("内置 workflow E2E(真实 worker thread + mock LLM runner)", () =
476
476
 
477
477
  it("TC4: actionRun 顺序——not_found 优先(平铺 + 不存在名);slug 护栏保留;chain 平铺 Correct 文案", async () => {
478
478
  const deps = makeDeps();
479
- // 平铺 + 不存在 name → not_found 优先(m6:registry.get 先于平铺检测)
480
- const notFound = await actionRun(
479
+ // 平铺 + 不存在 name → not_found 优先(m6:registry.get 先于平铺检测)。
480
+ // W4:not_found 改 throw(pi 只对 execute throw 置 isError:true,返回值里的
481
+ // isError 被 agent-loop 丢弃)——文案含 <available_workflows> <location> 恢复指引,
482
+ // pi catch 后原样进 toolResult content。
483
+ const notFoundErr: Error = await actionRun(
481
484
  { action: "run", name: wf("nope"), task: "x" },
482
485
  deps,
483
486
  undefined,
484
- );
485
- expect(notFound.isError).toBe(true);
486
- expect(notFound.content![0]!.text).toContain("not found"); // DoD 用户可见断言
487
- // R5(D7):workflow not_found 文案含 <available_workflows> <location> 恢复指引
488
- expect(notFound.content![0]!.text).toContain("<available_workflows>");
489
- expect(notFound.content![0]!.text).toContain("<location>");
490
- // chain 平铺 task → isError Correct 正例(动态集来自 registry)
491
- const flat = await actionRun(
487
+ ).catch((e: unknown) => e as Error);
488
+ expect(notFoundErr).toBeInstanceOf(Error);
489
+ expect(notFoundErr.message).toContain("not found"); // DoD 用户可见断言
490
+ expect(notFoundErr.message).toContain("<available_workflows>");
491
+ expect(notFoundErr.message).toContain("<location>");
492
+ // chain 平铺 task → throw Correct 正例(动态集来自 registry;W4b:原 textResult
493
+ // 间接 isError 返回改 throw——pi 只对 execute throw 置 isError:true,
494
+ // 返回值里的 isError 被 agent-loop 丢弃)
495
+ const flatErr: Error = await actionRun(
492
496
  { action: "run", name: wf("chain"), task: "x" },
493
497
  deps,
494
498
  undefined,
495
- );
496
- expect(flat.isError).toBe(true);
497
- expect(flat.content![0]!.text).toContain("Detected task at top level");
498
- expect(flat.content![0]!.text).toContain("Correct:");
499
- // slug 护栏保留(m6 顺序调整后仍在 runWorkflow 前)
500
- const slug = await actionRun(
499
+ ).catch((e: unknown) => e as Error);
500
+ expect(flatErr).toBeInstanceOf(Error);
501
+ expect(flatErr.message).toContain("Detected task at top level");
502
+ expect(flatErr.message).toContain("Correct:");
503
+ // slug 护栏保留(m6 顺序调整后仍在 runWorkflow 前;W4b 同改 throw)
504
+ const slugErr: Error = await actionRun(
501
505
  { action: "run", name: wf("chain"), args: { task: "x" }, slug: "a".repeat(40) },
502
506
  deps,
503
507
  undefined,
504
- );
505
- expect(slug.isError).toBe(true);
506
- expect(slug.content![0]!.text).toContain("slug exceeds");
508
+ ).catch((e: unknown) => e as Error);
509
+ expect(slugErr).toBeInstanceOf(Error);
510
+ expect(slugErr.message).toContain("slug exceeds");
507
511
  });
508
512
 
509
513
  it("TC6: 跨 workflow 平铺语义——review-fix-loop 平铺 task(非其参数)走 args-validator", async () => {
510
514
  const deps = makeDeps();
511
515
  // task 是 chain 参数非 review-fix-loop 参数 → 不报平铺 → args 缺 targetType
512
- // → m3 chokepoint invalid_args(错误更准——评审 m-5 语义锁定)
513
- const result = await actionRun(
514
- { action: "run", name: wf("review-fix-loop"), task: "x" },
515
- deps,
516
- undefined,
517
- );
518
- expect(result.isError).toBe(true);
519
- expect(result.content![0]!.text).toContain("Invalid args for workflow 'review-fix-loop'");
520
- expect(result.content![0]!.text).toContain("targetType");
516
+ // → m3 chokepoint invalid_args(错误更准——评审 m-5 语义锁定)。
517
+ // W4:ArgsValidationError 直接 throw(message 原文即 §5.3 指引)
518
+ await expect(
519
+ actionRun({ action: "run", name: wf("review-fix-loop"), task: "x" }, deps, undefined),
520
+ ).rejects.toThrow(/Invalid args for workflow 'review-fix-loop'[\s\S]*targetType/);
521
521
  });
522
522
 
523
- it("TC9: actionRun 参数校验失败 → isError ToolResult + §5.3 指引", async () => {
523
+ it("TC9: actionRun 参数校验失败 → throw + §5.3 指引(W4)", async () => {
524
524
  const deps = makeDeps();
525
- const result = await actionRun(
526
- { action: "run", name: wf("review-fix-loop"), args: { batch1: agentMd("code-reviewer") } },
527
- deps,
528
- undefined,
525
+ await expect(
526
+ actionRun(
527
+ { action: "run", name: wf("review-fix-loop"), args: { batch1: agentMd("code-reviewer") } },
528
+ deps,
529
+ undefined,
530
+ ),
531
+ ).rejects.toThrow(
532
+ /Invalid args for workflow 'review-fix-loop'[\s\S]*targetType[\s\S]*Read the workflow script file/,
529
533
  );
530
-
531
- expect(result.isError).toBe(true);
532
- const text = result.content?.[0]?.text ?? "";
533
- expect(text).toContain("Invalid args for workflow 'review-fix-loop'");
534
- expect(text).toContain("targetType");
535
- expect(text).toContain("Read the workflow script file");
536
534
  });
537
535
 
538
536
 
@@ -63,6 +63,9 @@ const EXPONENTIAL_BACKOFF_BASE = 2;
63
63
  /** errorLogs 最大保留条数(防止超长 session 中日志无界增长)。 */
64
64
  const MAX_ERROR_LOGS = 500;
65
65
 
66
+ /** malformed agent-call 日志中 opts JSON 的预览截断长度(字符)。 */
67
+ const MALFORMED_MSG_LOG_PREVIEW_CHARS = 200;
68
+
66
69
  // ── Worker 消息类型(与 infra/worker-script-builder.ts WorkerInMsg 对齐) ──
67
70
 
68
71
  interface AgentCallMsg {
@@ -312,7 +315,7 @@ function dispatchAgentCall(
312
315
  if (typeof msg.callId !== "number" || !Number.isFinite(msg.callId) ||
313
316
  typeof msg.opts !== "object" || msg.opts === null ||
314
317
  typeof msg.opts.prompt !== "string") {
315
- logger.error(`[workflow] malformed agent-call message: callId=${JSON.stringify(msg.callId)}, opts=${JSON.stringify(msg.opts)?.slice(0, 200)}`);
318
+ logger.error(`[workflow] malformed agent-call message: callId=${JSON.stringify(msg.callId)}, opts=${JSON.stringify(msg.opts)?.slice(0, MALFORMED_MSG_LOG_PREVIEW_CHARS)}`);
316
319
  return;
317
320
  }
318
321
 
@@ -41,14 +41,24 @@ const BACKOFF_EXPONENT_BASE = 2;
41
41
  const MAX_ATTEMPTS = 3;
42
42
 
43
43
  /**
44
- * Stale context 检测模式(P1-5)。
44
+ * Stale context 检测模式(P1-5;W4b 对齐 pi 0.84.x 真实文案)。
45
45
  *
46
46
  * pi session context 被 compact/cancel 时报告的模式。这种情况下重试无意义——
47
47
  * 同样的 call 会再次失败。直接 markDone failed 终止单次调用。
48
+ *
49
+ * W4b:原 "stale context"/"stalecontext" 与 pi 真实文案零匹配(真实文案为
50
+ * "This extension ctx is stale after session replacement or reload. ..."——
51
+ * runner.ts:544(dist runner.js:352),词序是 "ctx is stale" 而非 "stale context"),stale 分诊对
52
+ * 真实文案失效。现对齐:
53
+ * - "ctx is stale":真实文案核心子串(词序修正)
54
+ * - "stale after session replacement":scheduler 已验证 marker(runtime.ts
55
+ * STALE_CTX_MARKER,同文案锚定)
56
+ * - "context canceled"/"aborted":保留——abort 族错误同样不重试(signal.aborted
57
+ * 分支的先行分诊,防边界竞态漏网),删除会放宽重试语义。
48
58
  */
49
59
  export const STALE_CONTEXT_PATTERNS = [
50
- "stale context",
51
- "stalecontext",
60
+ "ctx is stale",
61
+ "stale after session replacement",
52
62
  "context canceled",
53
63
  "aborted",
54
64
  ] as const;