@zhushanwen/pi-subagent-workflow 2.0.1 → 4.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/agents/reviewer.md +1 -1
- package/package.json +3 -2
- package/skills/workflow-script-format/SKILL.md +2 -1
- package/src/execution/__tests__/agent-registry.test.ts +1 -1
- 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/__tests__/detectors.test.ts +14 -0
- package/src/interface/subagent-tool.ts +10 -11
- package/src/interface/tool-workflow.ts +17 -4
- 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 +126 -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 +34 -5
- package/workflows/README.md +18 -0
- package/workflows/review-fix-loop.js +677 -0
package/agents/reviewer.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-subagent-workflow",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.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.
|
|
43
|
+
"@xyz-agent/extension-protocol": "^0.3.0",
|
|
44
|
+
"@zhushanwen/pi-extension-logger": "0.2.0"
|
|
44
45
|
},
|
|
45
46
|
"peerDependencies": {
|
|
46
47
|
"@earendil-works/pi-coding-agent": "*",
|
|
@@ -54,7 +54,8 @@ const meta = { name: 'workflow-name', description: '...', phases: ['phase1', 'ph
|
|
|
54
54
|
支持三种签名:
|
|
55
55
|
- `agent(promptString)` — 最简,prompt 字符串,返回 content 字符串
|
|
56
56
|
- `agent(promptString, { label?, schema?, ... })` — 字符串 + opts(`label` 是 `description` 的别名)
|
|
57
|
-
- `agent({ prompt, schema?, description?, agent?, skill?, timeoutMs?, model?, scene? })` — 完整 opts 对象
|
|
57
|
+
- `agent({ prompt, schema?, description?, agent?, skill?, timeoutMs?, model?, scene?, thinkingLevel? })` — 完整 opts 对象
|
|
58
|
+
- `thinkingLevel?` (`string`) — 思考强度,合法值 `off | minimal | low | medium | high | xhigh`,透传至 pi CLI 拼接为 `--model provider/modelId:thinkingLevel`(与 `model?` 同效,影响子 agent 推理预算)
|
|
58
59
|
|
|
59
60
|
Returns `parsedOutput` (structured data when schema provided) or `content` (string).
|
|
60
61
|
|
|
@@ -173,7 +173,7 @@ describe("createPackageBuiltinRegistry", () => {
|
|
|
173
173
|
expect(builtin.get("orchestrator")?.tools).toEqual([
|
|
174
174
|
"todo", "goal_control", "workflow", "subagent", "ask_user",
|
|
175
175
|
]);
|
|
176
|
-
expect(builtin.get("reviewer")?.tools).toEqual(["read"]);
|
|
176
|
+
expect(builtin.get("reviewer")?.tools).toEqual(["read", "structured-output"]);
|
|
177
177
|
expect(builtin.get("planner")?.tools).toEqual(["read"]);
|
|
178
178
|
expect(builtin.get("oracle")?.tools).toEqual(["read"]);
|
|
179
179
|
expect(builtin.get("context-builder")?.tools).toEqual(["read"]);
|
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
234
|
+
loggerMock.warn.mockClear();
|
|
223
235
|
injectSlot({ version: 99 as unknown as 1, pending: [] });
|
|
224
236
|
|
|
225
237
|
getOrCreateChannelRegistry();
|
|
226
238
|
|
|
227
|
-
const msg =
|
|
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
|
-
|
|
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 写失败被
|
|
151
|
-
expect(
|
|
152
|
-
const errMsg =
|
|
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
|
-
// 清理
|
|
171
|
-
|
|
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
|
-
|
|
71
|
+
// vi.fn() 的返回类型原生含 .mock.calls——无需双重断言(ReturnType<typeof vi.fn> 已是强类型)。
|
|
72
|
+
let warnSpy: ReturnType<typeof vi.fn>;
|
|
59
73
|
|
|
60
74
|
beforeEach(() => {
|
|
61
|
-
// stdin-writer 在背压 / 序列化失败时
|
|
62
|
-
|
|
63
|
-
warnSpy =
|
|
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
|
-
|
|
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(
|
|
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(
|
|
80
|
+
expect(loggerMock.warn).toHaveBeenCalledWith(
|
|
68
81
|
expect.stringContaining("session=s1"),
|
|
69
82
|
);
|
|
70
|
-
expect(
|
|
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(
|
|
93
|
+
expect(loggerMock.warn).toHaveBeenCalledTimes(1);
|
|
81
94
|
|
|
82
95
|
obs.resetMissingHandlerWarnings();
|
|
83
96
|
|
|
84
97
|
obs.notifyMissingHandler("s1");
|
|
85
|
-
expect(
|
|
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(
|
|
107
|
+
expect(loggerMock.warn).toHaveBeenCalledTimes(2);
|
|
95
108
|
|
|
96
109
|
// 再次各自调用,已被去重,不再 warn
|
|
97
110
|
obs.notifyMissingHandler("s1");
|
|
98
111
|
obs.notifyMissingHandler("s2");
|
|
99
|
-
expect(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
+
logger.warn(
|
|
88
92
|
"[pi-subagent-workflow] channel handshake pending is not an array; discarding and recreating.",
|
|
89
93
|
);
|
|
90
94
|
return undefined;
|
|
@@ -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
|
-
|
|
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,
|