macro-agent 0.1.10 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/teams/seed-defaults.d.ts.map +1 -1
- package/dist/teams/seed-defaults.js +6 -2
- package/dist/teams/seed-defaults.js.map +1 -1
- package/dist/teams/team-loader.d.ts.map +1 -1
- package/dist/teams/team-loader.js +17 -1
- package/dist/teams/team-loader.js.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 -3
- 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/teams/seed-defaults.ts +6 -2
- package/src/teams/team-loader.ts +18 -1
- package/src/workspace/__tests__/land-dispatch.test.ts +214 -0
- package/src/workspace/__tests__/self-driving-yaml.test.ts +10 -2
- 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/templates/teams/self-driving/team.yaml +142 -0
- package/tsconfig.json +2 -1
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task Dispatch E2E Tests (mocked agents)
|
|
3
|
+
*
|
|
4
|
+
* Tests dispatch boot wiring, configuration, and basic dispatch flow
|
|
5
|
+
* using mocked acp-factory and opentasks (no real agents or daemon).
|
|
6
|
+
*
|
|
7
|
+
* REQUIRES: RUN_E2E_TESTS=true
|
|
8
|
+
*
|
|
9
|
+
* Run with:
|
|
10
|
+
* RUN_E2E_TESTS=true npx vitest run --config vitest.e2e.config.ts src/__tests__/e2e/dispatch.e2e.test.ts
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
describe,
|
|
15
|
+
it,
|
|
16
|
+
expect,
|
|
17
|
+
beforeEach,
|
|
18
|
+
afterEach,
|
|
19
|
+
vi,
|
|
20
|
+
} from "vitest";
|
|
21
|
+
import * as path from "path";
|
|
22
|
+
import * as os from "os";
|
|
23
|
+
import * as fs from "fs";
|
|
24
|
+
import { bootV2, type MacroAgentSystemV2 } from "../../boot-v2.js";
|
|
25
|
+
|
|
26
|
+
// ─────────────────────────────────────────────────────────────────
|
|
27
|
+
// Configuration
|
|
28
|
+
// ─────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
const RUN_E2E = !!process.env.RUN_E2E_TESTS;
|
|
31
|
+
const describeFn = RUN_E2E ? describe : describe.skip;
|
|
32
|
+
|
|
33
|
+
// ─────────────────────────────────────────────────────────────────
|
|
34
|
+
// Mocks
|
|
35
|
+
// ─────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
vi.mock("acp-factory", () => ({
|
|
38
|
+
AgentFactory: {
|
|
39
|
+
spawn: vi.fn().mockResolvedValue({
|
|
40
|
+
createSession: vi.fn().mockResolvedValue({
|
|
41
|
+
id: `session-${Date.now()}`,
|
|
42
|
+
prompt: vi.fn().mockReturnValue({
|
|
43
|
+
[Symbol.asyncIterator]: () => ({
|
|
44
|
+
next: () => Promise.resolve({ done: true, value: undefined }),
|
|
45
|
+
}),
|
|
46
|
+
}),
|
|
47
|
+
forkWithFlush: vi.fn().mockResolvedValue({ id: `forked-${Date.now()}` }),
|
|
48
|
+
}),
|
|
49
|
+
loadSession: vi.fn().mockResolvedValue({ id: `loaded-${Date.now()}` }),
|
|
50
|
+
close: vi.fn().mockResolvedValue(undefined),
|
|
51
|
+
isRunning: vi.fn().mockReturnValue(true),
|
|
52
|
+
}),
|
|
53
|
+
},
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
vi.mock("opentasks", () => ({
|
|
57
|
+
OpenTasksClient: vi.fn().mockImplementation(() => ({
|
|
58
|
+
connect: vi.fn().mockRejectedValue(new Error("No daemon")),
|
|
59
|
+
disconnect: vi.fn(),
|
|
60
|
+
query: vi.fn().mockResolvedValue({ items: [] }),
|
|
61
|
+
link: vi.fn().mockResolvedValue({ success: true }),
|
|
62
|
+
task: vi.fn().mockResolvedValue({ id: "t-1" }),
|
|
63
|
+
})),
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
// ─────────────────────────────────────────────────────────────────
|
|
67
|
+
// Helpers
|
|
68
|
+
// ─────────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
function createTestDir(): string {
|
|
71
|
+
const dir = path.join(
|
|
72
|
+
os.tmpdir(),
|
|
73
|
+
`dispatch-e2e-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
74
|
+
);
|
|
75
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
76
|
+
return dir;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ─────────────────────────────────────────────────────────────────
|
|
80
|
+
// Tests
|
|
81
|
+
// ─────────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
describeFn("Task Dispatch E2E", () => {
|
|
84
|
+
let system: MacroAgentSystemV2;
|
|
85
|
+
let testDir: string;
|
|
86
|
+
|
|
87
|
+
beforeEach(async () => {
|
|
88
|
+
testDir = createTestDir();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
afterEach(async () => {
|
|
92
|
+
if (system) {
|
|
93
|
+
try {
|
|
94
|
+
const running = system.agentManager.list({ state: "running" } as any);
|
|
95
|
+
for (const agent of running) {
|
|
96
|
+
await system.agentManager.terminate(agent.id, "cancelled");
|
|
97
|
+
}
|
|
98
|
+
} catch { /* best effort */ }
|
|
99
|
+
await system.shutdown();
|
|
100
|
+
}
|
|
101
|
+
if (fs.existsSync(testDir)) {
|
|
102
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ── Boot with dispatch enabled ─────────────────────────────
|
|
107
|
+
|
|
108
|
+
describe("Boot", () => {
|
|
109
|
+
it("boots successfully with dispatch enabled", async () => {
|
|
110
|
+
system = await bootV2({
|
|
111
|
+
cwd: testDir,
|
|
112
|
+
baseDir: testDir,
|
|
113
|
+
inbox: { socketPath: path.join(testDir, "inbox.sock") },
|
|
114
|
+
dispatch: {
|
|
115
|
+
enabled: true,
|
|
116
|
+
pollIntervalMs: 60_000,
|
|
117
|
+
maxConcurrent: 3,
|
|
118
|
+
defaultRole: "worker",
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
expect(system).toBeDefined();
|
|
123
|
+
expect(system.taskDispatcher).toBeDefined();
|
|
124
|
+
expect(system.taskDispatcher!.running).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("boots successfully with dispatch and reconcile enabled", async () => {
|
|
128
|
+
system = await bootV2({
|
|
129
|
+
cwd: testDir,
|
|
130
|
+
baseDir: testDir,
|
|
131
|
+
inbox: { socketPath: path.join(testDir, "inbox.sock") },
|
|
132
|
+
dispatch: {
|
|
133
|
+
enabled: true,
|
|
134
|
+
pollIntervalMs: 60_000,
|
|
135
|
+
maxConcurrent: 3,
|
|
136
|
+
reconcile: { enabled: true, intervalMs: 120_000 },
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
expect(system.taskDispatcher).toBeDefined();
|
|
141
|
+
expect(system.taskDispatcher!.running).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("boots without dispatch when not enabled", async () => {
|
|
145
|
+
system = await bootV2({
|
|
146
|
+
cwd: testDir,
|
|
147
|
+
baseDir: testDir,
|
|
148
|
+
inbox: { socketPath: path.join(testDir, "inbox.sock") },
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
expect(system.taskDispatcher).toBeUndefined();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("exposes tracker for observability", async () => {
|
|
155
|
+
system = await bootV2({
|
|
156
|
+
cwd: testDir,
|
|
157
|
+
baseDir: testDir,
|
|
158
|
+
inbox: { socketPath: path.join(testDir, "inbox.sock") },
|
|
159
|
+
dispatch: {
|
|
160
|
+
enabled: true,
|
|
161
|
+
pollIntervalMs: 60_000,
|
|
162
|
+
maxConcurrent: 3,
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
expect(system.taskDispatcher!.tracker).toBeDefined();
|
|
167
|
+
expect(system.taskDispatcher!.tracker.activeCount()).toBe(0);
|
|
168
|
+
expect(system.taskDispatcher!.tracker.listRetries()).toHaveLength(0);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ── Dispatch via dispatchNow ───────────────────────────────
|
|
173
|
+
|
|
174
|
+
describe("Dispatch", () => {
|
|
175
|
+
it("dispatchNow triggers a dispatch cycle", async () => {
|
|
176
|
+
system = await bootV2({
|
|
177
|
+
cwd: testDir,
|
|
178
|
+
baseDir: testDir,
|
|
179
|
+
inbox: { socketPath: path.join(testDir, "inbox.sock") },
|
|
180
|
+
dispatch: {
|
|
181
|
+
enabled: true,
|
|
182
|
+
pollIntervalMs: 600_000,
|
|
183
|
+
maxConcurrent: 5,
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// dispatchNow should not throw even with no ready tasks
|
|
188
|
+
await system.taskDispatcher!.dispatchNow();
|
|
189
|
+
expect(system.taskDispatcher!.tracker.activeCount()).toBe(0);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("reconcileNow triggers a reconciliation cycle", async () => {
|
|
193
|
+
system = await bootV2({
|
|
194
|
+
cwd: testDir,
|
|
195
|
+
baseDir: testDir,
|
|
196
|
+
inbox: { socketPath: path.join(testDir, "inbox.sock") },
|
|
197
|
+
dispatch: {
|
|
198
|
+
enabled: true,
|
|
199
|
+
pollIntervalMs: 600_000,
|
|
200
|
+
maxConcurrent: 3,
|
|
201
|
+
reconcile: { enabled: true, intervalMs: 600_000 },
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// reconcileNow should not throw with no active dispatches
|
|
206
|
+
await system.taskDispatcher!.reconcileNow();
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// ── Event subscription ─────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
describe("Events", () => {
|
|
213
|
+
it("emits poll events via onEvent", async () => {
|
|
214
|
+
system = await bootV2({
|
|
215
|
+
cwd: testDir,
|
|
216
|
+
baseDir: testDir,
|
|
217
|
+
inbox: { socketPath: path.join(testDir, "inbox.sock") },
|
|
218
|
+
dispatch: {
|
|
219
|
+
enabled: true,
|
|
220
|
+
pollIntervalMs: 600_000,
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const events: any[] = [];
|
|
225
|
+
system.taskDispatcher!.onEvent((e) => events.push(e));
|
|
226
|
+
|
|
227
|
+
await system.taskDispatcher!.dispatchNow();
|
|
228
|
+
|
|
229
|
+
const poll = events.find((e) => e.type === "poll");
|
|
230
|
+
expect(poll).toBeDefined();
|
|
231
|
+
expect(poll.dispatched).toBe(0);
|
|
232
|
+
expect(poll.active).toBe(0);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// ── Shutdown ───────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
describe("Shutdown", () => {
|
|
239
|
+
it("shuts down cleanly with dispatch enabled", async () => {
|
|
240
|
+
system = await bootV2({
|
|
241
|
+
cwd: testDir,
|
|
242
|
+
baseDir: testDir,
|
|
243
|
+
inbox: { socketPath: path.join(testDir, "inbox.sock") },
|
|
244
|
+
dispatch: {
|
|
245
|
+
enabled: true,
|
|
246
|
+
pollIntervalMs: 60_000,
|
|
247
|
+
maxConcurrent: 3,
|
|
248
|
+
reconcile: { enabled: true, intervalMs: 120_000 },
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
await system.shutdown();
|
|
253
|
+
system = undefined as any;
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// ── Config Variations ──────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
describe("Configuration", () => {
|
|
260
|
+
it("applies custom config values", async () => {
|
|
261
|
+
system = await bootV2({
|
|
262
|
+
cwd: testDir,
|
|
263
|
+
baseDir: testDir,
|
|
264
|
+
inbox: { socketPath: path.join(testDir, "inbox.sock") },
|
|
265
|
+
dispatch: {
|
|
266
|
+
enabled: true,
|
|
267
|
+
pollIntervalMs: 5_000,
|
|
268
|
+
maxConcurrent: 10,
|
|
269
|
+
defaultRole: "security-auditor",
|
|
270
|
+
tags: ["security", "audit"],
|
|
271
|
+
maxRetries: 5,
|
|
272
|
+
retryBaseDelayMs: 5_000,
|
|
273
|
+
retryMaxDelayMs: 120_000,
|
|
274
|
+
reconcile: { enabled: true, intervalMs: 30_000 },
|
|
275
|
+
eligibility: {
|
|
276
|
+
minPriority: 3,
|
|
277
|
+
excludeTags: ["wip"],
|
|
278
|
+
minScore: 0.5,
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
expect(system.taskDispatcher).toBeDefined();
|
|
284
|
+
expect(system.taskDispatcher!.running).toBe(true);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("boots without reconcile when reconcile.enabled is false", async () => {
|
|
288
|
+
system = await bootV2({
|
|
289
|
+
cwd: testDir,
|
|
290
|
+
baseDir: testDir,
|
|
291
|
+
inbox: { socketPath: path.join(testDir, "inbox.sock") },
|
|
292
|
+
dispatch: {
|
|
293
|
+
enabled: true,
|
|
294
|
+
pollIntervalMs: 60_000,
|
|
295
|
+
reconcile: { enabled: false },
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// Dispatcher should still work — just no reconcile timer
|
|
300
|
+
expect(system.taskDispatcher).toBeDefined();
|
|
301
|
+
expect(system.taskDispatcher!.running).toBe(true);
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// ── MAP Event Bridge ───────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
describe("MAP Event Bridge", () => {
|
|
308
|
+
it("dispatcher works without MAP sidecar", async () => {
|
|
309
|
+
// No MAP config → no sidecar → dispatch should still work
|
|
310
|
+
system = await bootV2({
|
|
311
|
+
cwd: testDir,
|
|
312
|
+
baseDir: testDir,
|
|
313
|
+
inbox: { socketPath: path.join(testDir, "inbox.sock") },
|
|
314
|
+
dispatch: {
|
|
315
|
+
enabled: true,
|
|
316
|
+
pollIntervalMs: 60_000,
|
|
317
|
+
},
|
|
318
|
+
// No map config
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
expect(system.taskDispatcher).toBeDefined();
|
|
322
|
+
expect(system.taskDispatcher!.running).toBe(true);
|
|
323
|
+
|
|
324
|
+
// dispatchNow should work without MAP bridge
|
|
325
|
+
await system.taskDispatcher!.dispatchNow();
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("onEvent subscription works independently of MAP", async () => {
|
|
329
|
+
system = await bootV2({
|
|
330
|
+
cwd: testDir,
|
|
331
|
+
baseDir: testDir,
|
|
332
|
+
inbox: { socketPath: path.join(testDir, "inbox.sock") },
|
|
333
|
+
dispatch: {
|
|
334
|
+
enabled: true,
|
|
335
|
+
pollIntervalMs: 60_000,
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
const events: any[] = [];
|
|
340
|
+
const unsub = system.taskDispatcher!.onEvent((e) => events.push(e));
|
|
341
|
+
|
|
342
|
+
await system.taskDispatcher!.dispatchNow();
|
|
343
|
+
|
|
344
|
+
expect(events.some((e) => e.type === "poll")).toBe(true);
|
|
345
|
+
|
|
346
|
+
// Unsubscribe stops events
|
|
347
|
+
const countBefore = events.length;
|
|
348
|
+
unsub();
|
|
349
|
+
await system.taskDispatcher!.dispatchNow();
|
|
350
|
+
expect(events.length).toBe(countBefore);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("MAP sidecar with unreachable server does not break dispatch", async () => {
|
|
354
|
+
system = await bootV2({
|
|
355
|
+
cwd: testDir,
|
|
356
|
+
baseDir: testDir,
|
|
357
|
+
inbox: { socketPath: path.join(testDir, "inbox.sock") },
|
|
358
|
+
dispatch: {
|
|
359
|
+
enabled: true,
|
|
360
|
+
pollIntervalMs: 60_000,
|
|
361
|
+
},
|
|
362
|
+
map: {
|
|
363
|
+
enabled: true,
|
|
364
|
+
server: "ws://127.0.0.1:1", // Unreachable
|
|
365
|
+
scope: "swarm:test",
|
|
366
|
+
},
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// Dispatch should work even though MAP sidecar failed to connect
|
|
370
|
+
expect(system.taskDispatcher).toBeDefined();
|
|
371
|
+
expect(system.taskDispatcher!.running).toBe(true);
|
|
372
|
+
|
|
373
|
+
await system.taskDispatcher!.dispatchNow();
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
});
|
package/src/acp/macro-agent.ts
CHANGED
|
@@ -587,12 +587,47 @@ export function createMacroAgent(
|
|
|
587
587
|
return {};
|
|
588
588
|
}
|
|
589
589
|
|
|
590
|
-
// No mapping
|
|
591
|
-
//
|
|
592
|
-
//
|
|
593
|
-
//
|
|
594
|
-
//
|
|
590
|
+
// No in-memory mapping and the ACP sessionId isn't an agent ID. If the
|
|
591
|
+
// client supplied provider_session_id via _meta, reverse-lookup the
|
|
592
|
+
// owning agent in the agent-store and resume it. This is the durable
|
|
593
|
+
// cross-restart recovery path: macro-agent's sessionMapper is
|
|
594
|
+
// in-memory, so after a process restart it's empty, but the agent +
|
|
595
|
+
// session records survive on disk keyed by provider_session_id.
|
|
596
|
+
//
|
|
597
|
+
// The critical piece is creating the sessionMapper entry here — without
|
|
598
|
+
// it, subsequent `prompt` calls throw `session not found` and the ACP
|
|
599
|
+
// layer catches that and returns stopReason:"cancelled", making the
|
|
600
|
+
// session appear unresponsive.
|
|
595
601
|
if (metaProviderSessionId) {
|
|
602
|
+
const store = (system as any).agentStore;
|
|
603
|
+
const sessionRec = typeof store?.findSessionByProviderSessionId === "function"
|
|
604
|
+
? store.findSessionByProviderSessionId(metaProviderSessionId)
|
|
605
|
+
: undefined;
|
|
606
|
+
if (sessionRec?.agent_id) {
|
|
607
|
+
const agentId = sessionRec.agent_id;
|
|
608
|
+
try {
|
|
609
|
+
// Idempotent: resume() throws ALREADY_RUNNING if the agent is
|
|
610
|
+
// already active (e.g. _macro/resumeAgent just brought it back).
|
|
611
|
+
// We still need to bind the ACP session to this agent.
|
|
612
|
+
if (!agentManager.hasActiveSession(agentId as any)) {
|
|
613
|
+
await agentManager.resume(agentId as any);
|
|
614
|
+
}
|
|
615
|
+
// Bind under BOTH the macro-agent ACP sessionId AND the provider
|
|
616
|
+
// session UUID. The MAP SDK's ACPStreamConnection often ends up
|
|
617
|
+
// storing _meta.provider_session_id as its stream.sessionId —
|
|
618
|
+
// which is what swarmcraft echoes back in session/prompt. Without
|
|
619
|
+
// the UUID mapping, prompt hits sessionMapper with the UUID key
|
|
620
|
+
// and fails (→ stopReason: cancelled).
|
|
621
|
+
sessionMapper.createMapping(sessionId, agentId);
|
|
622
|
+
if (metaProviderSessionId !== sessionId) {
|
|
623
|
+
sessionMapper.createMapping(metaProviderSessionId as any, agentId);
|
|
624
|
+
}
|
|
625
|
+
await replayHistory(agentId, metaProviderSessionId);
|
|
626
|
+
return {};
|
|
627
|
+
} catch {
|
|
628
|
+
// Fall through to history-only replay below
|
|
629
|
+
}
|
|
630
|
+
}
|
|
596
631
|
await replayHistory(undefined, metaProviderSessionId);
|
|
597
632
|
return {};
|
|
598
633
|
}
|
|
@@ -931,7 +966,7 @@ export function createMacroAgent(
|
|
|
931
966
|
}
|
|
932
967
|
|
|
933
968
|
return { stopReason: "end_turn" };
|
|
934
|
-
} catch
|
|
969
|
+
} catch {
|
|
935
970
|
// If prompt fails, still return a valid response
|
|
936
971
|
return { stopReason: "cancelled" };
|
|
937
972
|
} finally {
|
|
@@ -88,6 +88,7 @@ export class DefaultTasksAdapter implements ITasksAdapter {
|
|
|
88
88
|
parent_id: opts.parent,
|
|
89
89
|
tags: opts.tags,
|
|
90
90
|
priority: opts.priority,
|
|
91
|
+
metadata: opts.metadata,
|
|
91
92
|
});
|
|
92
93
|
|
|
93
94
|
return node?.id ?? "";
|
|
@@ -129,6 +130,7 @@ export class DefaultTasksAdapter implements ITasksAdapter {
|
|
|
129
130
|
limit: opts?.limit,
|
|
130
131
|
tags: opts?.tags,
|
|
131
132
|
},
|
|
133
|
+
verbose: true,
|
|
132
134
|
});
|
|
133
135
|
|
|
134
136
|
return (result.items ?? []).map((n: NodeSummaryLike) =>
|
|
@@ -146,6 +148,7 @@ export class DefaultTasksAdapter implements ITasksAdapter {
|
|
|
146
148
|
tags: filter?.tags,
|
|
147
149
|
limit: filter?.limit,
|
|
148
150
|
},
|
|
151
|
+
verbose: true,
|
|
149
152
|
});
|
|
150
153
|
|
|
151
154
|
return (result.items ?? []).map((n: NodeSummaryLike) =>
|
package/src/adapters/types.ts
CHANGED
|
@@ -409,5 +409,57 @@ describe("AgentStore", () => {
|
|
|
409
409
|
const session = store.getSession("agent-1")!;
|
|
410
410
|
expect(session.provider_session_id).toBeUndefined();
|
|
411
411
|
});
|
|
412
|
+
|
|
413
|
+
it("findSessionByProviderSessionId returns the matching session", () => {
|
|
414
|
+
store.putAgent(makeAgent({ id: "agent-2" }));
|
|
415
|
+
store.putSession({
|
|
416
|
+
agent_id: "agent-1",
|
|
417
|
+
session_id: "session-abc",
|
|
418
|
+
provider_session_id: "psid-xyz",
|
|
419
|
+
created_at: Date.now(),
|
|
420
|
+
});
|
|
421
|
+
store.putSession({
|
|
422
|
+
agent_id: "agent-2",
|
|
423
|
+
session_id: "session-def",
|
|
424
|
+
provider_session_id: "psid-other",
|
|
425
|
+
created_at: Date.now(),
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
const found = store.findSessionByProviderSessionId("psid-xyz");
|
|
429
|
+
expect(found).not.toBeNull();
|
|
430
|
+
expect(found!.agent_id).toBe("agent-1");
|
|
431
|
+
expect(found!.session_id).toBe("session-abc");
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("findSessionByProviderSessionId returns null when no match", () => {
|
|
435
|
+
store.putSession({
|
|
436
|
+
agent_id: "agent-1",
|
|
437
|
+
session_id: "session-1",
|
|
438
|
+
provider_session_id: "psid-1",
|
|
439
|
+
created_at: Date.now(),
|
|
440
|
+
});
|
|
441
|
+
expect(store.findSessionByProviderSessionId("psid-missing")).toBeNull();
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it("findSessionByProviderSessionId returns most recent on duplicate psid", () => {
|
|
445
|
+
// Shouldn't normally happen but verify the ORDER BY created_at DESC path.
|
|
446
|
+
store.putAgent(makeAgent({ id: "agent-old" }));
|
|
447
|
+
store.putAgent(makeAgent({ id: "agent-new" }));
|
|
448
|
+
store.putSession({
|
|
449
|
+
agent_id: "agent-old",
|
|
450
|
+
session_id: "session-old",
|
|
451
|
+
provider_session_id: "psid-dup",
|
|
452
|
+
created_at: 1000,
|
|
453
|
+
});
|
|
454
|
+
store.putSession({
|
|
455
|
+
agent_id: "agent-new",
|
|
456
|
+
session_id: "session-new",
|
|
457
|
+
provider_session_id: "psid-dup",
|
|
458
|
+
created_at: 2000,
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
const found = store.findSessionByProviderSessionId("psid-dup");
|
|
462
|
+
expect(found!.agent_id).toBe("agent-new");
|
|
463
|
+
});
|
|
412
464
|
});
|
|
413
465
|
});
|
|
@@ -879,7 +879,16 @@ export function createAgentManagerV2(
|
|
|
879
879
|
healthCheckService.stopForCoordinator(agentId);
|
|
880
880
|
}
|
|
881
881
|
|
|
882
|
-
//
|
|
882
|
+
// Land the worker's work if completed with a workspace.
|
|
883
|
+
//
|
|
884
|
+
// V3 path (preferred): look up the role's YAML landing strategy via
|
|
885
|
+
// TopologyPolicy.getRoleConfig and dispatch through
|
|
886
|
+
// WorkspaceManager.land(). This fires cascade events (stream.merged or
|
|
887
|
+
// queue.added) so the hub sees the work. Landing = 'none' short-circuits.
|
|
888
|
+
//
|
|
889
|
+
// Legacy fallback: if no TopologyPolicy is wired or it can't resolve a
|
|
890
|
+
// landing for this role, submit to the legacy MergeQueue as before.
|
|
891
|
+
// Keeps pre-V3 programmatic callers + tests that bypass YAML working.
|
|
883
892
|
if (
|
|
884
893
|
workspaceManager &&
|
|
885
894
|
agentWorkspaces.has(agentId) &&
|
|
@@ -887,18 +896,45 @@ export function createAgentManagerV2(
|
|
|
887
896
|
) {
|
|
888
897
|
const ws = agentWorkspaces.get(agentId)!;
|
|
889
898
|
if (ws.role === "worker" && ws.streamId) {
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
899
|
+
const roleConfig = topologyPolicy?.getRoleConfig?.(record.role);
|
|
900
|
+
const yamlLandingName = roleConfig?.landing;
|
|
901
|
+
const usingV3Landing =
|
|
902
|
+
typeof yamlLandingName === "string" && yamlLandingName.length > 0;
|
|
903
|
+
|
|
904
|
+
if (usingV3Landing) {
|
|
905
|
+
try {
|
|
906
|
+
const taskRef = (record.metadata as Record<string, unknown> | undefined)
|
|
907
|
+
?.task_ref as { resource_id: string; node_id: string } | undefined;
|
|
908
|
+
await workspaceManager.land({
|
|
909
|
+
agentId,
|
|
894
910
|
streamId: ws.streamId,
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
911
|
+
sourceWorktree: ws.path,
|
|
912
|
+
strategyName: yamlLandingName,
|
|
913
|
+
strategyConfig: roleConfig?.landing_config,
|
|
914
|
+
taskRef,
|
|
915
|
+
// Dispatcher overwrites this with `this`; placeholder keeps the
|
|
916
|
+
// type satisfied without a cast.
|
|
917
|
+
workspaceManager,
|
|
898
918
|
});
|
|
919
|
+
} catch {
|
|
920
|
+
// Non-fatal landing failure — agent still terminates; conflicts
|
|
921
|
+
// and strategy errors surface via WorkspaceEvent emission and
|
|
922
|
+
// the strategy's own logs.
|
|
923
|
+
}
|
|
924
|
+
} else {
|
|
925
|
+
try {
|
|
926
|
+
const mergeQueue = workspaceManager.getMergeQueue();
|
|
927
|
+
if (mergeQueue) {
|
|
928
|
+
mergeQueue.submit({
|
|
929
|
+
streamId: ws.streamId,
|
|
930
|
+
workerBranch: ws.branch,
|
|
931
|
+
taskId: record.task_id ?? agentId,
|
|
932
|
+
workerAgentId: agentId,
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
} catch {
|
|
936
|
+
// Non-fatal merge queue submission failure
|
|
899
937
|
}
|
|
900
|
-
} catch {
|
|
901
|
-
// Non-fatal merge queue submission failure
|
|
902
938
|
}
|
|
903
939
|
}
|
|
904
940
|
}
|
|
@@ -987,11 +1023,16 @@ export function createAgentManagerV2(
|
|
|
987
1023
|
.map((r) => agentRecordToAgent(r)),
|
|
988
1024
|
terminate: (id: AgentId, r: AgentStopReason) => terminate(id, r),
|
|
989
1025
|
};
|
|
1026
|
+
const parentTaskRef = (record.metadata as Record<string, unknown> | undefined)
|
|
1027
|
+
?.task_ref as { resource_id: string; node_id: string } | undefined;
|
|
990
1028
|
await terminateWithChangeConsolidation(
|
|
991
1029
|
child.id as AgentId,
|
|
992
1030
|
agentId,
|
|
993
1031
|
cascadeAdapter,
|
|
994
|
-
wsProvider
|
|
1032
|
+
wsProvider,
|
|
1033
|
+
undefined,
|
|
1034
|
+
workspaceManager ?? undefined,
|
|
1035
|
+
parentTaskRef
|
|
995
1036
|
);
|
|
996
1037
|
}
|
|
997
1038
|
}
|
|
@@ -1097,6 +1138,21 @@ export function createAgentManagerV2(
|
|
|
1097
1138
|
});
|
|
1098
1139
|
|
|
1099
1140
|
const agent = agentRecordToAgent(agentStore.getAgent(agentId)!);
|
|
1141
|
+
|
|
1142
|
+
// Re-publish the agent to subscribers (local MAP server, hub lifecycle
|
|
1143
|
+
// bridge, team auto-join listeners) so a resumed agent is a first-class
|
|
1144
|
+
// registered agent — not just an in-memory handle. Without this, the hub
|
|
1145
|
+
// never re-registers the agent after cold-start; ACP routing works but
|
|
1146
|
+
// the hub's "Registered Agents" view stays empty and capabilities never
|
|
1147
|
+
// propagate back through `map/agents/register`.
|
|
1148
|
+
//
|
|
1149
|
+
// Spawn semantics are correct here: the process is new, the session is
|
|
1150
|
+
// (re)loaded, and subscribers treat it as a fresh registration. Paired
|
|
1151
|
+
// with the `stopped` event that fired on the prior termination, this
|
|
1152
|
+
// keeps the bridge's `registered` map consistent.
|
|
1153
|
+
notifyLifecycle({ type: "spawned", agent });
|
|
1154
|
+
notifyLifecycle({ type: "started", agent });
|
|
1155
|
+
|
|
1100
1156
|
return {
|
|
1101
1157
|
id: agentId,
|
|
1102
1158
|
session_id: sessionRecord?.session_id ?? "",
|
|
@@ -1500,6 +1556,18 @@ export function createAgentManagerV2(
|
|
|
1500
1556
|
}
|
|
1501
1557
|
}
|
|
1502
1558
|
|
|
1559
|
+
// Auto-terminate when done() was called and the handler signaled shouldTerminate.
|
|
1560
|
+
// This closes the lifecycle gap: without this, agents stay in "running" state
|
|
1561
|
+
// after calling done() because nothing triggers terminate().
|
|
1562
|
+
if (doneCalled) {
|
|
1563
|
+
const reason = doneStatus === "completed" ? "completed" : (doneStatus ?? "failed");
|
|
1564
|
+
try {
|
|
1565
|
+
await terminate(agentId, reason as any);
|
|
1566
|
+
} catch {
|
|
1567
|
+
// Best effort — agent may already be stopping
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1503
1571
|
return { doneCalled, doneStatus, updates: allUpdates };
|
|
1504
1572
|
}
|
|
1505
1573
|
|