@zhushanwen/pi-subagent-workflow 7.4.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/README.md +15 -0
- package/package.json +7 -4
- package/scripts/rfl.mjs +308 -0
- package/skills/workflow-script-format/SKILL.md +1 -1
- package/src/execution/__tests__/__fixtures__/truncline.snapshot.json +1 -0
- package/src/execution/__tests__/ask-user-transit-e2e.test.ts +10 -4
- package/src/execution/__tests__/before-agent-start-injection.test.ts +132 -0
- package/src/execution/__tests__/bg-notify-render.test.ts +15 -15
- package/src/execution/__tests__/chatmode-first-round-closure-service.test.ts +365 -0
- package/src/execution/__tests__/chatmode-first-round-closure-spawn.test.ts +190 -0
- package/src/execution/__tests__/chatmode-round-notify-real-chain.test.ts +215 -0
- package/src/execution/__tests__/conversation-wiring.test.ts +198 -0
- package/src/execution/__tests__/crash-recovery.test.ts +8 -2
- package/src/execution/__tests__/delivery-methods.test.ts +385 -0
- package/src/execution/__tests__/epipe-fallback.test.ts +241 -0
- package/src/execution/__tests__/execute-and-await-worktree.test.ts +49 -2
- package/src/execution/__tests__/execute-nesting.test.ts +20 -72
- package/src/execution/__tests__/execution-record.test.ts +199 -0
- package/src/execution/__tests__/finalize-record.test.ts +197 -15
- package/src/execution/__tests__/format.test.ts +131 -7
- package/src/execution/__tests__/gc-timer.test.ts +184 -0
- package/src/execution/__tests__/get-record-for-action-restart.test.ts +254 -0
- package/src/execution/__tests__/helpers/spawn-mock.ts +37 -10
- package/src/execution/__tests__/index-session-start-identity.test.ts +371 -0
- package/src/execution/__tests__/index-session-start.test.ts +257 -5
- package/src/execution/__tests__/lifecycle-manager-lock.test.ts +211 -0
- package/src/execution/__tests__/lifecycle-manager.test.ts +337 -0
- package/src/execution/__tests__/lifecycle-predicates.test.ts +116 -0
- package/src/execution/__tests__/list-component.test.ts +59 -5
- package/src/execution/__tests__/list-fields.test.ts +109 -0
- package/src/execution/__tests__/model-resolver.test.ts +38 -1
- package/src/execution/__tests__/nested-visibility-env-propagation.test.ts +287 -0
- package/src/execution/__tests__/nested-visibility.test.ts +325 -0
- package/src/execution/__tests__/notifier-flush.test.ts +209 -7
- package/src/execution/__tests__/one-shot-upgrade.test.ts +205 -0
- package/src/execution/__tests__/parent-child-matrix.test.ts +336 -0
- package/src/execution/__tests__/record-store.test.ts +442 -54
- package/src/execution/__tests__/recursive-visibility-baseline.test.ts +11 -12
- package/src/execution/__tests__/recursive-visibility-env.test.ts +18 -20
- package/src/execution/__tests__/resource-policy.test.ts +109 -0
- package/src/execution/__tests__/run-and-finalize-chatmode.test.ts +267 -0
- package/src/execution/__tests__/run-spawn-chatmode-settled.test.ts +253 -0
- package/src/execution/__tests__/run-spawn-edges.test.ts +18 -25
- package/src/execution/__tests__/run-spawn-integration.test.ts +29 -25
- package/src/execution/__tests__/run-spawn-resume.test.ts +322 -0
- package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +14 -11
- package/src/execution/__tests__/session-pending.test.ts +61 -2
- package/src/execution/__tests__/session-reconstructor.test.ts +4 -4
- package/src/execution/__tests__/session-runner-epipe.test.ts +178 -0
- package/src/execution/__tests__/session-runner-schema-env.test.ts +15 -21
- package/src/execution/__tests__/session-start-reaper.test.ts +10 -8
- package/src/execution/__tests__/spawn-args.test.ts +127 -49
- package/src/execution/__tests__/spawn-worktree-guidance.test.ts +1 -0
- package/src/execution/__tests__/spawned-children.test.ts +92 -0
- package/src/execution/__tests__/status-refactor.test.ts +345 -0
- package/src/execution/__tests__/stdin-writer.test.ts +97 -0
- package/src/execution/__tests__/subagent-service-message-close.test.ts +629 -0
- package/src/execution/__tests__/subagent-service-parent-guard.test.ts +180 -0
- package/src/execution/__tests__/subagent-service.test.ts +49 -11
- package/src/execution/__tests__/timeout-integration.test.ts +27 -13
- package/src/execution/__tests__/tool-action.test.ts +12 -10
- package/src/execution/__tests__/truncline-snapshot.test.ts +81 -0
- package/src/execution/__tests__/turn-limiter-semantics.test.ts +194 -0
- package/src/execution/__tests__/worktree-manager.test.ts +300 -90
- package/src/execution/__tests__/worktree-pid-registration.integration.test.ts +17 -13
- 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 +21 -2
- package/src/execution/channel-registry-access.ts +3 -1
- package/src/execution/execution-record.ts +126 -9
- package/src/execution/finalize-record.ts +99 -13
- package/src/execution/idle-gc.ts +47 -0
- package/src/execution/lifecycle-manager.ts +491 -0
- package/src/execution/lifecycle-predicates.ts +65 -0
- package/src/execution/manifest-store.ts +61 -16
- package/src/execution/model-resolver.ts +26 -5
- package/src/execution/notifier.ts +69 -12
- package/src/execution/pi-invocation.ts +21 -1
- package/src/execution/record-entry.ts +118 -0
- package/src/execution/record-store.ts +844 -108
- package/src/execution/session-pending.ts +121 -49
- package/src/execution/session-reconstructor.ts +224 -7
- package/src/execution/session-runner.ts +713 -316
- package/src/execution/sessions-index.ts +304 -0
- package/src/execution/stdin-writer.ts +93 -7
- package/src/execution/stream-sink.ts +20 -3
- package/src/execution/subagent-service.ts +917 -138
- package/src/execution/temp-prompt.ts +8 -3
- package/src/execution/turn-limiter.ts +14 -0
- package/src/execution/types.ts +218 -19
- package/src/execution/worktree-manager.ts +449 -59
- package/src/execution/worktree-registry.ts +97 -29
- package/src/index.ts +318 -20
- package/src/injectors/subagent-list-injector.ts +26 -8
- package/src/injectors/workflow-list-injector.ts +25 -8
- package/src/interface/__tests__/subagent-tool-prompt.test.ts +17 -4
- package/src/interface/__tests__/tool-render.test.ts +10 -8
- 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/bg-notify-render.ts +32 -8
- package/src/interface/command-actions.ts +26 -7
- package/src/interface/commands.ts +21 -22
- package/src/interface/format.ts +53 -20
- package/src/interface/gui-mappers.ts +6 -8
- package/src/interface/helpers.ts +170 -10
- package/src/interface/list-component.ts +53 -14
- package/src/interface/subagent-actions.ts +235 -17
- package/src/interface/subagent-tool.ts +81 -16
- package/src/interface/subagents.ts +2 -1
- package/src/interface/tool-render.ts +21 -27
- package/src/interface/tool-workflow-script.ts +29 -33
- package/src/interface/tool-workflow.ts +67 -100
- package/src/interface/views/WorkflowsView.ts +89 -32
- package/src/interface/views/__tests__/WorkflowsView-signature.test.ts +264 -0
- package/src/interface/views/detail-content.ts +1 -1
- package/src/interface/views/format.ts +3 -3
- package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +333 -0
- package/src/orchestration/__tests__/args-validator.test.ts +1 -1
- package/src/orchestration/__tests__/config-loader.test.ts +38 -0
- package/src/orchestration/__tests__/error-recovery-handlers.test.ts +394 -4
- package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +4 -4
- package/src/orchestration/__tests__/execute-agent-call.test.ts +144 -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 +820 -19
- package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +0 -2
- package/src/orchestration/__tests__/lifecycle-runid-injection.test.ts +96 -0
- package/src/orchestration/__tests__/lifecycle.test.ts +332 -149
- package/src/orchestration/__tests__/review-fix-loop-e2e.test.ts +1108 -19
- package/src/orchestration/__tests__/skill-discovery.test.ts +130 -61
- package/src/orchestration/__tests__/test-mocks.ts +197 -0
- package/src/orchestration/__tests__/worker-returnmeta-passthrough.test.ts +164 -0
- package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +110 -0
- package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +0 -2
- package/src/orchestration/__tests__/workflow-script-lint-memo.test.ts +110 -0
- package/src/orchestration/__tests__/workflows-e2e.test.ts +38 -40
- package/src/orchestration/agent-opts-resolver.ts +4 -1
- package/src/orchestration/args-validator.ts +2 -2
- package/src/orchestration/config-loader.ts +30 -1
- package/src/orchestration/error-recovery.ts +137 -30
- package/src/orchestration/execute-agent-call.ts +44 -10
- package/src/orchestration/jsonl-run-store.ts +397 -71
- package/src/orchestration/launcher.ts +7 -1
- package/src/orchestration/lifecycle.ts +145 -133
- package/src/orchestration/models/__tests__/trace.test.ts +408 -0
- package/src/orchestration/models/budget.ts +1 -1
- package/src/orchestration/models/run-runtime.ts +15 -17
- package/src/orchestration/models/run-spec.ts +2 -2
- package/src/orchestration/models/run-state.ts +3 -3
- package/src/orchestration/models/trace.ts +95 -15
- package/src/orchestration/models/types.ts +8 -9
- package/src/orchestration/models/workflow-run.ts +50 -71
- package/src/orchestration/models/workflow-script.ts +32 -1
- package/src/orchestration/skill-discovery.ts +30 -0
- package/src/orchestration/worker-handle.ts +1 -1
- package/src/orchestration/worker-host.ts +1 -1
- package/src/orchestration/worker-script-builder.ts +38 -11
- package/src/shared/__tests__/agent-ref.test.ts +34 -0
- package/src/shared/__tests__/resource-discovery-manifest-cache.test.ts +280 -0
- package/src/shared/__tests__/resource-discovery.test.ts +79 -0
- package/src/shared/__tests__/schema-jsonify.test.ts +81 -0
- package/src/shared/agent-ref.ts +22 -1
- package/src/shared/resource-discovery.ts +162 -59
- package/src/shared/schema-jsonify.ts +56 -0
- package/workflows/review-fix-loop-utils.cjs +542 -32
- package/workflows/review-fix-loop.js +462 -109
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// src/__tests__/worktree-manager.test.ts
|
|
2
2
|
//
|
|
3
3
|
// WorktreeManager 单元测试。
|
|
4
|
-
// mock
|
|
4
|
+
// mock execFile(git 命令)+ WorktreeRegistry(注册表)+ alive-store(进程探活)。
|
|
5
5
|
//
|
|
6
6
|
// [全局注册表重构] scan 不再读 sidecar / 不再依赖 cwd 是否 git repo。
|
|
7
7
|
// 判据从「终态 marker 状态机」改为「pid 死活」。
|
|
@@ -14,7 +14,7 @@ import { DirtyWorktreeError } from "../types.ts";
|
|
|
14
14
|
// ── mock modules ──
|
|
15
15
|
|
|
16
16
|
vi.mock("node:child_process", () => ({
|
|
17
|
-
|
|
17
|
+
execFile: vi.fn(),
|
|
18
18
|
}));
|
|
19
19
|
|
|
20
20
|
vi.mock("node:fs", () => ({
|
|
@@ -36,7 +36,7 @@ vi.mock("../alive-store.ts", () => ({
|
|
|
36
36
|
|
|
37
37
|
// WorktreeRegistry mock:内存数组模拟,add/updatePid/remove/load 全部可追踪
|
|
38
38
|
const { mockLoad, mockAdd, mockUpdatePid, mockRemove, registryEntries } = vi.hoisted(() => {
|
|
39
|
-
type Entry = { repo: string; branch: string; checkout: string; pid: number; createdAt: number };
|
|
39
|
+
type Entry = { repo: string; branch: string; checkout: string; pid: number; createdAt: number; sessionFile?: string };
|
|
40
40
|
const entries: Entry[] = [];
|
|
41
41
|
return {
|
|
42
42
|
registryEntries: entries,
|
|
@@ -46,9 +46,12 @@ const { mockLoad, mockAdd, mockUpdatePid, mockRemove, registryEntries } = vi.hoi
|
|
|
46
46
|
if (idx >= 0) entries[idx] = e;
|
|
47
47
|
else entries.push(e);
|
|
48
48
|
}),
|
|
49
|
-
mockUpdatePid: vi.fn((branch: string, pid: number): void => {
|
|
49
|
+
mockUpdatePid: vi.fn((branch: string, pid: number, sessionFile?: string): void => {
|
|
50
50
|
const e = entries.find((x) => x.branch === branch);
|
|
51
|
-
if (e)
|
|
51
|
+
if (e) {
|
|
52
|
+
e.pid = pid;
|
|
53
|
+
if (sessionFile !== undefined) e.sessionFile = sessionFile;
|
|
54
|
+
}
|
|
52
55
|
}),
|
|
53
56
|
mockRemove: vi.fn((branch: string): void => {
|
|
54
57
|
const idx = entries.findIndex((x) => x.branch === branch);
|
|
@@ -67,7 +70,8 @@ vi.mock("../worktree-registry.ts", () => ({
|
|
|
67
70
|
SPAWN_GRACE_MS: 60_000,
|
|
68
71
|
}));
|
|
69
72
|
|
|
70
|
-
|
|
73
|
+
|
|
74
|
+
import { execFile } from "node:child_process";
|
|
71
75
|
import * as fs from "node:fs";
|
|
72
76
|
import * as os from "node:os";
|
|
73
77
|
import * as path from "node:path";
|
|
@@ -76,10 +80,29 @@ import { isProcessAlive } from "../alive-store.ts";
|
|
|
76
80
|
import { encodeCwd } from "../path-encoding.ts";
|
|
77
81
|
import { WorktreeManager } from "../worktree-manager.ts";
|
|
78
82
|
|
|
79
|
-
const
|
|
83
|
+
const mockExecFile = vi.mocked(execFile);
|
|
80
84
|
const mockExistsSync = vi.mocked(fs.existsSync);
|
|
81
85
|
const mockIsProcessAlive = vi.mocked(isProcessAlive);
|
|
82
86
|
|
|
87
|
+
/**
|
|
88
|
+
* gitRunAsync(execFile callback 风格)mock 装配。
|
|
89
|
+
* impl 返回 {err?, stdout?, stderr?};err 模拟失败路径(exitCode=killed/signal 挂在 err 上)。
|
|
90
|
+
*/
|
|
91
|
+
type ExecFileResult = {
|
|
92
|
+
err?: (Error & { code?: unknown; killed?: boolean; signal?: string }) | null;
|
|
93
|
+
stdout?: string;
|
|
94
|
+
stderr?: string;
|
|
95
|
+
};
|
|
96
|
+
function setupExecFile(impl?: (args: readonly string[]) => ExecFileResult): void {
|
|
97
|
+
mockExecFile.mockImplementation(
|
|
98
|
+
(_cmd: string, args: readonly string[], _opts: unknown, cb: (err: Error | null, stdout?: string, stderr?: string) => void) => {
|
|
99
|
+
const r = impl ? impl(args) : { stdout: "" };
|
|
100
|
+
if (r.err) cb(r.err, r.stdout ?? "", r.stderr ?? "");
|
|
101
|
+
else cb(null, r.stdout ?? "", r.stderr ?? "");
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
83
106
|
const MAIN_CWD = "/home/user/project";
|
|
84
107
|
const AGENT_DIR = "/home/user/.pi/agent";
|
|
85
108
|
const RECORD_ID = "bg-42-abc";
|
|
@@ -101,12 +124,10 @@ function makeHandle(checkoutPath: string = expectedCreatePath(RECORD_ID)) {
|
|
|
101
124
|
}
|
|
102
125
|
|
|
103
126
|
function setupCleanTree(): void {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
if (
|
|
107
|
-
|
|
108
|
-
if (args?.[0] === "branch") return "";
|
|
109
|
-
return "";
|
|
127
|
+
setupExecFile((_args: readonly string[]) => {
|
|
128
|
+
// rev-parse 真实输出形态:hash + \n(gitRunAsync 不 trim,消费点 trim 后才得干净 hash)
|
|
129
|
+
if (_args?.[0] === "rev-parse" && _args?.[1] === "HEAD") return { stdout: `${BASE_COMMIT}\n` };
|
|
130
|
+
return { stdout: "" };
|
|
110
131
|
});
|
|
111
132
|
// worktreePath 不存在(无需前置清理);node_modules 存在
|
|
112
133
|
mockExistsSync.mockImplementation((p: unknown) => {
|
|
@@ -118,13 +139,14 @@ function setupCleanTree(): void {
|
|
|
118
139
|
}
|
|
119
140
|
|
|
120
141
|
/** 向注册表注入一条活条目(模拟 create 后的状态)。 */
|
|
121
|
-
function injectEntry(overrides: Partial<{ branch: string; pid: number; checkout: string; repo: string; createdAt: number }> = {}): void {
|
|
142
|
+
function injectEntry(overrides: Partial<{ branch: string; pid: number; checkout: string; repo: string; createdAt: number; sessionFile: string }> = {}): void {
|
|
122
143
|
registryEntries.push({
|
|
123
144
|
repo: overrides.repo ?? MAIN_CWD,
|
|
124
145
|
branch: overrides.branch ?? "pi-sub-orphan1",
|
|
125
146
|
checkout: overrides.checkout ?? path.join(os.tmpdir(), "pi-sub-orphan1"),
|
|
126
147
|
pid: overrides.pid ?? 0,
|
|
127
148
|
createdAt: overrides.createdAt ?? Date.now(),
|
|
149
|
+
sessionFile: overrides.sessionFile,
|
|
128
150
|
});
|
|
129
151
|
}
|
|
130
152
|
|
|
@@ -138,10 +160,10 @@ describe("WorktreeManager", () => {
|
|
|
138
160
|
});
|
|
139
161
|
|
|
140
162
|
describe("create", () => {
|
|
141
|
-
it("正常流程返回冻结 handle(path 在 tmpdir,含 mainCwd)", () => {
|
|
163
|
+
it("正常流程返回冻结 handle(path 在 tmpdir,含 mainCwd)", async () => {
|
|
142
164
|
setupCleanTree();
|
|
143
165
|
|
|
144
|
-
const handle = mgr.create(MAIN_CWD, RECORD_ID);
|
|
166
|
+
const handle = await mgr.create(MAIN_CWD, RECORD_ID);
|
|
145
167
|
|
|
146
168
|
expect(handle.path).toBe(expectedCreatePath(RECORD_ID));
|
|
147
169
|
expect(handle.branch).toBe(`pi-sub-${RECORD_ID}`);
|
|
@@ -150,10 +172,10 @@ describe("WorktreeManager", () => {
|
|
|
150
172
|
expect(Object.isFrozen(handle)).toBe(true);
|
|
151
173
|
});
|
|
152
174
|
|
|
153
|
-
it("成功后写入注册表(pid=0 占位)", () => {
|
|
175
|
+
it("成功后写入注册表(pid=0 占位)", async () => {
|
|
154
176
|
setupCleanTree();
|
|
155
177
|
|
|
156
|
-
mgr.create(MAIN_CWD, RECORD_ID);
|
|
178
|
+
await mgr.create(MAIN_CWD, RECORD_ID);
|
|
157
179
|
|
|
158
180
|
expect(mockAdd).toHaveBeenCalledTimes(1);
|
|
159
181
|
const entry = mockAdd.mock.calls[0][0] as { repo: string; branch: string; pid: number };
|
|
@@ -162,30 +184,35 @@ describe("WorktreeManager", () => {
|
|
|
162
184
|
expect(entry.pid).toBe(0);
|
|
163
185
|
});
|
|
164
186
|
|
|
165
|
-
it("脏树抛 DirtyWorktreeError
|
|
166
|
-
|
|
167
|
-
if (args
|
|
168
|
-
return "";
|
|
187
|
+
it("脏树抛 DirtyWorktreeError 且不写注册表(status 与 rev-parse 并行,均发起)", async () => {
|
|
188
|
+
setupExecFile((args: readonly string[]) => {
|
|
189
|
+
if (args[0] === "status") return { stdout: "M src/index.ts\n" };
|
|
190
|
+
return { stdout: "" };
|
|
169
191
|
});
|
|
170
192
|
|
|
171
|
-
|
|
193
|
+
// async create:同步 throw 变 rejected promise
|
|
194
|
+
await expect(mgr.create(MAIN_CWD, RECORD_ID)).rejects.toThrow(DirtyWorktreeError);
|
|
172
195
|
expect(mockAdd).not.toHaveBeenCalled();
|
|
196
|
+
// allSettled 并行:两条读命令都发起(rev-parse 结果在脏树时被丢弃)
|
|
197
|
+
const cmds = mockExecFile.mock.calls.map((c) => `${c[1]?.[0]} ${c[1]?.[1] ?? ""}`);
|
|
198
|
+
expect(cmds).toContain("status --porcelain");
|
|
199
|
+
expect(cmds).toContain("rev-parse HEAD");
|
|
173
200
|
});
|
|
174
201
|
|
|
175
|
-
it("recordId 含特殊字符抛 DirtyWorktreeError", () => {
|
|
176
|
-
expect(
|
|
177
|
-
expect(
|
|
178
|
-
expect(
|
|
179
|
-
expect(
|
|
202
|
+
it("recordId 含特殊字符抛 DirtyWorktreeError", async () => {
|
|
203
|
+
await expect(mgr.create(MAIN_CWD, "../evil-path")).rejects.toThrow(DirtyWorktreeError);
|
|
204
|
+
await expect(mgr.create(MAIN_CWD, "hello world")).rejects.toThrow(DirtyWorktreeError);
|
|
205
|
+
await expect(mgr.create(MAIN_CWD, "")).rejects.toThrow(DirtyWorktreeError);
|
|
206
|
+
await expect(mgr.create(MAIN_CWD, "a;b")).rejects.toThrow(DirtyWorktreeError);
|
|
180
207
|
});
|
|
181
208
|
|
|
182
|
-
it("recordId 单字符 / 连续短横线合法", () => {
|
|
209
|
+
it("recordId 单字符 / 连续短横线合法", async () => {
|
|
183
210
|
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--");
|
|
211
|
+
expect((await mgr.create(MAIN_CWD, "a")).branch).toBe("pi-sub-a");
|
|
212
|
+
expect((await mgr.create(MAIN_CWD, "--test--")).branch).toBe("pi-sub---test--");
|
|
186
213
|
});
|
|
187
214
|
|
|
188
|
-
it("残留 checkout 目录存在时前置清理(fs.rmSync)", () => {
|
|
215
|
+
it("残留 checkout 目录存在时前置清理(fs.rmSync)", async () => {
|
|
189
216
|
setupCleanTree();
|
|
190
217
|
// worktreePath 已存在 → 触发前置 rmSync
|
|
191
218
|
mockExistsSync.mockImplementation((p: unknown) => {
|
|
@@ -195,7 +222,7 @@ describe("WorktreeManager", () => {
|
|
|
195
222
|
return false;
|
|
196
223
|
});
|
|
197
224
|
|
|
198
|
-
mgr.create(MAIN_CWD, RECORD_ID);
|
|
225
|
+
await mgr.create(MAIN_CWD, RECORD_ID);
|
|
199
226
|
|
|
200
227
|
expect(fs.rmSync).toHaveBeenCalledWith(
|
|
201
228
|
expectedCreatePath(RECORD_ID),
|
|
@@ -203,75 +230,120 @@ describe("WorktreeManager", () => {
|
|
|
203
230
|
);
|
|
204
231
|
});
|
|
205
232
|
|
|
206
|
-
it("symlink 失败时回滚 worktree + 分支 +
|
|
233
|
+
it("symlink 失败时回滚 worktree + 分支 + 注册表(回滚经 gitRunAsync)", async () => {
|
|
207
234
|
setupCleanTree();
|
|
208
235
|
// symlink 抛错触发 MF#3 回滚
|
|
209
236
|
vi.mocked(fs.symlinkSync).mockImplementation(() => {
|
|
210
237
|
throw new Error("symlink permission denied");
|
|
211
238
|
});
|
|
212
239
|
|
|
213
|
-
expect(
|
|
240
|
+
await expect(mgr.create(MAIN_CWD, RECORD_ID)).rejects.toThrow("symlink permission denied");
|
|
214
241
|
|
|
215
|
-
// 回滚:worktree remove + branch delete
|
|
216
|
-
expect(
|
|
242
|
+
// 回滚:worktree remove + branch delete(异步路径)+ registry remove
|
|
243
|
+
expect(mockExecFile).toHaveBeenCalledWith(
|
|
217
244
|
"git",
|
|
218
245
|
expect.arrayContaining(["worktree", "remove", "--force"]),
|
|
219
246
|
expect.anything(),
|
|
247
|
+
expect.anything(),
|
|
220
248
|
);
|
|
221
|
-
expect(
|
|
249
|
+
expect(mockExecFile).toHaveBeenCalledWith(
|
|
222
250
|
"git",
|
|
223
251
|
expect.arrayContaining(["branch", "-D"]),
|
|
224
252
|
expect.anything(),
|
|
253
|
+
expect.anything(),
|
|
225
254
|
);
|
|
226
255
|
expect(mockRemove).toHaveBeenCalledWith(`pi-sub-${RECORD_ID}`);
|
|
227
256
|
});
|
|
257
|
+
it("per-repo mutex:同 repo 并发 create 的 worktree add 串行(maxActive=1)", async () => {
|
|
258
|
+
// clearAllMocks 不清 mockImplementation——重置前例注入的 symlinkSync throw,本用例走完整 create
|
|
259
|
+
vi.mocked(fs.symlinkSync).mockImplementation(() => {});
|
|
260
|
+
mockExistsSync.mockImplementation((p: unknown) => {
|
|
261
|
+
const s = String(p);
|
|
262
|
+
if (s.includes("node_modules")) return true;
|
|
263
|
+
return false;
|
|
264
|
+
});
|
|
265
|
+
let writeActive = 0;
|
|
266
|
+
let maxWriteActive = 0;
|
|
267
|
+
let addCount = 0;
|
|
268
|
+
mockExecFile.mockImplementation(
|
|
269
|
+
(_cmd: string, args: readonly string[], _opts: unknown, cb: (err: Error | null, stdout?: string, stderr?: string) => void) => {
|
|
270
|
+
const isAdd = args[0] === "worktree" && args[1] === "add";
|
|
271
|
+
if (isAdd) {
|
|
272
|
+
// 只对写命令(worktree add)计并发——读命令(status/rev-parse)无锁可并行是设计预期
|
|
273
|
+
writeActive++;
|
|
274
|
+
maxWriteActive = Math.max(maxWriteActive, writeActive);
|
|
275
|
+
addCount++;
|
|
276
|
+
}
|
|
277
|
+
setTimeout(() => {
|
|
278
|
+
if (isAdd) writeActive--;
|
|
279
|
+
// rev-parse 真实输出带尾换行;baseCommit 由消费点 trim
|
|
280
|
+
cb(null, args[0] === "rev-parse" ? `${BASE_COMMIT}\n` : "", "");
|
|
281
|
+
}, 5);
|
|
282
|
+
},
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
const handles = await Promise.all([
|
|
286
|
+
mgr.create(MAIN_CWD, "con-a"),
|
|
287
|
+
mgr.create(MAIN_CWD, "con-b"),
|
|
288
|
+
]);
|
|
289
|
+
|
|
290
|
+
// 两个 create 全部成功、branch 不同
|
|
291
|
+
expect(handles[0].branch).toBe("pi-sub-con-a");
|
|
292
|
+
expect(handles[1].branch).toBe("pi-sub-con-b");
|
|
293
|
+
// 同 repo 的 2 个 worktree add 全部执行且互斥(per-repo mutex)
|
|
294
|
+
expect(addCount).toBe(2);
|
|
295
|
+
expect(maxWriteActive).toBe(1);
|
|
296
|
+
});
|
|
228
297
|
});
|
|
229
298
|
|
|
230
299
|
describe("registerPid", () => {
|
|
231
300
|
it("委托 registry.updatePid", () => {
|
|
232
301
|
mgr.registerPid("pi-sub-bg-1", 12345);
|
|
233
|
-
expect(mockUpdatePid).toHaveBeenCalledWith("pi-sub-bg-1", 12345);
|
|
302
|
+
expect(mockUpdatePid).toHaveBeenCalledWith("pi-sub-bg-1", 12345, undefined);
|
|
234
303
|
});
|
|
235
304
|
});
|
|
236
305
|
|
|
237
306
|
describe("cleanup", () => {
|
|
238
|
-
it("worktree remove + branch -D + 注册表移除(cwd 用 handle.mainCwd)", () => {
|
|
239
|
-
|
|
307
|
+
it("worktree remove + branch -D + 注册表移除(cwd 用 handle.mainCwd)", async () => {
|
|
308
|
+
setupExecFile();
|
|
240
309
|
const handle = makeHandle();
|
|
241
310
|
|
|
242
|
-
mgr.cleanup(handle);
|
|
311
|
+
await mgr.cleanup(handle);
|
|
243
312
|
|
|
244
|
-
expect(
|
|
313
|
+
expect(mockExecFile).toHaveBeenCalledWith(
|
|
245
314
|
"git",
|
|
246
315
|
["worktree", "remove", "--force", handle.path],
|
|
247
316
|
expect.objectContaining({ cwd: MAIN_CWD }),
|
|
317
|
+
expect.anything(),
|
|
248
318
|
);
|
|
249
|
-
expect(
|
|
319
|
+
expect(mockExecFile).toHaveBeenCalledWith(
|
|
250
320
|
"git",
|
|
251
321
|
["branch", "-D", handle.branch],
|
|
252
322
|
expect.objectContaining({ cwd: MAIN_CWD }),
|
|
323
|
+
expect.anything(),
|
|
253
324
|
);
|
|
254
325
|
expect(mockRemove).toHaveBeenCalledWith(handle.branch);
|
|
255
326
|
});
|
|
256
327
|
|
|
257
|
-
it("worktree remove 失败时 branch -D + 注册表移除仍执行(best-effort 分离)", () => {
|
|
258
|
-
// 第一条 git(worktree remove
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
return "";
|
|
328
|
+
it("worktree remove 失败时 branch -D + 注册表移除仍执行(best-effort 分离)", async () => {
|
|
329
|
+
// 第一条 git(worktree remove)失败,第二条(branch -D)应仍执行
|
|
330
|
+
setupExecFile((args) => {
|
|
331
|
+
if (args[1] === "remove") {
|
|
332
|
+
return { err: Object.assign(new Error("Command failed: git worktree remove"), { code: 128 }), stderr: "fatal: worktree locked" };
|
|
333
|
+
}
|
|
334
|
+
return { stdout: "" };
|
|
264
335
|
});
|
|
265
336
|
const handle = makeHandle();
|
|
266
337
|
|
|
267
338
|
// 不应抛错
|
|
268
|
-
expect(
|
|
339
|
+
await expect(mgr.cleanup(handle)).resolves.toBeUndefined();
|
|
269
340
|
|
|
270
341
|
// branch -D 仍被调用
|
|
271
|
-
expect(
|
|
342
|
+
expect(mockExecFile).toHaveBeenCalledWith(
|
|
272
343
|
"git",
|
|
273
344
|
["branch", "-D", handle.branch],
|
|
274
345
|
expect.anything(),
|
|
346
|
+
expect.anything(),
|
|
275
347
|
);
|
|
276
348
|
// 注册表仍被移除
|
|
277
349
|
expect(mockRemove).toHaveBeenCalledWith(handle.branch);
|
|
@@ -279,141 +351,279 @@ describe("WorktreeManager", () => {
|
|
|
279
351
|
});
|
|
280
352
|
|
|
281
353
|
describe("collectPatch", () => {
|
|
282
|
-
it("有改动返回 patch 文件", () => {
|
|
283
|
-
|
|
354
|
+
it("有改动返回 patch 文件", async () => {
|
|
355
|
+
setupExecFile((args) => (args[0] === "diff" ? { stdout: "diff --git a/src\n+// new" } : { stdout: "" }));
|
|
284
356
|
|
|
285
357
|
const handle = makeHandle();
|
|
286
358
|
const patchFile = path.join(os.tmpdir(), `outside-${RECORD_ID}.patch`);
|
|
287
359
|
|
|
288
|
-
const result = mgr.collectPatch(handle, patchFile);
|
|
360
|
+
const result = await mgr.collectPatch(handle, patchFile);
|
|
289
361
|
|
|
290
362
|
expect(result.failed).toBe(false);
|
|
291
363
|
expect(result.written).toBe(true);
|
|
292
|
-
expect(
|
|
293
|
-
expect(
|
|
364
|
+
expect(mockExecFile).toHaveBeenCalledWith("git", ["add", "-A"], expect.anything(), expect.anything());
|
|
365
|
+
expect(mockExecFile).toHaveBeenCalledWith(
|
|
294
366
|
"git",
|
|
295
367
|
["diff", "--cached", BASE_COMMIT],
|
|
296
368
|
expect.anything(),
|
|
369
|
+
expect.anything(),
|
|
297
370
|
);
|
|
298
371
|
});
|
|
299
372
|
|
|
300
|
-
it("无改动返回 failed=false, written=false(不写文件)", () => {
|
|
301
|
-
|
|
302
|
-
const result = mgr.collectPatch(makeHandle(), "/tmp/x.patch");
|
|
373
|
+
it("无改动返回 failed=false, written=false(不写文件)", async () => {
|
|
374
|
+
setupExecFile();
|
|
375
|
+
const result = await mgr.collectPatch(makeHandle(), "/tmp/x.patch");
|
|
303
376
|
expect(result.failed).toBe(false);
|
|
304
377
|
expect(result.written).toBe(false);
|
|
305
378
|
});
|
|
379
|
+
|
|
380
|
+
it("patch 内容 = git diff stdout 原文(含尾部换行不被裁剪),git apply --check 兼容", async () => {
|
|
381
|
+
// 真实 git diff 输出以 \n 结尾;旧实现 gitRunAsync resolve(stdout.trim())
|
|
382
|
+
// 裁掉尾换行 → 落盘 patch 在干净副本上 `git apply --check` 报
|
|
383
|
+
// "corrupt patch at line N"(2026-08-16 门 4 实测,exit 128)
|
|
384
|
+
const rawDiff =
|
|
385
|
+
"diff --git a/data.txt b/data.txt\n" +
|
|
386
|
+
"index c690e0d..f858a88 100644\n" +
|
|
387
|
+
"--- a/data.txt\n" +
|
|
388
|
+
"+++ b/data.txt\n" +
|
|
389
|
+
"@@ -1 +1,2 @@\n" +
|
|
390
|
+
" base content\n" +
|
|
391
|
+
"+appended\n";
|
|
392
|
+
setupExecFile((args) => (args[0] === "diff" ? { stdout: rawDiff } : { stdout: "" }));
|
|
393
|
+
|
|
394
|
+
await mgr.collectPatch(makeHandle(), "/tmp/x.patch");
|
|
395
|
+
|
|
396
|
+
// 写盘字节与 diff stdout 逐字节一致(保真 = 不裁剪两端空白)
|
|
397
|
+
const written = vi.mocked(fs.writeFileSync).mock.calls[0][1] as string;
|
|
398
|
+
expect(written).toBe(rawDiff);
|
|
399
|
+
expect(written.endsWith("\n")).toBe(true);
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// ============================================================
|
|
404
|
+
// gitRunAsync 包装与 per-repo mutex(Phase 1 异步化新增)
|
|
405
|
+
// ============================================================
|
|
406
|
+
describe("gitRunAsync(经 collectPatch/cleanup 公共面触达)", () => {
|
|
407
|
+
it("非零退出:message 同构 + exitCode/stderr 属性(P-errshape:stderr 在 callback 第三参)", async () => {
|
|
408
|
+
setupExecFile((args) => {
|
|
409
|
+
if (args[0] === "diff") {
|
|
410
|
+
return {
|
|
411
|
+
err: Object.assign(new Error("Command failed: git diff --cached abc123def456\nfatal: bad object"), { code: 128 }),
|
|
412
|
+
stderr: "fatal: bad object",
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
return { stdout: "" };
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// diff 无 try/catch,直接向上抛(与旧同步 gitRun 行为一致)
|
|
419
|
+
const err = await mgr.collectPatch(makeHandle(), "/tmp/x.patch").catch((e: unknown) => e);
|
|
420
|
+
expect(err).toBeInstanceOf(Error);
|
|
421
|
+
expect((err as Error).message).toContain("git diff failed: Command failed: git diff --cached");
|
|
422
|
+
expect((err as { exitCode?: number }).exitCode).toBe(128);
|
|
423
|
+
expect((err as { stderr?: string }).stderr).toBe("fatal: bad object");
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it("超时路径:killed+SIGTERM 标记 timedOut,不挂误导性 exitCode", async () => {
|
|
427
|
+
setupExecFile((args) => {
|
|
428
|
+
if (args[0] === "diff") {
|
|
429
|
+
return { err: Object.assign(new Error("Command failed: git diff --cached"), { killed: true, signal: "SIGTERM", code: null }) };
|
|
430
|
+
}
|
|
431
|
+
return { stdout: "" };
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
const err = await mgr.collectPatch(makeHandle(), "/tmp/x.patch").catch((e: unknown) => e);
|
|
435
|
+
expect((err as Error).message).toContain("git diff failed:");
|
|
436
|
+
expect((err as { timedOut?: boolean }).timedOut).toBe(true);
|
|
437
|
+
expect((err as { exitCode?: number }).exitCode).toBeUndefined();
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("per-repo mutex:同 repo 写类命令串行(最大并发 1),全部完成无饥饿", async () => {
|
|
441
|
+
let active = 0;
|
|
442
|
+
let maxActive = 0;
|
|
443
|
+
// 异步完成 cb:若队列失效,后继命令的 impl 入口会先于前驱 cb 到达 → active=2
|
|
444
|
+
mockExecFile.mockImplementation(
|
|
445
|
+
(_cmd: string, args: readonly string[], _opts: unknown, cb: (err: Error | null, stdout?: string, stderr?: string) => void) => {
|
|
446
|
+
active++;
|
|
447
|
+
maxActive = Math.max(maxActive, active);
|
|
448
|
+
setTimeout(() => {
|
|
449
|
+
active--;
|
|
450
|
+
cb(null, "", "");
|
|
451
|
+
}, 5);
|
|
452
|
+
void args;
|
|
453
|
+
},
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
const distinct = [1, 2, 3].map((i) =>
|
|
457
|
+
Object.freeze({ ...makeHandle(path.join(os.tmpdir(), `pi-sub-mut-${i}`)), branch: `pi-sub-mut-${i}` }),
|
|
458
|
+
);
|
|
459
|
+
await Promise.all(distinct.map((h) => mgr.cleanup(h)));
|
|
460
|
+
|
|
461
|
+
// 同 repo 6 条写命令(3×(remove+branch -D))全部执行(无饥饿)
|
|
462
|
+
const writeCalls = mockExecFile.mock.calls.filter(
|
|
463
|
+
(c) => (c[1]?.[0] === "worktree" && c[1]?.[1] === "remove") || (c[1]?.[0] === "branch" && c[1]?.[1] === "-D"),
|
|
464
|
+
);
|
|
465
|
+
expect(writeCalls).toHaveLength(6);
|
|
466
|
+
// 队列生效:任何时刻至多 1 条写命令在途
|
|
467
|
+
expect(maxActive).toBe(1);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it("mutex 前驱失败不传染后继:第一个 remove 失败,后续同 repo 写命令正常执行", async () => {
|
|
471
|
+
let removeCount = 0;
|
|
472
|
+
setupExecFile((args) => {
|
|
473
|
+
if (args[0] === "worktree" && args[1] === "remove") {
|
|
474
|
+
removeCount++;
|
|
475
|
+
if (removeCount === 1) {
|
|
476
|
+
return { err: Object.assign(new Error("Command failed: git worktree remove"), { code: 128 }) };
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return { stdout: "" };
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
const h1 = Object.freeze({ ...makeHandle(), branch: "pi-sub-fail-1" });
|
|
483
|
+
const h2 = Object.freeze({ ...makeHandle(), branch: "pi-sub-fail-2" });
|
|
484
|
+
await Promise.all([mgr.cleanup(h1), mgr.cleanup(h2)]);
|
|
485
|
+
|
|
486
|
+
// h2 的 remove 正常执行(第 2 次),h1/h2 的 branch -D 各自执行
|
|
487
|
+
expect(removeCount).toBe(2);
|
|
488
|
+
const branchCalls = mockExecFile.mock.calls.filter((c) => c[1]?.[0] === "branch" && c[1]?.[1] === "-D");
|
|
489
|
+
expect(branchCalls).toHaveLength(2);
|
|
490
|
+
// 两个注册表条目都移除(各自 cleanup 尾部执行)
|
|
491
|
+
expect(mockRemove).toHaveBeenCalledWith("pi-sub-fail-1");
|
|
492
|
+
expect(mockRemove).toHaveBeenCalledWith("pi-sub-fail-2");
|
|
493
|
+
});
|
|
306
494
|
});
|
|
307
495
|
|
|
308
496
|
// ============================================================
|
|
309
497
|
// scan:全局注册表 + pid 死活判据(核心重构)
|
|
310
498
|
// ============================================================
|
|
311
499
|
describe("scan", () => {
|
|
312
|
-
it("pid 已死的条目被清理(正常退出未 cleanup / 崩溃残留)", () => {
|
|
500
|
+
it("pid 已死的条目被清理(正常退出未 cleanup / 崩溃残留)", async () => {
|
|
501
|
+
setupExecFile();
|
|
313
502
|
injectEntry({ branch: "pi-sub-dead", pid: 11111 });
|
|
314
503
|
mockIsProcessAlive.mockReturnValue(false); // pid 死
|
|
315
504
|
|
|
316
|
-
mgr.scan();
|
|
505
|
+
await mgr.scan();
|
|
317
506
|
|
|
318
|
-
expect(
|
|
507
|
+
expect(mockExecFile).toHaveBeenCalledWith(
|
|
319
508
|
"git",
|
|
320
509
|
["worktree", "remove", "--force", expect.any(String)],
|
|
321
510
|
expect.objectContaining({ cwd: MAIN_CWD }),
|
|
511
|
+
expect.anything(),
|
|
322
512
|
);
|
|
323
|
-
expect(
|
|
513
|
+
expect(mockExecFile).toHaveBeenCalledWith(
|
|
324
514
|
"git",
|
|
325
515
|
["branch", "-D", "pi-sub-dead"],
|
|
326
516
|
expect.objectContaining({ cwd: MAIN_CWD }),
|
|
517
|
+
expect.anything(),
|
|
327
518
|
);
|
|
328
519
|
expect(mockRemove).toHaveBeenCalledWith("pi-sub-dead");
|
|
329
520
|
});
|
|
330
521
|
|
|
331
|
-
it("pid 活的条目不删(绝不删活进程)", () => {
|
|
332
|
-
|
|
522
|
+
it("pid 活的条目不删(绝不删活进程)", async () => {
|
|
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 });
|
|
333
531
|
mockIsProcessAlive.mockReturnValue(true); // pid 活
|
|
532
|
+
mockExistsSync.mockImplementation((p: unknown) => String(p) === checkout);
|
|
334
533
|
|
|
335
|
-
mgr.scan();
|
|
534
|
+
await mgr.scan();
|
|
336
535
|
|
|
337
|
-
const removeCalls =
|
|
536
|
+
const removeCalls = mockExecFile.mock.calls.filter(
|
|
338
537
|
(c) => c[1]?.[0] === "worktree" && c[1]?.[1] === "remove",
|
|
339
538
|
);
|
|
340
539
|
expect(removeCalls).toHaveLength(0);
|
|
341
540
|
expect(mockRemove).not.toHaveBeenCalled();
|
|
342
541
|
});
|
|
343
542
|
|
|
344
|
-
it("崩溃残留:无终态 + pid 死 → 清理(原 P0 缺陷场景)", () => {
|
|
543
|
+
it("崩溃残留:无终态 + pid 死 → 清理(原 P0 缺陷场景)", async () => {
|
|
345
544
|
// 这是原 reaper 永远泄漏的场景:进程崩溃无人写终态 marker
|
|
545
|
+
setupExecFile();
|
|
346
546
|
injectEntry({ branch: "pi-sub-crash", pid: 33333 });
|
|
347
547
|
mockIsProcessAlive.mockReturnValue(false); // 崩溃后进程已死
|
|
348
548
|
|
|
349
|
-
mgr.scan();
|
|
549
|
+
await mgr.scan();
|
|
350
550
|
|
|
351
|
-
expect(
|
|
551
|
+
expect(mockExecFile).toHaveBeenCalledWith(
|
|
352
552
|
"git",
|
|
353
553
|
["branch", "-D", "pi-sub-crash"],
|
|
354
554
|
expect.anything(),
|
|
555
|
+
expect.anything(),
|
|
355
556
|
);
|
|
356
557
|
expect(mockRemove).toHaveBeenCalledWith("pi-sub-crash");
|
|
357
558
|
});
|
|
358
559
|
|
|
359
|
-
it("pid=0 + 未超 SPAWN_GRACE → 跳过(可能正在 spawn)", () => {
|
|
560
|
+
it("pid=0 + 未超 SPAWN_GRACE → 跳过(可能正在 spawn)", async () => {
|
|
561
|
+
setupExecFile();
|
|
360
562
|
injectEntry({ branch: "pi-sub-spawning", pid: 0, createdAt: Date.now() });
|
|
361
563
|
|
|
362
|
-
mgr.scan();
|
|
564
|
+
await mgr.scan();
|
|
363
565
|
|
|
364
|
-
const removeCalls =
|
|
566
|
+
const removeCalls = mockExecFile.mock.calls.filter(
|
|
365
567
|
(c) => c[1]?.[0] === "worktree" && c[1]?.[1] === "remove",
|
|
366
568
|
);
|
|
367
569
|
expect(removeCalls).toHaveLength(0);
|
|
368
570
|
});
|
|
369
571
|
|
|
370
|
-
it("pid=0 + 超 SPAWN_GRACE → 清理(create 后崩溃)", () => {
|
|
572
|
+
it("pid=0 + 超 SPAWN_GRACE → 清理(create 后崩溃)", async () => {
|
|
573
|
+
setupExecFile();
|
|
371
574
|
const sixtyOneMinAgo = Date.now() - 61 * 60 * 1000;
|
|
372
575
|
injectEntry({ branch: "pi-sub-spawn-crash", pid: 0, createdAt: sixtyOneMinAgo });
|
|
373
576
|
|
|
374
|
-
mgr.scan();
|
|
577
|
+
await mgr.scan();
|
|
375
578
|
|
|
376
|
-
expect(
|
|
579
|
+
expect(mockExecFile).toHaveBeenCalledWith(
|
|
377
580
|
"git",
|
|
378
581
|
["branch", "-D", "pi-sub-spawn-crash"],
|
|
379
582
|
expect.anything(),
|
|
583
|
+
expect.anything(),
|
|
380
584
|
);
|
|
381
585
|
expect(mockRemove).toHaveBeenCalledWith("pi-sub-spawn-crash");
|
|
382
586
|
});
|
|
383
587
|
|
|
384
|
-
it("跨 repo 清理:条目 repo 字段作为 git -C 目标", () => {
|
|
588
|
+
it("跨 repo 清理:条目 repo 字段作为 git -C 目标", async () => {
|
|
589
|
+
setupExecFile();
|
|
385
590
|
const OTHER_REPO = "/home/user/other-repo";
|
|
386
591
|
injectEntry({ branch: "pi-sub-cross", pid: 44444, repo: OTHER_REPO });
|
|
387
592
|
mockIsProcessAlive.mockReturnValue(false);
|
|
388
593
|
|
|
389
|
-
mgr.scan();
|
|
594
|
+
await mgr.scan();
|
|
390
595
|
|
|
391
596
|
// git 命令的 cwd 应为 OTHER_REPO,非 MAIN_CWD
|
|
392
|
-
expect(
|
|
597
|
+
expect(mockExecFile).toHaveBeenCalledWith(
|
|
393
598
|
"git",
|
|
394
599
|
expect.arrayContaining(["branch", "-D", "pi-sub-cross"]),
|
|
395
600
|
expect.objectContaining({ cwd: OTHER_REPO }),
|
|
601
|
+
expect.anything(),
|
|
396
602
|
);
|
|
397
603
|
});
|
|
398
604
|
|
|
399
|
-
it("空注册表时无操作", () => {
|
|
605
|
+
it("空注册表时无操作", async () => {
|
|
606
|
+
setupExecFile();
|
|
400
607
|
// registryEntries 已在 beforeEach 清空
|
|
401
|
-
mgr.scan();
|
|
402
|
-
expect(
|
|
608
|
+
await mgr.scan();
|
|
609
|
+
expect(mockExecFile).not.toHaveBeenCalled();
|
|
403
610
|
expect(mockRemove).not.toHaveBeenCalled();
|
|
404
611
|
});
|
|
405
612
|
|
|
406
|
-
it("worktree remove 失败时 branch -D + 注册表移除仍执行(best-effort)", () => {
|
|
613
|
+
it("worktree remove 失败时 branch -D + 注册表移除仍执行(best-effort)", async () => {
|
|
614
|
+
setupExecFile((args) => {
|
|
615
|
+
if (args[0] === "worktree") {
|
|
616
|
+
return { err: Object.assign(new Error("Command failed: git worktree remove"), { code: 128 }) };
|
|
617
|
+
}
|
|
618
|
+
return { stdout: "" };
|
|
619
|
+
});
|
|
407
620
|
injectEntry({ branch: "pi-sub-stubborn", pid: 55555 });
|
|
408
621
|
mockIsProcessAlive.mockReturnValue(false);
|
|
409
|
-
mockExec.mockImplementation(() => {
|
|
410
|
-
throw new Error("worktree locked");
|
|
411
|
-
});
|
|
412
622
|
|
|
413
|
-
mgr.scan();
|
|
623
|
+
await mgr.scan();
|
|
414
624
|
|
|
415
625
|
// branch -D 仍被调用(两次 git 调用各自独立)
|
|
416
|
-
const branchDeleteCalls =
|
|
626
|
+
const branchDeleteCalls = mockExecFile.mock.calls.filter(
|
|
417
627
|
(c) => c[1]?.[0] === "branch" && c[1]?.[1] === "-D",
|
|
418
628
|
);
|
|
419
629
|
expect(branchDeleteCalls).toHaveLength(1);
|