@zhushanwen/pi-subagent-workflow 0.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/agents/context-builder.md +17 -0
- package/agents/general-purpose.md +16 -0
- package/agents/oracle.md +17 -0
- package/agents/planner.md +17 -0
- package/agents/researcher.md +17 -0
- package/agents/reviewer.md +17 -0
- package/agents/scout.md +17 -0
- package/agents/worker.md +16 -0
- package/examples/README.md +43 -0
- package/examples/chain.example.js +92 -0
- package/examples/map-reduce.example.js +99 -0
- package/examples/parallel.example.js +82 -0
- package/examples/scatter-gather.example.js +106 -0
- package/index.ts +1 -0
- package/package.json +66 -0
- package/skills/workflow-script-format/SKILL.md +328 -0
- package/src/execution/__tests__/agent-registry.test.ts +164 -0
- package/src/execution/__tests__/agent-result-mapper.test.ts +128 -0
- package/src/execution/__tests__/alive-store.test.ts +147 -0
- package/src/execution/__tests__/bg-notify-render.test.ts +256 -0
- package/src/execution/__tests__/concurrency-pool.test.ts +217 -0
- package/src/execution/__tests__/config.test.ts +110 -0
- package/src/execution/__tests__/crash-recovery.test.ts +311 -0
- package/src/execution/__tests__/execute-nesting.test.ts +359 -0
- package/src/execution/__tests__/execute-options-mapper.test.ts +138 -0
- package/src/execution/__tests__/execution-record.test.ts +959 -0
- package/src/execution/__tests__/finalized-marker.test.ts +82 -0
- package/src/execution/__tests__/format-schema-instruction.test.ts +135 -0
- package/src/execution/__tests__/format.test.ts +320 -0
- package/src/execution/__tests__/helpers/mock-extension-api.ts +30 -0
- package/src/execution/__tests__/list-component.test.ts +347 -0
- package/src/execution/__tests__/model-resolver.test.ts +356 -0
- package/src/execution/__tests__/output-collector.test.ts +61 -0
- package/src/execution/__tests__/path-encoding.test.ts +75 -0
- package/src/execution/__tests__/pi-invocation.test.ts +73 -0
- package/src/execution/__tests__/record-store.test.ts +545 -0
- package/src/execution/__tests__/run-spawn-edges.test.ts +439 -0
- package/src/execution/__tests__/run-spawn-integration.test.ts +897 -0
- package/src/execution/__tests__/sdk-contract.test.ts +272 -0
- package/src/execution/__tests__/session-context-resolver.test.ts +167 -0
- package/src/execution/__tests__/session-file-gc.test.ts +247 -0
- package/src/execution/__tests__/session-reconstructor.test.ts +359 -0
- package/src/execution/__tests__/session-runner-schema-env.test.ts +314 -0
- package/src/execution/__tests__/session-start-reaper.test.ts +227 -0
- package/src/execution/__tests__/spawn-args.test.ts +244 -0
- package/src/execution/__tests__/spawn-event-adapter.test.ts +167 -0
- package/src/execution/__tests__/subagent-service.test.ts +678 -0
- package/src/execution/__tests__/subprocess-agent-runner.test.ts +389 -0
- package/src/execution/__tests__/temp-prompt.test.ts +53 -0
- package/src/execution/__tests__/timeout-integration.test.ts +381 -0
- package/src/execution/__tests__/tombstone-store.test.ts +73 -0
- package/src/execution/__tests__/tool-action.test.ts +330 -0
- package/src/execution/__tests__/turn-limiter.test.ts +65 -0
- package/src/execution/__tests__/worktree-manager.test.ts +423 -0
- package/src/execution/__tests__/worktree-registry.test.ts +161 -0
- package/src/execution/agent-registry.ts +252 -0
- package/src/execution/agent-result-mapper.ts +84 -0
- package/src/execution/alive-store.ts +92 -0
- package/src/execution/best-effort.ts +30 -0
- package/src/execution/concurrency-pool.ts +84 -0
- package/src/execution/config.ts +73 -0
- package/src/execution/execute-options-mapper.ts +86 -0
- package/src/execution/execution-record.ts +778 -0
- package/src/execution/finalized-marker.ts +51 -0
- package/src/execution/model-config-service.ts +225 -0
- package/src/execution/model-resolver.ts +247 -0
- package/src/execution/notifier.ts +168 -0
- package/src/execution/output-collector.ts +88 -0
- package/src/execution/path-encoding.ts +34 -0
- package/src/execution/pi-invocation.ts +70 -0
- package/src/execution/record-store.ts +350 -0
- package/src/execution/session-context-resolver.ts +64 -0
- package/src/execution/session-file-gc.ts +98 -0
- package/src/execution/session-reconstructor.ts +450 -0
- package/src/execution/session-runner.ts +725 -0
- package/src/execution/spawn-event-adapter.ts +150 -0
- package/src/execution/subagent-service.ts +973 -0
- package/src/execution/subprocess-agent-runner.ts +108 -0
- package/src/execution/temp-prompt.ts +57 -0
- package/src/execution/tombstone-store.ts +72 -0
- package/src/execution/turn-limiter.ts +88 -0
- package/src/execution/types.ts +634 -0
- package/src/execution/worktree-manager.ts +285 -0
- package/src/execution/worktree-registry.ts +144 -0
- package/src/index.ts +454 -0
- package/src/interface/bg-notify-render.ts +286 -0
- package/src/interface/commands.ts +157 -0
- package/src/interface/format.ts +501 -0
- package/src/interface/gui-adapter.ts +136 -0
- package/src/interface/helpers.ts +110 -0
- package/src/interface/list-component.ts +643 -0
- package/src/interface/list-shared.ts +84 -0
- package/src/interface/list-view.ts +373 -0
- package/src/interface/reentry-guard.ts +30 -0
- package/src/interface/subagent-actions.ts +294 -0
- package/src/interface/subagent-tool.ts +294 -0
- package/src/interface/subagents.ts +30 -0
- package/src/interface/tool-render.ts +333 -0
- package/src/interface/tool-workflow-script.ts +351 -0
- package/src/interface/tool-workflow.ts +485 -0
- package/src/interface/views/WorkflowsView.ts +944 -0
- package/src/interface/views/detail-content.ts +298 -0
- package/src/interface/views/format.ts +320 -0
- package/src/orchestration/__tests__/concurrency-gate.test.ts +125 -0
- package/src/orchestration/__tests__/config-loader.test.ts +381 -0
- package/src/orchestration/__tests__/error-recovery-handlers.test.ts +332 -0
- package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +166 -0
- package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +248 -0
- package/src/orchestration/__tests__/lifecycle.test.ts +385 -0
- package/src/orchestration/__tests__/script-lint.test.ts +347 -0
- package/src/orchestration/__tests__/worker-script-builder.test.ts +42 -0
- package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +319 -0
- package/src/orchestration/agent-opts-resolver.ts +128 -0
- package/src/orchestration/concurrency-gate.ts +69 -0
- package/src/orchestration/config-loader.ts +313 -0
- package/src/orchestration/error-recovery.ts +578 -0
- package/src/orchestration/execute-agent-call.ts +174 -0
- package/src/orchestration/jsonl-run-store.ts +292 -0
- package/src/orchestration/launcher.ts +368 -0
- package/src/orchestration/lifecycle.ts +373 -0
- package/src/orchestration/models/__tests__/budget.test.ts +367 -0
- package/src/orchestration/models/agent-call.ts +76 -0
- package/src/orchestration/models/budget.ts +148 -0
- package/src/orchestration/models/ports.ts +165 -0
- package/src/orchestration/models/run-runtime.ts +91 -0
- package/src/orchestration/models/run-spec.ts +54 -0
- package/src/orchestration/models/run-state.ts +44 -0
- package/src/orchestration/models/trace.ts +102 -0
- package/src/orchestration/models/types.ts +242 -0
- package/src/orchestration/models/workflow-run.ts +275 -0
- package/src/orchestration/models/workflow-script-registry.ts +32 -0
- package/src/orchestration/models/workflow-script.ts +90 -0
- package/src/orchestration/node-ops.ts +192 -0
- package/src/orchestration/script-lint.ts +387 -0
- package/src/orchestration/skill-discovery.ts +60 -0
- package/src/orchestration/worker-handle.ts +115 -0
- package/src/orchestration/worker-host.ts +93 -0
- package/src/orchestration/worker-script-builder.ts +281 -0
- package/src/orchestration/workflow-files.ts +85 -0
- package/src/orchestration/workflow-script-registry-impl.ts +128 -0
- package/src/shared/__tests__/resource-discovery.test.ts +226 -0
- package/src/shared/agent-event.ts +13 -0
- package/src/shared/resource-discovery.ts +535 -0
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
// src/__tests__/worktree-manager.test.ts
|
|
2
|
+
//
|
|
3
|
+
// WorktreeManager 单元测试。
|
|
4
|
+
// mock execFileSync(git 命令)+ WorktreeRegistry(注册表)+ alive-store(进程探活)。
|
|
5
|
+
//
|
|
6
|
+
// [全局注册表重构] scan 不再读 sidecar / 不再依赖 cwd 是否 git repo。
|
|
7
|
+
// 判据从「终态 marker 状态机」改为「pid 死活」。
|
|
8
|
+
// 测试重点覆盖原方案缺失的崩溃残留场景(P0)。
|
|
9
|
+
|
|
10
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
11
|
+
|
|
12
|
+
import { DirtyWorktreeError } from "../types.ts";
|
|
13
|
+
|
|
14
|
+
// ── mock modules ──
|
|
15
|
+
|
|
16
|
+
vi.mock("node:child_process", () => ({
|
|
17
|
+
execFileSync: vi.fn(),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
vi.mock("node:fs", () => ({
|
|
21
|
+
default: {
|
|
22
|
+
existsSync: vi.fn(),
|
|
23
|
+
rmSync: vi.fn(),
|
|
24
|
+
symlinkSync: vi.fn(),
|
|
25
|
+
writeFileSync: vi.fn(),
|
|
26
|
+
},
|
|
27
|
+
existsSync: vi.fn(),
|
|
28
|
+
rmSync: vi.fn(),
|
|
29
|
+
symlinkSync: vi.fn(),
|
|
30
|
+
writeFileSync: vi.fn(),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
vi.mock("../alive-store.ts", () => ({
|
|
34
|
+
isProcessAlive: vi.fn(),
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
// WorktreeRegistry mock:内存数组模拟,add/updatePid/remove/load 全部可追踪
|
|
38
|
+
const { mockLoad, mockAdd, mockUpdatePid, mockRemove, registryEntries } = vi.hoisted(() => {
|
|
39
|
+
type Entry = { repo: string; branch: string; checkout: string; pid: number; createdAt: number };
|
|
40
|
+
const entries: Entry[] = [];
|
|
41
|
+
return {
|
|
42
|
+
registryEntries: entries,
|
|
43
|
+
mockLoad: vi.fn((): Entry[] => entries.slice()),
|
|
44
|
+
mockAdd: vi.fn((e: Entry): void => {
|
|
45
|
+
const idx = entries.findIndex((x) => x.branch === e.branch);
|
|
46
|
+
if (idx >= 0) entries[idx] = e;
|
|
47
|
+
else entries.push(e);
|
|
48
|
+
}),
|
|
49
|
+
mockUpdatePid: vi.fn((branch: string, pid: number): void => {
|
|
50
|
+
const e = entries.find((x) => x.branch === branch);
|
|
51
|
+
if (e) e.pid = pid;
|
|
52
|
+
}),
|
|
53
|
+
mockRemove: vi.fn((branch: string): void => {
|
|
54
|
+
const idx = entries.findIndex((x) => x.branch === branch);
|
|
55
|
+
if (idx >= 0) entries.splice(idx, 1);
|
|
56
|
+
}),
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
vi.mock("../worktree-registry.ts", () => ({
|
|
61
|
+
WorktreeRegistry: class {
|
|
62
|
+
add = mockAdd;
|
|
63
|
+
updatePid = mockUpdatePid;
|
|
64
|
+
remove = mockRemove;
|
|
65
|
+
load = mockLoad;
|
|
66
|
+
},
|
|
67
|
+
SPAWN_GRACE_MS: 60_000,
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
import { execFileSync } from "node:child_process";
|
|
71
|
+
import * as fs from "node:fs";
|
|
72
|
+
import * as os from "node:os";
|
|
73
|
+
import * as path from "node:path";
|
|
74
|
+
|
|
75
|
+
import { encodeCwd } from "../path-encoding.ts";
|
|
76
|
+
import { isProcessAlive } from "../alive-store.ts";
|
|
77
|
+
import { WorktreeManager } from "../worktree-manager.ts";
|
|
78
|
+
|
|
79
|
+
const mockExec = vi.mocked(execFileSync);
|
|
80
|
+
const mockExistsSync = vi.mocked(fs.existsSync);
|
|
81
|
+
const mockIsProcessAlive = vi.mocked(isProcessAlive);
|
|
82
|
+
|
|
83
|
+
const MAIN_CWD = "/home/user/project";
|
|
84
|
+
const AGENT_DIR = "/home/user/.pi/agent";
|
|
85
|
+
const RECORD_ID = "bg-42-abc";
|
|
86
|
+
const BASE_COMMIT = "abc123def456";
|
|
87
|
+
|
|
88
|
+
/** create 路径期望(tmpdir/pi-subagents/<enc(mainCwd)> 下) */
|
|
89
|
+
function expectedCreatePath(recordId: string): string {
|
|
90
|
+
return path.join(os.tmpdir(), "pi-subagents", encodeCwd(MAIN_CWD), `pi-sub-${recordId}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 构造完整 handle(含 mainCwd,供 cleanup/collectPatch 测试用) */
|
|
94
|
+
function makeHandle(checkoutPath: string = expectedCreatePath(RECORD_ID)) {
|
|
95
|
+
return Object.freeze({
|
|
96
|
+
path: checkoutPath,
|
|
97
|
+
branch: `pi-sub-${RECORD_ID}`,
|
|
98
|
+
baseCommit: BASE_COMMIT,
|
|
99
|
+
mainCwd: MAIN_CWD,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function setupCleanTree(): void {
|
|
104
|
+
mockExec.mockImplementation((_cmd: string, args?: readonly string[]) => {
|
|
105
|
+
if (args?.[0] === "status") return "";
|
|
106
|
+
if (args?.[0] === "rev-parse" && args?.[1] === "HEAD") return BASE_COMMIT;
|
|
107
|
+
if (args?.[0] === "worktree") return "";
|
|
108
|
+
if (args?.[0] === "branch") return "";
|
|
109
|
+
return "";
|
|
110
|
+
});
|
|
111
|
+
// worktreePath 不存在(无需前置清理);node_modules 存在
|
|
112
|
+
mockExistsSync.mockImplementation((p: unknown) => {
|
|
113
|
+
const s = String(p);
|
|
114
|
+
if (s.includes("pi-sub-")) return false; // checkout 目录不存在
|
|
115
|
+
if (s.includes("node_modules")) return true;
|
|
116
|
+
return false;
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** 向注册表注入一条活条目(模拟 create 后的状态)。 */
|
|
121
|
+
function injectEntry(overrides: Partial<{ branch: string; pid: number; checkout: string; repo: string; createdAt: number }> = {}): void {
|
|
122
|
+
registryEntries.push({
|
|
123
|
+
repo: overrides.repo ?? MAIN_CWD,
|
|
124
|
+
branch: overrides.branch ?? "pi-sub-orphan1",
|
|
125
|
+
checkout: overrides.checkout ?? path.join(os.tmpdir(), "pi-sub-orphan1"),
|
|
126
|
+
pid: overrides.pid ?? 0,
|
|
127
|
+
createdAt: overrides.createdAt ?? Date.now(),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
describe("WorktreeManager", () => {
|
|
132
|
+
let mgr: WorktreeManager;
|
|
133
|
+
|
|
134
|
+
beforeEach(() => {
|
|
135
|
+
vi.clearAllMocks();
|
|
136
|
+
registryEntries.length = 0;
|
|
137
|
+
mgr = new WorktreeManager(AGENT_DIR);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("create", () => {
|
|
141
|
+
it("正常流程返回冻结 handle(path 在 tmpdir,含 mainCwd)", () => {
|
|
142
|
+
setupCleanTree();
|
|
143
|
+
|
|
144
|
+
const handle = mgr.create(MAIN_CWD, RECORD_ID);
|
|
145
|
+
|
|
146
|
+
expect(handle.path).toBe(expectedCreatePath(RECORD_ID));
|
|
147
|
+
expect(handle.branch).toBe(`pi-sub-${RECORD_ID}`);
|
|
148
|
+
expect(handle.baseCommit).toBe(BASE_COMMIT);
|
|
149
|
+
expect(handle.mainCwd).toBe(MAIN_CWD);
|
|
150
|
+
expect(Object.isFrozen(handle)).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("成功后写入注册表(pid=0 占位)", () => {
|
|
154
|
+
setupCleanTree();
|
|
155
|
+
|
|
156
|
+
mgr.create(MAIN_CWD, RECORD_ID);
|
|
157
|
+
|
|
158
|
+
expect(mockAdd).toHaveBeenCalledTimes(1);
|
|
159
|
+
const entry = mockAdd.mock.calls[0][0] as { repo: string; branch: string; pid: number };
|
|
160
|
+
expect(entry.repo).toBe(MAIN_CWD);
|
|
161
|
+
expect(entry.branch).toBe(`pi-sub-${RECORD_ID}`);
|
|
162
|
+
expect(entry.pid).toBe(0);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("脏树抛 DirtyWorktreeError 且不写注册表", () => {
|
|
166
|
+
mockExec.mockImplementation((_cmd: string, args?: readonly string[]) => {
|
|
167
|
+
if (args?.[0] === "status") return "M src/index.ts\n";
|
|
168
|
+
return "";
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
expect(() => mgr.create(MAIN_CWD, RECORD_ID)).toThrow(DirtyWorktreeError);
|
|
172
|
+
expect(mockAdd).not.toHaveBeenCalled();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("recordId 含特殊字符抛 DirtyWorktreeError", () => {
|
|
176
|
+
expect(() => mgr.create(MAIN_CWD, "../evil-path")).toThrow(DirtyWorktreeError);
|
|
177
|
+
expect(() => mgr.create(MAIN_CWD, "hello world")).toThrow(DirtyWorktreeError);
|
|
178
|
+
expect(() => mgr.create(MAIN_CWD, "")).toThrow(DirtyWorktreeError);
|
|
179
|
+
expect(() => mgr.create(MAIN_CWD, "a;b")).toThrow(DirtyWorktreeError);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("recordId 单字符 / 连续短横线合法", () => {
|
|
183
|
+
setupCleanTree();
|
|
184
|
+
expect(mgr.create(MAIN_CWD, "a").branch).toBe("pi-sub-a");
|
|
185
|
+
expect(mgr.create(MAIN_CWD, "--test--").branch).toBe("pi-sub---test--");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("残留 checkout 目录存在时前置清理(fs.rmSync)", () => {
|
|
189
|
+
setupCleanTree();
|
|
190
|
+
// worktreePath 已存在 → 触发前置 rmSync
|
|
191
|
+
mockExistsSync.mockImplementation((p: unknown) => {
|
|
192
|
+
const s = String(p);
|
|
193
|
+
if (s.includes("pi-sub-bg-42-abc")) return true; // checkout 残留
|
|
194
|
+
if (s.includes("node_modules")) return true;
|
|
195
|
+
return false;
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
mgr.create(MAIN_CWD, RECORD_ID);
|
|
199
|
+
|
|
200
|
+
expect(fs.rmSync).toHaveBeenCalledWith(
|
|
201
|
+
expectedCreatePath(RECORD_ID),
|
|
202
|
+
{ recursive: true, force: true },
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("symlink 失败时回滚 worktree + 分支 + 注册表", () => {
|
|
207
|
+
setupCleanTree();
|
|
208
|
+
// symlink 抛错触发 MF#3 回滚
|
|
209
|
+
vi.mocked(fs.symlinkSync).mockImplementation(() => {
|
|
210
|
+
throw new Error("symlink permission denied");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
expect(() => mgr.create(MAIN_CWD, RECORD_ID)).toThrow("symlink permission denied");
|
|
214
|
+
|
|
215
|
+
// 回滚:worktree remove + branch delete + registry remove
|
|
216
|
+
expect(mockExec).toHaveBeenCalledWith(
|
|
217
|
+
"git",
|
|
218
|
+
expect.arrayContaining(["worktree", "remove", "--force"]),
|
|
219
|
+
expect.anything(),
|
|
220
|
+
);
|
|
221
|
+
expect(mockExec).toHaveBeenCalledWith(
|
|
222
|
+
"git",
|
|
223
|
+
expect.arrayContaining(["branch", "-D"]),
|
|
224
|
+
expect.anything(),
|
|
225
|
+
);
|
|
226
|
+
expect(mockRemove).toHaveBeenCalledWith(`pi-sub-${RECORD_ID}`);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe("registerPid", () => {
|
|
231
|
+
it("委托 registry.updatePid", () => {
|
|
232
|
+
mgr.registerPid("pi-sub-bg-1", 12345);
|
|
233
|
+
expect(mockUpdatePid).toHaveBeenCalledWith("pi-sub-bg-1", 12345);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe("cleanup", () => {
|
|
238
|
+
it("worktree remove + branch -D + 注册表移除(cwd 用 handle.mainCwd)", () => {
|
|
239
|
+
mockExec.mockReturnValue("");
|
|
240
|
+
const handle = makeHandle();
|
|
241
|
+
|
|
242
|
+
mgr.cleanup(handle);
|
|
243
|
+
|
|
244
|
+
expect(mockExec).toHaveBeenCalledWith(
|
|
245
|
+
"git",
|
|
246
|
+
["worktree", "remove", "--force", handle.path],
|
|
247
|
+
expect.objectContaining({ cwd: MAIN_CWD }),
|
|
248
|
+
);
|
|
249
|
+
expect(mockExec).toHaveBeenCalledWith(
|
|
250
|
+
"git",
|
|
251
|
+
["branch", "-D", handle.branch],
|
|
252
|
+
expect.objectContaining({ cwd: MAIN_CWD }),
|
|
253
|
+
);
|
|
254
|
+
expect(mockRemove).toHaveBeenCalledWith(handle.branch);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("worktree remove 失败时 branch -D + 注册表移除仍执行(best-effort 分离)", () => {
|
|
258
|
+
// 第一条 git(worktree remove)抛错,第二条(branch -D)应仍执行
|
|
259
|
+
let callCount = 0;
|
|
260
|
+
mockExec.mockImplementation(() => {
|
|
261
|
+
callCount++;
|
|
262
|
+
if (callCount === 1) throw new Error("worktree locked");
|
|
263
|
+
return "";
|
|
264
|
+
});
|
|
265
|
+
const handle = makeHandle();
|
|
266
|
+
|
|
267
|
+
// 不应抛错
|
|
268
|
+
expect(() => mgr.cleanup(handle)).not.toThrow();
|
|
269
|
+
|
|
270
|
+
// branch -D 仍被调用
|
|
271
|
+
expect(mockExec).toHaveBeenCalledWith(
|
|
272
|
+
"git",
|
|
273
|
+
["branch", "-D", handle.branch],
|
|
274
|
+
expect.anything(),
|
|
275
|
+
);
|
|
276
|
+
// 注册表仍被移除
|
|
277
|
+
expect(mockRemove).toHaveBeenCalledWith(handle.branch);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe("collectPatch", () => {
|
|
282
|
+
it("有改动返回 patch 文件", () => {
|
|
283
|
+
mockExec.mockReturnValue("diff --git a/src\n+// new");
|
|
284
|
+
|
|
285
|
+
const handle = makeHandle();
|
|
286
|
+
const patchFile = path.join(os.tmpdir(), `outside-${RECORD_ID}.patch`);
|
|
287
|
+
|
|
288
|
+
const result = mgr.collectPatch(handle, patchFile);
|
|
289
|
+
|
|
290
|
+
expect(result.failed).toBe(false);
|
|
291
|
+
expect(result.written).toBe(true);
|
|
292
|
+
expect(mockExec).toHaveBeenCalledWith("git", ["add", "-A"], expect.anything());
|
|
293
|
+
expect(mockExec).toHaveBeenCalledWith(
|
|
294
|
+
"git",
|
|
295
|
+
["diff", "--cached", BASE_COMMIT],
|
|
296
|
+
expect.anything(),
|
|
297
|
+
);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("无改动返回 failed=false, written=false(不写文件)", () => {
|
|
301
|
+
mockExec.mockReturnValue("");
|
|
302
|
+
const result = mgr.collectPatch(makeHandle(), "/tmp/x.patch");
|
|
303
|
+
expect(result.failed).toBe(false);
|
|
304
|
+
expect(result.written).toBe(false);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// ============================================================
|
|
309
|
+
// scan:全局注册表 + pid 死活判据(核心重构)
|
|
310
|
+
// ============================================================
|
|
311
|
+
describe("scan", () => {
|
|
312
|
+
it("pid 已死的条目被清理(正常退出未 cleanup / 崩溃残留)", () => {
|
|
313
|
+
injectEntry({ branch: "pi-sub-dead", pid: 11111 });
|
|
314
|
+
mockIsProcessAlive.mockReturnValue(false); // pid 死
|
|
315
|
+
|
|
316
|
+
mgr.scan();
|
|
317
|
+
|
|
318
|
+
expect(mockExec).toHaveBeenCalledWith(
|
|
319
|
+
"git",
|
|
320
|
+
["worktree", "remove", "--force", expect.any(String)],
|
|
321
|
+
expect.objectContaining({ cwd: MAIN_CWD }),
|
|
322
|
+
);
|
|
323
|
+
expect(mockExec).toHaveBeenCalledWith(
|
|
324
|
+
"git",
|
|
325
|
+
["branch", "-D", "pi-sub-dead"],
|
|
326
|
+
expect.objectContaining({ cwd: MAIN_CWD }),
|
|
327
|
+
);
|
|
328
|
+
expect(mockRemove).toHaveBeenCalledWith("pi-sub-dead");
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("pid 活的条目不删(绝不删活进程)", () => {
|
|
332
|
+
injectEntry({ branch: "pi-sub-alive", pid: 22222 });
|
|
333
|
+
mockIsProcessAlive.mockReturnValue(true); // pid 活
|
|
334
|
+
|
|
335
|
+
mgr.scan();
|
|
336
|
+
|
|
337
|
+
const removeCalls = mockExec.mock.calls.filter(
|
|
338
|
+
(c) => c[1]?.[0] === "worktree" && c[1]?.[1] === "remove",
|
|
339
|
+
);
|
|
340
|
+
expect(removeCalls).toHaveLength(0);
|
|
341
|
+
expect(mockRemove).not.toHaveBeenCalled();
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("崩溃残留:无终态 + pid 死 → 清理(原 P0 缺陷场景)", () => {
|
|
345
|
+
// 这是原 reaper 永远泄漏的场景:进程崩溃无人写终态 marker
|
|
346
|
+
injectEntry({ branch: "pi-sub-crash", pid: 33333 });
|
|
347
|
+
mockIsProcessAlive.mockReturnValue(false); // 崩溃后进程已死
|
|
348
|
+
|
|
349
|
+
mgr.scan();
|
|
350
|
+
|
|
351
|
+
expect(mockExec).toHaveBeenCalledWith(
|
|
352
|
+
"git",
|
|
353
|
+
["branch", "-D", "pi-sub-crash"],
|
|
354
|
+
expect.anything(),
|
|
355
|
+
);
|
|
356
|
+
expect(mockRemove).toHaveBeenCalledWith("pi-sub-crash");
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("pid=0 + 未超 SPAWN_GRACE → 跳过(可能正在 spawn)", () => {
|
|
360
|
+
injectEntry({ branch: "pi-sub-spawning", pid: 0, createdAt: Date.now() });
|
|
361
|
+
|
|
362
|
+
mgr.scan();
|
|
363
|
+
|
|
364
|
+
const removeCalls = mockExec.mock.calls.filter(
|
|
365
|
+
(c) => c[1]?.[0] === "worktree" && c[1]?.[1] === "remove",
|
|
366
|
+
);
|
|
367
|
+
expect(removeCalls).toHaveLength(0);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("pid=0 + 超 SPAWN_GRACE → 清理(create 后崩溃)", () => {
|
|
371
|
+
const sixtyOneMinAgo = Date.now() - 61 * 60 * 1000;
|
|
372
|
+
injectEntry({ branch: "pi-sub-spawn-crash", pid: 0, createdAt: sixtyOneMinAgo });
|
|
373
|
+
|
|
374
|
+
mgr.scan();
|
|
375
|
+
|
|
376
|
+
expect(mockExec).toHaveBeenCalledWith(
|
|
377
|
+
"git",
|
|
378
|
+
["branch", "-D", "pi-sub-spawn-crash"],
|
|
379
|
+
expect.anything(),
|
|
380
|
+
);
|
|
381
|
+
expect(mockRemove).toHaveBeenCalledWith("pi-sub-spawn-crash");
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("跨 repo 清理:条目 repo 字段作为 git -C 目标", () => {
|
|
385
|
+
const OTHER_REPO = "/home/user/other-repo";
|
|
386
|
+
injectEntry({ branch: "pi-sub-cross", pid: 44444, repo: OTHER_REPO });
|
|
387
|
+
mockIsProcessAlive.mockReturnValue(false);
|
|
388
|
+
|
|
389
|
+
mgr.scan();
|
|
390
|
+
|
|
391
|
+
// git 命令的 cwd 应为 OTHER_REPO,非 MAIN_CWD
|
|
392
|
+
expect(mockExec).toHaveBeenCalledWith(
|
|
393
|
+
"git",
|
|
394
|
+
expect.arrayContaining(["branch", "-D", "pi-sub-cross"]),
|
|
395
|
+
expect.objectContaining({ cwd: OTHER_REPO }),
|
|
396
|
+
);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("空注册表时无操作", () => {
|
|
400
|
+
// registryEntries 已在 beforeEach 清空
|
|
401
|
+
mgr.scan();
|
|
402
|
+
expect(mockExec).not.toHaveBeenCalled();
|
|
403
|
+
expect(mockRemove).not.toHaveBeenCalled();
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("worktree remove 失败时 branch -D + 注册表移除仍执行(best-effort)", () => {
|
|
407
|
+
injectEntry({ branch: "pi-sub-stubborn", pid: 55555 });
|
|
408
|
+
mockIsProcessAlive.mockReturnValue(false);
|
|
409
|
+
mockExec.mockImplementation(() => {
|
|
410
|
+
throw new Error("worktree locked");
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
mgr.scan();
|
|
414
|
+
|
|
415
|
+
// branch -D 仍被调用(两次 git 调用各自独立)
|
|
416
|
+
const branchDeleteCalls = mockExec.mock.calls.filter(
|
|
417
|
+
(c) => c[1]?.[0] === "branch" && c[1]?.[1] === "-D",
|
|
418
|
+
);
|
|
419
|
+
expect(branchDeleteCalls).toHaveLength(1);
|
|
420
|
+
expect(mockRemove).toHaveBeenCalledWith("pi-sub-stubborn");
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// src/__tests__/worktree-registry.test.ts
|
|
2
|
+
//
|
|
3
|
+
// WorktreeRegistry 单元测试。
|
|
4
|
+
// 用真实 tmpdir 做文件 IO(不 mock fs),验证:
|
|
5
|
+
// - add/updatePid/remove/load 语义
|
|
6
|
+
// - 同 branch 覆盖(去重)
|
|
7
|
+
// - 文件不存在 / 损坏 / IO 错误的降级
|
|
8
|
+
// - 原子写(.tmp → rename)
|
|
9
|
+
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import * as os from "node:os";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
15
|
+
|
|
16
|
+
import { SPAWN_GRACE_MS, type WorktreeEntry,WorktreeRegistry } from "../worktree-registry.ts";
|
|
17
|
+
|
|
18
|
+
const REPO_A = "/home/user/repo-a";
|
|
19
|
+
const REPO_B = "/home/user/repo-b";
|
|
20
|
+
|
|
21
|
+
function makeEntry(overrides: Partial<WorktreeEntry> = {}): WorktreeEntry {
|
|
22
|
+
return {
|
|
23
|
+
repo: REPO_A,
|
|
24
|
+
branch: "pi-sub-bg-1",
|
|
25
|
+
checkout: path.join(os.tmpdir(), "pi-sub-bg-1"),
|
|
26
|
+
pid: 0,
|
|
27
|
+
createdAt: Date.now(),
|
|
28
|
+
...overrides,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("WorktreeRegistry", () => {
|
|
33
|
+
let tmpAgentDir: string;
|
|
34
|
+
let registry: WorktreeRegistry;
|
|
35
|
+
let registryFile: string;
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
tmpAgentDir = fs.mkdtempSync(path.join(os.tmpdir(), "wt-registry-test-"));
|
|
39
|
+
registry = new WorktreeRegistry(tmpAgentDir);
|
|
40
|
+
registryFile = path.join(tmpAgentDir, "subagents", "worktrees.json");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
fs.rmSync(tmpAgentDir, { recursive: true, force: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("add + load", () => {
|
|
48
|
+
it("add 后 load 能读到", () => {
|
|
49
|
+
const entry = makeEntry();
|
|
50
|
+
registry.add(entry);
|
|
51
|
+
const loaded = registry.load();
|
|
52
|
+
expect(loaded).toHaveLength(1);
|
|
53
|
+
expect(loaded[0]).toEqual(entry);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("多条目跨 repo 共存", () => {
|
|
57
|
+
const entryA = makeEntry({ repo: REPO_A, branch: "pi-sub-bg-1" });
|
|
58
|
+
const entryB = makeEntry({ repo: REPO_B, branch: "pi-sub-bg-2" });
|
|
59
|
+
registry.add(entryA);
|
|
60
|
+
registry.add(entryB);
|
|
61
|
+
const loaded = registry.load();
|
|
62
|
+
expect(loaded).toHaveLength(2);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("同 branch add 覆盖(去重)", () => {
|
|
66
|
+
const entry = makeEntry({ pid: 0 });
|
|
67
|
+
registry.add(entry);
|
|
68
|
+
// 同 branch 再次 add(覆盖)
|
|
69
|
+
const updated = makeEntry({ pid: 999 });
|
|
70
|
+
registry.add(updated);
|
|
71
|
+
const loaded = registry.load();
|
|
72
|
+
expect(loaded).toHaveLength(1);
|
|
73
|
+
expect(loaded[0].pid).toBe(999);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("updatePid", () => {
|
|
78
|
+
it("补全 pid(create 占位 → first header 补全)", () => {
|
|
79
|
+
registry.add(makeEntry({ branch: "pi-sub-bg-1", pid: 0 }));
|
|
80
|
+
registry.updatePid("pi-sub-bg-1", 12345);
|
|
81
|
+
const loaded = registry.load();
|
|
82
|
+
expect(loaded[0].pid).toBe(12345);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("branch 不存在时忽略(幂等)", () => {
|
|
86
|
+
registry.add(makeEntry({ branch: "pi-sub-bg-1" }));
|
|
87
|
+
registry.updatePid("pi-sub-nonexistent", 12345);
|
|
88
|
+
const loaded = registry.load();
|
|
89
|
+
expect(loaded).toHaveLength(1);
|
|
90
|
+
expect(loaded[0].pid).toBe(0); // 原条目未变
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("update 不改变其他字段", () => {
|
|
94
|
+
const entry = makeEntry({ branch: "pi-sub-bg-1", repo: REPO_A, checkout: "/tmp/x" });
|
|
95
|
+
registry.add(entry);
|
|
96
|
+
registry.updatePid("pi-sub-bg-1", 999);
|
|
97
|
+
const loaded = registry.load();
|
|
98
|
+
expect(loaded[0].repo).toBe(REPO_A);
|
|
99
|
+
expect(loaded[0].checkout).toBe("/tmp/x");
|
|
100
|
+
expect(loaded[0].branch).toBe("pi-sub-bg-1");
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("remove", () => {
|
|
105
|
+
it("移除指定 branch", () => {
|
|
106
|
+
registry.add(makeEntry({ branch: "pi-sub-bg-1" }));
|
|
107
|
+
registry.add(makeEntry({ branch: "pi-sub-bg-2" }));
|
|
108
|
+
registry.remove("pi-sub-bg-1");
|
|
109
|
+
const loaded = registry.load();
|
|
110
|
+
expect(loaded).toHaveLength(1);
|
|
111
|
+
expect(loaded[0].branch).toBe("pi-sub-bg-2");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("branch 不存在时忽略(幂等)", () => {
|
|
115
|
+
registry.add(makeEntry({ branch: "pi-sub-bg-1" }));
|
|
116
|
+
registry.remove("pi-sub-nonexistent");
|
|
117
|
+
expect(registry.load()).toHaveLength(1);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("空注册表 remove 不报错", () => {
|
|
121
|
+
expect(() => registry.remove("pi-sub-anything")).not.toThrow();
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("降级与健壮性", () => {
|
|
126
|
+
it("文件不存在时 load 返回空数组", () => {
|
|
127
|
+
expect(registry.load()).toEqual([]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("损坏 JSON 时 load 返回空数组", () => {
|
|
131
|
+
fs.mkdirSync(path.dirname(registryFile), { recursive: true });
|
|
132
|
+
fs.writeFileSync(registryFile, "{ not valid json }}}", "utf-8");
|
|
133
|
+
expect(registry.load()).toEqual([]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("entries 字段缺失时 load 返回空数组", () => {
|
|
137
|
+
fs.mkdirSync(path.dirname(registryFile), { recursive: true });
|
|
138
|
+
fs.writeFileSync(registryFile, '{"other": 123}', "utf-8");
|
|
139
|
+
expect(registry.load()).toEqual([]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("save 创建不存在的父目录", () => {
|
|
143
|
+
// registryFile 在 tmpAgentDir/subagents/ 下,subagents 目录不存在
|
|
144
|
+
expect(fs.existsSync(path.dirname(registryFile))).toBe(false);
|
|
145
|
+
registry.add(makeEntry());
|
|
146
|
+
expect(fs.existsSync(registryFile)).toBe(true);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("save 后无残留 .tmp 文件(原子写)", () => {
|
|
150
|
+
registry.add(makeEntry());
|
|
151
|
+
const tmpFile = `${registryFile}.tmp`;
|
|
152
|
+
expect(fs.existsSync(tmpFile)).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("SPAWN_GRACE_MS 常量", () => {
|
|
157
|
+
it("值为 60s", () => {
|
|
158
|
+
expect(SPAWN_GRACE_MS).toBe(60_000);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
});
|