@herdctl/core 5.2.2 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/dist/__tests__/fleet-composition-integration.test.d.ts +15 -0
  2. package/dist/__tests__/fleet-composition-integration.test.d.ts.map +1 -0
  3. package/dist/__tests__/fleet-composition-integration.test.js +942 -0
  4. package/dist/__tests__/fleet-composition-integration.test.js.map +1 -0
  5. package/dist/config/__tests__/fleet-error-messages.test.d.ts +9 -0
  6. package/dist/config/__tests__/fleet-error-messages.test.d.ts.map +1 -0
  7. package/dist/config/__tests__/fleet-error-messages.test.js +321 -0
  8. package/dist/config/__tests__/fleet-error-messages.test.js.map +1 -0
  9. package/dist/config/__tests__/fleet-loading.test.d.ts +2 -0
  10. package/dist/config/__tests__/fleet-loading.test.d.ts.map +1 -0
  11. package/dist/config/__tests__/fleet-loading.test.js +627 -0
  12. package/dist/config/__tests__/fleet-loading.test.js.map +1 -0
  13. package/dist/config/__tests__/fleet-naming-and-defaults.test.d.ts +2 -0
  14. package/dist/config/__tests__/fleet-naming-and-defaults.test.d.ts.map +1 -0
  15. package/dist/config/__tests__/fleet-naming-and-defaults.test.js +1024 -0
  16. package/dist/config/__tests__/fleet-naming-and-defaults.test.js.map +1 -0
  17. package/dist/config/__tests__/schema.test.js +172 -1
  18. package/dist/config/__tests__/schema.test.js.map +1 -1
  19. package/dist/config/index.d.ts +2 -2
  20. package/dist/config/index.d.ts.map +1 -1
  21. package/dist/config/index.js +2 -2
  22. package/dist/config/index.js.map +1 -1
  23. package/dist/config/loader.d.ts +63 -2
  24. package/dist/config/loader.d.ts.map +1 -1
  25. package/dist/config/loader.js +309 -74
  26. package/dist/config/loader.js.map +1 -1
  27. package/dist/config/schema.d.ts +58 -0
  28. package/dist/config/schema.d.ts.map +1 -1
  29. package/dist/config/schema.js +29 -0
  30. package/dist/config/schema.js.map +1 -1
  31. package/dist/fleet-manager/__tests__/config-reload-qualified.test.d.ts +8 -0
  32. package/dist/fleet-manager/__tests__/config-reload-qualified.test.d.ts.map +1 -0
  33. package/dist/fleet-manager/__tests__/config-reload-qualified.test.js +416 -0
  34. package/dist/fleet-manager/__tests__/config-reload-qualified.test.js.map +1 -0
  35. package/dist/fleet-manager/__tests__/integration.test.js +2 -2
  36. package/dist/fleet-manager/__tests__/integration.test.js.map +1 -1
  37. package/dist/fleet-manager/chat-manager-interface.d.ts +6 -6
  38. package/dist/fleet-manager/chat-manager-interface.d.ts.map +1 -1
  39. package/dist/fleet-manager/config-reload.d.ts.map +1 -1
  40. package/dist/fleet-manager/config-reload.js +17 -14
  41. package/dist/fleet-manager/config-reload.js.map +1 -1
  42. package/dist/fleet-manager/fleet-manager.d.ts +9 -5
  43. package/dist/fleet-manager/fleet-manager.d.ts.map +1 -1
  44. package/dist/fleet-manager/fleet-manager.js +18 -9
  45. package/dist/fleet-manager/fleet-manager.js.map +1 -1
  46. package/dist/fleet-manager/job-control.js +9 -9
  47. package/dist/fleet-manager/job-control.js.map +1 -1
  48. package/dist/fleet-manager/schedule-executor.js +13 -13
  49. package/dist/fleet-manager/schedule-executor.js.map +1 -1
  50. package/dist/fleet-manager/schedule-management.d.ts.map +1 -1
  51. package/dist/fleet-manager/schedule-management.js +22 -16
  52. package/dist/fleet-manager/schedule-management.js.map +1 -1
  53. package/dist/fleet-manager/status-queries.d.ts +5 -1
  54. package/dist/fleet-manager/status-queries.d.ts.map +1 -1
  55. package/dist/fleet-manager/status-queries.js +16 -8
  56. package/dist/fleet-manager/status-queries.js.map +1 -1
  57. package/dist/fleet-manager/types.d.ts +12 -1
  58. package/dist/fleet-manager/types.d.ts.map +1 -1
  59. package/dist/runner/__tests__/job-executor.test.js +2 -0
  60. package/dist/runner/__tests__/job-executor.test.js.map +1 -1
  61. package/dist/runner/__tests__/sdk-adapter.test.js +2 -0
  62. package/dist/runner/__tests__/sdk-adapter.test.js.map +1 -1
  63. package/dist/scheduler/__tests__/schedule-runner.test.js +2 -0
  64. package/dist/scheduler/__tests__/schedule-runner.test.js.map +1 -1
  65. package/dist/scheduler/__tests__/scheduler.test.js +2 -0
  66. package/dist/scheduler/__tests__/scheduler.test.js.map +1 -1
  67. package/dist/scheduler/schedule-runner.js +12 -12
  68. package/dist/scheduler/schedule-runner.js.map +1 -1
  69. package/dist/scheduler/scheduler.js +22 -22
  70. package/dist/scheduler/scheduler.js.map +1 -1
  71. package/dist/state/__tests__/session.test.js +27 -0
  72. package/dist/state/__tests__/session.test.js.map +1 -1
  73. package/dist/state/fleet-state.d.ts +2 -2
  74. package/dist/state/fleet-state.js +2 -2
  75. package/dist/state/schemas/session-info.d.ts +5 -2
  76. package/dist/state/schemas/session-info.d.ts.map +1 -1
  77. package/dist/state/schemas/session-info.js +5 -2
  78. package/dist/state/schemas/session-info.js.map +1 -1
  79. package/dist/state/session.d.ts +4 -1
  80. package/dist/state/session.d.ts.map +1 -1
  81. package/dist/state/session.js +4 -1
  82. package/dist/state/session.js.map +1 -1
  83. package/dist/state/utils/__tests__/path-safety.test.js +18 -3
  84. package/dist/state/utils/__tests__/path-safety.test.js.map +1 -1
  85. package/dist/state/utils/path-safety.d.ts +4 -2
  86. package/dist/state/utils/path-safety.d.ts.map +1 -1
  87. package/dist/state/utils/path-safety.js +5 -3
  88. package/dist/state/utils/path-safety.js.map +1 -1
  89. package/dist/work-sources/__tests__/manager.test.js +2 -0
  90. package/dist/work-sources/__tests__/manager.test.js.map +1 -1
  91. package/package.json +1 -1
@@ -0,0 +1,1024 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdir, writeFile, rm, realpath } from "node:fs/promises";
3
+ import { join, resolve } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { loadConfig, FleetNameCollisionError, } from "../loader.js";
6
+ import { ConfigError } from "../parser.js";
7
+ // =============================================================================
8
+ // Test helpers
9
+ // =============================================================================
10
+ async function createTempDir() {
11
+ const baseDir = join(tmpdir(), `herdctl-fleet-naming-${Date.now()}-${Math.random().toString(36).slice(2)}`);
12
+ await mkdir(baseDir, { recursive: true });
13
+ return await realpath(baseDir);
14
+ }
15
+ async function createFile(filePath, content) {
16
+ await mkdir(join(filePath, ".."), { recursive: true });
17
+ await writeFile(filePath, content, "utf-8");
18
+ }
19
+ const fixturesDir = resolve(__dirname, "fixtures", "fleet-composition");
20
+ // =============================================================================
21
+ // Fleet Name Resolution Tests
22
+ // =============================================================================
23
+ describe("fleet name resolution", () => {
24
+ let tempDir;
25
+ beforeEach(async () => {
26
+ tempDir = await createTempDir();
27
+ });
28
+ afterEach(async () => {
29
+ await rm(tempDir, { recursive: true, force: true });
30
+ });
31
+ describe("priority order", () => {
32
+ it("parent explicit name overrides sub-fleet fleet.name", async () => {
33
+ await createFile(join(tempDir, "herdctl.yaml"), `
34
+ version: 1
35
+ fleets:
36
+ - path: ./sub/herdctl.yaml
37
+ name: parent-chosen
38
+ `);
39
+ await createFile(join(tempDir, "sub", "herdctl.yaml"), `
40
+ version: 1
41
+ fleet:
42
+ name: sub-fleet-own-name
43
+ agents:
44
+ - path: ./agents/worker.yaml
45
+ `);
46
+ await createFile(join(tempDir, "sub", "agents", "worker.yaml"), "name: worker");
47
+ const result = await loadConfig(tempDir, { env: {}, envFile: false });
48
+ expect(result.agents[0].fleetPath).toEqual(["parent-chosen"]);
49
+ expect(result.agents[0].qualifiedName).toBe("parent-chosen.worker");
50
+ });
51
+ it("sub-fleet fleet.name used when no parent override", async () => {
52
+ await createFile(join(tempDir, "herdctl.yaml"), `
53
+ version: 1
54
+ fleets:
55
+ - path: ./sub/herdctl.yaml
56
+ `);
57
+ await createFile(join(tempDir, "sub", "herdctl.yaml"), `
58
+ version: 1
59
+ fleet:
60
+ name: self-declared
61
+ agents:
62
+ - path: ./agents/worker.yaml
63
+ `);
64
+ await createFile(join(tempDir, "sub", "agents", "worker.yaml"), "name: worker");
65
+ const result = await loadConfig(tempDir, { env: {}, envFile: false });
66
+ expect(result.agents[0].fleetPath).toEqual(["self-declared"]);
67
+ expect(result.agents[0].qualifiedName).toBe("self-declared.worker");
68
+ });
69
+ it("directory name used when neither parent nor sub-fleet provides name", async () => {
70
+ await createFile(join(tempDir, "herdctl.yaml"), `
71
+ version: 1
72
+ fleets:
73
+ - path: ./my-cool-project/herdctl.yaml
74
+ `);
75
+ await createFile(join(tempDir, "my-cool-project", "herdctl.yaml"), `
76
+ version: 1
77
+ agents:
78
+ - path: ./agents/worker.yaml
79
+ `);
80
+ await createFile(join(tempDir, "my-cool-project", "agents", "worker.yaml"), "name: worker");
81
+ const result = await loadConfig(tempDir, { env: {}, envFile: false });
82
+ expect(result.agents[0].fleetPath).toEqual(["my-cool-project"]);
83
+ expect(result.agents[0].qualifiedName).toBe("my-cool-project.worker");
84
+ });
85
+ it("parent override takes priority when both parent name and fleet.name are present", async () => {
86
+ // Both parent name and sub-fleet fleet.name exist; parent wins
87
+ await createFile(join(tempDir, "herdctl.yaml"), `
88
+ version: 1
89
+ fleets:
90
+ - path: ./subdir/herdctl.yaml
91
+ name: parent-override
92
+ `);
93
+ await createFile(join(tempDir, "subdir", "herdctl.yaml"), `
94
+ version: 1
95
+ fleet:
96
+ name: fleet-self-name
97
+ agents:
98
+ - path: ./agents/a.yaml
99
+ - path: ./agents/b.yaml
100
+ `);
101
+ await createFile(join(tempDir, "subdir", "agents", "a.yaml"), "name: agent-a");
102
+ await createFile(join(tempDir, "subdir", "agents", "b.yaml"), "name: agent-b");
103
+ const result = await loadConfig(tempDir, { env: {}, envFile: false });
104
+ // Both agents should use parent-override in their fleetPath
105
+ for (const agent of result.agents) {
106
+ expect(agent.fleetPath).toEqual(["parent-override"]);
107
+ expect(agent.qualifiedName).toMatch(/^parent-override\./);
108
+ }
109
+ expect(result.agents.find((a) => a.name === "agent-a").qualifiedName).toBe("parent-override.agent-a");
110
+ expect(result.agents.find((a) => a.name === "agent-b").qualifiedName).toBe("parent-override.agent-b");
111
+ });
112
+ it("directory name fallback uses containing directory, not config file name", async () => {
113
+ // The config is at deeply/nested/path/herdctl.yaml => directory is "path"
114
+ await createFile(join(tempDir, "herdctl.yaml"), `
115
+ version: 1
116
+ fleets:
117
+ - path: ./deeply/nested/path/herdctl.yaml
118
+ `);
119
+ await createFile(join(tempDir, "deeply", "nested", "path", "herdctl.yaml"), `
120
+ version: 1
121
+ agents:
122
+ - path: ./agents/worker.yaml
123
+ `);
124
+ await createFile(join(tempDir, "deeply", "nested", "path", "agents", "worker.yaml"), "name: worker");
125
+ const result = await loadConfig(tempDir, { env: {}, envFile: false });
126
+ // Directory name is "path" (the immediate parent of herdctl.yaml)
127
+ expect(result.agents[0].fleetPath).toEqual(["path"]);
128
+ expect(result.agents[0].qualifiedName).toBe("path.worker");
129
+ });
130
+ });
131
+ describe("valid fleet names", () => {
132
+ it("accepts fleet name with hyphens", async () => {
133
+ await createFile(join(tempDir, "herdctl.yaml"), `
134
+ version: 1
135
+ fleets:
136
+ - path: ./sub/herdctl.yaml
137
+ name: my-cool-project
138
+ `);
139
+ await createFile(join(tempDir, "sub", "herdctl.yaml"), `
140
+ version: 1
141
+ agents:
142
+ - path: ./agents/worker.yaml
143
+ `);
144
+ await createFile(join(tempDir, "sub", "agents", "worker.yaml"), "name: worker");
145
+ const result = await loadConfig(tempDir, { env: {}, envFile: false });
146
+ expect(result.agents[0].qualifiedName).toBe("my-cool-project.worker");
147
+ });
148
+ it("accepts fleet name with underscores", async () => {
149
+ await createFile(join(tempDir, "herdctl.yaml"), `
150
+ version: 1
151
+ fleets:
152
+ - path: ./sub/herdctl.yaml
153
+ name: my_cool_project
154
+ `);
155
+ await createFile(join(tempDir, "sub", "herdctl.yaml"), `
156
+ version: 1
157
+ agents:
158
+ - path: ./agents/worker.yaml
159
+ `);
160
+ await createFile(join(tempDir, "sub", "agents", "worker.yaml"), "name: worker");
161
+ const result = await loadConfig(tempDir, { env: {}, envFile: false });
162
+ expect(result.agents[0].qualifiedName).toBe("my_cool_project.worker");
163
+ });
164
+ it("accepts fleet name with mixed hyphens and underscores", async () => {
165
+ await createFile(join(tempDir, "herdctl.yaml"), `
166
+ version: 1
167
+ fleets:
168
+ - path: ./sub/herdctl.yaml
169
+ name: my-cool_project
170
+ `);
171
+ await createFile(join(tempDir, "sub", "herdctl.yaml"), `
172
+ version: 1
173
+ agents:
174
+ - path: ./agents/worker.yaml
175
+ `);
176
+ await createFile(join(tempDir, "sub", "agents", "worker.yaml"), "name: worker");
177
+ const result = await loadConfig(tempDir, { env: {}, envFile: false });
178
+ expect(result.agents[0].qualifiedName).toBe("my-cool_project.worker");
179
+ });
180
+ it("accepts fleet name starting with a number", async () => {
181
+ await createFile(join(tempDir, "herdctl.yaml"), `
182
+ version: 1
183
+ fleets:
184
+ - path: ./sub/herdctl.yaml
185
+ name: 42project
186
+ `);
187
+ await createFile(join(tempDir, "sub", "herdctl.yaml"), `
188
+ version: 1
189
+ agents:
190
+ - path: ./agents/worker.yaml
191
+ `);
192
+ await createFile(join(tempDir, "sub", "agents", "worker.yaml"), "name: worker");
193
+ const result = await loadConfig(tempDir, { env: {}, envFile: false });
194
+ expect(result.agents[0].qualifiedName).toBe("42project.worker");
195
+ });
196
+ it("accepts single-character fleet name", async () => {
197
+ await createFile(join(tempDir, "herdctl.yaml"), `
198
+ version: 1
199
+ fleets:
200
+ - path: ./sub/herdctl.yaml
201
+ name: x
202
+ `);
203
+ await createFile(join(tempDir, "sub", "herdctl.yaml"), `
204
+ version: 1
205
+ agents:
206
+ - path: ./agents/worker.yaml
207
+ `);
208
+ await createFile(join(tempDir, "sub", "agents", "worker.yaml"), "name: worker");
209
+ const result = await loadConfig(tempDir, { env: {}, envFile: false });
210
+ expect(result.agents[0].qualifiedName).toBe("x.worker");
211
+ });
212
+ it("accepts fleet name from fleet.name with hyphens", async () => {
213
+ await createFile(join(tempDir, "herdctl.yaml"), `
214
+ version: 1
215
+ fleets:
216
+ - path: ./sub/herdctl.yaml
217
+ `);
218
+ await createFile(join(tempDir, "sub", "herdctl.yaml"), `
219
+ version: 1
220
+ fleet:
221
+ name: hyphen-name
222
+ agents:
223
+ - path: ./agents/worker.yaml
224
+ `);
225
+ await createFile(join(tempDir, "sub", "agents", "worker.yaml"), "name: worker");
226
+ const result = await loadConfig(tempDir, { env: {}, envFile: false });
227
+ expect(result.agents[0].qualifiedName).toBe("hyphen-name.worker");
228
+ });
229
+ it("accepts fleet name from directory with underscores", async () => {
230
+ await createFile(join(tempDir, "herdctl.yaml"), `
231
+ version: 1
232
+ fleets:
233
+ - path: ./my_project_dir/herdctl.yaml
234
+ `);
235
+ await createFile(join(tempDir, "my_project_dir", "herdctl.yaml"), `
236
+ version: 1
237
+ agents:
238
+ - path: ./agents/worker.yaml
239
+ `);
240
+ await createFile(join(tempDir, "my_project_dir", "agents", "worker.yaml"), "name: worker");
241
+ const result = await loadConfig(tempDir, { env: {}, envFile: false });
242
+ expect(result.agents[0].qualifiedName).toBe("my_project_dir.worker");
243
+ });
244
+ });
245
+ describe("invalid fleet names", () => {
246
+ it("rejects fleet name with dots via fleet.name", async () => {
247
+ await createFile(join(tempDir, "herdctl.yaml"), `
248
+ version: 1
249
+ fleets:
250
+ - path: ./sub/herdctl.yaml
251
+ `);
252
+ await createFile(join(tempDir, "sub", "herdctl.yaml"), `
253
+ version: 1
254
+ fleet:
255
+ name: my.project
256
+ agents:
257
+ - path: ./agents/worker.yaml
258
+ `);
259
+ await createFile(join(tempDir, "sub", "agents", "worker.yaml"), "name: worker");
260
+ await expect(loadConfig(tempDir, { env: {}, envFile: false })).rejects.toThrow(/invalid/i);
261
+ });
262
+ it("rejects fleet name starting with a hyphen via fleet.name", async () => {
263
+ await createFile(join(tempDir, "herdctl.yaml"), `
264
+ version: 1
265
+ fleets:
266
+ - path: ./sub/herdctl.yaml
267
+ `);
268
+ await createFile(join(tempDir, "sub", "herdctl.yaml"), `
269
+ version: 1
270
+ fleet:
271
+ name: -starts-with-hyphen
272
+ agents:
273
+ - path: ./agents/worker.yaml
274
+ `);
275
+ await createFile(join(tempDir, "sub", "agents", "worker.yaml"), "name: worker");
276
+ await expect(loadConfig(tempDir, { env: {}, envFile: false })).rejects.toThrow(/invalid/i);
277
+ });
278
+ it("rejects fleet name starting with underscore via fleet.name", async () => {
279
+ await createFile(join(tempDir, "herdctl.yaml"), `
280
+ version: 1
281
+ fleets:
282
+ - path: ./sub/herdctl.yaml
283
+ `);
284
+ await createFile(join(tempDir, "sub", "herdctl.yaml"), `
285
+ version: 1
286
+ fleet:
287
+ name: _underscore_start
288
+ agents:
289
+ - path: ./agents/worker.yaml
290
+ `);
291
+ await createFile(join(tempDir, "sub", "agents", "worker.yaml"), "name: worker");
292
+ await expect(loadConfig(tempDir, { env: {}, envFile: false })).rejects.toThrow(/invalid/i);
293
+ });
294
+ it("rejects fleet name with dots when derived from directory name", async () => {
295
+ // Create a directory with a dot in its name
296
+ await createFile(join(tempDir, "herdctl.yaml"), `
297
+ version: 1
298
+ fleets:
299
+ - path: ./my.project/herdctl.yaml
300
+ `);
301
+ await createFile(join(tempDir, "my.project", "herdctl.yaml"), `
302
+ version: 1
303
+ agents:
304
+ - path: ./agents/worker.yaml
305
+ `);
306
+ await createFile(join(tempDir, "my.project", "agents", "worker.yaml"), "name: worker");
307
+ await expect(loadConfig(tempDir, { env: {}, envFile: false })).rejects.toThrow(/invalid/i);
308
+ });
309
+ it("rejects fleet name with dots via parent name override (schema validation)", async () => {
310
+ // Parent name override with dots should fail at schema level
311
+ await createFile(join(tempDir, "herdctl.yaml"), `
312
+ version: 1
313
+ fleets:
314
+ - path: ./sub/herdctl.yaml
315
+ name: "my.bad.name"
316
+ `);
317
+ await createFile(join(tempDir, "sub", "herdctl.yaml"), `
318
+ version: 1
319
+ agents:
320
+ - path: ./agents/worker.yaml
321
+ `);
322
+ await createFile(join(tempDir, "sub", "agents", "worker.yaml"), "name: worker");
323
+ // Should fail during config parsing (Zod regex on FleetReferenceSchema.name)
324
+ await expect(loadConfig(tempDir, { env: {}, envFile: false })).rejects.toThrow();
325
+ });
326
+ it("provides clear error message for invalid fleet names", async () => {
327
+ await createFile(join(tempDir, "herdctl.yaml"), `
328
+ version: 1
329
+ fleets:
330
+ - path: ./sub/herdctl.yaml
331
+ `);
332
+ await createFile(join(tempDir, "sub", "herdctl.yaml"), `
333
+ version: 1
334
+ fleet:
335
+ name: has.dots.in.name
336
+ agents:
337
+ - path: ./agents/worker.yaml
338
+ `);
339
+ await createFile(join(tempDir, "sub", "agents", "worker.yaml"), "name: worker");
340
+ try {
341
+ await loadConfig(tempDir, { env: {}, envFile: false });
342
+ expect.fail("Should have thrown an error");
343
+ }
344
+ catch (error) {
345
+ expect(error).toBeInstanceOf(ConfigError);
346
+ const msg = error.message;
347
+ expect(msg).toContain("has.dots.in.name");
348
+ expect(msg.toLowerCase()).toContain("invalid");
349
+ }
350
+ });
351
+ it("rejects fleet name with spaces via fleet.name", async () => {
352
+ await createFile(join(tempDir, "herdctl.yaml"), `
353
+ version: 1
354
+ fleets:
355
+ - path: ./sub/herdctl.yaml
356
+ `);
357
+ await createFile(join(tempDir, "sub", "herdctl.yaml"), `
358
+ version: 1
359
+ fleet:
360
+ name: "has spaces"
361
+ agents:
362
+ - path: ./agents/worker.yaml
363
+ `);
364
+ await createFile(join(tempDir, "sub", "agents", "worker.yaml"), "name: worker");
365
+ await expect(loadConfig(tempDir, { env: {}, envFile: false })).rejects.toThrow(/invalid/i);
366
+ });
367
+ });
368
+ describe("qualified name construction", () => {
369
+ it("root-level agents have qualifiedName equal to their local name", async () => {
370
+ await createFile(join(tempDir, "herdctl.yaml"), `
371
+ version: 1
372
+ agents:
373
+ - path: ./agents/monitor.yaml
374
+ `);
375
+ await createFile(join(tempDir, "agents", "monitor.yaml"), "name: monitor");
376
+ const result = await loadConfig(tempDir, { env: {}, envFile: false });
377
+ expect(result.agents[0].qualifiedName).toBe("monitor");
378
+ expect(result.agents[0].fleetPath).toEqual([]);
379
+ });
380
+ it("single-level sub-fleet agents use fleetName.agentName", async () => {
381
+ await createFile(join(tempDir, "herdctl.yaml"), `
382
+ version: 1
383
+ fleets:
384
+ - path: ./sub/herdctl.yaml
385
+ name: myfleet
386
+ `);
387
+ await createFile(join(tempDir, "sub", "herdctl.yaml"), `
388
+ version: 1
389
+ agents:
390
+ - path: ./agents/worker.yaml
391
+ `);
392
+ await createFile(join(tempDir, "sub", "agents", "worker.yaml"), "name: worker");
393
+ const result = await loadConfig(tempDir, { env: {}, envFile: false });
394
+ expect(result.agents[0].qualifiedName).toBe("myfleet.worker");
395
+ });
396
+ it("deeply nested agents include all intermediate fleet names", async () => {
397
+ const result = await loadConfig(join(fixturesDir, "deep-nesting", "root.yaml"), { env: {}, envFile: false });
398
+ // Should have agents at 4 levels: root, level1, level2, level3
399
+ expect(result.agents).toHaveLength(4);
400
+ const rootAgent = result.agents.find((a) => a.name === "root-agent");
401
+ expect(rootAgent).toBeDefined();
402
+ expect(rootAgent.qualifiedName).toBe("root-agent");
403
+ expect(rootAgent.fleetPath).toEqual([]);
404
+ const l1Agent = result.agents.find((a) => a.name === "l1-agent");
405
+ expect(l1Agent).toBeDefined();
406
+ expect(l1Agent.qualifiedName).toBe("level1.l1-agent");
407
+ expect(l1Agent.fleetPath).toEqual(["level1"]);
408
+ const l2Agent = result.agents.find((a) => a.name === "l2-agent");
409
+ expect(l2Agent).toBeDefined();
410
+ expect(l2Agent.qualifiedName).toBe("level1.level2.l2-agent");
411
+ expect(l2Agent.fleetPath).toEqual(["level1", "level2"]);
412
+ const l3Agent = result.agents.find((a) => a.name === "l3-agent");
413
+ expect(l3Agent).toBeDefined();
414
+ expect(l3Agent.qualifiedName).toBe("level1.level2.level3.l3-agent");
415
+ expect(l3Agent.fleetPath).toEqual(["level1", "level2", "level3"]);
416
+ });
417
+ it("same agent name in different sub-fleets produces different qualified names", async () => {
418
+ await createFile(join(tempDir, "herdctl.yaml"), `
419
+ version: 1
420
+ fleets:
421
+ - path: ./fleet-a/herdctl.yaml
422
+ - path: ./fleet-b/herdctl.yaml
423
+ `);
424
+ await createFile(join(tempDir, "fleet-a", "herdctl.yaml"), `
425
+ version: 1
426
+ fleet:
427
+ name: fleet-a
428
+ agents:
429
+ - path: ./agents/worker.yaml
430
+ `);
431
+ await createFile(join(tempDir, "fleet-b", "herdctl.yaml"), `
432
+ version: 1
433
+ fleet:
434
+ name: fleet-b
435
+ agents:
436
+ - path: ./agents/worker.yaml
437
+ `);
438
+ await createFile(join(tempDir, "fleet-a", "agents", "worker.yaml"), "name: worker");
439
+ await createFile(join(tempDir, "fleet-b", "agents", "worker.yaml"), "name: worker");
440
+ const result = await loadConfig(tempDir, { env: {}, envFile: false });
441
+ expect(result.agents).toHaveLength(2);
442
+ const fleetAWorker = result.agents.find((a) => a.qualifiedName === "fleet-a.worker");
443
+ const fleetBWorker = result.agents.find((a) => a.qualifiedName === "fleet-b.worker");
444
+ expect(fleetAWorker).toBeDefined();
445
+ expect(fleetBWorker).toBeDefined();
446
+ expect(fleetAWorker.qualifiedName).not.toBe(fleetBWorker.qualifiedName);
447
+ });
448
+ });
449
+ });
450
+ // =============================================================================
451
+ // Defaults Merging Edge Case Tests
452
+ // =============================================================================
453
+ describe("defaults merging edge cases", () => {
454
+ let tempDir;
455
+ beforeEach(async () => {
456
+ tempDir = await createTempDir();
457
+ });
458
+ afterEach(async () => {
459
+ await rm(tempDir, { recursive: true, force: true });
460
+ });
461
+ describe("5-level merge priority", () => {
462
+ it("sub-fleet defaults override super-fleet defaults for model", async () => {
463
+ // Priority level 2 (sub-fleet defaults) > level 1 (super-fleet defaults)
464
+ await createFile(join(tempDir, "herdctl.yaml"), `
465
+ version: 1
466
+ defaults:
467
+ model: super-model
468
+ max_turns: 200
469
+ fleets:
470
+ - path: ./sub/herdctl.yaml
471
+ `);
472
+ await createFile(join(tempDir, "sub", "herdctl.yaml"), `
473
+ version: 1
474
+ fleet:
475
+ name: sub
476
+ defaults:
477
+ model: sub-model
478
+ agents:
479
+ - path: ./agents/worker.yaml
480
+ `);
481
+ await createFile(join(tempDir, "sub", "agents", "worker.yaml"), "name: worker");
482
+ const result = await loadConfig(tempDir, { env: {}, envFile: false });
483
+ // sub-fleet model wins over super-fleet model
484
+ expect(result.agents[0].model).toBe("sub-model");
485
+ // super-fleet max_turns fills the gap (sub-fleet doesn't set it)
486
+ expect(result.agents[0].max_turns).toBe(200);
487
+ });
488
+ it("super-fleet defaults fill gaps when sub-fleet sets nothing", async () => {
489
+ // Priority level 1 acts as gap-filler
490
+ await createFile(join(tempDir, "herdctl.yaml"), `
491
+ version: 1
492
+ defaults:
493
+ model: super-model
494
+ max_turns: 100
495
+ permission_mode: plan
496
+ fleets:
497
+ - path: ./sub/herdctl.yaml
498
+ `);
499
+ await createFile(join(tempDir, "sub", "herdctl.yaml"), `
500
+ version: 1
501
+ fleet:
502
+ name: sub
503
+ agents:
504
+ - path: ./agents/worker.yaml
505
+ `);
506
+ await createFile(join(tempDir, "sub", "agents", "worker.yaml"), "name: worker");
507
+ const result = await loadConfig(tempDir, { env: {}, envFile: false });
508
+ expect(result.agents[0].model).toBe("super-model");
509
+ expect(result.agents[0].max_turns).toBe(100);
510
+ expect(result.agents[0].permission_mode).toBe("plan");
511
+ });
512
+ it("agent own config overrides both super-fleet and sub-fleet defaults", async () => {
513
+ // Priority level 3 (agent's own config) > levels 1 and 2
514
+ await createFile(join(tempDir, "herdctl.yaml"), `
515
+ version: 1
516
+ defaults:
517
+ model: super-model
518
+ max_turns: 200
519
+ fleets:
520
+ - path: ./sub/herdctl.yaml
521
+ `);
522
+ await createFile(join(tempDir, "sub", "herdctl.yaml"), `
523
+ version: 1
524
+ fleet:
525
+ name: sub
526
+ defaults:
527
+ model: sub-model
528
+ max_turns: 50
529
+ agents:
530
+ - path: ./agents/worker.yaml
531
+ `);
532
+ await createFile(join(tempDir, "sub", "agents", "worker.yaml"), `
533
+ name: worker
534
+ model: agent-model
535
+ max_turns: 10
536
+ `);
537
+ const result = await loadConfig(tempDir, { env: {}, envFile: false });
538
+ expect(result.agents[0].model).toBe("agent-model");
539
+ expect(result.agents[0].max_turns).toBe(10);
540
+ });
541
+ it("per-agent overrides from sub-fleet agents entry override agent config", async () => {
542
+ // Priority level 4 (per-agent overrides from sub-fleet's agents entry)
543
+ await createFile(join(tempDir, "herdctl.yaml"), `
544
+ version: 1
545
+ fleets:
546
+ - path: ./sub/herdctl.yaml
547
+ `);
548
+ await createFile(join(tempDir, "sub", "herdctl.yaml"), `
549
+ version: 1
550
+ fleet:
551
+ name: sub
552
+ agents:
553
+ - path: ./agents/worker.yaml
554
+ overrides:
555
+ model: per-agent-override
556
+ max_turns: 999
557
+ `);
558
+ await createFile(join(tempDir, "sub", "agents", "worker.yaml"), `
559
+ name: worker
560
+ model: agent-model
561
+ max_turns: 10
562
+ `);
563
+ const result = await loadConfig(tempDir, { env: {}, envFile: false });
564
+ // Per-agent overrides win
565
+ expect(result.agents[0].model).toBe("per-agent-override");
566
+ expect(result.agents[0].max_turns).toBe(999);
567
+ });
568
+ it("per-fleet defaults override from super-fleet forcefully overrides sub-fleet defaults", async () => {
569
+ // Priority level 5: per-fleet overrides on the fleets entry
570
+ await createFile(join(tempDir, "herdctl.yaml"), `
571
+ version: 1
572
+ defaults:
573
+ model: super-default
574
+ fleets:
575
+ - path: ./sub/herdctl.yaml
576
+ overrides:
577
+ defaults:
578
+ model: forced-override-model
579
+ `);
580
+ await createFile(join(tempDir, "sub", "herdctl.yaml"), `
581
+ version: 1
582
+ fleet:
583
+ name: sub
584
+ defaults:
585
+ model: sub-model
586
+ max_turns: 50
587
+ agents:
588
+ - path: ./agents/worker.yaml
589
+ `);
590
+ await createFile(join(tempDir, "sub", "agents", "worker.yaml"), "name: worker");
591
+ const result = await loadConfig(tempDir, { env: {}, envFile: false });
592
+ // forced-override-model from per-fleet overrides should win
593
+ expect(result.agents[0].model).toBe("forced-override-model");
594
+ // max_turns still from sub-fleet defaults
595
+ expect(result.agents[0].max_turns).toBe(50);
596
+ });
597
+ it("full 5-level priority chain with all levels set", async () => {
598
+ // All 5 levels set model, verify highest applicable priority wins
599
+ await createFile(join(tempDir, "herdctl.yaml"), `
600
+ version: 1
601
+ defaults:
602
+ model: level1-super-default
603
+ fleets:
604
+ - path: ./sub/herdctl.yaml
605
+ overrides:
606
+ defaults:
607
+ model: level5-fleet-override
608
+ `);
609
+ await createFile(join(tempDir, "sub", "herdctl.yaml"), `
610
+ version: 1
611
+ fleet:
612
+ name: sub
613
+ defaults:
614
+ model: level2-sub-default
615
+ agents:
616
+ - path: ./agents/worker.yaml
617
+ overrides:
618
+ model: level4-per-agent-override
619
+ `);
620
+ await createFile(join(tempDir, "sub", "agents", "worker.yaml"), `
621
+ name: worker
622
+ model: level3-agent-own
623
+ `);
624
+ const result = await loadConfig(tempDir, { env: {}, envFile: false });
625
+ // The merge order: effective defaults = deepMerge(super-default, sub-default + fleet-override)
626
+ // Then: mergeAgentConfig(effective, agent) => agent's own model wins over defaults
627
+ // Then: per-agent overrides are applied last on the agent level
628
+ // So level4-per-agent-override should win over level3-agent-own
629
+ expect(result.agents[0].model).toBe("level4-per-agent-override");
630
+ });
631
+ });
632
+ describe("multi-level defaults inheritance (fixture-based)", () => {
633
+ it("sub-fleet model wins over super-fleet model for inheriting agent", async () => {
634
+ const result = await loadConfig(join(fixturesDir, "defaults-cascade", "root.yaml"), { env: {}, envFile: false });
635
+ const inheritor = result.agents.find((a) => a.name === "inheritor");
636
+ expect(inheritor).toBeDefined();
637
+ // sub-fleet model (sub-model) overrides super-fleet model (super-model)
638
+ expect(inheritor.model).toBe("sub-model");
639
+ // max_turns from super-fleet gap-fills
640
+ expect(inheritor.max_turns).toBe(200);
641
+ });
642
+ it("agent own model wins over both fleet defaults", async () => {
643
+ const result = await loadConfig(join(fixturesDir, "defaults-cascade", "root.yaml"), { env: {}, envFile: false });
644
+ const ownModel = result.agents.find((a) => a.name === "own-model");
645
+ expect(ownModel).toBeDefined();
646
+ // Agent sets its own model and max_turns
647
+ expect(ownModel.model).toBe("agent-model");
648
+ expect(ownModel.max_turns).toBe(5);
649
+ });
650
+ });
651
+ describe("3-level nesting defaults (fixture-based)", () => {
652
+ it("correct model reaches agents at each level in deep nesting", async () => {
653
+ const result = await loadConfig(join(fixturesDir, "deep-nesting", "root.yaml"), { env: {}, envFile: false });
654
+ // root-agent: gets root defaults (model: root-model, max_turns: 100)
655
+ const rootAgent = result.agents.find((a) => a.name === "root-agent");
656
+ expect(rootAgent.model).toBe("root-model");
657
+ expect(rootAgent.max_turns).toBe(100);
658
+ // l1-agent: level1 defaults override root defaults (model: level1-model, max_turns: 50)
659
+ const l1Agent = result.agents.find((a) => a.name === "l1-agent");
660
+ expect(l1Agent.model).toBe("level1-model");
661
+ expect(l1Agent.max_turns).toBe(50);
662
+ // l2-agent: level2 defaults override (model: level2-model), max_turns from level1 (50)
663
+ const l2Agent = result.agents.find((a) => a.name === "l2-agent");
664
+ expect(l2Agent.model).toBe("level2-model");
665
+ expect(l2Agent.max_turns).toBe(50);
666
+ // l3-agent: level3 sets no defaults, so inherits from level2 (model: level2-model, max_turns: 50)
667
+ const l3Agent = result.agents.find((a) => a.name === "l3-agent");
668
+ expect(l3Agent.model).toBe("level2-model");
669
+ expect(l3Agent.max_turns).toBe(50);
670
+ });
671
+ });
672
+ describe("defaults merging with no defaults", () => {
673
+ it("agent with no fleet defaults gets no extra fields", async () => {
674
+ await createFile(join(tempDir, "herdctl.yaml"), `
675
+ version: 1
676
+ fleets:
677
+ - path: ./sub/herdctl.yaml
678
+ `);
679
+ await createFile(join(tempDir, "sub", "herdctl.yaml"), `
680
+ version: 1
681
+ fleet:
682
+ name: sub
683
+ agents:
684
+ - path: ./agents/worker.yaml
685
+ `);
686
+ await createFile(join(tempDir, "sub", "agents", "worker.yaml"), "name: worker");
687
+ const result = await loadConfig(tempDir, { env: {}, envFile: false });
688
+ // No defaults at any level, so model and max_turns should be undefined
689
+ expect(result.agents[0].model).toBeUndefined();
690
+ expect(result.agents[0].max_turns).toBeUndefined();
691
+ });
692
+ it("super-fleet defaults do not override sub-fleet agent explicit values", async () => {
693
+ await createFile(join(tempDir, "herdctl.yaml"), `
694
+ version: 1
695
+ defaults:
696
+ model: super-model
697
+ max_turns: 999
698
+ fleets:
699
+ - path: ./sub/herdctl.yaml
700
+ `);
701
+ await createFile(join(tempDir, "sub", "herdctl.yaml"), `
702
+ version: 1
703
+ fleet:
704
+ name: sub
705
+ agents:
706
+ - path: ./agents/worker.yaml
707
+ `);
708
+ await createFile(join(tempDir, "sub", "agents", "worker.yaml"), `
709
+ name: worker
710
+ model: my-model
711
+ max_turns: 5
712
+ `);
713
+ const result = await loadConfig(tempDir, { env: {}, envFile: false });
714
+ // Agent's own values should win
715
+ expect(result.agents[0].model).toBe("my-model");
716
+ expect(result.agents[0].max_turns).toBe(5);
717
+ });
718
+ });
719
+ describe("defaults merging with per-fleet override on defaults", () => {
720
+ it("per-fleet overrides on defaults merge into sub-fleet defaults before agent application", async () => {
721
+ // Super-fleet overrides sub-fleet's defaults via fleet-level overrides
722
+ await createFile(join(tempDir, "herdctl.yaml"), `
723
+ version: 1
724
+ fleets:
725
+ - path: ./sub/herdctl.yaml
726
+ overrides:
727
+ defaults:
728
+ max_turns: 777
729
+ `);
730
+ await createFile(join(tempDir, "sub", "herdctl.yaml"), `
731
+ version: 1
732
+ fleet:
733
+ name: sub
734
+ defaults:
735
+ model: sub-model
736
+ max_turns: 50
737
+ agents:
738
+ - path: ./agents/worker.yaml
739
+ `);
740
+ await createFile(join(tempDir, "sub", "agents", "worker.yaml"), "name: worker");
741
+ const result = await loadConfig(tempDir, { env: {}, envFile: false });
742
+ // Fleet-level override replaces sub-fleet's max_turns
743
+ expect(result.agents[0].max_turns).toBe(777);
744
+ // sub-fleet model still applies
745
+ expect(result.agents[0].model).toBe("sub-model");
746
+ });
747
+ it("per-fleet defaults override applies to all agents in the sub-fleet", async () => {
748
+ await createFile(join(tempDir, "herdctl.yaml"), `
749
+ version: 1
750
+ fleets:
751
+ - path: ./sub/herdctl.yaml
752
+ overrides:
753
+ defaults:
754
+ model: forced-model
755
+ `);
756
+ await createFile(join(tempDir, "sub", "herdctl.yaml"), `
757
+ version: 1
758
+ fleet:
759
+ name: sub
760
+ defaults:
761
+ model: sub-model
762
+ agents:
763
+ - path: ./agents/a.yaml
764
+ - path: ./agents/b.yaml
765
+ `);
766
+ await createFile(join(tempDir, "sub", "agents", "a.yaml"), "name: agent-a");
767
+ await createFile(join(tempDir, "sub", "agents", "b.yaml"), "name: agent-b");
768
+ const result = await loadConfig(tempDir, { env: {}, envFile: false });
769
+ // Both agents should get the forced model from fleet-level override
770
+ for (const agent of result.agents) {
771
+ expect(agent.model).toBe("forced-model");
772
+ }
773
+ });
774
+ });
775
+ });
776
+ // =============================================================================
777
+ // Structural Edge Case Tests
778
+ // =============================================================================
779
+ describe("fleet structural edge cases", () => {
780
+ let tempDir;
781
+ beforeEach(async () => {
782
+ tempDir = await createTempDir();
783
+ });
784
+ afterEach(async () => {
785
+ await rm(tempDir, { recursive: true, force: true });
786
+ });
787
+ describe("empty/missing fleets and agents", () => {
788
+ it("empty fleets array behaves exactly as no fleets", async () => {
789
+ await createFile(join(tempDir, "herdctl.yaml"), `
790
+ version: 1
791
+ fleets: []
792
+ agents:
793
+ - path: ./agents/worker.yaml
794
+ `);
795
+ await createFile(join(tempDir, "agents", "worker.yaml"), "name: worker");
796
+ const result = await loadConfig(tempDir, { env: {}, envFile: false });
797
+ expect(result.agents).toHaveLength(1);
798
+ expect(result.agents[0].qualifiedName).toBe("worker");
799
+ expect(result.agents[0].fleetPath).toEqual([]);
800
+ });
801
+ it("fleet with fleets but no agents is valid", async () => {
802
+ const result = await loadConfig(join(fixturesDir, "fleets-only", "root.yaml"), { env: {}, envFile: false });
803
+ expect(result.agents).toHaveLength(2);
804
+ expect(result.agents.find((a) => a.name === "worker-a")).toBeDefined();
805
+ expect(result.agents.find((a) => a.name === "worker-b")).toBeDefined();
806
+ });
807
+ it("fleet with agents but no fleets behaves exactly as before", async () => {
808
+ await createFile(join(tempDir, "herdctl.yaml"), `
809
+ version: 1
810
+ agents:
811
+ - path: ./agents/a.yaml
812
+ - path: ./agents/b.yaml
813
+ `);
814
+ await createFile(join(tempDir, "agents", "a.yaml"), "name: agent-a");
815
+ await createFile(join(tempDir, "agents", "b.yaml"), "name: agent-b");
816
+ const result = await loadConfig(tempDir, { env: {}, envFile: false });
817
+ expect(result.agents).toHaveLength(2);
818
+ for (const agent of result.agents) {
819
+ expect(agent.fleetPath).toEqual([]);
820
+ expect(agent.qualifiedName).toBe(agent.name);
821
+ }
822
+ });
823
+ it("fleet with no agents and no fleets produces empty agent list", async () => {
824
+ await createFile(join(tempDir, "herdctl.yaml"), `
825
+ version: 1
826
+ `);
827
+ const result = await loadConfig(tempDir, { env: {}, envFile: false });
828
+ expect(result.agents).toEqual([]);
829
+ });
830
+ it("sub-fleet with no agents is valid", async () => {
831
+ await createFile(join(tempDir, "herdctl.yaml"), `
832
+ version: 1
833
+ fleets:
834
+ - path: ./sub/herdctl.yaml
835
+ agents:
836
+ - path: ./agents/root-agent.yaml
837
+ `);
838
+ await createFile(join(tempDir, "sub", "herdctl.yaml"), `
839
+ version: 1
840
+ fleet:
841
+ name: empty-sub
842
+ agents: []
843
+ `);
844
+ await createFile(join(tempDir, "agents", "root-agent.yaml"), "name: root-agent");
845
+ const result = await loadConfig(tempDir, { env: {}, envFile: false });
846
+ // Only root-agent, nothing from empty sub-fleet
847
+ expect(result.agents).toHaveLength(1);
848
+ expect(result.agents[0].name).toBe("root-agent");
849
+ });
850
+ });
851
+ describe("deeply nested fleets", () => {
852
+ it("4-level nesting produces correct qualified names (fixture-based)", async () => {
853
+ const result = await loadConfig(join(fixturesDir, "deep-nesting", "root.yaml"), { env: {}, envFile: false });
854
+ expect(result.agents).toHaveLength(4);
855
+ // Verify qualified names for all levels
856
+ const names = result.agents.map((a) => a.qualifiedName).sort();
857
+ expect(names).toEqual([
858
+ "level1.l1-agent",
859
+ "level1.level2.l2-agent",
860
+ "level1.level2.level3.l3-agent",
861
+ "root-agent",
862
+ ]);
863
+ });
864
+ it("fleetPath arrays grow correctly with depth", async () => {
865
+ const result = await loadConfig(join(fixturesDir, "deep-nesting", "root.yaml"), { env: {}, envFile: false });
866
+ const l3Agent = result.agents.find((a) => a.name === "l3-agent");
867
+ expect(l3Agent.fleetPath).toEqual(["level1", "level2", "level3"]);
868
+ expect(l3Agent.fleetPath).toHaveLength(3);
869
+ });
870
+ });
871
+ describe("fleet with only fleets (pure delegation)", () => {
872
+ it("all agents come from sub-fleets, none from root (fixture-based)", async () => {
873
+ const result = await loadConfig(join(fixturesDir, "fleets-only", "root.yaml"), { env: {}, envFile: false });
874
+ // All agents should have non-empty fleetPath
875
+ for (const agent of result.agents) {
876
+ expect(agent.fleetPath.length).toBeGreaterThan(0);
877
+ }
878
+ });
879
+ it("qualified names include sub-fleet names for delegation fleet", async () => {
880
+ const result = await loadConfig(join(fixturesDir, "fleets-only", "root.yaml"), { env: {}, envFile: false });
881
+ const workerA = result.agents.find((a) => a.name === "worker-a");
882
+ const workerB = result.agents.find((a) => a.name === "worker-b");
883
+ expect(workerA.qualifiedName).toBe("sub-a.worker-a");
884
+ expect(workerB.qualifiedName).toBe("sub-b.worker-b");
885
+ });
886
+ });
887
+ describe("fleet name collision edge cases", () => {
888
+ it("same name at different levels does NOT collide", async () => {
889
+ // "same-name" at level 1, and "same-name" nested inside another fleet at level 2
890
+ // These are at different levels so no collision
891
+ await createFile(join(tempDir, "herdctl.yaml"), `
892
+ version: 1
893
+ fleets:
894
+ - path: ./fleet-a/herdctl.yaml
895
+ name: fleet-a
896
+ - path: ./fleet-b/herdctl.yaml
897
+ name: fleet-b
898
+ `);
899
+ await createFile(join(tempDir, "fleet-a", "herdctl.yaml"), `
900
+ version: 1
901
+ fleets:
902
+ - path: ./nested/herdctl.yaml
903
+ name: same-name
904
+ `);
905
+ await createFile(join(tempDir, "fleet-a", "nested", "herdctl.yaml"), `
906
+ version: 1
907
+ agents:
908
+ - path: ./agents/worker.yaml
909
+ `);
910
+ await createFile(join(tempDir, "fleet-a", "nested", "agents", "worker.yaml"), "name: worker-a-nested");
911
+ await createFile(join(tempDir, "fleet-b", "herdctl.yaml"), `
912
+ version: 1
913
+ fleets:
914
+ - path: ./nested/herdctl.yaml
915
+ name: same-name
916
+ `);
917
+ await createFile(join(tempDir, "fleet-b", "nested", "herdctl.yaml"), `
918
+ version: 1
919
+ agents:
920
+ - path: ./agents/worker.yaml
921
+ `);
922
+ await createFile(join(tempDir, "fleet-b", "nested", "agents", "worker.yaml"), "name: worker-b-nested");
923
+ // Should NOT throw — "same-name" is at different levels (under fleet-a vs fleet-b)
924
+ const result = await loadConfig(tempDir, { env: {}, envFile: false });
925
+ expect(result.agents).toHaveLength(2);
926
+ expect(result.agents.find((a) => a.qualifiedName === "fleet-a.same-name.worker-a-nested")).toBeDefined();
927
+ expect(result.agents.find((a) => a.qualifiedName === "fleet-b.same-name.worker-b-nested")).toBeDefined();
928
+ });
929
+ it("directory-derived names that collide are detected", async () => {
930
+ // Two sub-fleets in directories both named "project" with no explicit names
931
+ await createFile(join(tempDir, "herdctl.yaml"), `
932
+ version: 1
933
+ fleets:
934
+ - path: ./path-a/project/herdctl.yaml
935
+ - path: ./path-b/project/herdctl.yaml
936
+ `);
937
+ await createFile(join(tempDir, "path-a", "project", "herdctl.yaml"), `
938
+ version: 1
939
+ agents: []
940
+ `);
941
+ await createFile(join(tempDir, "path-b", "project", "herdctl.yaml"), `
942
+ version: 1
943
+ agents: []
944
+ `);
945
+ // Both resolve to "project" directory name => collision
946
+ await expect(loadConfig(tempDir, { env: {}, envFile: false })).rejects.toThrow(FleetNameCollisionError);
947
+ });
948
+ it("collision between explicit name and directory-derived name", async () => {
949
+ // One fleet has explicit name "myfleet", another's directory derives to "myfleet"
950
+ await createFile(join(tempDir, "herdctl.yaml"), `
951
+ version: 1
952
+ fleets:
953
+ - path: ./first/herdctl.yaml
954
+ name: myfleet
955
+ - path: ./myfleet/herdctl.yaml
956
+ `);
957
+ await createFile(join(tempDir, "first", "herdctl.yaml"), `
958
+ version: 1
959
+ agents: []
960
+ `);
961
+ await createFile(join(tempDir, "myfleet", "herdctl.yaml"), `
962
+ version: 1
963
+ agents: []
964
+ `);
965
+ await expect(loadConfig(tempDir, { env: {}, envFile: false })).rejects.toThrow(FleetNameCollisionError);
966
+ });
967
+ });
968
+ describe("mixed root agents and sub-fleet agents", () => {
969
+ it("root agents and sub-fleet agents coexist correctly", async () => {
970
+ await createFile(join(tempDir, "herdctl.yaml"), `
971
+ version: 1
972
+ fleets:
973
+ - path: ./sub/herdctl.yaml
974
+ agents:
975
+ - path: ./agents/root-worker.yaml
976
+ `);
977
+ await createFile(join(tempDir, "sub", "herdctl.yaml"), `
978
+ version: 1
979
+ fleet:
980
+ name: sub
981
+ agents:
982
+ - path: ./agents/sub-worker.yaml
983
+ `);
984
+ await createFile(join(tempDir, "agents", "root-worker.yaml"), "name: root-worker");
985
+ await createFile(join(tempDir, "sub", "agents", "sub-worker.yaml"), "name: sub-worker");
986
+ const result = await loadConfig(tempDir, { env: {}, envFile: false });
987
+ expect(result.agents).toHaveLength(2);
988
+ const rootWorker = result.agents.find((a) => a.name === "root-worker");
989
+ const subWorker = result.agents.find((a) => a.name === "sub-worker");
990
+ expect(rootWorker.fleetPath).toEqual([]);
991
+ expect(rootWorker.qualifiedName).toBe("root-worker");
992
+ expect(subWorker.fleetPath).toEqual(["sub"]);
993
+ expect(subWorker.qualifiedName).toBe("sub.sub-worker");
994
+ });
995
+ it("root defaults apply to root agents but not sub-fleet agents with their own defaults", async () => {
996
+ await createFile(join(tempDir, "herdctl.yaml"), `
997
+ version: 1
998
+ defaults:
999
+ model: root-model
1000
+ fleets:
1001
+ - path: ./sub/herdctl.yaml
1002
+ agents:
1003
+ - path: ./agents/root-worker.yaml
1004
+ `);
1005
+ await createFile(join(tempDir, "sub", "herdctl.yaml"), `
1006
+ version: 1
1007
+ fleet:
1008
+ name: sub
1009
+ defaults:
1010
+ model: sub-model
1011
+ agents:
1012
+ - path: ./agents/sub-worker.yaml
1013
+ `);
1014
+ await createFile(join(tempDir, "agents", "root-worker.yaml"), "name: root-worker");
1015
+ await createFile(join(tempDir, "sub", "agents", "sub-worker.yaml"), "name: sub-worker");
1016
+ const result = await loadConfig(tempDir, { env: {}, envFile: false });
1017
+ const rootWorker = result.agents.find((a) => a.name === "root-worker");
1018
+ const subWorker = result.agents.find((a) => a.name === "sub-worker");
1019
+ expect(rootWorker.model).toBe("root-model");
1020
+ expect(subWorker.model).toBe("sub-model");
1021
+ });
1022
+ });
1023
+ });
1024
+ //# sourceMappingURL=fleet-naming-and-defaults.test.js.map