macro-agent 0.1.10 → 0.1.12

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 (111) hide show
  1. package/CLAUDE.md +97 -0
  2. package/dist/acp/macro-agent.d.ts.map +1 -1
  3. package/dist/acp/macro-agent.js +42 -6
  4. package/dist/acp/macro-agent.js.map +1 -1
  5. package/dist/adapters/tasks-adapter.d.ts.map +1 -1
  6. package/dist/adapters/tasks-adapter.js +3 -0
  7. package/dist/adapters/tasks-adapter.js.map +1 -1
  8. package/dist/adapters/types.d.ts +1 -0
  9. package/dist/adapters/types.d.ts.map +1 -1
  10. package/dist/agent/agent-manager-v2.d.ts.map +1 -1
  11. package/dist/agent/agent-manager-v2.js +74 -11
  12. package/dist/agent/agent-manager-v2.js.map +1 -1
  13. package/dist/agent/agent-store.d.ts +10 -0
  14. package/dist/agent/agent-store.d.ts.map +1 -1
  15. package/dist/agent/agent-store.js +22 -0
  16. package/dist/agent/agent-store.js.map +1 -1
  17. package/dist/boot-v2.d.ts +88 -1
  18. package/dist/boot-v2.d.ts.map +1 -1
  19. package/dist/boot-v2.js +343 -7
  20. package/dist/boot-v2.js.map +1 -1
  21. package/dist/cli/acp.js +4 -0
  22. package/dist/cli/acp.js.map +1 -1
  23. package/dist/lifecycle/cascade.d.ts +25 -2
  24. package/dist/lifecycle/cascade.d.ts.map +1 -1
  25. package/dist/lifecycle/cascade.js +70 -2
  26. package/dist/lifecycle/cascade.js.map +1 -1
  27. package/dist/map/cascade-action-handler.d.ts +24 -0
  28. package/dist/map/cascade-action-handler.d.ts.map +1 -0
  29. package/dist/map/cascade-action-handler.js +170 -0
  30. package/dist/map/cascade-action-handler.js.map +1 -0
  31. package/dist/map/cascade-bridge.d.ts.map +1 -1
  32. package/dist/map/cascade-bridge.js +42 -5
  33. package/dist/map/cascade-bridge.js.map +1 -1
  34. package/dist/map/coordination-handler.d.ts.map +1 -1
  35. package/dist/map/coordination-handler.js +12 -1
  36. package/dist/map/coordination-handler.js.map +1 -1
  37. package/dist/map/server.d.ts.map +1 -1
  38. package/dist/map/server.js +172 -1
  39. package/dist/map/server.js.map +1 -1
  40. package/dist/map/sidecar.d.ts.map +1 -1
  41. package/dist/map/sidecar.js +18 -2
  42. package/dist/map/sidecar.js.map +1 -1
  43. package/dist/map/types.d.ts +2 -0
  44. package/dist/map/types.d.ts.map +1 -1
  45. package/dist/teams/seed-defaults.d.ts.map +1 -1
  46. package/dist/teams/seed-defaults.js +6 -2
  47. package/dist/teams/seed-defaults.js.map +1 -1
  48. package/dist/teams/team-loader.d.ts.map +1 -1
  49. package/dist/teams/team-loader.js +17 -1
  50. package/dist/teams/team-loader.js.map +1 -1
  51. package/dist/workspace/git-cascade-adapter.d.ts +1 -1
  52. package/dist/workspace/git-cascade-adapter.d.ts.map +1 -1
  53. package/dist/workspace/git-cascade-adapter.js +26 -0
  54. package/dist/workspace/git-cascade-adapter.js.map +1 -1
  55. package/dist/workspace/landing/merge-to-parent.d.ts.map +1 -1
  56. package/dist/workspace/landing/merge-to-parent.js +1 -0
  57. package/dist/workspace/landing/merge-to-parent.js.map +1 -1
  58. package/dist/workspace/recovery/spawn-resolver.d.ts.map +1 -1
  59. package/dist/workspace/recovery/spawn-resolver.js +8 -1
  60. package/dist/workspace/recovery/spawn-resolver.js.map +1 -1
  61. package/dist/workspace/types-v3.d.ts +7 -0
  62. package/dist/workspace/types-v3.d.ts.map +1 -1
  63. package/dist/workspace/types-v3.js.map +1 -1
  64. package/dist/workspace/types.d.ts +17 -0
  65. package/dist/workspace/types.d.ts.map +1 -1
  66. package/dist/workspace/workspace-manager.d.ts +9 -0
  67. package/dist/workspace/workspace-manager.d.ts.map +1 -1
  68. package/dist/workspace/workspace-manager.js +45 -2
  69. package/dist/workspace/workspace-manager.js.map +1 -1
  70. package/docs/design/task-dispatcher.md +880 -0
  71. package/package.json +3 -3
  72. package/src/__tests__/boot-v2.test.ts +435 -0
  73. package/src/__tests__/e2e/acp-over-map.e2e.test.ts +92 -0
  74. package/src/__tests__/e2e/bootstrap.e2e.test.ts +319 -0
  75. package/src/__tests__/e2e/dispatch-coordination.e2e.test.ts +495 -0
  76. package/src/__tests__/e2e/dispatch-live.e2e.test.ts +564 -0
  77. package/src/__tests__/e2e/dispatch-opentasks.e2e.test.ts +496 -0
  78. package/src/__tests__/e2e/dispatch-phase2-live.e2e.test.ts +456 -0
  79. package/src/__tests__/e2e/dispatch-phase2.e2e.test.ts +386 -0
  80. package/src/__tests__/e2e/dispatch.e2e.test.ts +376 -0
  81. package/src/acp/macro-agent.ts +41 -6
  82. package/src/adapters/__tests__/tasks-adapter.test.ts +1 -0
  83. package/src/adapters/tasks-adapter.ts +3 -0
  84. package/src/adapters/types.ts +1 -0
  85. package/src/agent/__tests__/agent-store.test.ts +52 -0
  86. package/src/agent/agent-manager-v2.ts +79 -11
  87. package/src/agent/agent-store.ts +24 -0
  88. package/src/boot-v2.ts +522 -35
  89. package/src/cli/acp.ts +4 -0
  90. package/src/lifecycle/__tests__/cascade-consolidation.test.ts +240 -0
  91. package/src/lifecycle/cascade.ts +77 -2
  92. package/src/map/__tests__/emit-event.test.ts +71 -0
  93. package/src/map/cascade-action-handler.ts +205 -0
  94. package/src/map/cascade-bridge.ts +43 -5
  95. package/src/map/coordination-handler.ts +13 -1
  96. package/src/map/server.ts +178 -1
  97. package/src/map/sidecar.ts +19 -2
  98. package/src/map/types.ts +3 -0
  99. package/src/teams/seed-defaults.ts +6 -2
  100. package/src/teams/team-loader.ts +18 -1
  101. package/src/workspace/__tests__/land-dispatch.test.ts +214 -0
  102. package/src/workspace/__tests__/self-driving-yaml.test.ts +10 -2
  103. package/src/workspace/git-cascade-adapter.ts +30 -3
  104. package/src/workspace/landing/__tests__/strategies.test.ts +42 -0
  105. package/src/workspace/landing/merge-to-parent.ts +1 -0
  106. package/src/workspace/recovery/spawn-resolver.ts +8 -1
  107. package/src/workspace/types-v3.ts +7 -0
  108. package/src/workspace/types.ts +20 -0
  109. package/src/workspace/workspace-manager.ts +61 -2
  110. package/templates/teams/self-driving/team.yaml +142 -0
  111. package/tsconfig.json +2 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "macro-agent",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "Interact with multiple agents as if they were a single agent.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -49,8 +49,7 @@
49
49
  },
50
50
  "dependencies": {
51
51
  "@modelcontextprotocol/sdk": "^1.25.1",
52
- "@multi-agent-protocol/sdk": "^0.1.11",
53
- "@sudocode-ai/claude-code-acp": "^0.13.9",
52
+ "@multi-agent-protocol/sdk": "^0.1.12",
54
53
  "acp-factory": "^0.1.12",
55
54
  "agent-inbox": "^0.1.8",
56
55
  "better-sqlite3": "^12.5.0",
@@ -62,6 +61,7 @@
62
61
  "nanoid": "^5.0.0",
63
62
  "opentasks": "^0.0.3",
64
63
  "openteams": "^0.2.2",
64
+ "swarm-dispatch": "^0.3.0",
65
65
  "unique-names-generator": "^4.7.1",
66
66
  "ws": "^8.20.0",
67
67
  "zod": "^4.2.1"
@@ -193,4 +193,439 @@ describe("Boot V2", () => {
193
193
  // Prevent double-shutdown in afterEach
194
194
  system = null;
195
195
  });
196
+
197
+ describe("bootstrap", () => {
198
+ /**
199
+ * Wait for a coordinator to appear in the agent store. The bootstrap
200
+ * spawn is fired non-blocking (so boot doesn't gate on agent process
201
+ * startup), so direct `await bootV2(...)` returns before the coordinator
202
+ * exists. Poll with a short timeout to bridge the gap.
203
+ */
204
+ async function waitForCoordinator(
205
+ sys: MacroAgentSystemV2,
206
+ timeoutMs = 2000,
207
+ ): Promise<{ id: string; cwd?: string | null } | null> {
208
+ const deadline = Date.now() + timeoutMs;
209
+ while (Date.now() < deadline) {
210
+ const agents = sys.agentStore.listAgents({ role: "coordinator" });
211
+ if (agents.length > 0) return agents[0];
212
+ await new Promise((r) => setTimeout(r, 25));
213
+ }
214
+ return null;
215
+ }
216
+
217
+ afterEach(() => {
218
+ delete process.env.MACRO_BOOTSTRAP_COORDINATOR;
219
+ delete process.env.MACRO_BOOTSTRAP_CWD;
220
+ delete process.env.MACRO_BOOTSTRAP_REHYDRATE;
221
+ });
222
+
223
+ it("does not spawn when bootstrap is unset", async () => {
224
+ testDir = createTestDir();
225
+ system = await bootV2({
226
+ cwd: testDir,
227
+ baseDir: testDir,
228
+ inbox: { socketPath: path.join(testDir, "inbox.sock") },
229
+ });
230
+ // Give any rogue spawn a chance to fire before asserting absence.
231
+ await new Promise((r) => setTimeout(r, 250));
232
+ const agents = system.agentStore.listAgents({ role: "coordinator" });
233
+ expect(agents).toHaveLength(0);
234
+ });
235
+
236
+ it("spawns a coordinator when bootstrap.coordinator: true", async () => {
237
+ testDir = createTestDir();
238
+ system = await bootV2({
239
+ cwd: testDir,
240
+ baseDir: testDir,
241
+ inbox: { socketPath: path.join(testDir, "inbox.sock") },
242
+ bootstrap: { coordinator: true },
243
+ });
244
+ const agent = await waitForCoordinator(system);
245
+ expect(agent).not.toBeNull();
246
+ expect(agent!.cwd).toBe(testDir);
247
+ });
248
+
249
+ it("uses bootstrap.coordinator.cwd when provided", async () => {
250
+ testDir = createTestDir();
251
+ const projectDir = path.join(testDir, "project");
252
+ fs.mkdirSync(projectDir, { recursive: true });
253
+
254
+ system = await bootV2({
255
+ cwd: testDir,
256
+ baseDir: testDir,
257
+ inbox: { socketPath: path.join(testDir, "inbox.sock") },
258
+ bootstrap: { coordinator: { cwd: projectDir } },
259
+ });
260
+ const agent = await waitForCoordinator(system);
261
+ expect(agent).not.toBeNull();
262
+ expect(agent!.cwd).toBe(projectDir);
263
+ });
264
+
265
+ it("env-var bridge: MACRO_BOOTSTRAP_COORDINATOR=true triggers bootstrap", async () => {
266
+ testDir = createTestDir();
267
+ process.env.MACRO_BOOTSTRAP_COORDINATOR = "true";
268
+
269
+ system = await bootV2({
270
+ cwd: testDir,
271
+ baseDir: testDir,
272
+ inbox: { socketPath: path.join(testDir, "inbox.sock") },
273
+ });
274
+ const agent = await waitForCoordinator(system);
275
+ expect(agent).not.toBeNull();
276
+ expect(agent!.cwd).toBe(testDir);
277
+ });
278
+
279
+ it("env-var bridge: MACRO_BOOTSTRAP_CWD overrides default cwd", async () => {
280
+ testDir = createTestDir();
281
+ const projectDir = path.join(testDir, "project");
282
+ fs.mkdirSync(projectDir, { recursive: true });
283
+
284
+ process.env.MACRO_BOOTSTRAP_COORDINATOR = "true";
285
+ process.env.MACRO_BOOTSTRAP_CWD = projectDir;
286
+
287
+ system = await bootV2({
288
+ cwd: testDir,
289
+ baseDir: testDir,
290
+ inbox: { socketPath: path.join(testDir, "inbox.sock") },
291
+ });
292
+ const agent = await waitForCoordinator(system);
293
+ expect(agent!.cwd).toBe(projectDir);
294
+ });
295
+
296
+ it("programmatic bootstrap wins over env var", async () => {
297
+ testDir = createTestDir();
298
+ const programmaticDir = path.join(testDir, "programmatic");
299
+ const envDir = path.join(testDir, "env");
300
+ fs.mkdirSync(programmaticDir, { recursive: true });
301
+ fs.mkdirSync(envDir, { recursive: true });
302
+
303
+ process.env.MACRO_BOOTSTRAP_COORDINATOR = "true";
304
+ process.env.MACRO_BOOTSTRAP_CWD = envDir;
305
+
306
+ system = await bootV2({
307
+ cwd: testDir,
308
+ baseDir: testDir,
309
+ inbox: { socketPath: path.join(testDir, "inbox.sock") },
310
+ bootstrap: { coordinator: { cwd: programmaticDir } },
311
+ });
312
+ const agent = await waitForCoordinator(system);
313
+ // Programmatic value wins; env-bridge skipped because field already set.
314
+ expect(agent!.cwd).toBe(programmaticDir);
315
+ });
316
+
317
+ it("env-var bridge: MACRO_BOOTSTRAP_REHYDRATE sets rehydrate policy", async () => {
318
+ testDir = createTestDir();
319
+ process.env.MACRO_BOOTSTRAP_COORDINATOR = "true";
320
+ process.env.MACRO_BOOTSTRAP_REHYDRATE = "none";
321
+
322
+ // First boot creates a coordinator and persists it to agent-store.
323
+ const sys1 = await bootV2({
324
+ cwd: testDir,
325
+ baseDir: testDir,
326
+ inbox: { socketPath: path.join(testDir, "inbox.sock") },
327
+ });
328
+ const first = await waitForCoordinator(sys1);
329
+ expect(first).not.toBeNull();
330
+ await sys1.shutdown();
331
+
332
+ // Second boot with REHYDRATE=none should spawn a fresh coordinator
333
+ // rather than reviving the prior one — so we end up with two rows.
334
+ system = await bootV2({
335
+ cwd: testDir,
336
+ baseDir: testDir,
337
+ inbox: { socketPath: path.join(testDir, "inbox.sock") },
338
+ });
339
+ await waitForCoordinator(system);
340
+ const coordinators = system.agentStore
341
+ .listAgents({ role: "coordinator" })
342
+ .filter((a) => a.cwd === testDir);
343
+ expect(coordinators.length).toBeGreaterThanOrEqual(2);
344
+ });
345
+
346
+ it("rehydrate: 'none' spawns a fresh coordinator even when priors exist", async () => {
347
+ testDir = createTestDir();
348
+
349
+ const sys1 = await bootV2({
350
+ cwd: testDir,
351
+ baseDir: testDir,
352
+ inbox: { socketPath: path.join(testDir, "inbox.sock") },
353
+ bootstrap: { coordinator: true },
354
+ });
355
+ const first = await waitForCoordinator(sys1);
356
+ const firstId = first!.id;
357
+ await sys1.shutdown();
358
+
359
+ system = await bootV2({
360
+ cwd: testDir,
361
+ baseDir: testDir,
362
+ inbox: { socketPath: path.join(testDir, "inbox.sock") },
363
+ bootstrap: { coordinator: true, rehydrate: "none" },
364
+ });
365
+ await waitForCoordinator(system);
366
+ const coordinators = system.agentStore
367
+ .listAgents({ role: "coordinator" })
368
+ .filter((a) => a.cwd === testDir);
369
+ expect(coordinators.length).toBeGreaterThanOrEqual(2);
370
+ // Prior is still present (not reused), new coordinator has a
371
+ // different id.
372
+ const priorStill = coordinators.find((c) => c.id === firstId);
373
+ const fresh = coordinators.find((c) => c.id !== firstId);
374
+ expect(priorStill).toBeDefined();
375
+ expect(fresh).toBeDefined();
376
+ });
377
+
378
+ it("rehydrate: 'all' revives workers alongside the coordinator", async () => {
379
+ testDir = createTestDir();
380
+
381
+ // First boot — create a coordinator then a worker under it so the
382
+ // agent-store carries both records into the second boot.
383
+ const sys1 = await bootV2({
384
+ cwd: testDir,
385
+ baseDir: testDir,
386
+ inbox: { socketPath: path.join(testDir, "inbox.sock") },
387
+ bootstrap: { coordinator: true },
388
+ });
389
+ const coord = await waitForCoordinator(sys1);
390
+ expect(coord).not.toBeNull();
391
+
392
+ const worker = await sys1.agentManager.spawn({
393
+ task: "worker task",
394
+ role: "worker",
395
+ parent: coord!.id as any,
396
+ cwd: testDir,
397
+ });
398
+ const workerId = worker.id;
399
+
400
+ // Snapshot the worker's state before shutdown — it should still
401
+ // read 'running' because we never terminated it (mirrors the
402
+ // hosted-swarm-abrupt-restart case).
403
+ const preShutdown = sys1.agentStore.getAgent(workerId as any);
404
+ expect(preShutdown?.state).toBe("running");
405
+ await sys1.shutdown();
406
+
407
+ system = await bootV2({
408
+ cwd: testDir,
409
+ baseDir: testDir,
410
+ inbox: { socketPath: path.join(testDir, "inbox.sock") },
411
+ bootstrap: { coordinator: true, rehydrate: "all" },
412
+ });
413
+ // Give the non-blocking rehydration loop a chance to resume both
414
+ // the coordinator (depth 0) and the worker (depth 1).
415
+ await waitForCoordinator(system);
416
+ const deadline = Date.now() + 2000;
417
+ while (Date.now() < deadline) {
418
+ if (system.agentManager.hasActiveSession(workerId as any)) break;
419
+ await new Promise((r) => setTimeout(r, 25));
420
+ }
421
+ expect(system.agentManager.hasActiveSession(workerId as any)).toBe(true);
422
+ });
423
+
424
+ it("rehydrates prior coordinator instead of spawning a new one", async () => {
425
+ // Simulate the restart flow: boot once with bootstrap.coordinator,
426
+ // shut down (agent-store persists), then boot again at the same
427
+ // cwd/baseDir. The second boot should reuse the same agent id/name
428
+ // instead of creating a new one, so openhive-side "registered
429
+ // agents" stays stable across hosted-swarm revivals.
430
+ testDir = createTestDir();
431
+
432
+ const system1 = await bootV2({
433
+ cwd: testDir,
434
+ baseDir: testDir,
435
+ inbox: { socketPath: path.join(testDir, "inbox.sock") },
436
+ bootstrap: { coordinator: true },
437
+ });
438
+ const first = await waitForCoordinator(system1);
439
+ expect(first).not.toBeNull();
440
+ const firstId = first!.id;
441
+ await system1.shutdown();
442
+
443
+ // Second boot — same baseDir so agent-store is reused.
444
+ system = await bootV2({
445
+ cwd: testDir,
446
+ baseDir: testDir,
447
+ inbox: { socketPath: path.join(testDir, "inbox.sock") },
448
+ bootstrap: { coordinator: true },
449
+ });
450
+ const second = await waitForCoordinator(system);
451
+ expect(second).not.toBeNull();
452
+ // Same agent id → rehydrated, not re-spawned.
453
+ expect(second!.id).toBe(firstId);
454
+
455
+ // And only one coordinator for this cwd — no duplicates.
456
+ const coordinators = system.agentStore
457
+ .listAgents({ role: "coordinator" })
458
+ .filter((a) => a.cwd === testDir);
459
+ expect(coordinators).toHaveLength(1);
460
+ });
461
+ });
462
+
463
+ // ─── default baseDir isolation ─────────────────────────────────────
464
+ //
465
+ // Regression guard: without an explicit `baseDir`, macro-agent used to
466
+ // default to `~/.macro-agent/` — a singleton directory shared across
467
+ // every run on the box. Two instances in different projects would
468
+ // collide on `agents.db`, `inbox.db`, and `control.sock` (only one can
469
+ // bind the sockets). The default now compartmentalizes per-cwd via a
470
+ // stable hash, so sibling projects stay isolated while restarts in the
471
+ // same project still reuse their previous store.
472
+
473
+ describe("default baseDir (per-cwd isolation)", () => {
474
+ it("derives a stable cwd-hashed baseDir when none is provided", async () => {
475
+ testDir = createTestDir();
476
+ system = await bootV2({
477
+ cwd: testDir,
478
+ // baseDir omitted — want to verify the default picks a unique,
479
+ // stable path under ~/.macro-agent rather than the singleton.
480
+ inbox: { socketPath: path.join(testDir, "inbox.sock") },
481
+ });
482
+
483
+ // The expected path is `~/.macro-agent/inst_<12-hex>` where the hex
484
+ // is sha256(resolved(cwd)).slice(0, 12). Reproduce the computation
485
+ // here and assert the agent store landed there.
486
+ const crypto = await import("node:crypto");
487
+ const expectedId =
488
+ "inst_" +
489
+ crypto
490
+ .createHash("sha256")
491
+ .update(path.resolve(testDir))
492
+ .digest("hex")
493
+ .slice(0, 12);
494
+ const expectedDir = path.join(os.homedir(), ".macro-agent", expectedId);
495
+
496
+ expect(fs.existsSync(path.join(expectedDir, "agents.db"))).toBe(true);
497
+ expect(fs.existsSync(path.join(expectedDir, "inbox.db"))).toBe(true);
498
+
499
+ // Clean up the derived dir so repeated test runs don't leave state behind.
500
+ await system.shutdown();
501
+ system = null;
502
+ try { fs.rmSync(expectedDir, { recursive: true, force: true }); } catch { /* best-effort */ }
503
+ });
504
+
505
+ it("two instances in sibling cwds get distinct stores", async () => {
506
+ const a = createTestDir();
507
+ const b = createTestDir();
508
+
509
+ // Keep a reference to clean up afterwards; the outer afterEach only
510
+ // tracks `system` and a single `testDir`.
511
+ const sysA = await bootV2({
512
+ cwd: a,
513
+ inbox: { socketPath: path.join(a, "inbox.sock") },
514
+ });
515
+ const sysB = await bootV2({
516
+ cwd: b,
517
+ inbox: { socketPath: path.join(b, "inbox.sock") },
518
+ });
519
+
520
+ const crypto = await import("node:crypto");
521
+ const hash = (p: string) =>
522
+ "inst_" + crypto.createHash("sha256").update(path.resolve(p)).digest("hex").slice(0, 12);
523
+ const dirA = path.join(os.homedir(), ".macro-agent", hash(a));
524
+ const dirB = path.join(os.homedir(), ".macro-agent", hash(b));
525
+
526
+ expect(dirA).not.toBe(dirB);
527
+ expect(fs.existsSync(path.join(dirA, "agents.db"))).toBe(true);
528
+ expect(fs.existsSync(path.join(dirB, "agents.db"))).toBe(true);
529
+
530
+ await sysA.shutdown();
531
+ await sysB.shutdown();
532
+ for (const d of [dirA, dirB, a, b]) {
533
+ try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* best-effort */ }
534
+ }
535
+ });
536
+
537
+ it("explicit baseDir still overrides the derived default", async () => {
538
+ testDir = createTestDir();
539
+ const explicit = createTestDir();
540
+ system = await bootV2({
541
+ cwd: testDir,
542
+ baseDir: explicit,
543
+ inbox: { socketPath: path.join(explicit, "inbox.sock") },
544
+ });
545
+
546
+ // Store landed in the explicit dir, not under ~/.macro-agent/.
547
+ expect(fs.existsSync(path.join(explicit, "agents.db"))).toBe(true);
548
+
549
+ const crypto = await import("node:crypto");
550
+ const derivedId =
551
+ "inst_" +
552
+ crypto.createHash("sha256").update(path.resolve(testDir)).digest("hex").slice(0, 12);
553
+ const derivedDir = path.join(os.homedir(), ".macro-agent", derivedId);
554
+ // Ensure the default path wasn't touched.
555
+ expect(fs.existsSync(path.join(derivedDir, "agents.db"))).toBe(false);
556
+
557
+ try { fs.rmSync(explicit, { recursive: true, force: true }); } catch { /* best-effort */ }
558
+ });
559
+
560
+ it("explicit instanceId wins over the cwd-hash fallback", async () => {
561
+ testDir = createTestDir();
562
+ // Pick an id that starts with `test-` so a stray leak is easy to spot.
563
+ const id = `test-inst-${Date.now().toString(36)}`;
564
+ system = await bootV2({
565
+ cwd: testDir,
566
+ instanceId: id,
567
+ inbox: { socketPath: path.join(testDir, "inbox.sock") },
568
+ });
569
+
570
+ const explicitDir = path.join(os.homedir(), ".macro-agent", id);
571
+ expect(fs.existsSync(path.join(explicitDir, "agents.db"))).toBe(true);
572
+
573
+ // The cwd-hash default should NOT have been used.
574
+ const crypto = await import("node:crypto");
575
+ const derivedId =
576
+ "inst_" +
577
+ crypto.createHash("sha256").update(path.resolve(testDir)).digest("hex").slice(0, 12);
578
+ const derivedDir = path.join(os.homedir(), ".macro-agent", derivedId);
579
+ expect(fs.existsSync(path.join(derivedDir, "agents.db"))).toBe(false);
580
+
581
+ await system.shutdown();
582
+ system = null;
583
+ try { fs.rmSync(explicitDir, { recursive: true, force: true }); } catch { /* best-effort */ }
584
+ });
585
+
586
+ it("falls through to map.swarmId when no instanceId is set", async () => {
587
+ testDir = createTestDir();
588
+ const swarmId = `swarm-test-${Date.now().toString(36)}`;
589
+ system = await bootV2({
590
+ cwd: testDir,
591
+ // No instanceId — map.swarmId should win over the cwd hash.
592
+ // `map.enabled: false` keeps the sidecar from actually trying
593
+ // to connect; we only need the id to flow into baseDir selection.
594
+ map: { enabled: false, swarmId },
595
+ inbox: { socketPath: path.join(testDir, "inbox.sock") },
596
+ });
597
+
598
+ const swarmDir = path.join(os.homedir(), ".macro-agent", swarmId);
599
+ expect(fs.existsSync(path.join(swarmDir, "agents.db"))).toBe(true);
600
+
601
+ await system.shutdown();
602
+ system = null;
603
+ try { fs.rmSync(swarmDir, { recursive: true, force: true }); } catch { /* best-effort */ }
604
+ });
605
+
606
+ it("prefers explicit instanceId over map.swarmId", async () => {
607
+ testDir = createTestDir();
608
+ const id = `explicit-${Date.now().toString(36)}`;
609
+ const swarmId = `map-${Date.now().toString(36)}`;
610
+ system = await bootV2({
611
+ cwd: testDir,
612
+ instanceId: id,
613
+ map: { enabled: false, swarmId },
614
+ inbox: { socketPath: path.join(testDir, "inbox.sock") },
615
+ });
616
+
617
+ expect(fs.existsSync(
618
+ path.join(os.homedir(), ".macro-agent", id, "agents.db"),
619
+ )).toBe(true);
620
+ expect(fs.existsSync(
621
+ path.join(os.homedir(), ".macro-agent", swarmId, "agents.db"),
622
+ )).toBe(false);
623
+
624
+ await system.shutdown();
625
+ system = null;
626
+ try {
627
+ fs.rmSync(path.join(os.homedir(), ".macro-agent", id), { recursive: true, force: true });
628
+ } catch { /* best-effort */ }
629
+ });
630
+ });
196
631
  });
@@ -218,6 +218,98 @@ describe("ACP-over-MAP E2E", () => {
218
218
  // ignore
219
219
  }
220
220
  }, 15000);
221
+
222
+ // ─────────────────────────────────────────────────────────────────
223
+ // _macro/resumeAgent — durable resume by providerSessionId
224
+ // ─────────────────────────────────────────────────────────────────
225
+
226
+ it("_macro/resumeAgent returns error when providerSessionId is unknown", async () => {
227
+ const result = (await client!.callExtension("_macro/resumeAgent", {
228
+ providerSessionId: "psid-does-not-exist",
229
+ })) as { success: boolean; error?: string };
230
+
231
+ expect(result.success).toBe(false);
232
+ expect(result.error).toBeTruthy();
233
+ }, 10000);
234
+
235
+ it("_macro/resumeAgent returns error when neither id nor psid given", async () => {
236
+ const result = (await client!.callExtension("_macro/resumeAgent", {})) as {
237
+ success: boolean;
238
+ error?: string;
239
+ };
240
+
241
+ expect(result.success).toBe(false);
242
+ expect(result.error).toMatch(/providerSessionId or agentId/);
243
+ }, 10000);
244
+
245
+ it("_macro/resumeAgent resolves an active agent by providerSessionId (session-first)", async () => {
246
+ // Spawn an agent so the sessions table has a row with a provider_session_id.
247
+ // The mock acp-factory sets session.id = `session-mock-${Date.now()}`, which
248
+ // macro-agent stores as provider_session_id (see agent-manager-v2.ts:1136).
249
+ const spawnResult = (await client!.callExtension("_macro/spawnAgent", {
250
+ task: "resume target",
251
+ role: "worker",
252
+ })) as { agent: { id: string; localId?: string } };
253
+
254
+ // Give the lifecycle bridge a moment to register the agent in the store.
255
+ await new Promise((r) => setTimeout(r, 200));
256
+
257
+ // Read back the session record directly via the exposed AgentStore.
258
+ // The handler under test looks up agents by provider_session_id via the
259
+ // same path, so this also validates the store reverse-lookup end to end.
260
+ const agentStore = system.agentStore;
261
+ const allAgents = agentStore.listAgents();
262
+ const spawned = allAgents.find((a) => a.task === "resume target");
263
+ expect(spawned).toBeDefined();
264
+
265
+ const sessionRec = agentStore.getSession(spawned!.id);
266
+ expect(sessionRec).not.toBeNull();
267
+ expect(sessionRec!.provider_session_id).toBeTruthy();
268
+ const providerSessionId = sessionRec!.provider_session_id!;
269
+
270
+ // Call _macro/resumeAgent with the provider_session_id. Because the agent
271
+ // is still active (hasActiveSession === true), the handler takes the
272
+ // fast path and returns the live session info without re-spawning.
273
+ const result = (await client!.callExtension("_macro/resumeAgent", {
274
+ providerSessionId,
275
+ })) as {
276
+ success: boolean;
277
+ agent?: { id: string; localId: string; name?: string; role?: string };
278
+ acpSessionId?: string;
279
+ providerSessionId?: string;
280
+ error?: string;
281
+ };
282
+
283
+ expect(result.success).toBe(true);
284
+ expect(result.agent).toBeDefined();
285
+ expect(result.agent!.localId).toBe(spawned!.id);
286
+ expect(result.agent!.role).toBe("worker");
287
+ expect(result.acpSessionId).toBe(sessionRec!.session_id);
288
+ expect(result.providerSessionId).toBe(providerSessionId);
289
+ // peerMapId (agent.id) should resolve to a MAP-server ULID, not the
290
+ // local store id — this confirms MAPServer registration is in place.
291
+ expect(result.agent!.id).toBeTruthy();
292
+ }, 15000);
293
+
294
+ it("_macro/resumeAgent resolves by agentId (fallback path)", async () => {
295
+ // Use an existing agent from the previous test. Listing is stable within
296
+ // the same MAP server instance.
297
+ const agentStore = system.agentStore;
298
+ const agents = agentStore.listAgents();
299
+ expect(agents.length).toBeGreaterThan(0);
300
+ const target = agents[0];
301
+
302
+ const result = (await client!.callExtension("_macro/resumeAgent", {
303
+ agentId: target.id,
304
+ })) as {
305
+ success: boolean;
306
+ agent?: { id: string; localId: string };
307
+ error?: string;
308
+ };
309
+
310
+ expect(result.success).toBe(true);
311
+ expect(result.agent!.localId).toBe(target.id);
312
+ }, 15000);
221
313
  });
222
314
 
223
315
  describe("ACP-over-MAP E2E — MAP-level operations with agents", () => {