@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.
- package/dist/execution/engine/engines/zcode/reader.d.cts +1 -1
- package/dist/execution/engine/engines/zcode/reader.d.ts +1 -1
- package/dist/index.cjs +1122 -452
- package/dist/index.d.cts +188 -5
- package/dist/index.d.ts +188 -5
- package/dist/index.js +1133 -463
- package/dist/{types-Dv4QhSJ_.d.cts → types-C3fE3R9x.d.cts} +65 -0
- package/dist/{types-Dv4QhSJ_.d.ts → types-C3fE3R9x.d.ts} +65 -0
- package/dist.bundle/index.cjs +34933 -0
- package/package.json +2 -2
- package/src/execution/__tests__/agent-registry.test.ts +1 -1
- package/src/execution/__tests__/alive-store.test.ts +1 -1
- package/src/execution/__tests__/collect-budget.test.ts +291 -0
- package/src/execution/__tests__/collect-coordinator-service.test.ts +327 -0
- package/src/execution/__tests__/collect-coordinator.test.ts +371 -0
- package/src/execution/__tests__/collect-mixed-dispatch.test.ts +262 -0
- package/src/execution/__tests__/config-collect-sync.test.ts +120 -0
- package/src/execution/__tests__/config.test.ts +1 -1
- package/src/execution/__tests__/explicit-agent-ref-guard.test.ts +1 -1
- package/src/execution/__tests__/finalized-marker.test.ts +1 -1
- package/src/execution/__tests__/gc-timer.test.ts +1 -1
- package/src/execution/__tests__/manifest-store-tmp-recovery.test.ts +1 -1
- package/src/execution/__tests__/notify-batch.test.ts +500 -0
- package/src/execution/__tests__/notify-ledger.test.ts +27 -0
- package/src/execution/__tests__/pi-invocation.test.ts +34 -2
- package/src/execution/__tests__/record-entry-collect.test.ts +102 -0
- package/src/execution/__tests__/relay-env.test.ts +12 -1
- package/src/execution/__tests__/session-file-gc.test.ts +1 -1
- package/src/execution/__tests__/spawn-event-adapter.test.ts +2 -2
- package/src/execution/__tests__/start-collect-guard.test.ts +255 -0
- package/src/execution/__tests__/start-sync-model-guard.test.ts +1 -1
- package/src/execution/__tests__/subagent-actions-core.test.ts +6 -0
- package/src/execution/__tests__/subagent-service-multiproc-guard.test.ts +13 -0
- package/src/execution/__tests__/sync-collect-recovery.test.ts +1117 -0
- package/src/execution/__tests__/tombstone-store.test.ts +1 -1
- package/src/execution/__tests__/worktree-git-ops.test.ts +1 -1
- package/src/execution/__tests__/worktree-reconcile-aging.test.ts +2 -2
- package/src/execution/__tests__/worktree-reconcile.integration.test.ts +1 -1
- package/src/execution/__tests__/worktree-registry.test.ts +1 -1
- package/src/execution/collect-coordinator.ts +200 -0
- package/src/execution/config.ts +77 -5
- package/src/execution/engine/__tests__/common/event-journal.test.ts +1 -1
- package/src/execution/engine/__tests__/common/pool-manager.test.ts +2 -2
- package/src/execution/engine/__tests__/conformance/contract.read-degradation.test.ts +1 -1
- package/src/execution/engine/__tests__/conformance/golden-replay.pi.test.ts +1 -1
- package/src/execution/engine/engines/zcode/__tests__/appserver-launcher.test.ts +1 -1
- package/src/execution/engine/engines/zcode/__tests__/zcode-engine-degrade.test.ts +1 -1
- package/src/execution/execution-record.ts +7 -0
- package/src/execution/finalize-record.ts +15 -11
- package/src/execution/notifier.ts +234 -25
- package/src/execution/notify-host.ts +21 -1
- package/src/execution/record-entry.ts +16 -0
- package/src/execution/record-store.ts +161 -30
- package/src/execution/subagent-actions-core.ts +62 -6
- package/src/execution/subagent-service.ts +416 -7
- package/src/execution/sync-rebuild.ts +87 -0
- package/src/execution/types.ts +63 -0
- package/src/index.ts +1 -1
- package/src/orchestration/__tests__/config-loader.test.ts +1 -1
- package/src/orchestration/__tests__/file-run-store-prune.test.ts +1 -1
- package/src/orchestration/__tests__/file-run-store-throttle.test.ts +1 -1
- package/src/orchestration/__tests__/review-fix-loop-scriptpath-failfast.test.ts +1 -1
- package/src/orchestration/__tests__/script-generate.test.ts +5 -5
- package/src/orchestration/__tests__/skill-discovery.test.ts +2 -2
- package/src/orchestration/__tests__/workflow-files.test.ts +10 -10
- package/src/orchestration/__tests__/workflow-script-registry-impl.test.ts +1 -1
- package/src/shared/__tests__/atomic-write.test.ts +1 -1
- package/src/shared/__tests__/resource-discovery-host-roots.test.ts +5 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/subagent-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Cross-host shared subagent execution layer and workflow orchestration core (dual-form npm package: TS source for workspace, dist ESM+CJS for npm)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"ajv": "^8.20.0",
|
|
85
85
|
"proper-lockfile": "^4.1.2",
|
|
86
86
|
"yaml": "^2.9.0",
|
|
87
|
-
"@xyz-agent/extension-protocol": "0.8.
|
|
87
|
+
"@xyz-agent/extension-protocol": "0.8.2"
|
|
88
88
|
},
|
|
89
89
|
"devDependencies": {
|
|
90
90
|
"@types/node": "^24.0.0",
|
|
@@ -145,7 +145,7 @@ model: x/y`);
|
|
|
145
145
|
describe("AgentRegistry.loadByPath", () => {
|
|
146
146
|
let ws: string;
|
|
147
147
|
beforeEach(() => { ws = tmpWorkspace(); });
|
|
148
|
-
afterEach(() => { fs.rmSync(ws, { recursive: true, force: true }); });
|
|
148
|
+
afterEach(() => { fs.rmSync(ws, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 }); });
|
|
149
149
|
|
|
150
150
|
it("按绝对路径加载 agent:frontmatter + body → AgentConfig", () => {
|
|
151
151
|
const file = writeAgent(path.join(ws, ".pi", "agents"), "worker", `---
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// src/execution/__tests__/collect-budget.test.ts
|
|
2
|
+
//
|
|
3
|
+
// U4 预算截断 + 指针 纯函数规格锁(subagent-sync-collect 设计 §3.1.2):
|
|
4
|
+
//
|
|
5
|
+
// 两段式预算(确定性,无瀑布分配):
|
|
6
|
+
// ① per-item 截断:每条目正文 ≤ perItemChars(默认 4000);
|
|
7
|
+
// ② 总量再压缩:Σ(截断后正文) > totalChars(默认 24000)时统一收紧
|
|
8
|
+
// effectivePerItem = clamp(floor(totalChars / n), 200, perItemChars);
|
|
9
|
+
// floor < 200(n > totalChars/200)→ 纯清单退化(头行 + 指针行,正文 0)。
|
|
10
|
+
//
|
|
11
|
+
// 指针行:[truncated {omitted} of {total} chars — full result: session_read
|
|
12
|
+
// {"action":"result","session":"{id}"}](omitted = total − kept;千位分隔)。
|
|
13
|
+
// [D6 #9] fixture id 统一 `sa-` 前缀——与产线 record id(`sa-<uuid>`,
|
|
14
|
+
// subprocess-agent-runner.ts / subagent-service.ts 同款)同形,session_read 指针行
|
|
15
|
+
// 断言的是产线真实可达的 id 形态。
|
|
16
|
+
// [C3 limit 随附] 该成员预算(effectivePerItem)> session_read 默认 8000 时 JSON 附
|
|
17
|
+
// "limit":N(N = effectivePerItem);≤8000 不附(默认已覆盖)。
|
|
18
|
+
//
|
|
19
|
+
// totalChars 口径:仅计条目正文(截断后)之和;批头/头行/指针行/分隔符等
|
|
20
|
+
// 包装开销有界常量不入预算。
|
|
21
|
+
//
|
|
22
|
+
// 纯内存零 IO;不依赖产线 mock(只 import 两个纯函数 + BgNotifyRecord 类型)。
|
|
23
|
+
|
|
24
|
+
import { describe, expect, it } from "vitest";
|
|
25
|
+
import type { BgNotifyRecord } from "../notifier.ts";
|
|
26
|
+
import { buildBatchLlmContent, computeBatchBudget } from "../notifier.ts";
|
|
27
|
+
|
|
28
|
+
// ─── fixture ──
|
|
29
|
+
|
|
30
|
+
/** 成功完成的批成员;result = ch 重复 bodyLen 次(缺省 7 字符,永不触预算)。 */
|
|
31
|
+
function member(id: string, over: Partial<BgNotifyRecord> = {}): BgNotifyRecord {
|
|
32
|
+
return {
|
|
33
|
+
id,
|
|
34
|
+
status: "closed",
|
|
35
|
+
outcome: "completed",
|
|
36
|
+
agent: `worker-${id}`,
|
|
37
|
+
result: `result of ${id}`,
|
|
38
|
+
startedAt: 1000,
|
|
39
|
+
endedAt: 2000,
|
|
40
|
+
...over,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function filled(id: string, bodyLen: number, ch = "A"): BgNotifyRecord {
|
|
45
|
+
return member(id, { result: ch.repeat(bodyLen) });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 设计默认预算(config 热读接线前 U4 以默认常量锁规格)。 */
|
|
49
|
+
const PER_ITEM = 4000;
|
|
50
|
+
const TOTAL = 24000;
|
|
51
|
+
|
|
52
|
+
/** 期望指针行(千位分隔与设计样例 11,234 对齐)。perItemLimit > 8000 时附 "limit":N(C3)。 */
|
|
53
|
+
function pointer(id: string, kept: number, total: number, perItemLimit: number): string {
|
|
54
|
+
const fmt = (n: number) => n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
55
|
+
const limitPart = perItemLimit > 8000 ? `,"limit":${perItemLimit}` : "";
|
|
56
|
+
return `[truncated ${fmt(total - kept)} of ${fmt(total)} chars — full result: session_read {"action":"result","session":"${id}"${limitPart}}]`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 期望条目:Result: 头行 + kept 正文 + (截断时)… + 指针行。 */
|
|
60
|
+
function expectedEntry(
|
|
61
|
+
rec: { id: string; agent: string },
|
|
62
|
+
fullBody: string,
|
|
63
|
+
kept: number,
|
|
64
|
+
perItemLimit: number,
|
|
65
|
+
): string {
|
|
66
|
+
const head = `Subagent "${rec.agent}" (${rec.id}) completed. Result:\n`;
|
|
67
|
+
if (fullBody.length <= kept) return head + fullBody;
|
|
68
|
+
return `${head}${fullBody.slice(0, kept)}…\n${pointer(rec.id, kept, fullBody.length, perItemLimit)}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─── computeBatchBudget 纯函数公式 ──
|
|
72
|
+
|
|
73
|
+
describe("computeBatchBudget(两段式预算公式)", () => {
|
|
74
|
+
it("7 成员各 6000:effectivePerItem = clamp(floor(24000/7)=3428, 200, 4000) = 3428", () => {
|
|
75
|
+
const plan = computeBatchBudget(Array.from({ length: 7 }, () => 6000), PER_ITEM, TOTAL);
|
|
76
|
+
expect(plan.tightened).toBe(true);
|
|
77
|
+
expect(plan.listOnly).toBe(false);
|
|
78
|
+
expect(plan.effectivePerItem).toBe(3428);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("6 成员各 3000(Σ=18000 ≤ totalChars):不触发第二段收紧", () => {
|
|
82
|
+
const plan = computeBatchBudget(Array.from({ length: 6 }, () => 3000), PER_ITEM, TOTAL);
|
|
83
|
+
expect(plan.tightened).toBe(false);
|
|
84
|
+
expect(plan.listOnly).toBe(false);
|
|
85
|
+
expect(plan.effectivePerItem).toBe(PER_ITEM);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("n > totalChars/200(n=125 → floor=192 < 200):纯清单退化 effectivePerItem = 0", () => {
|
|
89
|
+
expect(TOTAL / 200).toBe(120);
|
|
90
|
+
const plan = computeBatchBudget(Array.from({ length: 125 }, () => 1000), PER_ITEM, TOTAL);
|
|
91
|
+
expect(plan.tightened).toBe(true);
|
|
92
|
+
expect(plan.listOnly).toBe(true);
|
|
93
|
+
expect(plan.effectivePerItem).toBe(0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("边界 n=120(floor(24000/120)=200 恰达下限):不退化,effectivePerItem = 200", () => {
|
|
97
|
+
const plan = computeBatchBudget(Array.from({ length: 120 }, () => 1000), PER_ITEM, TOTAL);
|
|
98
|
+
expect(plan.listOnly).toBe(false);
|
|
99
|
+
expect(plan.effectivePerItem).toBe(200);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("口径:bodyLengths 仅收正文长度,Σ 恰等于 totalChars(=24000)不算超、不收紧", () => {
|
|
103
|
+
const plan = computeBatchBudget(Array.from({ length: 6 }, () => 4000), PER_ITEM, TOTAL);
|
|
104
|
+
expect(plan.tightened).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ─── buildBatchLlmContent 组装链 ──
|
|
109
|
+
|
|
110
|
+
describe("buildBatchLlmContent 预算截断组装(设计 §3.1.2)", () => {
|
|
111
|
+
it("7 成员各 6000:每条收紧至 3428 字符 + 指针行,总量回到预算内", () => {
|
|
112
|
+
const records = Array.from({ length: 7 }, (_, i) => filled(`sa-${i}`, 6000));
|
|
113
|
+
const content = buildBatchLlmContent(records);
|
|
114
|
+
const parts = content.split("\n\n---\n\n");
|
|
115
|
+
|
|
116
|
+
expect(parts[0]).toBe("Subagent batch completed: 7 finished, 0 failed, 0 cancelled.");
|
|
117
|
+
expect(parts).toHaveLength(8);
|
|
118
|
+
for (let i = 0; i < 7; i++) {
|
|
119
|
+
expect(parts[i + 1]).toBe(expectedEntry(records[i], "A".repeat(6000), 3428, 3428));
|
|
120
|
+
expect(parts[i + 1]).toContain(
|
|
121
|
+
pointer(`sa-${i}`, 3428, 6000, 3428), // omitted = 6000 − 3428 = 2,572
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("6 成员各 3000(Σ=18000):不触发第二段——每条全量在场、零指针行", () => {
|
|
127
|
+
const records = Array.from({ length: 6 }, (_, i) => filled(`sa-${i}`, 3000));
|
|
128
|
+
const content = buildBatchLlmContent(records);
|
|
129
|
+
const parts = content.split("\n\n---\n\n");
|
|
130
|
+
|
|
131
|
+
expect(parts[0]).toBe("Subagent batch completed: 6 finished, 0 failed, 0 cancelled.");
|
|
132
|
+
expect(content).not.toContain("[truncated");
|
|
133
|
+
for (let i = 0; i < 6; i++) {
|
|
134
|
+
expect(parts[i + 1]).toBe(expectedEntry(records[i], "A".repeat(3000), 3000, PER_ITEM));
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("n=125:纯清单退化——每条只剩头行 + 指针行,正文零残留", () => {
|
|
139
|
+
const records = Array.from({ length: 125 }, (_, i) => filled(`sa-${i}`, 1000, "B"));
|
|
140
|
+
const content = buildBatchLlmContent(records);
|
|
141
|
+
const parts = content.split("\n\n---\n\n");
|
|
142
|
+
|
|
143
|
+
expect(parts[0]).toBe("Subagent batch completed: 125 finished, 0 failed, 0 cancelled.");
|
|
144
|
+
expect(parts).toHaveLength(126);
|
|
145
|
+
for (let i = 0; i < 125; i++) {
|
|
146
|
+
// 头行 + 指针行紧邻(kept=0 → 无正文、无省略号)
|
|
147
|
+
expect(parts[i + 1]).toBe(
|
|
148
|
+
`Subagent "worker-sa-${i}" (sa-${i}) completed. Result:\n${pointer(`sa-${i}`, 0, 1000, 0)}`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
// 正文零残留(kept=0)
|
|
152
|
+
expect(content).not.toContain("BBBB");
|
|
153
|
+
expect(content).not.toContain("…");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("指针行格式(第一段 per-item 截断同样接指针):omitted = total − kept", () => {
|
|
157
|
+
// 2 成员各 5000:Σ=8000 ≤ 24000 不收紧,per-item 截 4000
|
|
158
|
+
const records = [filled("sa-aaa", 5000), filled("sa-bbb", 5000)];
|
|
159
|
+
const parts = buildBatchLlmContent(records).split("\n\n---\n\n");
|
|
160
|
+
for (const rec of records) {
|
|
161
|
+
expect(parts).toContain(expectedEntry(rec, "A".repeat(5000), PER_ITEM, PER_ITEM));
|
|
162
|
+
}
|
|
163
|
+
expect(parts[1]).toContain(pointer("sa-aaa", 4000, 5000, PER_ITEM)); // 1,000 of 5,000
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("长短参差(1 短 500 + 6 长 6000):统一收紧 3428,短条目足额保留——非瀑布分配", () => {
|
|
167
|
+
// Σ(per-item 后) = 500 + 6×4000 = 24500 > 24000 → 触发收紧
|
|
168
|
+
const shortOne = filled("sa-short", 500, "S");
|
|
169
|
+
const longs = Array.from({ length: 6 }, (_, i) => filled(`sa-long-${i}`, 6000));
|
|
170
|
+
const records = [shortOne, ...longs];
|
|
171
|
+
const content = buildBatchLlmContent(records);
|
|
172
|
+
const parts = content.split("\n\n---\n\n");
|
|
173
|
+
|
|
174
|
+
expect(parts[0]).toBe("Subagent batch completed: 7 finished, 0 failed, 0 cancelled.");
|
|
175
|
+
// 短条目:500 < 3428 足额全文,无截断
|
|
176
|
+
expect(parts[1]).toBe(expectedEntry(shortOne, "S".repeat(500), 500, 3428));
|
|
177
|
+
expect(parts[1]).not.toContain("[truncated");
|
|
178
|
+
// 长条目:统一收紧到同一 effectivePerItem = 3428
|
|
179
|
+
for (let i = 0; i < 6; i++) {
|
|
180
|
+
expect(parts[i + 2]).toBe(expectedEntry(longs[i], "A".repeat(6000), 3428, 3428));
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("确定性:同输入同输出;打乱条目顺序不改变各条目自身截断结果", () => {
|
|
185
|
+
const records = [
|
|
186
|
+
filled("sa-a", 6000),
|
|
187
|
+
filled("sa-b", 500), // 短
|
|
188
|
+
filled("sa-c", 6000),
|
|
189
|
+
filled("sa-d", 6000),
|
|
190
|
+
filled("sa-e", 3000),
|
|
191
|
+
filled("sa-f", 6000),
|
|
192
|
+
filled("sa-g", 6000),
|
|
193
|
+
];
|
|
194
|
+
const run1 = buildBatchLlmContent(records);
|
|
195
|
+
const run2 = buildBatchLlmContent(records);
|
|
196
|
+
expect(run1).toBe(run2); // 同输入同输出
|
|
197
|
+
|
|
198
|
+
const entryOf = (content: string, id: string): string =>
|
|
199
|
+
content.split("\n\n---\n\n").find((p) => p.includes(`(${id})`)) ?? "";
|
|
200
|
+
const reversed = buildBatchLlmContent([...records].reverse());
|
|
201
|
+
for (const rec of records) {
|
|
202
|
+
// 统一收紧与顺序无关:每条目内容在两种排布下逐字节一致
|
|
203
|
+
expect(entryOf(reversed, rec.id)).toBe(entryOf(run1, rec.id));
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("totalChars 口径:仅计条目正文之和——Σ=23898 < 24000 时包装开销(批头/头行/指针行/分隔符)不触发收紧", () => {
|
|
208
|
+
// 整条通知实际字节数 ≈ 23898 + 批头 ~60 + 头行 7×~45 + 分隔符 ~80 > 24000,
|
|
209
|
+
// 但预算判定只看正文和 → 不收紧
|
|
210
|
+
const records = Array.from({ length: 6 }, (_, i) => filled(`sa-${i}`, 3983));
|
|
211
|
+
const content = buildBatchLlmContent(records);
|
|
212
|
+
expect(content.length).toBeGreaterThan(TOTAL); // 整体确实超了 totalChars
|
|
213
|
+
expect(content).not.toContain("[truncated"); // 却不收紧
|
|
214
|
+
for (let i = 0; i < 6; i++) {
|
|
215
|
+
expect(content).toContain("A".repeat(3983)); // 全量正文在场
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("Σ=24000 恰好等于 totalChars:不算超(> 才收紧),6×4000 全量在场零指针行", () => {
|
|
220
|
+
const records = Array.from({ length: 6 }, (_, i) => filled(`sa-${i}`, 4000));
|
|
221
|
+
const content = buildBatchLlmContent(records);
|
|
222
|
+
expect(content).not.toContain("[truncated");
|
|
223
|
+
const parts = content.split("\n\n---\n\n");
|
|
224
|
+
for (let i = 0; i < 6; i++) {
|
|
225
|
+
expect(parts[i + 1]).toBe(expectedEntry(records[i], "A".repeat(4000), 4000, PER_ITEM));
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("失败成员不入正文预算:failed error 全文原样,且不计入 Σ——成功条目 kept=4000 而非收紧 3428", () => {
|
|
230
|
+
const failedOne = member("sa-bad", { outcome: "failed", error: "boom".repeat(2000) });
|
|
231
|
+
const longs = Array.from({ length: 6 }, (_, i) => filled(`sa-${i}`, 6000));
|
|
232
|
+
const records = [failedOne, ...longs];
|
|
233
|
+
// 成功正文 Σ(per-item 后) = 6×4000 = 24000 ≤ 24000 → 不收紧;若把 failed error
|
|
234
|
+
//(8000)计入 Σ = 32000 > 24000 → 会收紧至 3428。断言 kept=4000 即锁住口径。
|
|
235
|
+
const content = buildBatchLlmContent(records);
|
|
236
|
+
const parts = content.split("\n\n---\n\n");
|
|
237
|
+
expect(parts[0]).toBe("Subagent batch completed: 6 finished, 1 failed, 0 cancelled.");
|
|
238
|
+
// failed 条目:error 全文原样、无截断无指针
|
|
239
|
+
expect(content).toContain(`Subagent "worker-sa-bad" (sa-bad) failed: ${"boom".repeat(2000)}`);
|
|
240
|
+
const failedPart = parts.find((p) => p.includes("(sa-bad)")) ?? "";
|
|
241
|
+
expect(failedPart).not.toContain("[truncated");
|
|
242
|
+
// 成功条目:仅第一段 per-item 截断(kept=4000),未被第二段收紧
|
|
243
|
+
for (let i = 0; i < 6; i++) {
|
|
244
|
+
expect(parts).toContain(expectedEntry(longs[i], "A".repeat(6000), 4000, PER_ITEM));
|
|
245
|
+
expect(content).toContain(pointer(`sa-${i}`, 4000, 6000, PER_ITEM)); // 2,000 of 6,000
|
|
246
|
+
}
|
|
247
|
+
expect(content).not.toContain("2,572 of 6,000"); // 3428 收紧形态未出现
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("极短 result 的既有形态零回归:不触预算时输出与 U3 基线逐字节一致", () => {
|
|
251
|
+
const records = [member("sa-1"), member("sa-2")];
|
|
252
|
+
const content = buildBatchLlmContent(records);
|
|
253
|
+
expect(content).toBe(
|
|
254
|
+
[
|
|
255
|
+
"Subagent batch completed: 2 finished, 0 failed, 0 cancelled.",
|
|
256
|
+
'Subagent "worker-sa-1" (sa-1) completed. Result:\nresult of sa-1',
|
|
257
|
+
'Subagent "worker-sa-2" (sa-2) completed. Result:\nresult of sa-2',
|
|
258
|
+
].join("\n\n---\n\n"),
|
|
259
|
+
);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// ─── C3 指针行 limit 随附(adversarial-review-fixes §3.4 C3)───
|
|
264
|
+
|
|
265
|
+
describe("buildBatchLlmContent 指针行 limit 随附(C3:预算 > session_read 默认 8000 时附,≤ 不附)", () => {
|
|
266
|
+
it("perItemChars=12000(>8000):指针行 JSON 附 \"limit\":12000——模型照抄即取回 ≥ 默认值的有效正文", () => {
|
|
267
|
+
// 2 成员各 15000:Σ=24000 ≤ totalChars=48000 不收紧 → effectivePerItem = 12000
|
|
268
|
+
const records = [filled("sa-big", 15000), filled("sa-big2", 15000)];
|
|
269
|
+
const content = buildBatchLlmContent(records, { perItemChars: 12000, totalChars: 48000 });
|
|
270
|
+
expect(content).toContain(
|
|
271
|
+
`[truncated 3,000 of 15,000 chars — full result: session_read {"action":"result","session":"sa-big","limit":12000}]`,
|
|
272
|
+
);
|
|
273
|
+
expect(content).toContain(`"session":"sa-big2","limit":12000`);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("perItemChars=4000(默认,≤8000):指针行无 limit 字段(默认已覆盖,避免噪音)", () => {
|
|
277
|
+
const records = [filled("sa-a", 5000)];
|
|
278
|
+
const content = buildBatchLlmContent(records);
|
|
279
|
+
expect(content).toContain(
|
|
280
|
+
`[truncated 1,000 of 5,000 chars — full result: session_read {"action":"result","session":"sa-a"}]`,
|
|
281
|
+
);
|
|
282
|
+
expect(content).not.toContain(`"limit"`);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("收紧后 effectivePerItem=3428(≤8000):同样不附 limit——判定看收紧后的实际预算", () => {
|
|
286
|
+
const records = Array.from({ length: 7 }, (_, i) => filled(`sa-${i}`, 6000));
|
|
287
|
+
const content = buildBatchLlmContent(records);
|
|
288
|
+
expect(content).toContain(`"session":"sa-0"}`);
|
|
289
|
+
expect(content).not.toContain(`"limit"`);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// src/execution/__tests__/collect-coordinator-service.test.ts
|
|
2
|
+
//
|
|
3
|
+
// collectCoordinator 的 service 集成面(subagent-sync-collect U2):
|
|
4
|
+
// 1. 偏差#4 落点:execute({collect:"sync"}) 的 record 落 collectMode="sync"
|
|
5
|
+
// (观察者形态:subagent-record entry 落盘产物断言,非 mock record);
|
|
6
|
+
// 2. notifyComplete 统一路由:sync 成员终态 → 协调器缓冲 → 单成员闭合 → 降级
|
|
7
|
+
// flush → notifier.notify 收到该成员(观察点:替换 notifier.notify 为 spy,
|
|
8
|
+
// 闭包经 this 运行时读取,替换生效);
|
|
9
|
+
// 3. async record(无 collect)直通同一 notifier.notify(现状路径字节不变);
|
|
10
|
+
// 4. 偏差#3 接线:getCollectSyncDefault 读真实 config(缺省 async / 配置 sync +
|
|
11
|
+
// reloadGlobalConfig 后生效);
|
|
12
|
+
// 5. flush 屏障时序:manifest 写完成先于 notifyBatch 写账(通知可达 ⇒ 索引就位
|
|
13
|
+
// 的构造性保证,时序竞态修复)。
|
|
14
|
+
//
|
|
15
|
+
// mock 手法对齐 ended-message-and-fork-from.test.ts:mock session-runner(不 spawn
|
|
16
|
+
// 真子进程)+ logger;record-store / config 走真实实现(tmpdir 自建自删,红线)。
|
|
17
|
+
|
|
18
|
+
import * as fs from "node:fs";
|
|
19
|
+
import * as os from "node:os";
|
|
20
|
+
import * as path from "node:path";
|
|
21
|
+
|
|
22
|
+
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
|
|
23
|
+
|
|
24
|
+
const { loggerMock, runSpawnMock } = vi.hoisted(() => ({
|
|
25
|
+
loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn(), info: vi.fn() },
|
|
26
|
+
/** runSpawn mock 实例提升:测试内直接引用控制 resolve 时序(不依赖 import 路径解析——
|
|
27
|
+
* vi.mock 字符串与本文件既有写法保持一致,mock 实例经 hoisted 闭包共享)。 */
|
|
28
|
+
runSpawnMock: vi.fn(async () => ({
|
|
29
|
+
text: "ok",
|
|
30
|
+
turns: 1,
|
|
31
|
+
durationMs: 10,
|
|
32
|
+
success: true,
|
|
33
|
+
sessionId: "spawned",
|
|
34
|
+
toolCalls: [],
|
|
35
|
+
})),
|
|
36
|
+
}));
|
|
37
|
+
vi.mock("../../core/logger.ts", () => ({ getLogger: () => loggerMock }));
|
|
38
|
+
|
|
39
|
+
// mock session-runner:execute 链经 kickOffBackground → runAndFinalize → runSpawn。
|
|
40
|
+
// runSpawn 返回最小成功 AgentResult,后台收尾链完整走完(finalize + archive + notify)。
|
|
41
|
+
// 路径必须命中生产 import 的真实模块——merge 后 session-runner 落位
|
|
42
|
+
// src/execution/engine/engines/pi/session-runner.ts(subagent-service.ts 的 import 源),
|
|
43
|
+
// 旧路径 "../session-runner.ts" 模块已不存在,拦截静默失效(真 runSpawn 被执行,
|
|
44
|
+
// runSpawnMock.calls 恒 0 → 受控 promise 用例 until 超时)。
|
|
45
|
+
vi.mock("../engine/engines/pi/session-runner.ts", () => ({
|
|
46
|
+
runSpawn: runSpawnMock,
|
|
47
|
+
killAllSpawnedChildren: vi.fn(),
|
|
48
|
+
killRecordChildWithEscalation: vi.fn(),
|
|
49
|
+
getChildByRecord: vi.fn(() => undefined),
|
|
50
|
+
registerSpawnedChildForRecord: vi.fn(),
|
|
51
|
+
spawnedChildren: new Map(),
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
import { getSubagentSessionDir, getSubagentRecordsDir } from "../path-encoding.ts";
|
|
55
|
+
import { ModelConfigService } from "../model-config-service.ts";
|
|
56
|
+
import type { ModelRegistryLike } from "../model-resolver.ts";
|
|
57
|
+
import { SubagentService } from "../subagent-service.ts";
|
|
58
|
+
|
|
59
|
+
const IDENTITY_ENV_KEYS = [
|
|
60
|
+
"PI_SUBAGENT_ROOT_SESSION_ID",
|
|
61
|
+
"PI_SUBAGENT_SELF_RECORD_ID",
|
|
62
|
+
"PI_SUBAGENT_DEPTH",
|
|
63
|
+
"PI_SUBAGENT_ROOT_CWD",
|
|
64
|
+
"PI_SUBAGENT_FORK_DEPTH",
|
|
65
|
+
] as const;
|
|
66
|
+
|
|
67
|
+
function makePi() {
|
|
68
|
+
return {
|
|
69
|
+
appendEntry: vi.fn(),
|
|
70
|
+
events: { emit: vi.fn() },
|
|
71
|
+
sendMessage: vi.fn(),
|
|
72
|
+
on: vi.fn(),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface NotifierSpy {
|
|
77
|
+
// Mock<T> 而非纯函数签名:测试断言消费 .mock.calls,需保留 mock 元数据
|
|
78
|
+
notify: Mock<(record: unknown) => void>;
|
|
79
|
+
/** U3:sync 批投递 spy。mock 返回 true(flushBatch 接线的落标 accepted 分支可达)。 */
|
|
80
|
+
notifyBatch: Mock<(records: unknown, budget?: unknown) => boolean>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 替换 service 私有 notifier 为仅覆盖 notify/notifyBatch 的 spy(保留其余方法;
|
|
84
|
+
* 协调器 deps 闭包经 this 运行时读取,替换生效)。 */
|
|
85
|
+
function spyNotifier(service: SubagentService): NotifierSpy {
|
|
86
|
+
// [D4-① 适配] notifier 实例已封装进 notifyHost(createNotifyHost),spy 改为包装
|
|
87
|
+
// host 的 notify/notifyBatch 出口(其余方法经 ...host 保留真实现);协调器 deps
|
|
88
|
+
// 闭包经 this 运行时读取 service.notifyHost,字段替换即生效。
|
|
89
|
+
const host = (service as unknown as { notifyHost: object }).notifyHost;
|
|
90
|
+
const spy: NotifierSpy = { notify: vi.fn(), notifyBatch: vi.fn(() => true) };
|
|
91
|
+
(service as unknown as { notifyHost: unknown }).notifyHost = {
|
|
92
|
+
...host,
|
|
93
|
+
notify: (record: unknown) => spy.notify(record),
|
|
94
|
+
notifyBatch: (records: unknown, budget?: unknown) => spy.notifyBatch(records, budget),
|
|
95
|
+
};
|
|
96
|
+
return spy;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 异步链等待:手写短轮询(真实 timers)。
|
|
101
|
+
* 为什么不用 vi.waitFor:vitest 4.1.8 本包环境下 vi.waitFor 对 falsy callback
|
|
102
|
+
* 立即 resolve 不轮询不超时(sanity 用例实证:waitFor(()=>false,{timeout:100})
|
|
103
|
+
* resolve(false) 而非 reject)——属测试基建缺陷,与被测链路无关,此处绕开。
|
|
104
|
+
*/
|
|
105
|
+
async function until(cond: () => boolean, timeoutMs = 3000): Promise<void> {
|
|
106
|
+
const start = Date.now();
|
|
107
|
+
while (!cond()) {
|
|
108
|
+
if (Date.now() - start > timeoutMs) {
|
|
109
|
+
throw new Error(`collect-coordinator-service: condition not met within ${timeoutMs}ms`);
|
|
110
|
+
}
|
|
111
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
describe("collectCoordinator service integration (U2)", () => {
|
|
116
|
+
let agentDir: string;
|
|
117
|
+
let service: SubagentService;
|
|
118
|
+
let pi: ReturnType<typeof makePi>;
|
|
119
|
+
|
|
120
|
+
beforeEach(() => {
|
|
121
|
+
for (const k of IDENTITY_ENV_KEYS) delete process.env[k];
|
|
122
|
+
agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "core-collect-coord-"));
|
|
123
|
+
fs.mkdirSync(getSubagentSessionDir(agentDir, agentDir), { recursive: true });
|
|
124
|
+
pi = makePi();
|
|
125
|
+
service = makeService(pi);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
afterEach(() => {
|
|
129
|
+
service.dispose();
|
|
130
|
+
fs.rmSync(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
function makeService(piInstance: ReturnType<typeof makePi>): SubagentService {
|
|
134
|
+
const modelService = new ModelConfigService({ agentDir, cwd: agentDir });
|
|
135
|
+
const modelRegistry: ModelRegistryLike = {
|
|
136
|
+
getAvailable: () => [],
|
|
137
|
+
find: () => undefined,
|
|
138
|
+
hasConfiguredAuth: () => false,
|
|
139
|
+
};
|
|
140
|
+
modelService.initModel({
|
|
141
|
+
sessionId: "root-session-cur",
|
|
142
|
+
ctxModel: { id: "m", name: "M", provider: "p", reasoning: false },
|
|
143
|
+
modelRegistry,
|
|
144
|
+
});
|
|
145
|
+
const created = new SubagentService({ cwd: agentDir, modelService });
|
|
146
|
+
created.initSession({ pi: piInstance, sessionId: "root-session-cur" });
|
|
147
|
+
return created;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
it("stamps collectMode on the in-memory record visible to the coordinator routing (偏差#4 落点)", async () => {
|
|
151
|
+
// 注:entry 落盘观察者断言(subagent-record entry 含 collectMode)依赖
|
|
152
|
+
// record-store.recordToSubagent 投影扩展——U5 领地(偏差登记);本用例锁
|
|
153
|
+
// 内存 record 经协调器路由的可见行为:sync 成员终态 → 单成员闭合 → notifyBatch
|
|
154
|
+
// 批投递(下方用例),async 成员直通。本条记录 execute 链零异常完成。
|
|
155
|
+
const spy = spyNotifier(service);
|
|
156
|
+
const handle = await service.execute({ task: "collect me", slug: "collect-me", collect: "sync" });
|
|
157
|
+
await until(() => spy.notifyBatch.mock.calls.length > 0);
|
|
158
|
+
expect(handle.subagentId).toMatch(/^sa-/);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("routes a finished sync member into a single-member batch (U3 接线:单成员闭合 → notifyBatch)", async () => {
|
|
162
|
+
const spy = spyNotifier(service);
|
|
163
|
+
const handle = await service.execute({ task: "collect me", slug: "collect-me", collect: "sync" });
|
|
164
|
+
await until(() => spy.notifyBatch.mock.calls.length > 0);
|
|
165
|
+
// U3 接线后:sync 成员走单条批投递(不再逐条降级 notify)
|
|
166
|
+
expect(spy.notify).not.toHaveBeenCalled();
|
|
167
|
+
const batches = spy.notifyBatch.mock.calls.map((c) => c[0] as { id: string }[]);
|
|
168
|
+
expect(batches).toHaveLength(1);
|
|
169
|
+
expect(batches[0]!.map((m) => m.id)).toEqual([handle.subagentId]);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("flushBatch accepted=false(幂等拒绝)时不落标:零带 batchFinalized 的 entry", async () => {
|
|
173
|
+
const spy = spyNotifier(service);
|
|
174
|
+
spy.notifyBatch.mockReturnValue(false); // 模拟同成员集批已在账(E1 重建重发形态)
|
|
175
|
+
const handle = await service.execute({ task: "collect me", slug: "collect-me", collect: "sync" });
|
|
176
|
+
await until(() => spy.notifyBatch.mock.calls.length > 0);
|
|
177
|
+
expect(handle.subagentId).toMatch(/^sa-/);
|
|
178
|
+
// 落标出口①绑「写账成功」:accepted=false → 无带 batchFinalized 标记的 subagent-record
|
|
179
|
+
// entry(register/archive 常规 entry 经 recordToSubagent 投影携带 batchFinalized=
|
|
180
|
+
// undefined——未离场成员,非 true;U5 投影扩展后缺省键不落值,断言语义不变)
|
|
181
|
+
const recordEntries = pi.appendEntry.mock.calls
|
|
182
|
+
.filter((c) => c[0] === "subagent-record")
|
|
183
|
+
.map((c) => c[1] as Record<string, unknown>);
|
|
184
|
+
expect(recordEntries.length).toBeGreaterThan(0); // execute 链自身的 register/archive 在场
|
|
185
|
+
expect(recordEntries.some((d) => d["batchFinalized"] === true)).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("routes an async member through the same notifyAsync path (现状直通)", async () => {
|
|
189
|
+
const spy = spyNotifier(service);
|
|
190
|
+
const handle = await service.execute({ task: "plain", slug: "plain" });
|
|
191
|
+
await until(() => spy.notify.mock.calls.length > 0);
|
|
192
|
+
const notified = spy.notify.mock.calls.map((c) => c[0] as { id: string });
|
|
193
|
+
expect(notified.some((n) => n.id === handle.subagentId)).toBe(true);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("getCollectSyncDefault reads real config (偏差#3 接线)", () => {
|
|
197
|
+
// 无 config.json → 缺省 async
|
|
198
|
+
expect(service.getCollectSyncDefault()).toBe("async");
|
|
199
|
+
// 写入 collectSync.default=sync + reload → sync 生效
|
|
200
|
+
const configPath = path.join(agentDir, "subagents", "config.json");
|
|
201
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
202
|
+
fs.writeFileSync(
|
|
203
|
+
configPath,
|
|
204
|
+
JSON.stringify({ version: 1, maxConcurrent: 6, collectSync: { default: "sync" } }),
|
|
205
|
+
"utf-8",
|
|
206
|
+
);
|
|
207
|
+
const modelService = (service as unknown as { modelService: ModelConfigService }).modelService;
|
|
208
|
+
modelService.reloadGlobalConfig();
|
|
209
|
+
expect(service.getCollectSyncDefault()).toBe("sync");
|
|
210
|
+
// E5 坏值回默认:非枚举 → async 兜底
|
|
211
|
+
fs.writeFileSync(
|
|
212
|
+
configPath,
|
|
213
|
+
JSON.stringify({ version: 1, maxConcurrent: 6, collectSync: { default: "immediate" } }),
|
|
214
|
+
"utf-8",
|
|
215
|
+
);
|
|
216
|
+
modelService.reloadGlobalConfig();
|
|
217
|
+
expect(service.getCollectSyncDefault()).toBe("async");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
/** 伪造子 session 文件(record-store.test.ts 同款:header + identity + subagent-record
|
|
221
|
+
* entry)——落标通路 getFullRecord 冷路径重建的真实文件数据源(mock runSpawn 不产子文件)。 */
|
|
222
|
+
function writeChildSessionFile(recordId: string, agent: string, result: string): void {
|
|
223
|
+
const sessionsDir = getSubagentSessionDir(agentDir, agentDir);
|
|
224
|
+
const ts = new Date(1000).toISOString();
|
|
225
|
+
const header = JSON.stringify({
|
|
226
|
+
type: "session", version: 3, id: `sess-${recordId}`, timestamp: ts, cwd: agentDir,
|
|
227
|
+
});
|
|
228
|
+
const identity = JSON.stringify({
|
|
229
|
+
type: "custom", id: "id-1", parentId: null, timestamp: ts,
|
|
230
|
+
customType: "subagent-identity",
|
|
231
|
+
data: {
|
|
232
|
+
id: recordId, agent, mode: "background", task: "collect me", startedAt: 1000,
|
|
233
|
+
rootSessionId: "root-session-cur", depth: 0,
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
const recordEntry = JSON.stringify({
|
|
237
|
+
type: "custom", id: "id-2", parentId: "id-1", timestamp: ts,
|
|
238
|
+
customType: "subagent-record",
|
|
239
|
+
data: {
|
|
240
|
+
v: 1, id: recordId, agent, task: "collect me", slug: "collect-me",
|
|
241
|
+
status: "closed", mode: "background", startedAt: 1000,
|
|
242
|
+
rootSessionId: "root-session-cur", parentRecordId: undefined, depth: 0,
|
|
243
|
+
endedAt: 2000, turns: 1, totalTokens: 10, model: "prov/m1", thinkingLevel: undefined,
|
|
244
|
+
eventLog: [], displayItems: [], result,
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
fs.writeFileSync(path.join(sessionsDir, `${recordId}.jsonl`), `${header}\n${identity}\n${recordEntry}\n`, "utf-8");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const spawnOk = () => ({
|
|
251
|
+
text: "ok", turns: 1, durationMs: 10, success: true, sessionId: "spawned", toolCalls: [],
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("跨轮续累(D2 service 层):两轮派 2 sync → 闭合时单批 2 成员", async () => {
|
|
255
|
+
let resolve1!: (v: ReturnType<typeof spawnOk>) => void;
|
|
256
|
+
let resolve2!: (v: ReturnType<typeof spawnOk>) => void;
|
|
257
|
+
runSpawnMock.mockImplementationOnce(() => new Promise((res) => { resolve1 = res; }));
|
|
258
|
+
runSpawnMock.mockImplementationOnce(() => new Promise((res) => { resolve2 = res; }));
|
|
259
|
+
const spy = spyNotifier(service);
|
|
260
|
+
const h1 = await service.execute({ task: "t1", slug: "one", collect: "sync" });
|
|
261
|
+
const h2 = await service.execute({ task: "t2", slug: "two", collect: "sync" });
|
|
262
|
+
// runSpawn 在 execute 返回后的异步链内才被调用——等两个受控 promise 都构造完
|
|
263
|
+
await until(() => runSpawnMock.mock.calls.length >= 2);
|
|
264
|
+
|
|
265
|
+
// 第一台终态:第二台仍 running → 缓冲不闭合(零投递)
|
|
266
|
+
resolve1(spawnOk());
|
|
267
|
+
await new Promise((resolve) => setTimeout(resolve, 50)); // microtask 链排空
|
|
268
|
+
expect(spy.notifyBatch.mock.calls).toHaveLength(0);
|
|
269
|
+
|
|
270
|
+
// 第二台终态:闭合 → 单批 2 成员(含第一台的快照,跨轮续累不丢)
|
|
271
|
+
resolve2(spawnOk());
|
|
272
|
+
await until(() => spy.notifyBatch.mock.calls.length > 0);
|
|
273
|
+
expect(spy.notifyBatch.mock.calls).toHaveLength(1);
|
|
274
|
+
const batch = spy.notifyBatch.mock.calls[0]![0] as { id: string }[];
|
|
275
|
+
expect(batch.map((m) => m.id).sort()).toEqual([h1.subagentId, h2.subagentId].sort());
|
|
276
|
+
expect(spy.notify).not.toHaveBeenCalled();
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("batchFinalized 落标(真文件通路):闭合 flush 写账成功后末条 entry 带标记(E1 排除判据)", async () => {
|
|
280
|
+
let resolveSpawn!: (v: ReturnType<typeof spawnOk>) => void;
|
|
281
|
+
runSpawnMock.mockImplementationOnce(() => new Promise((res) => { resolveSpawn = res; }));
|
|
282
|
+
const spy = spyNotifier(service);
|
|
283
|
+
const handle = await service.execute({ task: "collect me", slug: "collect-me", collect: "sync" });
|
|
284
|
+
await until(() => runSpawnMock.mock.calls.length >= 1); // 受控 promise 已构造
|
|
285
|
+
// 子 session 文件在闭合判定/flush 前就位(真实生产链中 runSpawn 已写好 identity)
|
|
286
|
+
writeChildSessionFile(handle.subagentId, "/agents/worker.md", "ok");
|
|
287
|
+
resolveSpawn(spawnOk());
|
|
288
|
+
await until(() => spy.notifyBatch.mock.calls.length > 0);
|
|
289
|
+
|
|
290
|
+
// 观察者形态:主 session 落盘的末条 subagent-record entry 带 batchFinalized=true +
|
|
291
|
+
// collectMode=sync(reportSubagentRecord 直投影 SubagentRecord,不经 recordToSubagent)。
|
|
292
|
+
// status 不在此锁:one-shot 成功链走 SP-5 resumable 回退(record 留内存 running 态,
|
|
293
|
+
// 真实形态),E1 排除判据只依赖 collectMode+batchFinalized 两字段。
|
|
294
|
+
const marked = pi.appendEntry.mock.calls
|
|
295
|
+
.filter((c) => c[0] === "subagent-record")
|
|
296
|
+
.map((c) => c[1] as Record<string, unknown>)
|
|
297
|
+
.filter((d) => d["id"] === handle.subagentId && d["batchFinalized"] === true);
|
|
298
|
+
expect(marked).toHaveLength(1);
|
|
299
|
+
expect(marked[0]?.["collectMode"]).toBe("sync");
|
|
300
|
+
expect(marked[0]?.["result"]).toBe("ok");
|
|
301
|
+
expect(marked[0]?.["resumable"]).toBe(true); // SP-5 成功回退态(真链形态保真)
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("flush 屏障:manifest 写完成先于 notifyBatch 写账(通知可达 ⇒ 索引就位,构造性保证)", async () => {
|
|
305
|
+
// 时序竞态修复(v2 D1 修订):批通知路径的 manifest 写从「写账后 fire-and-forget」
|
|
306
|
+
// 前移为「写账前 await 全部落盘」屏障——「通知可达 ⇒ 索引就位」从大概率成立升为
|
|
307
|
+
// 构造性保证(by construction)。断言打在 notifyBatch(写账入口)被调用的时刻:
|
|
308
|
+
// 磁盘上 records/<sa-id>.json 必已存在(修复前该时刻大概率 false,探针实测 mtime
|
|
309
|
+
// 相对 notify entry ±2/3ms 方向不定)。
|
|
310
|
+
let resolveSpawn!: (v: ReturnType<typeof spawnOk>) => void;
|
|
311
|
+
runSpawnMock.mockImplementationOnce(() => new Promise((res) => { resolveSpawn = res; }));
|
|
312
|
+
const spy = spyNotifier(service);
|
|
313
|
+
const handle = await service.execute({ task: "collect me", slug: "collect-me", collect: "sync" });
|
|
314
|
+
await until(() => runSpawnMock.mock.calls.length >= 1); // 受控 promise 已构造
|
|
315
|
+
writeChildSessionFile(handle.subagentId, "/agents/worker.md", "ok");
|
|
316
|
+
const manifestPath = path.join(getSubagentRecordsDir(agentDir, agentDir), `${handle.subagentId}.json`);
|
|
317
|
+
// 写账时刻钩子:mock 内联捕获 manifest 存在性(此刻即指针行消费可用性)
|
|
318
|
+
let manifestExistsAtLedgerWrite = false;
|
|
319
|
+
spy.notifyBatch.mockImplementation(() => {
|
|
320
|
+
manifestExistsAtLedgerWrite = fs.existsSync(manifestPath);
|
|
321
|
+
return true;
|
|
322
|
+
});
|
|
323
|
+
resolveSpawn(spawnOk());
|
|
324
|
+
await until(() => spy.notifyBatch.mock.calls.length > 0);
|
|
325
|
+
expect(manifestExistsAtLedgerWrite).toBe(true);
|
|
326
|
+
});
|
|
327
|
+
});
|