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
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
|
+
});
|
package/src/lifecycle/cascade.ts
CHANGED
|
@@ -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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
}
|