@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.
- package/package.json +1 -1
- package/src/execution/__tests__/execute-and-await-worktree.test.ts +4 -27
- package/src/execution/__tests__/execute-nesting.test.ts +1 -1
- package/src/execution/__tests__/helpers/spawn-mock.ts +2 -0
- package/src/execution/__tests__/record-store.test.ts +42 -2
- package/src/execution/__tests__/recursive-visibility-baseline.test.ts +340 -0
- package/src/execution/__tests__/recursive-visibility-env.test.ts +275 -0
- package/src/execution/__tests__/session-runner-schema-env.test.ts +5 -2
- package/src/execution/__tests__/subagent-service.test.ts +27 -28
- package/src/execution/__tests__/timeout-integration.test.ts +2 -0
- package/src/execution/__tests__/tool-action.test.ts +26 -0
- package/src/execution/notifier.ts +1 -1
- package/src/execution/path-encoding.ts +12 -4
- package/src/execution/session-runner.ts +26 -1
- package/src/execution/subagent-service.ts +96 -32
- package/src/execution/types.ts +5 -5
- package/src/interface/bg-notify-render.ts +2 -2
- package/src/interface/subagent-actions.ts +15 -1
- package/src/interface/subagent-tool.ts +2 -2
- package/src/interface/subagents.ts +1 -1
- package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +52 -0
- package/src/orchestration/jsonl-run-store.ts +20 -1
- package/src/orchestration/models/types.ts +2 -2
|
@@ -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
|
|
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
|
|
388
|
+
// execute() worktree 路径(worktree 与 fork 解耦后)
|
|
389
389
|
// ============================================================
|
|
390
390
|
//
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
// 1. worktree:true + fork:false →
|
|
395
|
-
// 2. worktree:true + fork:true →
|
|
396
|
-
// 3. worktree:false + fork:false →
|
|
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
|
-
//
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
|
|
403
|
-
|
|
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 →
|
|
423
|
+
it("worktree:true + fork:false → 解耦后不抛 'requires fork'(worktree 独立于 fork)", async () => {
|
|
426
424
|
const service = makeReadyService();
|
|
427
|
-
//
|
|
428
|
-
|
|
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
|
-
)
|
|
436
|
-
|
|
437
|
-
|
|
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 →
|
|
439
|
+
it("worktree:true + fork:true → 创建 worktree 路径(不抛 'requires fork')", async () => {
|
|
441
440
|
const service = makeReadyService();
|
|
442
|
-
//
|
|
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 →
|
|
456
|
+
it("worktree:false + fork:false → 默认路径(不创建 worktree,不抛 'requires fork')", async () => {
|
|
458
457
|
const service = makeReadyService();
|
|
459
|
-
//
|
|
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]
|
|
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
|
|
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>
|
|
41
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|