diff --git a/docs/design/agent-proximity.md b/docs/design/agent-proximity.md index 649d13a9..c9d3e2b9 100644 --- a/docs/design/agent-proximity.md +++ b/docs/design/agent-proximity.md @@ -44,11 +44,15 @@ with channel weights *ω_i ∈ [0,1]*. The reported **distance** is the dual For shared files *S = files(W_a) ∩ files(W_b)*: ``` -lineOverlap(f) = |R_f^a ∩ R_f^b| / |R_f^a ∪ R_f^b| -r_overlap = max( Jaccard(files_a, files_b), max_{f∈S} lineOverlap(f) ) (3) +lineOverlap(f) = |R_f^a ∩ R_f^b| / min(|R_f^a|, |R_f^b|) (overlap coefficient) +r_overlap = max_{f∈S} w_f^a·w_f^b · lineOverlap(f) (3) ``` -Same file, overlapping lines ⇒ imminent collision (*r_overlap → 1*). +The overlap coefficient (not Jaccard) is the right measure: it stays high when one +agent's small edit sits inside the other's large region (Jaccard would dilute it by +union size). A whole-file edit (no line info) ⇒ `lineOverlap = 1`. Same file, +overlapping lines ⇒ imminent collision; same file, *disjoint* line ranges (different +functions) ⇒ low `r_overlap`. Different files ⇒ no shared `f` ⇒ `r_overlap = 0`. ### Channel 2 — dependency coupling *r_dep* diff --git a/scripts/lib/agent-proximity/distance.js b/scripts/lib/agent-proximity/distance.js index 7118825f..2cddcbb8 100644 --- a/scripts/lib/agent-proximity/distance.js +++ b/scripts/lib/agent-proximity/distance.js @@ -31,11 +31,14 @@ * r_tree — proximity in the directory tree (a soft prior) * * ── Channel 1: edit overlap ──────────────────────────────────────────────── - * For the shared files S = files(W_a) ∩ files(W_b): - * - same file, no line info → Jaccard of the file sets is the floor. - * - same file with line ranges → fraction of overlapping lines: - * lineOverlap(f) = |R_f^a ∩ R_f^b| / |R_f^a ∪ R_f^b|. - * r_overlap = max( jaccard(files_a, files_b), max_{f∈S} lineOverlap(f) ). (3) + * For each shared file f ∈ files(W_a) ∩ files(W_b), the overlap COEFFICIENT + * (Szymkiewicz–Simpson) over the edited line ranges: + * lineOverlap(f) = |R_f^a ∩ R_f^b| / min(|R_f^a|, |R_f^b|) + * (the right collision measure — high when one agent's edit sits inside the + * other's region even if that region is huge; =1 when either side is a whole-file + * edit). The channel risk is the recency-weighted max across shared files: + * r_overlap = max_{f∈S} w_f^a·w_f^b · lineOverlap(f). (3) + * Different files ⇒ no shared f ⇒ r_overlap = 0 (tree/dep channels take over). * * ── Channel 2: dependency coupling ───────────────────────────────────────── * Build a directed dependency graph G=(V,E), V=files, edge f→g iff f imports g. @@ -111,9 +114,11 @@ function treeDistance(a, b) { } /** - * Line-range overlap fraction (Jaccard over covered lines) for two range lists. - * Each range is [start, end] inclusive. Empty/absent ranges ⇒ treat the whole - * file as touched, so two file-level edits to the same file count as full overlap. + * Line-range overlap as the overlap COEFFICIENT (Szymkiewicz–Simpson): + * |A ∩ B| / min(|A|, |B|). This is the right collision measure — if one agent's + * edit sits largely inside the other's region the score is high even when the + * other region is huge (Jaccard would dilute it by union size). Empty/absent + * ranges ⇒ whole-file edit ⇒ full overlap (1). Each range is [start,end] inclusive. */ function lineRangeOverlap(rangesA, rangesB) { const a = Array.isArray(rangesA) ? rangesA : []; @@ -130,14 +135,10 @@ function lineRangeOverlap(rangesA, rangesB) { }; const ca = covered(a); const cb = covered(b); + if (ca.size === 0 || cb.size === 0) return 0; let inter = 0; for (const v of ca) if (cb.has(v)) inter += 1; - const union = ca.size + cb.size - inter; - return union === 0 ? 0 : inter / union; -} - -function fileSet(workingSet) { - return new Set((workingSet.files || []).map(f => normalizePath(f.path))); + return inter / Math.min(ca.size, cb.size); } function jaccard(setA, setB) { @@ -154,10 +155,13 @@ function jaccard(setA, setB) { function overlapRisk(a, b) { const filesA = a.files || []; const filesB = b.files || []; - const setA = fileSet(a); - const setB = fileSet(b); - let r = jaccard(setA, setB); const byPathB = new Map(filesB.map(f => [normalizePath(f.path), f])); + // Per shared file, the (line-precise) overlap — lineRangeOverlap returns 1 when + // either side lacks ranges (a whole-file edit). The risk is the max across + // shared files: even one fully-overlapping file is a collision, while the same + // file edited in disjoint line ranges scores low. No coarse file-set Jaccard + // floor (it would max out for any shared file and mask line-level disjointness). + let r = 0; for (const fa of filesA) { const fb = byPathB.get(normalizePath(fa.path)); if (fb) { diff --git a/scripts/lib/agent-proximity/index.js b/scripts/lib/agent-proximity/index.js index cd934c27..6815fe29 100644 --- a/scripts/lib/agent-proximity/index.js +++ b/scripts/lib/agent-proximity/index.js @@ -158,6 +158,58 @@ function scanAirspace(agents, graph = {}, options = {}) { }; } +function clamp01(x) { + return !Number.isFinite(x) ? 0 : x < 0 ? 0 : x > 1 ? 1 : x; +} +function pct(x) { + return Math.round(clamp01(x) * 100); +} + +/** + * Turn airspace advisories into the messages to inject between agent sessions — + * the concrete "transmit intent / steer away" actions. Transport-agnostic: each + * trigger is { to, from, type, risk, content }; a dispatcher delivers them. + */ +function buildProximityTriggers(advisories) { + const triggers = []; + for (const adv of advisories || []) { + if (adv.level === 'advisory') { + // Traffic Advisory: both agents exchange intent. + triggers.push({ + to: adv.a, + from: adv.b, + type: 'proximity_transmit', + risk: adv.risk, + content: `Proximity ${pct(adv.risk)}%: you and ${adv.b} are converging in code-space. Share what you're working on and check for overlap before continuing.` + }); + triggers.push({ + to: adv.b, + from: adv.a, + type: 'proximity_transmit', + risk: adv.risk, + content: `Proximity ${pct(adv.risk)}%: you and ${adv.a} are converging in code-space. Share what you're working on and check for overlap before continuing.` + }); + } else if (adv.level === 'resolution') { + // Resolution Advisory: the lower-priority agent steers; the other holds. + triggers.push({ + to: adv.steer, + from: adv.hold, + type: 'proximity_steer', + risk: adv.risk, + content: `Collision risk ${pct(adv.risk)}% with ${adv.hold}, which holds right-of-way. Steer away: move to a different file/area, or coordinate with ${adv.hold} before editing the shared region.` + }); + triggers.push({ + to: adv.hold, + from: adv.steer, + type: 'proximity_hold', + risk: adv.risk, + content: `Collision risk ${pct(adv.risk)}% with ${adv.steer}; you hold right-of-way. ${adv.steer} has been asked to steer away — continue, but expect a handoff if they can't.` + }); + } + } + return triggers; +} + module.exports = { DEFAULTS, scanAirspace, @@ -165,6 +217,7 @@ module.exports = { fileCoordinate, collisionRisk, advise, + buildProximityTriggers, buildDependencyGraph, buildDependencyGraphFromSources }; diff --git a/scripts/lib/control-pane/proximity.js b/scripts/lib/control-pane/proximity.js index bc7378d3..bfe1ecf6 100644 --- a/scripts/lib/control-pane/proximity.js +++ b/scripts/lib/control-pane/proximity.js @@ -12,25 +12,74 @@ const path = require('path'); const { execFileSync } = require('child_process'); -const { scanAirspace } = require('../agent-proximity'); +const { scanAirspace, buildProximityTriggers } = require('../agent-proximity'); const { buildDependencyGraph } = require('../agent-proximity/graph'); /** - * Default working-set source: `git diff --name-only ` inside a session's - * worktree, returning repo-relative changed files. Returns [] on any failure so + * Parse `git diff --unified=0` output into per-file NEW-side line ranges. Hunk + * headers look like `@@ -a,b +c,d @@`; we keep the +c,d (new) side so the overlap + * channel can tell that two agents touch the *same file* but *different line + * ranges* (different functions) and not flag a false collision. + * + * @param {string} diff + * @returns {Map>} + */ +function parseDiffRanges(diff) { + const byFile = new Map(); + let current = null; + for (const line of String(diff || '').split('\n')) { + const fileMatch = line.match(/^\+\+\+ b\/(.+)$/); + if (fileMatch) { + const name = fileMatch[1].trim(); + current = name === '/dev/null' ? null : name; + if (current && !byFile.has(current)) byFile.set(current, []); + continue; + } + const hunk = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/); + if (hunk && current) { + const start = parseInt(hunk[1], 10); + const count = hunk[2] === undefined ? 1 : parseInt(hunk[2], 10); + if (count > 0) byFile.get(current).push([start, start + count - 1]); + } + } + return byFile; +} + +function runGitDiff(worktreePath, base, extraArgs) { + return execFileSync('git', ['-C', worktreePath, 'diff', ...extraArgs, `${base}...HEAD`], { + encoding: 'utf8', + timeout: 5000, + maxBuffer: 8 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'ignore'] + }); +} + +/** + * Default working-set source: a session's worktree diff against its base, with + * per-file changed line ranges. Returns [{ path, lines? }], or [] on failure so * proximity degrades gracefully (never throws into the snapshot path). */ +function defaultWorkingSetFor(session) { + const wt = session && session.worktree; + if (!wt || !wt.path) return []; + const base = wt.base || 'HEAD'; + try { + const ranges = parseDiffRanges(runGitDiff(wt.path, base, ['--unified=0'])); + return [...ranges.entries()].map(([path, lines]) => (lines.length > 0 ? { path, lines } : { path })); + } catch { + return []; + } +} + +/** + * Back-compat file-name-only source (no line ranges). + */ function defaultChangedFilesFor(session) { const wt = session && session.worktree; if (!wt || !wt.path) return []; const base = wt.base || 'HEAD'; try { - const out = execFileSync('git', ['-C', wt.path, 'diff', '--name-only', `${base}...HEAD`], { - encoding: 'utf8', - timeout: 4000, - stdio: ['ignore', 'pipe', 'ignore'] - }); - return out + return runGitDiff(wt.path, base, ['--name-only']) .split('\n') .map(s => s.trim()) .filter(Boolean); @@ -42,12 +91,14 @@ function defaultChangedFilesFor(session) { /** * Map sessions to agent working sets. Only sessions with a worktree and at least * one changed file participate (an agent with no edits cannot collide). + * Inject `workingSetFor` (returns [{path,lines?}]) or `changedFilesFor` + * (returns file-name strings) for tests. */ function sessionsToAgents(sessions, deps = {}) { - const changedFilesFor = deps.changedFilesFor || defaultChangedFilesFor; + const workingSetFor = deps.workingSetFor || (deps.changedFilesFor ? session => deps.changedFilesFor(session).map(p => ({ path: p })) : defaultWorkingSetFor); const agents = []; for (const session of sessions || []) { - const files = changedFilesFor(session).map(p => ({ path: p, weight: 1 })); + const files = workingSetFor(session).map(f => ({ weight: 1, ...f })); if (files.length === 0) continue; agents.push({ agentId: session.id, @@ -91,21 +142,48 @@ function buildProximitySnapshot(sessions, options = {}) { const scan = scanAirspace(agents, graph, options); const labels = new Map(agents.map(a => [a.agentId, a.label])); + const advisories = scan.advisories.map(adv => ({ + ...adv, + aLabel: labels.get(adv.a) || adv.a, + bLabel: labels.get(adv.b) || adv.b + })); return { enabled: true, - advisories: scan.advisories.map(adv => ({ - ...adv, - aLabel: labels.get(adv.a) || adv.a, - bLabel: labels.get(adv.b) || adv.b - })), + advisories, + triggers: buildProximityTriggers(scan.advisories), positions: scan.positions, links: scan.links, counts: scan.counts }; } +/** + * Deliver proximity triggers via an injected message sink. The sink is + * `sendMessage({ fromSession, toSession, content, msgType })` — e.g. a writer + * for the ECC `messages` table the control pane already reads. Best-effort: + * a failing send is skipped, never thrown. Returns the dispatched count. + */ +function dispatchProximityTriggers(triggers, deps = {}) { + const send = deps.sendMessage; + if (typeof send !== 'function') return { dispatched: 0, skipped: (triggers || []).length }; + let dispatched = 0; + let skipped = 0; + for (const t of triggers || []) { + try { + send({ fromSession: t.from, toSession: t.to, content: t.content, msgType: t.type }); + dispatched += 1; + } catch { + skipped += 1; + } + } + return { dispatched, skipped }; +} + module.exports = { buildProximitySnapshot, sessionsToAgents, - defaultChangedFilesFor + defaultWorkingSetFor, + defaultChangedFilesFor, + parseDiffRanges, + dispatchProximityTriggers }; diff --git a/tests/lib/agent-proximity.test.js b/tests/lib/agent-proximity.test.js index f4b2787f..2b7d5471 100644 --- a/tests/lib/agent-proximity.test.js +++ b/tests/lib/agent-proximity.test.js @@ -46,10 +46,14 @@ test('treeDistance: siblings are closer than cousins', () => { test('lineRangeOverlap: full overlap when whole-file (no ranges)', () => { assert.strictEqual(lineRangeOverlap([], []), 1); }); -test('lineRangeOverlap: partial overlapping ranges', () => { +test('lineRangeOverlap: partial overlapping ranges (overlap coefficient)', () => { const r = lineRangeOverlap([[1, 10]], [[5, 14]]); - // overlap lines 5..10 = 6, union 1..14 = 14 → 6/14 - assert.ok(Math.abs(r - 6 / 14) < 1e-9, `got ${r}`); + // overlap lines 5..10 = 6; min size = 10 → 6/10 + assert.ok(Math.abs(r - 6 / 10) < 1e-9, `got ${r}`); +}); +test('lineRangeOverlap: smaller edit fully inside the larger ⇒ 1', () => { + // overlap coefficient catches "B's whole edit is inside A's region". + assert.strictEqual(lineRangeOverlap([[1, 200]], [[40, 60]]), 1); }); test('lineRangeOverlap: disjoint ranges are 0', () => { assert.strictEqual(lineRangeOverlap([[1, 5]], [[20, 25]]), 0); @@ -78,12 +82,18 @@ test('graphDistance: direct edge is 1, two hops is 2, unreachable is Infinity', // ── collision risk channels ── test('collisionRisk: two agents editing the SAME file ⇒ high risk', () => { - const a = { agentId: 'a', files: [{ path: 'src/api/users.js', lines: [[1, 50]] }] }; - const b = { agentId: 'b', files: [{ path: 'src/api/users.js', lines: [[40, 90]] }] }; + // Whole-file edits to the same file (no line info) ⇒ full overlap. + const a = { agentId: 'a', files: [{ path: 'src/api/users.js' }] }; + const b = { agentId: 'b', files: [{ path: 'src/api/users.js' }] }; const { risk, channels } = collisionRisk(a, b, {}); assert.ok(risk > 0.5, `same-file risk ${risk} should be high`); assert.ok(channels.overlap > 0); }); +test('collisionRisk: same file but heavily-overlapping lines ⇒ still high', () => { + const a = { agentId: 'a', files: [{ path: 'src/api/users.js', lines: [[1, 50]] }] }; + const b = { agentId: 'b', files: [{ path: 'src/api/users.js', lines: [[5, 55]] }] }; + assert.ok(collisionRisk(a, b, {}).risk > 0.5); +}); test('collisionRisk: unrelated far-apart files ⇒ low risk', () => { const a = { agentId: 'a', files: [{ path: 'src/api/users.js' }] }; const b = { agentId: 'b', files: [{ path: 'docs/guide.md' }] }; diff --git a/tests/lib/control-pane-proximity.test.js b/tests/lib/control-pane-proximity.test.js index e15bc51d..556cb654 100644 --- a/tests/lib/control-pane-proximity.test.js +++ b/tests/lib/control-pane-proximity.test.js @@ -5,7 +5,7 @@ const assert = require('assert'); -const { buildProximitySnapshot, sessionsToAgents } = require('../../scripts/lib/control-pane/proximity'); +const { buildProximitySnapshot, sessionsToAgents, parseDiffRanges, dispatchProximityTriggers } = require('../../scripts/lib/control-pane/proximity'); let passed = 0; let failed = 0; @@ -87,5 +87,64 @@ test('buildProximitySnapshot: advisories carry human-readable labels', () => { assert.ok(collision.aLabel && collision.bLabel, 'labels present'); }); +test('parseDiffRanges: extracts new-side line ranges per file', () => { + const diff = ['diff --git a/src/x.js b/src/x.js', '--- a/src/x.js', '+++ b/src/x.js', '@@ -10,0 +11,3 @@', '+a', '+b', '+c', '@@ -40,2 +44,1 @@', '+z'].join('\n'); + const ranges = parseDiffRanges(diff); + assert.deepStrictEqual(ranges.get('src/x.js'), [ + [11, 13], + [44, 44] + ]); +}); + +test('line-range channel: same file but disjoint ranges ⇒ no resolution', () => { + // Two agents in the same file, far-apart functions. workingSetFor provides ranges. + const workingSetFor = s => + ({ + 'lead-hermes': [{ path: 'src/api/users.js', lines: [[1, 20]] }], + 'worker-kb': [{ path: 'src/api/users.js', lines: [[500, 540]] }] + })[s.id] || []; + const prox = buildProximitySnapshot([sessions[0], sessions[1]], { workingSetFor, graph: { adjacency: {} } }); + const collision = prox.advisories.find(a => [a.a, a.b].includes('worker-kb')); + // Disjoint line ranges in the same file should NOT be a resolution-level collision. + assert.ok(!collision || collision.level !== 'resolution', `disjoint ranges should not force a steer (got ${collision && collision.level})`); +}); + +test('line-range channel: same file overlapping ranges ⇒ resolution', () => { + // worker's edit sits inside the lead's region — a definite conflict zone. + const workingSetFor = s => + ({ + 'lead-hermes': [{ path: 'src/api/users.js', lines: [[1, 120]] }], + 'worker-kb': [{ path: 'src/api/users.js', lines: [[30, 70]] }] + })[s.id] || []; + const prox = buildProximitySnapshot([sessions[0], sessions[1]], { workingSetFor, graph: { adjacency: {} } }); + const collision = prox.advisories.find(a => [a.a, a.b].includes('worker-kb')); + assert.ok(collision && collision.level === 'resolution', 'overlapping ranges should force a steer'); +}); + +test('triggers: resolution produces a steer message to the yielding agent and a hold notice', () => { + const prox = buildProximitySnapshot(sessions, { changedFilesFor, graph: { adjacency: {} } }); + const steer = prox.triggers.find(t => t.type === 'proximity_steer'); + const hold = prox.triggers.find(t => t.type === 'proximity_hold'); + assert.ok(steer && steer.to === 'worker-kb', 'steer message goes to the yielding worker'); + assert.ok(hold && hold.to === 'lead-hermes', 'hold notice goes to the lead'); + assert.ok(/steer away/i.test(steer.content)); +}); + +test('dispatchProximityTriggers: delivers each trigger through the injected sink', () => { + const prox = buildProximitySnapshot(sessions, { changedFilesFor, graph: { adjacency: {} } }); + const sent = []; + const result = dispatchProximityTriggers(prox.triggers, { + sendMessage: m => sent.push(m) + }); + assert.strictEqual(result.dispatched, prox.triggers.length); + assert.ok(sent.every(m => m.fromSession && m.toSession && m.content && m.msgType)); +}); + +test('dispatchProximityTriggers: no sink ⇒ nothing thrown, all skipped', () => { + const r = dispatchProximityTriggers([{ to: 'a', from: 'b', type: 'x', content: 'c' }], {}); + assert.strictEqual(r.dispatched, 0); + assert.strictEqual(r.skipped, 1); +}); + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); if (failed > 0) process.exit(1);