@zhushanwen/pi-subagent-workflow 7.3.0 → 7.3.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-subagent-workflow",
3
- "version": "7.3.0",
3
+ "version": "7.3.2",
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.",
@@ -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,206 @@
1
+ // src/execution/__tests__/spawn-worktree-guidance.test.ts
2
+ //
3
+ // worktree 模式认知纠正提示注入测试。
4
+ //
5
+ // 背景:worktree checkout 放 os.tmpdir()(路径形似临时沙箱),子 agent 无 worktree 语义
6
+ // 提示时误判 cwd 为"空隔离目录",主动 cd 别处放弃隔离(wave-agent 事故 session 019ff64c)。
7
+ // 修复在 runSpawn 的 appendParts 注入 WORKTREE_GUIDANCE_PROMPT。
8
+ //
9
+ // 验证:
10
+ // - worktree 模式(opts.worktree 传入)→ appendSystemPrompt content 含 WORKTREE_GUIDANCE_PROMPT
11
+ // - 非 worktree 模式 → content 不含 worktree 提示
12
+ //
13
+ // 测试策略:mock writePromptToTempFile 捕获拼接后的 appendParts content(vi.hoisted 防
14
+ // vi.mock hoisting 引用未初始化变量),断言含/不含 worktree 提示标识。
15
+
16
+ import type { PassThrough } from "node:stream";
17
+
18
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
19
+
20
+ // 捕获 writePromptToTempFile 的 content 参数(appendParts.join("\n\n"))。
21
+ // vi.hoisted 保证变量在 vi.mock factory hoisting 前已声明可用。
22
+ const captured = vi.hoisted(() => ({ content: "" }));
23
+
24
+ // ── mock modules(与 session-runner-schema-env.test.ts 同模式)──
25
+
26
+ vi.mock("node:child_process", async () => {
27
+ const { EventEmitter } = await import("node:events");
28
+ const { PassThrough } = await import("node:stream");
29
+
30
+ class FakeChild extends EventEmitter {
31
+ pid = 12345;
32
+ stdout = new PassThrough();
33
+ stderr = new PassThrough();
34
+ killed = false;
35
+ killSignal: string | undefined;
36
+ kill(sig?: string): boolean {
37
+ this.killed = true;
38
+ this.killSignal = sig;
39
+ return true;
40
+ }
41
+ }
42
+
43
+ return {
44
+ spawn: vi.fn(() => new FakeChild()),
45
+ execFileSync: vi.fn(() => ""),
46
+ };
47
+ });
48
+
49
+ vi.mock("node:fs", async () => {
50
+ const actual = await import("node:fs");
51
+ return {
52
+ default: {
53
+ ...actual,
54
+ mkdirSync: vi.fn(),
55
+ existsSync: vi.fn(() => false),
56
+ appendFileSync: vi.fn(),
57
+ writeFileSync: vi.fn(),
58
+ readdirSync: vi.fn(() => []),
59
+ },
60
+ mkdirSync: vi.fn(),
61
+ existsSync: vi.fn(() => false),
62
+ appendFileSync: vi.fn(),
63
+ writeFileSync: vi.fn(),
64
+ readdirSync: vi.fn(() => []),
65
+ promises: actual.promises,
66
+ };
67
+ });
68
+
69
+ vi.mock("../alive-store.ts", () => ({
70
+ writeAliveMarker: vi.fn(),
71
+ }));
72
+
73
+ vi.mock("../temp-prompt.ts", () => ({
74
+ writePromptToTempFile: vi.fn(async (agent: string, content: string) => {
75
+ captured.content = content;
76
+ const safeName = agent.replace(/[^\w.-]+/g, "_");
77
+ return { dir: `/tmp/fake-${safeName}`, filePath: `/tmp/fake-${safeName}/prompt.md` };
78
+ }),
79
+ cleanupTempPrompt: vi.fn(async () => {}),
80
+ }));
81
+
82
+ import { spawn } from "node:child_process";
83
+
84
+ import { createRecord } from "../execution-record.ts";
85
+ import { runSpawn, type RunOptions, type SessionRunnerContext } from "../session-runner.ts";
86
+
87
+ const mockSpawn = vi.mocked(spawn);
88
+
89
+ interface FakeChild {
90
+ pid: number;
91
+ stdout: PassThrough;
92
+ stderr: PassThrough;
93
+ killed: boolean;
94
+ killSignal: string | undefined;
95
+ kill(sig?: string): boolean;
96
+ emit(event: string, ...args: unknown[]): boolean;
97
+ }
98
+
99
+ function getLastSpawnedChild(): FakeChild {
100
+ const result = mockSpawn.mock.results.at(-1);
101
+ if (!result) throw new Error("spawn was not called yet");
102
+ return result.value as FakeChild;
103
+ }
104
+
105
+ async function waitForSpawn(timeoutMs = 1000): Promise<void> {
106
+ const start = Date.now();
107
+ while (mockSpawn.mock.results.length === 0) {
108
+ if (Date.now() - start > timeoutMs) {
109
+ throw new Error(`spawn was not called within ${timeoutMs}ms`);
110
+ }
111
+ await new Promise((r) => setTimeout(r, 5));
112
+ }
113
+ }
114
+
115
+ function makeRecord() {
116
+ return createRecord("wt-guidance-1", {
117
+ agent: "general-purpose",
118
+ model: "test/model",
119
+ mode: "sync",
120
+ task: "test task",
121
+ startedAt: Date.now(),
122
+ rootSessionId: "s1",
123
+ parentRecordId: undefined,
124
+ depth: 0,
125
+ });
126
+ }
127
+
128
+ function makeRunOpts(overrides: Partial<RunOptions> = {}): RunOptions {
129
+ return {
130
+ resolved: { model: { provider: "test", id: "model" }, thinkingLevel: undefined },
131
+ agentConfig: undefined,
132
+ appendSystemPrompt: undefined,
133
+ skillPath: undefined,
134
+ schema: undefined,
135
+ maxTurns: undefined,
136
+ graceTurns: undefined,
137
+ signal: undefined,
138
+ onEvent: undefined,
139
+ ...overrides,
140
+ };
141
+ }
142
+
143
+ function makeCtx(overrides: Partial<SessionRunnerContext> = {}): SessionRunnerContext {
144
+ return {
145
+ cwd: "/fake/cwd",
146
+ agentDir: "/fake/agent",
147
+ skillDirs: [],
148
+ mainCwd: "/fake/cwd",
149
+ sessionRootId: "root-session-test",
150
+ rootCwd: "/fake/cwd",
151
+ ...overrides,
152
+ };
153
+ }
154
+
155
+ describe("worktree guidance prompt injection", () => {
156
+ beforeEach(() => {
157
+ vi.clearAllMocks();
158
+ captured.content = "";
159
+ });
160
+
161
+ afterEach(() => {
162
+ vi.restoreAllMocks();
163
+ });
164
+
165
+ it("worktree 模式 → appendSystemPrompt content 含 worktree 认知纠正提示", async () => {
166
+ const record = makeRecord();
167
+ const opts = makeRunOpts({
168
+ worktree: {
169
+ path: "/tmp/pi-subagents/--fake--/pi-sub-sa-wt-guidance-1",
170
+ branch: "pi-sub-sa-wt-guidance-1",
171
+ baseCommit: "abc123",
172
+ mainCwd: "/fake/cwd",
173
+ },
174
+ });
175
+ const ctx = makeCtx();
176
+
177
+ const resultPromise = runSpawn(record, "test task", opts, ctx);
178
+ await waitForSpawn();
179
+
180
+ // content 含 WORKTREE_GUIDANCE_PROMPT 的标题
181
+ expect(captured.content).toContain("Git Worktree");
182
+ // 含关键纠正信息:cwd 含完整项目代码(非临时沙箱)
183
+ expect(captured.content).toContain("complete project source code");
184
+ // 含防 cd 别处的行为指引
185
+ expect(captured.content).toContain("Do NOT");
186
+
187
+ const child = getLastSpawnedChild();
188
+ child.emit("close", 0);
189
+ await resultPromise;
190
+ });
191
+
192
+ it("非 worktree 模式 → content 不含 worktree 认知提示", async () => {
193
+ const record = makeRecord();
194
+ const opts = makeRunOpts(); // 不传 worktree
195
+ const ctx = makeCtx();
196
+
197
+ const resultPromise = runSpawn(record, "test task", opts, ctx);
198
+ await waitForSpawn();
199
+
200
+ expect(captured.content).not.toContain("Git Worktree");
201
+
202
+ const child = getLastSpawnedChild();
203
+ child.emit("close", 0);
204
+ await resultPromise;
205
+ });
206
+ });
@@ -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
+ });
@@ -174,6 +174,30 @@ The \`ask_user\` tool is available in this session. When you call \`ask_user\`,
174
174
  - Use ask_user only when you genuinely cannot resolve ambiguity yourself (see tool description for guidelines)
175
175
  `.trim();
176
176
 
177
+ /**
178
+ * worktree 模式注入子 agent 的认知纠正提示。
179
+ *
180
+ * 背景:worktree checkout 放在 os.tmpdir()(如 `/private/var/folders/.../pi-subagents/.../pi-sub-<id>`),
181
+ * 路径形似临时沙箱。子 agent system prompt 无任何 worktree 语义说明时,会误判 cwd 为
182
+ * "空隔离目录",主动 cd 别处(如主 worktree)放弃隔离——实测见 wave-agent 事故
183
+ *(session 019ff64c T001 自述 cwd 是 pi 隔离目录,实际是合法 worktree checkout)。
184
+ *
185
+ * 此提示在 worktree 模式下注入,明确告知子 agent:cwd 是含完整项目代码的 git worktree,
186
+ * 直接在此工作即可,不要 cd 别处找"真正的项目"。
187
+ */
188
+ export const WORKTREE_GUIDANCE_PROMPT = `
189
+ ## Working Directory Is a Git Worktree
190
+
191
+ Your working directory (the "Working directory" in the environment block above) is a **dedicated git worktree** — an isolated checkout of the repository at HEAD, NOT a temporary sandbox. It contains the **complete project source code**.
192
+
193
+ **You should:**
194
+ - Work directly in your current cwd — it already has the full project (every file). Read project files via relative paths as usual.
195
+ - To locate the shared repository root: \`git rev-parse --git-common-dir\`.
196
+ - Your file changes are automatically captured as a patch when you finish — just do the work; no need to commit, push, or merge.
197
+
198
+ **Do NOT** \`cd\` to another directory looking for "the real project" — your cwd IS the project. A path like \`/private/var/folders/.../pi-subagents/.../pi-sub-<id>\` is your worktree checkout, not an empty sandbox.
199
+ `.trim();
200
+
177
201
  // ============================================================
178
202
  // 孤儿进程兜底(C1)
179
203
  // ============================================================
@@ -662,6 +686,11 @@ export async function runSpawn(
662
686
  if (opts.agentConfig?.tools?.includes("ask_user") && willRespondToAskUser(ctx.mode)) {
663
687
  appendParts.push(ASK_USER_RPC_PROMPT);
664
688
  }
689
+ // worktree 认知纠正:告知子 agent cwd 是 git worktree(非临时沙箱),含完整项目代码,
690
+ // 直接在此工作。防 wave-agent 类误判 cwd 为空隔离目录后 cd 主 worktree 放弃隔离。
691
+ if (opts.worktree) {
692
+ appendParts.push(WORKTREE_GUIDANCE_PROMPT);
693
+ }
665
694
  if (appendParts.length > 0) {
666
695
  tempPromptFile = await writePromptToTempFile(record.agent, appendParts.join("\n\n"));
667
696
  }
@@ -723,6 +752,14 @@ export async function runSpawn(
723
752
  env: childEnv,
724
753
  });
725
754
  proc = child;
755
+ // [worktree-reaper-fix] 同步补全注册表 pid:spawn 返回后 child.pid 立即可得(Node.js
756
+ // 同步属性),无需等任何 stdout 事件。原补全点挂在 header 分支(下方 stdout handler 内),
757
+ // 而 RPC mode(buildSpawnArgs 固定 --mode rpc)不输出 header 行——pid 恒为 0,超
758
+ // SPAWN_GRACE_MS 后被 reaper 当孤儿误删活 worktree(2026-08-11 cw 递归编排整树失活事故)。
759
+ // header 分支调用保留:json mode 回切时仍能补全,updatePid 同 branch 覆盖写幂等,无副作用。
760
+ if (opts.worktree && child.pid) {
761
+ ctx.onWorktreePid?.(opts.worktree.branch, child.pid);
762
+ }
726
763
  // [C1] track 子进程供 dispose 兜底 kill(sync + background 均注册——sync 无 controller,
727
764
  // abortRunningControllers 跳过它,靠本 Set 兜底)。close/error 后移除(已退出无需再 kill)。
728
765
  spawnedChildren.add(child);
@@ -957,8 +994,13 @@ export async function runSpawn(
957
994
  });
958
995
  child.on("error", (err: Error) => {
959
996
  // spawn 本身失败(command not found 等)
997
+ // [worktree-reaper-fix] 拼 spawnCwd 进错误消息:ENOENT 的 err.message 只含 command 名,
998
+ // 无 cwd 线索(worktree 被 reaper 误删后 cwd 指向虚空)会导致误诊——2026-08-11 事故
999
+ // AI 误判"node 被卸载"的直接原因。
960
1000
  spawnedChildren.delete(child);
961
- record.lastError = err.message;
1001
+ const errno = err as NodeJS.ErrnoException;
1002
+ const cwdHint = errno.code === "ENOENT" ? ` (cwd: ${spawnCwd})` : "";
1003
+ record.lastError = `${err.message}${cwdHint}`;
962
1004
  resolve(SIGNAL_EXIT_CODE_THRESHOLD); // 非零退出
963
1005
  });
964
1006
  });
@@ -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 占位)。session-runner first header 时补 pid。
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(session-runner first header 时调)。
128
- * create 时 pid 未知写 0 占位,子进程 spawn 拿到 pid 后由此补全。
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
- return now - entry.createdAt > SPAWN_GRACE_MS;
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(session-runner first header 时调)。
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
  }