@zhushanwen/pi-subagent-workflow 7.0.1 → 7.2.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.
@@ -0,0 +1,275 @@
1
+ // src/__tests__/recursive-visibility-env.test.ts
2
+ //
3
+ // 递归 subagent 跨层可见性:env 身份贯穿验证(设计 docs/design/recursive-subagent-visibility.md 场景 1b)。
4
+ //
5
+ // 验证 runSpawn 构造的 childEnv 含 4 个 PI_SUBAGENT_* 身份 env,值 = ctx.sessionRootId /
6
+ // record.id / String(record.depth) / ctx.rootCwd([MF-3] 第 4 个:ROOT cwd,落盘目录编码键)。覆盖 opts.fork=true 与 opts.fork=false 两种(决策 2 无条件注入)。
7
+ //
8
+ // 这是场景 1(端到端三层嵌套全树可见)的「env 传递机制」确定性验证——不依赖 LLM 配合,
9
+ // mock spawn 拦截 childEnv 直接断言。端到端可见性由场景 1(真实 pi CLI + recursive-worker agent)覆盖。
10
+
11
+ import type { PassThrough } from "node:stream";
12
+
13
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
14
+
15
+ import { getSubagentSessionDir } from "../path-encoding.ts";
16
+
17
+ // ── mock modules(同 session-runner-schema-env.test.ts 模式)──
18
+
19
+ vi.mock("node:child_process", async () => {
20
+ const { EventEmitter } = await import("node:events");
21
+ const { PassThrough } = await import("node:stream");
22
+
23
+ class FakeChild extends EventEmitter {
24
+ pid = 12345;
25
+ stdout = new PassThrough();
26
+ stderr = new PassThrough();
27
+ killed = false;
28
+ killSignal: string | undefined;
29
+ kill(sig?: string): boolean {
30
+ this.killed = true;
31
+ this.killSignal = sig;
32
+ return true;
33
+ }
34
+ }
35
+
36
+ return {
37
+ spawn: vi.fn(() => new FakeChild()),
38
+ execFileSync: vi.fn(() => ""),
39
+ };
40
+ });
41
+
42
+ vi.mock("node:fs", async () => {
43
+ const actual = await import("node:fs");
44
+ return {
45
+ default: {
46
+ ...actual,
47
+ mkdirSync: vi.fn(),
48
+ existsSync: vi.fn(() => false),
49
+ appendFileSync: vi.fn(),
50
+ writeFileSync: vi.fn(),
51
+ readdirSync: vi.fn(() => []),
52
+ },
53
+ mkdirSync: vi.fn(),
54
+ existsSync: vi.fn(() => false),
55
+ appendFileSync: vi.fn(),
56
+ writeFileSync: vi.fn(),
57
+ readdirSync: vi.fn(() => []),
58
+ promises: actual.promises,
59
+ };
60
+ });
61
+
62
+ vi.mock("../alive-store.ts", () => ({
63
+ writeAliveMarker: vi.fn(),
64
+ }));
65
+
66
+ vi.mock("../temp-prompt.ts", () => ({
67
+ writePromptToTempFile: vi.fn(async (agent: string) => {
68
+ const safeName = agent.replace(/[^\w.-]+/g, "_");
69
+ return { dir: `/tmp/fake-${safeName}`, filePath: `/tmp/fake-${safeName}/prompt-${safeName}.md` };
70
+ }),
71
+ cleanupTempPrompt: vi.fn(async () => {}),
72
+ }));
73
+
74
+ import { execFileSync, spawn } from "node:child_process";
75
+ import * as fs from "node:fs";
76
+
77
+ import { createRecord } from "../execution-record.ts";
78
+ import { type RunOptions, runSpawn, type SessionRunnerContext } from "../session-runner.ts";
79
+
80
+ const mockSpawn = vi.mocked(spawn);
81
+ const mockExec = vi.mocked(execFileSync);
82
+ const mockExistsSync = vi.mocked(fs.existsSync);
83
+
84
+ interface FakeChild {
85
+ pid: number;
86
+ stdout: PassThrough;
87
+ stderr: PassThrough;
88
+ killed: boolean;
89
+ killSignal: string | undefined;
90
+ kill(sig?: string): boolean;
91
+ emit(event: string, ...args: unknown[]): boolean;
92
+ }
93
+
94
+ function getLastSpawnedChild(): FakeChild {
95
+ const result = mockSpawn.mock.results.at(-1);
96
+ if (!result) throw new Error("spawn was not called yet");
97
+ return result.value as FakeChild;
98
+ }
99
+
100
+ function getLastSpawnEnv(): Record<string, string | undefined> {
101
+ return (mockSpawn.mock.calls.at(-1)?.[2]?.env as Record<string, string | undefined>) ?? {};
102
+ }
103
+
104
+ /** 等待 runSpawn 内部调到 spawn(async,spawn 在 writePromptToTempFile await 之后才调)。 */
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
+ // ── fixture ──
116
+
117
+ function makeRecord(overrides: { id?: string; depth?: number } = {}) {
118
+ return createRecord(overrides.id ?? "sa-test-record", {
119
+ agent: "general-purpose",
120
+ model: "test/model",
121
+ mode: "background",
122
+ task: "test task",
123
+ startedAt: Date.now(),
124
+ rootSessionId: "should-be-overridden-by-sessionRootId-source",
125
+ parentRecordId: undefined,
126
+ depth: overrides.depth ?? 0,
127
+ });
128
+ }
129
+
130
+ function makeRunOpts(overrides: Partial<RunOptions> = {}): RunOptions {
131
+ return {
132
+ resolved: { model: { provider: "test", id: "model" }, thinkingLevel: undefined },
133
+ agentConfig: undefined,
134
+ appendSystemPrompt: undefined,
135
+ skillPath: undefined,
136
+ schema: undefined,
137
+ maxTurns: undefined,
138
+ graceTurns: undefined,
139
+ signal: undefined,
140
+ onEvent: undefined,
141
+ ...overrides,
142
+ };
143
+ }
144
+
145
+ function makeCtx(overrides: Partial<SessionRunnerContext> = {}): SessionRunnerContext {
146
+ return {
147
+ cwd: "/fake/cwd",
148
+ agentDir: "/fake/agent",
149
+ skillDirs: [],
150
+ mainCwd: "/fake/cwd",
151
+ sessionRootId: "root-main-session",
152
+ rootCwd: "/fake/cwd",
153
+ ...overrides,
154
+ };
155
+ }
156
+
157
+ // ── runSpawn childEnv 身份 env 注入(场景 1b)──
158
+
159
+ describe("runSpawn 跨进程身份 env 注入(递归可见性场景 1b)", () => {
160
+ beforeEach(() => {
161
+ vi.clearAllMocks();
162
+ mockExistsSync.mockReturnValue(false);
163
+ mockExec.mockReturnValue("");
164
+ });
165
+
166
+ afterEach(() => {
167
+ vi.restoreAllMocks();
168
+ });
169
+
170
+ it("非 fork(fork=false/undefined):无条件注入 4 个身份 env(决策 2)", async () => {
171
+ const record = makeRecord({ id: "sa-aaa", depth: 0 });
172
+ const ctx = makeCtx({ sessionRootId: "root-main" });
173
+ const opts = makeRunOpts({ fork: false });
174
+
175
+ const resultPromise = runSpawn(record, "test task", opts, ctx);
176
+ await waitForSpawn();
177
+ const childEnv = getLastSpawnEnv();
178
+
179
+ expect(childEnv.PI_SUBAGENT_ROOT_SESSION_ID).toBe("root-main");
180
+ expect(childEnv.PI_SUBAGENT_SELF_RECORD_ID).toBe("sa-aaa");
181
+ expect(childEnv.PI_SUBAGENT_DEPTH).toBe("0");
182
+ // [MF-3] 第 4 个贯穿 env:ROOT cwd(子进程落盘目录编码键)
183
+ expect(childEnv.PI_SUBAGENT_ROOT_CWD).toBe("/fake/cwd");
184
+ // fork=false 不注入 fork depth env(与既有行为一致,本测试不改变它)
185
+ expect(childEnv.PI_SUBAGENT_FORK_DEPTH).toBeUndefined();
186
+
187
+ const child = getLastSpawnedChild();
188
+ child.emit("close", 0);
189
+ await resultPromise;
190
+ });
191
+
192
+ it("fork=true:4 个身份 env 与 fork depth env 共存(决策 2 无条件注入不依赖 fork)", async () => {
193
+ const record = makeRecord({ id: "sa-bbb", depth: 2 });
194
+ const ctx = makeCtx({ sessionRootId: "root-main" });
195
+ const opts = makeRunOpts({ fork: true, parentForkDepth: 1 });
196
+
197
+ const resultPromise = runSpawn(record, "test task", opts, ctx);
198
+ await waitForSpawn();
199
+ const childEnv = getLastSpawnEnv();
200
+
201
+ // 身份 env 无条件存在(决策 2)
202
+ expect(childEnv.PI_SUBAGENT_ROOT_SESSION_ID).toBe("root-main");
203
+ expect(childEnv.PI_SUBAGENT_SELF_RECORD_ID).toBe("sa-bbb");
204
+ expect(childEnv.PI_SUBAGENT_DEPTH).toBe("2");
205
+ expect(childEnv.PI_SUBAGENT_ROOT_CWD).toBe("/fake/cwd");
206
+ // fork depth env 同时存在(fork=true + parentForkDepth=1 → 2)
207
+ expect(childEnv.PI_SUBAGENT_FORK_DEPTH).toBe("2");
208
+
209
+ const child = getLastSpawnedChild();
210
+ child.emit("close", 0);
211
+ await resultPromise;
212
+ });
213
+
214
+ it("深层 record(depth=3):DEPTH env = String(record.depth),正确贯穿嵌套层级", async () => {
215
+ const record = makeRecord({ id: "sa-deep", depth: 3 });
216
+ const ctx = makeCtx({ sessionRootId: "root-topmost" });
217
+
218
+ const resultPromise = runSpawn(record, "test task", makeRunOpts(), ctx);
219
+ await waitForSpawn();
220
+ const childEnv = getLastSpawnEnv();
221
+
222
+ expect(childEnv.PI_SUBAGENT_DEPTH).toBe("3");
223
+ expect(childEnv.PI_SUBAGENT_SELF_RECORD_ID).toBe("sa-deep");
224
+ expect(childEnv.PI_SUBAGENT_ROOT_SESSION_ID).toBe("root-topmost");
225
+
226
+ const child = getLastSpawnedChild();
227
+ child.emit("close", 0);
228
+ await resultPromise;
229
+ });
230
+
231
+ it("ROOT_SESSION_ID 恒等于 ctx.sessionRootId(贯穿真 ROOT,非 record.rootSessionId)", async () => {
232
+ // record.rootSessionId 是 createRecord 时写入的值(可能来自旧逻辑),但 env 注入用的是
233
+ // ctx.sessionRootId(经 buildSessionRunnerContext 从 this.sessionRootId 透传,贯穿真 ROOT)。
234
+ // 这保证深层 subagent 的子进程仍归顶层 ROOT(设计决策 1/3)。
235
+ const record = makeRecord({ id: "sa-ccc" }); // record.rootSessionId = fixture 默认值
236
+ const ctx = makeCtx({ sessionRootId: "real-root-session" });
237
+
238
+ const resultPromise = runSpawn(record, "test task", makeRunOpts(), ctx);
239
+ await waitForSpawn();
240
+ const childEnv = getLastSpawnEnv();
241
+
242
+ // env 用 ctx.sessionRootId,不是 record.rootSessionId
243
+ expect(childEnv.PI_SUBAGENT_ROOT_SESSION_ID).toBe("real-root-session");
244
+ expect(childEnv.PI_SUBAGENT_ROOT_SESSION_ID).not.toBe(record.rootSessionId);
245
+
246
+ const child = getLastSpawnedChild();
247
+ child.emit("close", 0);
248
+ await resultPromise;
249
+ });
250
+
251
+ it("[MF-3 回归] worktree 模式(mainCwd=checkout ≠ rootCwd):sessionDir 用 ROOT cwd 编码,深层 record 落盘到 ROOT 可扫描段", async () => {
252
+ // 模拟 B(worktree 子进程)spawn C:ctx.cwd/mainCwd = checkout 路径,rootCwd = 真 ROOT cwd。
253
+ // 旧实现 sessionDir 用 ctx.mainCwd 编码 → enc(checkout) 段,ROOT 磁盘重建扫不到(MF-3)。
254
+ const rootCwd = "/root/project";
255
+ const checkoutPath = "/var/folders/worktree/pi-subagents/--root-project--/branch";
256
+ const agentDir = "/fake/agent";
257
+ const record = makeRecord({ id: "sa-deep", depth: 2 });
258
+ const ctx = makeCtx({ cwd: checkoutPath, mainCwd: checkoutPath, rootCwd, agentDir });
259
+
260
+ const resultPromise = runSpawn(record, "test task", makeRunOpts(), ctx);
261
+ await waitForSpawn();
262
+ const childEnv = getLastSpawnEnv();
263
+ const spawnArgs = mockSpawn.mock.calls.at(-1)?.[1] as string[];
264
+
265
+ // 第 4 个 env 贯穿 ROOT cwd
266
+ expect(childEnv.PI_SUBAGENT_ROOT_CWD).toBe(rootCwd);
267
+ // spawn --session-dir 指向 enc(ROOT cwd)(非 enc(checkout))
268
+ expect(spawnArgs).toContain(getSubagentSessionDir(agentDir, rootCwd));
269
+ expect(spawnArgs).not.toContain(getSubagentSessionDir(agentDir, checkoutPath));
270
+
271
+ const child = getLastSpawnedChild();
272
+ child.emit("close", 0);
273
+ await resultPromise;
274
+ });
275
+ });
@@ -104,7 +104,7 @@ interface FakeChild {
104
104
  function getLastSpawnedChild(): FakeChild {
105
105
  const result = mockSpawn.mock.results.at(-1);
106
106
  if (!result) throw new Error("spawn was not called yet");
107
- return result.value as unknown as FakeChild;
107
+ return result.value as FakeChild;
108
108
  }
109
109
 
110
110
  function getLastSpawnEnv(): Record<string, string | undefined> {
@@ -155,12 +155,15 @@ function makeRunOpts(overrides: Partial<RunOptions> = {}): RunOptions {
155
155
  };
156
156
  }
157
157
 
158
- function makeCtx(): SessionRunnerContext {
158
+ function makeCtx(overrides: Partial<SessionRunnerContext> = {}): SessionRunnerContext {
159
159
  return {
160
160
  cwd: "/fake/cwd",
161
161
  agentDir: "/fake/agent",
162
162
  skillDirs: [],
163
163
  mainCwd: "/fake/cwd",
164
+ sessionRootId: "root-session-test",
165
+ rootCwd: "/fake/cwd",
166
+ ...overrides,
164
167
  };
165
168
  }
166
169
 
@@ -385,24 +385,22 @@ describe("SubagentService", () => {
385
385
  });
386
386
 
387
387
  // ============================================================
388
- // execute() worktree fail-fast 校验 [MF#7](commit 8e8e75966)
388
+ // execute() worktree 路径(worktree fork 解耦后)
389
389
  // ============================================================
390
390
  //
391
- // [MF#7] execute 入口校验 `worktree:true && !fork` → fail-fast 抛错。
392
- // 否则下面三个 worktree 分支都不命中,worktreeHandle 恒 undefined → 子 agent
393
- // 零文件隔离且零报错(静默 no-op)。此组验证该校验的三种 fork/worktree 组合:
394
- // 1. worktree:true + fork:false → "requires fork"(fail-fast 命中)
395
- // 2. worktree:true + fork:true → 不命中校验(执行越过 guard,后续因副作用失败)
396
- // 3. worktree:false + fork:false → 不命中校验(默认路径,执行越过 guard
391
+ // worktree(文件隔离)与 fork(上下文继承)已解耦:worktree:true 可独立于 fork 工作
392
+ // (worktreeManager.create 只看 opts.worktree,不读 fork)。此组验证三种 fork/worktree
393
+ // 组合下 worktree 路径的行为(均不应抛 'requires fork'——该 guard 已移除):
394
+ // 1. worktree:true + fork:false → 解耦后正常(创建 worktree 路径,不抛 requires fork
395
+ // 2. worktree:true + fork:true → 创建 worktree 路径(测试环境 git 失败,抛非 requires fork 错)
396
+ // 3. worktree:false + fork:false → 默认路径(不创建 worktree
397
397
  //
398
- // 被测点是 execute() 入口的 guard(subagent-service.ts L277-282),在任何副作用
399
- // (record 创建 / worktree 创建 / spawn)之前。本文件不 mock spawn(保持与文件头
400
- // 声明一致——execute 集成测试在 execute-nesting / run-spawn-integration),
401
- // 因此 case 2/3 验证「guard 放行」而非「执行完成」:执行越过 guard 后在后续步骤
402
- // (worktreeManager.create 调 git / runSpawn 调 spawn)抛与 fork/worktree 无关的错。
403
- // try/catch 断言抛出的不是 guard 错误,精确锁住 guard 的触发条件。
404
-
405
- describe("execute() worktree fail-fast 校验 [MF#7]", () => {
398
+ // 本文件不 mock spawn(保持与文件头声明一致——execute 集成测试在 execute-nesting /
399
+ // run-spawn-integration),因此 case 验证「不抛 requires fork」而非「执行完成」:
400
+ // 执行越过 worktree 创建后在后续步骤(worktreeManager.create 调 git / runSpawn 调
401
+ // spawn)抛与 fork/worktree 无关的错。用 try/catch 断言抛出的不是 requires fork。
402
+
403
+ describe("execute() worktree 路径(worktree fork 解耦)", () => {
406
404
  /** 构造已就绪的 service(initSession + initModel 注入 ctxModel,使 resolveIdentity 不因 model 拗错)。 */
407
405
  function makeReadyService(): SubagentService {
408
406
  const service = new SubagentService({ cwd: agentDir, modelService });
@@ -422,24 +420,25 @@ describe("SubagentService", () => {
422
420
  return service;
423
421
  }
424
422
 
425
- it("worktree:true + fork:false → fail-fast 抛错含 'requires fork'(guard 命中)", async () => {
423
+ it("worktree:true + fork:false → 解耦后不抛 'requires fork'(worktree 独立于 fork)", async () => {
426
424
  const service = makeReadyService();
427
- // guard 在所有副作用之前:无 record 创建、无 spawn
428
- await expect(
429
- service.execute({
430
- task: "worktree without fork",
425
+ // 解耦后 worktree:true+fork:false 不再 throw requires fork(worktreeManager.create 只看 worktree)
426
+ try {
427
+ await service.execute({
428
+ task: "worktree without fork (decoupled)",
431
429
  worktree: true,
432
430
  fork: false,
433
431
  ctxModel: { id: "ctx-model", name: "Ctx", provider: "p", reasoning: false },
434
- }),
435
- ).rejects.toThrow(/requires fork/);
436
- // 无副作用:record 未创建(guard createRecordForMode 之前)
437
- expect(service.collectRecords(10)).toHaveLength(0);
432
+ });
433
+ } catch (err) {
434
+ // 解耦后绝不抛 requires fork(执行继续到 worktreeManager.create/spawn 才可能抛其他错)
435
+ expect((err as Error).message).not.toMatch(/requires fork/);
436
+ }
438
437
  });
439
438
 
440
- it("worktree:true + fork:true → 不命中 guard(执行越过 guard,不抛 'requires fork')", async () => {
439
+ it("worktree:true + fork:true → 创建 worktree 路径(不抛 'requires fork')", async () => {
441
440
  const service = makeReadyService();
442
- // guard 放行 → 执行继续:先创建 record,然后 worktreeManager.create 调 git(测试环境无 repo → 抛与 fork 无关的错)
441
+ // 执行继续:先创建 record,然后 worktreeManager.create 调 git(测试环境无 repo → 抛与 fork 无关的错)
443
442
  try {
444
443
  await service.execute({
445
444
  task: "worktree with fork",
@@ -454,9 +453,9 @@ describe("SubagentService", () => {
454
453
  }
455
454
  });
456
455
 
457
- it("worktree:false + fork:false → 不命中 guard(默认路径越过 guard,不抛 'requires fork')", async () => {
456
+ it("worktree:false + fork:false → 默认路径(不创建 worktree,不抛 'requires fork')", async () => {
458
457
  const service = makeReadyService();
459
- // guard 放行 → 执行继续:runSpawn 调 child_process.spawn(测试环境无真实 pi → 抛与 fork 无关的错)
458
+ // 默认路径:runSpawn 调 child_process.spawn(测试环境无真实 pi → 抛与 fork 无关的错)
460
459
  try {
461
460
  await service.execute({
462
461
  task: "default path",
@@ -179,6 +179,8 @@ function makeCtx(overrides: Partial<SessionRunnerContext> = {}): SessionRunnerCo
179
179
  skillDirs: [],
180
180
  mainCwd: "/tmp/test",
181
181
  mainSessionFile: undefined,
182
+ sessionRootId: "root-session-test",
183
+ rootCwd: "/tmp/test",
182
184
  ...overrides,
183
185
  };
184
186
  }
@@ -247,6 +247,32 @@ describe("cancelHandler", () => {
247
247
  await expect(cancelHandler(svc, { subagentId: "nope" })).rejects.toThrow(/No subagent record with id "nope"/);
248
248
  });
249
249
 
250
+ it("[S-19] id 属树内其他进程的 running record(磁盘可见、本进程内存无)→ throw 跨进程专属消息", async () => {
251
+ // MF-1 全树可见后:子进程 list 能看到父/兄弟进程的 running record,但 cancel 只作用于
252
+ // 本进程内存。旧消息「may have finished」误导(该 record 未 finished、正被列出)。
253
+ const foreign: SubagentRecord = {
254
+ id: "bg-foreign", agent: "w", status: "running", mode: "background", startedAt: 1,
255
+ endedAt: undefined, turns: 0, totalTokens: 0, model: "m", thinkingLevel: undefined, eventLog: [],
256
+ };
257
+ const svc = makeService({
258
+ findRecord: vi.fn(() => undefined),
259
+ collectRecords: vi.fn(() => [foreign] as SubagentRecord[]),
260
+ });
261
+ await expect(cancelHandler(svc, { subagentId: "bg-foreign" })).rejects.toThrow(/owned by another process in the tree/);
262
+ });
263
+
264
+ it("[S-19] id 在树内但已终态(磁盘可见 done)→ 仍用 may have finished 消息", async () => {
265
+ const done: SubagentRecord = {
266
+ id: "bg-done", agent: "w", status: "done", mode: "background", startedAt: 1,
267
+ endedAt: 2, turns: 0, totalTokens: 0, model: "m", thinkingLevel: undefined, eventLog: [],
268
+ };
269
+ const svc = makeService({
270
+ findRecord: vi.fn(() => undefined),
271
+ collectRecords: vi.fn(() => [done] as SubagentRecord[]),
272
+ });
273
+ await expect(cancelHandler(svc, { subagentId: "bg-done" })).rejects.toThrow(/may have finished/);
274
+ });
275
+
250
276
  it("已终态(cancel 返回 false)→ throw could not be cancelled", async () => {
251
277
  const svc = makeService({
252
278
  findRecord: vi.fn(() => makeSnapshot({ id: "bg-1", mode: "background", status: "done" })),
@@ -19,7 +19,7 @@ export interface BgNotifyRecord {
19
19
  error?: string;
20
20
  startedAt: number;
21
21
  endedAt: number | undefined;
22
- /** [MF#1] fork+worktree 模式下子 agent 改动的 patch 路径(worktree 外,cleanup 后留存)。
22
+ /** [MF#1] worktree 模式下子 agent 改动的 patch 路径(worktree 外,cleanup 后留存)。
23
23
  * done 时通知文本显式提示 `git apply`,否则 background 子 agent 在隔离 worktree 的改动
24
24
  * 会静默丢失——父 LLM 不知 patch 路径,无法应用。 */
25
25
  patchFile?: string;
@@ -21,9 +21,13 @@ export function encodeCwd(cwd: string): string {
21
21
  *
22
22
  * D-004: 用主 cwd 编码——保证同一主 cwd 下所有 subagent 的 session 文件
23
23
  * 存放在同一目录,便于 session-file-gc 统一清理。
24
+ * [MF-3] worktree 模式下调用方必须传树根 cwd(ROOT mainCwd,经 PI_SUBAGENT_ROOT_CWD
25
+ * 贯穿),不能传子进程自身的 checkout 路径——否则深层 record 落盘到 enc(worktree) 段,
26
+ * ROOT 磁盘重建扫不到(全树可见性断裂)。根进程传自身 cwd(行为不变)。
24
27
  *
25
28
  * @param agentDir agent 配置目录(如 ~/.pi/agent)
26
- * @param mainCwd agent 的工作目录(非 subagent 的 effectiveCwd
29
+ * @param mainCwd 树根主 agent 的工作目录(非 subagent 的 effectiveCwd;worktree 模式下
30
+ * 是 ROOT 的 cwd,不是 checkout 路径)
27
31
  * @returns session 持久化目录绝对路径
28
32
  */
29
33
  export function getSubagentSessionDir(agentDir: string, mainCwd: string): string {
@@ -37,14 +41,18 @@ export function getSubagentSessionDir(agentDir: string, mainCwd: string): string
37
41
  * 获取 subagent records(manifest)持久化目录路径。
38
42
  *
39
43
  * 与 getSubagentSessionDir 同用 encodeCwd(mainCwd),保证 records 与 sessions 在同一
40
- * <enc> 段下物理相邻——worktree 场景三者恒等(init.cwd /
41
- * buildSessionRunnerContext.mainCwd / record.worktreeHandle.mainCwd 指向同一主 cwd)。
44
+ * <enc> 段下物理相邻。
45
+ * [MF-3] 调用方必须与 getSubagentSessionDir 传同一编码键(进程内 init.cwd /
46
+ * buildSessionRunnerContext.mainCwd / record.worktreeHandle.mainCwd 恒等;worktree 模式
47
+ * 跨进程时统一用贯穿的 ROOT cwd)——sessions 与 records 两套目录同段,GC/重建才不会
48
+ * 互相找不到(enc 段不变量)。
42
49
  *
43
50
  * D-004 同源:用主 cwd 编码做物理隔离,使 session-file-gc 按 <enc>/records/ 子目录
44
51
  * 匹配 manifest .json 时天然限定在当前 cwd 范围内,不会越界清理其他 cwd 的 manifest。
45
52
  *
46
53
  * @param agentDir agent 配置目录(如 ~/.pi/agent)
47
- * @param mainCwd agent 的工作目录(非 subagent 的 effectiveCwd
54
+ * @param mainCwd 树根主 agent 的工作目录(非 subagent 的 effectiveCwd;worktree 模式下
55
+ * 是 ROOT 的 cwd,不是 checkout 路径)
48
56
  * @returns records 持久化目录绝对路径
49
57
  */
50
58
  export function getSubagentRecordsDir(agentDir: string, mainCwd: string): string {
@@ -274,6 +274,13 @@ export interface SessionRunnerContext {
274
274
  dialogQueue?: DialogGlobalQueue;
275
275
  /** 主进程运行模式(W4 守卫:headless 不注入 ask_user RPC 提示词)。 */
276
276
  mode?: ExtensionMode;
277
+ /** 所属根 session ID(跨进程身份贯穿用)。子进程的 record.rootSessionId 全指向真 ROOT,
278
+ * 使主进程 /subagents 能看到完整递归树。runSpawn 无条件注入为子进程 env(设计 recursive-subagent-visibility.md)。 */
279
+ sessionRootId: string;
280
+ /** [MF-3] 所属根进程 cwd(跨进程落盘目录编码键)。根进程=自身 cwd;worktree 模式下子进程
281
+ * mainCwd=checkout 路径,rootCwd 贯穿真 ROOT——session 文件落盘统一用 ROOT cwd 编码,
282
+ * 主进程磁盘重建才能看到全树(设计 recursive-subagent-visibility.md)。 */
283
+ rootCwd: string;
277
284
  }
278
285
 
279
286
  /** SessionRunner.run 的入参。 */
@@ -622,7 +629,11 @@ export async function runSpawn(
622
629
  };
623
630
 
624
631
  // d. session 目录(与 in-process 一致:list/恢复可发现同一目录)
625
- const sessionDir = getSubagentSessionDir(ctx.agentDir, ctx.mainCwd);
632
+ // [MF-3] ctx.rootCwd(贯穿真 ROOT)而非 ctx.mainCwd 编码:worktree 模式下 mainCwd 是
633
+ // 子进程的 checkout 路径,按它编码会让深层 record 落到 enc(worktree) 段,ROOT 磁盘重建
634
+ // 扫不到 → 全树可见性深度 ≥ 2 断裂。rootCwd 与 store 构造同源(subagent-service 同键),
635
+ // 保证 runSpawn 写入的 session 文件就在本进程/ROOT store 扫描的目录里。
636
+ const sessionDir = getSubagentSessionDir(ctx.agentDir, ctx.rootCwd);
626
637
  fs.mkdirSync(sessionDir, { recursive: true });
627
638
 
628
639
  // e. worktree 模式:checkout 路径作为 spawn cwd(隔离文件系统)
@@ -660,6 +671,20 @@ export async function runSpawn(
660
671
  if (opts.fork && opts.parentForkDepth !== undefined) {
661
672
  childEnv.PI_SUBAGENT_FORK_DEPTH = String(opts.parentForkDepth + 1);
662
673
  }
674
+ // [递归可见性] 跨进程身份贯穿(设计 docs/design/recursive-subagent-visibility.md)。
675
+ // 无条件注入每个 subagent(决策 2:身份贯穿是基础需求,不依赖 fork)。env 描述「子进程自己的身份」:
676
+ // - ROOT_SESSION_ID:所属根 session(贯穿真 ROOT,子进程 sessionRootId 读它)
677
+ // - SELF_RECORD_ID:子进程自己的 record id(子进程 execCtxAls 基线 = 孙的直接父)
678
+ // - DEPTH:子进程的嵌套深度(子进程 execCtxAls 基线 depth)
679
+ // - ROOT_CWD:真 ROOT 的 cwd([MF-3] 落盘目录编码键,worktree 下与自身 spawn cwd 不同)
680
+ // 子进程 initSession 读这 4 个 env 建立基线 → createRecordForMode 读 execCtxAls 自动正确。
681
+ childEnv.PI_SUBAGENT_ROOT_SESSION_ID = ctx.sessionRootId;
682
+ childEnv.PI_SUBAGENT_SELF_RECORD_ID = record.id;
683
+ childEnv.PI_SUBAGENT_DEPTH = String(record.depth);
684
+ // [MF-3] 第 4 个贯穿 env:真 ROOT 的 cwd。worktree 模式下子进程 spawn cwd = checkout 路径,
685
+ // 子进程的 store/runSpawn 落盘目录须统一编码在 enc(ROOT cwd) 段(与身份贯穿同构),
686
+ // 否则 ROOT 磁盘重建扫不到深层 record(见 subagent-service ENV_ROOT_CWD 注释)。
687
+ childEnv.PI_SUBAGENT_ROOT_CWD = ctx.rootCwd;
663
688
  // D-A6 bridge: schema 激活 structured-output 扩展注册 tool(workflow 编排层需要)
664
689
  applySchemaEnvToChildEnv(childEnv, opts.schemaEnv);
665
690