@zhushanwen/pi-subagent-workflow 9.2.0 → 9.3.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 +16 -16
- package/src/host/__tests__/inflight-reporter.test.ts +44 -56
- package/src/host/__tests__/journal-reporter.test.ts +230 -0
- package/src/host/__tests__/staged-discovery.test.ts +147 -0
- package/src/host/inflight-reporter.ts +29 -82
- package/src/host/journal-reporter.ts +204 -0
- package/src/host/pi-host.ts +54 -6
- package/src/interface/command/subagents.ts +2 -2
- package/src/interface/tool/subagent-tool.ts +6 -5
- package/src/interface/tool/tool-workflow-script.ts +13 -9
- package/src/session-lifecycle.ts +4 -6
- package/src/workflow-events.ts +30 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-subagent-workflow",
|
|
3
|
-
"version": "9.
|
|
3
|
+
"version": "9.3.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.",
|
|
@@ -58,22 +58,22 @@
|
|
|
58
58
|
]
|
|
59
59
|
},
|
|
60
60
|
"dependencies": {
|
|
61
|
-
"@zhushanwen/
|
|
62
|
-
"@zhushanwen/
|
|
63
|
-
"@zhushanwen/pi-ext-guards": "0.4.
|
|
64
|
-
"@zhushanwen/pi-
|
|
65
|
-
"@zhushanwen/pi-
|
|
66
|
-
"@zhushanwen/subagent-core": "1.
|
|
67
|
-
"@zhushanwen/pi-subagent-cli": "0.
|
|
68
|
-
"@zhushanwen/zcode-subagent-cli": "0.
|
|
61
|
+
"@zhushanwen/extension-protocol": "0.18.0",
|
|
62
|
+
"@zhushanwen/session-delivery": "1.1.0",
|
|
63
|
+
"@zhushanwen/pi-ext-guards": "0.4.3",
|
|
64
|
+
"@zhushanwen/pi-extension-logger": "0.6.2",
|
|
65
|
+
"@zhushanwen/pi-notify-ledger-host": "0.3.0",
|
|
66
|
+
"@zhushanwen/subagent-core": "1.3.0",
|
|
67
|
+
"@zhushanwen/pi-subagent-cli": "0.6.0",
|
|
68
|
+
"@zhushanwen/zcode-subagent-cli": "0.5.0"
|
|
69
69
|
},
|
|
70
70
|
"peerDependencies": {
|
|
71
|
-
"@earendil-works/pi-ai": "^0.
|
|
72
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
73
|
-
"@earendil-works/pi-tui": "^0.
|
|
74
|
-
"typebox": "
|
|
75
|
-
"@zhushanwen/pi-pending-notifications": "0.8.
|
|
76
|
-
"@zhushanwen/pi-structured-output": "6.
|
|
71
|
+
"@earendil-works/pi-ai": "^1.0.0",
|
|
72
|
+
"@earendil-works/pi-coding-agent": "^1.0.0",
|
|
73
|
+
"@earendil-works/pi-tui": "^1.0.0",
|
|
74
|
+
"typebox": "1.3.27",
|
|
75
|
+
"@zhushanwen/pi-pending-notifications": "0.8.2",
|
|
76
|
+
"@zhushanwen/pi-structured-output": "6.1.0"
|
|
77
77
|
},
|
|
78
78
|
"peerDependenciesMeta": {
|
|
79
79
|
"@earendil-works/pi-coding-agent": {
|
|
@@ -87,7 +87,7 @@
|
|
|
87
87
|
"@types/node": "^24.0.0",
|
|
88
88
|
"@vitest/coverage-v8": "^4.1.9",
|
|
89
89
|
"vitest": "^4.1.8",
|
|
90
|
-
"@zhushanwen/subagent-engine-sdk": "0.
|
|
90
|
+
"@zhushanwen/subagent-engine-sdk": "0.9.0"
|
|
91
91
|
},
|
|
92
92
|
"scripts": {
|
|
93
93
|
"typecheck": "npx tsc --noEmit",
|
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
// 三视角:
|
|
4
4
|
// ①使用者(runtime event-adapter 视角)——帧形状:title=SUBAGENT_INFLIGHT_MARKER、
|
|
5
5
|
// options=[JSON 帧](inFlight/sessionId/emittedAt)、控制面级 timeout 在场;
|
|
6
|
-
// ②构建者——初始上报(attachSession 触发,无需任何 subagent
|
|
7
|
-
//
|
|
6
|
+
// ②构建者——初始上报(attachSession 触发,无需任何 subagent 调用);失败折叠 +
|
|
7
|
+
// 置脏(无 timer 自动重试,ADR-0122);推送在途期间多次迁移合并为单帧且携带
|
|
8
|
+
// 最新绝对计数;
|
|
8
9
|
// ③观察者——onInFlightChanged 同步返回(不 await select,不进生命周期链);
|
|
9
|
-
// detachSession
|
|
10
|
+
// detachSession 后通道静默,session 死后不推帧。
|
|
10
11
|
//
|
|
11
12
|
// [HISTORICAL] 行为于 9f914b749 变更:kind='initial'|'delta' 帧字段与 initialAcked
|
|
12
13
|
// 状态机作为死面删除(消费方 event-adapter 从不读 kind,绝对计数语义下帧间等价;
|
|
@@ -35,7 +36,6 @@ import { INFLIGHT_REPORT_ACK, SUBAGENT_INFLIGHT_MARKER } from "@zhushanwen/exten
|
|
|
35
36
|
|
|
36
37
|
import { createInFlightReporter } from "../inflight-reporter.ts";
|
|
37
38
|
|
|
38
|
-
const RETRY_MS = 50;
|
|
39
39
|
const SELECT_TIMEOUT_MS = 1_000;
|
|
40
40
|
|
|
41
41
|
type SelectCall = { title: string; payload: string; timeout: number | undefined };
|
|
@@ -93,7 +93,7 @@ async function advance(ms: number): Promise<void> {
|
|
|
93
93
|
describe("初始上报(D5:触发时点 = extension 加载完成 / session 就绪)", () => {
|
|
94
94
|
it("attachSession 即发初始帧(count=当下快照,无需任何 subagent 调用)", async () => {
|
|
95
95
|
const channel = makeSelectChannel();
|
|
96
|
-
const reporter = createInFlightReporter({
|
|
96
|
+
const reporter = createInFlightReporter({ selectTimeoutMs: SELECT_TIMEOUT_MS });
|
|
97
97
|
|
|
98
98
|
reporter.attachSession(makeCtx(channel));
|
|
99
99
|
await advance(0);
|
|
@@ -113,7 +113,7 @@ describe("初始上报(D5:触发时点 = extension 加载完成 / session
|
|
|
113
113
|
|
|
114
114
|
it("初始未送达前发生的迁移不产生第二帧(合并为单帧,ack 后补推最新快照)", async () => {
|
|
115
115
|
const channel = makeSelectChannel();
|
|
116
|
-
const reporter = createInFlightReporter(
|
|
116
|
+
const reporter = createInFlightReporter();
|
|
117
117
|
reporter.attachSession(makeCtx(channel));
|
|
118
118
|
await advance(0);
|
|
119
119
|
|
|
@@ -135,7 +135,7 @@ describe("初始上报(D5:触发时点 = extension 加载完成 / session
|
|
|
135
135
|
describe("绝对计数语义(每帧携带当下值,非增量)", () => {
|
|
136
136
|
it("连续迁移各帧均为整值快照(2 → 0,不做加减)", async () => {
|
|
137
137
|
const channel = makeSelectChannel();
|
|
138
|
-
const reporter = createInFlightReporter(
|
|
138
|
+
const reporter = createInFlightReporter();
|
|
139
139
|
reporter.attachSession(makeCtx(channel));
|
|
140
140
|
await advance(0);
|
|
141
141
|
channel.settleAll(INFLIGHT_REPORT_ACK);
|
|
@@ -158,27 +158,32 @@ describe("绝对计数语义(每帧携带当下值,非增量)", () => {
|
|
|
158
158
|
});
|
|
159
159
|
});
|
|
160
160
|
|
|
161
|
-
describe("
|
|
162
|
-
it("select resolve undefined(超时/旧版 runtime)→
|
|
161
|
+
describe("失败折叠(ADR-0122:无 timer 自动重试,事件驱动重推)", () => {
|
|
162
|
+
it("select resolve undefined(超时/旧版 runtime)→ 置脏不重试;下次迁移事件重推最新快照", async () => {
|
|
163
163
|
const channel = makeSelectChannel();
|
|
164
|
-
const reporter = createInFlightReporter({
|
|
164
|
+
const reporter = createInFlightReporter({ selectTimeoutMs: SELECT_TIMEOUT_MS });
|
|
165
165
|
reporter.attachSession(makeCtx(channel));
|
|
166
166
|
await advance(0);
|
|
167
167
|
expect(channel.calls).toHaveLength(1);
|
|
168
168
|
|
|
169
|
-
// 首帧无人 ack,select 以 undefined 落定(超时形态)→
|
|
169
|
+
// 首帧无人 ack,select 以 undefined 落定(超时形态)→ 折叠置脏
|
|
170
170
|
channel.settle(0, undefined);
|
|
171
|
-
await advance(
|
|
172
|
-
|
|
173
|
-
await advance(
|
|
171
|
+
await advance(0);
|
|
172
|
+
// 推进远超原重试周期的时间:无 timer 自动重试(ADR-0122),零新帧
|
|
173
|
+
await advance(60_000);
|
|
174
|
+
expect(channel.calls).toHaveLength(1);
|
|
175
|
+
|
|
176
|
+
// 下一次 in-flight 变化事件驱动重推(携带最新绝对计数快照)
|
|
177
|
+
reporter.onInFlightChanged();
|
|
178
|
+
await advance(0);
|
|
174
179
|
expect(channel.calls).toHaveLength(2);
|
|
175
180
|
const retryFrame = parseFrame(channel.calls[1]);
|
|
176
|
-
expect(retryFrame.kind).toBeUndefined(); // kind 已删(9f914b749
|
|
177
|
-
expect(retryFrame.inFlight).toBe(0); //
|
|
181
|
+
expect(retryFrame.kind).toBeUndefined(); // kind 已删(9f914b749):重推帧与普通帧等价
|
|
182
|
+
expect(retryFrame.inFlight).toBe(0); // 重推帧携带完整绝对计数快照
|
|
178
183
|
|
|
179
|
-
//
|
|
184
|
+
// 重推帧得到 ack → 通道恢复;随后迁移照常送达
|
|
180
185
|
channel.settle(1, INFLIGHT_REPORT_ACK);
|
|
181
|
-
await advance(
|
|
186
|
+
await advance(60_000);
|
|
182
187
|
expect(channel.calls).toHaveLength(2);
|
|
183
188
|
|
|
184
189
|
reporter.onInFlightChanged();
|
|
@@ -188,81 +193,64 @@ describe("失败折叠 + 延迟重试(有界:累计 MAX_REPORT_ATTEMPTS 次
|
|
|
188
193
|
channel.settleAll(INFLIGHT_REPORT_ACK);
|
|
189
194
|
});
|
|
190
195
|
|
|
191
|
-
it("select
|
|
196
|
+
it("select 通道抛错同样折叠置脏,由迁移事件重推", async () => {
|
|
192
197
|
const channel = makeSelectChannel();
|
|
193
|
-
const reporter = createInFlightReporter(
|
|
198
|
+
const reporter = createInFlightReporter();
|
|
194
199
|
reporter.attachSession(makeCtx(channel));
|
|
195
200
|
await advance(0);
|
|
196
201
|
channel.settle(0, new Error("channel blew up"));
|
|
197
|
-
await advance(RETRY_MS);
|
|
198
|
-
expect(channel.calls.length).toBeGreaterThanOrEqual(2);
|
|
199
|
-
channel.settleAll(INFLIGHT_REPORT_ACK);
|
|
200
202
|
await advance(0);
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
const channel = makeSelectChannel();
|
|
205
|
-
const reporter = createInFlightReporter({ retryDelayMs: RETRY_MS });
|
|
206
|
-
reporter.attachSession(makeCtx(channel));
|
|
203
|
+
await advance(60_000); // 无 timer 重试
|
|
204
|
+
expect(channel.calls).toHaveLength(1);
|
|
205
|
+
reporter.onInFlightChanged();
|
|
207
206
|
await advance(0);
|
|
208
|
-
channel.settle(0, '{"ok":1}');
|
|
209
|
-
await advance(RETRY_MS);
|
|
210
207
|
expect(channel.calls).toHaveLength(2);
|
|
211
208
|
channel.settleAll(INFLIGHT_REPORT_ACK);
|
|
212
209
|
await advance(0);
|
|
213
210
|
});
|
|
214
211
|
|
|
215
|
-
it("
|
|
212
|
+
it("非确认回包(旧版 runtime 的任意字符串)不算送达,置脏等事件重推", async () => {
|
|
216
213
|
const channel = makeSelectChannel();
|
|
217
|
-
|
|
218
|
-
const reporter = createInFlightReporter({ retryDelayMs: RETRY_MS, selectTimeoutMs: SELECT_TIMEOUT_MS });
|
|
214
|
+
const reporter = createInFlightReporter();
|
|
219
215
|
reporter.attachSession(makeCtx(channel));
|
|
220
216
|
await advance(0);
|
|
217
|
+
channel.settle(0, '{"ok":1}');
|
|
218
|
+
await advance(0);
|
|
219
|
+
await advance(60_000); // 无 timer 重试
|
|
221
220
|
expect(channel.calls).toHaveLength(1);
|
|
222
|
-
|
|
223
|
-
// 三帧全部失败落定:首帧 + 2 次退避重试;第 3 次失败触顶
|
|
224
|
-
channel.settle(0, undefined);
|
|
225
|
-
await advance(RETRY_MS);
|
|
226
|
-
expect(channel.calls).toHaveLength(2);
|
|
227
|
-
channel.settle(1, undefined);
|
|
228
|
-
await advance(RETRY_MS);
|
|
229
|
-
expect(channel.calls).toHaveLength(3);
|
|
230
|
-
channel.settle(2, undefined);
|
|
231
|
-
|
|
232
|
-
// 达到上限:放弃为 session 内终态——时间推进与后续迁移都不再发帧
|
|
233
|
-
await advance(RETRY_MS * 10);
|
|
234
|
-
expect(channel.calls).toHaveLength(3);
|
|
235
221
|
reporter.onInFlightChanged();
|
|
236
|
-
await advance(
|
|
237
|
-
expect(channel.calls).toHaveLength(
|
|
222
|
+
await advance(0);
|
|
223
|
+
expect(channel.calls).toHaveLength(2);
|
|
224
|
+
channel.settleAll(INFLIGHT_REPORT_ACK);
|
|
225
|
+
await advance(0);
|
|
238
226
|
});
|
|
239
227
|
});
|
|
240
228
|
|
|
241
229
|
describe("不阻塞生命周期主链(D5 接线约束①)", () => {
|
|
242
230
|
it("onInFlightChanged 同步返回:select 永不落定也不挂调用方(detach 可丢弃在途帧)", async () => {
|
|
243
231
|
const channel = makeSelectChannel();
|
|
244
|
-
const reporter = createInFlightReporter(
|
|
232
|
+
const reporter = createInFlightReporter();
|
|
245
233
|
reporter.attachSession(makeCtx(channel));
|
|
246
234
|
await advance(0);
|
|
247
235
|
|
|
248
236
|
// 第一帧挂起(模拟 runtime 无响应)——迁移调用仍同步返回
|
|
249
237
|
expect(() => reporter.onInFlightChanged()).not.toThrow();
|
|
250
238
|
|
|
251
|
-
// session 死后 detach
|
|
239
|
+
// session 死后 detach:挂起帧被丢弃,通道静止(时间推进零新调用)
|
|
252
240
|
reporter.detachSession();
|
|
253
241
|
channel.settleAll(undefined);
|
|
254
|
-
await advance(
|
|
242
|
+
await advance(60_000);
|
|
255
243
|
expect(channel.calls).toHaveLength(1);
|
|
256
244
|
});
|
|
257
245
|
|
|
258
|
-
it("detachSession
|
|
246
|
+
it("detachSession 后失败落定不再发帧(ctx 已空,置脏无效果)", async () => {
|
|
259
247
|
const channel = makeSelectChannel();
|
|
260
|
-
const reporter = createInFlightReporter(
|
|
248
|
+
const reporter = createInFlightReporter();
|
|
261
249
|
reporter.attachSession(makeCtx(channel));
|
|
262
250
|
await advance(0);
|
|
263
251
|
reporter.detachSession();
|
|
264
|
-
channel.settleAll(undefined); // 在途帧失败落定——ctx
|
|
265
|
-
await advance(
|
|
252
|
+
channel.settleAll(undefined); // 在途帧失败落定——ctx 已空,不排重推
|
|
253
|
+
await advance(60_000);
|
|
266
254
|
expect(channel.calls).toHaveLength(1);
|
|
267
255
|
});
|
|
268
256
|
});
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// journal-reporter.test.ts —— 壳层 journal 事件推送出口(event-push-channel W-P1)。
|
|
2
|
+
//
|
|
3
|
+
// 三视角:
|
|
4
|
+
// ①使用者(runtime event-adapter 视角)——帧形状:title=SUBAGENT_JOURNAL_MARKER、
|
|
5
|
+
// options=[JSON 报告](domain/fileKey/events/sessionId/emittedAt)、控制面级
|
|
6
|
+
// timeout 在场;
|
|
7
|
+
// ②构建者——单帧单文件(fileKey 单数);一次推送尝试在途时新事件并入待推缓冲,
|
|
8
|
+
// 成功后逐组继续;失败(无 ack)→ 待推缓冲整体丢弃不重推(设计 D5,与 inflight
|
|
9
|
+
// 的 dirty 重推刻意分叉——消费方有 seq 水位可判缺口);
|
|
10
|
+
// ③观察者——onJournalAppended 同步返回(不 await select,不进落盘主链);
|
|
11
|
+
// 非 rpc 模式 no-op(不设 ctx,全程零帧);detach 后通道静默。
|
|
12
|
+
//
|
|
13
|
+
// 与 inflight-reporter 的差异锚:无初始帧(报告语义是增量事件,空 session 无事件
|
|
14
|
+
// 可报,缺席语义归消费方冷读——设计 §3.3)。
|
|
15
|
+
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
17
|
+
|
|
18
|
+
// ── hoisted mocks(依赖收窄:logger 防文件落盘) ──
|
|
19
|
+
|
|
20
|
+
const extensionLoggerMock = vi.hoisted(() => ({
|
|
21
|
+
getLogger: vi.fn(() => ({
|
|
22
|
+
debug: vi.fn(),
|
|
23
|
+
warn: vi.fn(),
|
|
24
|
+
error: vi.fn(),
|
|
25
|
+
})),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
vi.mock("@zhushanwen/pi-extension-logger", () => extensionLoggerMock);
|
|
29
|
+
|
|
30
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
31
|
+
import { JOURNAL_REPORT_ACK, SUBAGENT_JOURNAL_MARKER } from "@zhushanwen/extension-protocol";
|
|
32
|
+
|
|
33
|
+
import { createJournalReporter } from "../journal-reporter.ts";
|
|
34
|
+
|
|
35
|
+
const SELECT_TIMEOUT_MS = 1_000;
|
|
36
|
+
|
|
37
|
+
type SelectCall = { title: string; payload: string; timeout: number | undefined };
|
|
38
|
+
|
|
39
|
+
/** 可控 select 通道:每次调用的应答由用例逐帧裁决(ack / undefined / 抛错 / 挂起)。 */
|
|
40
|
+
function makeSelectChannel() {
|
|
41
|
+
const calls: SelectCall[] = [];
|
|
42
|
+
const pending: Array<(v: unknown) => void> = [];
|
|
43
|
+
const select = vi.fn(
|
|
44
|
+
(title: string, options: string[], opts?: { timeout?: number }): Promise<unknown> => {
|
|
45
|
+
calls.push({ title, payload: options[0] ?? "", timeout: opts?.timeout });
|
|
46
|
+
return new Promise((resolve) => pending.push(resolve));
|
|
47
|
+
},
|
|
48
|
+
);
|
|
49
|
+
return {
|
|
50
|
+
select,
|
|
51
|
+
calls,
|
|
52
|
+
/** resolve 第 N 帧(0 起)。 */
|
|
53
|
+
settle(index: number, value: unknown): void {
|
|
54
|
+
pending[index]?.(value);
|
|
55
|
+
},
|
|
56
|
+
settleAll(value: unknown): void {
|
|
57
|
+
while (pending.length > 0) pending.shift()?.(value);
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function makeCtx(channel: ReturnType<typeof makeSelectChannel>, sessionId = "sess-journal"): ExtensionContext {
|
|
63
|
+
return {
|
|
64
|
+
cwd: "/w",
|
|
65
|
+
mode: "rpc",
|
|
66
|
+
sessionManager: { getSessionId: () => sessionId },
|
|
67
|
+
ui: { select: channel.select },
|
|
68
|
+
} as unknown as ExtensionContext;
|
|
69
|
+
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function makeNonRpcCtx(channel: ReturnType<typeof makeSelectChannel>): ExtensionContext {
|
|
73
|
+
return {
|
|
74
|
+
cwd: "/w",
|
|
75
|
+
mode: "tui",
|
|
76
|
+
sessionManager: { getSessionId: () => "sess-journal" },
|
|
77
|
+
ui: { select: channel.select },
|
|
78
|
+
} as unknown as ExtensionContext;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parseFrame(call: SelectCall): Record<string, unknown> {
|
|
82
|
+
return JSON.parse(call.payload) as Record<string, unknown>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
beforeEach(() => {
|
|
86
|
+
vi.useFakeTimers();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
afterEach(() => {
|
|
90
|
+
vi.useRealTimers();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
/** 推进 fake 时间并排空微任务(attempt 的 await 链走完)。 */
|
|
94
|
+
async function advance(ms: number): Promise<void> {
|
|
95
|
+
await vi.advanceTimersByTimeAsync(ms);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
describe("帧形状与单文件单帧(①使用者契约)", () => {
|
|
99
|
+
it("onJournalAppended → 单帧报告(title/marker、domain/fileKey/events/sessionId、控制面 timeout)", async () => {
|
|
100
|
+
const channel = makeSelectChannel();
|
|
101
|
+
const reporter = createJournalReporter({ selectTimeoutMs: SELECT_TIMEOUT_MS });
|
|
102
|
+
reporter.attachSession(makeCtx(channel));
|
|
103
|
+
|
|
104
|
+
reporter.onJournalAppended("run", "wf-1", [{ type: "run-created", ts: 1, seq: 1 }]);
|
|
105
|
+
await advance(0);
|
|
106
|
+
|
|
107
|
+
expect(channel.calls).toHaveLength(1);
|
|
108
|
+
expect(channel.calls[0].title).toBe(SUBAGENT_JOURNAL_MARKER);
|
|
109
|
+
expect(channel.calls[0].timeout).toBe(SELECT_TIMEOUT_MS);
|
|
110
|
+
const frame = parseFrame(channel.calls[0]);
|
|
111
|
+
expect(frame.domain).toBe("run");
|
|
112
|
+
expect(frame.fileKey).toBe("wf-1");
|
|
113
|
+
expect(frame.events).toEqual([{ type: "run-created", ts: 1, seq: 1 }]);
|
|
114
|
+
expect(frame.sessionId).toBe("sess-journal");
|
|
115
|
+
expect(typeof frame.emittedAt).toBe("number");
|
|
116
|
+
|
|
117
|
+
channel.settleAll(JOURNAL_REPORT_ACK);
|
|
118
|
+
await advance(0);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("在途期间同文件新事件并入待推缓冲(成功后作为下一帧);不同文件逐帧发送(fileKey 单数契约)", async () => {
|
|
122
|
+
const channel = makeSelectChannel();
|
|
123
|
+
const reporter = createJournalReporter();
|
|
124
|
+
reporter.attachSession(makeCtx(channel));
|
|
125
|
+
|
|
126
|
+
reporter.onJournalAppended("run", "wf-1", [{ type: "run-created", ts: 1, seq: 1 }]);
|
|
127
|
+
// 第一帧在途:同文件第二事件并入待推缓冲组;另一文件组队
|
|
128
|
+
reporter.onJournalAppended("run", "wf-1", [{ type: "agent-started", ts: 2, seq: 2 }]);
|
|
129
|
+
reporter.onJournalAppended("run", "wf-1", [{ type: "agent-settled", ts: 3, seq: 3 }]);
|
|
130
|
+
reporter.onJournalAppended("record", "sa-1", [{ type: "record-created", ts: 4, seq: 1 }]);
|
|
131
|
+
await advance(0);
|
|
132
|
+
expect(channel.calls).toHaveLength(1); // 在途串行化:不并发发帧
|
|
133
|
+
|
|
134
|
+
// 帧 0(run-created)ack 落定 → 缓冲组 wf-1(两事件已合并为一组)先发
|
|
135
|
+
channel.settle(0, JOURNAL_REPORT_ACK);
|
|
136
|
+
await advance(0);
|
|
137
|
+
expect(channel.calls).toHaveLength(2);
|
|
138
|
+
expect(parseFrame(channel.calls[1]).fileKey).toBe("wf-1");
|
|
139
|
+
expect(parseFrame(channel.calls[1]).events).toEqual([
|
|
140
|
+
{ type: "agent-started", ts: 2, seq: 2 },
|
|
141
|
+
{ type: "agent-settled", ts: 3, seq: 3 },
|
|
142
|
+
]);
|
|
143
|
+
|
|
144
|
+
// 组间 FIFO:record 组随后
|
|
145
|
+
channel.settle(1, JOURNAL_REPORT_ACK);
|
|
146
|
+
await advance(0);
|
|
147
|
+
expect(channel.calls).toHaveLength(3);
|
|
148
|
+
expect(parseFrame(channel.calls[2]).fileKey).toBe("sa-1");
|
|
149
|
+
expect(parseFrame(channel.calls[2]).domain).toBe("record");
|
|
150
|
+
channel.settle(2, JOURNAL_REPORT_ACK);
|
|
151
|
+
await advance(0);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("失败折叠(设计 D5:缓冲即弃不重推,完整性归消费方缺口补读)", () => {
|
|
156
|
+
it("无 ack(超时形态)→ 待推缓冲整体丢弃;时间推进零重推,新事件重新起推", async () => {
|
|
157
|
+
const channel = makeSelectChannel();
|
|
158
|
+
const reporter = createJournalReporter();
|
|
159
|
+
reporter.attachSession(makeCtx(channel));
|
|
160
|
+
|
|
161
|
+
reporter.onJournalAppended("run", "wf-1", [{ type: "run-created", ts: 1, seq: 1 }]);
|
|
162
|
+
await advance(0);
|
|
163
|
+
channel.settle(0, undefined); // 首帧失败落定(超时形态)
|
|
164
|
+
await advance(0);
|
|
165
|
+
// 在途期间并入的事件随失败一并丢弃(缓冲即弃)
|
|
166
|
+
expect(channel.calls).toHaveLength(1);
|
|
167
|
+
|
|
168
|
+
await advance(60_000); // 无 timer 自动重试
|
|
169
|
+
expect(channel.calls).toHaveLength(1);
|
|
170
|
+
|
|
171
|
+
// 新的落盘事件重新起推(缓冲已清空,新事件是唯一载荷——不补发丢失事件)
|
|
172
|
+
reporter.onJournalAppended("run", "wf-1", [{ type: "agent-started", ts: 2, seq: 2 }]);
|
|
173
|
+
await advance(0);
|
|
174
|
+
expect(channel.calls).toHaveLength(2);
|
|
175
|
+
expect((parseFrame(channel.calls[1]).events as unknown[]).length).toBe(1);
|
|
176
|
+
channel.settleAll(JOURNAL_REPORT_ACK);
|
|
177
|
+
await advance(0);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("非确认回包(任意字符串)不算送达,同款缓冲即弃", async () => {
|
|
181
|
+
const channel = makeSelectChannel();
|
|
182
|
+
const reporter = createJournalReporter();
|
|
183
|
+
reporter.attachSession(makeCtx(channel));
|
|
184
|
+
reporter.onJournalAppended("record", "sa-1", [{ type: "record-created", ts: 1, seq: 1 }]);
|
|
185
|
+
await advance(0);
|
|
186
|
+
channel.settle(0, '{"ok":1}');
|
|
187
|
+
await advance(0);
|
|
188
|
+
await advance(60_000);
|
|
189
|
+
expect(channel.calls).toHaveLength(1);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("生命周期与环境门控(③观察者)", () => {
|
|
194
|
+
it("onJournalAppended 同步返回:select 永不落定也不挂调用方(detach 可丢弃在途帧与缓冲)", async () => {
|
|
195
|
+
const channel = makeSelectChannel();
|
|
196
|
+
const reporter = createJournalReporter();
|
|
197
|
+
reporter.attachSession(makeCtx(channel));
|
|
198
|
+
|
|
199
|
+
reporter.onJournalAppended("run", "wf-1", [{ type: "run-created", ts: 1, seq: 1 }]);
|
|
200
|
+
await advance(0);
|
|
201
|
+
// 第一帧挂起(模拟 runtime 无响应)——落盘出口调用仍同步返回
|
|
202
|
+
expect(() => reporter.onJournalAppended("run", "wf-1", [{ type: "agent-started", ts: 2, seq: 2 }])).not.toThrow();
|
|
203
|
+
|
|
204
|
+
// session 死后 detach:挂起帧被丢弃、缓冲清空,通道静止
|
|
205
|
+
reporter.detachSession();
|
|
206
|
+
channel.settleAll(undefined);
|
|
207
|
+
await advance(60_000);
|
|
208
|
+
expect(channel.calls).toHaveLength(1);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("非 rpc 模式 no-op:不设 ctx,落盘事件零帧(裸 pi TUI 下 marker select 会弹真框)", async () => {
|
|
212
|
+
const channel = makeSelectChannel();
|
|
213
|
+
const reporter = createJournalReporter();
|
|
214
|
+
reporter.attachSession(makeNonRpcCtx(channel));
|
|
215
|
+
|
|
216
|
+
reporter.onJournalAppended("run", "wf-1", [{ type: "run-created", ts: 1, seq: 1 }]);
|
|
217
|
+
await advance(60_000);
|
|
218
|
+
expect(channel.calls).toHaveLength(0);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("attach 前的落盘事件不进缓冲(无 ctx 直返——factory 阶段落盘点早于 session_start 的窗口)", async () => {
|
|
222
|
+
const channel = makeSelectChannel();
|
|
223
|
+
const reporter = createJournalReporter();
|
|
224
|
+
reporter.onJournalAppended("run", "wf-1", [{ type: "run-created", ts: 1, seq: 1 }]);
|
|
225
|
+
await advance(0);
|
|
226
|
+
reporter.attachSession(makeCtx(channel));
|
|
227
|
+
await advance(0);
|
|
228
|
+
expect(channel.calls).toHaveLength(0); // 丢失面归消费方冷读收敛
|
|
229
|
+
});
|
|
230
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// staged-discovery.test.ts — 打包态内置资产**发现结果**门禁(档 2,2026-10-03 B′)
|
|
2
|
+
//
|
|
3
|
+
// 为什么单独建这个文件:本次事故(打包版 `subagents` 批量工具报
|
|
4
|
+
// "Built-in workflow 'fan-out' is not available")的根因是**发现层面**——esbuild
|
|
5
|
+
// 自包含 bundle 下 `require.resolve("@zhushanwen/subagent-core/…")` 必败,staged 布局
|
|
6
|
+
// 又无 node_modules,于是孝在包里的 `workflows/` 无人能扫到。既有门禁全是“文件在不在”
|
|
7
|
+
// 级断言(dry-run import 不调工厂、TC8 只比字节),对本类失效天然失明。
|
|
8
|
+
//
|
|
9
|
+
// 本文件的断言对象是**发现结果**(6 内置 workflow + 10 内置 agent 真的被列出来),
|
|
10
|
+
// 而非“文件存在”:前者才能拦住“路径/产物层”回归。两条通路都被锁:
|
|
11
|
+
// ① 纯函数:bundle 形态模块路径 → scope 根;dev/异常形态 → undefined(不误触发);
|
|
12
|
+
// ② 集成:以合成 bundle 模块路径调 `corePackageNpmRoot()`(resolve 必败 → 回退分支),
|
|
13
|
+
// 再把回退根当 npm 槽根跑**真实** `discoverResources`,断言结果与源目录逐文件一致。
|
|
14
|
+
//
|
|
15
|
+
// 资产集不写死数量(口径:与 `packages/subagent-core/{workflows,agents}` 源目录集合比对,
|
|
16
|
+
// 源增删自动跟随)。
|
|
17
|
+
|
|
18
|
+
import { cpSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { createRequire } from "node:module";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { dirname, join } from "node:path";
|
|
22
|
+
import { pathToFileURL } from "node:url";
|
|
23
|
+
|
|
24
|
+
import { discoverResources, isTargetFile } from "@zhushanwen/subagent-core";
|
|
25
|
+
import { afterAll, describe, expect, it, vi } from "vitest";
|
|
26
|
+
|
|
27
|
+
// pi 宿主协作件 mock:本文件只跑发现链,日志面给可调用桩(回退分支会走 debug)。
|
|
28
|
+
vi.mock("@earendil-works/pi-coding-agent", () => ({
|
|
29
|
+
getAgentDir: () => process.env.PI_CODING_AGENT_DIR ?? "/mock/agent-dir",
|
|
30
|
+
}));
|
|
31
|
+
vi.mock("@zhushanwen/pi-extension-logger", () => ({
|
|
32
|
+
getLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
const { corePackageNpmRoot, stagedScopeRootFromModuleUrl } = await import("../pi-host.ts");
|
|
36
|
+
|
|
37
|
+
/** 本仓 `packages/subagent-core`(源单源:断言口径由此推导,不写死数量)。
|
|
38
|
+
* 路径经与生产同一锚点解析得出(禁止手数 `..` 层级)。 */
|
|
39
|
+
const CORE_PKG = dirname(
|
|
40
|
+
dirname(createRequire(import.meta.url).resolve("@zhushanwen/subagent-core/workflows/README.md")),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const tmpDirs: string[] = [];
|
|
44
|
+
function makeTmp(prefix: string): string {
|
|
45
|
+
const dir = mkdtempSync(join(tmpdir(), prefix));
|
|
46
|
+
tmpDirs.push(dir);
|
|
47
|
+
return dir;
|
|
48
|
+
}
|
|
49
|
+
afterAll(() => {
|
|
50
|
+
for (const d of tmpDirs) {
|
|
51
|
+
// teardown 递归删除补 maxRetries:与在途异步写竞争时 ENOTEMPTY 满载 flake
|
|
52
|
+
rmSync(d, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/** 合成 staged 布局:<tmp>/@zhushanwen/pi-subagent-workflow/{index.js,workflows,agents}。 */
|
|
57
|
+
function makeStagedLayout(): { scopeDir: string; moduleUrl: string } {
|
|
58
|
+
const root = makeTmp("staged-discovery-");
|
|
59
|
+
const pkgDir = join(root, "@zhushanwen", "pi-subagent-workflow");
|
|
60
|
+
mkdirSync(pkgDir, { recursive: true });
|
|
61
|
+
const indexJs = join(pkgDir, "index.js");
|
|
62
|
+
writeFileSync(indexJs, "// staged bundle placeholder\n", "utf8");
|
|
63
|
+
for (const kind of ["workflows", "agents"] as const) {
|
|
64
|
+
cpSync(join(CORE_PKG, kind), join(pkgDir, kind), { recursive: true });
|
|
65
|
+
}
|
|
66
|
+
return { scopeDir: join(root, "@zhushanwen"), moduleUrl: pathToFileURL(indexJs).href };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 源目录内可发现文件的集合——判据直接复用发现层导出的 isTargetFile(同一份
|
|
70
|
+
* 函数,发现层口径变化时期望集自动跟随,无双源)。 */
|
|
71
|
+
function sourceExpected(kind: "workflows" | "agents"): string[] {
|
|
72
|
+
return readdirSync(join(CORE_PKG, kind))
|
|
73
|
+
.filter((f) => isTargetFile(f, kind))
|
|
74
|
+
.sort();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe("B′:staged/bundle 形态的 core 资产发现根", () => {
|
|
78
|
+
it("stagedScopeRootFromModuleUrl:bundle 形态(<scope>/<pkg>/index.js)→ 返回 scope 目录", () => {
|
|
79
|
+
const { scopeDir, moduleUrl } = makeStagedLayout();
|
|
80
|
+
expect(stagedScopeRootFromModuleUrl(moduleUrl)).toBe(scopeDir);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("stagedScopeRootFromModuleUrl:dev 形态(非 scope 布局)→ undefined(不误触发回退)", () => {
|
|
84
|
+
const devLike = pathToFileURL(join(CORE_PKG, "workflows", "fan-out.js")).href;
|
|
85
|
+
expect(stagedScopeRootFromModuleUrl(devLike)).toBeUndefined();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("corePackageNpmRoot:dev/workspace 形态走 resolve 通路(不回退)", () => {
|
|
89
|
+
const root = corePackageNpmRoot();
|
|
90
|
+
expect(root).toBeDefined();
|
|
91
|
+
// workspace 形态:scope 根下必须有 subagent-core 包目录(resolve 命中锚点的上三级)
|
|
92
|
+
expect(readdirSync(root as string)).toContain("subagent-core");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("corePackageNpmRoot:bundle 形态 resolve 必败 → 回退到 scope 根", () => {
|
|
96
|
+
const { scopeDir, moduleUrl } = makeStagedLayout();
|
|
97
|
+
expect(corePackageNpmRoot(moduleUrl)).toBe(scopeDir);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("B′:回退根下的**发现结果**(本批防复发的核心断言)", () => {
|
|
102
|
+
it("workflows:发现结果与源目录集合逐文件一致(内置模板全部可派发)", async () => {
|
|
103
|
+
const { scopeDir } = makeStagedLayout();
|
|
104
|
+
const found = await discoverResources({
|
|
105
|
+
kind: "workflows",
|
|
106
|
+
workspaceRoot: scopeDir,
|
|
107
|
+
hostRoots: [{ dir: scopeDir, source: "npm" }],
|
|
108
|
+
});
|
|
109
|
+
// 只断言**回退根下**的发现结果(发现面还包括真实用户目录 ~/.agents/workflows
|
|
110
|
+
// 等宿主/约定根——本用例的主题是回退根本身,故按路径范围收窄,噪声不介入)
|
|
111
|
+
const names = found
|
|
112
|
+
.filter((r) => r.available && r.path.startsWith(scopeDir))
|
|
113
|
+
.map((r) => r.path.split("/").pop() as string)
|
|
114
|
+
.sort();
|
|
115
|
+
expect(names).toEqual(sourceExpected("workflows"));
|
|
116
|
+
// 回归锚:批量入口的固定执行体必须在列
|
|
117
|
+
expect(names).toContain("fan-out.js");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("agents:发现结果与源目录集合逐文件一致(10 内置角色全部可选)", async () => {
|
|
121
|
+
const { scopeDir } = makeStagedLayout();
|
|
122
|
+
const found = await discoverResources({
|
|
123
|
+
kind: "agents",
|
|
124
|
+
workspaceRoot: scopeDir,
|
|
125
|
+
hostRoots: [{ dir: scopeDir, source: "npm" }],
|
|
126
|
+
});
|
|
127
|
+
const names = found
|
|
128
|
+
.filter((r) => r.available && r.path.startsWith(scopeDir))
|
|
129
|
+
.map((r) => r.path.split("/").pop() as string)
|
|
130
|
+
.sort();
|
|
131
|
+
expect(names).toEqual(sourceExpected("agents"));
|
|
132
|
+
expect(names).toContain("general-purpose.md");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("负向:源资产缺席时发现结果为空(门禁失败方向正确,不会假绿)", async () => {
|
|
136
|
+
const root = makeTmp("staged-discovery-empty-");
|
|
137
|
+
const pkgDir = join(root, "@zhushanwen", "pi-subagent-workflow");
|
|
138
|
+
mkdirSync(pkgDir, { recursive: true });
|
|
139
|
+
writeFileSync(join(pkgDir, "index.js"), "// no assets\n", "utf8");
|
|
140
|
+
const found = await discoverResources({
|
|
141
|
+
kind: "workflows",
|
|
142
|
+
workspaceRoot: join(root, "@zhushanwen"),
|
|
143
|
+
hostRoots: [{ dir: join(root, "@zhushanwen"), source: "npm" }],
|
|
144
|
+
});
|
|
145
|
+
expect(found.filter((r) => r.available)).toEqual([]);
|
|
146
|
+
});
|
|
147
|
+
});
|