@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
@@ -9,6 +9,8 @@
9
9
  import { type ChildProcess,execFileSync, spawn } from "node:child_process";
10
10
  import * as fs from "node:fs";
11
11
 
12
+ import { getLogger } from "@zhushanwen/pi-extension-logger";
13
+
12
14
  import type { ExtensionMode } from "./host-mode.ts";
13
15
 
14
16
  import { type MirrorFlags, mirrorMainProcessFlags } from "./argv-mirror.ts";
@@ -45,6 +47,8 @@ import type {
45
47
  import { createTurnLimiter, WRAP_UP_HINT } from "./turn-limiter.ts";
46
48
  import { createUiRequestQueue } from "./ui-request-queue.ts";
47
49
 
50
+ const logger = getLogger("subagents");
51
+
48
52
  /**
49
53
  * 运行时 guard:subscribe 回调收到的 event 形状未知,校验 type 字段后再交给 handle。
50
54
  * 防止 SDK 事件结构变化时 switch(raw.type) 静默失配(全走 default 不报错)。
@@ -908,8 +912,10 @@ export async function runSpawn(
908
912
  );
909
913
  } catch (err) {
910
914
  // best-effort:identity 写入失败不影响执行结果,但会影响 /subagents list 重建。
911
- // 记录到 stderr(非阻断)—— 终态 record 会从 list 消失,这是可观测的退化信号。
912
- console.error(`[subagents] identity append failed for ${record.sessionFile}:`, err);
915
+ // 记录到 logger(非阻断)—— 终态 record 会从 list 消失,这是可观测的退化信号。
916
+ logger.error(`[subagents] identity append failed for ${record.sessionFile}`, {
917
+ detail: err instanceof Error ? err.message : String(err),
918
+ });
913
919
  }
914
920
  }
915
921
  }
@@ -10,8 +10,12 @@
10
10
  import type { ChildProcess } from "node:child_process";
11
11
  import * as crypto from "node:crypto";
12
12
 
13
+ import { getLogger } from "@zhushanwen/pi-extension-logger";
14
+
13
15
  import type { UiResponse } from "./dialog-queue.ts";
14
16
 
17
+ const logger = getLogger("subagents");
18
+
15
19
  /**
16
20
  * 按 UiResponse 形状构造 Pi 原生 extension_ui_response 并写 stdin。
17
21
  *
@@ -37,7 +41,9 @@ export function respond(child: ChildProcess, id: string, out: UiResponse, signal
37
41
  else if ("cancelled" in out) line = JSON.stringify({ type: "extension_ui_response", id, cancelled: true });
38
42
  } catch (err) {
39
43
  // [R2] out.value 含循环引用/BigInt 等不可序列化结构——降级 cancelled,避免父进程崩溃。
40
- console.warn(`[subagents] JSON.stringify failed for ui response ${id}, degrading to cancelled:`, err);
44
+ logger.warn(`[subagents] JSON.stringify failed for ui response ${id}, degrading to cancelled`, {
45
+ detail: err instanceof Error ? err.message : String(err),
46
+ });
41
47
  line = JSON.stringify({ type: "extension_ui_response", id, cancelled: true });
42
48
  }
43
49
  // ack: fire-and-forget,不写 stdin(SR-5)
@@ -102,5 +108,5 @@ export function sendGetStateCommand(child: ChildProcess): string {
102
108
  function writeStdinLine(child: ChildProcess, line: string, warnTag: string): void {
103
109
  if (!child.stdin || child.stdin.destroyed) return;
104
110
  const ok = child.stdin.write(line + "\n");
105
- if (!ok) console.warn(`[subagents] stdin backpressure on ${warnTag}`);
111
+ if (!ok) logger.warn(`[subagents] stdin backpressure on ${warnTag}`);
106
112
  }
@@ -4,6 +4,8 @@
4
4
 
5
5
  import { AsyncLocalStorage } from "node:async_hooks";
6
6
 
7
+ import { getLogger } from "@zhushanwen/pi-extension-logger";
8
+
7
9
  import type { ExtensionMode } from "./host-mode.ts";
8
10
 
9
11
  import type { AgentResult as WorkflowAgentResult } from "../orchestration/models/types.ts";
@@ -51,6 +53,8 @@ import { DEFAULT_AGENT_NAME } from "./types.ts";
51
53
  import { registerGlobalObservability, UiRequestObservability } from "./ui-request-observability.ts";
52
54
  import { WorktreeManager } from "./worktree-manager.ts";
53
55
 
56
+ const logger = getLogger("subagents");
57
+
54
58
  /** dispose 后注入的 stub UI 请求 handler。
55
59
  *
56
60
  * [背景] Pi 单进程 session 串行接管。session A shutdown 时 SIGTERM 子进程后、
@@ -58,7 +62,7 @@ import { WorktreeManager } from "./worktree-manager.ts";
58
62
  * 子进程的 trailing extension_ui_request 仍可能被父进程 pump 解析,调到 A 的 handler 闭包。
59
63
  * 若 dispose 不清 uiRequestHandler,旧 handler 闭包仍持有 A 的 ctx,触发
60
64
  * ui-request-queue.ts 的 catch 分支打 `[subagents] uiRequestHandler threw` 误导性
61
- * console.error(看起来像 bug,实际是预期竞态;三层兜底已确保功能正确)。
65
+ * logger.error(看起来像 bug,实际是预期竞态;三层兜底已确保功能正确)。
62
66
  *
63
67
  * stub 始终返回 {cancelled:true},不调 ctx.ui、不捕获任何 ctx,让 trailing ui_request
64
68
  * 干净降级为 cancelled(等价于子进程主动取消)。
@@ -519,6 +523,15 @@ export class SubagentService {
519
523
  );
520
524
  }
521
525
 
526
+ // [MF#7] worktree:true requires fork:true — symmetric with execute() guard.
527
+ // Fails fast before any side effect (record creation / worktree creation).
528
+ if (opts.worktree === true && !opts.fork) {
529
+ throw new Error(
530
+ "worktree:true requires fork:true (worktree isolation only applies to forked sessions). " +
531
+ "Set fork:true together with worktree:true.",
532
+ );
533
+ }
534
+
522
535
  // ── 步骤 1: IDENTITY 解析 ──
523
536
  const identity = await this.resolveIdentity(opts);
524
537
 
@@ -526,6 +539,27 @@ export class SubagentService {
526
539
  const record = this.createRecordForMode(identity, opts, "background");
527
540
  emitPendingRegister(this.pi, record.id, record.agent);
528
541
 
542
+ // ── 步骤 2.5: worktree creation (only worktree===true; handle injection is execute()'s path) ──
543
+ // Workflow path receives boolean only (AgentCallOpts.worktree: boolean) — WorktreeHandle is a
544
+ // main-thread non-serializable object that cannot cross worker postMessage, so no object branch
545
+ // here (unlike execute() :445-447 which serves the subagent-tool path). MF#7 guard above ensures
546
+ // fork===true when worktree===true. On create failure, finalizeFailed cleans up the record, then
547
+ // throw lets SAR.run() convert it to an AgentResult.error (not return-handle like execute()).
548
+ let worktreeHandle: WorktreeHandle | undefined;
549
+ if (opts.worktree === true) {
550
+ try {
551
+ worktreeHandle = this.worktreeManager.create(this.cwd, record.id);
552
+ record.worktreeHandle = worktreeHandle;
553
+ } catch (err) {
554
+ // finalizeFailed: CAS→finalizeRecord→emitUnregister (record already registered above).
555
+ // throw (not return-handle): executeAndAwait's caller SAR.run() catches and wraps into
556
+ // AgentResult.error. Diverges from execute() :455-456 which returns buildEarlyFailedHandle
557
+ // because the two methods have different return types.
558
+ await this.finalizeFailed(record, err);
559
+ throw err;
560
+ }
561
+ }
562
+
529
563
  // ── 步骤 3: SessionRunnerContext ──
530
564
  const ctx = this.buildSessionRunnerContext(opts.cwd);
531
565
 
@@ -535,7 +569,7 @@ export class SubagentService {
535
569
  // 步骤 5: runAndFinalize(await,不 detached)。onUpdate=undefined(BC-11),onEvent 独立传,stream 透传。
536
570
  const result = await this.runAndFinalize(
537
571
  record,
538
- { ...opts, onUpdate: undefined },
572
+ { ...opts, onUpdate: undefined, worktree: worktreeHandle },
539
573
  ctx,
540
574
  identity,
541
575
  effectiveSignal,
@@ -550,7 +584,24 @@ export class SubagentService {
550
584
  // - CAS 失败(cancel/finalizeFailed/dispose 抢先转终态)→ 那些路径各自已 emit
551
585
  // (cancelBackground L709 / finalizeFailed→finalizeRecord / dispose L240)
552
586
  // 旧实现无条件 emit 一次 → CAS 成功分支重复 emit(双注销)。
553
- return mapToWorkflowAgentResult(result);
587
+ const wfResult = mapToWorkflowAgentResult(result);
588
+ // W2 改动 7:注入 worktreePath(worktree 隔离激活时来自 step 2.5 的 worktreeHandle)。
589
+ // mapToWorkflowAgentResult 不感知 worktree(它只做 subagents AgentResult → workflow AgentResult
590
+ // 的 DTO 映射),故在 caller 侧 mutate 刚新建的产物对象(无共享引用,安全)。
591
+ //
592
+ // ⚠️ worktreePath is diagnostic only, may not exist — see AgentResult.worktreePath JSDoc
593
+ // (orchestration/models/types.ts)。下方诊断标识符语义(not cwd)说明同源。
594
+ //
595
+ // 诊断标识符语义(not cwd):
596
+ // - runAndFinalize 内的 finalizeRecord 在 return 前已 cleanup(git worktree remove --force),
597
+ // worktreePath 指向的目录已被删除,不保证存在。
598
+ // - worktreePath 仅供日志/trace 关联(如定位某条 session jsonl 的 worktree 来源),无运行时语义。
599
+ // - **不可作为后续 agent 的 cwd**——目录已删,复用会 ENOENT。
600
+ // - wave 内 worktree 复用(spec-w §2 "wave 内 8 action 共享 worktree")在 pi 当前架构下
601
+ // 不可行:worktree 绑定单次 agent() record,每次 executeAndAwait 结束 finalizeRecord
602
+ // 无条件 cleanup,worktree 无法跨 action 存活。wave 改用主 cwd(见 recursive-split.js)。
603
+ wfResult.worktreePath = record.worktreeHandle?.path;
604
+ return wfResult;
554
605
  }
555
606
 
556
607
  // ── 状态查询(TUI 调)──────────────────────────────────
@@ -750,12 +801,12 @@ export class SubagentService {
750
801
  })
751
802
  .catch((err: unknown) => {
752
803
  // detached 吞错:runAndFinalize 内部已 finalize record(含 emitPendingUnregister),
753
- // 且 finalizeRecord 的 manifest 写入已降级为 best-effort(失败仅 console.error + appendEntry,
804
+ // 且 finalizeRecord 的 manifest 写入已降级为 best-effort(失败仅 logger.error + appendEntry,
754
805
  // 不外抛)。因此此处不应走到——但作为最后一道兼底,记录调试日志后吞下,不外抛。
755
806
  // 完成通知由 finalizeRecord 内的 emitPendingUnregister 承担(pending-notifications 消费)。
756
807
  // cancel 抢先时 status=cancelled,cancelBackground 自己 emit,此处无需重复。
757
808
  if (err instanceof Error) {
758
- console.debug(`[subagent] background finalize error (record=${record.id}): ${err.message}`);
809
+ logger.debug(`[subagent] background finalize error (record=${record.id}): ${err.message}`);
759
810
  }
760
811
  });
761
812
  }
@@ -19,12 +19,15 @@
19
19
  // setUiRequestHandler——/resume /fork 复用 existingService 时旧 handler 可能已失效。
20
20
 
21
21
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
22
+ import { getLogger } from "@zhushanwen/pi-extension-logger";
22
23
 
23
24
  import { DialogGlobalQueue, type UiRequest, type UiRequestHandler, type UiResponse } from "./dialog-queue.ts";
24
25
  import { type HostMode, resolveHostMode } from "./host-mode.ts";
25
26
  import type { UiChannelRegistry } from "./ui-channels.ts";
26
27
  import { isDialogMethod } from "./ui-interaction-model.ts";
27
28
 
29
+ const logger = getLogger("subagents");
30
+
28
31
  /** 按 ctx.mode 创建 UI 请求 handler(透传 + 排队总控)。
29
32
  *
30
33
  * 透传矩阵(§一冲突 2):
@@ -100,7 +103,7 @@ function createRealHandler(
100
103
  * - 形状不匹配 → 降级 {cancelled:true}(保守,不阻塞队列) */
101
104
  function coerceUiResponse(raw: unknown, reqId: string): UiResponse {
102
105
  if (typeof raw !== "object" || raw === null) {
103
- console.warn("[subagents] channel handler returned non-object, coercing to cancelled (req=", reqId, ")");
106
+ logger.warn("[subagents] channel handler returned non-object, coercing to cancelled", { detail: { reqId } });
104
107
  return { cancelled: true };
105
108
  }
106
109
  const obj = raw as Record<string, unknown>;
@@ -108,7 +111,7 @@ function coerceUiResponse(raw: unknown, reqId: string): UiResponse {
108
111
  if (typeof obj.confirmed === "boolean") return { confirmed: obj.confirmed };
109
112
  if (obj.cancelled === true) return { cancelled: true };
110
113
  if (obj.ack === true) return { ack: true };
111
- console.warn("[subagents] channel handler returned unrecognized shape, coercing to cancelled (req=", reqId, ")");
114
+ logger.warn("[subagents] channel handler returned unrecognized shape, coercing to cancelled", { detail: { reqId } });
112
115
  return { cancelled: true };
113
116
  }
114
117
 
@@ -153,21 +156,18 @@ async function defaultDialogForward(
153
156
  const text = await ui.editor(req.title ?? "", req.prefill);
154
157
  return text === undefined ? { cancelled: true } : { value: text };
155
158
  } catch (err) {
156
- console.warn(
157
- "[subagents] ctx.ui.editor unavailable/threw, returning cancelled for",
158
- req.id,
159
- err,
159
+ logger.warn(
160
+ "[subagents] ctx.ui.editor unavailable/threw, returning cancelled",
161
+ { detail: { id: req.id, error: err instanceof Error ? err.message : String(err) } },
160
162
  );
161
163
  return { cancelled: true };
162
164
  }
163
165
  }
164
166
  default: {
165
167
  // 未知 dialog method(非 select/confirm/input/editor)——保守 cancelled 不阻塞子进程
166
- console.warn(
168
+ logger.warn(
167
169
  "[subagents] defaultDialogForward: unknown dialog method",
168
- req.method,
169
- "for",
170
- req.id,
170
+ { detail: { method: req.method, id: req.id } },
171
171
  );
172
172
  return { cancelled: true };
173
173
  }
@@ -3,8 +3,12 @@
3
3
  // UI 请求可观测性状态(从 subagent-service.ts 提取,降低主文件行数)。
4
4
  // 持有 sessionMode + handler 缺失告警去重集合,供 SubagentService 委托调用。
5
5
 
6
+ import { getLogger } from "@zhushanwen/pi-extension-logger";
7
+
6
8
  import type { ExtensionMode } from "./host-mode.ts";
7
9
 
10
+ const logger = getLogger("subagents");
11
+
8
12
  // ── 跨模块桥接(ui-request-queue 无 ctx.service 引用时走这里) ──
9
13
  //
10
14
  // ui-request-queue.handleUiRequest 在 ctx.uiRequestHandler 缺失时需要触发去重告警,
@@ -32,7 +36,7 @@ export function notifyMissingHandlerGlobal(sessionId: string): void {
32
36
  if (obs) {
33
37
  obs.notifyMissingHandler(sessionId);
34
38
  } else {
35
- console.warn(
39
+ logger.warn(
36
40
  `[subagents] uiRequestHandler missing (session=${sessionId}, global observability not registered)`,
37
41
  );
38
42
  }
@@ -72,6 +76,6 @@ export class UiRequestObservability {
72
76
  this.warnedMissingHandlerSessions.clear();
73
77
  }
74
78
  this.warnedMissingHandlerSessions.add(sessionId);
75
- console.warn(`[subagents] uiRequestHandler missing (session=${sessionId}, mode=${this.sessionMode})`);
79
+ logger.warn(`[subagents] uiRequestHandler missing (session=${sessionId}, mode=${this.sessionMode})`);
76
80
  }
77
81
  }
@@ -13,6 +13,8 @@
13
13
 
14
14
  import type { ChildProcess } from "node:child_process";
15
15
 
16
+ import { getLogger } from "@zhushanwen/pi-extension-logger";
17
+
16
18
  import type { UiRequest } from "./dialog-queue.ts";
17
19
  // 类型再导出:dialog-queue.ts 是 UiRequest/UiResponse/UiRequestHandler 的规范来源,
18
20
  // 本模块再导出供测试 import(避免测试直接依赖 dialog-queue 内部实现)。
@@ -23,6 +25,8 @@ import { respond } from "./stdin-writer.ts";
23
25
  import { parseChannel } from "./ui-channels.ts";
24
26
  import { notifyMissingHandlerGlobal } from "./ui-request-observability.ts";
25
27
 
28
+ const logger = getLogger("subagents");
29
+
26
30
  /**
27
31
  * 创建 UI 请求队列。返回 enqueue 函数,调用方将 extension_ui_request 入队。
28
32
  *
@@ -142,7 +146,9 @@ async function handleUiRequest(
142
146
  } catch (err) {
143
147
  // [R3] 子进程已退出,跳过写入
144
148
  if (signal?.aborted) return;
145
- console.error("[subagents] uiRequestHandler threw:", err);
149
+ logger.error("[subagents] uiRequestHandler threw", {
150
+ detail: err instanceof Error ? err.message : String(err),
151
+ });
146
152
  respond(child, id, { cancelled: true }, signal);
147
153
  }
148
154
  }
package/src/index.ts CHANGED
@@ -19,6 +19,7 @@ import * as path from "node:path";
19
19
 
20
20
  import type { ExtensionAPI, ExtensionContext, SessionShutdownEvent, SessionStartEvent, SessionTreeEvent } from "@earendil-works/pi-coding-agent";
21
21
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
22
+ import { getLogger, setPiHandle } from "@zhushanwen/pi-extension-logger";
22
23
 
23
24
  import type { AgentRegistry } from "./execution/agent-registry.ts";
24
25
  import { bestEffort } from "./execution/best-effort.ts";
@@ -73,7 +74,14 @@ declare module "@earendil-works/pi-coding-agent" {
73
74
 
74
75
  // ── Factory ──────────────────────────────────────────────────
75
76
 
77
+ // 模块级 logger(setPiHandle 注入后自动走 appendEntry)
78
+ const logger = getLogger("subagents");
79
+
76
80
  export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
81
+ // 注入 pi handle 给全局 extension-logger,让深层代码(best-effort / error-recovery)
82
+ // 的 getLogger("subagents") 也能走 appendEntry。
83
+ setPiHandle(pi);
84
+
77
85
  // ════════════════════════════════════════════════════════════
78
86
  // subagents 域:tool + command + messageRenderer
79
87
  // ════════════════════════════════════════════════════════════
@@ -260,8 +268,9 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
260
268
  try {
261
269
  maybeCleanupExpiredSessionFiles(agentDir, cwd);
262
270
  } catch (err) {
263
- void err;
264
- console.warn("[subagents] expired session file cleanup failed:", err);
271
+ logger.warn("[subagents] expired session file cleanup failed", {
272
+ reason: err instanceof Error ? err.message : String(err),
273
+ });
265
274
  }
266
275
 
267
276
  // ADR-035 启动恢复:扫描 manifest tmp 残留(崩溃打断的 writeManifest 留下),
@@ -269,19 +278,21 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
269
278
  try {
270
279
  const recovered = await service.recoverManifestTmpFiles();
271
280
  if (recovered.recovered > 0 || recovered.deleted > 0) {
272
- console.warn(`[subagents] manifest tmp recovery: ${recovered.recovered} promoted, ${recovered.deleted} deleted`);
281
+ logger.warn(`[subagents] manifest tmp recovery: ${recovered.recovered} promoted, ${recovered.deleted} deleted`);
273
282
  }
274
283
  } catch (err) {
275
- void err;
276
- console.warn("[subagents] manifest tmp recovery failed:", err);
284
+ logger.warn("[subagents] manifest tmp recovery failed", {
285
+ reason: err instanceof Error ? err.message : String(err),
286
+ });
277
287
  }
278
288
 
279
289
  try {
280
290
  const wtm = new WorktreeManager(agentDir);
281
291
  wtm.scan();
282
292
  } catch (err) {
283
- void err;
284
- console.warn("[subagents] worktree reaper scan failed:", err);
293
+ logger.warn("[subagents] worktree reaper scan failed", {
294
+ reason: err instanceof Error ? err.message : String(err),
295
+ });
285
296
  }
286
297
 
287
298
  // ── workflow 域:per-session store + runs ──
@@ -315,7 +326,9 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
315
326
  }
316
327
  } catch (err) {
317
328
  // QMF-4 fix: store.loadAll 失败是关键路径错误,workflow 域将未初始化
318
- console.error("[subagent-workflow] store.loadAll failed, workflow domain uninitialized:", err);
329
+ logger.error("[subagent-workflow] store.loadAll failed, workflow domain uninitialized", {
330
+ reason: err instanceof Error ? err.message : String(err),
331
+ });
319
332
  storeHealthy = false;
320
333
  }
321
334
 
@@ -12,6 +12,7 @@
12
12
  import type { Component } from "@earendil-works/pi-tui";
13
13
  import { StringEnum } from "@earendil-works/pi-ai";
14
14
  import type { AgentToolResult, ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
15
+ import { getLogger } from "@zhushanwen/pi-extension-logger";
15
16
  import { type Static, Type } from "typebox";
16
17
 
17
18
  import { SLUG_MAX_LENGTH } from "../execution/execute-options-mapper.ts";
@@ -236,9 +237,10 @@ A subagent MAY call the \`subagent\` tool itself (each level spawns its own chil
236
237
  // 回调实现(模块级 const)
237
238
  // ============================================================
238
239
 
239
- // ponytail: renderCall 每次 TUI invalidate 都触发,同一解析错误会重复刷屏。
240
- // 按错误消息去重(Set),session 内只报第一次。错误消息含 modelStr,足够区分。
241
- const reportedRenderErrors = new Set<string>();
240
+ // ponytail: renderCall 每次 TUI invalidate 都触发。streaming 中 args 是 partial JSON
241
+ // 解析结果(如 model="deep" 来自未流完的 "deepseek-router/ds-pro"),解析失败是预期。
242
+ // 不走 appendEntry(非真实错误),只走 logger.debug(默认 no-op,PI_EXT_DEBUG=1 写文件)。
243
+ const renderCallLogger = getLogger("subagents");
242
244
 
243
245
  const subagentRenderCall: SubagentRenderCallCb = (args, theme, ctx) => {
244
246
  // 预解析 model(同步):让标题行能显示 model/thinking,不必等 execute。
@@ -256,14 +258,11 @@ const subagentRenderCall: SubagentRenderCallCb = (args, theme, ctx) => {
256
258
  const r = service?.resolveModel(agent, override);
257
259
  if (r) resolved = { model: `${r.model.provider}/${r.model.id}`, thinkingLevel: r.thinkingLevel };
258
260
  } catch (err) {
259
- // service 未注册 / modelRegistry 未注入 / 无可用 model → 降级不显示 model(renderCall 不应崩)。
260
- // 去重:同一 err.message 只 console.debug 一次,避免 TUI invalidate 反复刷屏。
261
- const msg = err instanceof Error ? err.message : String(err);
262
- if (!reportedRenderErrors.has(msg)) {
263
- reportedRenderErrors.add(msg);
264
- void err; // 显式确认忽略:renderCall 降级是设计意图,不阻断渲染
265
- console.debug("[subagents] renderCall model resolution failed, degrading:", err);
266
- }
261
+ // streaming 中间态(partial JSON)或 service 未就绪 → 降级不显示 model(renderCall 不应崩)。
262
+ // 不阻断渲染,不污染 TUI。开发期开 PI_EXT_DEBUG=1 可写文件日志排查。
263
+ renderCallLogger.debug("renderCall model resolution failed, degrading", {
264
+ reason: err instanceof Error ? err.message : String(err),
265
+ });
267
266
  }
268
267
  return renderSubagentCall(args, theme, ctx, resolved);
269
268
  };
@@ -23,6 +23,19 @@ import { fileURLToPath } from "node:url";
23
23
 
24
24
  import { describe, expect, it, vi } from "vitest";
25
25
 
26
+ // Mock 共享 logger,让 logger.error 可被 spy(源码已从 console.error 改为 logger.error)
27
+ const { loggerMock } = vi.hoisted(() => ({
28
+ loggerMock: {
29
+ debug: vi.fn(),
30
+ warn: vi.fn(),
31
+ error: vi.fn(),
32
+ info: vi.fn(),
33
+ },
34
+ }));
35
+ vi.mock("@zhushanwen/pi-extension-logger", () => ({
36
+ getLogger: () => loggerMock,
37
+ }));
38
+
26
39
  import { handleWorkerMessage, postBudgetUpdate } from "../error-recovery.ts";
27
40
  import type { LifecycleDeps, WorkerHandlers } from "../models/ports.ts";
28
41
  import type { WorkflowRun } from "../models/workflow-run.ts";
@@ -98,10 +111,12 @@ function postedAt(postMessage: ReturnType<typeof vi.fn>, idx: number): PostedMsg
98
111
  return postMessage.mock.calls[idx]![0] as PostedMsg;
99
112
  }
100
113
 
101
- /** 静默 console.error(防御路径会打印诊断,避免污染测试输出)。 */
114
+ /** 清空 logger.error 调用记录(防御路径会打印诊断,避免污染跨用例断言)。 */
102
115
  function silenceConsoleError(): () => void {
103
- const spy = vi.spyOn(console, "error").mockImplementation(() => {});
104
- return () => spy.mockRestore();
116
+ loggerMock.error.mockClear();
117
+ return () => {
118
+ loggerMock.error.mockClear();
119
+ };
105
120
  }
106
121
 
107
122
  // ── W2a: postBudgetUpdate try/catch ──
@@ -140,8 +155,8 @@ describe("W2a: postBudgetUpdate 防御 DataCloneError", () => {
140
155
 
141
156
  postBudgetUpdate(run);
142
157
 
143
- expect(console.error).toHaveBeenCalledTimes(1);
144
- const diag = (console.error as ReturnType<typeof vi.fn>).mock.calls[0]![0] as string;
158
+ expect(loggerMock.error).toHaveBeenCalledTimes(1);
159
+ const diag = loggerMock.error.mock.calls[0]![0] as string;
145
160
  expect(diag).toContain("postBudgetUpdate failed");
146
161
  expect(diag).toContain("Could not clone object");
147
162
  } finally {
@@ -243,7 +258,7 @@ describe("W2b: dispatchWorkflowCall postResult 防御 DataCloneError", () => {
243
258
 
244
259
  // 至少 2 次尝试(原始 + fallback),fallback 失败也记日志
245
260
  expect(postMessage.mock.calls.length).toBeGreaterThanOrEqual(2);
246
- const errorCalls = (console.error as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0] as string);
261
+ const errorCalls = loggerMock.error.mock.calls.map((c) => c[0] as string);
247
262
  expect(errorCalls.some((s) => s.includes("fallback also failed"))).toBe(true);
248
263
  } finally {
249
264
  restore();
@@ -345,7 +360,7 @@ describe("W2c: postAgentResult 行为测试(cached replay 路径)", () => {
345
360
 
346
361
  // 至少 2 次尝试(原始 + fallback),fallback 失败也记日志
347
362
  expect(postMessage.mock.calls.length).toBeGreaterThanOrEqual(2);
348
- const errorCalls = (console.error as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0] as string);
363
+ const errorCalls = loggerMock.error.mock.calls.map((c) => c[0] as string);
349
364
  expect(errorCalls.some((s) => s.includes("postAgentResult failed"))).toBe(true);
350
365
  expect(errorCalls.some((s) => s.includes("fallback also failed"))).toBe(true);
351
366
  } finally {
@@ -0,0 +1,56 @@
1
+ // src/orchestration/__tests__/error-recovery-serialize-failed-result.test.ts
2
+ //
3
+ // makeSerializeFailedResult 独立单测(W2 防御关键纯函数)。
4
+ //
5
+ // 背景:postMessage 序列化失败时,主线程需回发「必可克隆」的 fallback result
6
+ // 让 worker 内 pending Promise resolve(否则 agent()/workflow() 永久挂起,只能靠
7
+ // timeout 兜底)。两条 fallback 路径(postAgentResult / dispatchWorkflowCall.postResult)
8
+ // 共享 makeSerializeFailedResult 构造逻辑——本测试锁住其返回 shape,确保两条路径
9
+ // 不漂移。
10
+ //
11
+ // 验证点:
12
+ // 1. content 恒为 ""(纯字符串 fallback,必可克隆——不含 function/Symbol/循环引用)
13
+ // 2. error 形如 "<prefix>: <errMsg>"(前缀区分调用方,便于诊断定位)
14
+ // 3. prefix / errMsg 各自原样透传(含空串、特殊字符、多行)
15
+
16
+ import { describe, expect, it } from "vitest";
17
+
18
+ import { makeSerializeFailedResult } from "../error-recovery.ts";
19
+
20
+ describe("makeSerializeFailedResult", () => {
21
+ it("返回 {content:'', error:'<prefix>: <errMsg>'} 形状(必可克隆 fallback)", () => {
22
+ const result = makeSerializeFailedResult("Result serialization failed", "DataCloneError: ...");
23
+ expect(result).toEqual({
24
+ content: "",
25
+ error: "Result serialization failed: DataCloneError: ...",
26
+ });
27
+ });
28
+
29
+ it("content 恒为空串(纯字符串 fallback,不含不可克隆成员)", () => {
30
+ // 无论入参如何,content 必为 ""——postMessage 必须能克隆这个 fallback result。
31
+ const result = makeSerializeFailedResult("any prefix", "any error");
32
+ expect(result.content).toBe("");
33
+ });
34
+
35
+ it("error 由 prefix + ': ' + errMsg 拼接(前缀区分调用方,便于诊断)", () => {
36
+ const result = makeSerializeFailedResult("Workflow result serialization failed", "boom");
37
+ expect(result.error).toBe("Workflow result serialization failed: boom");
38
+ // 分隔符固定为 ": "——两条 fallback 路径共享同一构造逻辑。
39
+ expect(result.error).toContain(": ");
40
+ });
41
+
42
+ it("prefix 与 errMsg 原样透传(含空串、特殊字符、多行)", () => {
43
+ // 空串 prefix
44
+ expect(makeSerializeFailedResult("", "err").error).toBe(": err");
45
+ // 空串 errMsg
46
+ expect(makeSerializeFailedResult("prefix", "").error).toBe("prefix: ");
47
+ // 含冒号 / 换行 / unicode 的 errMsg(原样透传,不做转义)
48
+ const weird = "err: with: colons\nand newline 中文 🚀";
49
+ expect(makeSerializeFailedResult("P", weird).error).toBe(`P: ${weird}`);
50
+ });
51
+
52
+ it("返回对象字段仅 content + error(无多余字段,shape 稳定不漂移)", () => {
53
+ const result = makeSerializeFailedResult("p", "e");
54
+ expect(Object.keys(result).sort()).toEqual(["content", "error"]);
55
+ });
56
+ });