@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
@@ -1186,6 +1186,33 @@ describe("NotifyLedger — T4③ 重投止损(PS-6)", () => {
1186
1186
  ledger.dispose();
1187
1187
  });
1188
1188
 
1189
+ it("同 notifyId 在账重复 record 幂等拒绝零副作用(⛔2 补强:U3 批 hash 幂等依赖)", () => {
1190
+ const mock = makeLedgerHost();
1191
+ const ledger = createNotifyLedger(mock.host);
1192
+
1193
+ expect(ledger.record("sa-dup", "content-v1", { notifyId: "sa-dup", agent: "first" })).toBe(true);
1194
+ const ledgerEntriesBefore = mock.entries.filter((e) => e.customType === NOTIFY_LEDGER_CUSTOM_TYPE).length;
1195
+ expect(ledgerEntriesBefore).toBe(1);
1196
+
1197
+ // 同 notifyId 二次 record:返回 false,零副作用(不新增 entry、账面不被覆盖)
1198
+ expect(ledger.record("sa-dup", "content-v2-attempted-overwrite", { notifyId: "sa-dup", agent: "second" })).toBe(false);
1199
+ expect(mock.entries.filter((e) => e.customType === NOTIFY_LEDGER_CUSTOM_TYPE)).toHaveLength(ledgerEntriesBefore);
1200
+ expect(ledger.pendingCount()).toBe(1);
1201
+
1202
+ // 投递面零副作用:只送首条 content(后写不覆盖),回执匹配不受污染
1203
+ mock.setIdle(true);
1204
+ ledger.attemptDeliver();
1205
+ expect(mock.sentMessages).toHaveLength(1);
1206
+ expect(mock.sentMessages[0]?.content).toBe("content-v1");
1207
+
1208
+ // 销账后同号仍拒绝(已销账幂等,E1 补发窗口依赖)
1209
+ fireSettled(mock);
1210
+ expect(ledger.waitingReceiptCount()).toBe(0);
1211
+ expect(ledger.record("sa-dup", "content-v1", { notifyId: "sa-dup" })).toBe(false);
1212
+
1213
+ ledger.dispose();
1214
+ });
1215
+
1189
1216
  it("③ 未达上限时正常重投行为不变:照常超时重投,回执到达后照常销账", () => {
1190
1217
  const mock = makeLedgerHost();
1191
1218
  // 前三轮回执不可匹配(重投路径)
@@ -3,10 +3,42 @@ import * as fs from "node:fs";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
5
 
6
- import { afterEach, describe, expect, it } from "vitest";
6
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
7
7
 
8
8
  import { getPiInvocation } from "../engine/engines/pi/pi-invocation.ts";
9
- import { RELAY_ENV_NODE, RELAY_ENV_SCRIPT, RELAY_ENV_SOCKET } from "../relay-env.ts";
9
+ import {
10
+ RELAY_ENV_NODE,
11
+ RELAY_ENV_RECORD_ID,
12
+ RELAY_ENV_SCRIPT,
13
+ RELAY_ENV_SESSION_ID,
14
+ RELAY_ENV_SOCKET,
15
+ } from "../relay-env.ts";
16
+
17
+ // [验收门修复] getPiInvocation 的 relay 分支优先读宿主 RELAY env——根级 pnpm test
18
+ // 进程若继承宿主(pi 子进程链)的 RELAY 身份,直连分支用例的 execPath/argv 断言
19
+ // 会被改道(非自免疫,依赖 runner 环境)。文件级钩子对全部用例统一消毒:beforeEach
20
+ // 删五键、afterEach 恢复原值——有泄漏无泄漏双向绿;第二组 relay 分支用例自设
21
+ // RELAY_FULL 在清理之后执行,语义不受影响。
22
+ const RELAY_ENV_KEYS = [
23
+ RELAY_ENV_SOCKET,
24
+ RELAY_ENV_NODE,
25
+ RELAY_ENV_SCRIPT,
26
+ RELAY_ENV_SESSION_ID,
27
+ RELAY_ENV_RECORD_ID,
28
+ ] as const;
29
+ let savedRelayEnv: Record<string, string | undefined>;
30
+ beforeEach(() => {
31
+ savedRelayEnv = {};
32
+ for (const key of RELAY_ENV_KEYS) savedRelayEnv[key] = process.env[key];
33
+ for (const key of RELAY_ENV_KEYS) delete process.env[key];
34
+ });
35
+ afterEach(() => {
36
+ for (const key of RELAY_ENV_KEYS) {
37
+ const value = savedRelayEnv[key];
38
+ if (value === undefined) delete process.env[key];
39
+ else process.env[key] = value;
40
+ }
41
+ });
10
42
 
11
43
  describe("getPiInvocation", () => {
12
44
  const originalArgv = process.argv;
@@ -0,0 +1,102 @@
1
+ // src/execution/__tests__/record-entry-collect.test.ts
2
+ //
3
+ // subagent-record entry 序列化白名单:collectMode / batchFinalized 两字段
4
+ // (subagent-sync-collect U1 foundation)。
5
+ //
6
+ // 锁三件事(设计 §3.1.3):
7
+ // 1. 持久化:两字段在 entry data 中如实透传(round-trip 经 JSON 序列化不丢);
8
+ // 2. 零迁移:无字段(旧 record 形态)的 entry 产物不含新键(JSON.stringify 自然缺省),
9
+ // 与改动前逐字节一致;
10
+ // 3. customType 稳定:SUBAGENT_RECORD_CUSTOM_TYPE 不因扩字段漂移。
11
+
12
+ import { describe, expect, it } from "vitest";
13
+
14
+ import {
15
+ SUBAGENT_RECORD_CUSTOM_TYPE,
16
+ toSubagentRecordEntry,
17
+ type SubagentRecordEntryData,
18
+ } from "../record-entry.ts";
19
+ import type { SubagentRecord } from "../types.ts";
20
+
21
+ /** 最小合法 SubagentRecord(缺省无 collect 两字段 = 旧记录形态)。 */
22
+ function makeRecord(over: Partial<SubagentRecord> = {}): SubagentRecord {
23
+ return {
24
+ id: "sa-1",
25
+ agent: "/home/u/agents/worker.md",
26
+ task: "t",
27
+ slug: "worker",
28
+ status: "running",
29
+ mode: "background",
30
+ startedAt: 1000,
31
+ rootSessionId: "root-A",
32
+ parentRecordId: undefined,
33
+ depth: 0,
34
+ endedAt: undefined,
35
+ turns: 1,
36
+ totalTokens: 42,
37
+ model: "prov/m1",
38
+ thinkingLevel: undefined,
39
+ eventLog: [],
40
+ displayItems: [],
41
+ sessionFile: "sess-1.jsonl",
42
+ ...over,
43
+ } as SubagentRecord;
44
+ }
45
+
46
+ /** entry data 的 JSONL 形态字符串(appendEntry 落盘即此产物)。 */
47
+ function serialize(entry: SubagentRecordEntryData): string {
48
+ return JSON.stringify(entry);
49
+ }
50
+
51
+ describe("record-entry serialization: collect fields (U1 foundation)", () => {
52
+ it("keeps SUBAGENT_RECORD_CUSTOM_TYPE stable", () => {
53
+ expect(SUBAGENT_RECORD_CUSTOM_TYPE).toBe("subagent-record");
54
+ });
55
+
56
+ it("passes collectMode through for a sync record", () => {
57
+ const entry = toSubagentRecordEntry(makeRecord({ collectMode: "sync" }));
58
+ expect(entry.collectMode).toBe("sync");
59
+ expect(entry.batchFinalized).toBeUndefined();
60
+ });
61
+
62
+ it("passes batchFinalized through for a member that left the batch", () => {
63
+ const entry = toSubagentRecordEntry(
64
+ makeRecord({
65
+ collectMode: "sync",
66
+ batchFinalized: true,
67
+ status: "closed",
68
+ endedAt: 2000,
69
+ }),
70
+ );
71
+ expect(entry.collectMode).toBe("sync");
72
+ expect(entry.batchFinalized).toBe(true);
73
+ expect(entry.status).toBe("closed");
74
+ });
75
+
76
+ it("round-trips both fields through JSON serialization (persisted form)", () => {
77
+ const entry = toSubagentRecordEntry(
78
+ makeRecord({ collectMode: "sync", batchFinalized: true }),
79
+ );
80
+ const revived = JSON.parse(serialize(entry)) as SubagentRecordEntryData;
81
+ expect(revived.collectMode).toBe("sync");
82
+ expect(revived.batchFinalized).toBe(true);
83
+ expect(revived.v).toBe(1);
84
+ });
85
+
86
+ it("emits no new keys for legacy records (旧记录零迁移)", () => {
87
+ const json = serialize(toSubagentRecordEntry(makeRecord()));
88
+ expect(json).not.toContain("collectMode");
89
+ expect(json).not.toContain("batchFinalized");
90
+ const revived = JSON.parse(json) as SubagentRecordEntryData;
91
+ expect(revived.collectMode).toBeUndefined();
92
+ expect(revived.batchFinalized).toBeUndefined();
93
+ });
94
+
95
+ it("still emits the full legacy whitelist for legacy records (既有字段不受扩字段影响)", () => {
96
+ const entry = toSubagentRecordEntry(makeRecord());
97
+ expect(entry.id).toBe("sa-1");
98
+ expect(entry.status).toBe("running");
99
+ expect(entry.model).toBe("prov/m1");
100
+ expect(entry.sessionFile).toBe("sess-1.jsonl");
101
+ });
102
+ });
@@ -25,7 +25,18 @@ describe('relay env SSOT', () => {
25
25
  expect(isRelayActive({ ...full, [RELAY_ENV_SOCKET]: '' })).toBe(false);
26
26
  expect(isRelayActive({ ...full, [RELAY_ENV_NODE]: undefined })).toBe(false);
27
27
  expect(isRelayActive({})).toBe(false);
28
- expect(isRelayActive(process.env)).toBe(false);
28
+ // [验收门修复] 宿主(pi 子进程链)可能注入 RELAY env——断言对象改为「清空 RELAY
29
+ // 键后的副本」:保留真实环境形态意图的同时对泄漏免疫(与 pi-invocation.test.ts
30
+ // 的 beforeEach 清理同病同修,不依赖 runner 环境)。
31
+ const sanitized: Record<string, string | undefined> = {
32
+ ...process.env,
33
+ [RELAY_ENV_SOCKET]: undefined,
34
+ [RELAY_ENV_NODE]: undefined,
35
+ [RELAY_ENV_SCRIPT]: undefined,
36
+ [RELAY_ENV_SESSION_ID]: undefined,
37
+ [RELAY_ENV_RECORD_ID]: undefined,
38
+ };
39
+ expect(isRelayActive(sanitized)).toBe(false);
29
40
  });
30
41
 
31
42
  it('env 名与退出码稳定(relay.mjs 镜像一致性由 conformance relay 断言锁定)', () => {
@@ -34,7 +34,7 @@ beforeEach(() => {
34
34
  });
35
35
 
36
36
  afterEach(() => {
37
- fs.rmSync(tmpAgentDir, { recursive: true, force: true });
37
+ fs.rmSync(tmpAgentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
38
38
  vi.restoreAllMocks();
39
39
  });
40
40
 
@@ -150,14 +150,14 @@ describe("findSessionFileByHeaderId", () => {
150
150
  fs.writeFileSync(expectedFile, "{}");
151
151
  const result = findSessionFileByHeaderId(tmpDir, "sid-456");
152
152
  expect(result).toBe(expectedFile);
153
- fs.rmSync(tmpDir, { recursive: true });
153
+ fs.rmSync(tmpDir, { recursive: true, maxRetries: 5, retryDelay: 20 });
154
154
  });
155
155
 
156
156
  it("无匹配返回 undefined", () => {
157
157
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "spawn-adapter-test-"));
158
158
  const result = findSessionFileByHeaderId(tmpDir, "nonexistent");
159
159
  expect(result).toBeUndefined();
160
- fs.rmSync(tmpDir, { recursive: true });
160
+ fs.rmSync(tmpDir, { recursive: true, maxRetries: 5, retryDelay: 20 });
161
161
  });
162
162
 
163
163
  it("目录不存在返回 undefined(不抛错)", () => {
@@ -0,0 +1,255 @@
1
+ // src/execution/__tests__/start-collect-guard.test.ts
2
+ //
3
+ // startHandler 的 collect 语义(subagent-sync-collect U1 foundation):
4
+ // 1. E4 校验(设计 §3.1.5):conversation:true + collect:"sync" → immediate throw,
5
+ // 不产生半启动 record(execute 零调用);文案指引「sync 仅支持 one-shot,
6
+ // 去掉 conversation 或 collect」;
7
+ // 2. 合法组合放行:collect 透传 service.execute(ExecuteOptions.collect);
8
+ // 3. start 响应 collect 段(设计 §3.1.1):仅 resolved=sync 附段(async 响应字节
9
+ // 零变化,G3);pendingSyncCount = 未闭合批 sync 成员数(collectMode=sync 且无
10
+ // batchFinalized;U2 偏差#4 接线后 record 落 collectMode,枚举天然含本条,无 +1)。
11
+ //
12
+ // stub SubagentService 仅实现 handler 触达的方法子集(subagent-actions-core.test.ts
13
+ // 同款形态);真实 service 行为归属 subagent-service 各自测试。
14
+
15
+ import { describe, expect, it, vi, type Mock } from "vitest";
16
+
17
+ import { startHandler } from "../subagent-actions-core.ts";
18
+ // SubagentService 的权威源是 subagent-service.ts(types.ts 只有 record/tool 契约)
19
+ import type { SubagentService } from "../subagent-service.ts";
20
+ import type { StatusFilter } from "../record-store.ts";
21
+ import type {
22
+ ExecutionHandle,
23
+ SubagentRecord,
24
+ SubagentToolDetails,
25
+ } from "../types.ts";
26
+
27
+ function makeDetails(): SubagentToolDetails {
28
+ return {
29
+ status: "running",
30
+ mode: "background",
31
+ agent: "/home/u/agents/worker.md",
32
+ model: "prov/m1",
33
+ thinkingLevel: undefined,
34
+ slug: "src-slug",
35
+ turns: 0,
36
+ totalTokens: 0,
37
+ elapsedSeconds: 0,
38
+ eventLog: [],
39
+ displayItems: [],
40
+ result: undefined,
41
+ error: undefined,
42
+ } as SubagentToolDetails;
43
+ }
44
+
45
+ function makeHandle(): ExecutionHandle {
46
+ return { mode: "background", subagentId: "sa-new", sessionFile: undefined, details: makeDetails() };
47
+ }
48
+
49
+ /** collectRecords stub 数据行(只含 pendingSyncCount 口径判读所需字段)。 */
50
+ function makeRec(over: Partial<SubagentRecord> = {}): SubagentRecord {
51
+ return {
52
+ id: "sa-1",
53
+ agent: "/home/u/agents/worker.md",
54
+ task: "t",
55
+ slug: "worker",
56
+ status: "running",
57
+ mode: "background",
58
+ startedAt: 1000,
59
+ rootSessionId: "root-A",
60
+ parentRecordId: undefined,
61
+ depth: 0,
62
+ endedAt: undefined,
63
+ turns: 1,
64
+ totalTokens: 42,
65
+ model: "prov/m1",
66
+ thinkingLevel: undefined,
67
+ eventLog: [],
68
+ displayItems: [],
69
+ ...over,
70
+ } as SubagentRecord;
71
+ }
72
+
73
+ // Mock<T> 具名签名 + Omit 类原生同名成员:ReturnType<typeof vi.fn>
74
+ // (= Mock<Procedure | Constructable>)与类方法签名交叉会收窄 never
75
+ type ServiceStub = Omit<SubagentService, "execute" | "collectRecords" | "getCollectSyncDefault"> & {
76
+ // 带首参形态:断言消费 execute.mock.calls[0][0](ExecuteOptions),零参签名为空元组取不到
77
+ execute: Mock<(opts: Record<string, unknown>) => Promise<SubagentRecord>>;
78
+ collectRecords: Mock<(limit: number, statusFilter?: StatusFilter) => SubagentRecord[]>;
79
+ getCollectSyncDefault: Mock<() => "async" | "sync">;
80
+ };
81
+
82
+ /** startHandler 的测试包装:stub → SubagentService 断言收口一处(类私有成员不可结构模拟)。 */
83
+ function sh(svc: ServiceStub, input: Record<string, unknown>, signal?: AbortSignal) {
84
+ return startHandler(
85
+ svc as unknown as SubagentService,
86
+ input as Parameters<typeof startHandler>[1],
87
+ signal ?? undefined,
88
+ );
89
+ }
90
+
91
+ function makeService(
92
+ collectRecordsReturn: SubagentRecord[] = [],
93
+ collectSyncDefault: "async" | "sync" = "async",
94
+ ): ServiceStub {
95
+ const execute = vi.fn(async (_opts: Record<string, unknown>) => makeHandle());
96
+ const collectRecords = vi.fn(() => collectRecordsReturn);
97
+ const getCollectSyncDefault = vi.fn(() => collectSyncDefault);
98
+ // [D4-① 适配] countPendingSyncRecords 经查询面聚合读 collectRecords(dev 重构后
99
+ // service.collectRecords 收窄 private,public 出口 = service.queries.collectRecords)。
100
+ return {
101
+ execute,
102
+ queries: { collectRecords },
103
+ collectRecords,
104
+ getCollectSyncDefault,
105
+ } as unknown as ServiceStub;
106
+ }
107
+
108
+ const BASE_INPUT = { task: "do things", slug: "do-things" };
109
+
110
+ // ============================================================
111
+ // E4:conversation + collect:"sync" 即拒
112
+ // ============================================================
113
+
114
+ describe("startHandler E4 guard (conversation + collect:sync)", () => {
115
+ it("throws immediately and never calls execute (不产生半启动 record)", async () => {
116
+ const service = makeService();
117
+ await expect(
118
+ sh(service, { ...BASE_INPUT, conversation: true, collect: "sync" }, undefined),
119
+ ).rejects.toThrow();
120
+ expect(service.execute).not.toHaveBeenCalled();
121
+ });
122
+
123
+ it("error copy carries the design guidance verbatim (文案锚定)", async () => {
124
+ const service = makeService();
125
+ const err = await sh(service,
126
+ { ...BASE_INPUT, conversation: true, collect: "sync" },
127
+ undefined,
128
+ ).catch((e: unknown) => e as Error);
129
+ expect(err).toBeInstanceOf(Error);
130
+ expect((err as Error).message).toBe(
131
+ 'collect:"sync" only supports one-shot subagents — it cannot be combined with conversation:true. ' +
132
+ 'Remove either conversation or collect (use collect:"async", or omit it, for conversational subagents).',
133
+ );
134
+ });
135
+
136
+ it("allows conversation:true + collect:async (async 语义与 conversation 正交)", async () => {
137
+ const service = makeService();
138
+ const result = await sh(service,
139
+ { ...BASE_INPUT, conversation: true, collect: "async" },
140
+ undefined,
141
+ );
142
+ expect(result.subagentId).toBe("sa-new");
143
+ expect(service.execute).toHaveBeenCalledTimes(1);
144
+ });
145
+
146
+ it("allows collect:sync alone (one-shot sync 是合法主场景)", async () => {
147
+ const service = makeService();
148
+ const result = await sh(service, { ...BASE_INPUT, collect: "sync" }, undefined);
149
+ expect(result.subagentId).toBe("sa-new");
150
+ expect(service.execute).toHaveBeenCalledTimes(1);
151
+ });
152
+
153
+ it("E4 also blocks conversation when config default is sync (偏差#3 接线:resolved 含 config 默认)", async () => {
154
+ const service = makeService([], "sync");
155
+ await expect(
156
+ sh(service, { ...BASE_INPUT, conversation: true }, undefined),
157
+ ).rejects.toThrow('collect:"sync" only supports one-shot subagents');
158
+ expect(service.execute).not.toHaveBeenCalled();
159
+ });
160
+
161
+ it("config default=sync applies when collect omitted (缺省读真实 config,U2 偏差#3)", async () => {
162
+ const service = makeService([], "sync");
163
+ const result = await sh(service, { ...BASE_INPUT }, undefined);
164
+ expect(result.response.collect).toBeDefined();
165
+ expect(service.getCollectSyncDefault).toHaveBeenCalledTimes(1);
166
+ });
167
+
168
+ it("reads config default via service accessor when collect omitted (async 默认零附段)", async () => {
169
+ const service = makeService([], "async");
170
+ const result = await sh(service, { ...BASE_INPUT }, undefined);
171
+ expect(result.response.collect).toBeUndefined();
172
+ expect(service.getCollectSyncDefault).toHaveBeenCalledTimes(1);
173
+ });
174
+ });
175
+
176
+ // ============================================================
177
+ // collect 透传 + 响应 collect 段
178
+ // ============================================================
179
+
180
+ describe("startHandler collect forwarding + response collect segment", () => {
181
+ it("forwards collect to service.execute via ExecuteOptions.collect", async () => {
182
+ const service = makeService();
183
+ await sh(service, { ...BASE_INPUT, collect: "sync" }, undefined);
184
+ const opts = service.execute.mock.calls[0]?.[0] as { collect?: string };
185
+ expect(opts.collect).toBe("sync");
186
+ });
187
+
188
+ it("omits collect in ExecuteOptions when not provided (resolved≠sync 时保持 undefined,async 缺省语义)", async () => {
189
+ const service = makeService();
190
+ await sh(service, { ...BASE_INPUT }, undefined);
191
+ const opts = service.execute.mock.calls[0]?.[0] as { collect?: string };
192
+ expect(opts.collect).toBeUndefined();
193
+ });
194
+
195
+ it("B1:config default=sync + 省略 collect → execute 收 collect:\"sync\"(缺省解析作用于 record 本体)", async () => {
196
+ // 修复前:execute 收原始 input.collect=undefined → createRecordForMode 只认
197
+ // ==="sync" → record 走 async 逐条通知而响应声称已入批(设计 §3.1.3/types.ts:689
198
+ // 承诺「缺省 = config 默认」作用于 record)。修复后 resolved=sync 落值。
199
+ const service = makeService([], "sync");
200
+ await sh(service, { ...BASE_INPUT }, undefined);
201
+ const opts = service.execute.mock.calls[0]?.[0] as { collect?: string };
202
+ expect(opts.collect).toBe("sync");
203
+ });
204
+
205
+ it("B1:config default=sync 时显式 collect:\"async\" 优先(execute 收 \"async\")", async () => {
206
+ const service = makeService([], "sync");
207
+ await sh(service, { ...BASE_INPUT, collect: "async" }, undefined);
208
+ const opts = service.execute.mock.calls[0]?.[0] as { collect?: string };
209
+ expect(opts.collect).toBe("async");
210
+ });
211
+
212
+ it("attaches collect segment counting this very record from the enumeration (U2 偏差#4:无 +1 补偿)", async () => {
213
+ // 本条 record(id=sa-new)已由 createRecordForMode 落 collectMode 入枚举:
214
+ // stub 枚举返回本条 + 另一未闭合 sync → count=2(无 +1 补偿即含本条)
215
+ const service = makeService([
216
+ makeRec({ id: "sa-new", collectMode: "sync" }),
217
+ makeRec({ id: "sa-other", collectMode: "sync", status: "running" }),
218
+ makeRec({ id: "sa-gone", collectMode: "sync", batchFinalized: true }),
219
+ ]);
220
+ const result = await sh(service, { ...BASE_INPUT, collect: "sync" }, undefined);
221
+ expect(result.response.collect).toEqual({ mode: "sync", pendingSyncCount: 2 });
222
+ });
223
+
224
+ it("reports the enumeration verbatim when no sync records exist yet (如实反映,无补偿)", async () => {
225
+ const service = makeService([]);
226
+ const result = await sh(service, { ...BASE_INPUT, collect: "sync" }, undefined);
227
+ expect(result.response.collect).toEqual({ mode: "sync", pendingSyncCount: 0 });
228
+ });
229
+
230
+ it("counts pending unmarked sync members from the enumeration (跨轮续累口径)", async () => {
231
+ const service = makeService([
232
+ makeRec({ id: "sa-1", collectMode: "sync" }), // 未闭合(无 batchFinalized)→ 计入
233
+ makeRec({ id: "sa-2", collectMode: "sync", batchFinalized: true }), // 已离场 → 排除
234
+ makeRec({ id: "sa-3" }), // async → 排除
235
+ makeRec({ id: "sa-4", collectMode: "sync", batchFinalized: false }), // 显式 false → 计入
236
+ ]);
237
+ const result = await sh(service, { ...BASE_INPUT, collect: "sync" }, undefined);
238
+ expect(result.response.collect).toEqual({ mode: "sync", pendingSyncCount: 2 });
239
+ });
240
+
241
+ it("omits the collect segment for async starts (G3: async 响应字节零变化)", async () => {
242
+ for (const input of [{ ...BASE_INPUT }, { ...BASE_INPUT, collect: "async" as const }]) {
243
+ const service = makeService();
244
+ const result = await sh(service, input, undefined);
245
+ expect(result.response.collect).toBeUndefined();
246
+ // G3 字节锚:response 其余字段与既有形态全等
247
+ expect(result.response).toEqual({
248
+ status: "running",
249
+ mode: "background",
250
+ message: result.response.message,
251
+ notifyContract: "ledger+at-least-once",
252
+ });
253
+ }
254
+ });
255
+ });
@@ -93,7 +93,7 @@ describe("start 同步期裁决(U1 验收 3:非全等 → isError 且无 spa
93
93
 
94
94
  afterEach(() => {
95
95
  vi.restoreAllMocks();
96
- fs.rmSync(tmpDir, { recursive: true, force: true });
96
+ fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
97
97
  });
98
98
 
99
99
  it("小写变体入参 → execute 同步 reject(问句式报错),buildSpawnArgs/spawn/appendEntry 均未被调", async () => {
@@ -144,11 +144,17 @@ function makeService(over: Record<string, unknown> = {}): SubagentService {
144
144
  getRecordForAction: vi.fn(),
145
145
  closeSubagent: vi.fn(),
146
146
  deliverChatMessage: vi.fn(),
147
+ // [U2] startHandler 缺省 collect 解析读真实 config(偏差#3 接线):stub 缺省 async
148
+ //(本文件不测 collect 语义,专项见 start-collect-guard.test.ts)。
149
+ getCollectSyncDefault: vi.fn(() => "async" as const),
147
150
  ...over,
148
151
  };
149
152
  return {
150
153
  execute: m.execute,
151
154
  cancel: m.cancel,
155
+ // [U2 偏差#3 接线] startHandler 经平铺访问器读 config 缺省 collect(真实 service
156
+ // 为平铺方法 subagent-service.ts:1786,非 queries 聚合面成员),stub 须同构挂载。
157
+ getCollectSyncDefault: m.getCollectSyncDefault,
152
158
  queries: {
153
159
  findRecord: m.findRecord,
154
160
  lookupRecordAnyState: m.lookupRecordAnyState,
@@ -120,6 +120,19 @@ describe("T5③ cold-lookup running candidate foreign-instance guard", () => {
120
120
  let store: RecordStore;
121
121
 
122
122
  beforeEach(() => {
123
+ // 身份 env 剥离:initSession 会把 PI_SUBAGENT_ROOT_SESSION_ID 读作 sessionRootId 基线
124
+ //(env 优先于入参 sessionId)——宿主进程(xyz-agent 子 agent 环境)泄漏的身份 env
125
+ // 会让「root-session」桩记录归属校验恒不匹配 → 冷查用例误红(与 collect-coordinator-
126
+ // service.test.ts 同款剥离纪律)。
127
+ for (const k of [
128
+ "PI_SUBAGENT_ROOT_SESSION_ID",
129
+ "PI_SUBAGENT_SELF_RECORD_ID",
130
+ "PI_SUBAGENT_DEPTH",
131
+ "PI_SUBAGENT_ROOT_CWD",
132
+ "PI_SUBAGENT_FORK_DEPTH",
133
+ ] as const) {
134
+ delete process.env[k];
135
+ }
123
136
  foreignLiveSpy.mockReset();
124
137
  foreignLiveSpy.mockReturnValue(undefined);
125
138
  ({ agentDir, service, store } = setup());