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,694 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cascade-watcher.mjs — observed-git ref watcher for claude-code-swarm
|
|
3
|
+
*
|
|
4
|
+
* Phase 2 of cascade integration. The watcher is the **detector**: it polls
|
|
5
|
+
* `git for-each-ref` on an interval and diffs ref → SHA snapshots to detect
|
|
6
|
+
* commits, merges, pushes, and new branches. When it observes git activity it
|
|
7
|
+
* pulls real git data per commit and emits `x-cascade/*` events over the MAP
|
|
8
|
+
* connection (via cascade-events.mjs).
|
|
9
|
+
*
|
|
10
|
+
* Polling (not fs.watch) is deliberate: it is cross-platform reliable, needs
|
|
11
|
+
* zero new dependencies, and costs one cheap git call per tick.
|
|
12
|
+
*
|
|
13
|
+
* The watcher works fully standalone — it emits unattributed events when no
|
|
14
|
+
* fresh attribution hint is present. A `PostToolUse(Bash)` hook supplies
|
|
15
|
+
* *attribution* (agent_id, task_ref) as an optional side-channel; it never
|
|
16
|
+
* detects git itself.
|
|
17
|
+
*
|
|
18
|
+
* Everything here is wrapped so a cascade failure can never crash the sidecar:
|
|
19
|
+
* a bad tick logs and continues, the watcher never throws.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { execFile } from "child_process";
|
|
23
|
+
import { existsSync, readFileSync } from "fs";
|
|
24
|
+
import path from "path";
|
|
25
|
+
import { promisify } from "util";
|
|
26
|
+
import { createLogger } from "./log.mjs";
|
|
27
|
+
import {
|
|
28
|
+
ensureStream,
|
|
29
|
+
findStreamByBranch,
|
|
30
|
+
recordObservedCommit,
|
|
31
|
+
recordObservedMerge,
|
|
32
|
+
recordObservedConflict,
|
|
33
|
+
recordObservedConflictResolved,
|
|
34
|
+
} from "./cascade-client.mjs";
|
|
35
|
+
import {
|
|
36
|
+
buildStreamOpenedParams,
|
|
37
|
+
emitStreamOpened,
|
|
38
|
+
buildStreamCommittedParams,
|
|
39
|
+
emitStreamCommitted,
|
|
40
|
+
buildStreamMergedParams,
|
|
41
|
+
emitStreamMerged,
|
|
42
|
+
buildStreamPushedParams,
|
|
43
|
+
emitStreamPushed,
|
|
44
|
+
buildStreamConflictedParams,
|
|
45
|
+
emitStreamConflicted,
|
|
46
|
+
buildStreamConflictResolvedParams,
|
|
47
|
+
emitStreamConflictResolved,
|
|
48
|
+
} from "./cascade-events.mjs";
|
|
49
|
+
|
|
50
|
+
const log = createLogger("cascade-watcher");
|
|
51
|
+
|
|
52
|
+
const execFileAsync = promisify(execFile);
|
|
53
|
+
|
|
54
|
+
/** Poll interval for the ref watcher. One `git for-each-ref` call per tick. */
|
|
55
|
+
export const POLL_INTERVAL_MS = 3000;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Staleness window for attribution hints. A hint older than this is treated as
|
|
59
|
+
* unrelated to the observed git activity and ignored — the event is emitted
|
|
60
|
+
* unattributed rather than mis-attributed. Set to 5s: the PostToolUse(Bash)
|
|
61
|
+
* hook fires synchronously after the Bash tool returns, and the watcher's 3s
|
|
62
|
+
* poll picks it up on the next tick — 5s gives ~2s buffer while sharply
|
|
63
|
+
* narrowing the cross-tool-invocation race window vs the original 30s. A
|
|
64
|
+
* complete fix would key hints per agent / per tool-call (so two concurrent
|
|
65
|
+
* Bash tools from different agents can't overwrite each other's attribution);
|
|
66
|
+
* that is design work, not just a constant tweak — tracked as a known
|
|
67
|
+
* follow-up.
|
|
68
|
+
*/
|
|
69
|
+
export const ATTRIBUTION_STALENESS_MS = 5_000;
|
|
70
|
+
|
|
71
|
+
/** Git's empty-tree SHA — used as the parent of an initial (rootless) commit. */
|
|
72
|
+
const EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Run a git command in `repoPath`. Returns trimmed stdout, or "" on any error.
|
|
76
|
+
* Best-effort — never throws.
|
|
77
|
+
*/
|
|
78
|
+
async function git(repoPath, args) {
|
|
79
|
+
try {
|
|
80
|
+
const { stdout } = await execFileAsync("git", args, {
|
|
81
|
+
cwd: repoPath,
|
|
82
|
+
encoding: "utf-8",
|
|
83
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
84
|
+
});
|
|
85
|
+
return stdout.trim();
|
|
86
|
+
} catch {
|
|
87
|
+
return "";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Snapshot all refs → SHAs via a single `git for-each-ref` call.
|
|
93
|
+
* Returns a Map<refname, sha>. Empty Map on any error.
|
|
94
|
+
*/
|
|
95
|
+
async function snapshotRefs(repoPath) {
|
|
96
|
+
const out = await git(repoPath, [
|
|
97
|
+
"for-each-ref",
|
|
98
|
+
"--format=%(refname) %(objectname)",
|
|
99
|
+
]);
|
|
100
|
+
const map = new Map();
|
|
101
|
+
if (!out) return map;
|
|
102
|
+
for (const line of out.split("\n")) {
|
|
103
|
+
const sp = line.indexOf(" ");
|
|
104
|
+
if (sp === -1) continue;
|
|
105
|
+
const ref = line.slice(0, sp);
|
|
106
|
+
const sha = line.slice(sp + 1).trim();
|
|
107
|
+
if (ref && sha) map.set(ref, sha);
|
|
108
|
+
}
|
|
109
|
+
return map;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Start the observed-git ref watcher.
|
|
114
|
+
*
|
|
115
|
+
* Takes a baseline ref snapshot on start (no events emitted for the baseline —
|
|
116
|
+
* no history replay), then polls every POLL_INTERVAL_MS and emits `x-cascade/*`
|
|
117
|
+
* events for newly-observed git activity.
|
|
118
|
+
*
|
|
119
|
+
* @param {object} opts
|
|
120
|
+
* @param {object} opts.tracker git-cascade tracker (from openCascadeTracker)
|
|
121
|
+
* @param {object} opts.connection MAP AgentConnection (may be null/dead)
|
|
122
|
+
* @param {string} opts.repoPath Path to the git repository
|
|
123
|
+
* @param {Function} [opts.getAttribution] () => { agentId, taskRef, ts } | null
|
|
124
|
+
* @param {string} [opts.agentId] Fallback agent id for stream registration
|
|
125
|
+
* @returns {{ stop: Function, reassertStreams: Function }} Watcher handle.
|
|
126
|
+
*/
|
|
127
|
+
export function startCascadeWatcher({ tracker, connection, repoPath, getAttribution, agentId } = {}) {
|
|
128
|
+
let conn = connection;
|
|
129
|
+
let prevRefs = null;
|
|
130
|
+
let ticking = false;
|
|
131
|
+
let timer = null;
|
|
132
|
+
let stopped = false;
|
|
133
|
+
// Resolves once the baseline snapshot + initial re-assert have run. tick()
|
|
134
|
+
// awaits this so a poll never races the baseline.
|
|
135
|
+
let readyPromise = null;
|
|
136
|
+
|
|
137
|
+
// Stream id cache per branch name — avoids re-resolving on every tick.
|
|
138
|
+
const streamIdByBranch = new Map();
|
|
139
|
+
|
|
140
|
+
// In-flight merge-conflict state. Populated when the probe sees
|
|
141
|
+
// `.git/MERGE_HEAD` appear and cleared when it disappears. We cache enough
|
|
142
|
+
// to correlate the resolution event with the conflict event: stream id,
|
|
143
|
+
// conflict id, the HEAD SHA we observed *before* the merge started, and
|
|
144
|
+
// the conflicting commit (so we can tell "advanced to a merge commit"
|
|
145
|
+
// apart from "merge --abort, HEAD unchanged"). Keyed by the absolute path
|
|
146
|
+
// of `.git/MERGE_HEAD` so different worktrees don't collide.
|
|
147
|
+
let inFlightMerge = null;
|
|
148
|
+
|
|
149
|
+
const fallbackAgentId = agentId || "cascade-watcher";
|
|
150
|
+
|
|
151
|
+
/** Read the freshest attribution hint, or null when none/stale. */
|
|
152
|
+
function freshAttribution() {
|
|
153
|
+
if (typeof getAttribution !== "function") return null;
|
|
154
|
+
let hint;
|
|
155
|
+
try {
|
|
156
|
+
hint = getAttribution();
|
|
157
|
+
} catch {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
if (!hint || typeof hint.ts !== "number") return null;
|
|
161
|
+
if (Date.now() - hint.ts > ATTRIBUTION_STALENESS_MS) return null;
|
|
162
|
+
return hint;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Build the `agent_id` + `metadata` pair for an emitted event, consulting
|
|
167
|
+
* the latest attribution hint. Returns unattributed defaults when no fresh
|
|
168
|
+
* hint exists.
|
|
169
|
+
*/
|
|
170
|
+
function attributionFor(baseMetadata = {}) {
|
|
171
|
+
const hint = freshAttribution();
|
|
172
|
+
const metadata = { ...baseMetadata };
|
|
173
|
+
if (hint?.taskRef) metadata.task_ref = hint.taskRef;
|
|
174
|
+
return {
|
|
175
|
+
agentId: hint?.agentId || "",
|
|
176
|
+
metadata,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Resolve (and cache) the stream id for a branch, registering it if new. */
|
|
181
|
+
function resolveStreamId(branch, { parentStream } = {}) {
|
|
182
|
+
if (streamIdByBranch.has(branch)) return streamIdByBranch.get(branch);
|
|
183
|
+
let streamId = findStreamByBranch(tracker, branch);
|
|
184
|
+
if (!streamId) {
|
|
185
|
+
const result = ensureStream(tracker, { branch, agentId: fallbackAgentId, parentStream });
|
|
186
|
+
streamId = result?.streamId || null;
|
|
187
|
+
}
|
|
188
|
+
if (streamId) streamIdByBranch.set(branch, streamId);
|
|
189
|
+
return streamId;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Emit `x-cascade/stream.opened` for every currently-tracked stream.
|
|
194
|
+
*
|
|
195
|
+
* Idempotent on the hub — used on watcher start and exposed for the sidecar
|
|
196
|
+
* to call on MAP (re)connect. Covers Phase 1's "lost first-boot emit" gap
|
|
197
|
+
* where the stream.opened event was dropped before the connection existed.
|
|
198
|
+
*/
|
|
199
|
+
function reassertStreams() {
|
|
200
|
+
if (!tracker) return;
|
|
201
|
+
let streams;
|
|
202
|
+
try {
|
|
203
|
+
streams = tracker.listStreams();
|
|
204
|
+
} catch (err) {
|
|
205
|
+
log.warn("reassertStreams: listStreams failed", { error: err.message });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
for (const stream of streams || []) {
|
|
209
|
+
try {
|
|
210
|
+
let branch = null;
|
|
211
|
+
try {
|
|
212
|
+
branch = tracker.getStreamBranchName(stream.id);
|
|
213
|
+
} catch { /* not a local-mode stream / branch gone */ }
|
|
214
|
+
if (branch) streamIdByBranch.set(branch, stream.id);
|
|
215
|
+
const { agentId: attrAgent, metadata } = attributionFor({ trigger: "reassert" });
|
|
216
|
+
emitStreamOpened(conn, buildStreamOpenedParams({
|
|
217
|
+
streamId: stream.id,
|
|
218
|
+
name: stream.name || branch || stream.id,
|
|
219
|
+
agentId: attrAgent || stream.agent_id || fallbackAgentId,
|
|
220
|
+
baseCommit: stream.base_commit || "",
|
|
221
|
+
branchName: branch || undefined,
|
|
222
|
+
metadata,
|
|
223
|
+
}));
|
|
224
|
+
} catch (err) {
|
|
225
|
+
log.warn("reassertStreams: emit failed", { streamId: stream.id, error: err.message });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Emit a stream.committed event for one observed commit. */
|
|
231
|
+
async function emitCommit(streamId, branch, sha) {
|
|
232
|
+
try {
|
|
233
|
+
const summary = await git(repoPath, ["show", "-s", "--format=%s", sha]);
|
|
234
|
+
const filesRaw = await git(repoPath, [
|
|
235
|
+
"diff-tree", "--no-commit-id", "--name-only", "-r", sha,
|
|
236
|
+
]);
|
|
237
|
+
const filesTouched = filesRaw ? filesRaw.split("\n").filter(Boolean) : [];
|
|
238
|
+
let parent = await git(repoPath, ["rev-parse", `${sha}^`]);
|
|
239
|
+
if (!parent) parent = EMPTY_TREE_SHA;
|
|
240
|
+
|
|
241
|
+
const changeId = await recordObservedCommit(tracker, {
|
|
242
|
+
streamId,
|
|
243
|
+
commit: sha,
|
|
244
|
+
description: summary,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const { agentId: attrAgent, metadata } = attributionFor({
|
|
248
|
+
trigger: "watcher-commit",
|
|
249
|
+
branch,
|
|
250
|
+
});
|
|
251
|
+
emitStreamCommitted(conn, buildStreamCommittedParams({
|
|
252
|
+
streamId,
|
|
253
|
+
commitHash: sha,
|
|
254
|
+
changeId: changeId || "",
|
|
255
|
+
agentId: attrAgent,
|
|
256
|
+
messageSummary: summary,
|
|
257
|
+
filesTouched,
|
|
258
|
+
parentCommit: parent,
|
|
259
|
+
metadata,
|
|
260
|
+
}));
|
|
261
|
+
} catch (err) {
|
|
262
|
+
log.warn("emitCommit failed", { branch, sha, error: err.message });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Emit a stream.merged event for one observed merge commit. */
|
|
267
|
+
async function emitMerge(targetStreamId, targetBranch, mergeSha, parents) {
|
|
268
|
+
try {
|
|
269
|
+
const sourceCommit = parents[1] || "";
|
|
270
|
+
// Resolve the source stream best-effort: find a branch whose HEAD is (or
|
|
271
|
+
// contains) the 2nd parent. May be empty when the source branch is gone.
|
|
272
|
+
let sourceStreamId = "";
|
|
273
|
+
if (sourceCommit) {
|
|
274
|
+
const containing = await git(repoPath, [
|
|
275
|
+
"branch", "--format=%(refname:short)", "--contains", sourceCommit,
|
|
276
|
+
]);
|
|
277
|
+
const candidates = containing
|
|
278
|
+
? containing.split("\n").map((b) => b.trim()).filter((b) => b && b !== targetBranch)
|
|
279
|
+
: [];
|
|
280
|
+
for (const candidate of candidates) {
|
|
281
|
+
const head = await git(repoPath, ["rev-parse", candidate]);
|
|
282
|
+
if (head === sourceCommit) {
|
|
283
|
+
sourceStreamId = resolveStreamId(candidate) || "";
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
// No exact-head match — fall back to the first containing branch.
|
|
288
|
+
if (!sourceStreamId && candidates.length > 0) {
|
|
289
|
+
sourceStreamId = resolveStreamId(candidates[0]) || "";
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Only write the DB record when there is a real, distinct source stream.
|
|
294
|
+
// When sourceStreamId is empty (source branch gone / unresolved) the wire
|
|
295
|
+
// event is still emitted below — we just skip the DB record to avoid a
|
|
296
|
+
// self-merge binding.
|
|
297
|
+
if (sourceStreamId) {
|
|
298
|
+
await recordObservedMerge(tracker, {
|
|
299
|
+
sourceStreamId,
|
|
300
|
+
sourceCommit,
|
|
301
|
+
targetStreamId,
|
|
302
|
+
mergeCommit: mergeSha,
|
|
303
|
+
metadata: { trigger: "watcher-merge" },
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const { agentId: attrAgent, metadata } = attributionFor({
|
|
308
|
+
trigger: "watcher-merge",
|
|
309
|
+
branch: targetBranch,
|
|
310
|
+
});
|
|
311
|
+
emitStreamMerged(conn, buildStreamMergedParams({
|
|
312
|
+
sourceStreamId,
|
|
313
|
+
targetStreamId,
|
|
314
|
+
mergeCommit: mergeSha,
|
|
315
|
+
agentId: attrAgent,
|
|
316
|
+
sourceCommit,
|
|
317
|
+
strategy: "merge-commit",
|
|
318
|
+
metadata,
|
|
319
|
+
}));
|
|
320
|
+
} catch (err) {
|
|
321
|
+
log.warn("emitMerge failed", { targetBranch, mergeSha, error: err.message });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Handle a local branch ref that advanced (or appeared) since last tick. */
|
|
326
|
+
async function handleLocalBranch(refname, oldSha, newSha) {
|
|
327
|
+
const branch = refname.slice("refs/heads/".length);
|
|
328
|
+
|
|
329
|
+
// New branch — register it, link best-effort to the branch it forked from.
|
|
330
|
+
if (!oldSha) {
|
|
331
|
+
let parentStream;
|
|
332
|
+
try {
|
|
333
|
+
const tracked = tracker?.listStreams?.() || [];
|
|
334
|
+
// Best-effort fork detection: among tracked branches, pick the one
|
|
335
|
+
// whose merge-base with the new branch is the new branch's own root
|
|
336
|
+
// (a clean fork). Prefer a tracked branch whose HEAD *is* the fork
|
|
337
|
+
// point — that is the most likely parent. Fall back to the first
|
|
338
|
+
// tracked branch that shares any history.
|
|
339
|
+
let exactForkStreamId = null;
|
|
340
|
+
let sharedHistoryStreamId = null;
|
|
341
|
+
for (const stream of tracked) {
|
|
342
|
+
let otherBranch;
|
|
343
|
+
try {
|
|
344
|
+
otherBranch = tracker.getStreamBranchName(stream.id);
|
|
345
|
+
} catch { continue; }
|
|
346
|
+
if (!otherBranch || otherBranch === branch) continue;
|
|
347
|
+
const base = await git(repoPath, ["merge-base", branch, otherBranch]);
|
|
348
|
+
if (!base) continue;
|
|
349
|
+
if (!sharedHistoryStreamId) sharedHistoryStreamId = stream.id;
|
|
350
|
+
const otherHead = await git(repoPath, ["rev-parse", otherBranch]);
|
|
351
|
+
if (otherHead && otherHead === base) {
|
|
352
|
+
exactForkStreamId = stream.id;
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
parentStream = exactForkStreamId || sharedHistoryStreamId || undefined;
|
|
357
|
+
} catch { /* best-effort parent linkage */ }
|
|
358
|
+
|
|
359
|
+
const streamId = resolveStreamId(branch, { parentStream });
|
|
360
|
+
if (streamId) {
|
|
361
|
+
const { agentId: attrAgent, metadata } = attributionFor({
|
|
362
|
+
trigger: "watcher-new-branch",
|
|
363
|
+
branch,
|
|
364
|
+
});
|
|
365
|
+
emitStreamOpened(conn, buildStreamOpenedParams({
|
|
366
|
+
streamId,
|
|
367
|
+
name: branch,
|
|
368
|
+
agentId: attrAgent || fallbackAgentId,
|
|
369
|
+
baseCommit: newSha,
|
|
370
|
+
branchName: branch,
|
|
371
|
+
parentStream,
|
|
372
|
+
metadata,
|
|
373
|
+
}));
|
|
374
|
+
}
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (oldSha === newSha) return;
|
|
379
|
+
|
|
380
|
+
const streamId = resolveStreamId(branch);
|
|
381
|
+
if (!streamId) return;
|
|
382
|
+
|
|
383
|
+
// Merge commit? The new HEAD with 2+ parents is a merge.
|
|
384
|
+
const parentLine = await git(repoPath, ["rev-list", "--parents", "-n1", newSha]);
|
|
385
|
+
const parents = parentLine ? parentLine.split(/\s+/).slice(1) : [];
|
|
386
|
+
const isMerge = parents.length >= 2;
|
|
387
|
+
|
|
388
|
+
// Emit committed events for each new commit, oldest-first.
|
|
389
|
+
// When this is a merge, use --first-parent so only the target branch's own
|
|
390
|
+
// commits are included — the entire merged-in side-branch history is excluded.
|
|
391
|
+
const revList = await git(repoPath, [
|
|
392
|
+
"rev-list", "--reverse",
|
|
393
|
+
...(isMerge ? ["--first-parent"] : []),
|
|
394
|
+
`${oldSha}..${newSha}`,
|
|
395
|
+
]);
|
|
396
|
+
const newCommits = revList ? revList.split("\n").filter(Boolean) : [];
|
|
397
|
+
for (const sha of newCommits) {
|
|
398
|
+
if (isMerge && sha === newSha) continue; // merge commit handled below
|
|
399
|
+
await emitCommit(streamId, branch, sha);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (isMerge) {
|
|
403
|
+
await emitMerge(streamId, branch, newSha, parents);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Handle a remote-tracking branch ref that changed — observed as a push. */
|
|
408
|
+
async function handleRemoteBranch(refname, oldSha, newSha) {
|
|
409
|
+
if (oldSha === newSha) return;
|
|
410
|
+
// refs/remotes/<remote>/<branch...>
|
|
411
|
+
const rest = refname.slice("refs/remotes/".length);
|
|
412
|
+
const slash = rest.indexOf("/");
|
|
413
|
+
if (slash === -1) return;
|
|
414
|
+
const remote = rest.slice(0, slash);
|
|
415
|
+
const branch = rest.slice(slash + 1);
|
|
416
|
+
|
|
417
|
+
// Only treat it as a push when a local branch matches the new SHA — i.e.
|
|
418
|
+
// the local branch's work was pushed to the remote.
|
|
419
|
+
const localSha = await git(repoPath, ["rev-parse", `refs/heads/${branch}`]);
|
|
420
|
+
if (!localSha || localSha !== newSha) return;
|
|
421
|
+
|
|
422
|
+
const streamId = resolveStreamId(branch);
|
|
423
|
+
if (!streamId) return;
|
|
424
|
+
|
|
425
|
+
const { agentId: attrAgent, metadata } = attributionFor({
|
|
426
|
+
trigger: "watcher-push",
|
|
427
|
+
branch,
|
|
428
|
+
});
|
|
429
|
+
emitStreamPushed(conn, buildStreamPushedParams({
|
|
430
|
+
streamId,
|
|
431
|
+
agentId: attrAgent,
|
|
432
|
+
pushedCommit: newSha,
|
|
433
|
+
remote,
|
|
434
|
+
remoteRef: branch,
|
|
435
|
+
metadata,
|
|
436
|
+
}));
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Probe in-progress merge-conflict state.
|
|
441
|
+
*
|
|
442
|
+
* Cheap on the steady-state (no-merge) path: just one `existsSync` on
|
|
443
|
+
* `.git/MERGE_HEAD` (resolved once via `git rev-parse --git-dir`). Only on a
|
|
444
|
+
* transition does the probe spend more cycles to gather files, parents, and
|
|
445
|
+
* stream attribution.
|
|
446
|
+
*
|
|
447
|
+
* Transitions handled:
|
|
448
|
+
* - off → on: emit `stream.conflicted` with conflicted files + record a
|
|
449
|
+
* conflict row so we have a stable `conflict_id` to correlate the
|
|
450
|
+
* resolution event with.
|
|
451
|
+
* - on → off: discriminate manual-vs-abandoned by walking HEAD. If HEAD
|
|
452
|
+
* advanced to a commit with ≥2 parents since the conflict started, the
|
|
453
|
+
* user (or an agent) committed the merge: `resolution_method: "manual"`
|
|
454
|
+
* (or `"agent"` when a fresh attribution hint is present). Otherwise
|
|
455
|
+
* HEAD is unchanged → the merge was aborted: `resolution_method:
|
|
456
|
+
* "abandoned"`.
|
|
457
|
+
*
|
|
458
|
+
* Rebase conflicts (`.git/rebase-merge/`, `.git/rebase-apply/`) are out of
|
|
459
|
+
* scope for v1 — known TODO.
|
|
460
|
+
*
|
|
461
|
+
* Resilient — the whole probe is wrapped in try/catch and logs+continues on
|
|
462
|
+
* any failure. A wrong-state emission is worse than no emission.
|
|
463
|
+
*/
|
|
464
|
+
async function probeMergeConflicts() {
|
|
465
|
+
try {
|
|
466
|
+
// Resolve the worktree-local .git dir cheaply. `git rev-parse --git-dir`
|
|
467
|
+
// returns the dir relative to the cwd; resolve against `repoPath`.
|
|
468
|
+
const gitDirRel = await git(repoPath, ["rev-parse", "--git-dir"]);
|
|
469
|
+
if (!gitDirRel) {
|
|
470
|
+
// Not a git repo (or git not available). If we had an in-flight merge
|
|
471
|
+
// we can't reason about it any more — drop the cache silently.
|
|
472
|
+
inFlightMerge = null;
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
const gitDir = path.isAbsolute(gitDirRel)
|
|
476
|
+
? gitDirRel
|
|
477
|
+
: path.resolve(repoPath, gitDirRel);
|
|
478
|
+
const mergeHeadPath = path.join(gitDir, "MERGE_HEAD");
|
|
479
|
+
let inMerge;
|
|
480
|
+
try {
|
|
481
|
+
inMerge = existsSync(mergeHeadPath);
|
|
482
|
+
} catch {
|
|
483
|
+
inMerge = false;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// No transition: nothing to emit (conflict events fire on transitions
|
|
487
|
+
// only, never on every tick while a conflict is open).
|
|
488
|
+
if (inMerge && inFlightMerge) return;
|
|
489
|
+
if (!inMerge && !inFlightMerge) return;
|
|
490
|
+
|
|
491
|
+
if (inMerge && !inFlightMerge) {
|
|
492
|
+
// off → on: collect conflicted files, parents, owning stream, then emit.
|
|
493
|
+
const filesRaw = await git(repoPath, [
|
|
494
|
+
"diff", "--name-only", "--diff-filter=U",
|
|
495
|
+
]);
|
|
496
|
+
const conflictedFiles = filesRaw
|
|
497
|
+
? filesRaw.split("\n").map((f) => f.trim()).filter(Boolean)
|
|
498
|
+
: [];
|
|
499
|
+
|
|
500
|
+
// MERGE_HEAD is the SHA being merged in.
|
|
501
|
+
let conflictingCommit = "";
|
|
502
|
+
try {
|
|
503
|
+
conflictingCommit = readFileSync(mergeHeadPath, "utf-8").split(/\s+/)[0] || "";
|
|
504
|
+
} catch { /* leave empty */ }
|
|
505
|
+
|
|
506
|
+
const targetCommit = await git(repoPath, ["rev-parse", "HEAD"]);
|
|
507
|
+
|
|
508
|
+
// Resolve the owning stream id via the current branch.
|
|
509
|
+
let branch = "";
|
|
510
|
+
const symbolic = await git(repoPath, ["symbolic-ref", "HEAD"]);
|
|
511
|
+
if (symbolic && symbolic.startsWith("refs/heads/")) {
|
|
512
|
+
branch = symbolic.slice("refs/heads/".length);
|
|
513
|
+
}
|
|
514
|
+
const streamId = branch ? resolveStreamId(branch) : null;
|
|
515
|
+
if (!streamId) {
|
|
516
|
+
// Can't attribute to a stream — leave the cache empty so we don't
|
|
517
|
+
// try to emit a bogus resolved event later either.
|
|
518
|
+
log.warn("probeMergeConflicts: no owning stream", { branch });
|
|
519
|
+
inFlightMerge = { skip: true };
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const conflictId = await recordObservedConflict(tracker, {
|
|
524
|
+
streamId,
|
|
525
|
+
conflictingCommit,
|
|
526
|
+
targetCommit,
|
|
527
|
+
conflictedFiles,
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
const { agentId: attrAgent, metadata } = attributionFor({
|
|
531
|
+
trigger: "watcher-conflict",
|
|
532
|
+
branch,
|
|
533
|
+
});
|
|
534
|
+
emitStreamConflicted(conn, buildStreamConflictedParams({
|
|
535
|
+
streamId,
|
|
536
|
+
conflictId: conflictId || "",
|
|
537
|
+
conflictedFiles,
|
|
538
|
+
agentId: attrAgent,
|
|
539
|
+
conflictingCommit,
|
|
540
|
+
targetCommit,
|
|
541
|
+
source: "merge",
|
|
542
|
+
metadata,
|
|
543
|
+
}));
|
|
544
|
+
|
|
545
|
+
inFlightMerge = {
|
|
546
|
+
streamId,
|
|
547
|
+
branch,
|
|
548
|
+
conflictId: conflictId || "",
|
|
549
|
+
conflictingCommit,
|
|
550
|
+
targetCommitBeforeResolve: targetCommit,
|
|
551
|
+
};
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (!inMerge && inFlightMerge) {
|
|
556
|
+
// on → off: figure out whether HEAD advanced (manual/agent resolve)
|
|
557
|
+
// or stayed put (abandoned via `git merge --abort`).
|
|
558
|
+
const cached = inFlightMerge;
|
|
559
|
+
inFlightMerge = null;
|
|
560
|
+
if (cached.skip) return;
|
|
561
|
+
|
|
562
|
+
const currentHead = await git(repoPath, ["rev-parse", "HEAD"]);
|
|
563
|
+
let resolutionMethod = "abandoned";
|
|
564
|
+
let resolutionSummary;
|
|
565
|
+
if (currentHead && currentHead !== cached.targetCommitBeforeResolve) {
|
|
566
|
+
// HEAD moved. If the new HEAD has ≥2 parents we observed a real
|
|
567
|
+
// merge commit (the conflict was resolved + committed). Treat
|
|
568
|
+
// attribution-present resolutions as "agent" so the wire event
|
|
569
|
+
// reflects who did the work.
|
|
570
|
+
const parentLine = await git(repoPath, ["rev-list", "--parents", "-n1", currentHead]);
|
|
571
|
+
const parents = parentLine ? parentLine.split(/\s+/).slice(1) : [];
|
|
572
|
+
if (parents.length >= 2) {
|
|
573
|
+
const hint = freshAttribution();
|
|
574
|
+
resolutionMethod = hint?.agentId ? "agent" : "manual";
|
|
575
|
+
resolutionSummary = `Merged ${cached.conflictingCommit.slice(0, 7) || "MERGE_HEAD"} into ${cached.branch || "branch"}`;
|
|
576
|
+
}
|
|
577
|
+
// HEAD moved but not to a merge commit — unusual (e.g. user committed
|
|
578
|
+
// with `--no-ff` flow). Treat as "manual" so we don't lose the resolve.
|
|
579
|
+
else {
|
|
580
|
+
resolutionMethod = "manual";
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const { agentId: attrAgent, metadata } = attributionFor({
|
|
585
|
+
trigger: "watcher-conflict-resolved",
|
|
586
|
+
branch: cached.branch,
|
|
587
|
+
});
|
|
588
|
+
const resolvedBy = attrAgent || (resolutionMethod === "abandoned" ? "human" : "human");
|
|
589
|
+
|
|
590
|
+
await recordObservedConflictResolved(tracker, {
|
|
591
|
+
conflictId: cached.conflictId,
|
|
592
|
+
method: resolutionMethod,
|
|
593
|
+
resolvedBy,
|
|
594
|
+
summary: resolutionSummary,
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
emitStreamConflictResolved(conn, buildStreamConflictResolvedParams({
|
|
598
|
+
streamId: cached.streamId,
|
|
599
|
+
conflictId: cached.conflictId,
|
|
600
|
+
resolutionMethod,
|
|
601
|
+
resolvedBy,
|
|
602
|
+
resolutionSummary,
|
|
603
|
+
metadata,
|
|
604
|
+
}));
|
|
605
|
+
}
|
|
606
|
+
} catch (err) {
|
|
607
|
+
log.warn("probeMergeConflicts failed", { error: err.message });
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** One poll tick: snapshot refs, diff against the prior snapshot, emit. */
|
|
612
|
+
async function tick() {
|
|
613
|
+
if (stopped || ticking) return;
|
|
614
|
+
// Wait for the baseline to be established so a poll never races start.
|
|
615
|
+
if (readyPromise) {
|
|
616
|
+
try { await readyPromise; } catch { /* ignore — start logged it */ }
|
|
617
|
+
}
|
|
618
|
+
if (stopped || ticking) return;
|
|
619
|
+
ticking = true;
|
|
620
|
+
try {
|
|
621
|
+
const refs = await snapshotRefs(repoPath);
|
|
622
|
+
if (!prevRefs) {
|
|
623
|
+
// Baseline — record without emitting (no history replay).
|
|
624
|
+
prevRefs = refs;
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
for (const [refname, newSha] of refs) {
|
|
629
|
+
const oldSha = prevRefs.get(refname) || null;
|
|
630
|
+
if (oldSha === newSha) continue;
|
|
631
|
+
try {
|
|
632
|
+
if (refname.startsWith("refs/heads/")) {
|
|
633
|
+
await handleLocalBranch(refname, oldSha, newSha);
|
|
634
|
+
} else if (refname.startsWith("refs/remotes/")) {
|
|
635
|
+
await handleRemoteBranch(refname, oldSha, newSha);
|
|
636
|
+
}
|
|
637
|
+
} catch (err) {
|
|
638
|
+
log.warn("tick: ref handler failed", { refname, error: err.message });
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
prevRefs = refs;
|
|
643
|
+
|
|
644
|
+
// Probe in-progress merge state *after* the ref-diff pass — so an
|
|
645
|
+
// aborted merge's "HEAD unchanged" check is consistent with the same
|
|
646
|
+
// refs snapshot we just diffed.
|
|
647
|
+
await probeMergeConflicts();
|
|
648
|
+
} catch (err) {
|
|
649
|
+
log.warn("tick failed", { error: err.message });
|
|
650
|
+
} finally {
|
|
651
|
+
ticking = false;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Take the baseline snapshot, re-assert open streams, then start polling.
|
|
656
|
+
readyPromise = (async () => {
|
|
657
|
+
try {
|
|
658
|
+
prevRefs = await snapshotRefs(repoPath);
|
|
659
|
+
reassertStreams();
|
|
660
|
+
} catch (err) {
|
|
661
|
+
log.warn("watcher start failed", { error: err.message });
|
|
662
|
+
}
|
|
663
|
+
})();
|
|
664
|
+
|
|
665
|
+
timer = setInterval(() => { tick().catch(() => {}); }, POLL_INTERVAL_MS);
|
|
666
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
667
|
+
|
|
668
|
+
log.info("cascade watcher started", { repoPath, pollIntervalMs: POLL_INTERVAL_MS });
|
|
669
|
+
|
|
670
|
+
return {
|
|
671
|
+
/** Stop the watcher. Idempotent, never throws. */
|
|
672
|
+
stop() {
|
|
673
|
+
stopped = true;
|
|
674
|
+
if (timer) {
|
|
675
|
+
clearInterval(timer);
|
|
676
|
+
timer = null;
|
|
677
|
+
}
|
|
678
|
+
log.debug("cascade watcher stopped");
|
|
679
|
+
},
|
|
680
|
+
/**
|
|
681
|
+
* Re-assert `x-cascade/stream.opened` for all tracked streams. Called by
|
|
682
|
+
* the sidecar on MAP (re)connect — idempotent on the hub.
|
|
683
|
+
*/
|
|
684
|
+
reassertStreams,
|
|
685
|
+
/** Update the MAP connection ref (after a reconnect swaps it). */
|
|
686
|
+
setConnection(newConn) {
|
|
687
|
+
conn = newConn;
|
|
688
|
+
},
|
|
689
|
+
/** Run one tick immediately (used by tests to avoid waiting on the timer). */
|
|
690
|
+
_tickNow: tick,
|
|
691
|
+
/** Resolves once the baseline snapshot + initial re-assert have run (tests). */
|
|
692
|
+
_ready: readyPromise,
|
|
693
|
+
};
|
|
694
|
+
}
|