@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.
- package/package.json +7 -4
- package/scripts/rfl.mjs +308 -0
- package/src/execution/__tests__/finalize-record.test.ts +33 -7
- package/src/execution/__tests__/helpers/spawn-mock.ts +12 -3
- package/src/execution/__tests__/record-store.test.ts +284 -2
- package/src/execution/__tests__/spawn-args.test.ts +5 -5
- package/src/execution/__tests__/subagent-service-message-close.test.ts +31 -0
- package/src/execution/__tests__/worktree-manager.test.ts +9 -2
- package/src/execution/__tests__/worktree-pid-registration.integration.test.ts +4 -1
- package/src/execution/__tests__/worktree-reconcile.integration.test.ts +181 -0
- package/src/execution/__tests__/worktree-registry.test.ts +72 -34
- package/src/execution/agent-result-mapper.ts +4 -1
- package/src/execution/argv-mirror.ts +1 -1
- package/src/execution/channel-registry-access.ts +3 -1
- package/src/execution/finalize-record.ts +16 -7
- package/src/execution/idle-gc.ts +47 -0
- package/src/execution/lifecycle-manager.ts +9 -2
- package/src/execution/record-entry.ts +118 -0
- package/src/execution/record-store.ts +290 -1
- package/src/execution/session-pending.ts +5 -4
- package/src/execution/session-runner.ts +484 -289
- package/src/execution/subagent-service.ts +87 -37
- package/src/execution/temp-prompt.ts +8 -3
- package/src/execution/types.ts +20 -2
- package/src/execution/worktree-manager.ts +325 -14
- package/src/execution/worktree-registry.ts +90 -33
- package/src/index.ts +42 -2
- package/src/interface/__tests__/tool-workflow-script-generate.test.ts +103 -26
- package/src/interface/__tests__/tool-workflow-throw-paths.test.ts +179 -0
- package/src/interface/format.ts +10 -4
- package/src/interface/tool-render.ts +10 -3
- package/src/interface/tool-workflow-script.ts +29 -33
- package/src/interface/tool-workflow.ts +47 -65
- package/src/interface/views/detail-content.ts +1 -1
- package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +9 -1
- package/src/orchestration/__tests__/execute-agent-call.test.ts +49 -1
- package/src/orchestration/__tests__/jsonl-run-store-loadall-sources.test.ts +171 -0
- package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +181 -19
- package/src/orchestration/__tests__/lifecycle-runid-injection.test.ts +96 -0
- package/src/orchestration/__tests__/review-fix-loop-e2e.test.ts +1108 -19
- package/src/orchestration/__tests__/test-mocks.ts +9 -3
- package/src/orchestration/__tests__/worker-returnmeta-passthrough.test.ts +164 -0
- package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +12 -0
- package/src/orchestration/__tests__/workflows-e2e.test.ts +37 -39
- package/src/orchestration/error-recovery.ts +4 -1
- package/src/orchestration/execute-agent-call.ts +13 -3
- package/src/orchestration/jsonl-run-store.ts +139 -60
- package/src/orchestration/lifecycle.ts +10 -1
- package/src/orchestration/worker-script-builder.ts +9 -1
- package/src/shared/__tests__/resource-discovery.test.ts +24 -0
- package/src/shared/agent-ref.ts +6 -1
- package/src/shared/resource-discovery.ts +15 -1
- package/src/shared/schema-jsonify.ts +4 -1
- package/workflows/review-fix-loop-utils.cjs +542 -32
- 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)+
|
|
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
|
-
//
|
|
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 立即落盘 +
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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:
|
|
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
|
|
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
|
|
623
|
+
const p1 = store6.save(run);
|
|
624
|
+
const p2 = store6.save(run);
|
|
615
625
|
await vi.advanceTimersByTimeAsync(200);
|
|
616
|
-
await
|
|
626
|
+
await Promise.all([p1, p2]);
|
|
617
627
|
}
|
|
618
|
-
expect(mockPi.appendEntry).toHaveBeenCalledTimes(
|
|
628
|
+
expect(mockPi.appendEntry).toHaveBeenCalledTimes(4); // save 级不放大(2 save → 1 flush → 1 entry)
|
|
619
629
|
|
|
620
|
-
// done 终态
|
|
630
|
+
// done 终态 flush → 1 条 entry,快照携带终态(验收「entry 序列含终态」)
|
|
621
631
|
run.transition("done", "completed");
|
|
622
632
|
await store6.save(run);
|
|
623
|
-
expect(mockPi.appendEntry).toHaveBeenCalledTimes(
|
|
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
|
+
});
|