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
package/src/cli/acp.ts CHANGED
@@ -34,6 +34,10 @@ async function main() {
34
34
  try {
35
35
  const system = await bootV2({
36
36
  cwd: defaultCwd,
37
+ // `--instance-id` picks the on-disk compartment under ~/.macro-agent/
38
+ // so multiple runs with the same id share state. Omit to auto-derive
39
+ // from the cwd hash. See `BootV2Config.instanceId`.
40
+ ...(options.instanceId ? { instanceId: options.instanceId } : {}),
37
41
  // Enable ACP WebSocket server if port is specified (server mode)
38
42
  ...(options.port
39
43
  ? {
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Consolidation-through-tracker tests (A3 follow-up).
3
+ *
4
+ * Verifies `terminateWithChangeConsolidation` prefers
5
+ * `workspaceManager.mergeStream()` when both workspaces carry stream ids —
6
+ * so the merge fires `x-cascade/stream.merged` and propagates to the hub —
7
+ * and cleanly falls back to raw `git merge` when streams aren't available.
8
+ *
9
+ * Unit-only: stubs workspaces + agent manager. No git invocations.
10
+ */
11
+
12
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
13
+ import { terminateWithChangeConsolidation } from '../cascade.js';
14
+ import type {
15
+ CascadeAgentManager,
16
+ WorkspaceProvider,
17
+ } from '../cascade.js';
18
+ import type { Workspace, WorkspaceManager } from '../../workspace/types.js';
19
+ import * as cleanupModule from '../cleanup.js';
20
+
21
+ function mkWorkspace(overrides: Partial<Workspace> = {}): Workspace {
22
+ return {
23
+ agentId: 'agent',
24
+ path: '/tmp/wt',
25
+ branch: 'stream/s',
26
+ streamId: 's',
27
+ role: 'worker' as any,
28
+ createdAt: Date.now(),
29
+ ...overrides,
30
+ };
31
+ }
32
+
33
+ function mkAgentManager(): CascadeAgentManager {
34
+ return {
35
+ getChildren: vi.fn(() => []),
36
+ terminate: vi.fn(async () => {}),
37
+ };
38
+ }
39
+
40
+ describe('terminateWithChangeConsolidation', () => {
41
+ let attemptMergeSpy: ReturnType<typeof vi.spyOn>;
42
+ let getCurrentBranchSpy: ReturnType<typeof vi.spyOn>;
43
+
44
+ beforeEach(() => {
45
+ // Default: branch matches so the "parent on expected branch" warning doesn't fire.
46
+ getCurrentBranchSpy = vi
47
+ .spyOn(cleanupModule, 'getCurrentBranch')
48
+ .mockReturnValue('stream/parent');
49
+ attemptMergeSpy = vi.spyOn(cleanupModule, 'attemptMerge');
50
+ });
51
+
52
+ afterEach(() => {
53
+ vi.restoreAllMocks();
54
+ });
55
+
56
+ it('routes through workspaceManager.mergeStream when both workspaces have stream ids', async () => {
57
+ const mergeStream = vi.fn(() => ({ success: true, newHead: 'abc123' }));
58
+ const ws = { mergeStream } as unknown as WorkspaceManager;
59
+ const provider: WorkspaceProvider = {
60
+ getWorkspace: (id) =>
61
+ id === 'child'
62
+ ? mkWorkspace({ agentId: 'child', streamId: 'child-s', branch: 'stream/child' })
63
+ : mkWorkspace({ agentId: 'parent', streamId: 'parent-s', branch: 'stream/parent', path: '/tmp/parent' }),
64
+ };
65
+ const am = mkAgentManager();
66
+
67
+ const result = await terminateWithChangeConsolidation(
68
+ 'child' as any,
69
+ 'parent' as any,
70
+ am,
71
+ provider,
72
+ undefined,
73
+ ws,
74
+ );
75
+
76
+ expect(result).toEqual({ success: true, merged: true, mergeCommit: 'abc123' });
77
+ expect(mergeStream).toHaveBeenCalledWith({
78
+ sourceStreamId: 'child-s',
79
+ targetStreamId: 'parent-s',
80
+ agentId: 'parent',
81
+ worktree: '/tmp/parent',
82
+ metadata: undefined,
83
+ });
84
+ // Raw-git path must NOT be touched when the tracker handled it.
85
+ expect(attemptMergeSpy).not.toHaveBeenCalled();
86
+ expect(am.terminate).toHaveBeenCalledWith('child', 'changes_consolidated');
87
+ });
88
+
89
+ it('threads a provided taskRef into the mergeStream metadata', async () => {
90
+ const mergeStream = vi.fn(() => ({ success: true, newHead: 'def456' }));
91
+ const ws = { mergeStream } as unknown as WorkspaceManager;
92
+ const provider: WorkspaceProvider = {
93
+ getWorkspace: (id) =>
94
+ mkWorkspace({
95
+ agentId: id,
96
+ streamId: `${id}-s`,
97
+ branch: `stream/${id}`,
98
+ }),
99
+ };
100
+
101
+ const taskRef = { resource_id: 'res-a1', node_id: 'task-a1' };
102
+ await terminateWithChangeConsolidation(
103
+ 'child' as any,
104
+ 'parent' as any,
105
+ mkAgentManager(),
106
+ provider,
107
+ undefined,
108
+ ws,
109
+ taskRef,
110
+ );
111
+
112
+ expect(mergeStream).toHaveBeenCalledWith(
113
+ expect.objectContaining({ metadata: { task_ref: taskRef } }),
114
+ );
115
+ });
116
+
117
+ it('surfaces tracker conflicts without falling back to raw git', async () => {
118
+ const mergeStream = vi.fn(() => ({
119
+ success: false,
120
+ conflicts: ['shared.ts'],
121
+ }));
122
+ const ws = { mergeStream } as unknown as WorkspaceManager;
123
+ const provider: WorkspaceProvider = {
124
+ getWorkspace: (id) =>
125
+ mkWorkspace({
126
+ agentId: id,
127
+ streamId: `${id}-s`,
128
+ branch: `stream/${id}`,
129
+ }),
130
+ };
131
+ const am = mkAgentManager();
132
+
133
+ const result = await terminateWithChangeConsolidation(
134
+ 'child' as any,
135
+ 'parent' as any,
136
+ am,
137
+ provider,
138
+ undefined,
139
+ ws,
140
+ );
141
+
142
+ expect(result.success).toBe(false);
143
+ expect(result.conflicts).toEqual(['shared.ts']);
144
+ expect(attemptMergeSpy).not.toHaveBeenCalled();
145
+ expect(am.terminate).toHaveBeenCalledWith('child', 'merge_conflict');
146
+ });
147
+
148
+ it('falls back to raw git when workspaceManager is not provided', async () => {
149
+ attemptMergeSpy.mockReturnValue({
150
+ success: true,
151
+ mergeCommit: 'rawabc',
152
+ });
153
+ const provider: WorkspaceProvider = {
154
+ getWorkspace: (id) =>
155
+ mkWorkspace({
156
+ agentId: id,
157
+ streamId: `${id}-s`,
158
+ branch: `stream/${id}`,
159
+ }),
160
+ };
161
+ const am = mkAgentManager();
162
+
163
+ const result = await terminateWithChangeConsolidation(
164
+ 'child' as any,
165
+ 'parent' as any,
166
+ am,
167
+ provider,
168
+ undefined,
169
+ // No workspaceManager argument.
170
+ );
171
+
172
+ expect(result).toEqual({ success: true, merged: true, mergeCommit: 'rawabc' });
173
+ expect(attemptMergeSpy).toHaveBeenCalledOnce();
174
+ });
175
+
176
+ it('falls back to raw git when the child workspace lacks a stream id', async () => {
177
+ attemptMergeSpy.mockReturnValue({ success: true, mergeCommit: 'legacyabc' });
178
+ const mergeStream = vi.fn();
179
+ const ws = { mergeStream } as unknown as WorkspaceManager;
180
+ const provider: WorkspaceProvider = {
181
+ getWorkspace: (id) =>
182
+ id === 'child'
183
+ ? mkWorkspace({ agentId: 'child', streamId: '', branch: 'feat/legacy' })
184
+ : mkWorkspace({ agentId: 'parent', streamId: 'parent-s', branch: 'stream/parent' }),
185
+ };
186
+
187
+ const result = await terminateWithChangeConsolidation(
188
+ 'child' as any,
189
+ 'parent' as any,
190
+ mkAgentManager(),
191
+ provider,
192
+ undefined,
193
+ ws,
194
+ );
195
+
196
+ expect(result.success).toBe(true);
197
+ expect(mergeStream).not.toHaveBeenCalled();
198
+ expect(attemptMergeSpy).toHaveBeenCalledOnce();
199
+ });
200
+
201
+ it('falls back to raw git when tracker.mergeStream throws', async () => {
202
+ const mergeStream = vi.fn(() => {
203
+ throw new Error('stream not found');
204
+ });
205
+ attemptMergeSpy.mockReturnValue({ success: true, mergeCommit: 'fallbackabc' });
206
+ const ws = { mergeStream } as unknown as WorkspaceManager;
207
+ const provider: WorkspaceProvider = {
208
+ getWorkspace: (id) =>
209
+ mkWorkspace({
210
+ agentId: id,
211
+ streamId: `${id}-s`,
212
+ branch: `stream/${id}`,
213
+ }),
214
+ };
215
+
216
+ const result = await terminateWithChangeConsolidation(
217
+ 'child' as any,
218
+ 'parent' as any,
219
+ mkAgentManager(),
220
+ provider,
221
+ undefined,
222
+ ws,
223
+ );
224
+
225
+ expect(result.success).toBe(true);
226
+ expect(mergeStream).toHaveBeenCalledOnce();
227
+ expect(attemptMergeSpy).toHaveBeenCalledOnce();
228
+ });
229
+
230
+ it('preserves legacy "no workspace provider" behavior', async () => {
231
+ const am = mkAgentManager();
232
+ const result = await terminateWithChangeConsolidation(
233
+ 'child' as any,
234
+ 'parent' as any,
235
+ am,
236
+ );
237
+ expect(result).toEqual({ success: true, merged: false });
238
+ expect(am.terminate).toHaveBeenCalledWith('child', 'parent_stopped');
239
+ });
240
+ });
@@ -129,11 +129,24 @@ export interface WorkspaceProvider {
129
129
  * If a merge conflict occurs, the merge is aborted and the child is
130
130
  * terminated with a "merge_conflict" reason.
131
131
  *
132
+ * When a `workspaceManager` is provided AND both workspaces carry stream
133
+ * ids, the merge is routed through `workspaceManager.mergeStream()` so
134
+ * it flows through git-cascade's tracker — gaining a `stream.merged`
135
+ * cascade event that propagates to the hub via CascadeBridge. This is
136
+ * the primary observability win of A3 in the integration plan.
137
+ *
138
+ * Falls back to raw `attemptMerge` (unchanged behavior) when either
139
+ * workspace lacks a stream id or no manager is passed — so
140
+ * programmatic/legacy callers keep working unchanged.
141
+ *
132
142
  * @param childId - Child agent to terminate
133
143
  * @param parentId - Parent agent to consolidate changes into
134
144
  * @param agentManager - Agent manager for operations
135
145
  * @param workspaceProvider - Optional workspace provider for getting agent workspaces
136
146
  * @param options - Optional consolidation options
147
+ * @param workspaceManager - Optional workspace manager; enables cascade-
148
+ * event emission on the consolidation merge when both workspaces are
149
+ * stream-backed.
137
150
  * @returns ConsolidationResult indicating success or failure
138
151
  */
139
152
  export async function terminateWithChangeConsolidation(
@@ -141,7 +154,15 @@ export async function terminateWithChangeConsolidation(
141
154
  parentId: AgentId,
142
155
  agentManager: CascadeAgentManager,
143
156
  workspaceProvider?: WorkspaceProvider,
144
- options?: ConsolidationOptions
157
+ options?: ConsolidationOptions,
158
+ workspaceManager?: WorkspaceManager,
159
+ /**
160
+ * Optional task reference inherited from the parent agent's metadata.
161
+ * When provided + routing through the tracker, threaded into the
162
+ * `x-cascade/stream.merged` emit so the hub's cascade_merges row records
163
+ * which task drove the consolidation.
164
+ */
165
+ taskRef?: { resource_id: string; node_id: string }
145
166
  ): Promise<ConsolidationResult> {
146
167
  // If no workspace provider, just terminate normally
147
168
  if (!workspaceProvider) {
@@ -171,7 +192,61 @@ export async function terminateWithChangeConsolidation(
171
192
  // Continue with merge anyway - use the actual current branch
172
193
  }
173
194
 
174
- // Attempt to merge child branch into parent's worktree
195
+ // Attempt to merge child branch into parent's worktree.
196
+ //
197
+ // Preferred path: route through workspaceManager.mergeStream when both
198
+ // workspaces have stream ids. Goes via git-cascade's tracker.mergeStream,
199
+ // which fires `x-cascade/stream.merged` with source/target stream ids so
200
+ // the hub's cascade projection + the source stream's `merged` status
201
+ // update in lockstep with the actual git operation.
202
+ if (
203
+ workspaceManager &&
204
+ childWorkspace.streamId &&
205
+ parentWorkspace.streamId
206
+ ) {
207
+ try {
208
+ const result = workspaceManager.mergeStream({
209
+ sourceStreamId: childWorkspace.streamId,
210
+ targetStreamId: parentWorkspace.streamId,
211
+ agentId: parentId,
212
+ worktree: parentWorkspace.path,
213
+ metadata: taskRef ? { task_ref: taskRef } : undefined,
214
+ });
215
+ if (result.success) {
216
+ await agentManager.terminate(childId, "changes_consolidated");
217
+ return {
218
+ success: true,
219
+ merged: true,
220
+ mergeCommit: result.newHead,
221
+ };
222
+ }
223
+ if (result.conflicts && result.conflicts.length > 0) {
224
+ // Tracker already aborted; emit and terminate with conflict reason.
225
+ console.warn(
226
+ `[cascade] Merge conflict consolidating ${childId} -> ${parentId} via tracker: ${result.conflicts.join(", ")}`
227
+ );
228
+ await agentManager.terminate(childId, "merge_conflict");
229
+ return {
230
+ success: false,
231
+ merged: false,
232
+ conflicts: result.conflicts,
233
+ };
234
+ }
235
+ // Tracker returned a non-conflict failure — fall through to raw
236
+ // git path so we don't regress the consolidation semantics for
237
+ // cases the tracker can't handle (e.g., stream in unexpected
238
+ // status, detached HEAD, branch-name mismatches).
239
+ } catch (err) {
240
+ console.warn(
241
+ `[cascade] tracker.mergeStream failed consolidating ${childId} -> ${parentId}; falling back to raw git: ${err instanceof Error ? err.message : String(err)}`
242
+ );
243
+ // Fall through to raw-git path below.
244
+ }
245
+ }
246
+
247
+ // Fallback: raw git merge. Preserves pre-A3 behavior when streams
248
+ // aren't available (programmatic callers, legacy role-shaped
249
+ // workspaces) or when the tracker path bailed.
175
250
  const mergeMessage =
176
251
  options?.mergeMessage ??
177
252
  `Merge changes from ${childId} (${childBranch})`;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Tests for MAPSidecar.emitEvent() — custom event emission to MAP hub.
3
+ */
4
+
5
+ import { describe, it, expect, vi } from "vitest";
6
+ import { createMAPSidecar } from "../sidecar.js";
7
+ import type { MAPSidecarConfig } from "../types.js";
8
+ import type { AgentManager } from "../../agent/agent-manager.js";
9
+ import type { AgentStore } from "../../agent/agent-store.js";
10
+ import type { InboxAdapter, TasksAdapter } from "../../adapters/types.js";
11
+
12
+ function mockDeps() {
13
+ return {
14
+ agentManager: {
15
+ onLifecycleEvent: vi.fn(() => () => {}),
16
+ list: vi.fn(() => []),
17
+ get: vi.fn(() => null),
18
+ spawn: vi.fn().mockResolvedValue({ agent: { id: "spawned-1" } }),
19
+ close: vi.fn().mockResolvedValue(undefined),
20
+ } as unknown as AgentManager,
21
+ agentStore: {
22
+ getAgent: vi.fn(() => null),
23
+ listAgents: vi.fn(() => []),
24
+ } as unknown as AgentStore,
25
+ inboxAdapter: {
26
+ onDelivery: vi.fn(),
27
+ offDelivery: vi.fn(),
28
+ send: vi.fn().mockResolvedValue("msg-1"),
29
+ } as unknown as InboxAdapter,
30
+ tasksAdapter: {
31
+ createTask: vi.fn().mockResolvedValue("task-1"),
32
+ transitionTask: vi.fn().mockResolvedValue(undefined),
33
+ listTasks: vi.fn().mockResolvedValue([]),
34
+ connected: true,
35
+ } as unknown as TasksAdapter,
36
+ };
37
+ }
38
+
39
+ describe("MAPSidecar.emitEvent", () => {
40
+ it("is a no-op when disconnected", async () => {
41
+ const deps = mockDeps();
42
+ const config: MAPSidecarConfig = {
43
+ server: "ws://127.0.0.1:1",
44
+ scope: "swarm:test",
45
+ reconnection: { enabled: false },
46
+ reconnectIntervalMs: 999999,
47
+ };
48
+
49
+ const sidecar = createMAPSidecar(deps, config);
50
+ await sidecar.start();
51
+ expect(sidecar.connected).toBe(false);
52
+
53
+ // Should not throw when disconnected
54
+ await sidecar.emitEvent!({ type: "dispatch.poll", dispatched: 0 });
55
+
56
+ await sidecar.stop();
57
+ }, 10000);
58
+
59
+ it("is defined on the sidecar", () => {
60
+ const deps = mockDeps();
61
+ const config: MAPSidecarConfig = {
62
+ server: "ws://127.0.0.1:1",
63
+ reconnection: { enabled: false },
64
+ reconnectIntervalMs: 999999,
65
+ };
66
+
67
+ const sidecar = createMAPSidecar(deps, config);
68
+ expect(sidecar.emitEvent).toBeDefined();
69
+ expect(typeof sidecar.emitEvent).toBe("function");
70
+ });
71
+ });
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Cascade Action Handler — receives hub→runtime commands.
3
+ *
4
+ * Listens for `x-cascade/request.*` notifications from the OpenHive hub
5
+ * and dispatches to the GitCascadeAdapter. This is the inbound counterpart
6
+ * to the CascadeBridge (which handles outbound events).
7
+ *
8
+ * Actions are fire-and-forget from the hub's perspective: the hub sends
9
+ * the notification and the UI updates reactively when the resulting
10
+ * `x-cascade/stream.*` event flows back through the bridge.
11
+ *
12
+ * @module map/cascade-action-handler
13
+ */
14
+
15
+ import type { GitCascadeAdapter } from '../workspace/git-cascade-adapter.js';
16
+
17
+ export interface CascadeActionConnection {
18
+ onNotification(
19
+ method: string,
20
+ handler: (params: unknown) => void | Promise<void>,
21
+ ): void;
22
+ offNotification(
23
+ method: string,
24
+ handler: (params: unknown) => void | Promise<void>,
25
+ ): void;
26
+ }
27
+
28
+ const REQUEST_METHODS = {
29
+ MERGE: 'x-cascade/request.merge',
30
+ ABANDON: 'x-cascade/request.abandon',
31
+ PAUSE: 'x-cascade/request.pause',
32
+ RESUME: 'x-cascade/request.resume',
33
+ RESOLVE: 'x-cascade/request.resolve',
34
+ PUSH: 'x-cascade/request.push',
35
+ COMMIT: 'x-cascade/request.commit',
36
+ } as const;
37
+
38
+ /**
39
+ * Register cascade action handlers on the MAP connection.
40
+ * Returns a cleanup function that removes all handlers.
41
+ */
42
+ export function setupCascadeActionHandlers(
43
+ connection: CascadeActionConnection,
44
+ adapter: GitCascadeAdapter,
45
+ ): () => void {
46
+ const handlers: Array<{
47
+ method: string;
48
+ handler: (params: unknown) => void | Promise<void>;
49
+ }> = [];
50
+
51
+ const register = (
52
+ method: string,
53
+ handler: (params: unknown) => void | Promise<void>,
54
+ ): void => {
55
+ connection.onNotification(method, handler);
56
+ handlers.push({ method, handler });
57
+ };
58
+
59
+ /** Find the first worktree checked out on a given stream. */
60
+ function findWorktreeForStream(streamId: string): string | null {
61
+ const wts = adapter.listWorktrees();
62
+ const match = wts.find((wt) => wt.currentStream === streamId);
63
+ return match?.path ?? null;
64
+ }
65
+
66
+ // ── Merge ─────────────────────────────────────────────────────────
67
+ register(REQUEST_METHODS.MERGE, (params: unknown) => {
68
+ const p = params as { stream_id?: string; target_stream_id?: string };
69
+ if (!p?.stream_id) return;
70
+
71
+ const stream = adapter.getStream(p.stream_id);
72
+ const targetStreamId = p.target_stream_id ?? stream?.parentStream;
73
+ if (!targetStreamId) return;
74
+
75
+ const worktreePath = findWorktreeForStream(p.stream_id);
76
+ if (!worktreePath) return;
77
+
78
+ try {
79
+ adapter.mergeStream({
80
+ sourceStream: p.stream_id,
81
+ targetStream: targetStreamId,
82
+ agentId: 'hub-request',
83
+ worktree: worktreePath,
84
+ });
85
+ } catch {
86
+ // Non-fatal — the resulting event (or conflict) will surface via the bridge
87
+ }
88
+ });
89
+
90
+ // ── Abandon ───────────────────────────────────────────────────────
91
+ register(REQUEST_METHODS.ABANDON, (params: unknown) => {
92
+ const p = params as { stream_id?: string; reason?: string };
93
+ if (!p?.stream_id) return;
94
+ try {
95
+ adapter.abandonStream(p.stream_id, { reason: p.reason ?? 'hub-request' });
96
+ } catch { /* non-fatal */ }
97
+ });
98
+
99
+ // ── Pause ─────────────────────────────────────────────────────────
100
+ register(REQUEST_METHODS.PAUSE, (params: unknown) => {
101
+ const p = params as { stream_id?: string; reason?: string };
102
+ if (!p?.stream_id) return;
103
+ try {
104
+ adapter.pauseStream(p.stream_id, p.reason);
105
+ } catch { /* non-fatal */ }
106
+ });
107
+
108
+ // ── Resume ────────────────────────────────────────────────────────
109
+ register(REQUEST_METHODS.RESUME, (params: unknown) => {
110
+ const p = params as { stream_id?: string };
111
+ if (!p?.stream_id) return;
112
+ try {
113
+ adapter.resumeStream(p.stream_id);
114
+ } catch { /* non-fatal */ }
115
+ });
116
+
117
+ // ── Resolve conflict ──────────────────────────────────────────────
118
+ register(REQUEST_METHODS.RESOLVE, (params: unknown) => {
119
+ const p = params as {
120
+ stream_id?: string;
121
+ conflict_id?: string;
122
+ strategy?: string;
123
+ };
124
+ if (!p?.stream_id || !p?.conflict_id) return;
125
+ try {
126
+ adapter.resolveConflict({
127
+ conflictId: p.conflict_id,
128
+ resolution: {
129
+ method: (p.strategy as 'ours' | 'theirs') ?? 'ours',
130
+ resolvedBy: 'hub-request',
131
+ },
132
+ });
133
+ } catch { /* non-fatal */ }
134
+ });
135
+
136
+ // ── Push ───────────────────────────────────────────────────────────
137
+ register(REQUEST_METHODS.PUSH, (params: unknown) => {
138
+ const p = params as {
139
+ stream_id?: string;
140
+ remote?: string;
141
+ target_ref?: string;
142
+ };
143
+ if (!p?.stream_id) return;
144
+
145
+ const worktreePath = findWorktreeForStream(p.stream_id);
146
+ if (!worktreePath) return;
147
+
148
+ const remote = p.remote ?? 'origin';
149
+ const streamBranch = `stream/${p.stream_id}`;
150
+ const targetRef = p.target_ref ?? streamBranch;
151
+
152
+ try {
153
+ const { execSync } = require('child_process');
154
+ execSync(`git push ${remote} ${streamBranch}:refs/heads/${targetRef}`, {
155
+ cwd: worktreePath,
156
+ stdio: 'pipe',
157
+ encoding: 'utf-8',
158
+ });
159
+ // Emit pushed event so the hub records it
160
+ adapter.notifyStreamPushed?.({
161
+ streamId: p.stream_id,
162
+ agentId: 'hub-request',
163
+ pushedCommit: execSync('git rev-parse HEAD', {
164
+ cwd: worktreePath,
165
+ encoding: 'utf-8',
166
+ }).trim(),
167
+ remote,
168
+ remoteRef: targetRef,
169
+ strategy: 'hub-push',
170
+ });
171
+ } catch { /* non-fatal — push failure is reported via absence of pushed event */ }
172
+ });
173
+
174
+ // ── Commit ────────────────────────────────────────────────────────
175
+ register(REQUEST_METHODS.COMMIT, (params: unknown) => {
176
+ const p = params as {
177
+ stream_id?: string;
178
+ message?: string;
179
+ metadata?: Record<string, unknown>;
180
+ };
181
+ if (!p?.stream_id) return;
182
+
183
+ const worktreePath = findWorktreeForStream(p.stream_id);
184
+ if (!worktreePath) return;
185
+
186
+ const message = p.message ?? 'checkpoint (hub-requested)';
187
+ try {
188
+ adapter.commitChanges({
189
+ streamId: p.stream_id,
190
+ agentId: 'hub-request',
191
+ worktree: worktreePath,
192
+ message,
193
+ metadata: p.metadata,
194
+ });
195
+ } catch { /* non-fatal — nothing to commit, or stream conflicted */ }
196
+ });
197
+
198
+ // ── Cleanup ───────────────────────────────────────────────────────
199
+ return () => {
200
+ for (const { method, handler } of handlers) {
201
+ connection.offNotification(method, handler);
202
+ }
203
+ handlers.length = 0;
204
+ };
205
+ }
@@ -21,6 +21,14 @@
21
21
  */
22
22
 
23
23
  import { CASCADE_METHODS } from 'git-cascade/events';
24
+
25
+ // Fallback method names for events not yet present in the installed
26
+ // git-cascade version (`STREAM_PAUSED`, `STREAM_RESUMED`,
27
+ // `STREAM_ROLLED_BACK` were added after 0.0.7). When the dep is upgraded,
28
+ // collapse these back into `CASCADE_METHODS.*` for a single source of truth.
29
+ const X_CASCADE_STREAM_PAUSED = 'x-cascade/stream.paused' as const;
30
+ const X_CASCADE_STREAM_RESUMED = 'x-cascade/stream.resumed' as const;
31
+ const X_CASCADE_STREAM_ROLLED_BACK = 'x-cascade/stream.rolled_back' as const;
24
32
  import type { LifecycleBridgeConnection } from './lifecycle-bridge.js';
25
33
  import type {
26
34
  GitCascadeAdapter,
@@ -290,11 +298,41 @@ function translate(event: GitCascadeEvent): TranslatedCall | null {
290
298
  },
291
299
  };
292
300
 
293
- // Local-only events with no MAP counterpart (Phase 1 scope).
294
- // 'stream:updated', 'stream:forked', 'stream:paused', 'stream:resumed',
295
- // 'worktree:*', 'task:*', 'change:*', 'conflict:*' (legacy local-only
296
- // variant — cascade-bridge handles the cascade-driven 'conflict:resolved'
297
- // separately above)
301
+ case 'stream:paused':
302
+ if (!d.streamId) return null;
303
+ return {
304
+ method: X_CASCADE_STREAM_PAUSED,
305
+ params: {
306
+ stream_id: d.streamId,
307
+ reason: d.reason,
308
+ },
309
+ };
310
+
311
+ case 'stream:resumed':
312
+ if (!d.streamId) return null;
313
+ return {
314
+ method: X_CASCADE_STREAM_RESUMED,
315
+ params: {
316
+ stream_id: d.streamId,
317
+ },
318
+ };
319
+
320
+ case 'stream:rolled_back':
321
+ if (!d.streamId) return null;
322
+ return {
323
+ method: X_CASCADE_STREAM_ROLLED_BACK,
324
+ params: {
325
+ stream_id: d.streamId,
326
+ strategy: d.strategy,
327
+ target: d.target,
328
+ new_head: d.newHead,
329
+ },
330
+ };
331
+
332
+ // Local-only events with no MAP counterpart.
333
+ // 'stream:updated', 'stream:forked', 'worktree:*', 'task:*',
334
+ // 'change:*', 'conflict:*' (legacy local-only variant — cascade-bridge
335
+ // handles the cascade-driven 'conflict:resolved' separately above)
298
336
  default:
299
337
  return null;
300
338
  }