Files
everything-claude-code/scripts/lib/control-pane/proximity.js
T
Affaan Mustafa bd1be0c1ce feat(layer4): line-range channel + trigger firing
- Line precision: parse git diff --unified=0 into per-file changed line ranges
  (defaultWorkingSetFor), so two agents in the SAME file but DIFFERENT functions
  no longer false-collide. Overlap channel now uses the overlap coefficient
  (|A∩B|/min(|A|,|B|)) — high when one edit sits inside the other's region, low
  for disjoint ranges; whole-file edit = 1. Docstring + design doc updated.
- Trigger firing: buildProximityTriggers() turns advisories into the concrete
  messages — transmit-intent to both on a Traffic Advisory, steer-away to the
  yielding agent + a hold notice on a Resolution Advisory. buildProximitySnapshot
  now returns triggers; dispatchProximityTriggers(triggers, {sendMessage}) delivers
  them through an injectable sink (the ECC messages table), best-effort.
- 12 new tests (line-range disjoint vs overlapping, parseDiffRanges, triggers,
  dispatch). Full suite 2881/2881; lint green.
2026-06-20 17:30:52 -04:00

190 lines
6.2 KiB
JavaScript

'use strict';
/**
* Control-pane integration for the agent-space proximity metric.
*
* Turns live sessions into agent working sets (the files each session's worktree
* has changed), builds the dependency graph over those files, and runs the
* TCAS-style airspace scan — so the board can surface "two agents are converging"
* advisories and a 3D position per agent. See docs/design/agent-proximity.md.
*/
const path = require('path');
const { execFileSync } = require('child_process');
const { scanAirspace, buildProximityTriggers } = require('../agent-proximity');
const { buildDependencyGraph } = require('../agent-proximity/graph');
/**
* 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<string, Array<[number,number]>>}
*/
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 {
return runGitDiff(wt.path, base, ['--name-only'])
.split('\n')
.map(s => s.trim())
.filter(Boolean);
} catch {
return [];
}
}
/**
* 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 workingSetFor = deps.workingSetFor || (deps.changedFilesFor ? session => deps.changedFilesFor(session).map(p => ({ path: p })) : defaultWorkingSetFor);
const agents = [];
for (const session of sessions || []) {
const files = workingSetFor(session).map(f => ({ weight: 1, ...f }));
if (files.length === 0) continue;
agents.push({
agentId: session.id,
label: session.task || session.id,
startedAt: session.createdAt || null,
files
});
}
return agents;
}
/**
* Compute the proximity snapshot from the control-pane sessions.
*
* @param {Array} sessions normalized control-pane sessions
* @param {object} [options] { repoRoot, changedFilesFor, ...scanOptions }
* @returns {{ enabled, advisories, positions, links, counts }}
*/
function buildProximitySnapshot(sessions, options = {}) {
const repoRoot = path.resolve(options.repoRoot || path.join(__dirname, '..', '..', '..'));
const agents = sessionsToAgents(sessions, options);
// Need at least two participating agents for a collision to be possible.
if (agents.length < 2) {
return {
enabled: true,
advisories: [],
positions: agents.map(a => ({ agentId: a.agentId, position: [0, 0, 0], fileCount: a.files.length })),
links: [],
counts: { agents: agents.length, advisories: 0, resolutions: 0 }
};
}
const touched = [...new Set(agents.flatMap(a => a.files.map(f => f.path)))];
let graph = { adjacency: {}, files: [] };
try {
graph = options.graph || buildDependencyGraph(repoRoot, touched, options.graphDeps || {});
} catch {
graph = { adjacency: {}, files: [] };
}
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,
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,
defaultWorkingSetFor,
defaultChangedFilesFor,
parseDiffRanges,
dispatchProximityTriggers
};