@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-subagent-workflow",
3
- "version": "9.2.0",
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/session-delivery": "1.0.0",
62
- "@zhushanwen/extension-protocol": "0.17.0",
63
- "@zhushanwen/pi-ext-guards": "0.4.2",
64
- "@zhushanwen/pi-notify-ledger-host": "0.2.3",
65
- "@zhushanwen/pi-extension-logger": "0.6.1",
66
- "@zhushanwen/subagent-core": "1.2.0",
67
- "@zhushanwen/pi-subagent-cli": "0.5.0",
68
- "@zhushanwen/zcode-subagent-cli": "0.4.4"
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.84.4",
72
- "@earendil-works/pi-coding-agent": "^0.84.4",
73
- "@earendil-works/pi-tui": "^0.84.4",
74
- "typebox": "*",
75
- "@zhushanwen/pi-pending-notifications": "0.8.1",
76
- "@zhushanwen/pi-structured-output": "6.0.0"
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.8.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 调用)→ ack 清失败
7
- // 计数;失败折叠 + 延迟重试;推送在途期间多次迁移合并为单帧且携带最新绝对计数;
6
+ // ②构建者——初始上报(attachSession 触发,无需任何 subagent 调用);失败折叠 +
7
+ // 置脏(无 timer 自动重试,ADR-0122);推送在途期间多次迁移合并为单帧且携带
8
+ // 最新绝对计数;
8
9
  // ③观察者——onInFlightChanged 同步返回(不 await select,不进生命周期链);
9
- // detachSession 停重试,session 死后通道静默。
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({ retryDelayMs: RETRY_MS, selectTimeoutMs: SELECT_TIMEOUT_MS });
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({ retryDelayMs: RETRY_MS });
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({ retryDelayMs: RETRY_MS });
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("失败折叠 + 延迟重试(有界:累计 MAX_REPORT_ATTEMPTS 次放弃,D5 缺席语义②)", () => {
162
- it("select resolve undefined(超时/旧版 runtime)→ 折叠重试;重试帧携带完整快照;ack 后停", async () => {
161
+ describe("失败折叠(ADR-0122:无 timer 自动重试,事件驱动重推)", () => {
162
+ it("select resolve undefined(超时/旧版 runtime)→ 置脏不重试;下次迁移事件重推最新快照", async () => {
163
163
  const channel = makeSelectChannel();
164
- const reporter = createInFlightReporter({ retryDelayMs: RETRY_MS, selectTimeoutMs: SELECT_TIMEOUT_MS });
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(RETRY_MS - 1);
172
- expect(channel.calls).toHaveLength(1); // 未到退避点不重试
173
- await advance(1);
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
- // 重试帧得到 ack → 重试停止;随后迁移照常送达
184
+ // 重推帧得到 ack → 通道恢复;随后迁移照常送达
180
185
  channel.settle(1, INFLIGHT_REPORT_ACK);
181
- await advance(RETRY_MS * 10);
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 通道抛错同样折叠进重试路径", async () => {
196
+ it("select 通道抛错同样折叠置脏,由迁移事件重推", async () => {
192
197
  const channel = makeSelectChannel();
193
- const reporter = createInFlightReporter({ retryDelayMs: RETRY_MS });
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
- it("非确认回包(旧版 runtime 的任意字符串)不算送达,继续重试", async () => {
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("累计失败达默认上限(3 次)即放弃:不再重试,后续迁移也不再发帧", async () => {
212
+ it("非确认回包(旧版 runtime 的任意字符串)不算送达,置脏等事件重推", async () => {
216
213
  const channel = makeSelectChannel();
217
- // 未注入 maxAttempts:锁默认上限 MAX_REPORT_ATTEMPTS = 3(增强面降级语义)。
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(RETRY_MS * 10);
237
- expect(channel.calls).toHaveLength(3);
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({ retryDelayMs: RETRY_MS });
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(RETRY_MS * 5);
242
+ await advance(60_000);
255
243
  expect(channel.calls).toHaveLength(1);
256
244
  });
257
245
 
258
- it("detachSession 清掉待发重试定时器(session 已死,重试语义随之终结)", async () => {
246
+ it("detachSession 后失败落定不再发帧(ctx 已空,置脏无效果)", async () => {
259
247
  const channel = makeSelectChannel();
260
- const reporter = createInFlightReporter({ retryDelayMs: RETRY_MS });
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(RETRY_MS * 10);
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
+ });