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
@@ -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
  // =============================================================================
@@ -13,9 +13,13 @@ import * as path from "path";
13
13
  import { fileURLToPath } from "url";
14
14
 
15
15
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
- // Package root: up from src/teams/ → src/ → package root
16
+ // Package root: up from src/teams/ → src/ → package root.
17
+ // Bundled templates live under `templates/teams/` so they're clearly shipped
18
+ // assets (not confused with user runtime config, which lives at
19
+ // `<project>/.multiagent/teams/` — a gitignored directory that this module
20
+ // seeds INTO on first use).
17
21
  const PACKAGE_ROOT = path.resolve(__dirname, "..", "..");
18
- const BUNDLED_TEAMS_DIR = path.join(PACKAGE_ROOT, ".multiagent", "teams");
22
+ const BUNDLED_TEAMS_DIR = path.join(PACKAGE_ROOT, "templates", "teams");
19
23
  const TEAMS_DIR = ".multiagent/teams";
20
24
 
21
25
  /**
@@ -7,7 +7,9 @@
7
7
  * @module teams/team-loader
8
8
  */
9
9
 
10
+ import * as fs from "fs";
10
11
  import * as path from "path";
12
+ import { fileURLToPath } from "url";
11
13
  import { TemplateLoader } from "openteams";
12
14
  import type {
13
15
  ResolvedRole,
@@ -29,6 +31,15 @@ import {
29
31
 
30
32
  const TEAMS_DIR = ".multiagent/teams";
31
33
 
34
+ // Bundled team templates shipped with the macro-agent package. Used as a
35
+ // fallback when a team name isn't present in the user's `.multiagent/teams/`
36
+ // — so a fresh project can `startTeam('self-driving', cwd)` without first
37
+ // running the seed step. User-local copies always win when present.
38
+ const __filename = fileURLToPath(import.meta.url);
39
+ const __dirname = path.dirname(__filename);
40
+ const PACKAGE_ROOT = path.resolve(__dirname, "..", "..");
41
+ const BUNDLED_TEAMS_DIR = path.join(PACKAGE_ROOT, "templates", "teams");
42
+
32
43
  // =============================================================================
33
44
  // TeamLoader
34
45
  // =============================================================================
@@ -51,7 +62,13 @@ export async function loadTeam(
51
62
  basePath?: string
52
63
  ): Promise<TeamManifest> {
53
64
  const root = basePath ?? process.cwd();
54
- const teamDir = path.join(root, TEAMS_DIR, teamName);
65
+ const userTeamDir = path.join(root, TEAMS_DIR, teamName);
66
+ // Prefer a user-customised template; fall back to the bundled default
67
+ // shipped under `<package>/templates/teams/<name>/`. This lets fresh
68
+ // projects use built-in teams without an explicit seed step, while
69
+ // still honouring per-project overrides.
70
+ const bundledTeamDir = path.join(BUNDLED_TEAMS_DIR, teamName);
71
+ const teamDir = fs.existsSync(userTeamDir) ? userTeamDir : bundledTeamDir;
55
72
 
56
73
  // 1. Load via openteams TemplateLoader with hooks
57
74
  let template;
@@ -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
+ });
@@ -19,9 +19,17 @@ import type { WorkspaceManager } from '../types.js';
19
19
  import { vi } from 'vitest';
20
20
 
21
21
  describe('self-driving team YAML (V3 migration)', () => {
22
+ // Resolve relative to this test file so the path is cwd-independent and
23
+ // points at the shipped package asset (not a user-runtime copy under
24
+ // <project>/.multiagent/ — that only exists after `seed-defaults` runs).
25
+ // `src/workspace/__tests__/` → `../../../` → package root.
26
+ const PACKAGE_ROOT = path.resolve(
27
+ path.dirname(new URL(import.meta.url).pathname),
28
+ '..', '..', '..'
29
+ );
22
30
  const yamlPath = path.join(
23
- process.cwd(),
24
- '.multiagent/teams/self-driving/team.yaml'
31
+ PACKAGE_ROOT,
32
+ 'templates/teams/self-driving/team.yaml'
25
33
  );
26
34
 
27
35
  it('exists on disk', () => {
@@ -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