@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
|
@@ -18,11 +18,12 @@ import * as path from "node:path";
|
|
|
18
18
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
19
19
|
|
|
20
20
|
import { writeAliveMarker } from "../alive-store.ts";
|
|
21
|
-
import { createRecord } from "../execution-record.ts";
|
|
21
|
+
import { completeRecord, createRecord, tryTransition } from "../execution-record.ts";
|
|
22
22
|
import { writeFinalized } from "../finalized-marker.ts";
|
|
23
23
|
import type { ManifestRecord } from "../manifest-store.ts";
|
|
24
24
|
import { ManifestStore } from "../manifest-store.ts";
|
|
25
25
|
import { getSubagentRecordsDir, getSubagentSessionDir } from "../path-encoding.ts";
|
|
26
|
+
import { SUBAGENT_RECORD_CUSTOM_TYPE } from "../record-entry.ts";
|
|
26
27
|
import type { StatusFilter } from "../record-store.ts";
|
|
27
28
|
import { RecordStore } from "../record-store.ts";
|
|
28
29
|
import { writeCancelledTombstone } from "../tombstone-store.ts";
|
|
@@ -37,6 +38,8 @@ function makeRecord(over: Partial<ExecutionRecord> = {}): ExecutionRecord {
|
|
|
37
38
|
task: "t",
|
|
38
39
|
startedAt: 1000,
|
|
39
40
|
rootSessionId: "sess-current",
|
|
41
|
+
// 对齐生产 register 路径(subagent-service createRecord:one-shot 显式 false)
|
|
42
|
+
chatMode: false,
|
|
40
43
|
});
|
|
41
44
|
return { ...base, ...over };
|
|
42
45
|
}
|
|
@@ -47,7 +50,7 @@ function makeRecord(over: Partial<ExecutionRecord> = {}): ExecutionRecord {
|
|
|
47
50
|
*/
|
|
48
51
|
function writeSessionJsonl(
|
|
49
52
|
filePath: string,
|
|
50
|
-
identity: { id: string; agent: string; mode: "sync" | "background"; task: string; startedAt: number; rootSessionId?: string; parentRecordId?: string; depth?: number; lastTs?: number },
|
|
53
|
+
identity: { id: string; agent: string; mode: "sync" | "background"; task: string; startedAt: number; rootSessionId?: string; parentRecordId?: string; depth?: number; lastTs?: number; chatMode?: boolean },
|
|
51
54
|
assistantText = "result text",
|
|
52
55
|
): void {
|
|
53
56
|
const lastTs = identity.lastTs ?? identity.startedAt + 1000;
|
|
@@ -64,6 +67,7 @@ function writeSessionJsonl(
|
|
|
64
67
|
if (identity.rootSessionId !== undefined) identityData.rootSessionId = identity.rootSessionId;
|
|
65
68
|
if (identity.parentRecordId !== undefined) identityData.parentRecordId = identity.parentRecordId;
|
|
66
69
|
if (identity.depth !== undefined) identityData.depth = identity.depth;
|
|
70
|
+
if (identity.chatMode !== undefined) identityData.chatMode = identity.chatMode;
|
|
67
71
|
const identityEntry = JSON.stringify({
|
|
68
72
|
type: "custom",
|
|
69
73
|
id: "id-1",
|
|
@@ -772,4 +776,282 @@ describe("RecordStore", () => {
|
|
|
772
776
|
expect(found?.closedReason).toBe("cancelled");
|
|
773
777
|
});
|
|
774
778
|
});
|
|
779
|
+
|
|
780
|
+
// ============================================================
|
|
781
|
+
// 孤儿终态恢复(residual-fixes):分支 4 兜底 record 的判定与落盘
|
|
782
|
+
// ============================================================
|
|
783
|
+
describe("recoverOrphanRecords 孤儿终态恢复", () => {
|
|
784
|
+
/** 带真实磁盘 fixture + appendEntry 捕获的 store。 */
|
|
785
|
+
function makeRecoveryStore(): { store: RecordStore; appended: Array<{ customType: string; data: Record<string, unknown> }> } {
|
|
786
|
+
const appended: Array<{ customType: string; data: Record<string, unknown> }> = [];
|
|
787
|
+
const store = new RecordStore(tmpDir, undefined, {
|
|
788
|
+
appendEntry: (customType: string, data: unknown) => {
|
|
789
|
+
appended.push({ customType, data: data as Record<string, unknown> });
|
|
790
|
+
},
|
|
791
|
+
} as never);
|
|
792
|
+
return { store, appended };
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
it("末行完整 → closed 终态 entry + .finalized sidecar(防重锚)", () => {
|
|
796
|
+
const sessionFile = path.join(tmpDir, "orphan-done.jsonl");
|
|
797
|
+
writeSessionJsonl(sessionFile, {
|
|
798
|
+
id: "sa-orphan-1", agent: "worker", mode: "background", task: "orphan done",
|
|
799
|
+
startedAt: 1000, rootSessionId: "sess-orphan",
|
|
800
|
+
});
|
|
801
|
+
const { store, appended } = makeRecoveryStore();
|
|
802
|
+
store.recoverOrphanRecords("sess-orphan");
|
|
803
|
+
|
|
804
|
+
const entry = appended.find((c) => c.data.id === "sa-orphan-1");
|
|
805
|
+
expect(entry?.customType).toBe("subagent-record");
|
|
806
|
+
expect(entry?.data.status).toBe("closed");
|
|
807
|
+
expect(entry?.data.closedReason).toBe("gc");
|
|
808
|
+
expect(entry?.data.endedAt).toEqual(expect.any(Number));
|
|
809
|
+
expect(entry?.data.error).toBeUndefined();
|
|
810
|
+
expect(fs.existsSync(`${sessionFile}.finalized`)).toBe(true);
|
|
811
|
+
// 防重:sidecar 使下次重建走分支 2(closed),不再进判定
|
|
812
|
+
const again = [...appended];
|
|
813
|
+
store.recoverOrphanRecords("sess-orphan");
|
|
814
|
+
expect(appended.length).toBe(again.length);
|
|
815
|
+
const found = store.collectRecords(100, "all", "sess-orphan").find((r) => r.id === "sa-orphan-1");
|
|
816
|
+
expect(found?.status).toBe("closed");
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
it("末行截断 → closed + error(保守方向)", () => {
|
|
820
|
+
const sessionFile = path.join(tmpDir, "orphan-truncated.jsonl");
|
|
821
|
+
writeSessionJsonl(sessionFile, {
|
|
822
|
+
id: "sa-orphan-2", agent: "worker", mode: "background", task: "orphan truncated",
|
|
823
|
+
startedAt: 2000, rootSessionId: "sess-orphan",
|
|
824
|
+
});
|
|
825
|
+
// 制造截断:append 半行 JSON(无换行结尾)
|
|
826
|
+
fs.appendFileSync(sessionFile, '{"type":"message","id":"msg-2","pare', "utf-8");
|
|
827
|
+
const { store, appended } = makeRecoveryStore();
|
|
828
|
+
store.recoverOrphanRecords("sess-orphan");
|
|
829
|
+
|
|
830
|
+
const entry = appended.find((c) => c.data.id === "sa-orphan-2");
|
|
831
|
+
expect(entry?.data.status).toBe("closed");
|
|
832
|
+
expect(entry?.data.error).toContain("truncated");
|
|
833
|
+
expect(fs.existsSync(`${sessionFile}.finalized`)).toBe(true);
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
it("末行 >64KB 完整 JSON → 不误判截断,判 done(V1 探针实测回归)", () => {
|
|
837
|
+
const sessionFile = path.join(tmpDir, "orphan-longline.jsonl");
|
|
838
|
+
writeSessionJsonl(sessionFile, {
|
|
839
|
+
id: "sa-orphan-5", agent: "worker", mode: "background", task: "orphan long line",
|
|
840
|
+
startedAt: 5000, rootSessionId: "sess-orphan",
|
|
841
|
+
});
|
|
842
|
+
// 真实库实测形态:末行为超长完整 entry(subagent-identity 的 task 内嵌大 payload,
|
|
843
|
+
// 28/4822 个文件末行 65KB-776KB)——固定 64KB 尾窗曾把这类行从中间切开误判截断。
|
|
844
|
+
const bigEntry = JSON.stringify({
|
|
845
|
+
type: "custom", id: "id-big", parentId: null,
|
|
846
|
+
timestamp: new Date(5000).toISOString(), customType: "subagent-identity",
|
|
847
|
+
data: { id: "sa-orphan-5", agent: "worker", mode: "background", task: "x".repeat(70 * 1024), startedAt: 5000 },
|
|
848
|
+
});
|
|
849
|
+
fs.appendFileSync(sessionFile, bigEntry + "\n", "utf-8");
|
|
850
|
+
const { store, appended } = makeRecoveryStore();
|
|
851
|
+
store.recoverOrphanRecords("sess-orphan");
|
|
852
|
+
|
|
853
|
+
const entry = appended.find((c) => c.data.id === "sa-orphan-5");
|
|
854
|
+
expect(entry?.data.status).toBe("closed");
|
|
855
|
+
expect(entry?.data.error).toBeUndefined();
|
|
856
|
+
expect(fs.existsSync(`${sessionFile}.finalized`)).toBe(true);
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
it("chatMode 孤儿 → 不终态化,落 resumable entry(可续聊产品语义)", () => {
|
|
860
|
+
const sessionFile = path.join(tmpDir, "orphan-chat.jsonl");
|
|
861
|
+
writeSessionJsonl(sessionFile, {
|
|
862
|
+
id: "sa-orphan-3", agent: "worker", mode: "background", task: "orphan chat",
|
|
863
|
+
startedAt: 3000, rootSessionId: "sess-orphan", chatMode: true,
|
|
864
|
+
});
|
|
865
|
+
const { store, appended } = makeRecoveryStore();
|
|
866
|
+
store.recoverOrphanRecords("sess-orphan");
|
|
867
|
+
|
|
868
|
+
const entry = appended.find((c) => c.data.id === "sa-orphan-3");
|
|
869
|
+
expect(entry?.data.status).toBe("running");
|
|
870
|
+
expect(entry?.data.resumable).toBe(true);
|
|
871
|
+
expect(entry?.data.chatMode).toBe(true);
|
|
872
|
+
// chat 分流不写终态 sidecar
|
|
873
|
+
expect(fs.existsSync(`${sessionFile}.finalized`)).toBe(false);
|
|
874
|
+
// orphanJudged 缓存:同 store 重复调用不重复 append
|
|
875
|
+
const count = appended.length;
|
|
876
|
+
store.recoverOrphanRecords("sess-orphan");
|
|
877
|
+
expect(appended.length).toBe(count);
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
it("非分支 4 record(closed / 活进程)不进判定", () => {
|
|
881
|
+
const closedFile = path.join(tmpDir, "orphan-closed.jsonl");
|
|
882
|
+
writeSessionJsonl(closedFile, {
|
|
883
|
+
id: "sa-orphan-4", agent: "worker", mode: "background", task: "already closed",
|
|
884
|
+
startedAt: 4000, rootSessionId: "sess-orphan",
|
|
885
|
+
});
|
|
886
|
+
writeFinalized(closedFile);
|
|
887
|
+
const { store, appended } = makeRecoveryStore();
|
|
888
|
+
store.recoverOrphanRecords("sess-orphan");
|
|
889
|
+
expect(appended.find((c) => c.data.id === "sa-orphan-4")).toBeUndefined();
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
/** 写主 session fixture(含指定 subagent-record entry 序列)。 */
|
|
893
|
+
function writeMainSession(entries: Array<Record<string, unknown>>): string {
|
|
894
|
+
const mainFile = path.join(tmpDir, "main-session.jsonl");
|
|
895
|
+
const lines = entries.map((d) => JSON.stringify({ type: "custom", id: `e-${Math.random()}`, parentId: null, customType: "subagent-record", data: d }));
|
|
896
|
+
fs.writeFileSync(mainFile, lines.join("\n") + "\n", "utf-8");
|
|
897
|
+
return mainFile;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
it("entry-born 孤儿(无子文件,spawn 窗口期死亡)→ closed+gc+error(E2E 实测回归)", () => {
|
|
901
|
+
const mainFile = writeMainSession([
|
|
902
|
+
{ v: 1, id: "sa-entryonly-1", agent: "worker", task: "spawn interrupted", slug: "s", status: "running", mode: "background", startedAt: 6000, rootSessionId: "sess-orphan", depth: 0, turns: 0, totalTokens: 0, model: "m", eventLog: [], displayItems: [] },
|
|
903
|
+
]);
|
|
904
|
+
const { store, appended } = makeRecoveryStore();
|
|
905
|
+
store.recoverEntryOnlyOrphans(mainFile, "sess-orphan");
|
|
906
|
+
|
|
907
|
+
const entry = appended.find((c) => c.data.id === "sa-entryonly-1");
|
|
908
|
+
expect(entry?.data.status).toBe("closed");
|
|
909
|
+
expect(entry?.data.closedReason).toBe("gc");
|
|
910
|
+
expect(entry?.data.error).toContain("no child session file");
|
|
911
|
+
// 防重:orphanJudged 缓存拦截二次判定
|
|
912
|
+
const count = appended.length;
|
|
913
|
+
store.recoverEntryOnlyOrphans(mainFile, "sess-orphan");
|
|
914
|
+
expect(appended.length).toBe(count);
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
it("entry-born chatMode 孤儿 → 不终态化,落 resumable entry", () => {
|
|
918
|
+
const mainFile = writeMainSession([
|
|
919
|
+
{ v: 1, id: "sa-entryonly-2", agent: "worker", task: "chat spawn interrupted", slug: "s", status: "running", mode: "background", startedAt: 7000, rootSessionId: "sess-orphan", depth: 0, turns: 0, totalTokens: 0, model: "m", eventLog: [], displayItems: [], chatMode: true },
|
|
920
|
+
]);
|
|
921
|
+
const { store, appended } = makeRecoveryStore();
|
|
922
|
+
store.recoverEntryOnlyOrphans(mainFile, "sess-orphan");
|
|
923
|
+
|
|
924
|
+
const entry = appended.find((c) => c.data.id === "sa-entryonly-2");
|
|
925
|
+
expect(entry?.data.status).toBe("running");
|
|
926
|
+
expect(entry?.data.resumable).toBe(true);
|
|
927
|
+
expect(entry?.data.error).toBeUndefined();
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
it("有子文件锚 / 末条已终态 / 他 session 的 entry-born id 不进判定", () => {
|
|
931
|
+
const anchoredFile = path.join(tmpDir, "orphan-anchored.jsonl");
|
|
932
|
+
writeSessionJsonl(anchoredFile, {
|
|
933
|
+
id: "sa-anchored", agent: "worker", mode: "background", task: "has file",
|
|
934
|
+
startedAt: 8000, rootSessionId: "sess-orphan",
|
|
935
|
+
});
|
|
936
|
+
const mainFile = writeMainSession([
|
|
937
|
+
{ v: 1, id: "sa-anchored", agent: "worker", task: "has file", slug: "s", status: "running", mode: "background", startedAt: 8000, rootSessionId: "sess-orphan", depth: 0, turns: 0, totalTokens: 0, model: "m", eventLog: [], displayItems: [] },
|
|
938
|
+
{ v: 1, id: "sa-settled", agent: "worker", task: "settled", slug: "s", status: "closed", mode: "background", startedAt: 8100, rootSessionId: "sess-orphan", depth: 0, turns: 1, totalTokens: 0, model: "m", eventLog: [], displayItems: [] },
|
|
939
|
+
{ v: 1, id: "sa-foreign", agent: "worker", task: "other session", slug: "s", status: "running", mode: "background", startedAt: 8200, rootSessionId: "sess-other", depth: 0, turns: 0, totalTokens: 0, model: "m", eventLog: [], displayItems: [] },
|
|
940
|
+
]);
|
|
941
|
+
const { store, appended } = makeRecoveryStore();
|
|
942
|
+
store.recoverEntryOnlyOrphans(mainFile, "sess-orphan");
|
|
943
|
+
expect(appended.find((c) => c.data.id === "sa-anchored")).toBeUndefined();
|
|
944
|
+
expect(appended.find((c) => c.data.id === "sa-settled")).toBeUndefined();
|
|
945
|
+
expect(appended.find((c) => c.data.id === "sa-foreign")).toBeUndefined();
|
|
946
|
+
});
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
// ============================================================
|
|
950
|
+
// W16 [D4]:subagent-record 自描述 appendEntry 上报(状态迁移点)
|
|
951
|
+
// ============================================================
|
|
952
|
+
describe("W16 subagent-record 自描述 appendEntry 上报", () => {
|
|
953
|
+
/** appendEntry 捕获(RecordStorePi 最小实现)。 */
|
|
954
|
+
interface AppendedCall {
|
|
955
|
+
customType: string;
|
|
956
|
+
data: unknown;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/** 构造带 appendEntry 捕获的 store(pi 注入通道,对齐生产 setPi 后形态)。 */
|
|
960
|
+
function makeStoreWithPi(): { store: RecordStore; appended: AppendedCall[] } {
|
|
961
|
+
const appended: AppendedCall[] = [];
|
|
962
|
+
const store = new RecordStore(tmpDir, undefined, {
|
|
963
|
+
appendEntry: (customType: string, data: unknown) => {
|
|
964
|
+
appended.push({ customType, data });
|
|
965
|
+
},
|
|
966
|
+
});
|
|
967
|
+
return { store, appended };
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/** unknown → 对象的运行时 guard(taste/no-unsafe-cast:断言前先收窄)。 */
|
|
971
|
+
function asEntryData(d: unknown): Record<string, unknown> {
|
|
972
|
+
if (typeof d !== "object" || d === null) throw new Error("entry data is not an object");
|
|
973
|
+
return d as Record<string, unknown>;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
it("register:append subagent-record entry,data = v1 + SubagentRecord 完整快照 schema", () => {
|
|
977
|
+
const { store, appended } = makeStoreWithPi();
|
|
978
|
+
store.register(makeRecord());
|
|
979
|
+
|
|
980
|
+
expect(appended).toHaveLength(1);
|
|
981
|
+
// 写点字面量与 record-entry.ts 常量等值(钉住双源一致性)
|
|
982
|
+
expect(appended[0]?.customType).toBe(SUBAGENT_RECORD_CUSTOM_TYPE);
|
|
983
|
+
// [E2E 实测教训] 必须先 JSON 序列化再断言:appendEntry 捕获的是内存对象,
|
|
984
|
+
// undefined 值的键名仍在(旧 27-key 断言因此漏检 recordToSubagent 丢
|
|
985
|
+
// chatMode 的 bug);真实 JSONL 会丢 undefined 值键,此处对齐生产行为。
|
|
986
|
+
const data = asEntryData(JSON.parse(JSON.stringify(appended[0]?.data)));
|
|
987
|
+
// register 时点序列化存活字段(undefined 值字段按生产序列化丢弃):
|
|
988
|
+
// chatMode 必须显式在场(one-shot=false)——renderer isDone 判据依赖它。
|
|
989
|
+
expect(Object.keys(data).sort()).toEqual([
|
|
990
|
+
"agent", "chatMode", "depth", "displayItems", "eventLog",
|
|
991
|
+
"id", "mode", "model", "rootSessionId", "round", "startedAt",
|
|
992
|
+
"status", "task", "totalTokens", "turns", "v", "worktree",
|
|
993
|
+
]);
|
|
994
|
+
expect(data).toMatchObject({
|
|
995
|
+
v: 1,
|
|
996
|
+
id: "r1",
|
|
997
|
+
agent: "worker",
|
|
998
|
+
task: "t",
|
|
999
|
+
status: "running",
|
|
1000
|
+
mode: "sync",
|
|
1001
|
+
startedAt: 1000,
|
|
1002
|
+
rootSessionId: "sess-current",
|
|
1003
|
+
chatMode: false,
|
|
1004
|
+
turns: 0,
|
|
1005
|
+
totalTokens: 0,
|
|
1006
|
+
model: "m",
|
|
1007
|
+
eventLog: [],
|
|
1008
|
+
displayItems: [],
|
|
1009
|
+
});
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
it("archive:append 终态完整快照(result/endedAt/closedReason);one-shot 生命周期共 2 次 append", () => {
|
|
1013
|
+
const { store, appended } = makeStoreWithPi();
|
|
1014
|
+
const r = makeRecord();
|
|
1015
|
+
store.register(r);
|
|
1016
|
+
// 模拟正常终态路径:tryTransition CAS → completeRecord 冻结 → archive(D-017 时序)
|
|
1017
|
+
tryTransition(r, "closed", "gc");
|
|
1018
|
+
completeRecord(r, { text: "task done", turns: 1, durationMs: 500, success: true, sessionId: "r1", toolCalls: [] }, "closed", "gc");
|
|
1019
|
+
store.archive(r);
|
|
1020
|
+
|
|
1021
|
+
// 探针基线(单测级):one-shot 生命周期 = register + archive = 2 次 append
|
|
1022
|
+
expect(appended).toHaveLength(2);
|
|
1023
|
+
const data = asEntryData(appended[1]?.data);
|
|
1024
|
+
expect(data).toMatchObject({
|
|
1025
|
+
v: 1,
|
|
1026
|
+
id: "r1",
|
|
1027
|
+
status: "closed",
|
|
1028
|
+
closedReason: "gc",
|
|
1029
|
+
result: "task done",
|
|
1030
|
+
endedAt: expect.any(Number) as number,
|
|
1031
|
+
});
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
it("reportRecordTransition:类外恢复写点上报(chatMode 续轮 round 携带)", () => {
|
|
1035
|
+
const { store, appended } = makeStoreWithPi();
|
|
1036
|
+
const r = makeRecord({ chatMode: true, round: 1 });
|
|
1037
|
+
store.reportRecordTransition(r);
|
|
1038
|
+
|
|
1039
|
+
expect(appended).toHaveLength(1);
|
|
1040
|
+
expect(appended[0]?.customType).toBe(SUBAGENT_RECORD_CUSTOM_TYPE);
|
|
1041
|
+
expect(asEntryData(appended[0]?.data)).toMatchObject({
|
|
1042
|
+
v: 1,
|
|
1043
|
+
id: "r1",
|
|
1044
|
+
status: "running",
|
|
1045
|
+
round: 1,
|
|
1046
|
+
});
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
it("pi 未注入(session_start 前):三写点均安全降级不抛错", () => {
|
|
1050
|
+
const store = new RecordStore(tmpDir);
|
|
1051
|
+
const r = makeRecord();
|
|
1052
|
+
expect(() => store.register(r)).not.toThrow();
|
|
1053
|
+
expect(() => store.archive(r)).not.toThrow();
|
|
1054
|
+
expect(() => store.reportRecordTransition(r)).not.toThrow();
|
|
1055
|
+
});
|
|
1056
|
+
});
|
|
775
1057
|
});
|
|
@@ -194,18 +194,18 @@ describe("buildSpawnArgs", () => {
|
|
|
194
194
|
it("noContextFiles 镜像:主进程 argv 含 --no-context-files → 子进程 args 含该 flag", () => {
|
|
195
195
|
// 端到端链路断言:主 pi 进程以 --no-context-files 启动(用户 opt-out context files)
|
|
196
196
|
// → mirrorMainProcessFlags 解析 → buildSpawnArgs 产出同 flag 给每个 subagent。
|
|
197
|
-
// 缺此镜像时,--extension 镜像带入的
|
|
197
|
+
// 缺此镜像时,--extension 镜像带入的 @zhushanwen/pi-system-prompt 检查子进程
|
|
198
198
|
// 自己的 argv(无 flag)→ 全局 AGENTS.md 注入在 subagent system prompt 照常生效,
|
|
199
199
|
// 用户 opt-out 被绕过。
|
|
200
200
|
const mainArgv = [
|
|
201
201
|
"bun", "/pi", "--mode", "rpc", "--no-context-files",
|
|
202
|
-
"--extension", "/
|
|
202
|
+
"--extension", "/staged/@zhushanwen/pi-system-prompt/index.js",
|
|
203
203
|
];
|
|
204
204
|
const args = buildSpawnArgs({ ...baseParams, mirrorFlags: mirrorMainProcessFlags(mainArgv) });
|
|
205
205
|
expect(args).toContain("--no-context-files");
|
|
206
206
|
// extension 镜像照常(两者共存才构成完整契约)
|
|
207
207
|
const extIdx = args.indexOf("--extension");
|
|
208
|
-
expect(args[extIdx + 1]).toBe("/
|
|
208
|
+
expect(args[extIdx + 1]).toBe("/staged/@zhushanwen/pi-system-prompt/index.js");
|
|
209
209
|
});
|
|
210
210
|
|
|
211
211
|
it("noContextFiles 不镜像:主进程 argv 不含 flag → 子进程 args 不含(默认行为不变)", () => {
|
|
@@ -293,11 +293,11 @@ describe("mirrorMainProcessFlags", () => {
|
|
|
293
293
|
it("--no-context-files 长形式解析 → noContextFiles: true", () => {
|
|
294
294
|
const r = mirrorMainProcessFlags([
|
|
295
295
|
"bun", "/pi", "--mode", "rpc", "--no-context-files",
|
|
296
|
-
"--extension", "/
|
|
296
|
+
"--extension", "/staged/@zhushanwen/pi-system-prompt/index.js",
|
|
297
297
|
]);
|
|
298
298
|
expect(r.noContextFiles).toBe(true);
|
|
299
299
|
// 与 extension 镜像共存(实际 xyz-agent 启动形态:两 flag 同时出现)
|
|
300
|
-
expect(r.extensionPaths).toEqual(["/
|
|
300
|
+
expect(r.extensionPaths).toEqual(["/staged/@zhushanwen/pi-system-prompt/index.js"]);
|
|
301
301
|
expect(r.noExtensions).toBe(false);
|
|
302
302
|
expect(r.approve).toBe(false);
|
|
303
303
|
});
|
|
@@ -595,4 +595,35 @@ describe("[C-4] onRoundSettled 轮次增量 + [C-1] close 终态通知", () => {
|
|
|
595
595
|
expect(record.status).toBe("closed");
|
|
596
596
|
expect(pi.sendMessage).not.toHaveBeenCalled();
|
|
597
597
|
});
|
|
598
|
+
|
|
599
|
+
it("[W16 P-1 回归] close 终态 subagent-record entry 的 result == 轮终真实值(closeChatIdle 不抹空)", async () => {
|
|
600
|
+
// 完整生命周期:register(entry: running)→ 轮终(record.result = 轮终真实值)→
|
|
601
|
+
// idle close(archive 落 close 终态 entry——D4 重建源)。
|
|
602
|
+
// 缺陷形态(verifier W16 实测复现):closeChatIdle 合成 text:"" 经 completeRecord
|
|
603
|
+
// 覆盖 record.result → close entry 的 result=''(轮终 'ok' 被抹空),W18 消费后
|
|
604
|
+
// 重开 session result 回退空串。修复:doneResult.text 沿用 record.result。
|
|
605
|
+
const record = makeRecord({ id: "sa-close-entry-result", status: "running" });
|
|
606
|
+
record.sessionFile = path.join(agentDir, "close-entry-result.jsonl");
|
|
607
|
+
store.register(record);
|
|
608
|
+
armIdleTimer(record.id, () => {});
|
|
609
|
+
try {
|
|
610
|
+
updateFromEvent(record, { type: "text_delta", delta: "ok" });
|
|
611
|
+
updateFromEvent(record, { type: "turn_end" });
|
|
612
|
+
settleRound(record); // 轮终:onRoundSettled 写 record.result = "ok"
|
|
613
|
+
expect(record.result).toBe("ok"); // 前提锚点:轮终真实值在位
|
|
614
|
+
await service.closeSubagent(record, false); // idle close → completeRecord + archive
|
|
615
|
+
} finally {
|
|
616
|
+
disarmIdleTimer(record.id);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// appendEntry 序列中的 subagent-record 快照:register(running)→ close(closed)。
|
|
620
|
+
const snapshots = pi.appendEntry.mock.calls
|
|
621
|
+
.filter(([type]) => type === "subagent-record")
|
|
622
|
+
.map(([, data]) => data as { status: string; result?: string });
|
|
623
|
+
expect(snapshots.length).toBeGreaterThanOrEqual(2);
|
|
624
|
+
const closeEntry = snapshots[snapshots.length - 1]!;
|
|
625
|
+
expect(closeEntry.status).toBe("closed");
|
|
626
|
+
// 修复断言:close 终态 entry 保持轮终真实值(缺陷形态为 '')
|
|
627
|
+
expect(closeEntry.result).toBe("ok");
|
|
628
|
+
});
|
|
598
629
|
});
|
|
@@ -520,9 +520,16 @@ describe("WorktreeManager", () => {
|
|
|
520
520
|
});
|
|
521
521
|
|
|
522
522
|
it("pid 活的条目不删(绝不删活进程)", async () => {
|
|
523
|
-
|
|
524
|
-
|
|
523
|
+
// 物理面也 mock 存在(branch --list 返回该分支 + checkout 目录在)——
|
|
524
|
+
// D5b 对账的两个方向(清孤儿 / 清幻影条目)都必须不动活条目。
|
|
525
|
+
setupExecFile((args: readonly string[]) => {
|
|
526
|
+
if (args[0] === "branch" && args[1] === "--list") return { stdout: "pi-sub-alive\n" };
|
|
527
|
+
return { stdout: "" };
|
|
528
|
+
});
|
|
529
|
+
const checkout = path.join(os.tmpdir(), "pi-sub-orphan1");
|
|
530
|
+
injectEntry({ branch: "pi-sub-alive", pid: 22222, checkout });
|
|
525
531
|
mockIsProcessAlive.mockReturnValue(true); // pid 活
|
|
532
|
+
mockExistsSync.mockImplementation((p: unknown) => String(p) === checkout);
|
|
526
533
|
|
|
527
534
|
await mgr.scan();
|
|
528
535
|
|
|
@@ -139,7 +139,10 @@ afterEach(async () => {
|
|
|
139
139
|
|
|
140
140
|
// ── 用例 ──
|
|
141
141
|
|
|
142
|
-
|
|
142
|
+
// [HISTORICAL] 2026-08-20 PR #185:真实 spawn node 子进程 + 真实 git worktree + 注册表
|
|
143
|
+
// 文件锁集成用例显式超时——整包满并行 + 系统余载下真实子进程链路超 vitest 默认 5s
|
|
144
|
+
// testTimeout(对齐本包 worktree-registry D5a / process-shutdown-hook 口径)。
|
|
145
|
+
describe("worktree pid 注册链路(真实 spawn 集成)", { timeout: 30_000 }, () => {
|
|
143
146
|
it("正向:spawn 返回后注册表 pid 同步补全,活 worktree 超宽限不被 scan 误清", async () => {
|
|
144
147
|
// 0. 长驻脚本(子进程 90s 内不退出,模拟长跑子 agent)
|
|
145
148
|
scriptHolder.script = LONG_RUNNING_SCRIPT; // 1. 真实创建 worktree(pid=0 占位)
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// src/__tests__/worktree-reconcile.integration.test.ts
|
|
2
|
+
//
|
|
3
|
+
// D5b 对账双向 diff 集成测试:真实 git repo + 真实 tmpdir(不 mock fs/execFile)。
|
|
4
|
+
//
|
|
5
|
+
// 隔离手段:TMPDIR env 重定向到测试私有目录——WorktreeManager 的物理面扫描
|
|
6
|
+
// 根 = os.tmpdir()/pi-subagents(跟随 TMPDIR),避免扫到本机其他进程的真实残留。
|
|
7
|
+
//
|
|
8
|
+
// 覆盖四个收敛方向(判据见 worktree-manager.ts reconcileWithPhysical):
|
|
9
|
+
// 1. 物理有 + 注册无 + 无活 pid + 超 grace → 清分支与目录
|
|
10
|
+
// 2. 物理有 + 注册无 + 恰好 1 活 pid + 1 残留 → 补写回注册表(自愈)
|
|
11
|
+
// 3. 注册有(pid 活)+ 物理无 → 移除幻影条目
|
|
12
|
+
// 4. 多活 pid / 多残留(对应歧义)→ 跳过不动(宁延迟勿误删)
|
|
13
|
+
|
|
14
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
15
|
+
import * as fs from "node:fs";
|
|
16
|
+
import * as os from "node:os";
|
|
17
|
+
import * as path from "node:path";
|
|
18
|
+
|
|
19
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
20
|
+
|
|
21
|
+
import { encodeCwd } from "../path-encoding.ts";
|
|
22
|
+
import { WorktreeRegistry } from "../worktree-registry.ts";
|
|
23
|
+
import { WorktreeManager } from "../worktree-manager.ts";
|
|
24
|
+
|
|
25
|
+
/** 原始 TMPDIR(beforeEach 重定向、afterEach 还原)。 */
|
|
26
|
+
const ORIG_TMPDIR = os.tmpdir();
|
|
27
|
+
|
|
28
|
+
/** git 辅助:repo 内执行(输出 trim)。 */
|
|
29
|
+
function git(repo: string, ...args: string[]): string {
|
|
30
|
+
return execFileSync("git", ["-C", repo, ...args], { encoding: "utf-8" }).trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// [HISTORICAL] 2026-08-20 PR #185:真实 git 子进程 + 注册表文件锁集成用例显式超时——
|
|
34
|
+
// 每用例多次 git init/worktree add/branch -D 真实子进程调用,整包满并行 + 系统余载下
|
|
35
|
+
// 超 vitest 默认 5s testTimeout(对齐本包 worktree-registry D5a / process-shutdown-hook 口径)。
|
|
36
|
+
describe("WorktreeManager.scan 物理面对账(D5b)", { timeout: 30_000 }, () => {
|
|
37
|
+
let baseDir: string;
|
|
38
|
+
let agentDir: string;
|
|
39
|
+
let repo: string;
|
|
40
|
+
let enc: string;
|
|
41
|
+
let registry: WorktreeRegistry;
|
|
42
|
+
let mgr: WorktreeManager;
|
|
43
|
+
/** 真实系统 tmp 下的测试根(TMPDIR 重定向目标)。 */
|
|
44
|
+
let outerDir: string;
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
outerDir = fs.mkdtempSync(path.join(ORIG_TMPDIR, "wt-reconcile-it-"));
|
|
48
|
+
// 物理面扫描根重定向:os.tmpdir() 跟随 TMPDIR env(darwin/linux 均如此)
|
|
49
|
+
process.env.TMPDIR = outerDir;
|
|
50
|
+
baseDir = outerDir;
|
|
51
|
+
|
|
52
|
+
agentDir = path.join(baseDir, "agent");
|
|
53
|
+
repo = path.join(baseDir, "repo");
|
|
54
|
+
fs.mkdirSync(repo, { recursive: true });
|
|
55
|
+
git(repo, "init", "-q");
|
|
56
|
+
git(repo, "config", "user.email", "test@test.local");
|
|
57
|
+
git(repo, "config", "user.name", "test");
|
|
58
|
+
fs.writeFileSync(path.join(repo, "a.txt"), "init\n", "utf-8");
|
|
59
|
+
git(repo, "add", "-A");
|
|
60
|
+
git(repo, "commit", "-q", "-m", "init");
|
|
61
|
+
|
|
62
|
+
enc = encodeCwd(repo);
|
|
63
|
+
registry = new WorktreeRegistry(agentDir);
|
|
64
|
+
mgr = new WorktreeManager(agentDir);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
process.env.TMPDIR = ORIG_TMPDIR;
|
|
69
|
+
fs.rmSync(outerDir, { recursive: true, force: true });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/** 建一个物理 worktree(按 create() 的真实路径布局,绕过 WorktreeManager.create 的脏树校验)。 */
|
|
73
|
+
function makePhysicalWorktree(branch: string): string {
|
|
74
|
+
const checkout = path.join(baseDir, "pi-subagents", enc, branch);
|
|
75
|
+
git(repo, "worktree", "add", "-q", "-b", branch, checkout, "HEAD");
|
|
76
|
+
return checkout;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 在 <agentDir>/subagents/<enc>/sessions/ 下写一个 .alive marker。 */
|
|
80
|
+
function writeAliveMarker(sessionId: string, pid: number): void {
|
|
81
|
+
const sessions = path.join(agentDir, "subagents", enc, "sessions");
|
|
82
|
+
fs.mkdirSync(sessions, { recursive: true });
|
|
83
|
+
fs.writeFileSync(
|
|
84
|
+
path.join(sessions, `${sessionId}.jsonl.alive`),
|
|
85
|
+
JSON.stringify({ pid, id: sessionId, startedAt: Date.now() }),
|
|
86
|
+
"utf-8",
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 把目录 mtime 设到 grace 之前(对账判据的 createdAt 近似)。 */
|
|
91
|
+
function ageBeyondGrace(dir: string): void {
|
|
92
|
+
const past = new Date(Date.now() - 120_000);
|
|
93
|
+
fs.utimesSync(dir, past, past);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
it("物理有 + 注册无 + 无活 pid + 超 grace → 清分支与目录", async () => {
|
|
97
|
+
const checkout = makePhysicalWorktree("pi-sub-t1");
|
|
98
|
+
ageBeyondGrace(checkout);
|
|
99
|
+
|
|
100
|
+
await mgr.scan();
|
|
101
|
+
|
|
102
|
+
expect(fs.existsSync(checkout)).toBe(false);
|
|
103
|
+
expect(git(repo, "branch", "--list", "pi-sub-*")).toBe("");
|
|
104
|
+
expect(registry.load()).toEqual([]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("物理有 + 注册无 + 未超 grace(create 窗口)→ 跳过不删", async () => {
|
|
108
|
+
const checkout = makePhysicalWorktree("pi-sub-t2");
|
|
109
|
+
// mtime 保持「现在」——另一进程 worktree add 完成到 registry.add 落盘之间的窗口
|
|
110
|
+
|
|
111
|
+
await mgr.scan();
|
|
112
|
+
|
|
113
|
+
expect(fs.existsSync(checkout)).toBe(true);
|
|
114
|
+
expect(git(repo, "branch", "--list", "pi-sub-t2")).not.toBe("");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("物理有 + 注册无 + 恰好 1 活 pid + 1 残留 → 补写回注册表(自愈)", async () => {
|
|
118
|
+
const checkout = makePhysicalWorktree("pi-sub-t3");
|
|
119
|
+
// 活 pid 用本测试进程(isProcessAlive 真实调用必活)
|
|
120
|
+
writeAliveMarker("s-t3", process.pid);
|
|
121
|
+
|
|
122
|
+
await mgr.scan();
|
|
123
|
+
|
|
124
|
+
const entries = registry.load();
|
|
125
|
+
expect(entries).toHaveLength(1);
|
|
126
|
+
expect(entries[0].branch).toBe("pi-sub-t3");
|
|
127
|
+
expect(entries[0].pid).toBe(process.pid);
|
|
128
|
+
expect(entries[0].checkout).toBe(checkout);
|
|
129
|
+
// 物理资源未被删(自愈而非只删)
|
|
130
|
+
expect(fs.existsSync(checkout)).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("注册有(pid 活)+ 物理无 → 移除幻影条目", async () => {
|
|
134
|
+
// pid 活(本进程)使阶段一孤儿清理跳过,隔离方向一的独立语义
|
|
135
|
+
await registry.add({
|
|
136
|
+
repo,
|
|
137
|
+
branch: "pi-sub-ghost",
|
|
138
|
+
checkout: path.join(baseDir, "pi-subagents", enc, "pi-sub-ghost"),
|
|
139
|
+
pid: process.pid,
|
|
140
|
+
createdAt: Date.now(),
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
await mgr.scan();
|
|
144
|
+
|
|
145
|
+
expect(registry.load()).toEqual([]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("多残留 + 多活 pid(对应歧义)→ 跳过不动", async () => {
|
|
149
|
+
const c1 = makePhysicalWorktree("pi-sub-m1");
|
|
150
|
+
const c2 = makePhysicalWorktree("pi-sub-m2");
|
|
151
|
+
ageBeyondGrace(c1);
|
|
152
|
+
ageBeyondGrace(c2);
|
|
153
|
+
// 第二个「活 pid」用真实 sleep 子进程(与 process.pid 不同值)
|
|
154
|
+
const sleeper = spawn("sleep", ["60"]);
|
|
155
|
+
try {
|
|
156
|
+
expect(sleeper.pid).toBeTruthy();
|
|
157
|
+
writeAliveMarker("s-m1", process.pid);
|
|
158
|
+
writeAliveMarker("s-m2", sleeper.pid as number);
|
|
159
|
+
|
|
160
|
+
await mgr.scan();
|
|
161
|
+
|
|
162
|
+
// 歧义 → 本周期全部跳过(宁延迟勿误删),下周期活 pid 消失后收敛
|
|
163
|
+
expect(fs.existsSync(c1)).toBe(true);
|
|
164
|
+
expect(fs.existsSync(c2)).toBe(true);
|
|
165
|
+
expect(registry.load()).toEqual([]);
|
|
166
|
+
} finally {
|
|
167
|
+
sleeper.kill("SIGKILL");
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("幂等:连续两次 scan 结果一致(无重复动作)", async () => {
|
|
172
|
+
const checkout = makePhysicalWorktree("pi-sub-idem");
|
|
173
|
+
ageBeyondGrace(checkout);
|
|
174
|
+
|
|
175
|
+
await mgr.scan();
|
|
176
|
+
await mgr.scan();
|
|
177
|
+
|
|
178
|
+
expect(fs.existsSync(checkout)).toBe(false);
|
|
179
|
+
expect(git(repo, "branch", "--list", "pi-sub-*")).toBe("");
|
|
180
|
+
});
|
|
181
|
+
});
|