@zhushanwen/pi-subagent-workflow 7.1.0 → 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-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__/timeout-integration.test.ts +2 -0
- package/src/execution/__tests__/tool-action.test.ts +26 -0
- package/src/execution/path-encoding.ts +12 -4
- package/src/execution/session-runner.ts +26 -1
- package/src/execution/subagent-service.ts +93 -10
- package/src/interface/subagent-actions.ts +15 -1
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-subagent-workflow",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.2.0",
|
|
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.",
|
|
@@ -91,7 +91,7 @@ vi.mock("node:fs", async () => {
|
|
|
91
91
|
// (finalizeRecord 收尾删 .alive)。其余导出(readAliveMarker/isProcessAlive)保留真实实现
|
|
92
92
|
// (worktree-manager/record-store 用,本组用例不涉及但保留以避免间接报错)。
|
|
93
93
|
vi.mock("../alive-store.ts", async (importOriginal) => {
|
|
94
|
-
const actual = await importOriginal<typeof import("../
|
|
94
|
+
const actual = await importOriginal<typeof import("../alive-store.ts")>();
|
|
95
95
|
return {
|
|
96
96
|
...actual,
|
|
97
97
|
writeAliveMarker: vi.fn(),
|
|
@@ -204,6 +204,8 @@ export function makeCtx(overrides: Partial<SessionRunnerContext> = {}): SessionR
|
|
|
204
204
|
skillDirs: [],
|
|
205
205
|
mainCwd: "/tmp/test",
|
|
206
206
|
mainSessionFile: undefined,
|
|
207
|
+
sessionRootId: "root-session-test",
|
|
208
|
+
rootCwd: "/tmp/test",
|
|
207
209
|
...overrides,
|
|
208
210
|
};
|
|
209
211
|
}
|
|
@@ -20,7 +20,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
20
20
|
import { writeAliveMarker } from "../alive-store.ts";
|
|
21
21
|
import { createRecord } from "../execution-record.ts";
|
|
22
22
|
import { writeFinalized } from "../finalized-marker.ts";
|
|
23
|
-
import type { ManifestRecord
|
|
23
|
+
import type { ManifestRecord } from "../manifest-store.ts";
|
|
24
|
+
import { ManifestStore } from "../manifest-store.ts";
|
|
25
|
+
import { getSubagentRecordsDir, getSubagentSessionDir } from "../path-encoding.ts";
|
|
24
26
|
import type { StatusFilter } from "../record-store.ts";
|
|
25
27
|
import { RecordStore } from "../record-store.ts";
|
|
26
28
|
import { writeCancelledTombstone } from "../tombstone-store.ts";
|
|
@@ -45,7 +47,7 @@ function makeRecord(over: Partial<ExecutionRecord> = {}): ExecutionRecord {
|
|
|
45
47
|
*/
|
|
46
48
|
function writeSessionJsonl(
|
|
47
49
|
filePath: string,
|
|
48
|
-
identity: { id: string; agent: string; mode: "sync" | "background"; task: string; startedAt: number; rootSessionId?: string; lastTs?: number },
|
|
50
|
+
identity: { id: string; agent: string; mode: "sync" | "background"; task: string; startedAt: number; rootSessionId?: string; parentRecordId?: string; depth?: number; lastTs?: number },
|
|
49
51
|
assistantText = "result text",
|
|
50
52
|
): void {
|
|
51
53
|
const lastTs = identity.lastTs ?? identity.startedAt + 1000;
|
|
@@ -60,6 +62,8 @@ function writeSessionJsonl(
|
|
|
60
62
|
startedAt: identity.startedAt,
|
|
61
63
|
};
|
|
62
64
|
if (identity.rootSessionId !== undefined) identityData.rootSessionId = identity.rootSessionId;
|
|
65
|
+
if (identity.parentRecordId !== undefined) identityData.parentRecordId = identity.parentRecordId;
|
|
66
|
+
if (identity.depth !== undefined) identityData.depth = identity.depth;
|
|
63
67
|
const identityEntry = JSON.stringify({
|
|
64
68
|
type: "custom",
|
|
65
69
|
id: "id-1",
|
|
@@ -189,6 +193,42 @@ describe("RecordStore", () => {
|
|
|
189
193
|
const found = store.collectRecords(100).find((r) => r.id === "dup-1");
|
|
190
194
|
expect(found?.status).toBe("running"); // 内存 running 覆盖磁盘 crashed
|
|
191
195
|
});
|
|
196
|
+
|
|
197
|
+
it("[MF-3/S-20] 跨进程组合:子进程(worktree)写入 enc(ROOT) 的深层 record 被 ROOT store 重建,同 rootSessionId 全树可见、他树被排除", () => {
|
|
198
|
+
// MF-3 修复后:worktree 子进程把其子 record 的 session 文件写到统一 enc(ROOT cwd) 段,
|
|
199
|
+
// ROOT 进程的 store 扫同一目录重建(旧实现子进程写到 enc(checkout) 段,此处为空)。
|
|
200
|
+
const rootCwd = "/Users/x/root-proj";
|
|
201
|
+
const sessionsDir = getSubagentSessionDir(tmpDir, rootCwd);
|
|
202
|
+
const recordsDir = getSubagentRecordsDir(tmpDir, rootCwd);
|
|
203
|
+
|
|
204
|
+
// 父 record A(ROOT 自己 spawn,parentRecordId 缺省=顶层)+ 孙 record C(B 子进程写入,
|
|
205
|
+
// 同 rootSessionId、parentRecordId=A、depth=2)+ 他树 record X(rootSessionId 不同)
|
|
206
|
+
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
207
|
+
writeSessionJsonl(path.join(sessionsDir, "2026-01-01-uuid-a.jsonl"), {
|
|
208
|
+
id: "sa-A", agent: "worker", mode: "background", task: "level1", startedAt: 1000, rootSessionId: "root-main",
|
|
209
|
+
});
|
|
210
|
+
writeSessionJsonl(path.join(sessionsDir, "2026-01-01-uuid-c.jsonl"), {
|
|
211
|
+
id: "sa-C", agent: "worker", mode: "background", task: "level3", startedAt: 3000,
|
|
212
|
+
rootSessionId: "root-main", parentRecordId: "sa-A", depth: 2,
|
|
213
|
+
});
|
|
214
|
+
writeSessionJsonl(path.join(sessionsDir, "2026-01-01-uuid-x.jsonl"), {
|
|
215
|
+
id: "sa-X", agent: "worker", mode: "background", task: "other root", startedAt: 4000, rootSessionId: "root-other",
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// ROOT 进程的 store:sessionsDir/recordsDir 与子进程写盘目录同段(getSubagentSessionDir 同源)
|
|
219
|
+
const store = new RecordStore(sessionsDir, new ManifestStore(recordsDir), undefined);
|
|
220
|
+
const recs = store.collectRecords(10, "all", "root-main");
|
|
221
|
+
const ids = recs.map((r) => r.id);
|
|
222
|
+
|
|
223
|
+
// 全树可见:A(顶层)与 C(深度 2,跨进程写入)都在列表,身份字段正确
|
|
224
|
+
expect(ids).toContain("sa-A");
|
|
225
|
+
expect(ids).toContain("sa-C");
|
|
226
|
+
const c = recs.find((r) => r.id === "sa-C");
|
|
227
|
+
expect(c?.parentRecordId).toBe("sa-A");
|
|
228
|
+
expect(c?.depth).toBe(2);
|
|
229
|
+
// 他树 record 被 rootSessionFilter 排除(隔离不破坏)
|
|
230
|
+
expect(ids).not.toContain("sa-X");
|
|
231
|
+
});
|
|
192
232
|
});
|
|
193
233
|
|
|
194
234
|
// ============================================================
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
// src/execution/__tests__/recursive-visibility-baseline.test.ts
|
|
2
|
+
//
|
|
3
|
+
// 递归 subagent 跨进程身份:进程级基线兜底验证(ALS 断裂修复)。
|
|
4
|
+
//
|
|
5
|
+
// 背景(2026-08-11 实测):pi RPC mode 的 stdin JSONL 是事件回调式读取
|
|
6
|
+
// (attachJsonlLineReader → stream.on("data")),每个 RPC 命令是独立异步链。
|
|
7
|
+
// initSession 里 execCtxAls.enterWith 的 store 不会贯穿到后续 tool 调用事件——
|
|
8
|
+
// 递归第二层 subagent 的 parentRecordId/depth 丢失(rootSessionId 是实例字段所以正确)。
|
|
9
|
+
//
|
|
10
|
+
// 修复:execCtxBaseline / forkDepthBaseline 实例字段(initSession 从 env 读取,
|
|
11
|
+
// 与 sessionRootId 同机制),createRecordForMode / 嵌套护栏读 ALS store 失败时兜底。
|
|
12
|
+
// 本文件验证:
|
|
13
|
+
// 1. 有 env(子进程身份)→ execute 创建的 record parentRecordId/depth 来自基线
|
|
14
|
+
// 2. 无 env(根进程)→ parentRecordId undefined / depth 0(顶层)
|
|
15
|
+
// 3. execCtxAls.run 的 store 优先于基线(并发链语义不回归)
|
|
16
|
+
// 4. forkDepth 基线:env PI_SUBAGENT_FORK_DEPTH → fork spawn env 递增
|
|
17
|
+
//
|
|
18
|
+
// mock 策略与 execute-nesting.test.ts 一致(spawn → FakeChild,fs 同步方法 mock,
|
|
19
|
+
// temp-prompt / alive-store / finalized-marker / manifest-store mock)。
|
|
20
|
+
|
|
21
|
+
import type { PassThrough } from "node:stream";
|
|
22
|
+
|
|
23
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
24
|
+
|
|
25
|
+
// ── mock modules(同 execute-nesting.test.ts)──
|
|
26
|
+
|
|
27
|
+
vi.mock("node:child_process", async () => {
|
|
28
|
+
const { EventEmitter } = await import("node:events");
|
|
29
|
+
const { PassThrough } = await import("node:stream");
|
|
30
|
+
|
|
31
|
+
class FakeChild extends EventEmitter {
|
|
32
|
+
pid = 12345;
|
|
33
|
+
stdout = new PassThrough();
|
|
34
|
+
stderr = new PassThrough();
|
|
35
|
+
killed = false;
|
|
36
|
+
killSignal: string | undefined;
|
|
37
|
+
kill(sig?: string): boolean {
|
|
38
|
+
this.killed = true;
|
|
39
|
+
this.killSignal = sig;
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
spawn: vi.fn(() => new FakeChild()),
|
|
46
|
+
execFileSync: vi.fn(() => ""),
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
vi.mock("node:fs", async () => {
|
|
51
|
+
const actual = await import("node:fs");
|
|
52
|
+
return {
|
|
53
|
+
default: {
|
|
54
|
+
...actual,
|
|
55
|
+
mkdirSync: vi.fn(),
|
|
56
|
+
existsSync: vi.fn(() => false),
|
|
57
|
+
appendFileSync: vi.fn(),
|
|
58
|
+
writeFileSync: vi.fn(),
|
|
59
|
+
readdirSync: vi.fn(() => []),
|
|
60
|
+
},
|
|
61
|
+
mkdirSync: vi.fn(),
|
|
62
|
+
existsSync: vi.fn(() => false),
|
|
63
|
+
appendFileSync: vi.fn(),
|
|
64
|
+
writeFileSync: vi.fn(),
|
|
65
|
+
readdirSync: vi.fn(() => []),
|
|
66
|
+
promises: actual.promises,
|
|
67
|
+
};
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
vi.mock("../alive-store.ts", async (importOriginal) => {
|
|
71
|
+
const actual = await importOriginal<typeof import("../alive-store.ts")>();
|
|
72
|
+
return {
|
|
73
|
+
...actual,
|
|
74
|
+
writeAliveMarker: vi.fn(),
|
|
75
|
+
removeAliveMarker: vi.fn(),
|
|
76
|
+
};
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
vi.mock("../finalized-marker.ts", () => ({
|
|
80
|
+
writeFinalized: vi.fn(),
|
|
81
|
+
readFinalized: vi.fn(() => false),
|
|
82
|
+
}));
|
|
83
|
+
|
|
84
|
+
vi.mock("../manifest-store.ts", () => {
|
|
85
|
+
class FakeManifestStore {
|
|
86
|
+
writeManifest = vi.fn(async () => {});
|
|
87
|
+
readManifest = vi.fn(async () => null);
|
|
88
|
+
listAllSync = vi.fn(() => []);
|
|
89
|
+
recoverTmpFiles = vi.fn(async () => []);
|
|
90
|
+
}
|
|
91
|
+
// vi.fn 包裹:构造参数(recordsDir)可从 mock.calls 断言([MF-3] 目录统一验证)。
|
|
92
|
+
// 注意用普通 function(箭头函数不能被 new 调用)。
|
|
93
|
+
return { ManifestStore: vi.fn(function (_recordsDir: string) { return new FakeManifestStore(); }) };
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
vi.mock("../temp-prompt.ts", () => ({
|
|
97
|
+
writePromptToTempFile: vi.fn(async (agent: string) => {
|
|
98
|
+
const safeName = agent.replace(/[^\w.-]+/g, "_");
|
|
99
|
+
return { dir: `/tmp/fake-${safeName}`, filePath: `/tmp/fake-${safeName}/prompt-${safeName}.md` };
|
|
100
|
+
}),
|
|
101
|
+
cleanupTempPrompt: vi.fn(async () => {}),
|
|
102
|
+
}));
|
|
103
|
+
|
|
104
|
+
import { spawn } from "node:child_process";
|
|
105
|
+
|
|
106
|
+
import { ModelConfigService } from "../model-config-service.ts";
|
|
107
|
+
import type { ModelInfo, ModelRegistryLike } from "../model-resolver.ts";
|
|
108
|
+
import { ManifestStore } from "../manifest-store.ts";
|
|
109
|
+
import { getSubagentRecordsDir, getSubagentSessionDir } from "../path-encoding.ts";
|
|
110
|
+
import type { RecordStore } from "../record-store.ts";
|
|
111
|
+
import { SubagentService } from "../subagent-service.ts";
|
|
112
|
+
|
|
113
|
+
const mockSpawn = vi.mocked(spawn);
|
|
114
|
+
|
|
115
|
+
// ── 身份 env 名(与 subagent-service.ts 常量一致,避免魔法字符串)──
|
|
116
|
+
const ENV_ROOT_SESSION_ID = "PI_SUBAGENT_ROOT_SESSION_ID";
|
|
117
|
+
const ENV_SELF_RECORD_ID = "PI_SUBAGENT_SELF_RECORD_ID";
|
|
118
|
+
const ENV_DEPTH = "PI_SUBAGENT_DEPTH";
|
|
119
|
+
const ENV_ROOT_CWD = "PI_SUBAGENT_ROOT_CWD";
|
|
120
|
+
const ENV_FORK_DEPTH = "PI_SUBAGENT_FORK_DEPTH";
|
|
121
|
+
|
|
122
|
+
interface FakeChild {
|
|
123
|
+
pid: number;
|
|
124
|
+
stdout: PassThrough;
|
|
125
|
+
stderr: PassThrough;
|
|
126
|
+
killed: boolean;
|
|
127
|
+
killSignal: string | undefined;
|
|
128
|
+
kill(sig?: string): boolean;
|
|
129
|
+
emit(event: string, ...args: unknown[]): boolean;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function lastSpawnedChild(): FakeChild {
|
|
133
|
+
const result = mockSpawn.mock.results.at(-1);
|
|
134
|
+
if (!result) throw new Error("spawn was not called yet");
|
|
135
|
+
return result.value as FakeChild;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function getLastSpawnEnv(): Record<string, string | undefined> {
|
|
139
|
+
return (mockSpawn.mock.calls.at(-1)?.[2]?.env as Record<string, string | undefined>) ?? {};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function waitForSpawn(timeoutMs = 1000): Promise<void> {
|
|
143
|
+
const start = Date.now();
|
|
144
|
+
while (mockSpawn.mock.results.length === 0) {
|
|
145
|
+
if (Date.now() - start > timeoutMs) {
|
|
146
|
+
throw new Error(`spawn was not called within ${timeoutMs}ms`);
|
|
147
|
+
}
|
|
148
|
+
await new Promise((r) => setTimeout(r, 2));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function sessionHeader(id = "baseline-session"): Record<string, unknown> {
|
|
153
|
+
return { type: "session", id, timestamp: "2026-08-11T00-00-00-000Z", cwd: "/tmp/test" };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function emitStdoutLine(child: FakeChild, obj: Record<string, unknown>): void {
|
|
157
|
+
child.stdout.write(`${JSON.stringify(obj)}\n`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** 驱动 FakeChild 完成:header + 可选事件 + close(0)(runSpawn 自然 resolve)。 */
|
|
161
|
+
async function driveChildToCompletion(child: FakeChild, events: Record<string, unknown>[] = []): Promise<void> {
|
|
162
|
+
emitStdoutLine(child, sessionHeader());
|
|
163
|
+
for (const e of events) emitStdoutLine(child, e);
|
|
164
|
+
child.stdout.end();
|
|
165
|
+
child.stderr.end();
|
|
166
|
+
child.emit("close", 0);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── 辅助:service 构造 ──
|
|
170
|
+
|
|
171
|
+
function makeEmptyRegistry(): ModelRegistryLike {
|
|
172
|
+
return { getAvailable: () => [], find: () => undefined, hasConfiguredAuth: () => true };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function makePi() {
|
|
176
|
+
return { sendMessage: vi.fn(), appendEntry: vi.fn(), events: { emit: vi.fn() } };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function setup(env: Record<string, string>): { service: SubagentService; store: RecordStore } {
|
|
180
|
+
const agentDir = "/tmp/baseline-it";
|
|
181
|
+
const modelService = new ModelConfigService({ agentDir });
|
|
182
|
+
modelService.initModel({
|
|
183
|
+
modelRegistry: makeEmptyRegistry(),
|
|
184
|
+
sessionId: "baseline-it",
|
|
185
|
+
ctxModel: { id: "m", name: "M", provider: "p", reasoning: false },
|
|
186
|
+
});
|
|
187
|
+
const service = new SubagentService({
|
|
188
|
+
cwd: agentDir,
|
|
189
|
+
modelService,
|
|
190
|
+
getMainSessionFile: () => "/mock/main-session.jsonl",
|
|
191
|
+
});
|
|
192
|
+
service.initSession({ pi: makePi(), sessionId: "baseline-it" });
|
|
193
|
+
const store = Reflect.get(service, "store") as RecordStore;
|
|
194
|
+
return { service, store };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const ctxModel: ModelInfo = { id: "m", name: "M", provider: "p", reasoning: false };
|
|
198
|
+
|
|
199
|
+
/** execCtxAls.run 的 duck-type(绕过 import AsyncLocalStorage)。 */
|
|
200
|
+
interface ExecCtxAls {
|
|
201
|
+
run: <T>(store: { recordId: string | undefined; depth: number }, cb: () => T) => T;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── 用例 ──
|
|
205
|
+
|
|
206
|
+
describe("进程级基线兜底(ALS 断裂修复,pi 事件回调模型)", () => {
|
|
207
|
+
beforeEach(() => {
|
|
208
|
+
vi.clearAllMocks();
|
|
209
|
+
// 清理身份 env,防用例间泄漏
|
|
210
|
+
for (const k of [ENV_ROOT_SESSION_ID, ENV_SELF_RECORD_ID, ENV_DEPTH, ENV_FORK_DEPTH, ENV_ROOT_CWD]) {
|
|
211
|
+
delete process.env[k];
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
afterEach(() => {
|
|
216
|
+
for (const k of [ENV_ROOT_SESSION_ID, ENV_SELF_RECORD_ID, ENV_DEPTH, ENV_FORK_DEPTH, ENV_ROOT_CWD]) {
|
|
217
|
+
delete process.env[k];
|
|
218
|
+
}
|
|
219
|
+
vi.restoreAllMocks();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("[核心回归] 有 env(子进程身份):execute 创建 record 的 parentRecordId/depth 来自基线,不依赖 ALS store", async () => {
|
|
223
|
+
// 模拟第一层 subagent 进程:env 注入「自己的身份」(父 record id + depth=1)
|
|
224
|
+
process.env[ENV_ROOT_SESSION_ID] = "root-main";
|
|
225
|
+
process.env[ENV_SELF_RECORD_ID] = "sa-parent-record";
|
|
226
|
+
process.env[ENV_DEPTH] = "1";
|
|
227
|
+
|
|
228
|
+
const { service, store } = setup({});
|
|
229
|
+
|
|
230
|
+
// 不在 execCtxAls.run 内调用(模拟 ALS 断裂:事件回调上下文读不到 store)
|
|
231
|
+
const handle = await service.execute({ task: "child of parent", ctxModel });
|
|
232
|
+
|
|
233
|
+
const rec = store.collectRecords(10, "all", "root-main").find((r) => r.id === handle.subagentId);
|
|
234
|
+
expect(rec).toBeDefined();
|
|
235
|
+
expect(rec!.parentRecordId).toBe("sa-parent-record");
|
|
236
|
+
expect(rec!.depth).toBe(2); // 基线 depth 1 + 1
|
|
237
|
+
expect(rec!.rootSessionId).toBe("root-main");
|
|
238
|
+
|
|
239
|
+
// [MF-1 回归] 读侧 collectRecords 过滤必须与写侧盖章同源(sessionRootId)。
|
|
240
|
+
// 旧实现传 this.sessionId(子进程自己的 session id ≠ ROOT)→ 子进程内列表恒空。
|
|
241
|
+
// 子进程的本进程 sessionId 是 "baseline-it"(initSession 注入),而 record 归属
|
|
242
|
+
// root-main(env 贯穿的真 ROOT)——能查到即证明过滤用的是 sessionRootId。
|
|
243
|
+
const viaService = service.collectRecords(10);
|
|
244
|
+
expect(viaService.map((r) => r.id)).toContain(handle.subagentId);
|
|
245
|
+
expect(viaService[0]!.rootSessionId).toBe("root-main");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("[顶层] 无 env(根进程):parentRecordId undefined / depth 0", async () => {
|
|
249
|
+
const { service, store } = setup({});
|
|
250
|
+
|
|
251
|
+
const handle = await service.execute({ task: "top level", ctxModel });
|
|
252
|
+
|
|
253
|
+
const rec = store.collectRecords(10, "all", "baseline-it").find((r) => r.id === handle.subagentId);
|
|
254
|
+
expect(rec).toBeDefined();
|
|
255
|
+
expect(rec!.parentRecordId).toBeUndefined();
|
|
256
|
+
expect(rec!.depth).toBe(0);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("[优先级] execCtxAls.run 的 store 优先于基线(并发链语义不回归)", async () => {
|
|
260
|
+
process.env[ENV_ROOT_SESSION_ID] = "root-main";
|
|
261
|
+
process.env[ENV_SELF_RECORD_ID] = "sa-baseline";
|
|
262
|
+
process.env[ENV_DEPTH] = "0";
|
|
263
|
+
|
|
264
|
+
const { service, store } = setup({});
|
|
265
|
+
const execCtxAls = Reflect.get(service, "execCtxAls") as ExecCtxAls;
|
|
266
|
+
|
|
267
|
+
const handle = await execCtxAls.run({ recordId: "sa-inline-parent", depth: 3 }, () =>
|
|
268
|
+
service.execute({ task: "inline nested", ctxModel }),
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
const rec = store.collectRecords(10, "all", "root-main").find((r) => r.id === handle.subagentId);
|
|
272
|
+
expect(rec).toBeDefined();
|
|
273
|
+
expect(rec!.parentRecordId).toBe("sa-inline-parent"); // run store 优先,非基线 sa-baseline
|
|
274
|
+
expect(rec!.depth).toBe(4);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("[forkDepth 基线] env PI_SUBAGENT_FORK_DEPTH=1 + fork:spawn env 递增为 2(读点兜底基线生效)", async () => {
|
|
278
|
+
process.env[ENV_ROOT_SESSION_ID] = "root-main";
|
|
279
|
+
process.env[ENV_SELF_RECORD_ID] = "sa-fork-parent";
|
|
280
|
+
process.env[ENV_DEPTH] = "0";
|
|
281
|
+
process.env[ENV_FORK_DEPTH] = "1";
|
|
282
|
+
|
|
283
|
+
const { service } = setup({});
|
|
284
|
+
|
|
285
|
+
const execPromise = service.execute({ task: "fork child", ctxModel, fork: true });
|
|
286
|
+
await waitForSpawn();
|
|
287
|
+
const childEnv = getLastSpawnEnv();
|
|
288
|
+
|
|
289
|
+
// 742 行 parentDepth = forkDepthAls.getStore() ?? forkDepthBaseline —— ALS 断裂时基线=1,+1 → 2
|
|
290
|
+
expect(childEnv.PI_SUBAGENT_FORK_DEPTH).toBe("2");
|
|
291
|
+
|
|
292
|
+
const child = lastSpawnedChild();
|
|
293
|
+
child.emit("close", 0);
|
|
294
|
+
await execPromise;
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("[嵌套护栏] 基线 depth 参与 execute 入口嵌套护栏(depth>MAX 拒绝)", async () => {
|
|
298
|
+
process.env[ENV_ROOT_SESSION_ID] = "root-main";
|
|
299
|
+
process.env[ENV_SELF_RECORD_ID] = "sa-deep-parent";
|
|
300
|
+
process.env[ENV_DEPTH] = "5";
|
|
301
|
+
|
|
302
|
+
const { service } = setup({});
|
|
303
|
+
|
|
304
|
+
// MAX_FORK_DEPTH 至少 > 5 才不会被误拒;这里验证基线参与计数的方式是
|
|
305
|
+
// 用直接深度断言——execute 不抛错说明 nestingDepth=6 未超限,护栏不误伤。
|
|
306
|
+
// (MAX_FORK_DEPTH 具体值由 session-context-resolver 定义,这里不硬编码。)
|
|
307
|
+
await expect(service.execute({ task: "deep but allowed", ctxModel })).resolves.toBeDefined();
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("[MF-3 回归] 有 ENV_ROOT_CWD(worktree 子进程):sessions 与 records 两套目录统一编码在 ROOT cwd 段", () => {
|
|
311
|
+
// 模拟 B(worktree 子进程,自身 cwd=checkout 路径)经 env 拿到真 ROOT cwd。
|
|
312
|
+
// 旧实现按 init.cwd 编码 → enc(checkout) 段,ROOT 磁盘重建扫不到(MF-3)。
|
|
313
|
+
const rootCwd = "/root/project";
|
|
314
|
+
process.env[ENV_ROOT_CWD] = rootCwd;
|
|
315
|
+
|
|
316
|
+
const agentDir = "/tmp/baseline-it";
|
|
317
|
+
const checkoutPath = "/var/folders/worktree/pi-subagents/--root-project--/branch";
|
|
318
|
+
const modelService = new ModelConfigService({ agentDir });
|
|
319
|
+
modelService.initModel({
|
|
320
|
+
modelRegistry: makeEmptyRegistry(),
|
|
321
|
+
sessionId: "baseline-it",
|
|
322
|
+
ctxModel: { id: "m", name: "M", provider: "p", reasoning: false },
|
|
323
|
+
});
|
|
324
|
+
// service cwd = checkout 路径(worktree 子进程的 spawn cwd)
|
|
325
|
+
const service = new SubagentService({
|
|
326
|
+
cwd: checkoutPath,
|
|
327
|
+
modelService,
|
|
328
|
+
getMainSessionFile: () => "/mock/main-session.jsonl",
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
const store = Reflect.get(service, "store") as RecordStore;
|
|
332
|
+
const sessionsDir = Reflect.get(store, "sessionsDir") as string;
|
|
333
|
+
// ManifestStore 在本文件被 mock:recordsDir 从构造调用参数取(vi.fn 包裹)
|
|
334
|
+
const recordsDir = vi.mocked(ManifestStore).mock.calls.at(-1)?.[0] as string | undefined;
|
|
335
|
+
|
|
336
|
+
// 两套目录都编码在 enc(ROOT cwd) 段(不是 enc(checkout))
|
|
337
|
+
expect(sessionsDir).toBe(getSubagentSessionDir(agentDir, rootCwd));
|
|
338
|
+
expect(recordsDir).toBe(getSubagentRecordsDir(agentDir, rootCwd));
|
|
339
|
+
});
|
|
340
|
+
});
|
|
@@ -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
|
|
|
@@ -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" })),
|
|
@@ -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
|
|
|
@@ -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 ID
|
|
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
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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() ??
|
|
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
|
}
|
|
@@ -226,7 +226,21 @@ export async function cancelHandler(
|
|
|
226
226
|
|
|
227
227
|
// step 1: id 不存在(findRecord 只查内存 running record,不从 session.jsonl 重建)
|
|
228
228
|
const rec = service.findRecord(id);
|
|
229
|
-
if (!rec)
|
|
229
|
+
if (!rec) {
|
|
230
|
+
// [S-19] MF-1 全树可见后,list/completion 可能列出其他进程(父/兄弟)的 running record
|
|
231
|
+
//(collectRecords 扫共享 sessionsDir 按 rootSessionId 过滤,跨进程互相可见),而 cancel
|
|
232
|
+
// 只作用于本进程内存 record。区分两种失败,避免「may have finished」误导(该 record 正
|
|
233
|
+
// 被列出且未 finished,只是不属于本进程内存)。仅文案区分,不改 cancel 作用域。
|
|
234
|
+
const treeRec = service.collectRecords(DEFAULT_LIST_LIMIT, "all").find((r) => r.id === id);
|
|
235
|
+
if (treeRec && treeRec.status === "running") {
|
|
236
|
+
throw new Error(
|
|
237
|
+
`Subagent record "${id}" is running but owned by another process in the tree ` +
|
|
238
|
+
`(it was spawned by a different subagent process) — this process cannot cancel it; ` +
|
|
239
|
+
`cancel only works for subagents spawned by the current process.`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
throw new Error(`No subagent record with id "${id}". It may have finished — use action:'list' with includeFinished:true to verify.`);
|
|
243
|
+
}
|
|
230
244
|
// step 2: controller 检查(controller 为 undefined 表示 record 已终态或未启动)
|
|
231
245
|
if (rec.mode !== "background") {
|
|
232
246
|
throw new Error(`Cannot cancel subagent ${id} (unsupported mode: ${rec.mode})`);
|
|
@@ -32,7 +32,7 @@ export function registerSubagentsCommand(pi: ExtensionAPI): void {
|
|
|
32
32
|
try {
|
|
33
33
|
const service = getSubagentService();
|
|
34
34
|
if (!service) return null;
|
|
35
|
-
// collectRecords 合并内存(running) + 磁盘重建 record,按
|
|
35
|
+
// collectRecords 合并内存(running) + 磁盘重建 record,按 rootSessionId 过滤。
|
|
36
36
|
// cancel 只对 running 有效,但全部列出便于用户辨认(终态 record 会被 service 拒绝)。
|
|
37
37
|
const records = service.collectRecords(LIST_LIMIT);
|
|
38
38
|
if (records.length === 0) return null;
|
|
@@ -175,3 +175,55 @@ describe("W2: RunStore.stateFilePath 暴露 run 状态文件路径", () => {
|
|
|
175
175
|
expect(result).toBe(path.join(tmpDir, "workflow-state", "run-xyz.jsonl"));
|
|
176
176
|
});
|
|
177
177
|
});
|
|
178
|
+
|
|
179
|
+
// W3: save 兜底容错——run 工作目录被并发清理时 mkdir 抛 ENOENT,save 静默返回。
|
|
180
|
+
//
|
|
181
|
+
// 防的 bug(PR #166 CI 回归):review-fix-loop-e2e 等 runAndWait 测试中,
|
|
182
|
+
// handleReturn 的 run.transition("done") 同步改 status 后,runAndWait 轮询发现 done
|
|
183
|
+
// 并 resolve,测试 afterEach 随即 rmSync 删除 sessionDir;此时 handleReturn 内 in-flight
|
|
184
|
+
// 的 await save 尚未完成,mkdir 遇到目录链被并发删除 → ENOENT。原实现 await save 让错误
|
|
185
|
+
// 冒泡为 unhandled promise rejection(worker-host onMessage 无 catch),CI exit 1。
|
|
186
|
+
// 修复:save 仅容错 ENOENT(run 已终态,状态不再变化,持久化无意义也无法完成)→ silent return;
|
|
187
|
+
// 非 ENOENT 错误(EACCES/ENOSPC 等真实磁盘问题)仍重新抛出,不掩盖。
|
|
188
|
+
//
|
|
189
|
+
// 注:真正的 ENOENT 只在 rmSync 与 mkdir 并发时出现(串行 rmSync 后 mkdir {recursive:true}
|
|
190
|
+
// 会重建目录而非抛 ENOENT),故用 mock 直接锁定 save 的容错判定逻辑,不依赖竞态时序复现。
|
|
191
|
+
describe("W3: JsonlRunStore.save 兜底容错(run 工作目录被并发清理时的竞态)", () => {
|
|
192
|
+
let tmpDir: string;
|
|
193
|
+
let store: JsonlRunStore;
|
|
194
|
+
|
|
195
|
+
beforeEach(() => {
|
|
196
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-store-enoent-"));
|
|
197
|
+
store = new JsonlRunStore({ sessionDir: tmpDir });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
afterEach(() => {
|
|
201
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
202
|
+
vi.restoreAllMocks();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("mkdir 抛 ENOENT(sessionDir 被并发清理)→ save 静默返回,不抛 unhandled rejection", async () => {
|
|
206
|
+
const run = makeRunWithDoneCall();
|
|
207
|
+
const spy = vi
|
|
208
|
+
.spyOn(fs.promises, "mkdir")
|
|
209
|
+
.mockRejectedValueOnce(
|
|
210
|
+
Object.assign(new Error("ENOENT: no such file or directory, mkdir"), {
|
|
211
|
+
code: "ENOENT",
|
|
212
|
+
}),
|
|
213
|
+
);
|
|
214
|
+
// run 已终态 + 工作目录消失 → save 放弃持久化,resolve undefined(不抛)
|
|
215
|
+
await expect(store.save(run)).resolves.toBeUndefined();
|
|
216
|
+
expect(spy).toHaveBeenCalled();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("mkdir 抛非 ENOENT 错误(EACCES)→ save 重新抛出,不掩盖真实磁盘问题", async () => {
|
|
220
|
+
const run = makeRunWithDoneCall();
|
|
221
|
+
const spy = vi
|
|
222
|
+
.spyOn(fs.promises, "mkdir")
|
|
223
|
+
.mockRejectedValueOnce(
|
|
224
|
+
Object.assign(new Error("permission denied"), { code: "EACCES" }),
|
|
225
|
+
);
|
|
226
|
+
await expect(store.save(run)).rejects.toThrow("permission denied");
|
|
227
|
+
expect(spy).toHaveBeenCalled();
|
|
228
|
+
});
|
|
229
|
+
});
|
|
@@ -241,7 +241,26 @@ export class JsonlRunStore {
|
|
|
241
241
|
*/
|
|
242
242
|
async save(run: WorkflowRun): Promise<void> {
|
|
243
243
|
const filePath = this.filePathFor(run.runId);
|
|
244
|
-
|
|
244
|
+
// 兜底容错:run 工作目录(sessionDir)已被清理时,mkdir 抛 ENOENT,save 放弃。
|
|
245
|
+
// 竞态场景(review-fix-loop-e2e 等 runAndWait 测试):handleReturn 内
|
|
246
|
+
// run.transition("done") 同步改 status 后,runAndWait 轮询发现 done 并 resolve,
|
|
247
|
+
// 测试 afterEach 随即 rmSync 删除 sessionDir;此时本方法 in-flight 的 mkdir
|
|
248
|
+
// 遇到目录链已删除 → ENOENT({recursive:true} 在并发 rmSync 下仍可抛 ENOENT)。
|
|
249
|
+
// run 既已终态(状态不再变化),持久化无意义也无法完成 → silent return。
|
|
250
|
+
// 仅容错 ENOENT,重新抛出其他错误(EACCES/ENOSPC 等真实磁盘问题不掩盖)。
|
|
251
|
+
try {
|
|
252
|
+
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
|
253
|
+
} catch (err) {
|
|
254
|
+
if (
|
|
255
|
+
typeof err === "object" &&
|
|
256
|
+
err !== null &&
|
|
257
|
+
"code" in err &&
|
|
258
|
+
(err as { code: unknown }).code === "ENOENT"
|
|
259
|
+
) {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
throw err;
|
|
263
|
+
}
|
|
245
264
|
const snapshot = serializeRun(run);
|
|
246
265
|
await fs.promises.writeFile(filePath, JSON.stringify(snapshot) + "\n", "utf8");
|
|
247
266
|
if (this.pi) {
|