@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.
- package/package.json +3 -2
- package/src/execution/__tests__/channel-registry-handshake.test.ts +18 -8
- package/src/execution/__tests__/execute-and-await-worktree.test.ts +219 -0
- package/src/execution/__tests__/finalize-record.test.ts +19 -6
- package/src/execution/__tests__/stdin-writer.test.ts +18 -5
- package/src/execution/__tests__/ui-request-observability.test.ts +21 -8
- package/src/execution/agent-registry.ts +10 -2
- package/src/execution/best-effort.ts +13 -5
- package/src/execution/channel-registry-access.ts +7 -3
- package/src/execution/execute-options-mapper.ts +2 -0
- package/src/execution/finalize-record.ts +5 -1
- package/src/execution/record-store.ts +10 -4
- package/src/execution/session-runner.ts +8 -2
- package/src/execution/stdin-writer.ts +8 -2
- package/src/execution/subagent-service.ts +56 -5
- package/src/execution/ui-request-handler-factory.ts +10 -10
- package/src/execution/ui-request-observability.ts +6 -2
- package/src/execution/ui-request-queue.ts +7 -1
- package/src/index.ts +21 -8
- package/src/interface/subagent-tool.ts +10 -11
- package/src/orchestration/__tests__/error-recovery-postmessage-defense.test.ts +22 -7
- package/src/orchestration/__tests__/error-recovery-serialize-failed-result.test.ts +56 -0
- package/src/orchestration/__tests__/worker-script-builder-runtime.test.ts +105 -1
- package/src/orchestration/__tests__/worker-script-builder.test.ts +91 -0
- package/src/orchestration/error-recovery.ts +23 -15
- package/src/orchestration/lifecycle.ts +6 -2
- package/src/orchestration/models/types.ts +18 -0
- 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
|
-
// 记录到
|
|
912
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
*
|
|
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
|
-
|
|
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(失败仅
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
157
|
-
"[subagents] ctx.ui.editor unavailable/threw, returning cancelled
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
264
|
-
|
|
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
|
-
|
|
281
|
+
logger.warn(`[subagents] manifest tmp recovery: ${recovered.recovered} promoted, ${recovered.deleted} deleted`);
|
|
273
282
|
}
|
|
274
283
|
} catch (err) {
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
284
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
241
|
-
|
|
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
|
-
//
|
|
260
|
-
//
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
/**
|
|
114
|
+
/** 清空 logger.error 调用记录(防御路径会打印诊断,避免污染跨用例断言)。 */
|
|
102
115
|
function silenceConsoleError(): () => void {
|
|
103
|
-
|
|
104
|
-
return () =>
|
|
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(
|
|
144
|
-
const diag =
|
|
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 =
|
|
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 =
|
|
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
|
+
});
|