macro-agent 0.1.10 → 0.1.11

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 (104) 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/workspace/git-cascade-adapter.d.ts +1 -1
  46. package/dist/workspace/git-cascade-adapter.d.ts.map +1 -1
  47. package/dist/workspace/git-cascade-adapter.js +26 -0
  48. package/dist/workspace/git-cascade-adapter.js.map +1 -1
  49. package/dist/workspace/landing/merge-to-parent.d.ts.map +1 -1
  50. package/dist/workspace/landing/merge-to-parent.js +1 -0
  51. package/dist/workspace/landing/merge-to-parent.js.map +1 -1
  52. package/dist/workspace/recovery/spawn-resolver.d.ts.map +1 -1
  53. package/dist/workspace/recovery/spawn-resolver.js +8 -1
  54. package/dist/workspace/recovery/spawn-resolver.js.map +1 -1
  55. package/dist/workspace/types-v3.d.ts +7 -0
  56. package/dist/workspace/types-v3.d.ts.map +1 -1
  57. package/dist/workspace/types-v3.js.map +1 -1
  58. package/dist/workspace/types.d.ts +17 -0
  59. package/dist/workspace/types.d.ts.map +1 -1
  60. package/dist/workspace/workspace-manager.d.ts +9 -0
  61. package/dist/workspace/workspace-manager.d.ts.map +1 -1
  62. package/dist/workspace/workspace-manager.js +45 -2
  63. package/dist/workspace/workspace-manager.js.map +1 -1
  64. package/docs/design/task-dispatcher.md +880 -0
  65. package/package.json +3 -2
  66. package/src/__tests__/boot-v2.test.ts +435 -0
  67. package/src/__tests__/e2e/acp-over-map.e2e.test.ts +92 -0
  68. package/src/__tests__/e2e/bootstrap.e2e.test.ts +319 -0
  69. package/src/__tests__/e2e/dispatch-coordination.e2e.test.ts +495 -0
  70. package/src/__tests__/e2e/dispatch-live.e2e.test.ts +564 -0
  71. package/src/__tests__/e2e/dispatch-opentasks.e2e.test.ts +496 -0
  72. package/src/__tests__/e2e/dispatch-phase2-live.e2e.test.ts +456 -0
  73. package/src/__tests__/e2e/dispatch-phase2.e2e.test.ts +386 -0
  74. package/src/__tests__/e2e/dispatch.e2e.test.ts +376 -0
  75. package/src/acp/macro-agent.ts +41 -6
  76. package/src/adapters/__tests__/tasks-adapter.test.ts +1 -0
  77. package/src/adapters/tasks-adapter.ts +3 -0
  78. package/src/adapters/types.ts +1 -0
  79. package/src/agent/__tests__/agent-store.test.ts +52 -0
  80. package/src/agent/agent-manager-v2.ts +79 -11
  81. package/src/agent/agent-store.ts +24 -0
  82. package/src/boot-v2.ts +522 -35
  83. package/src/cli/acp.ts +4 -0
  84. package/src/lifecycle/__tests__/cascade-consolidation.test.ts +240 -0
  85. package/src/lifecycle/cascade.ts +77 -2
  86. package/src/map/__tests__/emit-event.test.ts +71 -0
  87. package/src/map/cascade-action-handler.ts +205 -0
  88. package/src/map/cascade-bridge.ts +43 -5
  89. package/src/map/coordination-handler.ts +13 -1
  90. package/src/map/server.ts +178 -1
  91. package/src/map/sidecar.ts +19 -2
  92. package/src/map/types.ts +3 -0
  93. package/src/workspace/__tests__/land-dispatch.test.ts +214 -0
  94. package/src/workspace/git-cascade-adapter.ts +30 -3
  95. package/src/workspace/landing/__tests__/strategies.test.ts +42 -0
  96. package/src/workspace/landing/merge-to-parent.ts +1 -0
  97. package/src/workspace/recovery/spawn-resolver.ts +8 -1
  98. package/src/workspace/types-v3.ts +7 -0
  99. package/src/workspace/types.ts +20 -0
  100. package/src/workspace/workspace-manager.ts +61 -2
  101. package/dist/workspace/dataplane-adapter.d.ts +0 -260
  102. package/dist/workspace/dataplane-adapter.d.ts.map +0 -1
  103. package/dist/workspace/dataplane-adapter.js +0 -416
  104. package/dist/workspace/dataplane-adapter.js.map +0 -1
@@ -75,12 +75,24 @@ export function setupCoordinationHandlers(
75
75
  if (!p?.title) return;
76
76
 
77
77
  try {
78
- // Create task in opentasks
78
+ // Extract tags and metadata from OpenHive context
79
+ const context = p.context ?? {};
80
+ const tags = Array.isArray(context.tags) ? context.tags as string[] : undefined;
81
+ const metadata: Record<string, unknown> = {
82
+ ...context,
83
+ ...(p.assigned_by ? { assigned_by: p.assigned_by } : {}),
84
+ ...(p.deadline ? { deadline: p.deadline } : {}),
85
+ };
86
+ // Remove tags from metadata (already a top-level field)
87
+ delete metadata.tags;
88
+
79
89
  const taskId = await tasksAdapter.createTask({
80
90
  title: p.title,
81
91
  content: p.description,
82
92
  assignee: p.assigned_to,
93
+ tags,
83
94
  priority: p.priority === "critical" ? 1 : p.priority === "high" ? 2 : p.priority === "low" ? 4 : 3,
95
+ metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
84
96
  });
85
97
 
86
98
  // Optionally spawn an agent to work on the task
package/src/map/server.ts CHANGED
@@ -74,6 +74,14 @@ export function createMAPServerInstance(
74
74
  const clientWebSockets = new Map<string, WebSocket>(); // participant/agent ID → WebSocket
75
75
  // Track subscription IDs by client agent ID for ACP response delivery
76
76
  const clientSubscriptions = new Map<string, string[]>(); // agent ID → subscription IDs
77
+ /**
78
+ * Per-subscription monotonic event counter. The MAP SDK's Subscription
79
+ * checks `sequenceNumber !== lastSequenceNumber + 1` and warns on gaps —
80
+ * using `Date.now()` (millisecond timestamp) breaks that assumption since
81
+ * each event becomes a "gap". Track a per-subscription counter starting
82
+ * at 1 and increment per event.
83
+ */
84
+ const subscriptionSequence = new Map<string, number>(); // subscription ID → next sequence number
77
85
  // Track original ws.send for each WebSocket (before interception)
78
86
  const originalSends = new Map<WebSocket, Function>();
79
87
 
@@ -90,11 +98,21 @@ export function createMAPServerInstance(
90
98
 
91
99
  // ── Agent extensions ──────────────────────────────────────────
92
100
  handlers["_macro/spawnAgent"] = async (params, ctx) => {
101
+ // Forward the full SpawnAgentOptions surface so callers can set
102
+ // permission mode, agent type, custom prompt, model config, etc.
103
+ // Previously this handler dropped everything except role/cwd/task,
104
+ // making _macro/spawnAgent useless for any non-default agent.
93
105
  const spawned = await agentManager.spawn({
94
106
  task: params.task ?? "Spawned via MAP",
95
107
  parent: params.parent ?? null,
96
108
  cwd: params.cwd,
97
109
  role: params.role ?? "worker",
110
+ permissionMode: params.permissionMode,
111
+ agentType: params.agentType,
112
+ customPrompt: params.customPrompt,
113
+ topics: params.topics,
114
+ config: params.config,
115
+ taskRef: params.taskRef,
98
116
  });
99
117
 
100
118
  // Ensure agent is registered in MAPServer's registry.
@@ -145,6 +163,128 @@ export function createMAPServerInstance(
145
163
  return { agent: { id: spawned.id } };
146
164
  };
147
165
 
166
+ /**
167
+ * Resume an agent session with full routing + session info returned.
168
+ *
169
+ * Session-first resolution: given a Claude Code `providerSessionId` (the
170
+ * session UUID persisted on the session record), reverse-look-up the
171
+ * owning agent. Falls back to `agentId` (either the MAP ULID or the
172
+ * local store id) when no providerSessionId is given or the reverse
173
+ * lookup misses.
174
+ *
175
+ * Behavior:
176
+ * 1. Resolve the local agent id.
177
+ * 2. Call `agentManager.resume(localId)` — idempotent; the manager
178
+ * re-spawns the coordinator/head-manager if its process isn't live,
179
+ * otherwise returns the existing handle.
180
+ * 3. Ensure the agent is registered in the MAPServer's registry so
181
+ * ACP streams can target it via the returned peerMapId.
182
+ * 4. Return `{ agent: { id: peerMapId, localId, name }, acpSessionId,
183
+ * providerSessionId }` — the caller needs peerMapId to open the
184
+ * ACP stream and providerSessionId to pass into `session/load` so
185
+ * Claude Code replays its on-disk transcript.
186
+ *
187
+ * Used by OpenHive's POST /sessions/:id/resume to revive a session whose
188
+ * swarm has been offline for longer than the hub's stale-grace window.
189
+ */
190
+ handlers["_macro/resumeAgent"] = async (params, ctx) => {
191
+ const providerSessionIdParam = params.providerSessionId as string | undefined;
192
+ const agentIdParam = params.agentId as string | undefined;
193
+
194
+ let localId: string | undefined;
195
+ let providerSessionId: string | undefined;
196
+
197
+ if (providerSessionIdParam) {
198
+ const session = agentStore.findSessionByProviderSessionId(providerSessionIdParam);
199
+ if (session) {
200
+ localId = session.agent_id;
201
+ providerSessionId = session.provider_session_id;
202
+ }
203
+ }
204
+
205
+ if (!localId && agentIdParam) {
206
+ localId = mapIdToLocalId.get(agentIdParam) ?? agentIdParam;
207
+ const session = agentStore.getSession(localId);
208
+ providerSessionId = session?.provider_session_id;
209
+ }
210
+
211
+ if (!localId) {
212
+ return {
213
+ success: false,
214
+ error: "providerSessionId or agentId required",
215
+ };
216
+ }
217
+
218
+ const agentRec = agentStore.getAgent(localId);
219
+ if (!agentRec) {
220
+ return { success: false, error: `Agent not found: ${localId}` };
221
+ }
222
+
223
+ // Already-running case: skip resume() (which rejects with ALREADY_RUNNING)
224
+ // and return the live agent's session info straight from the store.
225
+ // This makes the call idempotent — callers don't need to pre-check.
226
+ let resumedId: string;
227
+ let resumedSessionId: string;
228
+ let resumedName: string | undefined;
229
+ if (agentManager.hasActiveSession(localId as any)) {
230
+ resumedId = localId;
231
+ resumedName = agentRec.name;
232
+ const liveSession = agentStore.getSession(localId);
233
+ if (!liveSession) {
234
+ return {
235
+ success: false,
236
+ error: `Agent ${localId} is active but has no session record`,
237
+ };
238
+ }
239
+ resumedSessionId = liveSession.session_id;
240
+ } else {
241
+ try {
242
+ const resumed = await agentManager.resume(localId as any);
243
+ resumedId = resumed.id;
244
+ resumedSessionId = resumed.session_id;
245
+ resumedName = (resumed as any).name;
246
+ } catch (err) {
247
+ return { success: false, error: (err as Error).message };
248
+ }
249
+ }
250
+
251
+ // Ensure agent is registered in MAPServer's registry. resume() fires
252
+ // the spawned lifecycle event, which the lifecycle bridge handles —
253
+ // but we also register here for subscription routing context on the
254
+ // current MAP session (mirrors _macro/spawnAgent).
255
+ if (mapServer && !localIdToMapId.has(resumedId)) {
256
+ try {
257
+ const registered = mapServer.agents.register({
258
+ name: resumedName ?? resumedId,
259
+ role: agentRec.role,
260
+ state: "idle",
261
+ sessionId: ctx?.session?.id,
262
+ metadata: { peerAgentId: resumedId },
263
+ });
264
+ if (registered?.id) {
265
+ mapIdToLocalId.set(registered.id, resumedId);
266
+ localIdToMapId.set(resumedId, registered.id);
267
+ }
268
+ } catch {
269
+ // Best effort; lifecycle bridge will register on spawned event
270
+ }
271
+ }
272
+
273
+ const peerMapId = localIdToMapId.get(resumedId) ?? resumedId;
274
+
275
+ return {
276
+ success: true,
277
+ agent: {
278
+ id: peerMapId,
279
+ localId: resumedId,
280
+ name: resumedName,
281
+ role: agentRec.role,
282
+ },
283
+ acpSessionId: resumedSessionId,
284
+ providerSessionId,
285
+ };
286
+ };
287
+
148
288
  /**
149
289
  * Terminate a running agent. Accepts either the agent's local ID or the
150
290
  * MAP-assigned ULID (we resolve back to local via mapIdToLocalId).
@@ -288,15 +428,21 @@ export function createMAPServerInstance(
288
428
 
289
429
  // Send as subscription event notification (what ACPStreamConnection expects).
290
430
  // The _pushEvent method expects: { subscriptionId, sequenceNumber, eventId, timestamp, event }
431
+ //
432
+ // sequenceNumber must be a per-subscription monotonic counter that
433
+ // increments by exactly 1 — the SDK warns on any gap. Don't use
434
+ // Date.now() here (breaks the contract on every event).
291
435
  for (const subId of subIds) {
292
436
  const event = rawEvent.params?.event ?? rawEvent;
293
437
  const eventId = event.id ?? `acp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
438
+ const nextSeq = (subscriptionSequence.get(subId) ?? 0) + 1;
439
+ subscriptionSequence.set(subId, nextSeq);
294
440
  const notification = JSON.stringify({
295
441
  jsonrpc: "2.0",
296
442
  method: "map/event",
297
443
  params: {
298
444
  subscriptionId: subId,
299
- sequenceNumber: Date.now(),
445
+ sequenceNumber: nextSeq,
300
446
  eventId,
301
447
  timestamp: Date.now(),
302
448
  event,
@@ -413,6 +559,32 @@ export function createMAPServerInstance(
413
559
  return originalSend(data, ...args);
414
560
  } as any;
415
561
 
562
+ // Observe incoming messages so we drop subscription IDs from our
563
+ // routing array when the client unsubscribes. Without this, closed
564
+ // ACP streams keep receiving events ("MAP: Event for unknown
565
+ // subscription" warnings on the client). We don't intercept the
566
+ // SDK's processing — this listener runs alongside it.
567
+ ws.on("message", (data: any) => {
568
+ try {
569
+ const text = typeof data === "string"
570
+ ? data
571
+ : Buffer.isBuffer(data)
572
+ ? data.toString("utf-8")
573
+ : String(data);
574
+ const msg = JSON.parse(text);
575
+ if (msg?.method === "map/unsubscribe") {
576
+ const subId = msg?.params?.subscriptionId;
577
+ if (typeof subId === "string") {
578
+ const idx = subscriptionIds.indexOf(subId);
579
+ if (idx >= 0) subscriptionIds.splice(idx, 1);
580
+ subscriptionSequence.delete(subId);
581
+ }
582
+ }
583
+ } catch {
584
+ // Non-JSON or parse failure — ignore
585
+ }
586
+ });
587
+
416
588
  const stream = websocketStream(ws as unknown as globalThis.WebSocket);
417
589
  const router = mapServer.accept(stream, {
418
590
  role: "client",
@@ -422,6 +594,11 @@ export function createMAPServerInstance(
422
594
 
423
595
  ws.on("close", () => {
424
596
  connectionCount--;
597
+ // Clear sequence counters for any subscriptions belonging to this
598
+ // connection. Use a copy of subscriptionIds since we don't mutate it.
599
+ for (const subId of subscriptionIds) {
600
+ subscriptionSequence.delete(subId);
601
+ }
425
602
  if (clientAgentId) {
426
603
  clientWebSockets.delete(clientAgentId);
427
604
  clientSubscriptions.delete(clientAgentId);
@@ -294,11 +294,19 @@ export function createMAPSidecar(
294
294
  trajectoryReporter,
295
295
  });
296
296
 
297
- // 5. Cascade Bridge (optional — only when a GitCascadeAdapter is available)
297
+ // 5. Cascade Bridge + Action Handler (optional — only when a GitCascadeAdapter is available)
298
298
  if (gitCascadeAdapter) {
299
299
  const { createCascadeBridge } = await import("./cascade-bridge.js");
300
300
  const cascadeBridge = createCascadeBridge(connection, gitCascadeAdapter);
301
- cascadeBridgeCleanup = cascadeBridge.dispose;
301
+
302
+ // 5b. Inbound action handler — receives x-cascade/request.* from hub
303
+ const { setupCascadeActionHandlers } = await import("./cascade-action-handler.js");
304
+ const actionCleanup = setupCascadeActionHandlers(connection, gitCascadeAdapter);
305
+
306
+ cascadeBridgeCleanup = () => {
307
+ cascadeBridge.dispose();
308
+ actionCleanup();
309
+ };
302
310
  }
303
311
  }
304
312
 
@@ -346,5 +354,14 @@ export function createMAPSidecar(
346
354
  if (!trajectoryReporter) return null;
347
355
  return trajectoryReporter.reportCheckpoint(checkpoint);
348
356
  },
357
+
358
+ async emitEvent(event: Record<string, unknown>): Promise<void> {
359
+ if (!connection || !isConnected) return;
360
+ try {
361
+ await connection.send({ scope }, { ...event, _origin: "macro-agent" });
362
+ } catch {
363
+ // Best effort — MAP hub may be temporarily unavailable
364
+ }
365
+ },
349
366
  };
350
367
  }
package/src/map/types.ts CHANGED
@@ -103,6 +103,9 @@ export interface MAPSidecar {
103
103
  reportCheckpoint(
104
104
  checkpoint: TrajectoryCheckpointPayload,
105
105
  ): Promise<TrajectoryCheckpointResult | null>;
106
+
107
+ /** Emit a custom event to the MAP hub scope (best-effort, no-op if disconnected) */
108
+ emitEvent?(event: Record<string, unknown>): Promise<void>;
106
109
  }
107
110
 
108
111
  // =============================================================================
@@ -0,0 +1,214 @@
1
+ /**
2
+ * WorkspaceManager.land() dispatcher tests (A2 follow-up).
3
+ *
4
+ * Closes the gap where `registerLandingStrategy` existed but no caller
5
+ * invoked `.land(ctx)` at `done()` time. Verifies the dispatcher:
6
+ * - resolves strategy by name (internal or YAML form)
7
+ * - respects `strategyName: 'none'` (short-circuits to success)
8
+ * - errors out on unknown names
9
+ * - fills in `ctx.workspaceManager` before invoking the strategy
10
+ * - rejects when `canLand` returns false
11
+ *
12
+ * Unit-only — doesn't spin up git. The end-to-end path (strategy fires
13
+ * tracker events → hub projects) is covered by OpenHive's
14
+ * live-emission-e2e.test.ts.
15
+ */
16
+
17
+ import { describe, it, expect, vi } from 'vitest';
18
+ import type { GitCascadeAdapter } from '../git-cascade-adapter.js';
19
+ import { DefaultWorkspaceManager, resolveLandingStrategyName } from '../workspace-manager.js';
20
+ import type { LandingStrategy, LandingContext } from '../types-v3.js';
21
+
22
+ // A no-op adapter stub that satisfies the DefaultWorkspaceManager constructor
23
+ // without touching git. Every method the dispatcher path might hit throws so
24
+ // accidental delegation is loud.
25
+ function stubAdapter(): GitCascadeAdapter {
26
+ const throwing = (name: string) => () => {
27
+ throw new Error(`stub adapter: ${name} should not be called in this test`);
28
+ };
29
+ return {
30
+ close: vi.fn(),
31
+ reconcile: throwing('reconcile'),
32
+ listWorktrees: vi.fn(() => []),
33
+ getWorktree: vi.fn(() => null),
34
+ } as unknown as GitCascadeAdapter;
35
+ }
36
+
37
+ function makeTestStrategy(
38
+ name: string,
39
+ impl: (ctx: LandingContext) => ReturnType<LandingStrategy['land']>,
40
+ canLand?: (ctx: LandingContext) => boolean,
41
+ ): LandingStrategy {
42
+ return { name, land: impl, canLand };
43
+ }
44
+
45
+ describe('WorkspaceManager.land() dispatcher', () => {
46
+ it('dispatches to an internal-named strategy', async () => {
47
+ const wm = new DefaultWorkspaceManager(stubAdapter());
48
+ const land = vi.fn(async () => ({ success: true, newHead: 'abc' } as never));
49
+ wm.registerLandingStrategy(makeTestStrategy('merge-to-parent', land));
50
+
51
+ const result = await wm.land({
52
+ agentId: 'a1',
53
+ streamId: 's1',
54
+ sourceWorktree: '/tmp/wt',
55
+ strategyName: 'merge-to-parent',
56
+ workspaceManager: wm,
57
+ });
58
+
59
+ expect(result.success).toBe(true);
60
+ expect(land).toHaveBeenCalledOnce();
61
+ });
62
+
63
+ it('accepts YAML-style strategy names and maps them', async () => {
64
+ const wm = new DefaultWorkspaceManager(stubAdapter());
65
+ const land = vi.fn(async () => ({ success: true } as never));
66
+ wm.registerLandingStrategy(makeTestStrategy('queue-to-branch', land));
67
+
68
+ await wm.land({
69
+ agentId: 'a1',
70
+ streamId: 's1',
71
+ sourceWorktree: '/tmp/wt',
72
+ strategyName: 'queue_to_branch', // YAML form
73
+ workspaceManager: wm,
74
+ });
75
+
76
+ expect(land).toHaveBeenCalledOnce();
77
+ });
78
+
79
+ it('defaults to merge-to-parent when strategyName is unset', async () => {
80
+ const wm = new DefaultWorkspaceManager(stubAdapter());
81
+ const land = vi.fn(async () => ({ success: true } as never));
82
+ wm.registerLandingStrategy(makeTestStrategy('merge-to-parent', land));
83
+
84
+ await wm.land({
85
+ agentId: 'a1',
86
+ streamId: 's1',
87
+ sourceWorktree: '/tmp/wt',
88
+ workspaceManager: wm,
89
+ });
90
+
91
+ expect(land).toHaveBeenCalledOnce();
92
+ });
93
+
94
+ it('short-circuits on strategyName: "none" without invoking any strategy', async () => {
95
+ const wm = new DefaultWorkspaceManager(stubAdapter());
96
+ const land = vi.fn(async () => ({ success: false } as never));
97
+ wm.registerLandingStrategy(makeTestStrategy('merge-to-parent', land));
98
+
99
+ const result = await wm.land({
100
+ agentId: 'a1',
101
+ streamId: 's1',
102
+ sourceWorktree: '/tmp/wt',
103
+ strategyName: 'none',
104
+ workspaceManager: wm,
105
+ });
106
+
107
+ expect(result.success).toBe(true);
108
+ expect(land).not.toHaveBeenCalled();
109
+ });
110
+
111
+ it('throws on an unknown strategy name', async () => {
112
+ const wm = new DefaultWorkspaceManager(stubAdapter());
113
+ await expect(
114
+ wm.land({
115
+ agentId: 'a1',
116
+ streamId: 's1',
117
+ sourceWorktree: '/tmp/wt',
118
+ strategyName: 'nonexistent-strategy',
119
+ workspaceManager: wm,
120
+ }),
121
+ ).rejects.toThrow(/No landing strategy registered/);
122
+ });
123
+
124
+ it('fills ctx.workspaceManager with the dispatcher instance', async () => {
125
+ const wm = new DefaultWorkspaceManager(stubAdapter());
126
+ let seenManager: unknown = null;
127
+ wm.registerLandingStrategy(
128
+ makeTestStrategy('merge-to-parent', async (ctx) => {
129
+ seenManager = ctx.workspaceManager;
130
+ return { success: true } as never;
131
+ }),
132
+ );
133
+
134
+ await wm.land({
135
+ agentId: 'a1',
136
+ streamId: 's1',
137
+ sourceWorktree: '/tmp/wt',
138
+ // Deliberately pass a non-matching value to prove the dispatcher overrides.
139
+ workspaceManager: { bogus: true },
140
+ });
141
+
142
+ expect(seenManager).toBe(wm);
143
+ });
144
+
145
+ it('rejects when strategy.canLand(ctx) returns false', async () => {
146
+ const wm = new DefaultWorkspaceManager(stubAdapter());
147
+ wm.registerLandingStrategy(
148
+ makeTestStrategy(
149
+ 'merge-to-parent',
150
+ async () => ({ success: true } as never),
151
+ () => false,
152
+ ),
153
+ );
154
+
155
+ await expect(
156
+ wm.land({
157
+ agentId: 'a1',
158
+ streamId: 's1',
159
+ sourceWorktree: '/tmp/wt',
160
+ workspaceManager: wm,
161
+ }),
162
+ ).rejects.toThrow(/rejected context/);
163
+ });
164
+
165
+ it('propagates strategy return values including conflicts', async () => {
166
+ const wm = new DefaultWorkspaceManager(stubAdapter());
167
+ wm.registerLandingStrategy(
168
+ makeTestStrategy(
169
+ 'merge-to-parent',
170
+ async () => ({
171
+ success: false,
172
+ conflicts: ['foo.ts'],
173
+ } as never),
174
+ ),
175
+ );
176
+
177
+ const result = await wm.land({
178
+ agentId: 'a1',
179
+ streamId: 's1',
180
+ sourceWorktree: '/tmp/wt',
181
+ workspaceManager: wm,
182
+ });
183
+
184
+ expect(result.success).toBe(false);
185
+ expect((result as { conflicts?: string[] }).conflicts).toEqual(['foo.ts']);
186
+ });
187
+ });
188
+
189
+ describe('resolveLandingStrategyName()', () => {
190
+ it('maps every YAML name to its internal counterpart', () => {
191
+ expect(resolveLandingStrategyName('merge_to_parent_stream')).toBe('merge-to-parent');
192
+ expect(resolveLandingStrategyName('queue_to_branch')).toBe('queue-to-branch');
193
+ expect(resolveLandingStrategyName('direct_push')).toBe('direct-push');
194
+ expect(resolveLandingStrategyName('optimistic_push')).toBe('optimistic-push');
195
+ expect(resolveLandingStrategyName('cherry_pick_stack')).toBe('cherry-pick-stack');
196
+ });
197
+
198
+ it('passes internal names through unchanged', () => {
199
+ expect(resolveLandingStrategyName('merge-to-parent')).toBe('merge-to-parent');
200
+ expect(resolveLandingStrategyName('queue-to-branch')).toBe('queue-to-branch');
201
+ });
202
+
203
+ it('passes unknown names through (lets the dispatcher throw with a useful message)', () => {
204
+ expect(resolveLandingStrategyName('my-custom-strategy')).toBe('my-custom-strategy');
205
+ });
206
+
207
+ it('defaults to merge-to-parent on undefined', () => {
208
+ expect(resolveLandingStrategyName(undefined)).toBe('merge-to-parent');
209
+ });
210
+
211
+ it('preserves "none" so the dispatcher can short-circuit', () => {
212
+ expect(resolveLandingStrategyName('none')).toBe('none');
213
+ });
214
+ });
@@ -79,8 +79,9 @@ export type GitCascadeEventType =
79
79
  | 'stream:merged' // mapped from git-cascade stream.merged
80
80
  | 'stream:conflicted' // mapped from git-cascade stream.conflicted
81
81
  | 'stream:abandoned' // mapped from git-cascade stream.abandoned
82
- | 'stream:paused' // local (pauseStream)
83
- | 'stream:resumed' // local (resumeStream)
82
+ | 'stream:paused' // local (pauseStream) + cascade emit
83
+ | 'stream:resumed' // local (resumeStream) + cascade emit
84
+ | 'stream:rolled_back' // cascade emit (rollbackN, rollbackToOperation, rollbackToForkPoint)
84
85
  | 'worktree:created'
85
86
  | 'worktree:deallocated'
86
87
  | 'task:created'
@@ -250,7 +251,11 @@ export class GitCascadeAdapter {
250
251
  const suffix = matchCascadeSuffix(method);
251
252
  if (!suffix) return;
252
253
 
253
- switch (suffix) {
254
+ // Widen to `string` so case labels for suffixes not yet in the installed
255
+ // git-cascade version (e.g. `stream.paused`, `stream.resumed`,
256
+ // `stream.rolled_back` — added after 0.0.7) still compile. The runtime
257
+ // value is always a string either way; this is a version-skew shim.
258
+ switch (suffix as string) {
254
259
  case 'stream.opened': {
255
260
  const p = params as StreamOpenedParams;
256
261
  this.emit('stream:created', {
@@ -394,6 +399,28 @@ export class GitCascadeAdapter {
394
399
  });
395
400
  break;
396
401
  }
402
+ case 'stream.paused': {
403
+ // Tracker fires stream.paused after pauseStream. The adapter also
404
+ // emits stream:paused locally in its pauseStream() wrapper — the
405
+ // bridge uses the local event (not this cascade-forwarded one) for
406
+ // the MAP translation, so this case is mainly to keep the switch
407
+ // exhaustive. No double-emit: the bridge deduplicates by event type.
408
+ break;
409
+ }
410
+ case 'stream.resumed': {
411
+ // Same as stream.paused — adapter.resumeStream() fires the local event.
412
+ break;
413
+ }
414
+ case 'stream.rolled_back': {
415
+ const p = params as { stream_id: string; strategy?: string; target?: string | number; new_head?: string };
416
+ this.emit('stream:rolled_back', {
417
+ streamId: p.stream_id,
418
+ strategy: p.strategy,
419
+ target: p.target,
420
+ newHead: p.new_head,
421
+ });
422
+ break;
423
+ }
397
424
  }
398
425
  }
399
426
 
@@ -75,6 +75,48 @@ describe('landing strategies', () => {
75
75
  expect.objectContaining({ targetStreamId: 'target-1' })
76
76
  );
77
77
  });
78
+
79
+ it('threads ctx.taskRef into mergeStream metadata when present', async () => {
80
+ const strategy = new MergeToParentStrategy();
81
+ const ws = {
82
+ listStreams: vi.fn(() => []),
83
+ mergeStream: vi.fn(() => ({ success: true, newHead: 'aaa' })),
84
+ } as unknown as WorkspaceManager;
85
+
86
+ const taskRef = { resource_id: 'res-a1', node_id: 'task-a1' };
87
+ await strategy.land({
88
+ agentId: 'agent-1',
89
+ streamId: 'src-1',
90
+ sourceWorktree: '/tmp/wt',
91
+ targetStreamId: 'target-1',
92
+ taskRef,
93
+ workspaceManager: ws,
94
+ });
95
+
96
+ expect(ws.mergeStream).toHaveBeenCalledWith(
97
+ expect.objectContaining({ metadata: { task_ref: taskRef } })
98
+ );
99
+ });
100
+
101
+ it('omits metadata when ctx.taskRef is absent', async () => {
102
+ const strategy = new MergeToParentStrategy();
103
+ const ws = {
104
+ listStreams: vi.fn(() => []),
105
+ mergeStream: vi.fn(() => ({ success: true, newHead: 'bbb' })),
106
+ } as unknown as WorkspaceManager;
107
+
108
+ await strategy.land({
109
+ agentId: 'agent-1',
110
+ streamId: 'src-1',
111
+ sourceWorktree: '/tmp/wt',
112
+ targetStreamId: 'target-1',
113
+ workspaceManager: ws,
114
+ });
115
+
116
+ expect(ws.mergeStream).toHaveBeenCalledWith(
117
+ expect.objectContaining({ metadata: undefined })
118
+ );
119
+ });
78
120
  });
79
121
 
80
122
  describe('QueueToBranchStrategy', () => {
@@ -81,6 +81,7 @@ export class MergeToParentStrategy implements LandingStrategy {
81
81
  targetStreamId,
82
82
  agentId: ctx.agentId,
83
83
  worktree: mergeWorktree.path,
84
+ metadata: ctx.taskRef ? { task_ref: ctx.taskRef } : undefined,
84
85
  });
85
86
 
86
87
  // Cascade rebase on dependents if requested.
@@ -70,7 +70,8 @@ export class SpawnResolverStrategy implements ConflictRecoveryStrategy {
70
70
  };
71
71
  }
72
72
 
73
- // Spawn the resolver
73
+ // Spawn the resolver. Injects MACRO_RECOVERY_STRATEGY + MACRO_CONFLICT_ID
74
+ // so the resolve_conflict MCP tool can tag the resolution correctly.
74
75
  let resolverAgentId: string;
75
76
  try {
76
77
  const spawnOpts: SpawnAgentOptions = {
@@ -78,6 +79,12 @@ export class SpawnResolverStrategy implements ConflictRecoveryStrategy {
78
79
  task: `Resolve conflict ${ctx.conflictId} on stream ${ctx.streamId}`,
79
80
  parent: ctx.landingAgentId,
80
81
  capabilities: ['workspace.commit', 'workspace.resolve', 'workspace.read'],
82
+ config: {
83
+ env: {
84
+ MACRO_RECOVERY_STRATEGY: 'spawn-resolver',
85
+ MACRO_CONFLICT_ID: ctx.conflictId,
86
+ },
87
+ },
81
88
  };
82
89
  const spawned = await this.opts.agentManager.spawn(spawnOpts);
83
90
  resolverAgentId = spawned.id;
@@ -124,6 +124,13 @@ export interface LandingContext {
124
124
  streamId: StreamId;
125
125
  sourceWorktree: string;
126
126
  targetStreamId?: StreamId;
127
+ /**
128
+ * Strategy selector. Accepts either an internal strategy name
129
+ * (`merge-to-parent`, `queue-to-branch`, …) or the YAML form
130
+ * (`merge_to_parent_stream`, `queue_to_branch`, …). `WorkspaceManager.land`
131
+ * normalizes. When undefined, `merge-to-parent` is used.
132
+ */
133
+ strategyName?: string;
127
134
  strategyConfig?: Record<string, unknown>;
128
135
  /** Reference to the manager; strategies call back for merge/cascade. */
129
136
  workspaceManager: unknown; // WorkspaceManager — circular; narrowed at callsite