mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-24 22:39:31 +08:00
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:
parent
e2b8a51cea
commit
bd1be0c1ce
@ -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)*:
|
For shared files *S = files(W_a) ∩ files(W_b)*:
|
||||||
|
|
||||||
```
|
```
|
||||||
lineOverlap(f) = |R_f^a ∩ R_f^b| / |R_f^a ∪ R_f^b|
|
lineOverlap(f) = |R_f^a ∩ R_f^b| / min(|R_f^a|, |R_f^b|) (overlap coefficient)
|
||||||
r_overlap = max( Jaccard(files_a, files_b), max_{f∈S} lineOverlap(f) ) (3)
|
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*
|
### Channel 2 — dependency coupling *r_dep*
|
||||||
|
|
||||||
|
|||||||
@ -31,11 +31,14 @@
|
|||||||
* r_tree — proximity in the directory tree (a soft prior)
|
* r_tree — proximity in the directory tree (a soft prior)
|
||||||
*
|
*
|
||||||
* ── Channel 1: edit overlap ────────────────────────────────────────────────
|
* ── Channel 1: edit overlap ────────────────────────────────────────────────
|
||||||
* For the shared files S = files(W_a) ∩ files(W_b):
|
* For each shared file f ∈ files(W_a) ∩ files(W_b), the overlap COEFFICIENT
|
||||||
* - same file, no line info → Jaccard of the file sets is the floor.
|
* (Szymkiewicz–Simpson) over the edited line ranges:
|
||||||
* - same file with line ranges → fraction of overlapping lines:
|
* lineOverlap(f) = |R_f^a ∩ R_f^b| / min(|R_f^a|, |R_f^b|)
|
||||||
* lineOverlap(f) = |R_f^a ∩ R_f^b| / |R_f^a ∪ R_f^b|.
|
* (the right collision measure — high when one agent's edit sits inside the
|
||||||
* r_overlap = max( jaccard(files_a, files_b), max_{f∈S} lineOverlap(f) ). (3)
|
* 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 ─────────────────────────────────────────
|
* ── Channel 2: dependency coupling ─────────────────────────────────────────
|
||||||
* Build a directed dependency graph G=(V,E), V=files, edge f→g iff f imports g.
|
* 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.
|
* Line-range overlap as the overlap COEFFICIENT (Szymkiewicz–Simpson):
|
||||||
* Each range is [start, end] inclusive. Empty/absent ranges ⇒ treat the whole
|
* |A ∩ B| / min(|A|, |B|). This is the right collision measure — if one agent's
|
||||||
* file as touched, so two file-level edits to the same file count as full overlap.
|
* 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) {
|
function lineRangeOverlap(rangesA, rangesB) {
|
||||||
const a = Array.isArray(rangesA) ? rangesA : [];
|
const a = Array.isArray(rangesA) ? rangesA : [];
|
||||||
@ -130,14 +135,10 @@ function lineRangeOverlap(rangesA, rangesB) {
|
|||||||
};
|
};
|
||||||
const ca = covered(a);
|
const ca = covered(a);
|
||||||
const cb = covered(b);
|
const cb = covered(b);
|
||||||
|
if (ca.size === 0 || cb.size === 0) return 0;
|
||||||
let inter = 0;
|
let inter = 0;
|
||||||
for (const v of ca) if (cb.has(v)) inter += 1;
|
for (const v of ca) if (cb.has(v)) inter += 1;
|
||||||
const union = ca.size + cb.size - inter;
|
return inter / Math.min(ca.size, cb.size);
|
||||||
return union === 0 ? 0 : inter / union;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fileSet(workingSet) {
|
|
||||||
return new Set((workingSet.files || []).map(f => normalizePath(f.path)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function jaccard(setA, setB) {
|
function jaccard(setA, setB) {
|
||||||
@ -154,10 +155,13 @@ function jaccard(setA, setB) {
|
|||||||
function overlapRisk(a, b) {
|
function overlapRisk(a, b) {
|
||||||
const filesA = a.files || [];
|
const filesA = a.files || [];
|
||||||
const filesB = b.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]));
|
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) {
|
for (const fa of filesA) {
|
||||||
const fb = byPathB.get(normalizePath(fa.path));
|
const fb = byPathB.get(normalizePath(fa.path));
|
||||||
if (fb) {
|
if (fb) {
|
||||||
|
|||||||
@ -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 = {
|
module.exports = {
|
||||||
DEFAULTS,
|
DEFAULTS,
|
||||||
scanAirspace,
|
scanAirspace,
|
||||||
@ -165,6 +217,7 @@ module.exports = {
|
|||||||
fileCoordinate,
|
fileCoordinate,
|
||||||
collisionRisk,
|
collisionRisk,
|
||||||
advise,
|
advise,
|
||||||
|
buildProximityTriggers,
|
||||||
buildDependencyGraph,
|
buildDependencyGraph,
|
||||||
buildDependencyGraphFromSources
|
buildDependencyGraphFromSources
|
||||||
};
|
};
|
||||||
|
|||||||
@ -12,25 +12,74 @@
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { execFileSync } = require('child_process');
|
const { execFileSync } = require('child_process');
|
||||||
|
|
||||||
const { scanAirspace } = require('../agent-proximity');
|
const { scanAirspace, buildProximityTriggers } = require('../agent-proximity');
|
||||||
const { buildDependencyGraph } = require('../agent-proximity/graph');
|
const { buildDependencyGraph } = require('../agent-proximity/graph');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default working-set source: `git diff --name-only <base>` inside a session's
|
* Parse `git diff --unified=0` output into per-file NEW-side line ranges. Hunk
|
||||||
* worktree, returning repo-relative changed files. Returns [] on any failure so
|
* 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).
|
* 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) {
|
function defaultChangedFilesFor(session) {
|
||||||
const wt = session && session.worktree;
|
const wt = session && session.worktree;
|
||||||
if (!wt || !wt.path) return [];
|
if (!wt || !wt.path) return [];
|
||||||
const base = wt.base || 'HEAD';
|
const base = wt.base || 'HEAD';
|
||||||
try {
|
try {
|
||||||
const out = execFileSync('git', ['-C', wt.path, 'diff', '--name-only', `${base}...HEAD`], {
|
return runGitDiff(wt.path, base, ['--name-only'])
|
||||||
encoding: 'utf8',
|
|
||||||
timeout: 4000,
|
|
||||||
stdio: ['ignore', 'pipe', 'ignore']
|
|
||||||
});
|
|
||||||
return out
|
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.map(s => s.trim())
|
.map(s => s.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
@ -42,12 +91,14 @@ function defaultChangedFilesFor(session) {
|
|||||||
/**
|
/**
|
||||||
* Map sessions to agent working sets. Only sessions with a worktree and at least
|
* 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).
|
* 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 = {}) {
|
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 = [];
|
const agents = [];
|
||||||
for (const session of sessions || []) {
|
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;
|
if (files.length === 0) continue;
|
||||||
agents.push({
|
agents.push({
|
||||||
agentId: session.id,
|
agentId: session.id,
|
||||||
@ -91,21 +142,48 @@ function buildProximitySnapshot(sessions, options = {}) {
|
|||||||
|
|
||||||
const scan = scanAirspace(agents, graph, options);
|
const scan = scanAirspace(agents, graph, options);
|
||||||
const labels = new Map(agents.map(a => [a.agentId, a.label]));
|
const labels = new Map(agents.map(a => [a.agentId, a.label]));
|
||||||
return {
|
const advisories = scan.advisories.map(adv => ({
|
||||||
enabled: true,
|
|
||||||
advisories: scan.advisories.map(adv => ({
|
|
||||||
...adv,
|
...adv,
|
||||||
aLabel: labels.get(adv.a) || adv.a,
|
aLabel: labels.get(adv.a) || adv.a,
|
||||||
bLabel: labels.get(adv.b) || adv.b
|
bLabel: labels.get(adv.b) || adv.b
|
||||||
})),
|
}));
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
advisories,
|
||||||
|
triggers: buildProximityTriggers(scan.advisories),
|
||||||
positions: scan.positions,
|
positions: scan.positions,
|
||||||
links: scan.links,
|
links: scan.links,
|
||||||
counts: scan.counts
|
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 = {
|
module.exports = {
|
||||||
buildProximitySnapshot,
|
buildProximitySnapshot,
|
||||||
sessionsToAgents,
|
sessionsToAgents,
|
||||||
defaultChangedFilesFor
|
defaultWorkingSetFor,
|
||||||
|
defaultChangedFilesFor,
|
||||||
|
parseDiffRanges,
|
||||||
|
dispatchProximityTriggers
|
||||||
};
|
};
|
||||||
|
|||||||
@ -46,10 +46,14 @@ test('treeDistance: siblings are closer than cousins', () => {
|
|||||||
test('lineRangeOverlap: full overlap when whole-file (no ranges)', () => {
|
test('lineRangeOverlap: full overlap when whole-file (no ranges)', () => {
|
||||||
assert.strictEqual(lineRangeOverlap([], []), 1);
|
assert.strictEqual(lineRangeOverlap([], []), 1);
|
||||||
});
|
});
|
||||||
test('lineRangeOverlap: partial overlapping ranges', () => {
|
test('lineRangeOverlap: partial overlapping ranges (overlap coefficient)', () => {
|
||||||
const r = lineRangeOverlap([[1, 10]], [[5, 14]]);
|
const r = lineRangeOverlap([[1, 10]], [[5, 14]]);
|
||||||
// overlap lines 5..10 = 6, union 1..14 = 14 → 6/14
|
// overlap lines 5..10 = 6; min size = 10 → 6/10
|
||||||
assert.ok(Math.abs(r - 6 / 14) < 1e-9, `got ${r}`);
|
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', () => {
|
test('lineRangeOverlap: disjoint ranges are 0', () => {
|
||||||
assert.strictEqual(lineRangeOverlap([[1, 5]], [[20, 25]]), 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 ──
|
// ── collision risk channels ──
|
||||||
test('collisionRisk: two agents editing the SAME file ⇒ high risk', () => {
|
test('collisionRisk: two agents editing the SAME file ⇒ high risk', () => {
|
||||||
const a = { agentId: 'a', files: [{ path: 'src/api/users.js', lines: [[1, 50]] }] };
|
// Whole-file edits to the same file (no line info) ⇒ full overlap.
|
||||||
const b = { agentId: 'b', files: [{ path: 'src/api/users.js', lines: [[40, 90]] }] };
|
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, {});
|
const { risk, channels } = collisionRisk(a, b, {});
|
||||||
assert.ok(risk > 0.5, `same-file risk ${risk} should be high`);
|
assert.ok(risk > 0.5, `same-file risk ${risk} should be high`);
|
||||||
assert.ok(channels.overlap > 0);
|
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', () => {
|
test('collisionRisk: unrelated far-apart files ⇒ low risk', () => {
|
||||||
const a = { agentId: 'a', files: [{ path: 'src/api/users.js' }] };
|
const a = { agentId: 'a', files: [{ path: 'src/api/users.js' }] };
|
||||||
const b = { agentId: 'b', files: [{ path: 'docs/guide.md' }] };
|
const b = { agentId: 'b', files: [{ path: 'docs/guide.md' }] };
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
const assert = require('assert');
|
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 passed = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
@ -87,5 +87,64 @@ test('buildProximitySnapshot: advisories carry human-readable labels', () => {
|
|||||||
assert.ok(collision.aLabel && collision.bLabel, 'labels present');
|
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}`);
|
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||||
if (failed > 0) process.exit(1);
|
if (failed > 0) process.exit(1);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user