claude-code-swarm 0.3.25 → 0.4.0
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-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CLAUDE.md +95 -0
- package/LICENSE +21 -0
- package/hooks/hooks.json +9 -0
- package/package.json +15 -2
- package/renovate.json5 +6 -0
- package/scripts/map-hook.mjs +88 -7
- package/scripts/map-sidecar.mjs +210 -1
- package/src/__tests__/cascade-client.test.mjs +217 -0
- package/src/__tests__/cascade-diff-server.test.mjs +375 -0
- package/src/__tests__/cascade-watcher.test.mjs +475 -0
- package/src/__tests__/config.test.mjs +23 -0
- package/src/__tests__/loadout-schema-bridge.test.mjs +1 -2
- package/src/__tests__/sidecar-nudge.test.mjs +137 -0
- package/src/bootstrap.mjs +20 -9
- package/src/cascade-client.mjs +334 -0
- package/src/cascade-diff-server.mjs +326 -0
- package/src/cascade-events.mjs +285 -0
- package/src/cascade-watcher.mjs +694 -0
- package/src/config.mjs +7 -0
- package/src/map-connection.mjs +18 -1
- package/src/map-events.mjs +8 -1
- package/src/paths.mjs +12 -0
- package/src/sidecar-server.mjs +62 -0
- package/src/skilltree-client.mjs +1 -1
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cascade-watcher.test.mjs — observed-git ref watcher (Phase 2)
|
|
3
|
+
*
|
|
4
|
+
* Exercises startCascadeWatcher against a real temp git repo with a mocked
|
|
5
|
+
* MAP connection (captures callExtension calls). Tests baseline (no emit),
|
|
6
|
+
* commit detection, merge detection, and attribution stamping.
|
|
7
|
+
*
|
|
8
|
+
* git-cascade is resolved at runtime via swarmkit-resolver — these tests
|
|
9
|
+
* require git-cascade to be importable from the workspace.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
13
|
+
import { execSync } from "child_process";
|
|
14
|
+
import fs from "fs";
|
|
15
|
+
import path from "path";
|
|
16
|
+
import { openCascadeTracker, ensureStream, closeCascadeTracker } from "../cascade-client.mjs";
|
|
17
|
+
import { startCascadeWatcher } from "../cascade-watcher.mjs";
|
|
18
|
+
import { makeTmpDir, cleanupTmpDir } from "./helpers.mjs";
|
|
19
|
+
|
|
20
|
+
/** Run a git command in `dir`, returning trimmed stdout. */
|
|
21
|
+
function g(dir, args) {
|
|
22
|
+
return execSync(`git ${args}`, {
|
|
23
|
+
cwd: dir, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"],
|
|
24
|
+
}).trim();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Create a real git repo with an initial commit on `main`. */
|
|
28
|
+
function makeGitRepo(dir) {
|
|
29
|
+
g(dir, "init -b main");
|
|
30
|
+
g(dir, 'config user.email "test@test.com"');
|
|
31
|
+
g(dir, 'config user.name "Test"');
|
|
32
|
+
g(dir, "config commit.gpgsign false");
|
|
33
|
+
fs.writeFileSync(path.join(dir, "README.md"), "# test\n");
|
|
34
|
+
g(dir, "add .");
|
|
35
|
+
g(dir, 'commit -m "initial"');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Write a file and commit it; returns the new HEAD SHA. */
|
|
39
|
+
function commitFile(dir, relPath, content, message) {
|
|
40
|
+
const full = path.join(dir, relPath);
|
|
41
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
42
|
+
fs.writeFileSync(full, content);
|
|
43
|
+
g(dir, "add -A");
|
|
44
|
+
g(dir, `commit -m "${message}"`);
|
|
45
|
+
return g(dir, "rev-parse HEAD");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Mock MAP connection — captures every callExtension(method, params) call.
|
|
50
|
+
*/
|
|
51
|
+
function makeMockConnection() {
|
|
52
|
+
const calls = [];
|
|
53
|
+
return {
|
|
54
|
+
calls,
|
|
55
|
+
callExtension(method, params) {
|
|
56
|
+
calls.push({ method, params });
|
|
57
|
+
return Promise.resolve({ ok: true });
|
|
58
|
+
},
|
|
59
|
+
/** Calls for a given x-cascade method suffix. */
|
|
60
|
+
callsFor(suffix) {
|
|
61
|
+
return calls.filter((c) => c.method.endsWith(suffix));
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe("cascade-watcher", () => {
|
|
67
|
+
let repoDir;
|
|
68
|
+
let dbPath;
|
|
69
|
+
let tracker;
|
|
70
|
+
let watcher;
|
|
71
|
+
|
|
72
|
+
beforeEach(async () => {
|
|
73
|
+
repoDir = makeTmpDir("cascade-watcher-");
|
|
74
|
+
dbPath = path.join(repoDir, "tracker.db");
|
|
75
|
+
makeGitRepo(repoDir);
|
|
76
|
+
tracker = await openCascadeTracker({ repoPath: repoDir, dbPath });
|
|
77
|
+
// Register the working branch so the watcher resolves a stream for it.
|
|
78
|
+
ensureStream(tracker, { branch: "main", agentId: "team-sidecar" });
|
|
79
|
+
watcher = null;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
afterEach(() => {
|
|
83
|
+
if (watcher) {
|
|
84
|
+
try { watcher.stop(); } catch { /* ignore */ }
|
|
85
|
+
watcher = null;
|
|
86
|
+
}
|
|
87
|
+
if (tracker) {
|
|
88
|
+
closeCascadeTracker(tracker);
|
|
89
|
+
tracker = null;
|
|
90
|
+
}
|
|
91
|
+
cleanupTmpDir(repoDir);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("emits nothing for the baseline snapshot (no history replay)", async () => {
|
|
95
|
+
const conn = makeMockConnection();
|
|
96
|
+
watcher = startCascadeWatcher({ tracker, connection: conn, repoPath: repoDir });
|
|
97
|
+
|
|
98
|
+
// The start IIFE takes the baseline; a tick with no new git activity
|
|
99
|
+
// emits no committed/merged events (no history replay).
|
|
100
|
+
await watcher._ready;
|
|
101
|
+
await watcher._tickNow();
|
|
102
|
+
|
|
103
|
+
expect(conn.callsFor("stream.committed")).toHaveLength(0);
|
|
104
|
+
expect(conn.callsFor("stream.merged")).toHaveLength(0);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("emits stream.committed for a new commit with correct files and summary", async () => {
|
|
108
|
+
const conn = makeMockConnection();
|
|
109
|
+
watcher = startCascadeWatcher({ tracker, connection: conn, repoPath: repoDir });
|
|
110
|
+
await watcher._ready;
|
|
111
|
+
|
|
112
|
+
// New commit on main, then a detect tick.
|
|
113
|
+
const sha = commitFile(repoDir, "src/feature.txt", "hello\n", "add feature");
|
|
114
|
+
await watcher._tickNow();
|
|
115
|
+
|
|
116
|
+
const committed = conn.callsFor("stream.committed");
|
|
117
|
+
expect(committed.length).toBe(1);
|
|
118
|
+
const p = committed[0].params;
|
|
119
|
+
expect(p.commit_hash).toBe(sha);
|
|
120
|
+
expect(p.message_summary).toBe("add feature");
|
|
121
|
+
expect(p.files_touched).toContain("src/feature.txt");
|
|
122
|
+
expect(typeof p.change_id).toBe("string");
|
|
123
|
+
expect(p.parent_commit.length).toBeGreaterThan(0);
|
|
124
|
+
// No attribution hint → unattributed.
|
|
125
|
+
expect(p.agent_id).toBe("");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("emits committed events oldest-first for multiple new commits", async () => {
|
|
129
|
+
const conn = makeMockConnection();
|
|
130
|
+
watcher = startCascadeWatcher({ tracker, connection: conn, repoPath: repoDir });
|
|
131
|
+
await watcher._ready;
|
|
132
|
+
|
|
133
|
+
commitFile(repoDir, "a.txt", "1\n", "first");
|
|
134
|
+
commitFile(repoDir, "b.txt", "2\n", "second");
|
|
135
|
+
await watcher._tickNow();
|
|
136
|
+
|
|
137
|
+
const committed = conn.callsFor("stream.committed");
|
|
138
|
+
expect(committed.length).toBe(2);
|
|
139
|
+
expect(committed[0].params.message_summary).toBe("first");
|
|
140
|
+
expect(committed[1].params.message_summary).toBe("second");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("emits stream.merged for a merge commit", async () => {
|
|
144
|
+
const conn = makeMockConnection();
|
|
145
|
+
|
|
146
|
+
// Build a feature branch and register it before the watcher starts.
|
|
147
|
+
g(repoDir, "checkout -b feature-x");
|
|
148
|
+
commitFile(repoDir, "feat.txt", "feature\n", "feature work");
|
|
149
|
+
g(repoDir, "checkout main");
|
|
150
|
+
ensureStream(tracker, { branch: "feature-x", agentId: "team-sidecar" });
|
|
151
|
+
|
|
152
|
+
watcher = startCascadeWatcher({ tracker, connection: conn, repoPath: repoDir });
|
|
153
|
+
await watcher._ready;
|
|
154
|
+
|
|
155
|
+
// Merge feature-x into main with a real merge commit.
|
|
156
|
+
g(repoDir, "merge --no-ff -m 'merge feature-x' feature-x");
|
|
157
|
+
const mergeSha = g(repoDir, "rev-parse HEAD");
|
|
158
|
+
await watcher._tickNow();
|
|
159
|
+
|
|
160
|
+
const merged = conn.callsFor("stream.merged");
|
|
161
|
+
expect(merged.length).toBe(1);
|
|
162
|
+
const p = merged[0].params;
|
|
163
|
+
expect(p.merge_commit).toBe(mergeSha);
|
|
164
|
+
expect(typeof p.target_stream_id).toBe("string");
|
|
165
|
+
expect(p.target_stream_id.length).toBeGreaterThan(0);
|
|
166
|
+
// 2nd parent = feature-x head.
|
|
167
|
+
expect(p.source_commit).toBe(g(repoDir, "rev-parse feature-x"));
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("stamps agent_id and task_ref when a fresh attribution hint exists", async () => {
|
|
171
|
+
const conn = makeMockConnection();
|
|
172
|
+
const taskRef = { resource_id: "task://x/1", node_id: "n1" };
|
|
173
|
+
const getAttribution = () => ({ agentId: "agent-7", taskRef, ts: Date.now() });
|
|
174
|
+
|
|
175
|
+
watcher = startCascadeWatcher({
|
|
176
|
+
tracker, connection: conn, repoPath: repoDir, getAttribution,
|
|
177
|
+
});
|
|
178
|
+
await watcher._ready;
|
|
179
|
+
commitFile(repoDir, "x.txt", "x\n", "attributed commit");
|
|
180
|
+
await watcher._tickNow();
|
|
181
|
+
|
|
182
|
+
const committed = conn.callsFor("stream.committed");
|
|
183
|
+
expect(committed.length).toBe(1);
|
|
184
|
+
expect(committed[0].params.agent_id).toBe("agent-7");
|
|
185
|
+
expect(committed[0].params.metadata.task_ref).toEqual(taskRef);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("omits attribution when the hint is stale", async () => {
|
|
189
|
+
const conn = makeMockConnection();
|
|
190
|
+
// Hint timestamped well beyond the staleness window.
|
|
191
|
+
const getAttribution = () => ({
|
|
192
|
+
agentId: "agent-stale",
|
|
193
|
+
taskRef: { resource_id: "task://x/2", node_id: "n2" },
|
|
194
|
+
ts: Date.now() - 120_000,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
watcher = startCascadeWatcher({
|
|
198
|
+
tracker, connection: conn, repoPath: repoDir, getAttribution,
|
|
199
|
+
});
|
|
200
|
+
await watcher._ready;
|
|
201
|
+
commitFile(repoDir, "y.txt", "y\n", "stale commit");
|
|
202
|
+
await watcher._tickNow();
|
|
203
|
+
|
|
204
|
+
const committed = conn.callsFor("stream.committed");
|
|
205
|
+
expect(committed.length).toBe(1);
|
|
206
|
+
expect(committed[0].params.agent_id).toBe("");
|
|
207
|
+
expect(committed[0].params.metadata.task_ref).toBeUndefined();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("omits attribution when no hint is available", async () => {
|
|
211
|
+
const conn = makeMockConnection();
|
|
212
|
+
watcher = startCascadeWatcher({
|
|
213
|
+
tracker, connection: conn, repoPath: repoDir,
|
|
214
|
+
getAttribution: () => null,
|
|
215
|
+
});
|
|
216
|
+
await watcher._ready;
|
|
217
|
+
commitFile(repoDir, "z.txt", "z\n", "no-hint commit");
|
|
218
|
+
await watcher._tickNow();
|
|
219
|
+
|
|
220
|
+
const committed = conn.callsFor("stream.committed");
|
|
221
|
+
expect(committed.length).toBe(1);
|
|
222
|
+
expect(committed[0].params.agent_id).toBe("");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("re-asserts stream.opened for tracked streams on start", async () => {
|
|
226
|
+
const conn = makeMockConnection();
|
|
227
|
+
watcher = startCascadeWatcher({ tracker, connection: conn, repoPath: repoDir });
|
|
228
|
+
|
|
229
|
+
// reassertStreams runs in the start IIFE — await it via _ready.
|
|
230
|
+
await watcher._ready;
|
|
231
|
+
|
|
232
|
+
const opened = conn.callsFor("stream.opened");
|
|
233
|
+
expect(opened.length).toBeGreaterThanOrEqual(1);
|
|
234
|
+
expect(opened.some((c) => c.params.metadata?.trigger === "reassert")).toBe(true);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("emits stream.opened for a newly-appeared local branch", async () => {
|
|
238
|
+
const conn = makeMockConnection();
|
|
239
|
+
watcher = startCascadeWatcher({ tracker, connection: conn, repoPath: repoDir });
|
|
240
|
+
await watcher._ready;
|
|
241
|
+
const openedBefore = conn.callsFor("stream.opened").length;
|
|
242
|
+
|
|
243
|
+
// A new branch appears after the baseline.
|
|
244
|
+
g(repoDir, "branch new-feature");
|
|
245
|
+
await watcher._tickNow();
|
|
246
|
+
|
|
247
|
+
const opened = conn.callsFor("stream.opened");
|
|
248
|
+
expect(opened.length).toBeGreaterThan(openedBefore);
|
|
249
|
+
const newBranchEvent = opened.find((c) => c.params.branch_name === "new-feature");
|
|
250
|
+
expect(newBranchEvent).toBeTruthy();
|
|
251
|
+
expect(newBranchEvent.params.metadata?.trigger).toBe("watcher-new-branch");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("emits stream.pushed when a remote ref catches up to a local branch", async () => {
|
|
255
|
+
const conn = makeMockConnection();
|
|
256
|
+
|
|
257
|
+
// Set up a bare remote and push main once so a remote-tracking ref exists.
|
|
258
|
+
const remoteDir = makeTmpDir("cascade-remote-");
|
|
259
|
+
g(remoteDir, "init --bare");
|
|
260
|
+
g(repoDir, `remote add origin ${remoteDir}`);
|
|
261
|
+
g(repoDir, "push -u origin main");
|
|
262
|
+
|
|
263
|
+
watcher = startCascadeWatcher({ tracker, connection: conn, repoPath: repoDir });
|
|
264
|
+
await watcher._ready;
|
|
265
|
+
|
|
266
|
+
// New commit + push — the remote-tracking ref advances.
|
|
267
|
+
commitFile(repoDir, "pushed.txt", "p\n", "pushed work");
|
|
268
|
+
g(repoDir, "push origin main");
|
|
269
|
+
const pushedSha = g(repoDir, "rev-parse HEAD");
|
|
270
|
+
await watcher._tickNow();
|
|
271
|
+
|
|
272
|
+
const pushed = conn.callsFor("stream.pushed");
|
|
273
|
+
expect(pushed.length).toBeGreaterThanOrEqual(1);
|
|
274
|
+
const ev = pushed.find((c) => c.params.pushed_commit === pushedSha);
|
|
275
|
+
expect(ev).toBeTruthy();
|
|
276
|
+
expect(ev.params.remote).toBe("origin");
|
|
277
|
+
expect(ev.params.remote_ref).toBe("main");
|
|
278
|
+
|
|
279
|
+
cleanupTmpDir(remoteDir);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("a bad tick never throws — watcher survives a broken repo path", async () => {
|
|
283
|
+
const conn = makeMockConnection();
|
|
284
|
+
watcher = startCascadeWatcher({
|
|
285
|
+
tracker, connection: conn, repoPath: path.join(repoDir, "does-not-exist"),
|
|
286
|
+
});
|
|
287
|
+
await expect(watcher._tickNow()).resolves.not.toThrow();
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("does not emit feature-branch commits as stream.committed against target after merge", async () => {
|
|
291
|
+
const conn = makeMockConnection();
|
|
292
|
+
|
|
293
|
+
// Create a feature branch with two commits, then switch back to main.
|
|
294
|
+
g(repoDir, "checkout -b feature-y");
|
|
295
|
+
commitFile(repoDir, "feat-y-1.txt", "y1\n", "feature-y work 1");
|
|
296
|
+
commitFile(repoDir, "feat-y-2.txt", "y2\n", "feature-y work 2");
|
|
297
|
+
const featureHead = g(repoDir, "rev-parse HEAD");
|
|
298
|
+
g(repoDir, "checkout main");
|
|
299
|
+
|
|
300
|
+
// Register the feature stream so the watcher knows about it.
|
|
301
|
+
ensureStream(tracker, { branch: "feature-y", agentId: "team-sidecar" });
|
|
302
|
+
|
|
303
|
+
watcher = startCascadeWatcher({ tracker, connection: conn, repoPath: repoDir });
|
|
304
|
+
await watcher._ready;
|
|
305
|
+
|
|
306
|
+
// Add a commit directly on main (distinct from the feature work).
|
|
307
|
+
const mainCommit = commitFile(repoDir, "main-work.txt", "m\n", "main work");
|
|
308
|
+
|
|
309
|
+
// Merge feature-y into main with a real merge commit (--no-ff).
|
|
310
|
+
g(repoDir, "merge --no-ff -m 'merge feature-y' feature-y");
|
|
311
|
+
const mergeSha = g(repoDir, "rev-parse HEAD");
|
|
312
|
+
await watcher._tickNow();
|
|
313
|
+
|
|
314
|
+
// stream.committed should include the main work commit but NOT the two
|
|
315
|
+
// feature-y commits — --first-parent excludes the merged-in side history.
|
|
316
|
+
const committed = conn.callsFor("stream.committed");
|
|
317
|
+
const hashes = committed.map((c) => c.params.commit_hash);
|
|
318
|
+
expect(hashes).toContain(mainCommit);
|
|
319
|
+
expect(hashes).not.toContain(featureHead);
|
|
320
|
+
// The merge commit itself is never emitted as stream.committed (emitMerge handles it).
|
|
321
|
+
expect(hashes).not.toContain(mergeSha);
|
|
322
|
+
// Exactly one stream.committed for the main work commit (not 3 for all three).
|
|
323
|
+
expect(committed.length).toBe(1);
|
|
324
|
+
|
|
325
|
+
// stream.merged is emitted for the merge commit.
|
|
326
|
+
const merged = conn.callsFor("stream.merged");
|
|
327
|
+
expect(merged.length).toBe(1);
|
|
328
|
+
expect(merged[0].params.merge_commit).toBe(mergeSha);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("stop() is idempotent and never throws", () => {
|
|
332
|
+
const conn = makeMockConnection();
|
|
333
|
+
watcher = startCascadeWatcher({ tracker, connection: conn, repoPath: repoDir });
|
|
334
|
+
expect(() => { watcher.stop(); watcher.stop(); }).not.toThrow();
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
describe("cascade-watcher — stream.conflicted / stream.conflict_resolved", () => {
|
|
339
|
+
let repoDir;
|
|
340
|
+
let dbPath;
|
|
341
|
+
let tracker;
|
|
342
|
+
let watcher;
|
|
343
|
+
|
|
344
|
+
beforeEach(async () => {
|
|
345
|
+
repoDir = makeTmpDir("cascade-watcher-conflict-");
|
|
346
|
+
dbPath = path.join(repoDir, "tracker.db");
|
|
347
|
+
makeGitRepo(repoDir);
|
|
348
|
+
tracker = await openCascadeTracker({ repoPath: repoDir, dbPath });
|
|
349
|
+
ensureStream(tracker, { branch: "main", agentId: "team-sidecar" });
|
|
350
|
+
watcher = null;
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
afterEach(() => {
|
|
354
|
+
if (watcher) {
|
|
355
|
+
try { watcher.stop(); } catch { /* ignore */ }
|
|
356
|
+
watcher = null;
|
|
357
|
+
}
|
|
358
|
+
if (tracker) {
|
|
359
|
+
closeCascadeTracker(tracker);
|
|
360
|
+
tracker = null;
|
|
361
|
+
}
|
|
362
|
+
cleanupTmpDir(repoDir);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Set up two divergent branches that touch the same file at the same line —
|
|
367
|
+
* `git merge --no-ff feature` on main then produces a conflict. Returns the
|
|
368
|
+
* feature-branch HEAD SHA so tests can assert on `conflicting_commit`.
|
|
369
|
+
*/
|
|
370
|
+
function makeDivergent() {
|
|
371
|
+
// Seed a shared file on main first.
|
|
372
|
+
fs.writeFileSync(path.join(repoDir, "shared.txt"), "base\n");
|
|
373
|
+
g(repoDir, "add -A");
|
|
374
|
+
g(repoDir, 'commit -m "seed shared"');
|
|
375
|
+
|
|
376
|
+
g(repoDir, "checkout -b feature");
|
|
377
|
+
fs.writeFileSync(path.join(repoDir, "shared.txt"), "feature change\n");
|
|
378
|
+
g(repoDir, "add -A");
|
|
379
|
+
g(repoDir, 'commit -m "feature edit"');
|
|
380
|
+
const featureHead = g(repoDir, "rev-parse HEAD");
|
|
381
|
+
|
|
382
|
+
g(repoDir, "checkout main");
|
|
383
|
+
fs.writeFileSync(path.join(repoDir, "shared.txt"), "main change\n");
|
|
384
|
+
g(repoDir, "add -A");
|
|
385
|
+
g(repoDir, 'commit -m "main edit"');
|
|
386
|
+
return featureHead;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** `git merge --no-ff feature` that we expect to fail with a conflict. */
|
|
390
|
+
function startConflictingMerge() {
|
|
391
|
+
try {
|
|
392
|
+
execSync("git merge --no-ff -m 'merge feature' feature", {
|
|
393
|
+
cwd: repoDir, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"],
|
|
394
|
+
});
|
|
395
|
+
} catch {
|
|
396
|
+
// Expected — the merge fails with a conflict, MERGE_HEAD now exists.
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
it("emits stream.conflicted then stream.conflict_resolved with 'manual' when resolved by commit", async () => {
|
|
401
|
+
const conn = makeMockConnection();
|
|
402
|
+
const featureHead = makeDivergent();
|
|
403
|
+
|
|
404
|
+
watcher = startCascadeWatcher({ tracker, connection: conn, repoPath: repoDir });
|
|
405
|
+
await watcher._ready;
|
|
406
|
+
|
|
407
|
+
// Trigger the conflict, then tick: expect one stream.conflicted.
|
|
408
|
+
startConflictingMerge();
|
|
409
|
+
await watcher._tickNow();
|
|
410
|
+
|
|
411
|
+
const conflicted = conn.callsFor("stream.conflicted");
|
|
412
|
+
expect(conflicted.length).toBe(1);
|
|
413
|
+
const cp = conflicted[0].params;
|
|
414
|
+
expect(cp.conflicted_files).toContain("shared.txt");
|
|
415
|
+
expect(cp.conflicting_commit).toBe(featureHead);
|
|
416
|
+
expect(cp.source).toBe("merge");
|
|
417
|
+
expect(typeof cp.stream_id).toBe("string");
|
|
418
|
+
expect(cp.stream_id.length).toBeGreaterThan(0);
|
|
419
|
+
expect(typeof cp.conflict_id).toBe("string");
|
|
420
|
+
expect(cp.conflict_id.length).toBeGreaterThan(0);
|
|
421
|
+
// No spurious second emit while the conflict is open.
|
|
422
|
+
await watcher._tickNow();
|
|
423
|
+
expect(conn.callsFor("stream.conflicted").length).toBe(1);
|
|
424
|
+
expect(conn.callsFor("stream.conflict_resolved").length).toBe(0);
|
|
425
|
+
|
|
426
|
+
// Manually resolve and commit — HEAD advances to a merge commit (2 parents).
|
|
427
|
+
fs.writeFileSync(path.join(repoDir, "shared.txt"), "manually resolved\n");
|
|
428
|
+
g(repoDir, "add -A");
|
|
429
|
+
g(repoDir, 'commit -m "resolve merge"');
|
|
430
|
+
|
|
431
|
+
await watcher._tickNow();
|
|
432
|
+
|
|
433
|
+
const resolved = conn.callsFor("stream.conflict_resolved");
|
|
434
|
+
expect(resolved.length).toBe(1);
|
|
435
|
+
const rp = resolved[0].params;
|
|
436
|
+
expect(rp.resolution_method).toBe("manual");
|
|
437
|
+
expect(rp.stream_id).toBe(cp.stream_id);
|
|
438
|
+
expect(rp.conflict_id).toBe(cp.conflict_id);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it("emits stream.conflict_resolved with 'abandoned' when the merge is aborted", async () => {
|
|
442
|
+
const conn = makeMockConnection();
|
|
443
|
+
makeDivergent();
|
|
444
|
+
|
|
445
|
+
watcher = startCascadeWatcher({ tracker, connection: conn, repoPath: repoDir });
|
|
446
|
+
await watcher._ready;
|
|
447
|
+
|
|
448
|
+
startConflictingMerge();
|
|
449
|
+
await watcher._tickNow();
|
|
450
|
+
expect(conn.callsFor("stream.conflicted").length).toBe(1);
|
|
451
|
+
|
|
452
|
+
// Abort the merge — MERGE_HEAD goes away, HEAD unchanged.
|
|
453
|
+
g(repoDir, "merge --abort");
|
|
454
|
+
await watcher._tickNow();
|
|
455
|
+
|
|
456
|
+
const resolved = conn.callsFor("stream.conflict_resolved");
|
|
457
|
+
expect(resolved.length).toBe(1);
|
|
458
|
+
expect(resolved[0].params.resolution_method).toBe("abandoned");
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it("never emits stream.conflicted in a clean repo with no merge state", async () => {
|
|
462
|
+
const conn = makeMockConnection();
|
|
463
|
+
watcher = startCascadeWatcher({ tracker, connection: conn, repoPath: repoDir });
|
|
464
|
+
await watcher._ready;
|
|
465
|
+
|
|
466
|
+
// A few innocent ticks with no merge state.
|
|
467
|
+
await watcher._tickNow();
|
|
468
|
+
commitFile(repoDir, "clean.txt", "clean\n", "clean commit");
|
|
469
|
+
await watcher._tickNow();
|
|
470
|
+
await watcher._tickNow();
|
|
471
|
+
|
|
472
|
+
expect(conn.callsFor("stream.conflicted").length).toBe(0);
|
|
473
|
+
expect(conn.callsFor("stream.conflict_resolved").length).toBe(0);
|
|
474
|
+
});
|
|
475
|
+
});
|
|
@@ -78,6 +78,21 @@ describe("config", () => {
|
|
|
78
78
|
expect(config.opentasks.autoStart).toBe(false);
|
|
79
79
|
});
|
|
80
80
|
|
|
81
|
+
it("normalizes cascade fields with defaults (disabled)", () => {
|
|
82
|
+
const configPath = writeFile(tmpDir, "config.json", JSON.stringify({ template: "test" }));
|
|
83
|
+
const config = readConfig(configPath, noGlobal);
|
|
84
|
+
expect(config.cascade.enabled).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("reads cascade.enabled from config file", () => {
|
|
88
|
+
const configPath = writeFile(tmpDir, "config.json", JSON.stringify({
|
|
89
|
+
template: "test",
|
|
90
|
+
cascade: { enabled: true },
|
|
91
|
+
}));
|
|
92
|
+
const config = readConfig(configPath, noGlobal);
|
|
93
|
+
expect(config.cascade.enabled).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
81
96
|
it("returns defaults when file does not exist", () => {
|
|
82
97
|
const config = readConfig(path.join(tmpDir, "nonexistent.json"), noGlobal);
|
|
83
98
|
expect(config.template).toBe("");
|
|
@@ -401,6 +416,14 @@ describe("config", () => {
|
|
|
401
416
|
expect(readConfig(configPath, noGlobal).opentasks.autoStart).toBe(false);
|
|
402
417
|
});
|
|
403
418
|
|
|
419
|
+
it("SWARM_CASCADE_ENABLED overrides config file", () => {
|
|
420
|
+
process.env.SWARM_CASCADE_ENABLED = "true";
|
|
421
|
+
const configPath = writeFile(tmpDir, "config.json", JSON.stringify({
|
|
422
|
+
cascade: { enabled: false },
|
|
423
|
+
}));
|
|
424
|
+
expect(readConfig(configPath, noGlobal).cascade.enabled).toBe(true);
|
|
425
|
+
});
|
|
426
|
+
|
|
404
427
|
it("env vars override defaults when no config file exists", () => {
|
|
405
428
|
process.env.SWARM_MAP_SERVER = "ws://env-server:9090";
|
|
406
429
|
process.env.SWARM_MAP_SYSTEM_ID = "env-system";
|
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
* Asserts that the openteams `loadout.skills` schema (SkillsConfig) and
|
|
5
5
|
* skilltree-client's bridge mapping stay in sync. skill-tree is the
|
|
6
6
|
* mechanism; openteams' `loadout.skills` is the declaration that
|
|
7
|
-
* dispatches into it.
|
|
8
|
-
* the model.
|
|
7
|
+
* dispatches into it.
|
|
9
8
|
*
|
|
10
9
|
* What this test catches:
|
|
11
10
|
* - openteams adds a new field to SkillsConfig and the bridge doesn't
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for dispatch thread nudge commands on the sidecar command handler.
|
|
3
|
+
*
|
|
4
|
+
* Covers Phase 7 of dispatch-inbox-threads:
|
|
5
|
+
* - nudge command stores nudge state
|
|
6
|
+
* - check-nudge returns and clears pending nudges
|
|
7
|
+
* - Multiple nudges accumulate independently
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
11
|
+
import { createCommandHandler, respond } from "../sidecar-server.mjs";
|
|
12
|
+
|
|
13
|
+
function createTestHandler() {
|
|
14
|
+
const registeredAgents = new Map();
|
|
15
|
+
return createCommandHandler(null, "swarm:test", registeredAgents, {
|
|
16
|
+
transportMode: "websocket",
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function createFakeClient() {
|
|
21
|
+
let lastResponse = null;
|
|
22
|
+
return {
|
|
23
|
+
write(data) {
|
|
24
|
+
try {
|
|
25
|
+
lastResponse = JSON.parse(data.replace(/\n$/, ""));
|
|
26
|
+
} catch {
|
|
27
|
+
lastResponse = data;
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
writable: true,
|
|
31
|
+
getResponse() {
|
|
32
|
+
return lastResponse;
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("sidecar nudge commands", () => {
|
|
38
|
+
let handler;
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
handler = createTestHandler();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("check-nudge returns empty array when no nudges pending", async () => {
|
|
45
|
+
const client = createFakeClient();
|
|
46
|
+
await handler({ action: "check-nudge" }, client);
|
|
47
|
+
|
|
48
|
+
const resp = client.getResponse();
|
|
49
|
+
expect(resp.ok).toBe(true);
|
|
50
|
+
expect(resp.nudges).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("nudge stores state, check-nudge returns and clears it", async () => {
|
|
54
|
+
const fakeClient = { write: () => {}, writable: true };
|
|
55
|
+
|
|
56
|
+
// Store a nudge
|
|
57
|
+
await handler(
|
|
58
|
+
{ action: "nudge", dispatch_id: "d1", conversation_id: "conv-d1" },
|
|
59
|
+
fakeClient,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
// Check nudge should return it
|
|
63
|
+
const client = createFakeClient();
|
|
64
|
+
await handler({ action: "check-nudge" }, client);
|
|
65
|
+
|
|
66
|
+
const resp = client.getResponse();
|
|
67
|
+
expect(resp.ok).toBe(true);
|
|
68
|
+
expect(resp.nudges).toHaveLength(1);
|
|
69
|
+
expect(resp.nudges[0]).toEqual({
|
|
70
|
+
dispatch_id: "d1",
|
|
71
|
+
conversation_id: "conv-d1",
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Second check should be empty (cleared)
|
|
75
|
+
const client2 = createFakeClient();
|
|
76
|
+
await handler({ action: "check-nudge" }, client2);
|
|
77
|
+
|
|
78
|
+
const resp2 = client2.getResponse();
|
|
79
|
+
expect(resp2.nudges).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("accumulates multiple nudges for different dispatches", async () => {
|
|
83
|
+
const fakeClient = { write: () => {}, writable: true };
|
|
84
|
+
|
|
85
|
+
await handler(
|
|
86
|
+
{ action: "nudge", dispatch_id: "d1", conversation_id: "conv-d1" },
|
|
87
|
+
fakeClient,
|
|
88
|
+
);
|
|
89
|
+
await handler(
|
|
90
|
+
{ action: "nudge", dispatch_id: "d2", conversation_id: "conv-d2" },
|
|
91
|
+
fakeClient,
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const client = createFakeClient();
|
|
95
|
+
await handler({ action: "check-nudge" }, client);
|
|
96
|
+
|
|
97
|
+
const resp = client.getResponse();
|
|
98
|
+
expect(resp.nudges).toHaveLength(2);
|
|
99
|
+
const ids = resp.nudges.map((n) => n.dispatch_id).sort();
|
|
100
|
+
expect(ids).toEqual(["d1", "d2"]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("overwrites nudge for same dispatch_id (latest wins)", async () => {
|
|
104
|
+
const fakeClient = { write: () => {}, writable: true };
|
|
105
|
+
|
|
106
|
+
await handler(
|
|
107
|
+
{ action: "nudge", dispatch_id: "d1", conversation_id: "conv-old" },
|
|
108
|
+
fakeClient,
|
|
109
|
+
);
|
|
110
|
+
await handler(
|
|
111
|
+
{ action: "nudge", dispatch_id: "d1", conversation_id: "conv-new" },
|
|
112
|
+
fakeClient,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const client = createFakeClient();
|
|
116
|
+
await handler({ action: "check-nudge" }, client);
|
|
117
|
+
|
|
118
|
+
const resp = client.getResponse();
|
|
119
|
+
expect(resp.nudges).toHaveLength(1);
|
|
120
|
+
expect(resp.nudges[0].conversation_id).toBe("conv-new");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("ignores nudge with no dispatch_id", async () => {
|
|
124
|
+
const fakeClient = { write: () => {}, writable: true };
|
|
125
|
+
|
|
126
|
+
await handler(
|
|
127
|
+
{ action: "nudge", conversation_id: "conv-x" },
|
|
128
|
+
fakeClient,
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const client = createFakeClient();
|
|
132
|
+
await handler({ action: "check-nudge" }, client);
|
|
133
|
+
|
|
134
|
+
const resp = client.getResponse();
|
|
135
|
+
expect(resp.nudges).toEqual([]);
|
|
136
|
+
});
|
|
137
|
+
});
|