@zhushanwen/pi-subagent-workflow 7.3.1 → 7.3.3

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.3",
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
  }
@@ -735,6 +764,26 @@ export async function runSpawn(
735
764
  // abortRunningControllers 跳过它,靠本 Set 兜底)。close/error 后移除(已退出无需再 kill)。
736
765
  spawnedChildren.add(child);
737
766
 
767
+ // [worktree-reaper-fix] 同步补全注册表 pid:spawn 返回后 child.pid 立即可得(Node.js
768
+ // 同步属性),无需等任何 stdout 事件。原补全点挂在 header 分支(下方 stdout handler 内),
769
+ // 而 RPC mode(buildSpawnArgs 固定 --mode rpc)不输出 header 行——pid 恒为 0,超
770
+ // SPAWN_GRACE_MS 后被 reaper 当孤儿误删活 worktree(2026-08-11 cw 递归编排整树失活事故)。
771
+ // header 分支调用保留:json mode 回切时仍能补全,updatePid 同 branch 覆盖写幂等,无副作用。
772
+ // [S1] 防御:必须放在 spawnedChildren.add 之后(onWorktreePid 抛错时子进程已被跟踪,
773
+ // dispose 兜底 kill 不会泄漏),且包 try/catch(补全失败不阻断 spawn 主流程——
774
+ // 注册表写失败最坏后果是条目停留 pid=0,由 reaper 宽限回收兜底)。
775
+ if (opts.worktree && child.pid) {
776
+ try {
777
+ ctx.onWorktreePid?.(opts.worktree.branch, child.pid);
778
+ } catch (err) {
779
+ logger.warn("[worktree] worktree pid registration failed (defensive)", {
780
+ branch: opts.worktree.branch,
781
+ pid: child.pid,
782
+ err: err instanceof Error ? err.message : String(err),
783
+ });
784
+ }
785
+ }
786
+
738
787
  // stdout/stderr 用 utf8 编码:stream 自动按字符边界切分,避免多字节
739
788
  // UTF-8(CJK/emoji)跨 chunk 时 toString() 产生 U+FFFD 替换符导致 JSON.parse 失败。
740
789
  // [m2] 先 setEncoding 再注册 signal listener/watchdog:若 setEncoding 抛错,try/finally
@@ -968,9 +1017,12 @@ export async function runSpawn(
968
1017
  // [worktree-reaper-fix] 拼 spawnCwd 进错误消息:ENOENT 的 err.message 只含 command 名,
969
1018
  // 无 cwd 线索(worktree 被 reaper 误删后 cwd 指向虚空)会导致误诊——2026-08-11 事故
970
1019
  // AI 误判"node 被卸载"的直接原因。
1020
+ // [S3] code 读取带运行时 guard:非 ErrnoException(普通 Error)时 code 为 undefined,
1021
+ // 不加 cwd hint(行为与修复前一致);仅 ENOENT 才拼 cwd。
971
1022
  spawnedChildren.delete(child);
972
1023
  const errno = err as NodeJS.ErrnoException;
973
- const cwdHint = errno.code === "ENOENT" ? ` (cwd: ${spawnCwd})` : "";
1024
+ const errCode = "code" in err ? errno.code : undefined;
1025
+ const cwdHint = errCode === "ENOENT" ? ` (cwd: ${spawnCwd})` : "";
974
1026
  record.lastError = `${err.message}${cwdHint}`;
975
1027
  resolve(SIGNAL_EXIT_CODE_THRESHOLD); // 非零退出
976
1028
  });
package/src/index.ts CHANGED
@@ -14,7 +14,6 @@
14
14
  */
15
15
 
16
16
  import * as fs from "node:fs";
17
- import * as os from "node:os";
18
17
  import * as path from "node:path";
19
18
 
20
19
  import type { ExtensionAPI, ExtensionContext, SessionShutdownEvent, SessionStartEvent, SessionTreeEvent } from "@earendil-works/pi-coding-agent";
@@ -166,9 +165,10 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
166
165
  }
167
166
 
168
167
  function resolveSessionDir(): string {
169
- const defaultDir = path.join(os.homedir(), ".pi", "agent");
168
+ const defaultDir = getAgentDir();
170
169
  const sessionSlug = `--${process.cwd().replace(/^\//, "").replace(/\//g, "-")}--`;
171
- const sessionScopedDir = path.join(os.homedir(), ".pi", "agent", "sessions", sessionSlug);
170
+ // F2:根改 getAgentDir() 派生(实例隔离);保留 sessionScopedDir 存在则用之的探测语义
171
+ const sessionScopedDir = path.join(getAgentDir(), "sessions", sessionSlug);
172
172
  return fs.existsSync(sessionScopedDir) ? sessionScopedDir : defaultDir;
173
173
  }
174
174
 
@@ -310,6 +310,7 @@ export function formatToolCall(
310
310
  theme: ThemeLike,
311
311
  ): string {
312
312
  const shortenPath = (p: string): string => {
313
+ // 仅用于显示层路径缩写(~ 替换 home 前缀),不读取 pi 目录(TC9 合法命中)
313
314
  const home = os.homedir();
314
315
  return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
315
316
  };
@@ -23,10 +23,10 @@
23
23
  */
24
24
 
25
25
  import { promises as fsPromises } from "node:fs";
26
- import { homedir } from "node:os";
27
26
  import { join as pathJoin } from "node:path";
28
27
 
29
28
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
29
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
30
30
  import { Key, matchesKey } from "@earendil-works/pi-tui";
31
31
 
32
32
  import {
@@ -858,11 +858,11 @@ const TRACE_ACTIVITY_WIDTH = 80;
858
858
 
859
859
  /**
860
860
  * 导出完整 workflow trace 到 Markdown 文件。
861
- * 路径:~/.pi/agent/workflow-traces/{runId}.md
861
+ * 路径:<agentDir>/workflow-traces/{runId}.md(agentDir = getAgentDir(),实例隔离)
862
862
  * 对齐 main 的 saveTraceToFile(WorkflowsView.ts:365-396)。
863
863
  */
864
864
  function saveTraceToFile(run: WorkflowRun, ctx: ExtensionContext): void {
865
- const dir = pathJoin(homedir(), ".pi", "agent", "workflow-traces");
865
+ const dir = pathJoin(getAgentDir(), "workflow-traces");
866
866
  const filePath = pathJoin(dir, `${run.runId}.md`);
867
867
  const lines: string[] = [];
868
868
  lines.push(`# Workflow Trace: ${run.spec.scriptName} (${run.runId})`, "");
@@ -6,9 +6,10 @@
6
6
  */
7
7
 
8
8
  import * as fs from "node:fs";
9
- import * as os from "node:os";
10
9
  import * as path from "node:path";
11
10
 
11
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
12
+
12
13
  // ── Skill path resolution (with npm dir cache) ─────────────────────
13
14
 
14
15
  const skillCandidatesCache = new Map<string, string[]>();
@@ -32,8 +33,9 @@ function getNpmSkillCandidates(npmSkillsDir: string): string[] {
32
33
  * Resolve a skill name to its directory or SKILL.md path.
33
34
  * Search order:
34
35
  * 1. Project-level: .agents/skills/<name>/
35
- * 2. Global: ~/.pi/agent/skills/<name>/
36
- * 3. npm packages: ~/.pi/agent/npm/node_modules/<pkg>/skills/<name>/
36
+ * 2. Global: <agentDir>/skills/<name>/(agentDir = getAgentDir(),实例隔离:
37
+ * PI_CODING_AGENT_DIR 场景读隔离目录,不碰 ~/.pi/agent
38
+ * 3. npm packages: <agentDir>/npm/node_modules/<pkg>/skills/<name>/
37
39
  * Returns the directory path if found, undefined otherwise.
38
40
  */
39
41
  export function resolveSkillPath(skillName: string): string | undefined {
@@ -41,11 +43,11 @@ export function resolveSkillPath(skillName: string): string | undefined {
41
43
  // Project-level
42
44
  path.resolve(process.cwd(), ".agents/skills", skillName),
43
45
  // Global user skills
44
- path.join(os.homedir(), ".pi/agent/skills", skillName),
46
+ path.join(getAgentDir(), "skills", skillName),
45
47
  ];
46
48
 
47
49
  // npm package skills (cached)
48
- const npmSkillsDir = path.join(os.homedir(), ".pi/agent/npm/node_modules");
50
+ const npmSkillsDir = path.join(getAgentDir(), "npm/node_modules");
49
51
  for (const pkgSkillsBase of getNpmSkillCandidates(npmSkillsDir)) {
50
52
  candidates.push(path.join(pkgSkillsBase, skillName));
51
53
  }