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
@@ -0,0 +1,376 @@
1
+ /**
2
+ * Task Dispatch E2E Tests (mocked agents)
3
+ *
4
+ * Tests dispatch boot wiring, configuration, and basic dispatch flow
5
+ * using mocked acp-factory and opentasks (no real agents or daemon).
6
+ *
7
+ * REQUIRES: RUN_E2E_TESTS=true
8
+ *
9
+ * Run with:
10
+ * RUN_E2E_TESTS=true npx vitest run --config vitest.e2e.config.ts src/__tests__/e2e/dispatch.e2e.test.ts
11
+ */
12
+
13
+ import {
14
+ describe,
15
+ it,
16
+ expect,
17
+ beforeEach,
18
+ afterEach,
19
+ vi,
20
+ } from "vitest";
21
+ import * as path from "path";
22
+ import * as os from "os";
23
+ import * as fs from "fs";
24
+ import { bootV2, type MacroAgentSystemV2 } from "../../boot-v2.js";
25
+
26
+ // ─────────────────────────────────────────────────────────────────
27
+ // Configuration
28
+ // ─────────────────────────────────────────────────────────────────
29
+
30
+ const RUN_E2E = !!process.env.RUN_E2E_TESTS;
31
+ const describeFn = RUN_E2E ? describe : describe.skip;
32
+
33
+ // ─────────────────────────────────────────────────────────────────
34
+ // Mocks
35
+ // ─────────────────────────────────────────────────────────────────
36
+
37
+ vi.mock("acp-factory", () => ({
38
+ AgentFactory: {
39
+ spawn: vi.fn().mockResolvedValue({
40
+ createSession: vi.fn().mockResolvedValue({
41
+ id: `session-${Date.now()}`,
42
+ prompt: vi.fn().mockReturnValue({
43
+ [Symbol.asyncIterator]: () => ({
44
+ next: () => Promise.resolve({ done: true, value: undefined }),
45
+ }),
46
+ }),
47
+ forkWithFlush: vi.fn().mockResolvedValue({ id: `forked-${Date.now()}` }),
48
+ }),
49
+ loadSession: vi.fn().mockResolvedValue({ id: `loaded-${Date.now()}` }),
50
+ close: vi.fn().mockResolvedValue(undefined),
51
+ isRunning: vi.fn().mockReturnValue(true),
52
+ }),
53
+ },
54
+ }));
55
+
56
+ vi.mock("opentasks", () => ({
57
+ OpenTasksClient: vi.fn().mockImplementation(() => ({
58
+ connect: vi.fn().mockRejectedValue(new Error("No daemon")),
59
+ disconnect: vi.fn(),
60
+ query: vi.fn().mockResolvedValue({ items: [] }),
61
+ link: vi.fn().mockResolvedValue({ success: true }),
62
+ task: vi.fn().mockResolvedValue({ id: "t-1" }),
63
+ })),
64
+ }));
65
+
66
+ // ─────────────────────────────────────────────────────────────────
67
+ // Helpers
68
+ // ─────────────────────────────────────────────────────────────────
69
+
70
+ function createTestDir(): string {
71
+ const dir = path.join(
72
+ os.tmpdir(),
73
+ `dispatch-e2e-${Date.now()}-${Math.random().toString(36).slice(2)}`
74
+ );
75
+ fs.mkdirSync(dir, { recursive: true });
76
+ return dir;
77
+ }
78
+
79
+ // ─────────────────────────────────────────────────────────────────
80
+ // Tests
81
+ // ─────────────────────────────────────────────────────────────────
82
+
83
+ describeFn("Task Dispatch E2E", () => {
84
+ let system: MacroAgentSystemV2;
85
+ let testDir: string;
86
+
87
+ beforeEach(async () => {
88
+ testDir = createTestDir();
89
+ });
90
+
91
+ afterEach(async () => {
92
+ if (system) {
93
+ try {
94
+ const running = system.agentManager.list({ state: "running" } as any);
95
+ for (const agent of running) {
96
+ await system.agentManager.terminate(agent.id, "cancelled");
97
+ }
98
+ } catch { /* best effort */ }
99
+ await system.shutdown();
100
+ }
101
+ if (fs.existsSync(testDir)) {
102
+ fs.rmSync(testDir, { recursive: true, force: true });
103
+ }
104
+ });
105
+
106
+ // ── Boot with dispatch enabled ─────────────────────────────
107
+
108
+ describe("Boot", () => {
109
+ it("boots successfully with dispatch enabled", async () => {
110
+ system = await bootV2({
111
+ cwd: testDir,
112
+ baseDir: testDir,
113
+ inbox: { socketPath: path.join(testDir, "inbox.sock") },
114
+ dispatch: {
115
+ enabled: true,
116
+ pollIntervalMs: 60_000,
117
+ maxConcurrent: 3,
118
+ defaultRole: "worker",
119
+ },
120
+ });
121
+
122
+ expect(system).toBeDefined();
123
+ expect(system.taskDispatcher).toBeDefined();
124
+ expect(system.taskDispatcher!.running).toBe(true);
125
+ });
126
+
127
+ it("boots successfully with dispatch and reconcile enabled", async () => {
128
+ system = await bootV2({
129
+ cwd: testDir,
130
+ baseDir: testDir,
131
+ inbox: { socketPath: path.join(testDir, "inbox.sock") },
132
+ dispatch: {
133
+ enabled: true,
134
+ pollIntervalMs: 60_000,
135
+ maxConcurrent: 3,
136
+ reconcile: { enabled: true, intervalMs: 120_000 },
137
+ },
138
+ });
139
+
140
+ expect(system.taskDispatcher).toBeDefined();
141
+ expect(system.taskDispatcher!.running).toBe(true);
142
+ });
143
+
144
+ it("boots without dispatch when not enabled", async () => {
145
+ system = await bootV2({
146
+ cwd: testDir,
147
+ baseDir: testDir,
148
+ inbox: { socketPath: path.join(testDir, "inbox.sock") },
149
+ });
150
+
151
+ expect(system.taskDispatcher).toBeUndefined();
152
+ });
153
+
154
+ it("exposes tracker for observability", async () => {
155
+ system = await bootV2({
156
+ cwd: testDir,
157
+ baseDir: testDir,
158
+ inbox: { socketPath: path.join(testDir, "inbox.sock") },
159
+ dispatch: {
160
+ enabled: true,
161
+ pollIntervalMs: 60_000,
162
+ maxConcurrent: 3,
163
+ },
164
+ });
165
+
166
+ expect(system.taskDispatcher!.tracker).toBeDefined();
167
+ expect(system.taskDispatcher!.tracker.activeCount()).toBe(0);
168
+ expect(system.taskDispatcher!.tracker.listRetries()).toHaveLength(0);
169
+ });
170
+ });
171
+
172
+ // ── Dispatch via dispatchNow ───────────────────────────────
173
+
174
+ describe("Dispatch", () => {
175
+ it("dispatchNow triggers a dispatch cycle", async () => {
176
+ system = await bootV2({
177
+ cwd: testDir,
178
+ baseDir: testDir,
179
+ inbox: { socketPath: path.join(testDir, "inbox.sock") },
180
+ dispatch: {
181
+ enabled: true,
182
+ pollIntervalMs: 600_000,
183
+ maxConcurrent: 5,
184
+ },
185
+ });
186
+
187
+ // dispatchNow should not throw even with no ready tasks
188
+ await system.taskDispatcher!.dispatchNow();
189
+ expect(system.taskDispatcher!.tracker.activeCount()).toBe(0);
190
+ });
191
+
192
+ it("reconcileNow triggers a reconciliation cycle", async () => {
193
+ system = await bootV2({
194
+ cwd: testDir,
195
+ baseDir: testDir,
196
+ inbox: { socketPath: path.join(testDir, "inbox.sock") },
197
+ dispatch: {
198
+ enabled: true,
199
+ pollIntervalMs: 600_000,
200
+ maxConcurrent: 3,
201
+ reconcile: { enabled: true, intervalMs: 600_000 },
202
+ },
203
+ });
204
+
205
+ // reconcileNow should not throw with no active dispatches
206
+ await system.taskDispatcher!.reconcileNow();
207
+ });
208
+ });
209
+
210
+ // ── Event subscription ─────────────────────────────────────
211
+
212
+ describe("Events", () => {
213
+ it("emits poll events via onEvent", async () => {
214
+ system = await bootV2({
215
+ cwd: testDir,
216
+ baseDir: testDir,
217
+ inbox: { socketPath: path.join(testDir, "inbox.sock") },
218
+ dispatch: {
219
+ enabled: true,
220
+ pollIntervalMs: 600_000,
221
+ },
222
+ });
223
+
224
+ const events: any[] = [];
225
+ system.taskDispatcher!.onEvent((e) => events.push(e));
226
+
227
+ await system.taskDispatcher!.dispatchNow();
228
+
229
+ const poll = events.find((e) => e.type === "poll");
230
+ expect(poll).toBeDefined();
231
+ expect(poll.dispatched).toBe(0);
232
+ expect(poll.active).toBe(0);
233
+ });
234
+ });
235
+
236
+ // ── Shutdown ───────────────────────────────────────────────
237
+
238
+ describe("Shutdown", () => {
239
+ it("shuts down cleanly with dispatch enabled", async () => {
240
+ system = await bootV2({
241
+ cwd: testDir,
242
+ baseDir: testDir,
243
+ inbox: { socketPath: path.join(testDir, "inbox.sock") },
244
+ dispatch: {
245
+ enabled: true,
246
+ pollIntervalMs: 60_000,
247
+ maxConcurrent: 3,
248
+ reconcile: { enabled: true, intervalMs: 120_000 },
249
+ },
250
+ });
251
+
252
+ await system.shutdown();
253
+ system = undefined as any;
254
+ });
255
+ });
256
+
257
+ // ── Config Variations ──────────────────────────────────────
258
+
259
+ describe("Configuration", () => {
260
+ it("applies custom config values", async () => {
261
+ system = await bootV2({
262
+ cwd: testDir,
263
+ baseDir: testDir,
264
+ inbox: { socketPath: path.join(testDir, "inbox.sock") },
265
+ dispatch: {
266
+ enabled: true,
267
+ pollIntervalMs: 5_000,
268
+ maxConcurrent: 10,
269
+ defaultRole: "security-auditor",
270
+ tags: ["security", "audit"],
271
+ maxRetries: 5,
272
+ retryBaseDelayMs: 5_000,
273
+ retryMaxDelayMs: 120_000,
274
+ reconcile: { enabled: true, intervalMs: 30_000 },
275
+ eligibility: {
276
+ minPriority: 3,
277
+ excludeTags: ["wip"],
278
+ minScore: 0.5,
279
+ },
280
+ },
281
+ });
282
+
283
+ expect(system.taskDispatcher).toBeDefined();
284
+ expect(system.taskDispatcher!.running).toBe(true);
285
+ });
286
+
287
+ it("boots without reconcile when reconcile.enabled is false", async () => {
288
+ system = await bootV2({
289
+ cwd: testDir,
290
+ baseDir: testDir,
291
+ inbox: { socketPath: path.join(testDir, "inbox.sock") },
292
+ dispatch: {
293
+ enabled: true,
294
+ pollIntervalMs: 60_000,
295
+ reconcile: { enabled: false },
296
+ },
297
+ });
298
+
299
+ // Dispatcher should still work — just no reconcile timer
300
+ expect(system.taskDispatcher).toBeDefined();
301
+ expect(system.taskDispatcher!.running).toBe(true);
302
+ });
303
+ });
304
+
305
+ // ── MAP Event Bridge ───────────────────────────────────────
306
+
307
+ describe("MAP Event Bridge", () => {
308
+ it("dispatcher works without MAP sidecar", async () => {
309
+ // No MAP config → no sidecar → dispatch should still work
310
+ system = await bootV2({
311
+ cwd: testDir,
312
+ baseDir: testDir,
313
+ inbox: { socketPath: path.join(testDir, "inbox.sock") },
314
+ dispatch: {
315
+ enabled: true,
316
+ pollIntervalMs: 60_000,
317
+ },
318
+ // No map config
319
+ });
320
+
321
+ expect(system.taskDispatcher).toBeDefined();
322
+ expect(system.taskDispatcher!.running).toBe(true);
323
+
324
+ // dispatchNow should work without MAP bridge
325
+ await system.taskDispatcher!.dispatchNow();
326
+ });
327
+
328
+ it("onEvent subscription works independently of MAP", async () => {
329
+ system = await bootV2({
330
+ cwd: testDir,
331
+ baseDir: testDir,
332
+ inbox: { socketPath: path.join(testDir, "inbox.sock") },
333
+ dispatch: {
334
+ enabled: true,
335
+ pollIntervalMs: 60_000,
336
+ },
337
+ });
338
+
339
+ const events: any[] = [];
340
+ const unsub = system.taskDispatcher!.onEvent((e) => events.push(e));
341
+
342
+ await system.taskDispatcher!.dispatchNow();
343
+
344
+ expect(events.some((e) => e.type === "poll")).toBe(true);
345
+
346
+ // Unsubscribe stops events
347
+ const countBefore = events.length;
348
+ unsub();
349
+ await system.taskDispatcher!.dispatchNow();
350
+ expect(events.length).toBe(countBefore);
351
+ });
352
+
353
+ it("MAP sidecar with unreachable server does not break dispatch", async () => {
354
+ system = await bootV2({
355
+ cwd: testDir,
356
+ baseDir: testDir,
357
+ inbox: { socketPath: path.join(testDir, "inbox.sock") },
358
+ dispatch: {
359
+ enabled: true,
360
+ pollIntervalMs: 60_000,
361
+ },
362
+ map: {
363
+ enabled: true,
364
+ server: "ws://127.0.0.1:1", // Unreachable
365
+ scope: "swarm:test",
366
+ },
367
+ });
368
+
369
+ // Dispatch should work even though MAP sidecar failed to connect
370
+ expect(system.taskDispatcher).toBeDefined();
371
+ expect(system.taskDispatcher!.running).toBe(true);
372
+
373
+ await system.taskDispatcher!.dispatchNow();
374
+ });
375
+ });
376
+ });
@@ -587,12 +587,47 @@ export function createMacroAgent(
587
587
  return {};
588
588
  }
589
589
 
590
- // No mapping but if the client supplied provider_session_id via _meta,
591
- // we can still replay history from the JSONL on disk. This is the
592
- // cross-restart recovery path. We don't create a session mapping because
593
- // there's no live agent to bind to — the client should create a fresh
594
- // session if it wants to continue the conversation.
590
+ // No in-memory mapping and the ACP sessionId isn't an agent ID. If the
591
+ // client supplied provider_session_id via _meta, reverse-lookup the
592
+ // owning agent in the agent-store and resume it. This is the durable
593
+ // cross-restart recovery path: macro-agent's sessionMapper is
594
+ // in-memory, so after a process restart it's empty, but the agent +
595
+ // session records survive on disk keyed by provider_session_id.
596
+ //
597
+ // The critical piece is creating the sessionMapper entry here — without
598
+ // it, subsequent `prompt` calls throw `session not found` and the ACP
599
+ // layer catches that and returns stopReason:"cancelled", making the
600
+ // session appear unresponsive.
595
601
  if (metaProviderSessionId) {
602
+ const store = (system as any).agentStore;
603
+ const sessionRec = typeof store?.findSessionByProviderSessionId === "function"
604
+ ? store.findSessionByProviderSessionId(metaProviderSessionId)
605
+ : undefined;
606
+ if (sessionRec?.agent_id) {
607
+ const agentId = sessionRec.agent_id;
608
+ try {
609
+ // Idempotent: resume() throws ALREADY_RUNNING if the agent is
610
+ // already active (e.g. _macro/resumeAgent just brought it back).
611
+ // We still need to bind the ACP session to this agent.
612
+ if (!agentManager.hasActiveSession(agentId as any)) {
613
+ await agentManager.resume(agentId as any);
614
+ }
615
+ // Bind under BOTH the macro-agent ACP sessionId AND the provider
616
+ // session UUID. The MAP SDK's ACPStreamConnection often ends up
617
+ // storing _meta.provider_session_id as its stream.sessionId —
618
+ // which is what swarmcraft echoes back in session/prompt. Without
619
+ // the UUID mapping, prompt hits sessionMapper with the UUID key
620
+ // and fails (→ stopReason: cancelled).
621
+ sessionMapper.createMapping(sessionId, agentId);
622
+ if (metaProviderSessionId !== sessionId) {
623
+ sessionMapper.createMapping(metaProviderSessionId as any, agentId);
624
+ }
625
+ await replayHistory(agentId, metaProviderSessionId);
626
+ return {};
627
+ } catch {
628
+ // Fall through to history-only replay below
629
+ }
630
+ }
596
631
  await replayHistory(undefined, metaProviderSessionId);
597
632
  return {};
598
633
  }
@@ -931,7 +966,7 @@ export function createMacroAgent(
931
966
  }
932
967
 
933
968
  return { stopReason: "end_turn" };
934
- } catch (err) {
969
+ } catch {
935
970
  // If prompt fails, still return a valid response
936
971
  return { stopReason: "cancelled" };
937
972
  } finally {
@@ -185,6 +185,7 @@ describe("TasksAdapter", () => {
185
185
 
186
186
  expect(mockClient.query).toHaveBeenCalledWith({
187
187
  ready: { tags: ["backend"], limit: 5 },
188
+ verbose: true,
188
189
  });
189
190
  });
190
191
  });
@@ -88,6 +88,7 @@ export class DefaultTasksAdapter implements ITasksAdapter {
88
88
  parent_id: opts.parent,
89
89
  tags: opts.tags,
90
90
  priority: opts.priority,
91
+ metadata: opts.metadata,
91
92
  });
92
93
 
93
94
  return node?.id ?? "";
@@ -129,6 +130,7 @@ export class DefaultTasksAdapter implements ITasksAdapter {
129
130
  limit: opts?.limit,
130
131
  tags: opts?.tags,
131
132
  },
133
+ verbose: true,
132
134
  });
133
135
 
134
136
  return (result.items ?? []).map((n: NodeSummaryLike) =>
@@ -146,6 +148,7 @@ export class DefaultTasksAdapter implements ITasksAdapter {
146
148
  tags: filter?.tags,
147
149
  limit: filter?.limit,
148
150
  },
151
+ verbose: true,
149
152
  });
150
153
 
151
154
  return (result.items ?? []).map((n: NodeSummaryLike) =>
@@ -188,6 +188,7 @@ export interface CreateTaskOptions {
188
188
  parent?: string;
189
189
  tags?: string[];
190
190
  priority?: number;
191
+ metadata?: Record<string, unknown>;
191
192
  }
192
193
 
193
194
  /**
@@ -409,5 +409,57 @@ describe("AgentStore", () => {
409
409
  const session = store.getSession("agent-1")!;
410
410
  expect(session.provider_session_id).toBeUndefined();
411
411
  });
412
+
413
+ it("findSessionByProviderSessionId returns the matching session", () => {
414
+ store.putAgent(makeAgent({ id: "agent-2" }));
415
+ store.putSession({
416
+ agent_id: "agent-1",
417
+ session_id: "session-abc",
418
+ provider_session_id: "psid-xyz",
419
+ created_at: Date.now(),
420
+ });
421
+ store.putSession({
422
+ agent_id: "agent-2",
423
+ session_id: "session-def",
424
+ provider_session_id: "psid-other",
425
+ created_at: Date.now(),
426
+ });
427
+
428
+ const found = store.findSessionByProviderSessionId("psid-xyz");
429
+ expect(found).not.toBeNull();
430
+ expect(found!.agent_id).toBe("agent-1");
431
+ expect(found!.session_id).toBe("session-abc");
432
+ });
433
+
434
+ it("findSessionByProviderSessionId returns null when no match", () => {
435
+ store.putSession({
436
+ agent_id: "agent-1",
437
+ session_id: "session-1",
438
+ provider_session_id: "psid-1",
439
+ created_at: Date.now(),
440
+ });
441
+ expect(store.findSessionByProviderSessionId("psid-missing")).toBeNull();
442
+ });
443
+
444
+ it("findSessionByProviderSessionId returns most recent on duplicate psid", () => {
445
+ // Shouldn't normally happen but verify the ORDER BY created_at DESC path.
446
+ store.putAgent(makeAgent({ id: "agent-old" }));
447
+ store.putAgent(makeAgent({ id: "agent-new" }));
448
+ store.putSession({
449
+ agent_id: "agent-old",
450
+ session_id: "session-old",
451
+ provider_session_id: "psid-dup",
452
+ created_at: 1000,
453
+ });
454
+ store.putSession({
455
+ agent_id: "agent-new",
456
+ session_id: "session-new",
457
+ provider_session_id: "psid-dup",
458
+ created_at: 2000,
459
+ });
460
+
461
+ const found = store.findSessionByProviderSessionId("psid-dup");
462
+ expect(found!.agent_id).toBe("agent-new");
463
+ });
412
464
  });
413
465
  });
@@ -879,7 +879,16 @@ export function createAgentManagerV2(
879
879
  healthCheckService.stopForCoordinator(agentId);
880
880
  }
881
881
 
882
- // Submit merge request if worker completed with workspace
882
+ // Land the worker's work if completed with a workspace.
883
+ //
884
+ // V3 path (preferred): look up the role's YAML landing strategy via
885
+ // TopologyPolicy.getRoleConfig and dispatch through
886
+ // WorkspaceManager.land(). This fires cascade events (stream.merged or
887
+ // queue.added) so the hub sees the work. Landing = 'none' short-circuits.
888
+ //
889
+ // Legacy fallback: if no TopologyPolicy is wired or it can't resolve a
890
+ // landing for this role, submit to the legacy MergeQueue as before.
891
+ // Keeps pre-V3 programmatic callers + tests that bypass YAML working.
883
892
  if (
884
893
  workspaceManager &&
885
894
  agentWorkspaces.has(agentId) &&
@@ -887,18 +896,45 @@ export function createAgentManagerV2(
887
896
  ) {
888
897
  const ws = agentWorkspaces.get(agentId)!;
889
898
  if (ws.role === "worker" && ws.streamId) {
890
- try {
891
- const mergeQueue = workspaceManager.getMergeQueue();
892
- if (mergeQueue) {
893
- mergeQueue.submit({
899
+ const roleConfig = topologyPolicy?.getRoleConfig?.(record.role);
900
+ const yamlLandingName = roleConfig?.landing;
901
+ const usingV3Landing =
902
+ typeof yamlLandingName === "string" && yamlLandingName.length > 0;
903
+
904
+ if (usingV3Landing) {
905
+ try {
906
+ const taskRef = (record.metadata as Record<string, unknown> | undefined)
907
+ ?.task_ref as { resource_id: string; node_id: string } | undefined;
908
+ await workspaceManager.land({
909
+ agentId,
894
910
  streamId: ws.streamId,
895
- workerBranch: ws.branch,
896
- taskId: record.task_id ?? agentId,
897
- workerAgentId: agentId,
911
+ sourceWorktree: ws.path,
912
+ strategyName: yamlLandingName,
913
+ strategyConfig: roleConfig?.landing_config,
914
+ taskRef,
915
+ // Dispatcher overwrites this with `this`; placeholder keeps the
916
+ // type satisfied without a cast.
917
+ workspaceManager,
898
918
  });
919
+ } catch {
920
+ // Non-fatal landing failure — agent still terminates; conflicts
921
+ // and strategy errors surface via WorkspaceEvent emission and
922
+ // the strategy's own logs.
923
+ }
924
+ } else {
925
+ try {
926
+ const mergeQueue = workspaceManager.getMergeQueue();
927
+ if (mergeQueue) {
928
+ mergeQueue.submit({
929
+ streamId: ws.streamId,
930
+ workerBranch: ws.branch,
931
+ taskId: record.task_id ?? agentId,
932
+ workerAgentId: agentId,
933
+ });
934
+ }
935
+ } catch {
936
+ // Non-fatal merge queue submission failure
899
937
  }
900
- } catch {
901
- // Non-fatal merge queue submission failure
902
938
  }
903
939
  }
904
940
  }
@@ -987,11 +1023,16 @@ export function createAgentManagerV2(
987
1023
  .map((r) => agentRecordToAgent(r)),
988
1024
  terminate: (id: AgentId, r: AgentStopReason) => terminate(id, r),
989
1025
  };
1026
+ const parentTaskRef = (record.metadata as Record<string, unknown> | undefined)
1027
+ ?.task_ref as { resource_id: string; node_id: string } | undefined;
990
1028
  await terminateWithChangeConsolidation(
991
1029
  child.id as AgentId,
992
1030
  agentId,
993
1031
  cascadeAdapter,
994
- wsProvider
1032
+ wsProvider,
1033
+ undefined,
1034
+ workspaceManager ?? undefined,
1035
+ parentTaskRef
995
1036
  );
996
1037
  }
997
1038
  }
@@ -1097,6 +1138,21 @@ export function createAgentManagerV2(
1097
1138
  });
1098
1139
 
1099
1140
  const agent = agentRecordToAgent(agentStore.getAgent(agentId)!);
1141
+
1142
+ // Re-publish the agent to subscribers (local MAP server, hub lifecycle
1143
+ // bridge, team auto-join listeners) so a resumed agent is a first-class
1144
+ // registered agent — not just an in-memory handle. Without this, the hub
1145
+ // never re-registers the agent after cold-start; ACP routing works but
1146
+ // the hub's "Registered Agents" view stays empty and capabilities never
1147
+ // propagate back through `map/agents/register`.
1148
+ //
1149
+ // Spawn semantics are correct here: the process is new, the session is
1150
+ // (re)loaded, and subscribers treat it as a fresh registration. Paired
1151
+ // with the `stopped` event that fired on the prior termination, this
1152
+ // keeps the bridge's `registered` map consistent.
1153
+ notifyLifecycle({ type: "spawned", agent });
1154
+ notifyLifecycle({ type: "started", agent });
1155
+
1100
1156
  return {
1101
1157
  id: agentId,
1102
1158
  session_id: sessionRecord?.session_id ?? "",
@@ -1500,6 +1556,18 @@ export function createAgentManagerV2(
1500
1556
  }
1501
1557
  }
1502
1558
 
1559
+ // Auto-terminate when done() was called and the handler signaled shouldTerminate.
1560
+ // This closes the lifecycle gap: without this, agents stay in "running" state
1561
+ // after calling done() because nothing triggers terminate().
1562
+ if (doneCalled) {
1563
+ const reason = doneStatus === "completed" ? "completed" : (doneStatus ?? "failed");
1564
+ try {
1565
+ await terminate(agentId, reason as any);
1566
+ } catch {
1567
+ // Best effort — agent may already be stopping
1568
+ }
1569
+ }
1570
+
1503
1571
  return { doneCalled, doneStatus, updates: allUpdates };
1504
1572
  }
1505
1573