@zhushanwen/pi-subagent-workflow 7.2.0 → 7.3.1
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 +1 -1
- package/src/execution/__tests__/manifest-parentid.test.ts +117 -0
- package/src/execution/__tests__/run-spawn-integration.test.ts +18 -0
- package/src/execution/__tests__/worktree-pid-registration.integration.test.ts +225 -0
- package/src/execution/finalize-record.ts +1 -0
- package/src/execution/manifest-store.ts +6 -1
- package/src/execution/session-runner.ts +14 -1
- package/src/execution/worktree-manager.ts +17 -4
- package/src/execution/worktree-registry.ts +13 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-subagent-workflow",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.3.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"description": "Unified subagent execution and multi-agent workflow orchestration for Pi — spawned-process agent runtime with sync/background modes, stateful workflow management with persistence, state machine, and execution tracing.",
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// ManifestStore — parentRecordId 落盘测试(M3a)。
|
|
2
|
+
//
|
|
3
|
+
// 验证 M3a 三条契约:
|
|
4
|
+
// 1. 新 record 落盘含 parentRecordId(depth>=1 subagent 读回直接父 record id)
|
|
5
|
+
// 2. 旧 manifest JSON(无 parentRecordId 字段)readManifest 仍有效(向后兼容)
|
|
6
|
+
// 3. isValidManifest 校验不改(5 必填不变,parentRecordId optional)
|
|
7
|
+
//
|
|
8
|
+
// isValidManifest 私有未 export——通过 readManifest 等价验证:readManifest 内部
|
|
9
|
+
// `isValidManifest(parsed) ? parsed : null`,非 null ⟺ 校验通过。不 export 私有函数
|
|
10
|
+
// 避免扩大公共 API 表面(M3a C1 约束:不改 isValidManifest,仅 2 行代码改动)。
|
|
11
|
+
|
|
12
|
+
import * as fs from "node:fs";
|
|
13
|
+
import * as os from "node:os";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
16
|
+
|
|
17
|
+
import { ManifestStore, type ManifestRecord } from "../manifest-store.ts";
|
|
18
|
+
|
|
19
|
+
/** 构造最小合法 ManifestRecord(5 必填),optional 字段按 overrides 传入。 */
|
|
20
|
+
function makeBaseManifest(overrides: Partial<ManifestRecord> = {}): ManifestRecord {
|
|
21
|
+
return {
|
|
22
|
+
id: "rec-test",
|
|
23
|
+
rootSessionId: "session-main",
|
|
24
|
+
agentName: "worker",
|
|
25
|
+
status: "completed",
|
|
26
|
+
createdAt: 1000,
|
|
27
|
+
...overrides,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("ManifestStore — parentRecordId 落盘 (M3a)", () => {
|
|
32
|
+
let tmpDir: string;
|
|
33
|
+
let store: ManifestStore;
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "manifest-parentid-"));
|
|
37
|
+
store = new ManifestStore(tmpDir);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// ── TC-m3a-new-record-parentid ──
|
|
45
|
+
it("新 record 落盘含 parentRecordId(depth>=1 读回直接父 record id)", async () => {
|
|
46
|
+
const record = makeBaseManifest({
|
|
47
|
+
id: "rec-child",
|
|
48
|
+
parentRecordId: "sa-parent-1",
|
|
49
|
+
});
|
|
50
|
+
await store.writeManifest(record);
|
|
51
|
+
|
|
52
|
+
const readBack = await store.readManifest("rec-child");
|
|
53
|
+
expect(readBack).not.toBeNull();
|
|
54
|
+
expect(readBack?.parentRecordId).toBe("sa-parent-1");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("顶层 record(parentRecordId 缺失)落盘读回仍 undefined", async () => {
|
|
58
|
+
// 顶层 subagent parentRecordId=undefined(父是 main,main 无 record)
|
|
59
|
+
const record = makeBaseManifest({ id: "rec-top" });
|
|
60
|
+
await store.writeManifest(record);
|
|
61
|
+
|
|
62
|
+
const readBack = await store.readManifest("rec-top");
|
|
63
|
+
expect(readBack).not.toBeNull();
|
|
64
|
+
expect(readBack?.parentRecordId).toBeUndefined();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ── TC-m3a-old-manifest-compat ──
|
|
68
|
+
it("旧 manifest JSON(无 parentRecordId)readManifest 返有效(isValidManifest 通过)", async () => {
|
|
69
|
+
// 手写旧版本格式 manifest(无 parentRecordId 字段),模拟旧 subagent-workflow 写的磁盘文件
|
|
70
|
+
const oldManifest = {
|
|
71
|
+
id: "rec-old",
|
|
72
|
+
rootSessionId: "session-main",
|
|
73
|
+
agentName: "worker",
|
|
74
|
+
status: "completed",
|
|
75
|
+
createdAt: 2000,
|
|
76
|
+
// 无 parentRecordId —— 旧版本写的
|
|
77
|
+
};
|
|
78
|
+
fs.writeFileSync(
|
|
79
|
+
path.join(tmpDir, "rec-old.json"),
|
|
80
|
+
JSON.stringify(oldManifest),
|
|
81
|
+
"utf-8",
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const readBack = await store.readManifest("rec-old");
|
|
85
|
+
// readManifest 内 isValidManifest(parsed) ? parsed : null —— 非 null 即 5 必填校验通过
|
|
86
|
+
expect(readBack).not.toBeNull();
|
|
87
|
+
expect(readBack?.id).toBe("rec-old");
|
|
88
|
+
expect(readBack?.parentRecordId).toBeUndefined();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// ── TC-m3a-isvalidmanifest-unchanged ──
|
|
92
|
+
it("isValidManifest 不改:含/不含 parentRecordId 均通过(5 必填不变)", async () => {
|
|
93
|
+
// isValidManifest 私有,通过 readManifest 等价验证(非 null ⟺ 校验通过)。
|
|
94
|
+
// 含 parentRecordId —— 新格式
|
|
95
|
+
const withParent = makeBaseManifest({
|
|
96
|
+
id: "rec-with-parent",
|
|
97
|
+
parentRecordId: "sa-x",
|
|
98
|
+
});
|
|
99
|
+
await store.writeManifest(withParent);
|
|
100
|
+
expect(await store.readManifest("rec-with-parent")).not.toBeNull();
|
|
101
|
+
|
|
102
|
+
// 不含 parentRecordId —— 仅 5 必填(旧格式 / 顶层 record)
|
|
103
|
+
const minimal = {
|
|
104
|
+
id: "rec-minimal",
|
|
105
|
+
rootSessionId: "session-main",
|
|
106
|
+
agentName: "worker",
|
|
107
|
+
status: "completed",
|
|
108
|
+
createdAt: 3000,
|
|
109
|
+
};
|
|
110
|
+
fs.writeFileSync(
|
|
111
|
+
path.join(tmpDir, "rec-minimal.json"),
|
|
112
|
+
JSON.stringify(minimal),
|
|
113
|
+
"utf-8",
|
|
114
|
+
);
|
|
115
|
+
expect(await store.readManifest("rec-minimal")).not.toBeNull();
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -384,6 +384,24 @@ describe("runSpawn", () => {
|
|
|
384
384
|
expect(result.error).toContain("spawn ENOENT");
|
|
385
385
|
expect(record.lastError).toContain("spawn ENOENT");
|
|
386
386
|
});
|
|
387
|
+
|
|
388
|
+
it("[worktree-reaper-fix] ENOENT error 消息拼 spawnCwd(避免误诊 node 被卸载)", async () => {
|
|
389
|
+
const record = makeRecord();
|
|
390
|
+
const promise = runSpawn(record, "Task: enoent-cwd", makeOpts(), makeCtx());
|
|
391
|
+
|
|
392
|
+
await waitForSpawn();
|
|
393
|
+
const child = lastSpawnedChild();
|
|
394
|
+
|
|
395
|
+
// ENOENT 且带 code(Node spawn 失败的真实形态)——error handler 必须拼 spawnCwd
|
|
396
|
+
const err = Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" });
|
|
397
|
+
child.emit("error", err);
|
|
398
|
+
|
|
399
|
+
const result = await promise;
|
|
400
|
+
|
|
401
|
+
expect(result.success).toBe(false);
|
|
402
|
+
// makeCtx().cwd = "/tmp/test",无 worktree 时 spawnCwd = ctx.cwd
|
|
403
|
+
expect(record.lastError).toContain("/tmp/test");
|
|
404
|
+
});
|
|
387
405
|
});
|
|
388
406
|
|
|
389
407
|
// ── 7. identity 补写 ──
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// src/execution/__tests__/worktree-pid-registration.integration.test.ts
|
|
2
|
+
//
|
|
3
|
+
// [worktree-reaper-fix] 端到端集成测试:验证 worktree pid 注册链路(接线层)。
|
|
4
|
+
//
|
|
5
|
+
// 背景:2026-08-11 生产事故——reaper 误清活 worktree。根因:pid 补全代码唯一生产调用点
|
|
6
|
+
// 挂在 session-runner 的 header 分支(RPC mode 永不触发),注册表 pid 恒为 0,超
|
|
7
|
+
// SPAWN_GRACE_MS(60s) 后任意 session_start 触发的 scan() 必然误删活 worktree。
|
|
8
|
+
// 修复:spawn() 返回后同步补 pid。
|
|
9
|
+
//
|
|
10
|
+
// 为什么用真实 spawn(而非现有 run-spawn-* 的 FakeChild mock):
|
|
11
|
+
// 现有测试全 mock registerPid(session-start-reaper/crash-recovery/index-session-start/
|
|
12
|
+
// stream-sink-guard),验证的是「mock 了补全回调后的 reaper 行为」,从未验证
|
|
13
|
+
// 「真实调用链中补全回调是否被调用」——接线错误零检测能力(结构性盲区)。
|
|
14
|
+
// 本测试走真实链路:真实 git repo + 真实 worktree 创建 + 真实 spawn node 子进程 +
|
|
15
|
+
// 真实注册表文件,仅 mock ./pi-invocation.ts(把 pi 二进制替换为 node -e 脚本)。
|
|
16
|
+
//
|
|
17
|
+
// mock 最小化原则:
|
|
18
|
+
// - node:child_process 不 mock(真实 spawn / execFileSync git)
|
|
19
|
+
// - node:fs 不 mock(真实目录/文件:worktree checkout、注册表 JSON)
|
|
20
|
+
// - alive-store 不 mock(真实 process.kill(pid, 0) 探活)
|
|
21
|
+
// - 仅 vi.mock("./pi-invocation.ts"):getPiInvocation 返回 node -e 脚本
|
|
22
|
+
// - fake timers 仅 toFake: ["Date"]:推进注册表宽限判定用,不干扰真实 I/O 事件
|
|
23
|
+
|
|
24
|
+
import { execFileSync } from "node:child_process";
|
|
25
|
+
import * as fs from "node:fs";
|
|
26
|
+
import * as os from "node:os";
|
|
27
|
+
import * as path from "node:path";
|
|
28
|
+
|
|
29
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
30
|
+
|
|
31
|
+
// vi.hoisted:vi.mock 工厂体内不能引用顶层 let/const(提升限制),脚本字符串必须放这。
|
|
32
|
+
// scriptHolder 是可变对象:getPiInvocation 每次调用时读它(工厂函数体在运行时执行),
|
|
33
|
+
// 用例内可切换长驻/短命脚本。
|
|
34
|
+
const { scriptHolder, LONG_RUNNING_SCRIPT, SHORT_LIVED_SCRIPT } = vi.hoisted(() => {
|
|
35
|
+
const scriptHolder: { script: string } = { script: "process.exit(0)" };
|
|
36
|
+
return {
|
|
37
|
+
scriptHolder,
|
|
38
|
+
// 长驻脚本:90s 后退出(测试在 61s scan 时它必须还活着,验证「活 worktree 不被清」)
|
|
39
|
+
LONG_RUNNING_SCRIPT:
|
|
40
|
+
"setTimeout(() => process.exit(0), 90000);",
|
|
41
|
+
// 短命脚本:立即退出(模拟快速完成的子 agent,验证「真孤儿被回收」)
|
|
42
|
+
SHORT_LIVED_SCRIPT: "process.exit(0)",
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
vi.mock("./pi-invocation.ts", () => ({
|
|
47
|
+
getPiInvocation: (userArgs: string[]) => ({
|
|
48
|
+
command: process.execPath,
|
|
49
|
+
args: ["-e", scriptHolder.script, ...userArgs],
|
|
50
|
+
}),
|
|
51
|
+
}));
|
|
52
|
+
|
|
53
|
+
import { WorktreeManager } from "../worktree-manager.ts";
|
|
54
|
+
import { WorktreeRegistry, SPAWN_GRACE_MS } from "../worktree-registry.ts";
|
|
55
|
+
import { runSpawn } from "../session-runner.ts";
|
|
56
|
+
import type { WorktreeHandle } from "../types.ts";
|
|
57
|
+
import { makeCtx, makeOpts, makeRecord } from "./helpers/spawn-mock.ts";
|
|
58
|
+
|
|
59
|
+
// ── 测试夹具:临时 git repo + 临时 agentDir(避免污染 ~/.pi/agent)──
|
|
60
|
+
|
|
61
|
+
let tmpRoot: string;
|
|
62
|
+
let repoDir: string;
|
|
63
|
+
let agentDir: string;
|
|
64
|
+
let wtm: WorktreeManager;
|
|
65
|
+
let registry: WorktreeRegistry;
|
|
66
|
+
let handle: WorktreeHandle | undefined;
|
|
67
|
+
let spawnedPid: number | undefined;
|
|
68
|
+
|
|
69
|
+
function git(args: string[], cwd: string): string {
|
|
70
|
+
return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 初始化临时 git repo(至少一个 commit,worktreeManager.create 需要 clean tree + HEAD)。 */
|
|
74
|
+
function initRepo(): void {
|
|
75
|
+
repoDir = path.join(tmpRoot, "repo");
|
|
76
|
+
fs.mkdirSync(repoDir, { recursive: true });
|
|
77
|
+
git(["init", "-b", "main"], repoDir);
|
|
78
|
+
git(["config", "user.email", "test@test.local"], repoDir);
|
|
79
|
+
git(["config", "user.name", "test"], repoDir);
|
|
80
|
+
git(["commit", "--allow-empty", "-m", "init"], repoDir);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 从注册表文件读指定 branch 的条目(真实文件,轮询用)。 */
|
|
84
|
+
function readEntry(branch: string): { pid: number; createdAt: number } | undefined {
|
|
85
|
+
return registry.load().find((e) => e.branch === branch);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 轮询注册表直到 pid 补全(真实 fs 读 + 真实 setTimeout 轮询)。 */
|
|
89
|
+
async function waitForPid(branch: string, timeoutMs = 5000): Promise<number> {
|
|
90
|
+
const start = Date.now();
|
|
91
|
+
while (Date.now() - start < timeoutMs) {
|
|
92
|
+
const entry = readEntry(branch);
|
|
93
|
+
if (entry && entry.pid !== 0) return entry.pid;
|
|
94
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
95
|
+
}
|
|
96
|
+
throw new Error(`pid not registered within ${timeoutMs}ms (branch=${branch})`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 清理:kill 子进程 + worktree cleanup + 删除临时目录。 */
|
|
100
|
+
function cleanup(): void {
|
|
101
|
+
if (spawnedPid) {
|
|
102
|
+
try {
|
|
103
|
+
process.kill(spawnedPid, "SIGKILL");
|
|
104
|
+
} catch {
|
|
105
|
+
// 已退出
|
|
106
|
+
}
|
|
107
|
+
spawnedPid = undefined;
|
|
108
|
+
}
|
|
109
|
+
if (handle) {
|
|
110
|
+
try {
|
|
111
|
+
wtm.cleanup(handle);
|
|
112
|
+
} catch (err) {
|
|
113
|
+
// best-effort:git worktree remove 失败不阻断测试清理
|
|
114
|
+
// eslint-disable-next-line no-console
|
|
115
|
+
console.warn("worktree cleanup failed in test teardown", err);
|
|
116
|
+
}
|
|
117
|
+
handle = undefined;
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
121
|
+
} catch {
|
|
122
|
+
// best-effort
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
beforeEach(() => {
|
|
127
|
+
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "wt-reaper-it-"));
|
|
128
|
+
agentDir = path.join(tmpRoot, "agent");
|
|
129
|
+
initRepo();
|
|
130
|
+
wtm = new WorktreeManager(agentDir);
|
|
131
|
+
registry = new WorktreeRegistry(agentDir);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
afterEach(() => {
|
|
135
|
+
vi.useRealTimers();
|
|
136
|
+
cleanup();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ── 用例 ──
|
|
140
|
+
|
|
141
|
+
describe("worktree pid 注册链路(真实 spawn 集成)", () => {
|
|
142
|
+
it("正向:spawn 返回后注册表 pid 同步补全,活 worktree 超宽限不被 scan 误清", async () => {
|
|
143
|
+
// 0. 长驻脚本(子进程 90s 内不退出,模拟长跑子 agent)
|
|
144
|
+
scriptHolder.script = LONG_RUNNING_SCRIPT; // 1. 真实创建 worktree(pid=0 占位)
|
|
145
|
+
handle = wtm.create(repoDir, "rec-1");
|
|
146
|
+
expect(readEntry(handle.branch)).toMatchObject({ pid: 0 });
|
|
147
|
+
|
|
148
|
+
// 2. runSpawn 挂后台(不 await——长驻子进程 close 不触发,await 会挂死),
|
|
149
|
+
// ctx.onWorktreePid 接真实 registerPid(模拟 subagent-service 接线)
|
|
150
|
+
const ctx = makeCtx({
|
|
151
|
+
agentDir,
|
|
152
|
+
cwd: repoDir,
|
|
153
|
+
mainCwd: repoDir,
|
|
154
|
+
rootCwd: repoDir,
|
|
155
|
+
onWorktreePid: (branch: string, pid: number) => wtm.registerPid(branch, pid),
|
|
156
|
+
});
|
|
157
|
+
const runPromise = runSpawn(
|
|
158
|
+
makeRecord(),
|
|
159
|
+
"test task",
|
|
160
|
+
makeOpts({ worktree: handle }),
|
|
161
|
+
ctx,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// 3. 断言 spawn 后 pid 已补全(真实注册表文件轮询)——修复前此步超时红
|
|
165
|
+
spawnedPid = await waitForPid(handle.branch);
|
|
166
|
+
expect(spawnedPid).toBeGreaterThan(0);
|
|
167
|
+
|
|
168
|
+
// 4. 推进时钟超 SPAWN_GRACE_MS(仅 fake Date,不干扰真实 I/O)
|
|
169
|
+
vi.useFakeTimers({ toFake: ["Date"] });
|
|
170
|
+
vi.setSystemTime(Date.now() + SPAWN_GRACE_MS + 1000);
|
|
171
|
+
|
|
172
|
+
// 5. scan():活 worktree 必须不被清(修复前:pid=0 超宽限 → 误删 → 红)
|
|
173
|
+
wtm.scan();
|
|
174
|
+
expect(fs.existsSync(handle.path)).toBe(true);
|
|
175
|
+
|
|
176
|
+
// 6. 收尾:真实时钟恢复 + kill 子进程让 runPromise settle
|
|
177
|
+
vi.useRealTimers();
|
|
178
|
+
try {
|
|
179
|
+
process.kill(spawnedPid, "SIGTERM");
|
|
180
|
+
} catch {
|
|
181
|
+
// 已退出
|
|
182
|
+
}
|
|
183
|
+
await runPromise;
|
|
184
|
+
|
|
185
|
+
// 7. 反向:进程死后 scan 回收真孤儿
|
|
186
|
+
wtm.scan();
|
|
187
|
+
expect(fs.existsSync(handle.path)).toBe(false);
|
|
188
|
+
const entryAfter = readEntry(handle.branch);
|
|
189
|
+
expect(entryAfter).toBeUndefined();
|
|
190
|
+
}, 15000);
|
|
191
|
+
|
|
192
|
+
it("反向:短命子进程退出后,pid>0 且进程死 → scan 立即回收", async () => {
|
|
193
|
+
// 0. 短命脚本(子进程立即退出,模拟快速完成的子 agent)
|
|
194
|
+
scriptHolder.script = SHORT_LIVED_SCRIPT;
|
|
195
|
+
// 1. 真实创建 worktree
|
|
196
|
+
handle = wtm.create(repoDir, "rec-2");
|
|
197
|
+
|
|
198
|
+
// 2. 短命脚本子进程:spawn 后同步补 pid(修复前 pid=0,且未超宽限 → 不回收 → 红)
|
|
199
|
+
const ctx = makeCtx({
|
|
200
|
+
agentDir,
|
|
201
|
+
cwd: repoDir,
|
|
202
|
+
mainCwd: repoDir,
|
|
203
|
+
rootCwd: repoDir,
|
|
204
|
+
onWorktreePid: (branch: string, pid: number) => wtm.registerPid(branch, pid),
|
|
205
|
+
});
|
|
206
|
+
const result = await runSpawn(
|
|
207
|
+
makeRecord(),
|
|
208
|
+
"test task",
|
|
209
|
+
makeOpts({ worktree: handle }),
|
|
210
|
+
ctx,
|
|
211
|
+
);
|
|
212
|
+
expect(result.status).not.toBe("error"); // 进程正常退出(exit 0),非 spawn 失败
|
|
213
|
+
|
|
214
|
+
// 3. pid 已补全(短命进程退出后 pid 仍有效,registerPid 同步执行不受退出影响)
|
|
215
|
+
const entry = readEntry(handle.branch);
|
|
216
|
+
expect(entry).toBeDefined();
|
|
217
|
+
expect(entry!.pid).toBeGreaterThan(0);
|
|
218
|
+
spawnedPid = entry!.pid;
|
|
219
|
+
|
|
220
|
+
// 4. scan:pid>0 且进程死 → 立即判孤儿回收(无需等宽限)
|
|
221
|
+
wtm.scan();
|
|
222
|
+
expect(fs.existsSync(handle.path)).toBe(false);
|
|
223
|
+
expect(readEntry(handle.branch)).toBeUndefined();
|
|
224
|
+
}, 15000);
|
|
225
|
+
});
|
|
@@ -144,6 +144,7 @@ export async function doFinalizeRecord(
|
|
|
144
144
|
await deps.manifestStore.writeManifest({
|
|
145
145
|
id: record.id,
|
|
146
146
|
rootSessionId: record.rootSessionId ?? "",
|
|
147
|
+
parentRecordId: record.parentRecordId,
|
|
147
148
|
agentName: record.agent,
|
|
148
149
|
status: status === "done" ? "completed" : status,
|
|
149
150
|
createdAt: record.startedAt,
|
|
@@ -7,6 +7,8 @@ import { bestEffort } from "./best-effort.ts";
|
|
|
7
7
|
export interface ManifestRecord {
|
|
8
8
|
id: string;
|
|
9
9
|
rootSessionId: string;
|
|
10
|
+
/** 直接父 subagent record ID(层级树构建用)。顶层 record 缺失(undefined)。M3a 补字段。 */
|
|
11
|
+
parentRecordId?: string;
|
|
10
12
|
agentName: string;
|
|
11
13
|
/**
|
|
12
14
|
* 终态枚举:finalizeRecord 写 running/completed/failed/cancelled 四态;cancelled 不再
|
|
@@ -25,6 +27,9 @@ export interface ManifestRecord {
|
|
|
25
27
|
model?: string;
|
|
26
28
|
}
|
|
27
29
|
|
|
30
|
+
/** JSON.stringify 缩进空格数(no-magic-numbers 合规)。 */
|
|
31
|
+
const MANIFEST_INDENT_SPACES = 2;
|
|
32
|
+
|
|
28
33
|
/** 合法 manifest status 集合(4 态;运行时守卫用,磁盘文件可能陈旧/损坏)。crashed 不在其中。 */
|
|
29
34
|
const VALID_MANIFEST_STATUSES: ReadonlySet<string> = new Set([
|
|
30
35
|
"running",
|
|
@@ -69,7 +74,7 @@ export class ManifestStore {
|
|
|
69
74
|
async writeManifest(record: ManifestRecord): Promise<void> {
|
|
70
75
|
const filePath = path.join(this.dir, `${record.id}.json`);
|
|
71
76
|
const tmpPath = `${filePath}.tmp.${process.pid}`;
|
|
72
|
-
const content = JSON.stringify(record, null,
|
|
77
|
+
const content = JSON.stringify(record, null, MANIFEST_INDENT_SPACES);
|
|
73
78
|
|
|
74
79
|
let renamed = false;
|
|
75
80
|
try {
|
|
@@ -723,6 +723,14 @@ export async function runSpawn(
|
|
|
723
723
|
env: childEnv,
|
|
724
724
|
});
|
|
725
725
|
proc = child;
|
|
726
|
+
// [worktree-reaper-fix] 同步补全注册表 pid:spawn 返回后 child.pid 立即可得(Node.js
|
|
727
|
+
// 同步属性),无需等任何 stdout 事件。原补全点挂在 header 分支(下方 stdout handler 内),
|
|
728
|
+
// 而 RPC mode(buildSpawnArgs 固定 --mode rpc)不输出 header 行——pid 恒为 0,超
|
|
729
|
+
// SPAWN_GRACE_MS 后被 reaper 当孤儿误删活 worktree(2026-08-11 cw 递归编排整树失活事故)。
|
|
730
|
+
// header 分支调用保留:json mode 回切时仍能补全,updatePid 同 branch 覆盖写幂等,无副作用。
|
|
731
|
+
if (opts.worktree && child.pid) {
|
|
732
|
+
ctx.onWorktreePid?.(opts.worktree.branch, child.pid);
|
|
733
|
+
}
|
|
726
734
|
// [C1] track 子进程供 dispose 兜底 kill(sync + background 均注册——sync 无 controller,
|
|
727
735
|
// abortRunningControllers 跳过它,靠本 Set 兜底)。close/error 后移除(已退出无需再 kill)。
|
|
728
736
|
spawnedChildren.add(child);
|
|
@@ -957,8 +965,13 @@ export async function runSpawn(
|
|
|
957
965
|
});
|
|
958
966
|
child.on("error", (err: Error) => {
|
|
959
967
|
// spawn 本身失败(command not found 等)
|
|
968
|
+
// [worktree-reaper-fix] 拼 spawnCwd 进错误消息:ENOENT 的 err.message 只含 command 名,
|
|
969
|
+
// 无 cwd 线索(worktree 被 reaper 误删后 cwd 指向虚空)会导致误诊——2026-08-11 事故
|
|
970
|
+
// AI 误判"node 被卸载"的直接原因。
|
|
960
971
|
spawnedChildren.delete(child);
|
|
961
|
-
|
|
972
|
+
const errno = err as NodeJS.ErrnoException;
|
|
973
|
+
const cwdHint = errno.code === "ENOENT" ? ` (cwd: ${spawnCwd})` : "";
|
|
974
|
+
record.lastError = `${err.message}${cwdHint}`;
|
|
962
975
|
resolve(SIGNAL_EXIT_CODE_THRESHOLD); // 非零退出
|
|
963
976
|
});
|
|
964
977
|
});
|
|
@@ -24,9 +24,12 @@ import { encodeCwd } from "./path-encoding.ts";
|
|
|
24
24
|
import type { PatchResult,WorktreeHandle } from "./types.ts";
|
|
25
25
|
import { DirtyWorktreeError } from "./types.ts";
|
|
26
26
|
import { bestEffort } from "./best-effort.ts";
|
|
27
|
+
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
27
28
|
import { isProcessAlive } from "./alive-store.ts";
|
|
28
29
|
import { SPAWN_GRACE_MS,type WorktreeEntry,WorktreeRegistry } from "./worktree-registry.ts";
|
|
29
30
|
|
|
31
|
+
const logger = getLogger("subagents");
|
|
32
|
+
|
|
30
33
|
// recordId 白名单:字母数字下划线短横线
|
|
31
34
|
const SAFE_ID_RE = /^[\w-]+$/;
|
|
32
35
|
|
|
@@ -80,7 +83,7 @@ export class WorktreeManager {
|
|
|
80
83
|
cwd: mainCwd,
|
|
81
84
|
});
|
|
82
85
|
|
|
83
|
-
// 注册到全局表(pid=0 占位)。
|
|
86
|
+
// 注册到全局表(pid=0 占位)。runSpawn 在 spawn() 返回后同步补 pid。
|
|
84
87
|
// 放在 worktree add 成功后、symlink 前——确保只有真正创建了 worktree 才登记。
|
|
85
88
|
this.registry.add({
|
|
86
89
|
repo: mainCwd,
|
|
@@ -124,8 +127,8 @@ export class WorktreeManager {
|
|
|
124
127
|
}
|
|
125
128
|
|
|
126
129
|
/**
|
|
127
|
-
* 注册子进程 pid(
|
|
128
|
-
* create 时 pid 未知写 0 占位,子进程 spawn
|
|
130
|
+
* 注册子进程 pid(runSpawn spawn() 返回后同步调)。
|
|
131
|
+
* create 时 pid 未知写 0 占位,子进程 spawn 返回后(child.pid 同步可得)由此补全。
|
|
129
132
|
* reaper 据 pid 死活判孤儿,pid=0 条目用 SPAWN_GRACE 宽限。
|
|
130
133
|
*/
|
|
131
134
|
registerPid(branch: string, pid: number): void {
|
|
@@ -227,7 +230,17 @@ export class WorktreeManager {
|
|
|
227
230
|
private isOrphan(entry: WorktreeEntry, now: number): boolean {
|
|
228
231
|
if (entry.pid === 0) {
|
|
229
232
|
// create→spawn 窗口:超过宽限期仍未补 pid = create 后崩溃
|
|
230
|
-
|
|
233
|
+
const expired = now - entry.createdAt > SPAWN_GRACE_MS;
|
|
234
|
+
if (expired) {
|
|
235
|
+
// [worktree-reaper-fix] pid=0 超宽限 = create 后 spawn 前崩溃(或补全链路再次断链)。
|
|
236
|
+
// 正常路径 spawn 返回后 pid 已同步补全,此处不应命中活 worktree;命中即诊断信号,
|
|
237
|
+
// 与 updatePid 写盘失败的 warn 日志呼应(补全失败可观测闭环)。
|
|
238
|
+
logger.warn(
|
|
239
|
+
"[worktree] orphan reaper: pid=0 entry exceeded SPAWN_GRACE_MS, treating as orphan",
|
|
240
|
+
{ branch: entry.branch, checkout: entry.checkout, createdAt: entry.createdAt, now },
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
return expired;
|
|
231
244
|
}
|
|
232
245
|
return !isProcessAlive(entry.pid);
|
|
233
246
|
}
|
|
@@ -21,6 +21,9 @@ import * as fs from "node:fs";
|
|
|
21
21
|
import * as path from "node:path";
|
|
22
22
|
|
|
23
23
|
import { bestEffort } from "./best-effort.ts";
|
|
24
|
+
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
25
|
+
|
|
26
|
+
const logger = getLogger("subagents");
|
|
24
27
|
|
|
25
28
|
/** create→spawn 宽限期(ms):pid=0 条目超过此阈值判 create 后崩溃。 */
|
|
26
29
|
export const SPAWN_GRACE_MS = 60_000;
|
|
@@ -84,7 +87,7 @@ export class WorktreeRegistry {
|
|
|
84
87
|
}
|
|
85
88
|
|
|
86
89
|
/**
|
|
87
|
-
* 更新 pid(
|
|
90
|
+
* 更新 pid(runSpawn spawn() 返回后同步调)。
|
|
88
91
|
* branch 不存在则忽略(create 后崩溃 + reaper 已清的竞态)。
|
|
89
92
|
*/
|
|
90
93
|
updatePid(branch: string, pid: number): void {
|
|
@@ -92,7 +95,7 @@ export class WorktreeRegistry {
|
|
|
92
95
|
const idx = entries.findIndex((e) => e.branch === branch);
|
|
93
96
|
if (idx >= 0) {
|
|
94
97
|
entries[idx] = { ...entries[idx], pid };
|
|
95
|
-
this.save(entries);
|
|
98
|
+
this.save(entries, { branch, pid });
|
|
96
99
|
}
|
|
97
100
|
}
|
|
98
101
|
|
|
@@ -130,8 +133,9 @@ export class WorktreeRegistry {
|
|
|
130
133
|
* 原子写入全部条目。
|
|
131
134
|
* best-effort:写入失败不阻断主流程(create/cleanup 的 git 操作已执行,
|
|
132
135
|
* 注册表与 git 状态的短暂不一致靠下次 reaper 对账收敛)。
|
|
136
|
+
* 写盘失败时 warn 日志(updatePid 路径带 branch/pid,补全失败可观测闭环)。
|
|
133
137
|
*/
|
|
134
|
-
private save(entries: WorktreeEntry[]): void {
|
|
138
|
+
private save(entries: WorktreeEntry[], context?: { branch: string; pid: number }): void {
|
|
135
139
|
try {
|
|
136
140
|
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
|
|
137
141
|
const tmp = `${this.filePath}.tmp`;
|
|
@@ -139,6 +143,12 @@ export class WorktreeRegistry {
|
|
|
139
143
|
fs.renameSync(tmp, this.filePath);
|
|
140
144
|
} catch (err) {
|
|
141
145
|
bestEffort(err, "worktree registry save");
|
|
146
|
+
// [worktree-reaper-fix] 补全写盘失败静默吞错时,条目 pid 恒 0、60s 后被 reaper 误删
|
|
147
|
+
// 活 worktree 且无诊断线索。此 warn 与 reaper scan 的 pid=0 warn 呼应,形成闭环。
|
|
148
|
+
logger.warn(
|
|
149
|
+
"[worktree] registry save failed; pid may stay 0 and be reaped by orphan reaper",
|
|
150
|
+
{ ...(context ?? {}), err: err instanceof Error ? err.message : String(err) },
|
|
151
|
+
);
|
|
142
152
|
}
|
|
143
153
|
}
|
|
144
154
|
}
|