@zhushanwen/subagent-core 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/execution/engine/engines/zcode/reader.d.cts +1 -1
  2. package/dist/execution/engine/engines/zcode/reader.d.ts +1 -1
  3. package/dist/index.cjs +1122 -452
  4. package/dist/index.d.cts +188 -5
  5. package/dist/index.d.ts +188 -5
  6. package/dist/index.js +1133 -463
  7. package/dist/{types-Dv4QhSJ_.d.cts → types-C3fE3R9x.d.cts} +65 -0
  8. package/dist/{types-Dv4QhSJ_.d.ts → types-C3fE3R9x.d.ts} +65 -0
  9. package/dist.bundle/index.cjs +34933 -0
  10. package/package.json +2 -2
  11. package/src/execution/__tests__/agent-registry.test.ts +1 -1
  12. package/src/execution/__tests__/alive-store.test.ts +1 -1
  13. package/src/execution/__tests__/collect-budget.test.ts +291 -0
  14. package/src/execution/__tests__/collect-coordinator-service.test.ts +327 -0
  15. package/src/execution/__tests__/collect-coordinator.test.ts +371 -0
  16. package/src/execution/__tests__/collect-mixed-dispatch.test.ts +262 -0
  17. package/src/execution/__tests__/config-collect-sync.test.ts +120 -0
  18. package/src/execution/__tests__/config.test.ts +1 -1
  19. package/src/execution/__tests__/explicit-agent-ref-guard.test.ts +1 -1
  20. package/src/execution/__tests__/finalized-marker.test.ts +1 -1
  21. package/src/execution/__tests__/gc-timer.test.ts +1 -1
  22. package/src/execution/__tests__/manifest-store-tmp-recovery.test.ts +1 -1
  23. package/src/execution/__tests__/notify-batch.test.ts +500 -0
  24. package/src/execution/__tests__/notify-ledger.test.ts +27 -0
  25. package/src/execution/__tests__/pi-invocation.test.ts +34 -2
  26. package/src/execution/__tests__/record-entry-collect.test.ts +102 -0
  27. package/src/execution/__tests__/relay-env.test.ts +12 -1
  28. package/src/execution/__tests__/session-file-gc.test.ts +1 -1
  29. package/src/execution/__tests__/spawn-event-adapter.test.ts +2 -2
  30. package/src/execution/__tests__/start-collect-guard.test.ts +255 -0
  31. package/src/execution/__tests__/start-sync-model-guard.test.ts +1 -1
  32. package/src/execution/__tests__/subagent-actions-core.test.ts +6 -0
  33. package/src/execution/__tests__/subagent-service-multiproc-guard.test.ts +13 -0
  34. package/src/execution/__tests__/sync-collect-recovery.test.ts +1117 -0
  35. package/src/execution/__tests__/tombstone-store.test.ts +1 -1
  36. package/src/execution/__tests__/worktree-git-ops.test.ts +1 -1
  37. package/src/execution/__tests__/worktree-reconcile-aging.test.ts +2 -2
  38. package/src/execution/__tests__/worktree-reconcile.integration.test.ts +1 -1
  39. package/src/execution/__tests__/worktree-registry.test.ts +1 -1
  40. package/src/execution/collect-coordinator.ts +200 -0
  41. package/src/execution/config.ts +77 -5
  42. package/src/execution/engine/__tests__/common/event-journal.test.ts +1 -1
  43. package/src/execution/engine/__tests__/common/pool-manager.test.ts +2 -2
  44. package/src/execution/engine/__tests__/conformance/contract.read-degradation.test.ts +1 -1
  45. package/src/execution/engine/__tests__/conformance/golden-replay.pi.test.ts +1 -1
  46. package/src/execution/engine/engines/zcode/__tests__/appserver-launcher.test.ts +1 -1
  47. package/src/execution/engine/engines/zcode/__tests__/zcode-engine-degrade.test.ts +1 -1
  48. package/src/execution/execution-record.ts +7 -0
  49. package/src/execution/finalize-record.ts +15 -11
  50. package/src/execution/notifier.ts +234 -25
  51. package/src/execution/notify-host.ts +21 -1
  52. package/src/execution/record-entry.ts +16 -0
  53. package/src/execution/record-store.ts +161 -30
  54. package/src/execution/subagent-actions-core.ts +62 -6
  55. package/src/execution/subagent-service.ts +416 -7
  56. package/src/execution/sync-rebuild.ts +87 -0
  57. package/src/execution/types.ts +63 -0
  58. package/src/index.ts +1 -1
  59. package/src/orchestration/__tests__/config-loader.test.ts +1 -1
  60. package/src/orchestration/__tests__/file-run-store-prune.test.ts +1 -1
  61. package/src/orchestration/__tests__/file-run-store-throttle.test.ts +1 -1
  62. package/src/orchestration/__tests__/review-fix-loop-scriptpath-failfast.test.ts +1 -1
  63. package/src/orchestration/__tests__/script-generate.test.ts +5 -5
  64. package/src/orchestration/__tests__/skill-discovery.test.ts +2 -2
  65. package/src/orchestration/__tests__/workflow-files.test.ts +10 -10
  66. package/src/orchestration/__tests__/workflow-script-registry-impl.test.ts +1 -1
  67. package/src/shared/__tests__/atomic-write.test.ts +1 -1
  68. package/src/shared/__tests__/resource-discovery-host-roots.test.ts +5 -5
@@ -0,0 +1,371 @@
1
+ // src/execution/__tests__/collect-coordinator.test.ts
2
+ //
3
+ // collectCoordinator 单测(subagent-sync-collect U2):全依赖注入,fake flush 断言
4
+ // 1. async 路由字节不变语义:notifyAsync 直通同一 record,协调器零改写(G3/A5);
5
+ // 2. sync 登记 + 闭合检测:缓冲非空 && 无非终态 sync(⛔3 非终态口径,池排队恒
6
+ // running 自动等待)→ flushBatch 收全部快照、缓冲清空;
7
+ // 3. 跨轮续累(D2 隐式批):分两轮派 2+1 sync 仍同一 pending 集(单批三成员);
8
+ // 4. cancel / watchdog(orphan 类)终态入 pending(设计 E3/E6)。
9
+ //
10
+ // [U8 拆批修复] 闭合满足 → setTimeout(0) 同宏任务去抖合批 flush(非立即)——背靠背
11
+ // route(终态已落 store、notifyComplete 尚在 finalize 链间隙)一并入批;武装后
12
+ // flush 异步到期,断言投递面/缓冲清空前须 await settleFlush() 开窗。
13
+ //
14
+ // 纯注入测试:无文件 IO、无真实 service——service 集成面见 collect-coordinator-service.test.ts。
15
+
16
+ import { describe, expect, it, vi } from "vitest";
17
+
18
+ import { COLLECT_SCAN_LIMIT, CollectCoordinator } from "../collect-coordinator.ts";
19
+ import type { CollectCoordinatorDeps } from "../collect-coordinator.ts";
20
+ import type { BgNotifyRecord } from "../notifier.ts";
21
+ import type { ExecutionRecord, SubagentRecord } from "../types.ts";
22
+
23
+ // ── stub 工厂 ──
24
+
25
+ function makeRec(over: Partial<ExecutionRecord> = {}): ExecutionRecord {
26
+ return {
27
+ id: "sa-1",
28
+ agent: "/home/u/agents/worker.md",
29
+ model: "prov/m1",
30
+ thinkingLevel: undefined,
31
+ mode: "background",
32
+ task: "t",
33
+ slug: "worker",
34
+ startedAt: 1000,
35
+ rootSessionId: "root-A",
36
+ parentRecordId: undefined,
37
+ depth: 0,
38
+ status: "closed",
39
+ turns: [],
40
+ turnCount: 1,
41
+ totalTokens: 10,
42
+ lastError: undefined,
43
+ endedAt: 2000,
44
+ result: "done",
45
+ error: undefined,
46
+ agentResult: undefined,
47
+ controller: undefined,
48
+ ...over,
49
+ } as ExecutionRecord;
50
+ }
51
+
52
+ function makeSnapshot(id: string, over: Partial<BgNotifyRecord> = {}): BgNotifyRecord {
53
+ return {
54
+ id,
55
+ status: "closed",
56
+ agent: "/home/u/agents/worker.md",
57
+ model: "prov/m1",
58
+ result: `result of ${id}`,
59
+ startedAt: 1000,
60
+ endedAt: 2000,
61
+ ...over,
62
+ };
63
+ }
64
+
65
+ /** store 枚举 stub:listRecords 返回的 SubagentRecord 形态(协调器闭合判定输入)。 */
66
+ function makeStoreRec(over: Partial<SubagentRecord> = {}): SubagentRecord {
67
+ return {
68
+ id: "sa-1",
69
+ agent: "/home/u/agents/worker.md",
70
+ task: "t",
71
+ slug: "worker",
72
+ status: "closed",
73
+ mode: "background",
74
+ startedAt: 1000,
75
+ rootSessionId: "root-A",
76
+ parentRecordId: undefined,
77
+ depth: 0,
78
+ endedAt: 2000,
79
+ turns: 1,
80
+ totalTokens: 10,
81
+ model: "prov/m1",
82
+ thinkingLevel: undefined,
83
+ eventLog: [],
84
+ displayItems: [],
85
+ ...over,
86
+ } as SubagentRecord;
87
+ }
88
+
89
+ interface Harness {
90
+ coordinator: CollectCoordinator;
91
+ notifyAsync: ReturnType<typeof vi.fn>;
92
+ toNotifyRecord: ReturnType<typeof vi.fn>;
93
+ listRecords: ReturnType<typeof vi.fn>;
94
+ flushBatch: ReturnType<typeof vi.fn>;
95
+ }
96
+
97
+ function makeHarness(storeRecords: SubagentRecord[] = []): Harness {
98
+ const notifyAsync = vi.fn();
99
+ // toNotifyRecord stub:从 record 透传终态语义字段(closedReason/error/result),
100
+ // 与 service.toNotifyRecord 的投影语义对齐。
101
+ const toNotifyRecord = vi.fn((record: ExecutionRecord) =>
102
+ makeSnapshot(record.id, {
103
+ closedReason: record.closedReason,
104
+ error: record.error,
105
+ result: record.result,
106
+ }),
107
+ );
108
+ const listRecords = vi.fn(() => storeRecords);
109
+ const flushBatch = vi.fn();
110
+ const coordinator = new CollectCoordinator({
111
+ notifyAsync,
112
+ toNotifyRecord,
113
+ listRecords,
114
+ flushBatch,
115
+ } satisfies CollectCoordinatorDeps);
116
+ return { coordinator, notifyAsync, toNotifyRecord, listRecords, flushBatch };
117
+ }
118
+
119
+ /** [U8 拆批修复] 闭合合批排程窗口等待:flush 排程 = setTimeout(0)(Node 1ms clamp),
120
+ * 10ms 真实定时器保证窗口已开(本包 vitest 真实 timers 环境)。 */
121
+ async function settleFlush(): Promise<void> {
122
+ await new Promise((resolve) => setTimeout(resolve, 10));
123
+ }
124
+
125
+ // ============================================================
126
+ // async 路由(字节不变语义)
127
+ // ============================================================
128
+
129
+ describe("CollectCoordinator async routing", () => {
130
+ it("routes records without collectMode straight to notifyAsync (直通同一 record)", () => {
131
+ const h = makeHarness();
132
+ const record = makeRec({ id: "sa-async" });
133
+ expect(h.coordinator.route(record)).toBe("async");
134
+ expect(h.notifyAsync).toHaveBeenCalledTimes(1);
135
+ expect(h.notifyAsync).toHaveBeenCalledWith(record);
136
+ expect(h.flushBatch).not.toHaveBeenCalled();
137
+ expect(h.coordinator.pendingCount).toBe(0);
138
+ });
139
+
140
+ it("routes chatMode records async (对话回流不入批,D8)", () => {
141
+ const h = makeHarness();
142
+ const record = makeRec({ id: "sa-chat", chatMode: true });
143
+ expect(h.coordinator.route(record)).toBe("async");
144
+ expect(h.notifyAsync).toHaveBeenCalledTimes(1);
145
+ });
146
+
147
+ it("scan limit is 1000 (与 service 冷路径同量级)", () => {
148
+ expect(COLLECT_SCAN_LIMIT).toBe(1000);
149
+ });
150
+ });
151
+
152
+ // ============================================================
153
+ // sync 登记 + 闭合检测
154
+ // ============================================================
155
+
156
+ describe("CollectCoordinator sync buffering + closure", () => {
157
+ it("buffers a finished sync member while another sync member is still running", () => {
158
+ // store:sa-2 仍在跑(非终态)→ sa-1 终态入缓冲不闭合
159
+ const h = makeHarness([
160
+ makeStoreRec({ id: "sa-1", collectMode: "sync", status: "closed" }),
161
+ makeStoreRec({ id: "sa-2", collectMode: "sync", status: "running" }),
162
+ ]);
163
+ const result = h.coordinator.route(makeRec({ id: "sa-1", collectMode: "sync" }));
164
+ expect(result).toBe("sync-buffered");
165
+ expect(h.coordinator.pendingCount).toBe(1);
166
+ expect(h.flushBatch).not.toHaveBeenCalled();
167
+ });
168
+
169
+ it("flushes the whole buffer when the last running sync member finishes", async () => {
170
+ // 时序:sa-1 先终态(sa-2 仍 running → 入缓冲);sa-2 最后终态(store 全 closed)
171
+ // → 闭合 flush 两成员。
172
+ let sa2Running = true;
173
+ const storeRecords = (): SubagentRecord[] => [
174
+ makeStoreRec({ id: "sa-1", collectMode: "sync", status: "closed" }),
175
+ makeStoreRec({ id: "sa-2", collectMode: "sync", status: sa2Running ? "running" : "closed" }),
176
+ ];
177
+ const h = makeHarness();
178
+ (h as { listRecords: ReturnType<typeof vi.fn> }).listRecords.mockImplementation(storeRecords);
179
+ expect(h.coordinator.route(makeRec({ id: "sa-1", collectMode: "sync" }))).toBe("sync-buffered");
180
+ sa2Running = false;
181
+ expect(h.coordinator.route(makeRec({ id: "sa-2", collectMode: "sync" }))).toBe("sync-flushed");
182
+ await settleFlush();
183
+ expect(h.flushBatch).toHaveBeenCalledTimes(1);
184
+ const members = h.flushBatch.mock.calls[0]?.[0] as BgNotifyRecord[];
185
+ expect(members.map((m) => m.id)).toEqual(["sa-1", "sa-2"]);
186
+ expect(h.coordinator.pendingCount).toBe(0);
187
+ });
188
+
189
+ it("a lone sync member flushes via the coalescing window (单成员即闭合)", async () => {
190
+ const h = makeHarness([
191
+ makeStoreRec({ id: "sa-1", collectMode: "sync", status: "closed" }),
192
+ ]);
193
+ const result = h.coordinator.route(makeRec({ id: "sa-1", collectMode: "sync" }));
194
+ expect(result).toBe("sync-flushed");
195
+ await settleFlush();
196
+ expect(h.flushBatch).toHaveBeenCalledTimes(1);
197
+ });
198
+
199
+ it("back-to-back routes in one macrotask coalesce into ONE flush (U8 拆批盲窗回归)", async () => {
200
+ // 盲窗精确形态:A/B 均已 archive(listAllActive 扫描恒空——终态已落 store 但
201
+ // B 的 notifyComplete 尚在 finalize 链间隙)。修复前:route(A) 闭合立即 flush([A]),
202
+ // route(B) 再 flush([B]) → 两条单成员批;修复后:同宏任务去抖窗口把 B 并入,
203
+ // 单批两成员一次投递。
204
+ const h = makeHarness([]); // store 空 = 双成员均已 archive(盲窗扫描视图)
205
+ expect(h.coordinator.route(makeRec({ id: "sa-a", collectMode: "sync" }))).toBe("sync-flushed");
206
+ expect(h.coordinator.route(makeRec({ id: "sa-b", collectMode: "sync" }))).toBe("sync-flushed");
207
+ await settleFlush();
208
+ expect(h.flushBatch).toHaveBeenCalledTimes(1);
209
+ const members = h.flushBatch.mock.calls[0]?.[0] as BgNotifyRecord[];
210
+ expect(members.map((m) => m.id)).toEqual(["sa-a", "sa-b"]);
211
+ expect(h.coordinator.pendingCount).toBe(0);
212
+ });
213
+
214
+ it("cancelScheduledFlush drops the pending window and keeps the buffer (E9 dispose 语义)", async () => {
215
+ // dispose 到来时挂起排程取消、缓冲原样保留(convertPendingSyncBufferToAsync
216
+ // 仍读得到全部成员转 async)——service 侧 E9 交互的单元级前置。
217
+ const h = makeHarness([
218
+ makeStoreRec({ id: "sa-1", collectMode: "sync", status: "closed" }),
219
+ ]);
220
+ expect(h.coordinator.route(makeRec({ id: "sa-1", collectMode: "sync" }))).toBe("sync-flushed");
221
+ h.coordinator.cancelScheduledFlush();
222
+ await settleFlush();
223
+ expect(h.flushBatch).not.toHaveBeenCalled();
224
+ expect(h.coordinator.pendingCount).toBe(1);
225
+ expect(h.coordinator.pendingMembers().map((m) => m.id)).toEqual(["sa-1"]);
226
+ });
227
+
228
+ it("pool-queued sync members (status=running) block closure (⛔3 非终态口径)", () => {
229
+ // sa-2 在池排队:store.register 起即 status=running(无独立排队态)→ 阻止闭合
230
+ const h = makeHarness([
231
+ makeStoreRec({ id: "sa-1", collectMode: "sync", status: "closed" }),
232
+ makeStoreRec({ id: "sa-2", collectMode: "sync", status: "running" }),
233
+ ]);
234
+ const result = h.coordinator.route(makeRec({ id: "sa-1", collectMode: "sync" }));
235
+ expect(result).toBe("sync-buffered");
236
+ expect(h.flushBatch).not.toHaveBeenCalled();
237
+ });
238
+
239
+ it("batchFinalized members do not block closure (已离场成员)", async () => {
240
+ const h = makeHarness([
241
+ makeStoreRec({ id: "sa-1", collectMode: "sync", status: "closed" }),
242
+ makeStoreRec({ id: "sa-2", collectMode: "sync", status: "closed", batchFinalized: true }),
243
+ ]);
244
+ const result = h.coordinator.route(makeRec({ id: "sa-1", collectMode: "sync" }));
245
+ expect(result).toBe("sync-flushed");
246
+ await settleFlush();
247
+ expect(h.flushBatch).toHaveBeenCalledTimes(1);
248
+ });
249
+
250
+ it("async records never block sync closure (混派正交,A8 前置)", async () => {
251
+ const h = makeHarness([
252
+ makeStoreRec({ id: "sa-1", collectMode: "sync", status: "closed" }),
253
+ makeStoreRec({ id: "sa-async", status: "running" }), // 无 collectMode:async 在跑
254
+ ]);
255
+ const result = h.coordinator.route(makeRec({ id: "sa-1", collectMode: "sync" }));
256
+ expect(result).toBe("sync-flushed");
257
+ await settleFlush();
258
+ expect(h.flushBatch).toHaveBeenCalledTimes(1);
259
+ });
260
+
261
+ it("skips buffering when toNotifyRecord returns undefined (gate 未过,与 async 静默跳过对称)", () => {
262
+ const notifyAsync = vi.fn();
263
+ const toNotifyRecord = vi.fn(() => undefined);
264
+ const listRecords = vi.fn(() => [] as SubagentRecord[]);
265
+ const flushBatch = vi.fn();
266
+ const coordinator = new CollectCoordinator({ notifyAsync, toNotifyRecord, listRecords, flushBatch });
267
+ const result = coordinator.route(makeRec({ id: "sa-x", collectMode: "sync" }));
268
+ expect(result).toBe("sync-skipped");
269
+ expect(coordinator.pendingCount).toBe(0);
270
+ expect(flushBatch).not.toHaveBeenCalled();
271
+ });
272
+ });
273
+
274
+ // ============================================================
275
+ // 跨轮续累(D2 隐式批:2+1 同一 pending 集)
276
+ // ============================================================
277
+
278
+ describe("CollectCoordinator cross-turn accumulation (D2)", () => {
279
+ it("accumulates 2+1 sync starts across two turns into ONE flush batch", async () => {
280
+ // round1 派 A、B;round2 派 C。终态时序:A → C → B(B 最后闭合)。
281
+ // store 枚举由「谁还活着」驱动:闭合只看非终态 sync 是否清零。
282
+ let running = new Set(["sa-b"]);
283
+ const storeRecords = (): SubagentRecord[] =>
284
+ [...running].map((id) => makeStoreRec({ id, collectMode: "sync", status: "running" }));
285
+ const notifyAsync = vi.fn();
286
+ const toNotifyRecord = vi.fn((record: ExecutionRecord) => makeSnapshot(record.id));
287
+ const listRecords = vi.fn(() => storeRecords());
288
+ const flushBatch = vi.fn();
289
+ const coordinator = new CollectCoordinator({ notifyAsync, toNotifyRecord, listRecords, flushBatch });
290
+
291
+ // round1:A 终态(B 仍在跑)→ 缓冲 [A]
292
+ expect(coordinator.route(makeRec({ id: "sa-a", collectMode: "sync" }))).toBe("sync-buffered");
293
+ expect(coordinator.pendingMembers().map((m) => m.id)).toEqual(["sa-a"]);
294
+
295
+ // round2:派 C(跨轮续累——同一 pending 集);C 终态(B 仍在跑)→ 缓冲 [A, C]
296
+ expect(coordinator.route(makeRec({ id: "sa-c", collectMode: "sync" }))).toBe("sync-buffered");
297
+ expect(coordinator.pendingMembers().map((m) => m.id)).toEqual(["sa-a", "sa-c"]);
298
+
299
+ // B 终态(先从 running 集移除 = 终态落 store)→ 闭合:单批三成员(2+1 同一批)
300
+ running.delete("sa-b");
301
+ expect(coordinator.route(makeRec({ id: "sa-b", collectMode: "sync" }))).toBe("sync-flushed");
302
+ await settleFlush();
303
+ expect(flushBatch).toHaveBeenCalledTimes(1);
304
+ const members = flushBatch.mock.calls[0]?.[0] as BgNotifyRecord[];
305
+ expect(members.map((m) => m.id).sort()).toEqual(["sa-a", "sa-b", "sa-c"]);
306
+ expect(coordinator.pendingCount).toBe(0);
307
+ });
308
+
309
+ it("starts a fresh batch after a flush (闭合后新派开新批)", async () => {
310
+ // sa-2 在跑(阻止闭合):sa-1 终态入缓冲;sa-2 终态闭合后,新派 sa-3 终态开新批。
311
+ const store: SubagentRecord[] = [
312
+ makeStoreRec({ id: "sa-1", collectMode: "sync", status: "closed" }),
313
+ makeStoreRec({ id: "sa-2", collectMode: "sync", status: "running" }),
314
+ ];
315
+ const h = makeHarness();
316
+ (h as { listRecords: ReturnType<typeof vi.fn> }).listRecords.mockImplementation(() => store);
317
+ expect(h.coordinator.route(makeRec({ id: "sa-1", collectMode: "sync" }))).toBe("sync-buffered");
318
+ store[1] = makeStoreRec({ id: "sa-2", collectMode: "sync", status: "closed" });
319
+ expect(h.coordinator.route(makeRec({ id: "sa-2", collectMode: "sync" }))).toBe("sync-flushed");
320
+ await settleFlush(); // 第一批投出(合批窗口开)
321
+ // 闭合后新 sync 终态 → 新批缓冲(不复活旧成员):store 无 running sync → 但
322
+ // 缓冲非空在先入时判定,route(sa-3) 时缓冲空 + 无 running → 闭合开新批排程
323
+ expect(h.coordinator.route(makeRec({ id: "sa-3", collectMode: "sync" }))).toBe("sync-flushed");
324
+ await settleFlush();
325
+ expect(h.coordinator.pendingMembers().map((m) => m.id)).toEqual([]);
326
+ expect(h.flushBatch).toHaveBeenCalledTimes(2);
327
+ });
328
+ });
329
+
330
+ // ============================================================
331
+ // cancel / watchdog(orphan 类)终态入 pending(E3/E6)
332
+ // ============================================================
333
+
334
+ describe("CollectCoordinator cancel/watchdog terminal states enter the batch", () => {
335
+ it("routes a cancelled sync member into the buffer (E6:cancel 计入批)", async () => {
336
+ const h = makeHarness([
337
+ makeStoreRec({ id: "sa-1", collectMode: "sync", status: "closed" }),
338
+ ]);
339
+ const record = makeRec({
340
+ id: "sa-1",
341
+ collectMode: "sync",
342
+ status: "closed",
343
+ closedReason: "cancelled",
344
+ result: "",
345
+ error: "cancelled by user",
346
+ });
347
+ expect(h.coordinator.route(record)).toBe("sync-flushed");
348
+ await settleFlush();
349
+ const members = h.flushBatch.mock.calls[0]?.[0] as BgNotifyRecord[];
350
+ expect(members).toHaveLength(1);
351
+ expect(members[0]?.closedReason).toBe("cancelled");
352
+ });
353
+
354
+ it("routes a watchdog/gc-failed sync member into the buffer (E3:orphan 类终态计入批)", async () => {
355
+ const h = makeHarness([
356
+ makeStoreRec({ id: "sa-1", collectMode: "sync", status: "closed" }),
357
+ ]);
358
+ const record = makeRec({
359
+ id: "sa-1",
360
+ collectMode: "sync",
361
+ status: "closed",
362
+ closedReason: "gc",
363
+ result: "",
364
+ error: "settled watchdog timeout",
365
+ });
366
+ expect(h.coordinator.route(record)).toBe("sync-flushed");
367
+ await settleFlush();
368
+ const members = h.flushBatch.mock.calls[0]?.[0] as BgNotifyRecord[];
369
+ expect(members[0]?.error).toBe("settled watchdog timeout");
370
+ });
371
+ });
@@ -0,0 +1,262 @@
1
+ // src/execution/__tests__/collect-mixed-dispatch.test.ts
2
+ //
3
+ // A8 混派正交 service 级 e2e(subagent-sync-collect U8;设计 §4 A8 验收行 /
4
+ // §3.1.4 数据流):**同轮** 2 个 collect:"sync" + 1 个 async(不传 collect)全部经
5
+ // SubagentService.execute 真链(mock 仅 session-runner 的 runSpawn——时序受控终态,
6
+ // 与 collect-coordinator-service.test.ts 同手法)。
7
+ //
8
+ // 与协调器层既有覆盖(collect-coordinator.test.ts "async records never block sync
9
+ // closure" / notify-batch.test.ts "混派正交")的差异:那两层 stub 了 notifyAsync /
10
+ // toNotifyRecord / listRecords;本文件走 service.execute 全链(record 落 collectMode →
11
+ // kickOffBackground → runAndFinalize → notifyComplete → 协调器真路由 → notifier 真
12
+ // spy),锁「同轮混派」在真实投递接线下的两个方向:
13
+ // 1. async 成员先终态:立即走 notify 直通(不被 sync 批扣留),批零投递;
14
+ // 随后 sync 成员逐个终态 → 恰 1 次 notifyBatch、成员恰为 2 个 sync id;
15
+ // 2. sync 批先闭合(async 仍在跑):两个 sync 背靠背紧窗口终态(U8 拆批盲窗的
16
+ // 精确复现形态)→ 去抖合批单批 flush 不等 async;随后 async 终态才收到
17
+ // 自己的单条 notify。
18
+ //
19
+ // 三视角(TEST-STRATEGY §3):
20
+ // - 使用者黑盒:派发方可见形态 = async 通知即时到达、sync 结果合并为单条批;
21
+ // - 构建者白盒:协调器路由去向 + notifier spy 调用面;
22
+ // - 观察者形态:pi.appendEntry 落盘的 subagent-record entry(collectMode 标记)。
23
+
24
+ import * as fs from "node:fs";
25
+ import * as os from "node:os";
26
+ import * as path from "node:path";
27
+
28
+ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
29
+
30
+ const { loggerMock, runSpawnMock } = vi.hoisted(() => ({
31
+ loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn(), info: vi.fn() },
32
+ runSpawnMock: vi.fn(async () => ({
33
+ text: "ok",
34
+ turns: 1,
35
+ durationMs: 10,
36
+ success: true,
37
+ sessionId: "spawned",
38
+ toolCalls: [],
39
+ })),
40
+ }));
41
+ vi.mock("../../core/logger.ts", () => ({ getLogger: () => loggerMock }));
42
+
43
+ // mock 路径自检(impl-plan 偏差 #6 教训):从 __tests__ 解析必须命中生产 import 的
44
+ // 真实模块——merge 后 session-runner 落位 src/execution/engine/engines/pi/session-runner.ts
45
+ //(subagent-service.ts 的 import 源),旧路径 "../session-runner.ts" 模块已不存在,
46
+ // 拦截静默失效(真 runSpawn 被执行,runSpawnMock.calls 恒 0 → 受控 promise 用例超时)。
47
+ vi.mock("../engine/engines/pi/session-runner.ts", () => ({
48
+ runSpawn: runSpawnMock,
49
+ killAllSpawnedChildren: vi.fn(),
50
+ killRecordChildWithEscalation: vi.fn(),
51
+ getChildByRecord: vi.fn(() => undefined),
52
+ registerSpawnedChildForRecord: vi.fn(),
53
+ spawnedChildren: new Map(),
54
+ }));
55
+
56
+ import { getSubagentSessionDir } from "../path-encoding.ts";
57
+ import { ModelConfigService } from "../model-config-service.ts";
58
+ import type { ModelRegistryLike } from "../model-resolver.ts";
59
+ import { SubagentService } from "../subagent-service.ts";
60
+
61
+ const IDENTITY_ENV_KEYS = [
62
+ "PI_SUBAGENT_ROOT_SESSION_ID",
63
+ "PI_SUBAGENT_SELF_RECORD_ID",
64
+ "PI_SUBAGENT_DEPTH",
65
+ "PI_SUBAGENT_ROOT_CWD",
66
+ "PI_SUBAGENT_FORK_DEPTH",
67
+ ] as const;
68
+
69
+ function makePi() {
70
+ return {
71
+ appendEntry: vi.fn(),
72
+ events: { emit: vi.fn() },
73
+ sendMessage: vi.fn(),
74
+ on: vi.fn(),
75
+ };
76
+ }
77
+
78
+ interface NotifierSpy {
79
+ // Mock<T> 而非纯函数签名:测试断言消费 .mock.calls,需保留 mock 元数据
80
+ notify: Mock<(record: unknown) => void>;
81
+ notifyBatch: Mock<(records: unknown, budget?: unknown) => boolean>;
82
+ }
83
+
84
+ function spyNotifier(service: SubagentService): NotifierSpy {
85
+ // [D4-① 适配] notifier 实例已封装进 notifyHost(createNotifyHost),spy 改为包装
86
+ // host 的 notify/notifyBatch 出口(其余方法经 ...host 保留真实现);协调器 deps
87
+ // 闭包经 this 运行时读取 service.notifyHost,字段替换即生效。
88
+ const host = (service as unknown as { notifyHost: object }).notifyHost;
89
+ const spy: NotifierSpy = { notify: vi.fn(), notifyBatch: vi.fn(() => true) };
90
+ (service as unknown as { notifyHost: unknown }).notifyHost = {
91
+ ...host,
92
+ notify: (record: unknown) => spy.notify(record),
93
+ notifyBatch: (records: unknown, budget?: unknown) => spy.notifyBatch(records, budget),
94
+ };
95
+ return spy;
96
+ }
97
+
98
+ /** 手写短轮询(本包 vitest 4.1.8 环境 vi.waitFor 失效——impl-plan §7 环境注意)。 */
99
+ async function until(cond: () => boolean, timeoutMs = 3000): Promise<void> {
100
+ const start = Date.now();
101
+ while (!cond()) {
102
+ if (Date.now() - start > timeoutMs) {
103
+ throw new Error(`collect-mixed-dispatch: condition not met within ${timeoutMs}ms`);
104
+ }
105
+ await new Promise((resolve) => setTimeout(resolve, 10));
106
+ }
107
+ }
108
+
109
+ const spawnOk = () => ({
110
+ text: "ok",
111
+ turns: 1,
112
+ durationMs: 10,
113
+ success: true,
114
+ sessionId: "spawned",
115
+ toolCalls: [],
116
+ });
117
+
118
+ /** 受控 runSpawn:每次调用挂起返回 resolver,测试按成员序号放行终态。
119
+ * resolver 在 runSpawn 实际调用时(execute 异步链内)才构造——release 函数按
120
+ * 索引延迟取用,不能在挂起注册时立刻闭包捕获(彼时尚为 undefined)。 */
121
+ function controlledSpawns(n: number): Array<(v?: ReturnType<typeof spawnOk>) => void> {
122
+ const resolvers: Array<(v: ReturnType<typeof spawnOk>) => void> = [];
123
+ for (let i = 0; i < n; i += 1) {
124
+ runSpawnMock.mockImplementationOnce(
125
+ () =>
126
+ new Promise((res) => {
127
+ resolvers[i] = res;
128
+ }),
129
+ );
130
+ }
131
+ return Array.from({ length: n }, (_, i) => (v?: ReturnType<typeof spawnOk>) =>
132
+ resolvers[i]!(v ?? spawnOk()),
133
+ );
134
+ }
135
+
136
+ describe("A8 混派正交 service e2e(同轮 2 sync + 1 async,U8)", () => {
137
+ let agentDir: string;
138
+ let service: SubagentService;
139
+ let pi: ReturnType<typeof makePi>;
140
+
141
+ beforeEach(() => {
142
+ for (const k of IDENTITY_ENV_KEYS) delete process.env[k];
143
+ agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "core-mixed-dispatch-"));
144
+ fs.mkdirSync(getSubagentSessionDir(agentDir, agentDir), { recursive: true });
145
+ pi = makePi();
146
+ const modelService = new ModelConfigService({ agentDir, cwd: agentDir });
147
+ const modelRegistry: ModelRegistryLike = {
148
+ getAvailable: () => [],
149
+ find: () => undefined,
150
+ hasConfiguredAuth: () => false,
151
+ };
152
+ modelService.initModel({
153
+ sessionId: "root-session-cur",
154
+ ctxModel: { id: "m", name: "M", provider: "p", reasoning: false },
155
+ modelRegistry,
156
+ });
157
+ service = new SubagentService({ cwd: agentDir, modelService });
158
+ service.initSession({ pi, sessionId: "root-session-cur" });
159
+ });
160
+
161
+ afterEach(() => {
162
+ service.dispose();
163
+ fs.rmSync(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
164
+ });
165
+
166
+ it("async 先终态:即时直通 notify(不被 sync 批扣留)→ sync 逐个终态 → 恰 1 批 2 成员", async () => {
167
+ const spy = spyNotifier(service);
168
+ // 同轮三连派发:2 sync + 1 async(派发顺序 sync, sync, async——同一条消息内的
169
+ // 混派形态;runSpawn 调用序与 execute 序一致)
170
+ const [doneSync1, doneSync2, doneAsync] = controlledSpawns(3);
171
+ const hSync1 = await service.execute({ task: "s1", slug: "sync-one", collect: "sync" });
172
+ const hSync2 = await service.execute({ task: "s2", slug: "sync-two", collect: "sync" });
173
+ const hAsync = await service.execute({ task: "a1", slug: "async-one" });
174
+ await until(() => runSpawnMock.mock.calls.length >= 3); // 三个受控 promise 均已构造
175
+
176
+ // async 成员先终态:立即单条 notify(现状路径),批零投递、零扣留
177
+ doneAsync();
178
+ await until(() => spy.notify.mock.calls.length > 0);
179
+ expect(spy.notifyBatch).not.toHaveBeenCalled();
180
+ expect(spy.notify.mock.calls).toHaveLength(1);
181
+ expect((spy.notify.mock.calls[0]![0] as { id: string }).id).toBe(hAsync.subagentId);
182
+
183
+ // sync-1 终态:sync-2 仍 running → 缓冲不闭合(async 通知不重复)
184
+ doneSync1();
185
+ await new Promise((resolve) => setTimeout(resolve, 50));
186
+ expect(spy.notifyBatch).not.toHaveBeenCalled();
187
+ expect(spy.notify.mock.calls).toHaveLength(1);
188
+
189
+ // sync-2 终态:闭合 → 恰 1 次批投递,成员恰为 2 个 sync id(async 不入批)
190
+ doneSync2();
191
+ await until(() => spy.notifyBatch.mock.calls.length > 0);
192
+ expect(spy.notifyBatch.mock.calls).toHaveLength(1);
193
+ const batch = spy.notifyBatch.mock.calls[0]![0] as { id: string }[];
194
+ expect(batch.map((m) => m.id).sort()).toEqual([hSync1.subagentId, hSync2.subagentId].sort());
195
+ // async 通知面恒 1(批闭合不重发 async)
196
+ expect(spy.notify.mock.calls).toHaveLength(1);
197
+ });
198
+
199
+ it("sync 批先闭合(async 仍在跑):背靠背紧窗口合批单批;async 随后独立单条 notify", async () => {
200
+ const spy = spyNotifier(service);
201
+ const [doneSync1, doneSync2, doneAsync] = controlledSpawns(3);
202
+ const hSync1 = await service.execute({ task: "s1", slug: "sync-one", collect: "sync" });
203
+ const hSync2 = await service.execute({ task: "s2", slug: "sync-two", collect: "sync" });
204
+ const hAsync = await service.execute({ task: "a1", slug: "async-one" });
205
+ await until(() => runSpawnMock.mock.calls.length >= 3);
206
+
207
+ // 两个 sync 同步段背靠背终态(窄于 finalize 链宽度——U8 拆批盲窗的精确复现形态:
208
+ // 先 archive 的成员 route 时,后者的 notifyComplete 尚在 finalize 链间隙,闭合
209
+ // 扫描看不见)。协调器去抖合批窗口把间隙内 route 的成员并入同一批:修复前此处
210
+ // 产出 [["sa-1"],["sa-2"]] 两条单成员批,修复后稳定单批(50ms 错峰 workaround
211
+ // 已移除——本用例即拆批盲窗的回归守卫)。
212
+ doneSync1();
213
+ doneSync2();
214
+ await until(() => spy.notifyBatch.mock.calls.length > 0);
215
+ expect(spy.notifyBatch).toHaveBeenCalledTimes(1);
216
+ const batch = spy.notifyBatch.mock.calls[0]![0] as { id: string }[];
217
+ expect(batch.map((m) => m.id).sort()).toEqual([hSync1.subagentId, hSync2.subagentId].sort());
218
+ // async 未终态:批闭合不产生 async 通知
219
+ expect(spy.notify).not.toHaveBeenCalled();
220
+
221
+ // async 随后终态:独立单条 notify(与批互不串扰)
222
+ doneAsync();
223
+ await until(() => spy.notify.mock.calls.length > 0);
224
+ expect(spy.notify.mock.calls).toHaveLength(1);
225
+ expect((spy.notify.mock.calls[0]![0] as { id: string }).id).toBe(hAsync.subagentId);
226
+ expect(spy.notifyBatch).not.toHaveBeenCalledTimes(2);
227
+ });
228
+
229
+ it("观察者形态:同轮混派三 record 的落盘 entry 各自携带正确 collectMode", async () => {
230
+ const spy = spyNotifier(service);
231
+ const [, , doneAsync] = controlledSpawns(3);
232
+ const hSync1 = await service.execute({ task: "s1", slug: "sync-one", collect: "sync" });
233
+ const hSync2 = await service.execute({ task: "s2", slug: "sync-two", collect: "sync" });
234
+ const hAsync = await service.execute({ task: "a1", slug: "async-one" });
235
+ await until(() => runSpawnMock.mock.calls.length >= 3);
236
+ doneAsync();
237
+ await until(() => spy.notify.mock.calls.length > 0);
238
+ // 批不必闭合即可断言落盘(register 期 entry 即带 collectMode):
239
+ // sync 成员 entry 带 collectMode:"sync",async 成员 entry 无该键(缺省 async)
240
+ const entriesBy = (id: string) =>
241
+ pi.appendEntry.mock.calls
242
+ .filter((c) => c[0] === "subagent-record")
243
+ .map((c) => c[1] as Record<string, unknown>)
244
+ .filter((d) => d["id"] === id);
245
+ expect(entriesBy(hSync1.subagentId).some((d) => d["collectMode"] === "sync")).toBe(true);
246
+ expect(entriesBy(hSync2.subagentId).some((d) => d["collectMode"] === "sync")).toBe(true);
247
+ expect(
248
+ entriesBy(hAsync.subagentId).every((d) => !("collectMode" in d) || d["collectMode"] === undefined),
249
+ ).toBe(true);
250
+ });
251
+ });
252
+
253
+ // 【已知盲窗·已修复(U8 授权修复轮)】背靠背拆批竞态(机理回顾:runAndFinalize 的
254
+ // completeRecord+archive 先于 .then 链 notifyComplete,成员 B 已终态归档但未路由时
255
+ // A 的闭合检测看不到 B → flush([A]),随后 B route 再 flush([B]))已在
256
+ // collect-coordinator.ts 落地修复:闭合首次满足不立即 flush,改 setTimeout(0)
257
+ // 同宏任务去抖合批——排程窗口内新 route(finalize 链间隙的成员)并入缓冲后一并
258
+ // flush;触发时重验闭合条件(窗口内新 running 反转则留缓冲等其终态重排程)。
259
+ // E9 交互:dispose 经 convertPendingSyncBufferToAsync 先 cancelScheduledFlush
260
+ //(取消而非同步 flush——放任触发会与逐条转 async 写账双通道并发 → 双投递),缓冲
261
+ // 原样保留供转换;E1 无交集(session_start 时点排程必已被取消,补发直走 notifyBatch,
262
+ // 账本 sync-batch:<hash> 幂等兑底)。上方第二用例已改回背靠背紧窗口断言作回归守卫。