@zhushanwen/pi-subagent-workflow 7.3.1 → 7.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-subagent-workflow",
3
- "version": "7.3.1",
3
+ "version": "7.3.2",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
6
  "description": "Unified subagent execution and multi-agent workflow orchestration for Pi — spawned-process agent runtime with sync/background modes, stateful workflow management with persistence, state machine, and execution tracing.",
@@ -0,0 +1,206 @@
1
+ // src/execution/__tests__/spawn-worktree-guidance.test.ts
2
+ //
3
+ // worktree 模式认知纠正提示注入测试。
4
+ //
5
+ // 背景:worktree checkout 放 os.tmpdir()(路径形似临时沙箱),子 agent 无 worktree 语义
6
+ // 提示时误判 cwd 为"空隔离目录",主动 cd 别处放弃隔离(wave-agent 事故 session 019ff64c)。
7
+ // 修复在 runSpawn 的 appendParts 注入 WORKTREE_GUIDANCE_PROMPT。
8
+ //
9
+ // 验证:
10
+ // - worktree 模式(opts.worktree 传入)→ appendSystemPrompt content 含 WORKTREE_GUIDANCE_PROMPT
11
+ // - 非 worktree 模式 → content 不含 worktree 提示
12
+ //
13
+ // 测试策略:mock writePromptToTempFile 捕获拼接后的 appendParts content(vi.hoisted 防
14
+ // vi.mock hoisting 引用未初始化变量),断言含/不含 worktree 提示标识。
15
+
16
+ import type { PassThrough } from "node:stream";
17
+
18
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
19
+
20
+ // 捕获 writePromptToTempFile 的 content 参数(appendParts.join("\n\n"))。
21
+ // vi.hoisted 保证变量在 vi.mock factory hoisting 前已声明可用。
22
+ const captured = vi.hoisted(() => ({ content: "" }));
23
+
24
+ // ── mock modules(与 session-runner-schema-env.test.ts 同模式)──
25
+
26
+ vi.mock("node:child_process", async () => {
27
+ const { EventEmitter } = await import("node:events");
28
+ const { PassThrough } = await import("node:stream");
29
+
30
+ class FakeChild extends EventEmitter {
31
+ pid = 12345;
32
+ stdout = new PassThrough();
33
+ stderr = new PassThrough();
34
+ killed = false;
35
+ killSignal: string | undefined;
36
+ kill(sig?: string): boolean {
37
+ this.killed = true;
38
+ this.killSignal = sig;
39
+ return true;
40
+ }
41
+ }
42
+
43
+ return {
44
+ spawn: vi.fn(() => new FakeChild()),
45
+ execFileSync: vi.fn(() => ""),
46
+ };
47
+ });
48
+
49
+ vi.mock("node:fs", async () => {
50
+ const actual = await import("node:fs");
51
+ return {
52
+ default: {
53
+ ...actual,
54
+ mkdirSync: vi.fn(),
55
+ existsSync: vi.fn(() => false),
56
+ appendFileSync: vi.fn(),
57
+ writeFileSync: vi.fn(),
58
+ readdirSync: vi.fn(() => []),
59
+ },
60
+ mkdirSync: vi.fn(),
61
+ existsSync: vi.fn(() => false),
62
+ appendFileSync: vi.fn(),
63
+ writeFileSync: vi.fn(),
64
+ readdirSync: vi.fn(() => []),
65
+ promises: actual.promises,
66
+ };
67
+ });
68
+
69
+ vi.mock("../alive-store.ts", () => ({
70
+ writeAliveMarker: vi.fn(),
71
+ }));
72
+
73
+ vi.mock("../temp-prompt.ts", () => ({
74
+ writePromptToTempFile: vi.fn(async (agent: string, content: string) => {
75
+ captured.content = content;
76
+ const safeName = agent.replace(/[^\w.-]+/g, "_");
77
+ return { dir: `/tmp/fake-${safeName}`, filePath: `/tmp/fake-${safeName}/prompt.md` };
78
+ }),
79
+ cleanupTempPrompt: vi.fn(async () => {}),
80
+ }));
81
+
82
+ import { spawn } from "node:child_process";
83
+
84
+ import { createRecord } from "../execution-record.ts";
85
+ import { runSpawn, type RunOptions, type SessionRunnerContext } from "../session-runner.ts";
86
+
87
+ const mockSpawn = vi.mocked(spawn);
88
+
89
+ interface FakeChild {
90
+ pid: number;
91
+ stdout: PassThrough;
92
+ stderr: PassThrough;
93
+ killed: boolean;
94
+ killSignal: string | undefined;
95
+ kill(sig?: string): boolean;
96
+ emit(event: string, ...args: unknown[]): boolean;
97
+ }
98
+
99
+ function getLastSpawnedChild(): FakeChild {
100
+ const result = mockSpawn.mock.results.at(-1);
101
+ if (!result) throw new Error("spawn was not called yet");
102
+ return result.value as FakeChild;
103
+ }
104
+
105
+ async function waitForSpawn(timeoutMs = 1000): Promise<void> {
106
+ const start = Date.now();
107
+ while (mockSpawn.mock.results.length === 0) {
108
+ if (Date.now() - start > timeoutMs) {
109
+ throw new Error(`spawn was not called within ${timeoutMs}ms`);
110
+ }
111
+ await new Promise((r) => setTimeout(r, 5));
112
+ }
113
+ }
114
+
115
+ function makeRecord() {
116
+ return createRecord("wt-guidance-1", {
117
+ agent: "general-purpose",
118
+ model: "test/model",
119
+ mode: "sync",
120
+ task: "test task",
121
+ startedAt: Date.now(),
122
+ rootSessionId: "s1",
123
+ parentRecordId: undefined,
124
+ depth: 0,
125
+ });
126
+ }
127
+
128
+ function makeRunOpts(overrides: Partial<RunOptions> = {}): RunOptions {
129
+ return {
130
+ resolved: { model: { provider: "test", id: "model" }, thinkingLevel: undefined },
131
+ agentConfig: undefined,
132
+ appendSystemPrompt: undefined,
133
+ skillPath: undefined,
134
+ schema: undefined,
135
+ maxTurns: undefined,
136
+ graceTurns: undefined,
137
+ signal: undefined,
138
+ onEvent: undefined,
139
+ ...overrides,
140
+ };
141
+ }
142
+
143
+ function makeCtx(overrides: Partial<SessionRunnerContext> = {}): SessionRunnerContext {
144
+ return {
145
+ cwd: "/fake/cwd",
146
+ agentDir: "/fake/agent",
147
+ skillDirs: [],
148
+ mainCwd: "/fake/cwd",
149
+ sessionRootId: "root-session-test",
150
+ rootCwd: "/fake/cwd",
151
+ ...overrides,
152
+ };
153
+ }
154
+
155
+ describe("worktree guidance prompt injection", () => {
156
+ beforeEach(() => {
157
+ vi.clearAllMocks();
158
+ captured.content = "";
159
+ });
160
+
161
+ afterEach(() => {
162
+ vi.restoreAllMocks();
163
+ });
164
+
165
+ it("worktree 模式 → appendSystemPrompt content 含 worktree 认知纠正提示", async () => {
166
+ const record = makeRecord();
167
+ const opts = makeRunOpts({
168
+ worktree: {
169
+ path: "/tmp/pi-subagents/--fake--/pi-sub-sa-wt-guidance-1",
170
+ branch: "pi-sub-sa-wt-guidance-1",
171
+ baseCommit: "abc123",
172
+ mainCwd: "/fake/cwd",
173
+ },
174
+ });
175
+ const ctx = makeCtx();
176
+
177
+ const resultPromise = runSpawn(record, "test task", opts, ctx);
178
+ await waitForSpawn();
179
+
180
+ // content 含 WORKTREE_GUIDANCE_PROMPT 的标题
181
+ expect(captured.content).toContain("Git Worktree");
182
+ // 含关键纠正信息:cwd 含完整项目代码(非临时沙箱)
183
+ expect(captured.content).toContain("complete project source code");
184
+ // 含防 cd 别处的行为指引
185
+ expect(captured.content).toContain("Do NOT");
186
+
187
+ const child = getLastSpawnedChild();
188
+ child.emit("close", 0);
189
+ await resultPromise;
190
+ });
191
+
192
+ it("非 worktree 模式 → content 不含 worktree 认知提示", async () => {
193
+ const record = makeRecord();
194
+ const opts = makeRunOpts(); // 不传 worktree
195
+ const ctx = makeCtx();
196
+
197
+ const resultPromise = runSpawn(record, "test task", opts, ctx);
198
+ await waitForSpawn();
199
+
200
+ expect(captured.content).not.toContain("Git Worktree");
201
+
202
+ const child = getLastSpawnedChild();
203
+ child.emit("close", 0);
204
+ await resultPromise;
205
+ });
206
+ });
@@ -174,6 +174,30 @@ The \`ask_user\` tool is available in this session. When you call \`ask_user\`,
174
174
  - Use ask_user only when you genuinely cannot resolve ambiguity yourself (see tool description for guidelines)
175
175
  `.trim();
176
176
 
177
+ /**
178
+ * worktree 模式注入子 agent 的认知纠正提示。
179
+ *
180
+ * 背景:worktree checkout 放在 os.tmpdir()(如 `/private/var/folders/.../pi-subagents/.../pi-sub-<id>`),
181
+ * 路径形似临时沙箱。子 agent system prompt 无任何 worktree 语义说明时,会误判 cwd 为
182
+ * "空隔离目录",主动 cd 别处(如主 worktree)放弃隔离——实测见 wave-agent 事故
183
+ *(session 019ff64c T001 自述 cwd 是 pi 隔离目录,实际是合法 worktree checkout)。
184
+ *
185
+ * 此提示在 worktree 模式下注入,明确告知子 agent:cwd 是含完整项目代码的 git worktree,
186
+ * 直接在此工作即可,不要 cd 别处找"真正的项目"。
187
+ */
188
+ export const WORKTREE_GUIDANCE_PROMPT = `
189
+ ## Working Directory Is a Git Worktree
190
+
191
+ Your working directory (the "Working directory" in the environment block above) is a **dedicated git worktree** — an isolated checkout of the repository at HEAD, NOT a temporary sandbox. It contains the **complete project source code**.
192
+
193
+ **You should:**
194
+ - Work directly in your current cwd — it already has the full project (every file). Read project files via relative paths as usual.
195
+ - To locate the shared repository root: \`git rev-parse --git-common-dir\`.
196
+ - Your file changes are automatically captured as a patch when you finish — just do the work; no need to commit, push, or merge.
197
+
198
+ **Do NOT** \`cd\` to another directory looking for "the real project" — your cwd IS the project. A path like \`/private/var/folders/.../pi-subagents/.../pi-sub-<id>\` is your worktree checkout, not an empty sandbox.
199
+ `.trim();
200
+
177
201
  // ============================================================
178
202
  // 孤儿进程兜底(C1)
179
203
  // ============================================================
@@ -662,6 +686,11 @@ export async function runSpawn(
662
686
  if (opts.agentConfig?.tools?.includes("ask_user") && willRespondToAskUser(ctx.mode)) {
663
687
  appendParts.push(ASK_USER_RPC_PROMPT);
664
688
  }
689
+ // worktree 认知纠正:告知子 agent cwd 是 git worktree(非临时沙箱),含完整项目代码,
690
+ // 直接在此工作。防 wave-agent 类误判 cwd 为空隔离目录后 cd 主 worktree 放弃隔离。
691
+ if (opts.worktree) {
692
+ appendParts.push(WORKTREE_GUIDANCE_PROMPT);
693
+ }
665
694
  if (appendParts.length > 0) {
666
695
  tempPromptFile = await writePromptToTempFile(record.agent, appendParts.join("\n\n"));
667
696
  }