@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,120 @@
1
+ // src/execution/__tests__/config-collect-sync.test.ts
2
+ //
3
+ // collectSync 节 sanitize(E5)测试(subagent-sync-collect U1 foundation)。
4
+ // E5(设计 §3.1.5):config 坏值(负数/非枚举/坏类型)逐字段回默认,不炸启动,
5
+ // 与 maxConcurrent sanitize 同判(正整数/枚举成员校验)。
6
+ //
7
+ // 纯函数测试:无文件 IO、无 timer 依赖——不触真实数据目录(红线)。
8
+
9
+ import { describe, expect, it, vi, beforeEach } from "vitest";
10
+
11
+ // logger mock(D6 #8 坏值 warn 断言;config.test.ts 同款拦 getLogger)
12
+ const loggerMock = vi.hoisted(() => ({ warn: vi.fn(), debug: vi.fn(), info: vi.fn(), error: vi.fn() }));
13
+ vi.mock("../../core/logger.ts", () => ({ getLogger: () => loggerMock }));
14
+
15
+ import { DEFAULT_COLLECT_SYNC, DEFAULT_CONFIG, sanitizeCollectSync } from "../config.ts";
16
+
17
+ beforeEach(() => {
18
+ loggerMock.warn.mockClear();
19
+ });
20
+
21
+ // ============================================================
22
+ // 权威默认值
23
+ // ============================================================
24
+
25
+ describe("DEFAULT_COLLECT_SYNC", () => {
26
+ it("matches the design defaults (async / 4000 / 24000)", () => {
27
+ expect(DEFAULT_COLLECT_SYNC).toEqual({
28
+ default: "async",
29
+ perItemChars: 4000,
30
+ totalChars: 24000,
31
+ });
32
+ });
33
+
34
+ it("DEFAULT_CONFIG omits collectSync (缺省键缺失:startupConfig 声明守护保持绿,消费方 ?? 兜底)", () => {
35
+ expect(DEFAULT_CONFIG.collectSync).toBeUndefined();
36
+ });
37
+ });
38
+
39
+ // ============================================================
40
+ // sanitizeCollectSync(E5)
41
+ // ============================================================
42
+
43
+ describe("sanitizeCollectSync (E5)", () => {
44
+ it("passes a fully valid node through", () => {
45
+ expect(
46
+ sanitizeCollectSync({ default: "sync", perItemChars: 8000, totalChars: 48000 }),
47
+ ).toEqual({ default: "sync", perItemChars: 8000, totalChars: 48000 });
48
+ });
49
+
50
+ it("fills per-field defaults for a partial node (部分覆盖合法)", () => {
51
+ expect(sanitizeCollectSync({ default: "sync" })).toEqual({
52
+ default: "sync",
53
+ perItemChars: 4000,
54
+ totalChars: 24000,
55
+ });
56
+ expect(sanitizeCollectSync({ totalChars: 12000 })).toEqual({
57
+ default: "async",
58
+ perItemChars: 4000,
59
+ totalChars: 12000,
60
+ });
61
+ });
62
+
63
+ it("returns undefined for a missing node (消费方读 DEFAULT_COLLECT_SYNC 兜底)", () => {
64
+ expect(sanitizeCollectSync(undefined)).toBeUndefined();
65
+ });
66
+
67
+ it("falls back to default for non-enum collectSync.default values (E5 非枚举)", () => {
68
+ for (const bad of ["immediate", "SYNC", "batch", 1, 0, true, null]) {
69
+ expect(sanitizeCollectSync({ default: bad })?.default).toBe("async");
70
+ }
71
+ });
72
+
73
+ it("accepts both enum members", () => {
74
+ expect(sanitizeCollectSync({ default: "sync" })?.default).toBe("sync");
75
+ expect(sanitizeCollectSync({ default: "async" })?.default).toBe("async");
76
+ });
77
+
78
+ it("falls back to default for negative/zero/fraction/non-numeric budgets (E5 负数)", () => {
79
+ for (const bad of [-1, 0, -4000, 1.5, "4000", null]) {
80
+ expect(sanitizeCollectSync({ perItemChars: bad })?.perItemChars).toBe(4000);
81
+ expect(sanitizeCollectSync({ totalChars: bad })?.totalChars).toBe(24000);
82
+ }
83
+ expect(sanitizeCollectSync({ perItemChars: Number.NaN })?.perItemChars).toBe(4000);
84
+ expect(sanitizeCollectSync({ perItemChars: Number.POSITIVE_INFINITY })?.perItemChars).toBe(4000);
85
+ });
86
+
87
+ it("returns undefined for non-object scalar nodes (E5 坏类型)", () => {
88
+ for (const bad of [null, 42, "sync", true]) {
89
+ expect(sanitizeCollectSync(bad)).toBeUndefined();
90
+ }
91
+ });
92
+
93
+ it("treats an array node as an object with all-bad fields (whole-defaults)", () => {
94
+ expect(sanitizeCollectSync([])).toEqual(DEFAULT_COLLECT_SYNC);
95
+ });
96
+
97
+ it("never throws on arbitrary garbage (E5 不炸启动)", () => {
98
+ expect(() =>
99
+ sanitizeCollectSync({ default: Symbol("x"), perItemChars: {}, totalChars: NaN }),
100
+ ).not.toThrow();
101
+ });
102
+
103
+ it("D6 #8:显式坏值字段回默认时 warn 一条(含字段名与原值);缺省字段不 warn", () => {
104
+ // 全好字段(含合法部分覆盖)→ 零 warn
105
+ sanitizeCollectSync({ default: "sync", perItemChars: 8000 });
106
+ expect(loggerMock.warn).not.toHaveBeenCalled();
107
+
108
+ // 显式坏值(default 非枚举 + totalChars 负数)→ 一条 warn 列出两个坏字段
109
+ sanitizeCollectSync({ default: "immediate", perItemChars: 8000, totalChars: -5 });
110
+ expect(loggerMock.warn).toHaveBeenCalledTimes(1);
111
+ expect(loggerMock.warn).toHaveBeenCalledWith(
112
+ '[subagents] config collectSync invalid field(s) reverted to defaults: default="immediate", totalChars=-5',
113
+ );
114
+
115
+ // 缺省字段(部分覆盖)不算坏值——warn 计数不增
116
+ loggerMock.warn.mockClear();
117
+ sanitizeCollectSync({ perItemChars: 4000 });
118
+ expect(loggerMock.warn).not.toHaveBeenCalled();
119
+ });
120
+ });
@@ -34,7 +34,7 @@ beforeEach(() => {
34
34
  });
35
35
 
36
36
  afterEach(() => {
37
- fs.rmSync(tmpDir, { recursive: true, force: true });
37
+ fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
38
38
  });
39
39
 
40
40
  /** 写入 config.json(自动创建 subagents/ 子目录)。 */
@@ -95,7 +95,7 @@ describe("显式 agent ref 失败报错(非静默降级 general-purpose)", (
95
95
 
96
96
  afterEach(() => {
97
97
  vi.restoreAllMocks();
98
- fs.rmSync(tmpDir, { recursive: true, force: true });
98
+ fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
99
99
  });
100
100
 
101
101
  // ── 拒绝路径:显式 ref 失败 → 同步 reject + spawn 链路零触达 ──
@@ -20,7 +20,7 @@ describe("finalized-marker", () => {
20
20
  sessionFile = path.join(tmpDir, "2026-01-01_uuid.jsonl");
21
21
  });
22
22
  afterEach(() => {
23
- fs.rmSync(tmpDir, { recursive: true, force: true });
23
+ fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
24
24
  });
25
25
 
26
26
  describe("write → read 往返", () => {
@@ -119,7 +119,7 @@ describe("[M8] idle record GC 定时器(startGcTimer)", () => {
119
119
 
120
120
  afterEach(() => {
121
121
  service.dispose();
122
- fs.rmSync(agentDir, { recursive: true, force: true });
122
+ fs.rmSync(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
123
123
  vi.useRealTimers();
124
124
  });
125
125
 
@@ -54,7 +54,7 @@ describe("T5④ recoverTmpFiles per-file tolerance", () => {
54
54
 
55
55
  afterEach(() => {
56
56
  vi.restoreAllMocks();
57
- fs.rmSync(dir, { recursive: true, force: true });
57
+ fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
58
58
  });
59
59
 
60
60
  it("processes remaining tmp files when one unlink fails mid-loop", async () => {
@@ -0,0 +1,500 @@
1
+ // src/execution/__tests__/notify-batch.test.ts
2
+ //
3
+ // notifier.notifyBatch 单测族(subagent-sync-collect U3):
4
+ // 1. buildBatchNotifyId:幂等键 sync-batch:<sha1(sorted ids)>——同成员集不同登记
5
+ // 顺序同 hash(D2 隐式批身份稳定性),不同成员集异 hash;
6
+ // 2. buildBatchLlmContent:批头计数(N finished / M failed / K cancelled,A3/A8
7
+ // 前置)+ 批头行与成员条目 "\n\n---\n\n" join 形态;
8
+ // 3. notifyBatch + fake ledger:单 entry 写账 + notifyId 正确 + details 批形态 +
9
+ // attemptDeliver 边沿投递(与 notify 同一四步链);
10
+ // 4. notifyBatch 幂等:同成员集二次投递被账本拒绝(零副作用);
11
+ // 5. notifyBatch 无 ledger 降级:内核路径单条 send(dedupeKey = 批 hash);
12
+ // 6. CollectCoordinator ↔ notifyBatch 集成:跨轮续累(D2 探针)——分两轮 route
13
+ // 2+1 sync → 闭合时单批 3 成员一次投递。
14
+ //
15
+ // fake ledger host 手法对齐 notify-ledger.test.ts(同款 mock host + settled 回调);
16
+ // 全程 tmpdir 零文件 IO(本族纯内存);env 剥离见 beforeEach(RELAY/PI_SUBAGENT
17
+ // 泄漏防护,impl-plan 偏差#5 同族)。
18
+
19
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
20
+
21
+ const { loggerMock } = vi.hoisted(() => ({
22
+ loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn(), info: vi.fn() },
23
+ }));
24
+ vi.mock("../../core/logger.ts", () => ({ getLogger: () => loggerMock }));
25
+
26
+ import {
27
+ bindNotifyLedgerHost,
28
+ NOTIFY_CUSTOM_TYPE,
29
+ NOTIFY_LEDGER_CUSTOM_TYPE,
30
+ _resetNotifyLedgerForTest,
31
+ type NotifyLedgerHost,
32
+ } from "../notify-ledger.ts";
33
+ import {
34
+ buildBatchLlmContent,
35
+ buildBatchNotifyId,
36
+ createNotifier,
37
+ type BgNotifyRecord,
38
+ type NotifierHost,
39
+ } from "../notifier.ts";
40
+ import { CollectCoordinator } from "../collect-coordinator.ts";
41
+ import type { ExecutionRecord, SubagentRecord } from "../types.ts";
42
+
43
+ // ─── env 剥离(impl-plan 偏差#5:RELAY/PI_SUBAGENT 泄漏即红的存量敏感面隔离)──
44
+
45
+ const STRIPPED_ENV_PREFIXES = ["XYZ_SUBAGENT_RELAY_", "PI_SUBAGENT_"] as const;
46
+
47
+ function stripSensitiveEnv(): void {
48
+ for (const key of Object.keys(process.env)) {
49
+ if (STRIPPED_ENV_PREFIXES.some((p) => key.startsWith(p))) delete process.env[key];
50
+ }
51
+ }
52
+
53
+ // ─── fake ledger host(notify-ledger.test.ts 同款)──
54
+
55
+ interface LedgerHostMock {
56
+ host: NotifyLedgerHost;
57
+ // appendLedgerEntry(type/customType/data)与 sendDelivery(customType/content/display/details)
58
+ // 两种 entry 形态共用一个收集数组,宽化为字段并集
59
+ entries: { type: string; customType: string; data?: Record<string, unknown>; content?: string; display?: boolean; details?: unknown }[];
60
+ sentMessages: { customType: string; content: string; display: boolean; details?: unknown }[];
61
+ settledHandlers: Array<() => void>;
62
+ setIdle(idle: boolean): void;
63
+ }
64
+
65
+ function makeLedgerHost(): LedgerHostMock {
66
+ const entries: LedgerHostMock["entries"] = [];
67
+ const sentMessages: LedgerHostMock["sentMessages"] = [];
68
+ const settledHandlers: Array<() => void> = [];
69
+ const idle = { value: true };
70
+ return {
71
+ host: {
72
+ appendLedgerEntry: (customType, data) => {
73
+ entries.push({ type: "custom", customType, data: data as Record<string, unknown> });
74
+ },
75
+ readSessionEntries: () => entries,
76
+ isIdle: () => idle.value,
77
+ onAgentSettled: (handler) => {
78
+ settledHandlers.push(handler);
79
+ },
80
+ sendDelivery: (message) => {
81
+ sentMessages.push(message);
82
+ entries.push({
83
+ type: "custom_message",
84
+ customType: message.customType,
85
+ content: message.content,
86
+ display: message.display,
87
+ details: message.details as Record<string, unknown>,
88
+ });
89
+ },
90
+ },
91
+ entries,
92
+ sentMessages,
93
+ settledHandlers,
94
+ setIdle: (v: boolean) => {
95
+ idle.value = v;
96
+ },
97
+ };
98
+ }
99
+
100
+ // ─── 成员快照工厂 ──
101
+
102
+ function makeMember(id: string, over: Partial<BgNotifyRecord> = {}): BgNotifyRecord {
103
+ return {
104
+ id,
105
+ status: "closed",
106
+ agent: `worker-${id}`,
107
+ result: `result of ${id}`,
108
+ startedAt: 1000,
109
+ endedAt: 2000,
110
+ ...over,
111
+ };
112
+ }
113
+
114
+ // ─── 协调器集成 harness(collect-coordinator.test.ts 同款注入风格)──
115
+
116
+ function makeExecutionRecord(id: string, collectMode: "sync" | undefined): ExecutionRecord {
117
+ return {
118
+ id,
119
+ agent: "/agents/worker.md",
120
+ model: "prov/m1",
121
+ thinkingLevel: undefined,
122
+ mode: "background",
123
+ task: "t",
124
+ slug: "worker",
125
+ startedAt: 1000,
126
+ rootSessionId: "root-A",
127
+ parentRecordId: undefined,
128
+ depth: 0,
129
+ status: "closed",
130
+ turns: [],
131
+ turnCount: 1,
132
+ totalTokens: 10,
133
+ lastError: undefined,
134
+ endedAt: 2000,
135
+ result: `result of ${id}`,
136
+ error: undefined,
137
+ agentResult: undefined,
138
+ controller: undefined,
139
+ collectMode,
140
+ } as ExecutionRecord;
141
+ }
142
+
143
+ function makeStoreRec(id: string, collectMode: "sync" | undefined, status: "closed" | "running"): SubagentRecord {
144
+ return {
145
+ id,
146
+ agent: "/agents/worker.md",
147
+ task: "t",
148
+ slug: "worker",
149
+ status,
150
+ mode: "background",
151
+ startedAt: 1000,
152
+ rootSessionId: "root-A",
153
+ parentRecordId: undefined,
154
+ depth: 0,
155
+ endedAt: status === "closed" ? 2000 : undefined,
156
+ turns: 1,
157
+ totalTokens: 10,
158
+ model: "prov/m1",
159
+ thinkingLevel: undefined,
160
+ eventLog: [],
161
+ displayItems: [],
162
+ collectMode,
163
+ } as SubagentRecord;
164
+ }
165
+
166
+ beforeEach(() => {
167
+ stripSensitiveEnv();
168
+ });
169
+
170
+ afterEach(() => {
171
+ _resetNotifyLedgerForTest();
172
+ });
173
+
174
+ // ─── 1. 批身份键 ──
175
+
176
+ describe("buildBatchNotifyId — sync-batch:<sha1(sorted ids)>", () => {
177
+ it("同成员集不同登记顺序同 hash(D2 隐式批身份稳定性)", () => {
178
+ const a = buildBatchNotifyId(["sa-c", "sa-a", "sa-b"]);
179
+ const b = buildBatchNotifyId(["sa-b", "sa-c", "sa-a"]);
180
+ const c = buildBatchNotifyId(["sa-a", "sa-b", "sa-c"]);
181
+ expect(a).toBe(b);
182
+ expect(b).toBe(c);
183
+ });
184
+
185
+ it("前缀 sync-batch: + 不同成员集异 hash(跨批不互吞)", () => {
186
+ const id = buildBatchNotifyId(["sa-1", "sa-2"]);
187
+ expect(id.startsWith("sync-batch:")).toBe(true);
188
+ expect(id).not.toBe(buildBatchNotifyId(["sa-1", "sa-3"]));
189
+ // 单成员批也是合法批(后派 async 与残留单 sync 的混派边界)
190
+ expect(buildBatchNotifyId(["sa-1"])).not.toBe(buildBatchNotifyId(["sa-2"]));
191
+ });
192
+ });
193
+
194
+ // ─── 2. 批头计数 + join 形态 ──
195
+
196
+ describe("buildBatchLlmContent — 批头计数与 join 形态(A3/A8 前置)", () => {
197
+ it("3 finished:批头 `3 finished, 0 failed, 0 cancelled.` + 三条目 join", () => {
198
+ const members = [makeMember("sa-1"), makeMember("sa-2"), makeMember("sa-3")];
199
+ const content = buildBatchLlmContent(members);
200
+ const lines = content.split("\n\n---\n\n");
201
+ expect(lines).toHaveLength(4);
202
+ expect(lines[0]).toBe("Subagent batch completed: 3 finished, 0 failed, 0 cancelled.");
203
+ expect(lines[1]).toBe('Subagent "worker-sa-1" (sa-1) completed. Result:\nresult of sa-1');
204
+ expect(lines[3]).toBe('Subagent "worker-sa-3" (sa-3) completed. Result:\nresult of sa-3');
205
+ });
206
+
207
+ it("1 failed + 2 finished:失败条目 error 内联全文形态(A3,多行 error 原样保留)", () => {
208
+ const members = [
209
+ makeMember("sa-ok-1"),
210
+ makeMember("sa-bad", { result: undefined, error: "spawn EPIPE\ndetail line" }),
211
+ makeMember("sa-ok-2"),
212
+ ];
213
+ const content = buildBatchLlmContent(members);
214
+ expect(content).toContain("Subagent batch completed: 2 finished, 1 failed, 0 cancelled.");
215
+ expect(content).toContain('Subagent "worker-sa-bad" (sa-bad) failed: spawn EPIPE\ndetail line');
216
+ });
217
+
218
+ it("cancelled 计数:cancelled 条目短文案(E6 cancel 入批)", () => {
219
+ const members = [
220
+ makeMember("sa-ok"),
221
+ makeMember("sa-gone", { result: undefined, closedReason: "cancelled" }),
222
+ ];
223
+ const content = buildBatchLlmContent(members);
224
+ expect(content).toContain("Subagent batch completed: 1 finished, 0 failed, 1 cancelled.");
225
+ expect(content).toContain('Subagent "worker-sa-gone" (sa-gone) cancelled.');
226
+ });
227
+
228
+ it("outcome 已物化的成员直接读(不二次派生,计数与条目同源)", () => {
229
+ const members = [makeMember("sa-1", { outcome: "failed", error: "boom" })];
230
+ const content = buildBatchLlmContent(members);
231
+ expect(content).toContain("Subagent batch completed: 0 finished, 1 failed, 0 cancelled.");
232
+ });
233
+ });
234
+
235
+ // ─── 3+4. notifyBatch × ledger 四步链 + 幂等 ──
236
+
237
+ describe("notifyBatch — ledger 写账 → 边沿投递(与 notify 同一通道,单 entry)", () => {
238
+ it("闭合投递:单 entry 写账 + notifyId = sync-batch hash + details 批形态(顶层 notifyId 可达)", () => {
239
+ const mock = makeLedgerHost();
240
+ bindNotifyLedgerHost(mock.host);
241
+ const notifier = createNotifier({
242
+ sendMessage: () => {},
243
+ hasRunningBackground: () => false,
244
+ isIdle: () => true,
245
+ });
246
+
247
+ const members = [makeMember("sa-b"), makeMember("sa-a")];
248
+ const expectedId = buildBatchNotifyId(["sa-b", "sa-a"]);
249
+ const accepted = notifier.notifyBatch(members);
250
+ expect(accepted).toBe(true);
251
+
252
+ // 单 entry 写账:notifyId 为成员集 hash(与登记顺序无关)
253
+ const ledgerEntries = mock.entries.filter((e) => e.customType === NOTIFY_LEDGER_CUSTOM_TYPE);
254
+ expect(ledgerEntries).toHaveLength(1);
255
+ expect(ledgerEntries[0]?.data?.["notifyId"]).toBe(expectedId);
256
+
257
+ // idle → attemptDeliver 立即投递:单条批消息(不与 mergeItems 再包一层——批即单 entry)
258
+ expect(mock.sentMessages).toHaveLength(1);
259
+ const sent = mock.sentMessages[0]!;
260
+ expect(sent.customType).toBe(NOTIFY_CUSTOM_TYPE);
261
+ expect(sent.content).toContain("Subagent batch completed: 2 finished, 0 failed, 0 cancelled.");
262
+ expect(sent.content).toContain('Subagent "worker-sa-a" (sa-a) completed. Result:\nresult of sa-a');
263
+ const details = sent.details as { batch: boolean; notifyId: string; items: BgNotifyRecord[] };
264
+ expect(details.batch).toBe(true);
265
+ // 顶层 notifyId = 回执匹配键(collectDeliveredNotifyIds 认 details.notifyId)
266
+ expect(details.notifyId).toBe(expectedId);
267
+ expect(details.items).toHaveLength(2);
268
+ expect(details.items.map((i) => i.id)).toEqual(["sa-b", "sa-a"]);
269
+ // 成员条目 outcome 物化(bg-notify-render 渲染锁依赖顶层 outcome)
270
+ expect(details.items.every((i) => i.outcome === "completed")).toBe(true);
271
+
272
+ // 回执销账闭环:送达 entry 的 details.notifyId 匹配 → settled 边沿后账面清零
273
+ const fireSettled = () => {
274
+ for (const handler of mock.settledHandlers) handler();
275
+ };
276
+ fireSettled();
277
+ expect(mock.entries.some((e) => e.customType === "subagent-bg-notify-ack" && e.data?.["notifyId"] === expectedId)).toBe(true);
278
+
279
+ notifier.dispose();
280
+ });
281
+
282
+ it("busy 挂 pending,settled 边沿补投(闭合触发即 flush 的 busy 分支)", () => {
283
+ const mock = makeLedgerHost();
284
+ bindNotifyLedgerHost(mock.host);
285
+ mock.setIdle(false);
286
+ const notifier = createNotifier({
287
+ sendMessage: () => {},
288
+ hasRunningBackground: () => false,
289
+ isIdle: () => false,
290
+ });
291
+
292
+ expect(notifier.notifyBatch([makeMember("sa-1")])).toBe(true);
293
+ expect(mock.sentMessages).toHaveLength(0); // busy:账已落,投递挂边沿
294
+ expect(mock.entries.filter((e) => e.customType === NOTIFY_LEDGER_CUSTOM_TYPE)).toHaveLength(1);
295
+
296
+ mock.setIdle(true);
297
+ for (const handler of mock.settledHandlers) handler();
298
+ expect(mock.sentMessages).toHaveLength(1);
299
+ notifier.dispose();
300
+ });
301
+
302
+ it("幂等:同成员集二次 notifyBatch 被账本拒绝(零副作用)——E1 重建重发防线", () => {
303
+ const mock = makeLedgerHost();
304
+ bindNotifyLedgerHost(mock.host);
305
+ mock.setIdle(false); // 挂 pending,隔离投递时机干扰
306
+ const notifier = createNotifier({
307
+ sendMessage: () => {},
308
+ hasRunningBackground: () => false,
309
+ isIdle: () => false,
310
+ });
311
+
312
+ const members = [makeMember("sa-1"), makeMember("sa-2")];
313
+ expect(notifier.notifyBatch(members)).toBe(true);
314
+ const entriesBefore = mock.entries.length;
315
+ // 乱序同成员集 → 同 hash → 拒绝
316
+ expect(notifier.notifyBatch([members[1]!, members[0]!])).toBe(false);
317
+ expect(mock.entries).toHaveLength(entriesBefore);
318
+
319
+ // 异成员集 → 异 hash → 新批独立写账
320
+ expect(notifier.notifyBatch([makeMember("sa-1"), makeMember("sa-3")])).toBe(true);
321
+ const ledgerIds = mock.entries
322
+ .filter((e) => e.customType === NOTIFY_LEDGER_CUSTOM_TYPE)
323
+ .map((e) => e.data?.["notifyId"]);
324
+ expect(new Set(ledgerIds).size).toBe(2);
325
+
326
+ notifier.dispose();
327
+ });
328
+
329
+ it("空批 / dispose 后:false 短路零副作用", () => {
330
+ const mock = makeLedgerHost();
331
+ bindNotifyLedgerHost(mock.host);
332
+ const notifier = createNotifier({
333
+ sendMessage: () => {},
334
+ hasRunningBackground: () => false,
335
+ isIdle: () => true,
336
+ });
337
+ expect(notifier.notifyBatch([])).toBe(false);
338
+ notifier.dispose();
339
+ expect(notifier.notifyBatch([makeMember("sa-1")])).toBe(false);
340
+ expect(mock.entries).toHaveLength(0);
341
+ });
342
+ });
343
+
344
+ // ─── 5. 无 ledger 降级 ──
345
+
346
+ describe("notifyBatch — 无 ledger 内核路径降级(旧装配/无 ledger 测试兼容)", () => {
347
+ it("handle.send 单条批消息,dedupeKey = 批 hash", () => {
348
+ const sent: { content: string; details: unknown }[] = [];
349
+ const notifier = createNotifier({
350
+ sendMessage: (message) => {
351
+ sent.push({ content: (message as { content: string }).content, details: (message as { details?: unknown }).details });
352
+ },
353
+ hasRunningBackground: () => false,
354
+ isIdle: () => true,
355
+ });
356
+ const accepted = notifier.notifyBatch([makeMember("sa-x"), makeMember("sa-y")]);
357
+ expect(accepted).toBe(true);
358
+ expect(loggerMock.warn).toHaveBeenCalledWith(
359
+ expect.stringContaining("notify ledger not bound"),
360
+ expect.objectContaining({ notifyId: buildBatchNotifyId(["sa-x", "sa-y"]) }),
361
+ );
362
+ expect(sent).toHaveLength(1);
363
+ expect(sent[0]!.content).toContain("Subagent batch completed: 2 finished, 0 failed, 0 cancelled.");
364
+ expect((sent[0]!.details as { batch: boolean }).batch).toBe(true);
365
+ notifier.dispose();
366
+ });
367
+ });
368
+
369
+ // ─── 6. 协调器 ↔ notifyBatch 集成(D2 跨轮续累)──
370
+
371
+ describe("CollectCoordinator ↔ notifyBatch 集成 — 跨轮续累单批(D2 探针)", () => {
372
+ /** 真协调器 + 真 notifier(fake ledger host)+ store 枚举 stub:route 全真链。 */
373
+ function makeHarness(storeRecords: SubagentRecord[]) {
374
+ const mock = makeLedgerHost();
375
+ bindNotifyLedgerHost(mock.host);
376
+ const notifier = createNotifier({
377
+ sendMessage: () => {},
378
+ hasRunningBackground: () => false,
379
+ isIdle: () => true,
380
+ });
381
+ const coordinator = new CollectCoordinator({
382
+ notifyAsync: () => {},
383
+ toNotifyRecord: (record) => ({
384
+ id: record.id,
385
+ status: "closed" as const,
386
+ agent: `worker-${record.id}`,
387
+ result: `result of ${record.id}`,
388
+ startedAt: 1000,
389
+ endedAt: 2000,
390
+ }),
391
+ listRecords: (limit) => storeRecords.slice(0, limit),
392
+ // flushBatch 签名已 async 化(deps 契约 Promise<void>);本 fake 只透传 notifyBatch
393
+ flushBatch: async (members) => {
394
+ notifier.notifyBatch(members);
395
+ },
396
+ });
397
+ return { mock, notifier, coordinator };
398
+ }
399
+
400
+ /** [U8 拆批修复] 闭合合批排程窗口等待:flush 排程 = setTimeout(0)(Node 1ms clamp),
401
+ * 10ms 真实定时器保证窗口已开(本包 vitest 真实 timers 环境)。 */
402
+ async function settleFlush(): Promise<void> {
403
+ await new Promise((resolve) => setTimeout(resolve, 10));
404
+ }
405
+
406
+ it("分两轮派 2+1 sync:闭合时单批 3 成员一次投递(D2)", async () => {
407
+ // 第一轮:2 sync 终态,第三台仍在跑(非终态 → 不闭合)
408
+ const storeRecords = [
409
+ makeStoreRec("sa-1", "sync", "closed"),
410
+ makeStoreRec("sa-2", "sync", "closed"),
411
+ makeStoreRec("sa-3", "sync", "running"),
412
+ ];
413
+ const { mock, coordinator } = makeHarness(storeRecords);
414
+
415
+ expect(coordinator.route(makeExecutionRecord("sa-1", "sync"))).toBe("sync-buffered");
416
+ expect(coordinator.route(makeExecutionRecord("sa-2", "sync"))).toBe("sync-buffered");
417
+ expect(coordinator.pendingCount).toBe(2);
418
+ expect(mock.sentMessages).toHaveLength(0);
419
+
420
+ // 第二轮:sa-3 终态(store 快照同步终态)→ 闭合 → 单批 3 成员
421
+ storeRecords[2] = makeStoreRec("sa-3", "sync", "closed");
422
+ expect(coordinator.route(makeExecutionRecord("sa-3", "sync"))).toBe("sync-flushed");
423
+ await settleFlush();
424
+ expect(coordinator.pendingCount).toBe(0);
425
+
426
+ expect(mock.sentMessages).toHaveLength(1);
427
+ const sent = mock.sentMessages[0]!;
428
+ expect(sent.content).toContain("Subagent batch completed: 3 finished, 0 failed, 0 cancelled.");
429
+ expect(sent.content).toContain("Subagent batch completed: 3 finished");
430
+ const details = sent.details as { batch: boolean; notifyId: string; items: BgNotifyRecord[] };
431
+ expect(details.items.map((i) => i.id).sort()).toEqual(["sa-1", "sa-2", "sa-3"]);
432
+ expect(details.notifyId).toBe(buildBatchNotifyId(["sa-1", "sa-2", "sa-3"]));
433
+ });
434
+
435
+ it("flush 后新 sync 成员开新批(缓冲清空语义),两批 hash 互异", async () => {
436
+ const storeRecords = [makeStoreRec("sa-1", "sync", "closed")];
437
+ const { mock, coordinator } = makeHarness(storeRecords);
438
+
439
+ expect(coordinator.route(makeExecutionRecord("sa-1", "sync"))).toBe("sync-flushed");
440
+ await settleFlush();
441
+ // 第一批已投(仍挂 pending——idle),第二批成员入缓冲
442
+ storeRecords.push(makeStoreRec("sa-2", "sync", "closed"));
443
+ expect(coordinator.route(makeExecutionRecord("sa-2", "sync"))).toBe("sync-flushed");
444
+ await settleFlush();
445
+
446
+ expect(mock.sentMessages).toHaveLength(2);
447
+ const ids = mock.sentMessages.map((m) => (m.details as { notifyId: string }).notifyId);
448
+ expect(new Set(ids).size).toBe(2);
449
+ expect(ids).toContain(buildBatchNotifyId(["sa-1"]));
450
+ expect(ids).toContain(buildBatchNotifyId(["sa-2"]));
451
+ });
452
+
453
+ it("混派正交(A8):async record 直通不走批,sync 成员批闭合不受 async 干扰", async () => {
454
+ const storeRecords = [
455
+ makeStoreRec("sa-sync", "sync", "closed"),
456
+ makeStoreRec("sa-async", undefined, "closed"),
457
+ ];
458
+ const asyncDirect: BgNotifyRecord[] = [];
459
+ const mock = makeLedgerHost();
460
+ bindNotifyLedgerHost(mock.host);
461
+ const notifier = createNotifier({
462
+ sendMessage: () => {},
463
+ hasRunningBackground: () => false,
464
+ isIdle: () => true,
465
+ });
466
+ const coordinator = new CollectCoordinator({
467
+ notifyAsync: (record) => {
468
+ asyncDirect.push({
469
+ id: record.id,
470
+ status: "closed",
471
+ agent: `worker-${record.id}`,
472
+ result: `result of ${record.id}`,
473
+ startedAt: 1000,
474
+ endedAt: 2000,
475
+ });
476
+ },
477
+ toNotifyRecord: (record) => ({
478
+ id: record.id,
479
+ status: "closed" as const,
480
+ agent: `worker-${record.id}`,
481
+ result: `result of ${record.id}`,
482
+ startedAt: 1000,
483
+ endedAt: 2000,
484
+ }),
485
+ listRecords: (limit) => storeRecords.slice(0, limit),
486
+ flushBatch: async (members) => {
487
+ notifier.notifyBatch(members);
488
+ },
489
+ });
490
+
491
+ expect(coordinator.route(makeExecutionRecord("sa-async", undefined))).toBe("async");
492
+ expect(coordinator.route(makeExecutionRecord("sa-sync", "sync"))).toBe("sync-flushed");
493
+ await settleFlush();
494
+ // async 直通收集器(生产接 notifier.notify),批通道只见 sync 成员
495
+ expect(asyncDirect.map((r) => r.id)).toEqual(["sa-async"]);
496
+ expect(mock.sentMessages).toHaveLength(1);
497
+ const details = mock.sentMessages[0]!.details as { items: BgNotifyRecord[] };
498
+ expect(details.items.map((i) => i.id)).toEqual(["sa-sync"]);
499
+ });
500
+ });