@zhushanwen/pi-subagent-workflow 2.0.1 → 3.0.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.
Files changed (28) hide show
  1. package/package.json +3 -2
  2. package/src/execution/__tests__/channel-registry-handshake.test.ts +18 -8
  3. package/src/execution/__tests__/execute-and-await-worktree.test.ts +219 -0
  4. package/src/execution/__tests__/finalize-record.test.ts +19 -6
  5. package/src/execution/__tests__/stdin-writer.test.ts +18 -5
  6. package/src/execution/__tests__/ui-request-observability.test.ts +21 -8
  7. package/src/execution/agent-registry.ts +10 -2
  8. package/src/execution/best-effort.ts +13 -5
  9. package/src/execution/channel-registry-access.ts +7 -3
  10. package/src/execution/execute-options-mapper.ts +2 -0
  11. package/src/execution/finalize-record.ts +5 -1
  12. package/src/execution/record-store.ts +10 -4
  13. package/src/execution/session-runner.ts +8 -2
  14. package/src/execution/stdin-writer.ts +8 -2
  15. package/src/execution/subagent-service.ts +56 -5
  16. package/src/execution/ui-request-handler-factory.ts +10 -10
  17. package/src/execution/ui-request-observability.ts +6 -2
  18. package/src/execution/ui-request-queue.ts +7 -1
  19. package/src/index.ts +21 -8
  20. package/src/interface/subagent-tool.ts +10 -11
  21. package/src/orchestration/__tests__/error-recovery-postmessage-defense.test.ts +22 -7
  22. package/src/orchestration/__tests__/error-recovery-serialize-failed-result.test.ts +56 -0
  23. package/src/orchestration/__tests__/worker-script-builder-runtime.test.ts +105 -1
  24. package/src/orchestration/__tests__/worker-script-builder.test.ts +91 -0
  25. package/src/orchestration/error-recovery.ts +23 -15
  26. package/src/orchestration/lifecycle.ts +6 -2
  27. package/src/orchestration/models/types.ts +18 -0
  28. package/src/orchestration/worker-script-builder.ts +32 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-subagent-workflow",
3
- "version": "2.0.1",
3
+ "version": "3.0.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.",
@@ -40,7 +40,8 @@
40
40
  ]
41
41
  },
42
42
  "dependencies": {
43
- "@xyz-agent/extension-protocol": "^0.2.0"
43
+ "@xyz-agent/extension-protocol": "^0.2.0",
44
+ "@zhushanwen/pi-extension-logger": "0.2.0"
44
45
  },
45
46
  "peerDependencies": {
46
47
  "@earendil-works/pi-coding-agent": "*",
@@ -17,6 +17,19 @@
17
17
 
18
18
  import { afterEach, describe, expect, it, vi } from "vitest";
19
19
 
20
+ // Mock 共享 logger,让 logger.warn 可被 spy(源码已从 console.warn 改为 logger.warn)
21
+ const { loggerMock } = vi.hoisted(() => ({
22
+ loggerMock: {
23
+ debug: vi.fn(),
24
+ warn: vi.fn(),
25
+ error: vi.fn(),
26
+ info: vi.fn(),
27
+ },
28
+ }));
29
+ vi.mock("@zhushanwen/pi-extension-logger", () => ({
30
+ getLogger: () => loggerMock,
31
+ }));
32
+
20
33
  import {
21
34
  CHANNEL_HANDSHAKE_KEY,
22
35
  getOrCreateChannelRegistry,
@@ -31,6 +44,7 @@ function clearSlot(): void {
31
44
 
32
45
  afterEach(() => {
33
46
  clearSlot();
47
+ loggerMock.warn.mockClear();
34
48
  });
35
49
 
36
50
  /** 读取当前 slot(绕过本模块的 readHandshakeSlot,测试断言用)。 */
@@ -193,7 +207,7 @@ describe("getOrCreateChannelRegistry — 多条 pending flush 顺序与覆盖",
193
207
  describe("getOrCreateChannelRegistry — version 校验(向前兼容)", () => {
194
208
  it("version !== 1(如塞 {version:2,...})→ warn + 重建为新 slot,旧 pending 数据丢弃", () => {
195
209
  clearSlot();
196
- const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
210
+ loggerMock.warn.mockClear();
197
211
  // 塞一个 version:2 的 slot,含旧数据
198
212
  const staleHandler = vi.fn();
199
213
  injectSlot({
@@ -205,7 +219,7 @@ describe("getOrCreateChannelRegistry — version 校验(向前兼容)", () =
205
219
  const registry = getOrCreateChannelRegistry();
206
220
 
207
221
  // warn 被调用
208
- expect(warnSpy).toHaveBeenCalled();
222
+ expect(loggerMock.warn).toHaveBeenCalled();
209
223
  // 旧 pending 数据被丢弃(staleHandler 没被注册)
210
224
  expect(registry.resolve("ask_user")).toBeUndefined();
211
225
  // slot 被重建为 version:1,pending 为空
@@ -213,21 +227,17 @@ describe("getOrCreateChannelRegistry — version 校验(向前兼容)", () =
213
227
  expect(slot!.version).toBe(1);
214
228
  expect(slot!.pending).toEqual([]);
215
229
  expect(slot!.registry).toBe(registry);
216
-
217
- warnSpy.mockRestore();
218
230
  });
219
231
 
220
232
  it("version !== 1 时 warn 消息包含 got/expected 字样", () => {
221
233
  clearSlot();
222
- const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
234
+ loggerMock.warn.mockClear();
223
235
  injectSlot({ version: 99 as unknown as 1, pending: [] });
224
236
 
225
237
  getOrCreateChannelRegistry();
226
238
 
227
- const msg = warnSpy.mock.calls[0]?.[0] ?? "";
239
+ const msg = loggerMock.warn.mock.calls[0]?.[0] ?? "";
228
240
  expect(String(msg)).toMatch(/got/);
229
241
  expect(String(msg)).toMatch(/expected/);
230
-
231
- warnSpy.mockRestore();
232
242
  });
233
243
  });
@@ -0,0 +1,219 @@
1
+ // src/__tests__/execute-and-await-worktree.test.ts
2
+ //
3
+ // executeAndAwait 的 worktree 前置守卫 + 失败收尾测试(W1 code review 修复回归锁)。
4
+ //
5
+ // 覆盖两点:
6
+ // 1. [MF#7] worktree:true && !fork 在任何副作用之前 fail-fast 抛错
7
+ // 2. worktreeManager.create 抛错时 record 被 finalizeFailed(status→failed)且原错外抛
8
+ //
9
+ // ── mock 策略 ──
10
+ //
11
+ // 复用 execute-nesting.test.ts 的 spawn / node:fs / manifest-store / temp-prompt / alive-store
12
+ // / finalized-marker mock 范式(见该文件头部详细注释)。本文件 **不驱动 FakeChild 完成**——
13
+ // 被测的两个分支都在 runSpawn 之前抛/收尾(worktree create 在步骤 2.5,runSpawn 在步骤 5),
14
+ // 因此 spawn 即使被调也无人驱动,测试在抛错后立即断言即可结束。
15
+ //
16
+ // worktreeManager 是 SubagentService 构造时 new 出的私有字段(WorktreeManager 实例,非模块)。
17
+ // 测试 2 用 vi.spyOn(Reflect.get(service, "worktreeManager"), "create") 注入抛错,无需模块级 mock。
18
+
19
+ import { PassThrough } from "node:stream";
20
+
21
+ import { afterEach, describe, expect, it, vi } from "vitest";
22
+
23
+ // ── mock modules(与 execute-nesting.test.ts 同范式)──
24
+
25
+ vi.mock("node:child_process", async () => {
26
+ const { EventEmitter } = await import("node:events");
27
+ const { PassThrough } = await import("node:stream");
28
+
29
+ class FakeChild extends EventEmitter {
30
+ pid = 12345;
31
+ stdout = new PassThrough();
32
+ stderr = new PassThrough();
33
+ killed = false;
34
+ killSignal: string | undefined;
35
+ kill(sig?: string): boolean {
36
+ this.killed = true;
37
+ this.killSignal = sig;
38
+ return true;
39
+ }
40
+ }
41
+
42
+ return {
43
+ spawn: vi.fn(() => new FakeChild()),
44
+ execFileSync: vi.fn(() => ""),
45
+ };
46
+ });
47
+
48
+ vi.mock("node:fs", async () => {
49
+ const actual = await import("node:fs");
50
+ return {
51
+ default: {
52
+ ...actual,
53
+ mkdirSync: vi.fn(),
54
+ existsSync: vi.fn(() => false),
55
+ appendFileSync: vi.fn(),
56
+ writeFileSync: vi.fn(),
57
+ readdirSync: vi.fn(() => []),
58
+ },
59
+ mkdirSync: vi.fn(),
60
+ existsSync: vi.fn(() => false),
61
+ appendFileSync: vi.fn(),
62
+ writeFileSync: vi.fn(),
63
+ readdirSync: vi.fn(() => []),
64
+ promises: actual.promises,
65
+ };
66
+ });
67
+
68
+ vi.mock("../alive-store.ts", async (importOriginal) => {
69
+ const actual = await importOriginal<typeof import("../alive-store.ts")>();
70
+ return {
71
+ ...actual,
72
+ writeAliveMarker: vi.fn(),
73
+ removeAliveMarker: vi.fn(),
74
+ };
75
+ });
76
+
77
+ vi.mock("../finalized-marker.ts", () => ({
78
+ writeFinalized: vi.fn(),
79
+ readFinalized: vi.fn(() => false),
80
+ }));
81
+
82
+ vi.mock("../manifest-store.ts", () => {
83
+ class FakeManifestStore {
84
+ writeManifest = vi.fn(async () => {});
85
+ readManifest = vi.fn(async () => null);
86
+ listAllSync = vi.fn(() => []);
87
+ recoverTmpFiles = vi.fn(async () => []);
88
+ }
89
+ return { ManifestStore: FakeManifestStore };
90
+ });
91
+
92
+ vi.mock("../temp-prompt.ts", () => ({
93
+ writePromptToTempFile: vi.fn(async (agent: string) => {
94
+ const safeName = agent.replace(/[^\w.-]+/g, "_");
95
+ return { dir: `/tmp/fake-${safeName}`, filePath: `/tmp/fake-${safeName}/prompt-${safeName}.md` };
96
+ }),
97
+ cleanupTempPrompt: vi.fn(async () => {}),
98
+ }));
99
+
100
+ import { ModelConfigService } from "../model-config-service.ts";
101
+ import type { ModelInfo, ModelRegistryLike } from "../model-resolver.ts";
102
+ import type { RecordStore } from "../record-store.ts";
103
+ import type { WorktreeManager } from "../worktree-manager.ts";
104
+ import { SubagentService } from "../subagent-service.ts";
105
+
106
+ // ── 辅助:service 构造(与 execute-nesting.test.ts setup 等价)──
107
+
108
+ function makeEmptyRegistry(): ModelRegistryLike {
109
+ return { getAvailable: () => [], find: () => undefined, hasConfiguredAuth: () => true };
110
+ }
111
+
112
+ function makePi() {
113
+ return { sendMessage: vi.fn(), appendEntry: vi.fn(), events: { emit: vi.fn() } };
114
+ }
115
+
116
+ interface SetupResult {
117
+ service: SubagentService;
118
+ worktreeManager: WorktreeManager;
119
+ }
120
+
121
+ function setup(): SetupResult {
122
+ const agentDir = "/tmp/exec-await-worktree-it"; // fs 已 mock,路径不需真实存在
123
+ const modelService = new ModelConfigService({ agentDir });
124
+ modelService.initModel({
125
+ modelRegistry: makeEmptyRegistry(),
126
+ sessionId: "exec-await-worktree-it",
127
+ ctxModel: { id: "m", name: "M", provider: "p", reasoning: false },
128
+ });
129
+ const service = new SubagentService({
130
+ cwd: agentDir,
131
+ modelService,
132
+ getMainSessionFile: () => "/mock/main-session.jsonl",
133
+ });
134
+ service.initSession({ pi: makePi(), sessionId: "exec-await-worktree-it" });
135
+ // worktreeManager 是 SubagentService 构造时 new 的 private 字段(无外部注入入口),
136
+ // 测试经 Reflect.get 访问后 cast 到生产导出类型 WorktreeManager(已在文件顶部 import),
137
+ // 让字段/方法签名与生产类型契约绑定。
138
+ const worktreeManager = Reflect.get(service, "worktreeManager") as WorktreeManager;
139
+ return { service, worktreeManager };
140
+ }
141
+
142
+ const ctxModel: ModelInfo = { id: "m", name: "M", provider: "p", reasoning: false };
143
+
144
+ /**
145
+ * 从 service 取出 private store(断言 record 终态用)。
146
+ *
147
+ * worktreeManager 与 store 都是 SubagentService 构造时 new 出的 private 字段——
148
+ * 无外部注入入口,测试只能经 Reflect.get 访问。这里 cast 到生产导出类型
149
+ * (RecordStore / WorktreeManager)而非内联匿名 shape,让测试与生产类型契约绑定:
150
+ * 字段改名/签名变更时 tsc 立即报错(而非静默漂移)。
151
+ */
152
+ function getStore(service: SubagentService): RecordStore {
153
+ return Reflect.get(service, "store") as RecordStore;
154
+ }
155
+
156
+ describe("executeAndAwait worktree 前置守卫 + 失败收尾", () => {
157
+ afterEach(() => {
158
+ vi.restoreAllMocks();
159
+ });
160
+
161
+ // ============================================================
162
+ // [MF#7] worktree:true && !fork → fail-fast 抛错(任何副作用之前)
163
+ // ============================================================
164
+ it("[MF#7] worktree:true 且 fork 未设时抛 'worktree:true requires fork:true'", async () => {
165
+ const { service } = setup();
166
+
167
+ // guard 在 BC-12 深度检查之后、步骤 1 之前——无需 fork:true,worktree:true 即触发。
168
+ // 传入完整 ExecuteOptions(补全 slug 必填字段),不再用 `as` 掩盖缺失字段——让缺字段在类型层可见。
169
+ await expect(
170
+ service.executeAndAwait({
171
+ task: "needs worktree without fork",
172
+ slug: "mf7-worktree-without-fork",
173
+ worktree: true,
174
+ fork: undefined,
175
+ ctxModel,
176
+ }),
177
+ ).rejects.toThrow(/worktree:true requires fork:true/);
178
+
179
+ // 无副作用:guard 在 createRecordForMode 之前 → store 无 running record。
180
+ expect(getStore(service).listRunning()).toHaveLength(0);
181
+ });
182
+
183
+ // ============================================================
184
+ // worktreeManager.create 抛错 → record 收尾为 failed + 原错外抛
185
+ // ============================================================
186
+ it("worktreeManager.create 失败时 finalizeFailed 收尾 record 并抛原错", async () => {
187
+ const { service, worktreeManager } = setup();
188
+
189
+ const createErr = new Error("worktree create boom");
190
+ vi.spyOn(worktreeManager, "create").mockImplementation(() => {
191
+ throw createErr;
192
+ });
193
+
194
+ // spy store.archive:finalizeFailed 真实收尾链(CAS→completeRecord→archive)的最末一步。
195
+ // 捕获传入 archive 的 record,断言其 status 已被推向 "failed"(证明 finalizeFailed 完整执行,
196
+ // 而非仅 tryTransition 中途返回)。archive 真实执行(不 mockImplementation)以保留移出 running map 的语义。
197
+ const store = getStore(service);
198
+ const archiveSpy = vi.spyOn(store, "archive");
199
+
200
+ await expect(
201
+ service.executeAndAwait({
202
+ task: "worktree create will fail",
203
+ slug: "worktree-create-fail",
204
+ worktree: true,
205
+ fork: true,
206
+ ctxModel,
207
+ }),
208
+ ).rejects.toBe(createErr);
209
+
210
+ // finalizeFailed 完整执行:record 经 CAS→completeRecord 推到 failed 终态后 archive。
211
+ expect(archiveSpy).toHaveBeenCalledTimes(1);
212
+ // store 现已强类型为 RecordStore → archive 入参为 ExecutionRecord,无需 `as` 断言。
213
+ const archivedRecord = archiveSpy.mock.calls[0]![0];
214
+ expect(archivedRecord.status).toBe("failed");
215
+ // archive 后 record 已移出 running map → listRunning 空、getMutable 取不到。
216
+ expect(store.listRunning()).toHaveLength(0);
217
+ expect(store.getMutable(archivedRecord.id)).toBeUndefined();
218
+ });
219
+ });
@@ -12,6 +12,19 @@ import * as os from "node:os";
12
12
  import * as path from "node:path";
13
13
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
14
14
 
15
+ // Mock 共享 logger,让 logger.error 可被 spy(源码已从 console.error 改为 logger.error)
16
+ const { loggerMock } = vi.hoisted(() => ({
17
+ loggerMock: {
18
+ debug: vi.fn(),
19
+ warn: vi.fn(),
20
+ error: vi.fn(),
21
+ info: vi.fn(),
22
+ },
23
+ }));
24
+ vi.mock("@zhushanwen/pi-extension-logger", () => ({
25
+ getLogger: () => loggerMock,
26
+ }));
27
+
15
28
  import { doFinalizeRecord } from "../finalize-record.ts";
16
29
  import { ManifestStore } from "../manifest-store.ts";
17
30
  import type { AgentResult, ExecutionRecord } from "../types.ts";
@@ -129,7 +142,7 @@ describe("doFinalizeRecord — manifest status 透传 (M3 4 态)", () => {
129
142
  finalizedBeforeManifestWrite.value = fs.existsSync(`${sessionFile}.finalized`);
130
143
  throw new Error("disk full");
131
144
  });
132
- const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
145
+ loggerMock.error.mockClear();
133
146
 
134
147
  const deps = makeDeps();
135
148
 
@@ -147,9 +160,9 @@ describe("doFinalizeRecord — manifest status 透传 (M3 4 态)", () => {
147
160
  // ── 核心 claim 4:pending-notifications 注销仍触发(emitUnregister)──
148
161
  expect(deps.emitUnregister).toHaveBeenCalledWith("rec-cleanup-first", "done");
149
162
 
150
- // ── 核心 claim 5:manifest 写失败被 console.error 记录(含 record id + error)──
151
- expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("manifest 写入失败"));
152
- const errMsg = consoleErrorSpy.mock.calls[0]?.[0];
163
+ // ── 核心 claim 5:manifest 写失败被 logger.error 记录(含 record id + error)──
164
+ expect(loggerMock.error).toHaveBeenCalledWith(expect.stringContaining("manifest 写入失败"));
165
+ const errMsg = loggerMock.error.mock.calls[0]?.[0];
153
166
  expect(errMsg).toContain("rec-cleanup-first");
154
167
  expect(errMsg).toContain("disk full");
155
168
 
@@ -167,7 +180,7 @@ describe("doFinalizeRecord — manifest status 透传 (M3 4 态)", () => {
167
180
  // .finalized 尚未被 Step 3 写入),保护 Critical #1 时序不变量。
168
181
  expect(finalizedBeforeManifestWrite.value).toBe(true);
169
182
 
170
- // 清理 spy 防止污染其他测试
171
- consoleErrorSpy.mockRestore();
183
+ // 清理 mock 调用记录防污染
184
+ loggerMock.error.mockClear();
172
185
  });
173
186
  });
@@ -21,6 +21,19 @@ import { PassThrough } from "node:stream";
21
21
 
22
22
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
23
23
 
24
+ // Mock 共享 logger,让 logger.warn 可被 spy(源码已从 console.warn 改为 logger.warn)
25
+ const { loggerMock } = vi.hoisted(() => ({
26
+ loggerMock: {
27
+ debug: vi.fn(),
28
+ warn: vi.fn(),
29
+ error: vi.fn(),
30
+ info: vi.fn(),
31
+ },
32
+ }));
33
+ vi.mock("@zhushanwen/pi-extension-logger", () => ({
34
+ getLogger: () => loggerMock,
35
+ }));
36
+
24
37
  import { respond, sendGetStateCommand, sendPromptCommand } from "../stdin-writer.ts";
25
38
 
26
39
  // ── helpers ──
@@ -55,13 +68,13 @@ function readStdinLines(child: ChildProcess): unknown[] {
55
68
  .map((l) => JSON.parse(l));
56
69
  }
57
70
 
58
- let warnSpy: ReturnType<typeof vi.spyOn>;
71
+ // vi.fn() 的返回类型原生含 .mock.calls——无需双重断言(ReturnType<typeof vi.fn> 已是强类型)。
72
+ let warnSpy: ReturnType<typeof vi.fn>;
59
73
 
60
74
  beforeEach(() => {
61
- // stdin-writer 在背压 / 序列化失败时 console.warn;测试 stub 避免 noise,且可断言调用。
62
- // console.error(manifest 写失败路径)也 stub 静音,但测试不断言其调用。
63
- warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
64
- vi.spyOn(console, "error").mockImplementation(() => {});
75
+ // stdin-writer 在背压 / 序列化失败时 logger.warn;测试 mock logger 避免噪声,且可断言调用。
76
+ loggerMock.warn.mockClear();
77
+ warnSpy = loggerMock.warn;
65
78
  });
66
79
 
67
80
  afterEach(() => {
@@ -14,12 +14,25 @@
14
14
 
15
15
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
16
16
 
17
+ // Mock 共享 logger,让 logger.warn 可被 spy(源码已从 console.warn 改为 logger.warn)
18
+ const { loggerMock } = vi.hoisted(() => ({
19
+ loggerMock: {
20
+ debug: vi.fn(),
21
+ warn: vi.fn(),
22
+ error: vi.fn(),
23
+ info: vi.fn(),
24
+ },
25
+ }));
26
+ vi.mock("@zhushanwen/pi-extension-logger", () => ({
27
+ getLogger: () => loggerMock,
28
+ }));
29
+
17
30
  import { UiRequestObservability } from "../ui-request-observability.ts";
18
31
 
19
32
  // ── 公共 fixture ──────────────────────────────────────────────
20
33
 
21
34
  beforeEach(() => {
22
- vi.spyOn(console, "warn").mockImplementation(() => {});
35
+ loggerMock.warn.mockClear();
23
36
  });
24
37
 
25
38
  afterEach(() => {
@@ -57,17 +70,17 @@ describe("UiRequestObservability — notifyMissingHandler per-session 去重", (
57
70
  const obs = new UiRequestObservability();
58
71
  obs.notifyMissingHandler("s1");
59
72
  obs.notifyMissingHandler("s1");
60
- expect(console.warn).toHaveBeenCalledTimes(1);
73
+ expect(loggerMock.warn).toHaveBeenCalledTimes(1);
61
74
  });
62
75
 
63
76
  it("warn 内容含 sessionId 和 mode(可观测性)", () => {
64
77
  const obs = new UiRequestObservability();
65
78
  obs.setMode("tui");
66
79
  obs.notifyMissingHandler("s1");
67
- expect(console.warn).toHaveBeenCalledWith(
80
+ expect(loggerMock.warn).toHaveBeenCalledWith(
68
81
  expect.stringContaining("session=s1"),
69
82
  );
70
- expect(console.warn).toHaveBeenCalledWith(
83
+ expect(loggerMock.warn).toHaveBeenCalledWith(
71
84
  expect.stringContaining("mode=tui"),
72
85
  );
73
86
  });
@@ -77,12 +90,12 @@ describe("UiRequestObservability — resetMissingHandlerWarnings 后可重新 wa
77
90
  it("notify(s1) → reset → notify(s1) → warn 被调 2 次", () => {
78
91
  const obs = new UiRequestObservability();
79
92
  obs.notifyMissingHandler("s1");
80
- expect(console.warn).toHaveBeenCalledTimes(1);
93
+ expect(loggerMock.warn).toHaveBeenCalledTimes(1);
81
94
 
82
95
  obs.resetMissingHandlerWarnings();
83
96
 
84
97
  obs.notifyMissingHandler("s1");
85
- expect(console.warn).toHaveBeenCalledTimes(2);
98
+ expect(loggerMock.warn).toHaveBeenCalledTimes(2);
86
99
  });
87
100
  });
88
101
 
@@ -91,11 +104,11 @@ describe("UiRequestObservability — 不同 session 各自首次 warn", () => {
91
104
  const obs = new UiRequestObservability();
92
105
  obs.notifyMissingHandler("s1");
93
106
  obs.notifyMissingHandler("s2");
94
- expect(console.warn).toHaveBeenCalledTimes(2);
107
+ expect(loggerMock.warn).toHaveBeenCalledTimes(2);
95
108
 
96
109
  // 再次各自调用,已被去重,不再 warn
97
110
  obs.notifyMissingHandler("s1");
98
111
  obs.notifyMissingHandler("s2");
99
- expect(console.warn).toHaveBeenCalledTimes(2);
112
+ expect(loggerMock.warn).toHaveBeenCalledTimes(2);
100
113
  });
101
114
  });
@@ -11,6 +11,8 @@ import * as fs from "node:fs";
11
11
  import * as path from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
13
 
14
+ import { getLogger } from "@zhushanwen/pi-extension-logger";
15
+
14
16
  import {
15
17
  type DiscoveredResource,
16
18
  discoverResourcesSync,
@@ -18,6 +20,8 @@ import {
18
20
  } from "../shared/resource-discovery.ts";
19
21
  import type { AgentConfig } from "./model-resolver.ts";
20
22
 
23
+ const logger = getLogger("subagents");
24
+
21
25
  /** 内置 agent(代码硬编码,如 default worker)。 */
22
26
  export interface BuiltinAgentRegistry {
23
27
  get(name: string): AgentConfig | undefined;
@@ -52,13 +56,17 @@ export function createPackageBuiltinRegistry(): BuiltinAgentRegistry {
52
56
  } catch (err) {
53
57
  // 单个 builtin agent 文件损坏不影响其他——降级跳过该文件。
54
58
  void err;
55
- console.warn(`[subagents] skip malformed builtin agent: ${resource.path}`, err);
59
+ logger.warn(`[subagents] skip malformed builtin agent: ${resource.path}`, {
60
+ detail: err instanceof Error ? err.message : String(err),
61
+ });
56
62
  }
57
63
  }
58
64
  } catch (err) {
59
65
  // agents/ 目录不存在(打包遗漏)→ 空 builtin,不崩。
60
66
  void err;
61
- console.warn("[subagents] builtin agents/ directory unreadable, falling back to empty set:", err);
67
+ logger.warn("[subagents] builtin agents/ directory unreadable, falling back to empty set", {
68
+ detail: err instanceof Error ? err.message : String(err),
69
+ });
62
70
  }
63
71
  return {
64
72
  get: (name) => cache.get(name),
@@ -9,22 +9,30 @@
9
9
  //
10
10
  // 规则绕过原理:taste/no-silent-catch 仅检查 CatchClause 直接 body 是否为空或仅
11
11
  // console 调用。本 helper 是普通函数调用(ExpressionStatement),既非空也非仅
12
- // console,故合规。helper 函数体内部的 console 不被该规则检查。
12
+ // console,故合规。helper 函数体内部经共享 logger 路由(不裸 console)。
13
+
14
+ import { getLogger } from "@zhushanwen/pi-extension-logger";
15
+
16
+ const logger = getLogger("subagents");
13
17
 
14
18
  /** 错误日志级别。debug = 次要清理(默认);error = 关键步骤但需继续后续清理。 */
15
19
  export type BestEffortLevel = "debug" | "error";
16
20
 
17
21
  /**
18
- * 吞咽 best-effort IO 的错误,按 level 记录到 console。
22
+ * 吞咽 best-effort IO 的错误,按 level 经共享 logger 记录。
19
23
  *
20
24
  * - debug(默认):次要清理(sidecar/worktree/alive marker),失败属预期路径
21
25
  * - error:关键步骤抛错但需继续后续清理(如 finalizeRecord 的 B9 链:completeRecord
22
26
  * 抛错后仍要执行 finalized/cleanup,错误需可见但不阻断)
23
27
  *
24
- * 错误对象优先取 message(避免打印巨大堆栈/对象),其他类型原样打印。
28
+ * 错误对象优先取 message(避免打印巨大堆栈/对象),其他类型原样传入。
25
29
  */
26
30
  export function bestEffort(err: unknown, context: string, level: BestEffortLevel = "debug"): void {
27
31
  const detail = err instanceof Error ? err.message : err;
28
- const fn = level === "error" ? console.error : console.debug;
29
- fn(`[subagents] best-effort ${context} failed:`, detail);
32
+ const msg = `[subagents] best-effort ${context} failed`;
33
+ if (level === "error") {
34
+ logger.error(msg, { detail });
35
+ } else {
36
+ logger.debug(msg, { detail });
37
+ }
30
38
  }
@@ -24,8 +24,12 @@
24
24
  // handshake 形状: ChannelRegistryHandshake(version=1)
25
25
  // version 守卫: slot.version !== 1 时 console.warn + 丢弃重建(向前兼容未来升级)
26
26
 
27
+ import { getLogger } from "@zhushanwen/pi-extension-logger";
28
+
27
29
  import { createUiChannelRegistry, type UiChannelRegistry, type ChannelHandler } from "./ui-channels.ts";
28
30
 
31
+ const logger = getLogger("subagents");
32
+
29
33
  /** 进程级 channel 握手的 globalThis key(Symbol.for 跨模块共享)。
30
34
  *
31
35
  * **协议契约**:字面量 `"@zhushanwen/pi-subagents.channelHandshake"` 必须与
@@ -67,14 +71,14 @@ function readHandshakeSlot(): ChannelRegistryHandshake | undefined {
67
71
  if (slot === undefined) return undefined;
68
72
  // 形状校验:必须是对象且 version===1,否则视为不兼容
69
73
  if (typeof slot !== "object" || slot === null) {
70
- console.warn(
74
+ logger.warn(
71
75
  "[pi-subagent-workflow] channel handshake slot is not an object; discarding and recreating.",
72
76
  );
73
77
  return undefined;
74
78
  }
75
79
  const version = (slot as { version?: unknown }).version;
76
80
  if (version !== HANDSHAKE_VERSION) {
77
- console.warn(
81
+ logger.warn(
78
82
  `[pi-subagent-workflow] channel handshake version mismatch (got ${String(
79
83
  version,
80
84
  )}, expected ${HANDSHAKE_VERSION}); discarding and recreating.`,
@@ -84,7 +88,7 @@ function readHandshakeSlot(): ChannelRegistryHandshake | undefined {
84
88
  // version 正确,但 pending 可能被恶意/错误地塞了非数组;防御性处理
85
89
  const candidate = slot as ChannelRegistryHandshake;
86
90
  if (!Array.isArray(candidate.pending)) {
87
- console.warn(
91
+ logger.warn(
88
92
  "[pi-subagent-workflow] channel handshake pending is not an array; discarding and recreating.",
89
93
  );
90
94
  return undefined;
@@ -55,6 +55,8 @@ export function mapToExecuteOptions(
55
55
  schema: opts.schema,
56
56
  schemaEnv: opts.schemaEnv,
57
57
  cwd: opts.cwd,
58
+ fork: opts.fork,
59
+ worktree: opts.worktree,
58
60
  model: opts.model,
59
61
  ctxModel,
60
62
  skillPath: opts.skillPath,
@@ -16,6 +16,8 @@
16
16
  import * as fs from "node:fs";
17
17
  import * as path from "node:path";
18
18
 
19
+ import { getLogger } from "@zhushanwen/pi-extension-logger";
20
+
19
21
  import { removeAliveMarker } from "./alive-store.ts";
20
22
  import { bestEffort } from "./best-effort.ts";
21
23
  import { completeRecord } from "./execution-record.ts";
@@ -28,6 +30,8 @@ import { writeCancelledTombstone } from "./tombstone-store.ts";
28
30
  import type { AgentResult, ExecutionRecord } from "./types.ts";
29
31
  import type { WorktreeManager } from "./worktree-manager.ts";
30
32
 
33
+ const logger = getLogger("subagents");
34
+
31
35
  /** doFinalizeRecord 的依赖(从 SubagentService 注入,避免 this 绑定 + 解耦可测试)。 */
32
36
  export interface FinalizeDeps {
33
37
  manifestStore: ManifestStore;
@@ -151,7 +155,7 @@ export async function doFinalizeRecord(
151
155
  });
152
156
  } catch (err) {
153
157
  const msg = err instanceof Error ? err.message : String(err);
154
- console.error(`[subagent] manifest 写入失败 (record=${record.id}): ${msg}`);
158
+ logger.error(`[subagent] manifest 写入失败 (record=${record.id}): ${msg}`);
155
159
  deps.pi?.appendEntry?.("subagent:manifest-write-failed", {
156
160
  id: record.id,
157
161
  error: msg,
@@ -11,6 +11,8 @@
11
11
  import * as fs from "node:fs";
12
12
  import * as path from "node:path";
13
13
 
14
+ import { getLogger } from "@zhushanwen/pi-extension-logger";
15
+
14
16
  import { getCurrentActivity, getDisplayItems, getEventLog, markReconstructedStatus, snapshot as toSnapshot } from "./execution-record.ts";
15
17
  import type { ManifestRecord, ManifestStore } from "./manifest-store.ts";
16
18
  import { reconstructFromFile } from "./session-reconstructor.ts";
@@ -24,6 +26,8 @@ import { isProcessAlive, readAliveMarker } from "./alive-store.ts";
24
26
  import { readFinalized } from "./finalized-marker.ts";
25
27
  import { readCancelledTombstone } from "./tombstone-store.ts";
26
28
 
29
+ const logger = getLogger("subagents");
30
+
27
31
  // ============================================================
28
32
  // 常量
29
33
  // ============================================================
@@ -209,10 +213,12 @@ export class RecordStore {
209
213
  if (!rec) {
210
214
  // manifest status 越界=数据损坏(含历史 "error"、意外 crashed 值):跳过而非降级 failed,
211
215
  // 避免损坏 record 被误显示为 failed(触发错误重试/告警)。
212
- // 双通道上报:console.warn 给开发者(终端调试);pi.appendEntry 给用户(session 内可见,
213
- // 即使退出后也能从 session.jsonl 复盘事故原因)。SubagentService 构造时 pi 未注入
214
- // session_start 之前),appendEntry 走可选链安全降级。
215
- console.warn("[subagents] skip manifest with invalid status:", manifest.id, manifest.status);
216
+ // 双通道上报:logger.warn 给开发者(事后排查,appendEntry 持久化,不显 TUI);
217
+ // pi.appendEntry 给用户(session 内可见,即使退出后也能从 session.jsonl 复盘事故原因)。
218
+ // SubagentService 构造时 pi 未注入(session_start 之前),appendEntry 走可选链安全降级。
219
+ logger.warn("[subagents] skip manifest with invalid status", {
220
+ detail: { id: manifest.id, status: manifest.status },
221
+ });
216
222
  this.pi?.appendEntry?.("subagent:manifest-invalid-status", {
217
223
  id: manifest.id,
218
224
  status: manifest.status,