@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
@@ -31,7 +31,7 @@ import { Trace } from "../models/trace.ts";
31
31
  import type { ExecutionTraceNode } from "../models/types.ts";
32
32
  import type { RunSpec } from "../models/run-spec.ts";
33
33
  import { WorkflowRun } from "../models/workflow-run.ts";
34
- import { JsonlRunStore } from "../jsonl-run-store.ts";
34
+ import { JsonlRunStore, WORKFLOW_RECORD_CUSTOM_TYPE } from "../jsonl-run-store.ts";
35
35
  import { mkCtx, mkPi } from "./test-mocks.ts";
36
36
 
37
37
  function makeSpec(): RunSpec {
@@ -133,6 +133,15 @@ function readStateFile(
133
133
  return JSON.parse(raw.trim()) as { state: { status: string; trace: Array<{ stepIndex: number }> } };
134
134
  }
135
135
 
136
+ /** unknown → workflow-record entry data 的运行时收窄(taste/no-unsafe-cast:断言前先收窄,
137
+ * 对齐 record-store.test.ts 的 asEntryData 模式)。 */
138
+ function asRecordData(
139
+ d: unknown,
140
+ ): { v: number; snapshot: { runId: string; state: { status: string } } } {
141
+ if (typeof d !== "object" || d === null) throw new Error("entry data is not an object");
142
+ return d as { v: number; snapshot: { runId: string; state: { status: string } } };
143
+ }
144
+
136
145
  describe("W1: JsonlRunStore sessionFile 序列化 round-trip", () => {
137
146
  let tmpDir: string;
138
147
  let store: JsonlRunStore;
@@ -443,11 +452,11 @@ describe("W4: save 去抖(热路径合并 / 冷路径同步 flush)", () => {
443
452
  expect(readStateFile(tmpDir, "run-w2tc3b").state.status).toBe("done");
444
453
  });
445
454
 
446
- it("W2TC7: 终态冷路径同步 flush:立即落盘(绕过 timer)+ 终态指针", async () => {
455
+ it("W2TC7: 终态冷路径同步 flush:立即落盘(绕过 timer)+ 终态 workflow-record entry", async () => {
447
456
  const mockPi = mkPi();
448
457
  const store7 = new JsonlRunStore({ sessionDir: tmpDir, pi: mockPi });
449
458
  const run = makeRunningRun("run-w2tc7");
450
- await store7.save(run); // 首写 + 创建指针
459
+ await store7.save(run); // 首写 + entry
451
460
  expect(mockPi.appendEntry).toHaveBeenCalledTimes(1);
452
461
 
453
462
  const pHot = store7.save(run); // 热路径批 pending
@@ -458,14 +467,14 @@ describe("W4: save 去抖(热路径合并 / 冷路径同步 flush)", () => {
458
467
  expect(readStateFile(tmpDir, "run-w2tc7").state.status).toBe("done");
459
468
  // 合并的热路径批 Promise 一并 resolved
460
469
  await pHot;
461
- // 终态冷路径写终态指针(创建 + 终态 = 2 次)
470
+ // 终态冷路径合并批 1 次 flush → 1 条终态 entry(首写 + 终态 = 2 条)
462
471
  expect(mockPi.appendEntry).toHaveBeenCalledTimes(2);
463
472
  // advance 后无追加写
464
473
  await vi.advanceTimersByTimeAsync(1000);
465
474
  expect(mockPi.appendEntry).toHaveBeenCalledTimes(2);
466
475
  });
467
476
 
468
- it("W2TC8: 首写冷路径(跨 session resume):本 store 实例首 save 立即落盘 + 写创建指针", async () => {
477
+ it("W2TC8: 首写冷路径(跨 session resume):本 store 实例首 save 立即落盘 + entry", async () => {
469
478
  const mockPi = mkPi();
470
479
  const storeA = new JsonlRunStore({ sessionDir: tmpDir, pi: mockPi });
471
480
  const run = makeRunningRun("run-w2tc8");
@@ -473,14 +482,14 @@ describe("W4: save 去抖(热路径合并 / 冷路径同步 flush)", () => {
473
482
  // 实例 A 首写:status 是 running 也立即落盘(首写判定优先于 status)
474
483
  await storeA.save(run);
475
484
  expect(readStateFile(tmpDir, "run-w2tc8").state.status).toBe("running");
476
- // 创建指针(即使 status 是 running——新 store 实例对该 runId 的首写即建指针)
485
+ // 首写即写 entry(即使 status 是 running——新 store 实例对该 runId 的首写就落 entry)
477
486
  expect(mockPi.appendEntry).toHaveBeenCalledTimes(1);
478
487
 
479
488
  // 实例 B(另一 session 的 store)对同一 runId 再 save:又是一次实例首写
480
489
  const storeB = new JsonlRunStore({ sessionDir: tmpDir, pi: mockPi });
481
490
  run.state.trace.append(makeTraceNode(9));
482
491
  await storeB.save(run);
483
- // 跨实例指针仍有界:每实例每 run ≤2 条(此处各 1 条创建指针)
492
+ // 跨实例各 1 条 entry(实例级 writtenOnce 判定)
484
493
  expect(mockPi.appendEntry).toHaveBeenCalledTimes(2);
485
494
  expect(readStateFile(tmpDir, "run-w2tc8").state.trace).toHaveLength(2);
486
495
  });
@@ -548,13 +557,13 @@ describe("W5: 批 settle 与 IO 错误语义", () => {
548
557
  Object.assign(new Error("permission denied"), { code: "EACCES" }),
549
558
  );
550
559
  await expect(store4.save(run)).rejects.toThrow("permission denied");
551
- expect(mockPi.appendEntry).not.toHaveBeenCalled(); // 指针未写
560
+ expect(mockPi.appendEntry).not.toHaveBeenCalled(); // entry 未写(写在 writeFile 成功之后)
552
561
 
553
- // 恢复 IO 后 running 中间态 save:不经 timer 立即落盘(首写资格已回滚,冷路径重试指针)
562
+ // 恢复 IO 后 running 中间态 save:不经 timer 立即落盘(首写资格已回滚,冷路径重试 entry)
554
563
  const p = store4.save(run);
555
564
  await p; // 冷路径同步 flush 完成
556
565
  expect(readStateFile(tmpDir, "run-w2tc4b").state.status).toBe("running");
557
- expect(mockPi.appendEntry).toHaveBeenCalledTimes(1); // 指针经冷路径补写而非热路径永失
566
+ expect(mockPi.appendEntry).toHaveBeenCalledTimes(1); // entry 经冷路径补写
558
567
  });
559
568
 
560
569
  it("W2TC5: ENOENT 静默语义保留——热路径去抖批 mkdir ENOENT resolve 全部批 Promise", async () => {
@@ -585,7 +594,7 @@ describe("W5: 批 settle 与 IO 错误语义", () => {
585
594
  });
586
595
  });
587
596
 
588
- describe("W6: 指针收敛(仅创建/终态写)", () => {
597
+ describe("W6: workflow-record entry 计数(= flush 次数;save 级不放大)", () => {
589
598
  let tmpDir: string;
590
599
 
591
600
  beforeEach(() => {
@@ -599,28 +608,32 @@ describe("W6: 指针收敛(仅创建/终态写)", () => {
599
608
  fs.rmSync(tmpDir, { recursive: true, force: true });
600
609
  });
601
610
 
602
- it("W2TC6: 指针 appendEntry 计数:创建+终态=2 次,中间 N 次 save 0 次", async () => {
611
+ it("W2TC6(W17): entry 计数 = flush 次数:首写+终态各 1、中间去抖批合并(N save → 1 entry)、终态 entry 含 done", async () => {
603
612
  const mockPi = mkPi();
604
613
  const store6 = new JsonlRunStore({ sessionDir: tmpDir, pi: mockPi });
605
614
  const run = makeRunningRun("run-w2tc6");
606
615
 
607
- // 创建首写 → 1 次
616
+ // 创建首写 flush → 1 条 entry
608
617
  await store6.save(run);
609
618
  expect(mockPi.appendEntry).toHaveBeenCalledTimes(1);
610
619
 
611
- // 3 次 running 中间态 save(各自 advance 推进批)→ 仍 1 次
620
+ // 3 轮 running 中间态:每轮窗口内 2 次 save(热路径批合并)→ 各 1 次 flush → 各 1 条 entry
612
621
  for (let i = 1; i <= 3; i++) {
613
622
  run.state.trace.append(makeTraceNode(i));
614
- const p = store6.save(run);
623
+ const p1 = store6.save(run);
624
+ const p2 = store6.save(run);
615
625
  await vi.advanceTimersByTimeAsync(200);
616
- await p;
626
+ await Promise.all([p1, p2]);
617
627
  }
618
- expect(mockPi.appendEntry).toHaveBeenCalledTimes(1);
628
+ expect(mockPi.appendEntry).toHaveBeenCalledTimes(4); // save 级不放大(2 save → 1 flush → 1 entry)
619
629
 
620
- // done 终态 save → 2 次(终态指针)
630
+ // done 终态 flush → 1 条 entry,快照携带终态(验收「entry 序列含终态」)
621
631
  run.transition("done", "completed");
622
632
  await store6.save(run);
623
- expect(mockPi.appendEntry).toHaveBeenCalledTimes(2);
633
+ expect(mockPi.appendEntry).toHaveBeenCalledTimes(5);
634
+ const calls = mockPi.appendEntry.mock.calls;
635
+ expect(calls[4]![0]).toBe(WORKFLOW_RECORD_CUSTOM_TYPE);
636
+ expect(asRecordData(calls[4]![1]).snapshot.state.status).toBe("done");
624
637
  });
625
638
  });
626
639
 
@@ -866,3 +879,152 @@ describe("W8: 去抖窗口崩溃语义与 timer unref", () => {
866
879
  expect(loaded2[0]!.state.trace.toArray()).toHaveLength(2);
867
880
  });
868
881
  });
882
+
883
+ // ── W17: workflow-record 自描述 entry(D4 收敛:entry > state 文件 > 空)──────
884
+ //
885
+ // 持久化形态从「state 文件 + workflow-state-link 指针 entry」收敛为自描述完整记录:
886
+ // 每次成功 flush append 一条 workflow-record entry(pi 文件 = 持久化权威),state 文件
887
+ // 降级纯性能缓存;旧 link entry 兼容读取(优先级低,存量 run 不静默丢失——#9 踩坑)。
888
+
889
+ describe("W17: workflow-record 自描述 entry 重建(entry > state 文件 > 空)", () => {
890
+ let tmpDir: string;
891
+
892
+ beforeEach(() => {
893
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-store-w17-"));
894
+ });
895
+
896
+ afterEach(() => {
897
+ fs.rmSync(tmpDir, { recursive: true, force: true });
898
+ });
899
+
900
+ it("customType 常量字面量钉住:WORKFLOW_RECORD_CUSTOM_TYPE === 'workflow-record'", () => {
901
+ // 写点引用常量(单源);本断言钉住常量与消费方(W18 runtime extractor)约定的
902
+ // 字面量拼写,防重命名漂移后静默丢重建。
903
+ expect(WORKFLOW_RECORD_CUSTOM_TYPE).toBe("workflow-record");
904
+ });
905
+
906
+ it("save 落 workflow-record entry 序列:customType + v1 + 完整快照(running → done 终态)", async () => {
907
+ const entries: CustomEntry[] = [];
908
+ const store = new JsonlRunStore({ sessionDir: tmpDir, pi: mkPi(entries) });
909
+ const run = makeRunningRun("run-w17-shape");
910
+ await store.save(run); // 首写 flush → entry 1(running)
911
+ run.transition("done", "completed");
912
+ await store.save(run); // 终态 flush → entry 2(done)
913
+
914
+ const wfEntries = entries.filter((e) => e.customType === WORKFLOW_RECORD_CUSTOM_TYPE);
915
+ expect(wfEntries).toHaveLength(2);
916
+
917
+ const first = asRecordData(wfEntries[0]!.data);
918
+ expect(first.v).toBe(1);
919
+ expect(first.snapshot.runId).toBe("run-w17-shape");
920
+ expect(first.snapshot.state.status).toBe("running");
921
+
922
+ const last = asRecordData(wfEntries[1]!.data);
923
+ expect(last.v).toBe(1);
924
+ expect(last.snapshot.state.status).toBe("done"); // entry 序列含终态
925
+ });
926
+
927
+ it("新 entry 重建用例:loadAll 优先扫 workflow-record——state 文件删除后仍完整重建(纯性能缓存证明)", async () => {
928
+ const entries: CustomEntry[] = [];
929
+ const storeA = new JsonlRunStore({
930
+ sessionDir: tmpDir,
931
+ pi: mkPi(entries),
932
+ ctx: mkCtx(entries),
933
+ });
934
+ const run = makeRunWithDoneCall();
935
+ await storeA.save(run);
936
+
937
+ // 删除 state 文件(模拟缓存清理/丢失)——entry 是唯一残留源
938
+ fs.rmSync(path.join(tmpDir, "workflow-state", "run-test-001.jsonl"));
939
+
940
+ const storeB = new JsonlRunStore({ sessionDir: tmpDir, ctx: mkCtx(entries) });
941
+ const loaded = await storeB.loadAll();
942
+ expect(loaded).toHaveLength(1);
943
+ expect(loaded[0]!.runId).toBe("run-test-001");
944
+ expect(loaded[0]!.state.status).toBe("done");
945
+ // 自描述快照完整:AgentCall.sessionFile 等重水合字段在位(不依赖 state 文件)
946
+ expect(loaded[0]!.state.calls.get(0)!.sessionFile).toBe(
947
+ "/abs/.pi/agent/subagents/enc/sessions/2026-07-15T_session-abc.jsonl",
948
+ );
949
+ });
950
+
951
+ it("旧 link 兼容用例:存量 session(workflow-state-link + state 文件,无 workflow-record entry)→ loadAll 经 link 重建", async () => {
952
+ // 存量形态构造:旧版扩展(无 pi 注入路径)只落 state 文件,session JSONL 里有 link 指针
953
+ const storeA = new JsonlRunStore({ sessionDir: tmpDir });
954
+ await storeA.save(makeRunWithDoneCall());
955
+ const filePath = path.join(tmpDir, "workflow-state", "run-test-001.jsonl");
956
+ expect(fs.existsSync(filePath)).toBe(true);
957
+
958
+ const entries: CustomEntry[] = [
959
+ {
960
+ type: "custom",
961
+ customType: "workflow-state-link",
962
+ data: { runId: "run-test-001", path: filePath },
963
+ id: "seed-pointer",
964
+ parentId: null,
965
+ timestamp: new Date().toISOString(),
966
+ },
967
+ ];
968
+ const storeB = new JsonlRunStore({ sessionDir: tmpDir, ctx: mkCtx(entries) });
969
+ const loaded = await storeB.loadAll();
970
+ expect(loaded).toHaveLength(1); // 存量 run 不静默丢失(#9)
971
+ expect(loaded[0]!.state.calls.get(0)!.sessionFile).toBe(
972
+ "/abs/.pi/agent/subagents/enc/sessions/2026-07-15T_session-abc.jsonl",
973
+ );
974
+ });
975
+
976
+ it("读序优先级:同 runId 既有 workflow-record entry 又有旧 link(state 文件为旧 running 快照)→ entry 终态胜出", async () => {
977
+ const entries: CustomEntry[] = [];
978
+ const storeA = new JsonlRunStore({ sessionDir: tmpDir, pi: mkPi(entries) });
979
+ const run = makeRunningRun("run-w17-prio");
980
+ await storeA.save(run); // entry 1(running)+ state 文件(running)
981
+ run.transition("done", "completed");
982
+ await storeA.save(run); // entry 2(done)+ state 文件(done)
983
+
984
+ // 把 state 文件回写为旧 running 快照(从 entry 1 提取完整快照),并 seed 旧 link 指针
985
+ const filePath = path.join(tmpDir, "workflow-state", "run-w17-prio.jsonl");
986
+ fs.writeFileSync(
987
+ filePath,
988
+ JSON.stringify(asRecordData(entries[0]!.data).snapshot) + "\n",
989
+ "utf8",
990
+ );
991
+ const seedEntries: CustomEntry[] = [
992
+ ...entries,
993
+ {
994
+ type: "custom",
995
+ customType: "workflow-state-link",
996
+ data: { runId: "run-w17-prio", path: filePath },
997
+ id: "seed-pointer",
998
+ parentId: null,
999
+ timestamp: new Date().toISOString(),
1000
+ },
1001
+ ];
1002
+
1003
+ const storeB = new JsonlRunStore({ sessionDir: tmpDir, ctx: mkCtx(seedEntries) });
1004
+ const loaded = await storeB.loadAll();
1005
+ expect(loaded).toHaveLength(1);
1006
+ // entry 最后一条(done)胜出——不被 link 指向的旧 state 文件(running)回退
1007
+ expect(loaded[0]!.state.status).toBe("done");
1008
+ });
1009
+
1010
+ it("entry v guard:v:2 的 workflow-record entry(未来 schema)→ 跳过不崩(对齐 W16 消费约定)", async () => {
1011
+ // snapshot 本身是合法 wf-run-v2 快照,但 entry 层 v=2 ≠ 1 → 整条跳过(不猜测解析)
1012
+ const capture: CustomEntry[] = [];
1013
+ const storeA = new JsonlRunStore({ sessionDir: tmpDir, pi: mkPi(capture) });
1014
+ await storeA.save(makeRunningRun("run-w17-v2"));
1015
+ const snapshot = asRecordData(capture[0]!.data).snapshot;
1016
+
1017
+ const entries: CustomEntry[] = [
1018
+ {
1019
+ type: "custom",
1020
+ customType: WORKFLOW_RECORD_CUSTOM_TYPE,
1021
+ data: { v: 2, snapshot },
1022
+ id: "seed-future-entry",
1023
+ parentId: null,
1024
+ timestamp: new Date().toISOString(),
1025
+ },
1026
+ ];
1027
+ const storeB = new JsonlRunStore({ sessionDir: tmpDir, ctx: mkCtx(entries) });
1028
+ await expect(storeB.loadAll()).resolves.toEqual([]);
1029
+ });
1030
+ });
@@ -0,0 +1,96 @@
1
+ /**
2
+ * rfl 仪表 T2(tier-1 §7.1):引擎注入稳定 _runId。
3
+ *
4
+ * A3 runWorkflow 在 validateRunArgs 后向 spec.args 注入 _runId(值 = 返回的
5
+ * runId)——runAndWait 与 executeNestedWorkflow 两个 args 入口共用的单一
6
+ * choke point(lifecycle.ts runWorkflow)。
7
+ * A4 rebuildRuntime 复用 run.spec.args 同一对象,_runId 跨 worker rebuild 不
8
+ * 漂移(修复「rebuild 回退 run-<Date.now()> 导致 run 碎裂」)。
9
+ */
10
+ import { describe, expect, it, vi } from "vitest";
11
+
12
+ import { rebuildRuntime } from "../error-recovery.ts";
13
+ import { runWorkflow } from "../lifecycle.ts";
14
+ import type { RunSpec } from "../models/run-spec.ts";
15
+ import type { LifecycleDeps } from "../models/ports.ts";
16
+
17
+ function makeSpec(args: Record<string, unknown> = {}): RunSpec {
18
+ return {
19
+ scriptSource: "module.exports = { execute: async () => 'ok' };",
20
+ args,
21
+ scriptName: "test-wf",
22
+ scriptPath: "/fake/test.js",
23
+ };
24
+ }
25
+
26
+ /** LifecycleDeps mock:workerHost.start 可观察(记录每次调用收到的 args 引用)。 */
27
+ function makeRecordingDeps(): LifecycleDeps & {
28
+ startCalls: Array<{ spec: RunSpec; args: Record<string, unknown> }>;
29
+ } {
30
+ const startCalls: Array<{ spec: RunSpec; args: Record<string, unknown> }> = [];
31
+ const deps = {
32
+ store: { save: vi.fn(async () => {}), loadAll: vi.fn(async () => []) },
33
+ workerHost: {
34
+ start: vi.fn((spec: RunSpec, args: Record<string, unknown>) => {
35
+ startCalls.push({ spec, args });
36
+ return { postMessage: vi.fn(), terminate: vi.fn(async () => {}) };
37
+ }),
38
+ },
39
+ runner: { run: vi.fn(async () => ({})) },
40
+ runs: new Map(),
41
+ eventBus: { emit: vi.fn() },
42
+ onRunDone: vi.fn(),
43
+ log: vi.fn(),
44
+ } as unknown as LifecycleDeps;
45
+ return Object.assign(deps, { startCalls });
46
+ }
47
+
48
+ describe("A3 runWorkflow 注入 _runId(tier-1 §7.1)", () => {
49
+ it("A3 runWorkflow 后 spec.args._runId === 返回的 runId(字符串),且 workerHost.start 收到同一值", async () => {
50
+ const deps = makeRecordingDeps();
51
+ const spec = makeSpec({ targetType: "file", target: "/tmp/x" });
52
+ const runId = await runWorkflow(spec, deps);
53
+ expect(runId).toBeTruthy();
54
+ expect(spec.args._runId).toBe(runId);
55
+ expect(typeof spec.args._runId).toBe("string");
56
+ expect(deps.startCalls.length).toBe(1);
57
+ expect(deps.startCalls[0].args._runId).toBe(runId);
58
+ });
59
+
60
+ it("A3 引擎注入覆盖用户预传的 _runId(非公开参数,引擎值权威)", async () => {
61
+ const deps = makeRecordingDeps();
62
+ const spec = makeSpec({ _runId: "user-supplied" });
63
+ const runId = await runWorkflow(spec, deps);
64
+ expect(runId).not.toBe("user-supplied");
65
+ expect(spec.args._runId).toBe(runId);
66
+ });
67
+ });
68
+
69
+ describe("A4 rebuild 后 _runId 稳定(tier-1 §7.1)", () => {
70
+ it("A4 rebuildRuntime 第二次 workerHost.start 收到的 args._runId 非 undefined 且与首启一致(同一 args 对象)", async () => {
71
+ const deps = makeRecordingDeps();
72
+ const spec = makeSpec({ targetType: "file" });
73
+ const runId = await runWorkflow(spec, deps);
74
+ expect(deps.startCalls.length).toBe(1);
75
+
76
+ const run = deps.runs.get(runId);
77
+ expect(run).toBeTruthy();
78
+ expect(run.state.status).toBe("running");
79
+
80
+ // 触发 rebuild(对齐 error-recovery handleWorkerError 的恢复路径)
81
+ rebuildRuntime(run, deps, {
82
+ onMessage: vi.fn(),
83
+ onError: vi.fn(),
84
+ onExit: vi.fn(),
85
+ });
86
+
87
+ expect(deps.startCalls.length).toBe(2);
88
+ const first = deps.startCalls[0].args;
89
+ const second = deps.startCalls[1].args;
90
+ expect(second._runId).not.toBeUndefined();
91
+ expect(second._runId).toBe(first._runId);
92
+ expect(second._runId).toBe(runId);
93
+ // 同一对象引用(复用而非拷贝)——这是跨 rebuild 稳定的结构保证
94
+ expect(second).toBe(first);
95
+ });
96
+ });