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.
This commit is contained in:
Affaan Mustafa
2026-06-20 17:30:52 -04:00
parent e2b8a51cea
commit bd1be0c1ce
6 changed files with 251 additions and 43 deletions
+21 -17
View File
@@ -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
* (SzymkiewiczSimpson) 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 (SzymkiewiczSimpson):
* |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) {
+53
View File
@@ -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
};
+95 -17
View File
@@ -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 <base>` 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<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 {
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
};