@zhushanwen/pi-subagent-workflow 7.1.0 → 7.3.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
 
@@ -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" })),
@@ -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, 2);
77
+ const content = JSON.stringify(record, null, MANIFEST_INDENT_SPACES);
73
78
 
74
79
  let renamed = false;
75
80
  try {
@@ -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
 
@@ -145,6 +145,19 @@ export interface SubagentServiceSessionInit {
145
145
  /** background 优先级(保留 priority 排序机制,单一值)。 */
146
146
  const PRIORITY_BACKGROUND = 1000;
147
147
 
148
+ /** 跨进程身份贯穿的 env 名(父进程 spawn 子进程时注入,子进程 initSession 读取)。
149
+ * 仿照 PI_SUBAGENT_FORK_DEPTH 机制,让递归 subagent 的身份(rootSessionId / parentRecordId / depth)
150
+ * 跨进程传递,使主进程 /subagents 能看到完整递归树(设计见 docs/design/recursive-subagent-visibility.md)。
151
+ * 语义:env 描述「子进程自己的身份」,不是父的身份(决策 1)。
152
+ * [MF-3] 第 4 个 env:真 ROOT 的 cwd(PI_SUBAGENT_ROOT_CWD)。worktree 模式下子进程 spawn cwd =
153
+ * checkout 路径,若按各自 cwd 编码落盘目录,深层 record 写到 enc(worktree) 段、ROOT 磁盘重建
154
+ * 扫不到 → 全树可见性深度 ≥ 2 断裂。子进程经本 env 拿 ROOT cwd,sessions 与 records 两套目录
155
+ * 统一编码在 enc(ROOT cwd) 段(与身份贯穿同构,见 session-runner 注入点)。 */
156
+ const ENV_ROOT_SESSION_ID = "PI_SUBAGENT_ROOT_SESSION_ID";
157
+ const ENV_SELF_RECORD_ID = "PI_SUBAGENT_SELF_RECORD_ID";
158
+ const ENV_DEPTH = "PI_SUBAGENT_DEPTH";
159
+ const ENV_ROOT_CWD = "PI_SUBAGENT_ROOT_CWD";
160
+
148
161
  /** 触发 onUpdate 的事件类型(streaming delta 不触发,避免每 token 刷新)。 */
149
162
  const TRIGGERING_EVENT_TYPES = new Set<AgentEvent["type"]>([
150
163
  "tool_start",
@@ -196,8 +209,31 @@ export class SubagentService {
196
209
  /** UI 请求可观测性(sessionMode + handler 缺失告警去重,提取自本类降低行数)。 */
197
210
  private readonly uiObservability = new UiRequestObservability();
198
211
  private pi: PiLike | null = null;
199
- /** 当前 Pi session IDsession 隔离过滤用)。initSession 时注入。 */
212
+ /** 当前 Pi session ID(本进程 pi session,事件路由等用;record 过滤不用它)。initSession 时注入。 */
200
213
  private sessionId: string | null = null;
214
+ /** 所属根 session ID(record 归属过滤用)。根进程 = sessionId(自己是 root);
215
+ * 子进程 = env PI_SUBAGENT_ROOT_SESSION_ID 贯穿的真 ROOT(initSession 读取)。
216
+ * 与 sessionId 正交:sessionId 是本进程 pi session(事件路由等),sessionRootId 是所属根
217
+ * (collectRecords filter 用,与 createRecordForMode 的 rootSessionId 盖章同源——子进程
218
+ * 因此看到整棵 ROOT 树)。设计见 recursive-subagent-visibility.md 决策 3。 */
219
+ private sessionRootId: string | null = null;
220
+ /** 进程级执行上下文基线(不依赖 ALS 贯穿——pi RPC mode 的 stdin JSONL 是事件回调式
221
+ * (attachJsonlLineReader stream.on("data")),每个命令是独立异步链,initSession 里
222
+ * execCtxAls.enterWith 的 store 不会贯穿到后续 tool 调用事件(实测:递归第二层
223
+ * parentRecordId/depth 丢失而 rootSessionId 正确——rootSessionId 是实例字段所以不受影响)。
224
+ * 基线 = 本进程自己的身份(initSession 从 env 读取,与 sessionRootId 同机制):
225
+ * 读 ALS store 失败时兜底,保证「本进程派发的 subagent 都是本进程记录的孩子」
226
+ * 这一跨进程树形关系成立。
227
+ * initSession 设置:有 env PI_SUBAGENT_SELF_RECORD_ID → {recordId: env 值, depth: env DEPTH};
228
+ * 无 env(根进程)→ null(顶层)。 */
229
+ private execCtxBaseline: { recordId: string | undefined; depth: number } | null = null;
230
+ /** fork 深度基线(同 ALS 断裂问题:forkDepthAls.getStore() 兜底用)。根进程=0。 */
231
+ private forkDepthBaseline = 0;
232
+ /** [MF-3] 所属根进程 cwd(sessions/records 落盘目录编码键)。
233
+ * 根进程=自身 cwd(构造时 init.cwd);子进程=env PI_SUBAGENT_ROOT_CWD 贯穿的真 ROOT cwd。
234
+ * worktree 模式下子进程 this.cwd 是 checkout 路径,若按它编码目录,深层 record 落到
235
+ * enc(worktree) 段、ROOT 扫描不到 → 全树可见性深度 ≥ 2 断裂(与 sessionRootId 同构)。 */
236
+ private rootCwd: string;
201
237
  /** UI streaming sink(ctx.ui.setWidget)。workflow 域经 getStreamSink() 取用。 */
202
238
  private streamSink: StreamSink | null = null;
203
239
  /** [竞态修复] 主 agent isIdle 查询(ctx.isIdle)。notifier flush gate 用。
@@ -229,8 +265,15 @@ export class SubagentService {
229
265
  this.uiRequestHandler = init.uiRequestHandler;
230
266
  this.pool = new DefaultConcurrencyPool(this.modelService.getGlobalConfig().maxConcurrent);
231
267
  this.worktreeManager = new WorktreeManager(this.modelService.getAgentDir());
232
- const sessionsDir = getSubagentSessionDir(this.modelService.getAgentDir(), init.cwd);
233
- const recordsDir = getSubagentRecordsDir(this.modelService.getAgentDir(), init.cwd);
268
+ // [MF-3] worktree 隔离下全树落盘目录统一到 ROOT cwd:子进程(spawn cwd = worktree checkout 路径)
269
+ // 若按自身 cwd 编码目录,深层 record 写到 enc(worktree) 段,ROOT 磁盘重建扫不到。
270
+ // 读 env PI_SUBAGENT_ROOT_CWD(根进程无 env → init.cwd)。sessions 与 records 两套目录
271
+ // 必须同源(同一 rootCwd),否则 enc 段不变量断裂(只改其一会让同 record 的
272
+ // session 文件与 manifest 分落两段,GC/重建互相找不到)。
273
+ const envRootCwd = process.env[ENV_ROOT_CWD];
274
+ this.rootCwd = envRootCwd && envRootCwd !== "" ? envRootCwd : init.cwd;
275
+ const sessionsDir = getSubagentSessionDir(this.modelService.getAgentDir(), this.rootCwd);
276
+ const recordsDir = getSubagentRecordsDir(this.modelService.getAgentDir(), this.rootCwd);
234
277
  this.manifestStore = new ManifestStore(recordsDir);
235
278
  this.store = new RecordStore(sessionsDir, this.manifestStore, this.pi ?? undefined);
236
279
  this.notifier = new BgNotifier(this.piAdapter());
@@ -286,6 +329,32 @@ export class SubagentService {
286
329
  const base = Number.parseInt(envDepth, 10);
287
330
  if (!Number.isNaN(base) && base > 0) {
288
331
  this.forkDepthAls.enterWith(base);
332
+ this.forkDepthBaseline = base;
333
+ }
334
+ }
335
+ // [递归可见性] 跨进程身份贯穿(设计 recursive-subagent-visibility.md)。
336
+ // 父进程 spawn 时注入这 4 个 env 描述「子进程自己的身份」:
337
+ // - rootSessionId:所属根 session(贯穿真 ROOT,子进程不覆盖)
338
+ // - selfRecordId:子进程自己的 record id(孙 subagent 的直接父)
339
+ // - depth:子进程的嵌套深度
340
+ // - rootCwd:真 ROOT 的 cwd([MF-3] 落盘目录编码键,worktree 下与自身 cwd 不同)
341
+ // 子进程读 env 建立基线后,createRecordForMode 读 execCtxAls 自动正确(孙挂到子名下)。
342
+ // 根进程无 env → sessionRootId = init.sessionId(自己是 root),execCtxAls 不 enterWith(顶层)。
343
+ // enterWith 贯穿整个 session 生命周期(与 forkDepthAls 同构,决策 4)。
344
+ const envRoot = process.env[ENV_ROOT_SESSION_ID];
345
+ this.sessionRootId = envRoot ?? init.sessionId;
346
+ const envSelfRecord = process.env[ENV_SELF_RECORD_ID];
347
+ if (envSelfRecord !== undefined && envSelfRecord !== "") {
348
+ const envNestingDepth = Number.parseInt(process.env[ENV_DEPTH] ?? "0", 10);
349
+ const nestingDepth = Number.isNaN(envNestingDepth) ? 0 : envNestingDepth;
350
+ // [ALS 断裂修复] 基线兜底:enterWith 在 pi 事件回调模型下不可靠(见 execCtxBaseline 注释),
351
+ // 基线是 createRecordForMode / 护栏读 ALS store 失败时的权威回退。
352
+ this.execCtxBaseline = { recordId: envSelfRecord, depth: nestingDepth };
353
+ this.execCtxAls.enterWith({ recordId: envSelfRecord, depth: nestingDepth });
354
+ if (process.env.PI_EXT_DEBUG) {
355
+ logger.debug(
356
+ `[subagents] execCtxAls initialized: recordId=${envSelfRecord} depth=${nestingDepth} rootSessionId=${envRoot ?? init.sessionId}`,
357
+ );
289
358
  }
290
359
  }
291
360
  // revive(dispose 的逆操作:/resume /fork /new 后复活)
@@ -413,7 +482,8 @@ export class SubagentService {
413
482
  // 但耗资源且 LLM 易陷入「委派→再委派」死循环。在所有副作用之前拦截,错误直达调用方。
414
483
  // 计数基准:顶层 nestingDepth=0,nestingDepth>MAX 被拒。与 fork 体积护栏(parentForkDepth 检查)
415
484
  // 互补:本护栏更严(计所有嵌套),混合链下先生效;两者共享 MAX_FORK_DEPTH 上限不漂移。
416
- const parentNesting = this.execCtxAls.getStore();
485
+ // [ALS 断裂修复] getStore() 在 pi 事件回调模型下可能读空(enterWith 不贯穿),基线兜底。
486
+ const parentNesting = this.execCtxAls.getStore() ?? this.execCtxBaseline;
417
487
  const nestingDepth = parentNesting ? parentNesting.depth + 1 : 0;
418
488
  if (nestingDepth > MAX_FORK_DEPTH) {
419
489
  throw new ForkDepthExceededError(
@@ -506,7 +576,8 @@ export class SubagentService {
506
576
  this.assertReady();
507
577
 
508
578
  // ── BC-12 嵌套护栏:复用 execute() 的 execCtxAls 深度检查 ──
509
- const parentNesting = this.execCtxAls.getStore();
579
+ // [ALS 断裂修复] getStore() 可能读空,基线兜底(与 execute 同)。
580
+ const parentNesting = this.execCtxAls.getStore() ?? this.execCtxBaseline;
510
581
  const nestingDepth = parentNesting ? parentNesting.depth + 1 : 0;
511
582
  if (nestingDepth > MAX_FORK_DEPTH) {
512
583
  throw new ForkDepthExceededError(
@@ -599,9 +670,10 @@ export class SubagentService {
599
670
  }
600
671
 
601
672
  /** 合并内存(running) + 磁盘(session.jsonl 重建) record(/subagents list + tool list 消费)。
602
- * 按 rootSessionId 过滤,只返回当前 session 创建的 recordsession 隔离)。 */
673
+ * 按 rootSessionId 过滤:根进程=本 session(sessionRootId===sessionId);
674
+ * 子进程=env 贯穿的真 ROOT(sessionRootId≠sessionId)→ 看到整棵 ROOT 树(决策 3)。 */
603
675
  collectRecords(limit: number, statusFilter: StatusFilter = "all"): SubagentRecord[] {
604
- return this.store.collectRecords(limit, statusFilter, this.sessionId ?? undefined);
676
+ return this.store.collectRecords(limit, statusFilter, this.sessionRootId ?? this.sessionId ?? undefined);
605
677
  }
606
678
 
607
679
  // ── 执行内部:身份解析 + record 创建 ──────────
@@ -638,7 +710,9 @@ export class SubagentService {
638
710
  // 从 async 调用链读父执行上下文:主 session 链上无 store → 顶层 record;
639
711
  // B run() 期间包了 execCtxAls,B 内创建 C 时读到 B → C.parentRecordId=B.id, C.depth=B.depth+1。
640
712
  // depth 语义:顶层(无父)=0;有父=父 depth+1。靠 recordId 是否存在区分,不用负数魔数。
641
- const parentCtx = this.execCtxAls.getStore();
713
+ // [ALS 断裂修复] getStore() 在 pi 事件回调模型下可能读空(enterWith 不贯穿),
714
+ // 基线兜底——本进程的身份在 initSession 已确定(env 注入),任何上下文下都能正确挂父链。
715
+ const parentCtx = this.execCtxAls.getStore() ?? this.execCtxBaseline;
642
716
  const parentRecordId = parentCtx?.recordId;
643
717
  const depth = parentCtx ? parentCtx.depth + 1 : 0;
644
718
 
@@ -650,7 +724,7 @@ export class SubagentService {
650
724
  task: opts.task,
651
725
  slug: opts.slug,
652
726
  startedAt: Date.now(),
653
- rootSessionId: this.sessionId ?? undefined,
727
+ rootSessionId: this.sessionRootId ?? undefined,
654
728
  parentRecordId,
655
729
  depth,
656
730
  controller,
@@ -705,7 +779,7 @@ export class SubagentService {
705
779
  worktreeHandle = opts.worktree;
706
780
  }
707
781
  // [MF#4][MF#2] fork 深度护栏:ALS 传递深度(主 session 链无 store→0,fork 推进 +1)。
708
- const parentDepth = this.forkDepthAls.getStore() ?? 0;
782
+ const parentDepth = this.forkDepthAls.getStore() ?? this.forkDepthBaseline;
709
783
  const effectiveDepth = opts.fork ? parentDepth + 1 : parentDepth;
710
784
 
711
785
  let result: AgentResult;
@@ -982,6 +1056,15 @@ export class SubagentService {
982
1056
  dialogQueue: this.dialogQueue,
983
1057
  // 主进程运行模式:session-runner W4 守卫据此决定是否注入 ask_user RPC 提示词。
984
1058
  mode: this.uiObservability.getMode(),
1059
+ // [递归可见性] 透传所属根 session(runSpawn 注入为子进程 env PI_SUBAGENT_ROOT_SESSION_ID)。
1060
+ // sessionRootId 在 initSession 设定(根进程=sessionId,子进程=env 贯穿的真 ROOT)。
1061
+ // execute/executeAndAwait 调本方法前必经 initSession,此时 sessionRootId 已非空;
1062
+ // ?? 兑底防类型漂移(运行时不可达)。
1063
+ sessionRootId: this.sessionRootId ?? this.sessionId ?? "",
1064
+ // [MF-3] 透传 ROOT cwd(runSpawn 落盘目录编码键 + 注入子进程 env PI_SUBAGENT_ROOT_CWD)。
1065
+ // worktree 模式下 mainCwd = 本进程 checkout 路径,rootCwd 才是真 ROOT——session 文件
1066
+ // 落盘统一用 rootCwd 编码,ROOT 磁盘重建才扫得到深层 record(与 sessionRootId 同构)。
1067
+ rootCwd: this.rootCwd,
985
1068
  };
986
1069
  }
987
1070
  }