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.
- package/CLAUDE.md +97 -0
- package/dist/acp/macro-agent.d.ts.map +1 -1
- package/dist/acp/macro-agent.js +42 -6
- package/dist/acp/macro-agent.js.map +1 -1
- package/dist/adapters/tasks-adapter.d.ts.map +1 -1
- package/dist/adapters/tasks-adapter.js +3 -0
- package/dist/adapters/tasks-adapter.js.map +1 -1
- package/dist/adapters/types.d.ts +1 -0
- package/dist/adapters/types.d.ts.map +1 -1
- package/dist/agent/agent-manager-v2.d.ts.map +1 -1
- package/dist/agent/agent-manager-v2.js +74 -11
- package/dist/agent/agent-manager-v2.js.map +1 -1
- package/dist/agent/agent-store.d.ts +10 -0
- package/dist/agent/agent-store.d.ts.map +1 -1
- package/dist/agent/agent-store.js +22 -0
- package/dist/agent/agent-store.js.map +1 -1
- package/dist/boot-v2.d.ts +88 -1
- package/dist/boot-v2.d.ts.map +1 -1
- package/dist/boot-v2.js +343 -7
- package/dist/boot-v2.js.map +1 -1
- package/dist/cli/acp.js +4 -0
- package/dist/cli/acp.js.map +1 -1
- package/dist/lifecycle/cascade.d.ts +25 -2
- package/dist/lifecycle/cascade.d.ts.map +1 -1
- package/dist/lifecycle/cascade.js +70 -2
- package/dist/lifecycle/cascade.js.map +1 -1
- package/dist/map/cascade-action-handler.d.ts +24 -0
- package/dist/map/cascade-action-handler.d.ts.map +1 -0
- package/dist/map/cascade-action-handler.js +170 -0
- package/dist/map/cascade-action-handler.js.map +1 -0
- package/dist/map/cascade-bridge.d.ts.map +1 -1
- package/dist/map/cascade-bridge.js +42 -5
- package/dist/map/cascade-bridge.js.map +1 -1
- package/dist/map/coordination-handler.d.ts.map +1 -1
- package/dist/map/coordination-handler.js +12 -1
- package/dist/map/coordination-handler.js.map +1 -1
- package/dist/map/server.d.ts.map +1 -1
- package/dist/map/server.js +172 -1
- package/dist/map/server.js.map +1 -1
- package/dist/map/sidecar.d.ts.map +1 -1
- package/dist/map/sidecar.js +18 -2
- package/dist/map/sidecar.js.map +1 -1
- package/dist/map/types.d.ts +2 -0
- package/dist/map/types.d.ts.map +1 -1
- package/dist/workspace/git-cascade-adapter.d.ts +1 -1
- package/dist/workspace/git-cascade-adapter.d.ts.map +1 -1
- package/dist/workspace/git-cascade-adapter.js +26 -0
- package/dist/workspace/git-cascade-adapter.js.map +1 -1
- package/dist/workspace/landing/merge-to-parent.d.ts.map +1 -1
- package/dist/workspace/landing/merge-to-parent.js +1 -0
- package/dist/workspace/landing/merge-to-parent.js.map +1 -1
- package/dist/workspace/recovery/spawn-resolver.d.ts.map +1 -1
- package/dist/workspace/recovery/spawn-resolver.js +8 -1
- package/dist/workspace/recovery/spawn-resolver.js.map +1 -1
- package/dist/workspace/types-v3.d.ts +7 -0
- package/dist/workspace/types-v3.d.ts.map +1 -1
- package/dist/workspace/types-v3.js.map +1 -1
- package/dist/workspace/types.d.ts +17 -0
- package/dist/workspace/types.d.ts.map +1 -1
- package/dist/workspace/workspace-manager.d.ts +9 -0
- package/dist/workspace/workspace-manager.d.ts.map +1 -1
- package/dist/workspace/workspace-manager.js +45 -2
- package/dist/workspace/workspace-manager.js.map +1 -1
- package/docs/design/task-dispatcher.md +880 -0
- package/package.json +3 -2
- package/src/__tests__/boot-v2.test.ts +435 -0
- package/src/__tests__/e2e/acp-over-map.e2e.test.ts +92 -0
- package/src/__tests__/e2e/bootstrap.e2e.test.ts +319 -0
- package/src/__tests__/e2e/dispatch-coordination.e2e.test.ts +495 -0
- package/src/__tests__/e2e/dispatch-live.e2e.test.ts +564 -0
- package/src/__tests__/e2e/dispatch-opentasks.e2e.test.ts +496 -0
- package/src/__tests__/e2e/dispatch-phase2-live.e2e.test.ts +456 -0
- package/src/__tests__/e2e/dispatch-phase2.e2e.test.ts +386 -0
- package/src/__tests__/e2e/dispatch.e2e.test.ts +376 -0
- package/src/acp/macro-agent.ts +41 -6
- package/src/adapters/__tests__/tasks-adapter.test.ts +1 -0
- package/src/adapters/tasks-adapter.ts +3 -0
- package/src/adapters/types.ts +1 -0
- package/src/agent/__tests__/agent-store.test.ts +52 -0
- package/src/agent/agent-manager-v2.ts +79 -11
- package/src/agent/agent-store.ts +24 -0
- package/src/boot-v2.ts +522 -35
- package/src/cli/acp.ts +4 -0
- package/src/lifecycle/__tests__/cascade-consolidation.test.ts +240 -0
- package/src/lifecycle/cascade.ts +77 -2
- package/src/map/__tests__/emit-event.test.ts +71 -0
- package/src/map/cascade-action-handler.ts +205 -0
- package/src/map/cascade-bridge.ts +43 -5
- package/src/map/coordination-handler.ts +13 -1
- package/src/map/server.ts +178 -1
- package/src/map/sidecar.ts +19 -2
- package/src/map/types.ts +3 -0
- package/src/workspace/__tests__/land-dispatch.test.ts +214 -0
- package/src/workspace/git-cascade-adapter.ts +30 -3
- package/src/workspace/landing/__tests__/strategies.test.ts +42 -0
- package/src/workspace/landing/merge-to-parent.ts +1 -0
- package/src/workspace/recovery/spawn-resolver.ts +8 -1
- package/src/workspace/types-v3.ts +7 -0
- package/src/workspace/types.ts +20 -0
- package/src/workspace/workspace-manager.ts +61 -2
- package/dist/workspace/dataplane-adapter.d.ts +0 -260
- package/dist/workspace/dataplane-adapter.d.ts.map +0 -1
- package/dist/workspace/dataplane-adapter.js +0 -416
- 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
|
-
//
|
|
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:
|
|
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);
|
package/src/map/sidecar.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|