fix(agent-graph): use directional central exclusion

This commit is contained in:
777genius 2026-04-16 10:53:52 +03:00
parent 77d3e9f7d8
commit d81a45f15b
2 changed files with 395 additions and 52 deletions

View file

@ -60,6 +60,7 @@ export interface StableSlotLayoutSnapshot {
launchAnchor: { x: number; y: number } | null;
leadCentralReservedBlock: StableRect;
runtimeCentralExclusion: StableRect;
centralCollisionRects: StableRect[];
memberSlotFrames: SlotFrame[];
memberSlotFrameByOwnerId: Map<string, SlotFrame>;
unassignedTaskRect: StableRect | null;
@ -113,6 +114,7 @@ const SLOT_GEOMETRY = {
const PROCESS_RAIL_NODE_GAP = 42;
const PROCESS_RAIL_NODE_FOOTPRINT = 28;
const GEOMETRY_EPSILON = 0.001;
const SECTOR_VECTORS = STABLE_SLOT_SECTOR_VECTORS;
@ -143,27 +145,30 @@ export function buildStableSlotLayoutSnapshot({
const ownerFootprints = computeOwnerFootprints(nodes, layout);
const unassignedTaskRect = buildUnassignedTaskRect(nodes, leadCentralReservedBlock);
const centralCollisionRects = buildCentralCollisionRects({
leadCoreRect,
leadActivityRect,
launchHudRect,
unassignedTaskRect,
});
const runtimeCentralExclusion = padRect(
unionRects(
unassignedTaskRect
? [leadCentralReservedBlock, unassignedTaskRect]
: [leadCentralReservedBlock]
),
unionRects(centralCollisionRects),
SLOT_GEOMETRY.centralPadding
);
const memberSlotFrames = planOwnerSlots(ownerFootprints, runtimeCentralExclusion, layout);
const memberSlotFrames = planOwnerSlots(
ownerFootprints,
centralCollisionRects,
runtimeCentralExclusion,
layout
);
const memberSlotFrameByOwnerId = new Map(
memberSlotFrames.map((frame) => [frame.ownerId, frame] as const)
);
const fitBounds = unionRects(
[
leadCentralReservedBlock,
leadActivityRect,
launchHudRect,
runtimeCentralExclusion,
...memberSlotFrames.map((frame) => frame.bounds),
...(unassignedTaskRect ? [unassignedTaskRect] : []),
].filter(Boolean)
);
@ -180,6 +185,7 @@ export function buildStableSlotLayoutSnapshot({
},
leadCentralReservedBlock,
runtimeCentralExclusion,
centralCollisionRects,
memberSlotFrames,
memberSlotFrameByOwnerId,
unassignedTaskRect,
@ -187,6 +193,33 @@ export function buildStableSlotLayoutSnapshot({
};
}
function buildCentralCollisionRects(args: {
leadCoreRect: StableRect;
leadActivityRect: StableRect;
launchHudRect: StableRect;
unassignedTaskRect: StableRect | null;
}): StableRect[] {
const rects = [args.leadCoreRect, args.leadActivityRect, args.launchHudRect];
if (args.unassignedTaskRect) {
rects.push(args.unassignedTaskRect);
}
return rects;
}
function padCentralCollisionRects(
rects: readonly StableRect[],
padding: number
): StableRect[] {
return rects.map((rect) => padRect(rect, padding));
}
function rectOverlapsAnyCentralRect(
rect: StableRect,
centralCollisionRects: readonly StableRect[]
): boolean {
return centralCollisionRects.some((centralRect) => rectsOverlap(rect, centralRect));
}
export function computeOwnerFootprints(
nodes: GraphNode[],
layout?: GraphLayoutPort
@ -347,6 +380,7 @@ export function resolveNearestSlotAssignment(args: {
footprintByOwnerId,
currentFrame,
existingFrames,
centralCollisionRects: args.snapshot.centralCollisionRects,
runtimeCentralExclusion: args.snapshot.runtimeCentralExclusion,
ringStates,
pointerX: args.ownerX,
@ -418,6 +452,9 @@ function validateStaticSnapshotRects(
['leadCentralReservedBlock', snapshot.leadCentralReservedBlock],
['runtimeCentralExclusion', snapshot.runtimeCentralExclusion],
['fitBounds', snapshot.fitBounds],
...snapshot.centralCollisionRects.map(
(rect, index) => [`centralCollisionRects[${index}]`, rect] as [string, StableRect]
),
];
if (snapshot.unassignedTaskRect) {
@ -452,11 +489,19 @@ function validateLeadSnapshotRects(
if (!rectContainsRect(snapshot.runtimeCentralExclusion, snapshot.leadCentralReservedBlock)) {
return { valid: false, reason: 'runtimeCentralExclusion must contain leadCentralReservedBlock' };
}
const paddedCentralCollisionRects = padCentralCollisionRects(
snapshot.centralCollisionRects,
SLOT_GEOMETRY.centralPadding
);
if (
snapshot.unassignedTaskRect &&
!rectContainsRect(snapshot.runtimeCentralExclusion, snapshot.unassignedTaskRect)
paddedCentralCollisionRects.some(
(rect) => !rectContainsRect(snapshot.runtimeCentralExclusion, rect)
)
) {
return { valid: false, reason: 'runtimeCentralExclusion must contain unassignedTaskRect' };
return {
valid: false,
reason: 'runtimeCentralExclusion must contain all centralCollisionRects',
};
}
return null;
@ -485,8 +530,11 @@ function validateMemberSlotFrame(
}
seenAssignments.add(assignmentKey);
if (rectsOverlap(frame.bounds, snapshot.runtimeCentralExclusion)) {
return { valid: false, reason: `slot frame for ${frame.ownerId} overlaps runtimeCentralExclusion` };
if (rectOverlapsAnyCentralRect(frame.bounds, snapshot.centralCollisionRects)) {
return {
valid: false,
reason: `slot frame for ${frame.ownerId} overlaps centralCollisionRects`,
};
}
if (!rectContainsRect(frame.bounds, frame.boardBandRect)) {
return { valid: false, reason: `boardBandRect escapes slot bounds for ${frame.ownerId}` };
@ -614,7 +662,8 @@ function buildUnassignedTaskRect(
function planOwnerSlots(
ownerFootprints: OwnerFootprint[],
centralExclusion: StableRect,
centralCollisionRects: readonly StableRect[],
runtimeCentralExclusion: StableRect,
layout?: GraphLayoutPort
): SlotFrame[] {
const placedFrames: SlotFrame[] = [];
@ -626,7 +675,8 @@ function planOwnerSlots(
for (const footprint of ownerFootprints) {
const resolvedFrame = resolveOwnerSlotFrame({
footprint,
centralExclusion,
centralCollisionRects,
runtimeCentralExclusion,
ringStates,
preferredAssignment: preferredAssignments.get(footprint.ownerId),
usedSlotKeys,
@ -667,7 +717,8 @@ function buildPreferredAssignmentsMap(
function resolveOwnerSlotFrame(args: {
footprint: OwnerFootprint;
centralExclusion: StableRect;
centralCollisionRects: readonly StableRect[];
runtimeCentralExclusion: StableRect;
ringStates: RingLayoutStateMap;
preferredAssignment?: GraphOwnerSlotAssignment;
usedSlotKeys: Set<string>;
@ -676,7 +727,8 @@ function resolveOwnerSlotFrame(args: {
}): SlotFrame {
const {
footprint,
centralExclusion,
centralCollisionRects,
runtimeCentralExclusion,
ringStates,
preferredAssignment,
usedSlotKeys,
@ -690,7 +742,8 @@ function resolveOwnerSlotFrame(args: {
const directMatch = findFirstValidSlotFrame({
candidateAssignments: candidates,
footprint,
centralExclusion,
centralCollisionRects,
runtimeCentralExclusion,
ringStates,
usedSlotKeys,
placedFrames,
@ -706,7 +759,8 @@ function resolveOwnerSlotFrame(args: {
const spilloverMatch = findFirstValidSlotFrame({
candidateAssignments: spilloverCandidates,
footprint,
centralExclusion,
centralCollisionRects,
runtimeCentralExclusion,
ringStates,
usedSlotKeys,
placedFrames,
@ -717,7 +771,8 @@ function resolveOwnerSlotFrame(args: {
return buildEmergencyFallbackSlotFrame({
footprint,
centralExclusion,
centralCollisionRects,
runtimeCentralExclusion,
ringStates,
usedSlotKeys,
placedOwnerCount: placedFrames.length,
@ -728,19 +783,29 @@ function resolveOwnerSlotFrame(args: {
function buildSlotFrame(
footprint: OwnerFootprint,
assignment: GraphOwnerSlotAssignment,
centralExclusion: StableRect,
centralCollisionRects: readonly StableRect[],
runtimeCentralExclusion: StableRect,
options: { ringStates: RingLayoutStateMap }
): SlotFrame | null {
const vector = SECTOR_VECTORS[assignment.sectorIndex % SECTOR_VECTORS.length] ?? SECTOR_VECTORS[0];
const radius = resolveRingRadiusForAssignment({
assignment,
footprint,
centralExclusion,
centralCollisionRects,
runtimeCentralExclusion,
ringStates: options.ringStates,
});
if (radius == null) {
return null;
}
return buildSlotFrameAtRadius(footprint, assignment, radius);
}
function buildSlotFrameAtRadius(
footprint: OwnerFootprint,
assignment: GraphOwnerSlotAssignment,
radius: number
): SlotFrame {
const vector = SECTOR_VECTORS[assignment.sectorIndex % SECTOR_VECTORS.length] ?? SECTOR_VECTORS[0];
const ownerX = vector.x * radius;
const ownerY = vector.y * radius;
const slotTop =
@ -844,7 +909,8 @@ function ownerFootprintsSpillBudget(placedOwnerCount: number): number {
function buildEmergencyFallbackSlotFrame(args: {
footprint: OwnerFootprint;
centralExclusion: StableRect;
centralCollisionRects: readonly StableRect[];
runtimeCentralExclusion: StableRect;
ringStates: RingLayoutStateMap;
usedSlotKeys: Set<string>;
placedOwnerCount: number;
@ -855,9 +921,15 @@ function buildEmergencyFallbackSlotFrame(args: {
sectorIndex: 0,
};
args.usedSlotKeys.add(buildAssignmentKey(assignment));
const frame = buildSlotFrame(args.footprint, assignment, args.centralExclusion, {
ringStates: args.ringStates,
});
const frame = buildSlotFrame(
args.footprint,
assignment,
args.centralCollisionRects,
args.runtimeCentralExclusion,
{
ringStates: args.ringStates,
}
);
if (!frame) {
throw new Error(`failed to build emergency fallback slot frame for ${args.footprint.ownerId}`);
}
@ -871,6 +943,7 @@ function rankNearestSlotAssignmentResult(args: {
footprintByOwnerId: ReadonlyMap<string, OwnerFootprint>;
currentFrame: SlotFrame;
existingFrames: readonly SlotFrame[];
centralCollisionRects: readonly StableRect[];
runtimeCentralExclusion: StableRect;
ringStates: RingLayoutStateMap;
pointerX: number;
@ -883,14 +956,21 @@ function rankNearestSlotAssignmentResult(args: {
footprintByOwnerId,
currentFrame,
existingFrames,
centralCollisionRects,
runtimeCentralExclusion,
ringStates,
pointerX,
pointerY,
} = args;
const frame = buildSlotFrame(footprint, assignment, runtimeCentralExclusion, {
ringStates,
});
const frame = buildSlotFrame(
footprint,
assignment,
centralCollisionRects,
runtimeCentralExclusion,
{
ringStates,
}
);
if (!frame) {
return null;
}
@ -900,6 +980,7 @@ function rankNearestSlotAssignmentResult(args: {
occupiedFrame,
footprintByOwnerId,
currentFrame,
centralCollisionRects,
runtimeCentralExclusion,
ringStates,
});
@ -908,8 +989,8 @@ function rankNearestSlotAssignmentResult(args: {
}
const otherFrames = existingFrames.filter((existing) => existing.ownerId !== occupiedFrame.ownerId);
if (
!isSlotFramePlacementValid(frame, otherFrames, runtimeCentralExclusion) ||
!isSlotFramePlacementValid(displacedFrame, otherFrames, runtimeCentralExclusion) ||
!isSlotFramePlacementValid(frame, otherFrames, centralCollisionRects) ||
!isSlotFramePlacementValid(displacedFrame, otherFrames, centralCollisionRects) ||
rectsOverlapWithGap(frame.bounds, displacedFrame.bounds, SLOT_GEOMETRY.ringPadding)
) {
return null;
@ -927,7 +1008,7 @@ function rankNearestSlotAssignmentResult(args: {
});
}
if (!isSlotFramePlacementValid(frame, existingFrames, runtimeCentralExclusion)) {
if (!isSlotFramePlacementValid(frame, existingFrames, centralCollisionRects)) {
return null;
}
@ -943,6 +1024,7 @@ function buildDisplacedFrameForNearestAssignment(args: {
occupiedFrame: SlotFrame;
footprintByOwnerId: ReadonlyMap<string, OwnerFootprint>;
currentFrame: SlotFrame;
centralCollisionRects: readonly StableRect[];
runtimeCentralExclusion: StableRect;
ringStates: RingLayoutStateMap;
}): SlotFrame | null {
@ -956,6 +1038,7 @@ function buildDisplacedFrameForNearestAssignment(args: {
ringIndex: args.currentFrame.ringIndex,
sectorIndex: args.currentFrame.sectorIndex,
},
args.centralCollisionRects,
args.runtimeCentralExclusion,
{ ringStates: args.ringStates }
);
@ -982,7 +1065,8 @@ function buildRankedNearestSlotAssignmentResult(args: {
function findFirstValidSlotFrame(args: {
candidateAssignments: readonly GraphOwnerSlotAssignment[];
footprint: OwnerFootprint;
centralExclusion: StableRect;
centralCollisionRects: readonly StableRect[];
runtimeCentralExclusion: StableRect;
ringStates: RingLayoutStateMap;
usedSlotKeys: Set<string>;
placedFrames: readonly SlotFrame[];
@ -1000,7 +1084,8 @@ function findFirstValidSlotFrame(args: {
function tryBuildValidSlotFrame(
args: {
footprint: OwnerFootprint;
centralExclusion: StableRect;
centralCollisionRects: readonly StableRect[];
runtimeCentralExclusion: StableRect;
ringStates: RingLayoutStateMap;
usedSlotKeys: Set<string>;
placedFrames: readonly SlotFrame[];
@ -1012,13 +1097,19 @@ function tryBuildValidSlotFrame(
if (args.usedSlotKeys.has(slotKey) && !isSameAssignment(args.preferredAssignment, assignment)) {
return null;
}
const frame = buildSlotFrame(args.footprint, assignment, args.centralExclusion, {
ringStates: args.ringStates,
});
const frame = buildSlotFrame(
args.footprint,
assignment,
args.centralCollisionRects,
args.runtimeCentralExclusion,
{
ringStates: args.ringStates,
}
);
if (!frame) {
return null;
}
if (!isSlotFramePlacementValid(frame, args.placedFrames, args.centralExclusion)) {
if (!isSlotFramePlacementValid(frame, args.placedFrames, args.centralCollisionRects)) {
return null;
}
args.usedSlotKeys.add(slotKey);
@ -1152,12 +1243,18 @@ function computeSlotDirectionalDepths(
function resolveRingRadiusForAssignment(args: {
assignment: GraphOwnerSlotAssignment;
footprint: OwnerFootprint;
centralExclusion: StableRect;
centralCollisionRects: readonly StableRect[];
runtimeCentralExclusion: StableRect;
ringStates: RingLayoutStateMap;
}): number | null {
const vector =
SECTOR_VECTORS[args.assignment.sectorIndex % SECTOR_VECTORS.length] ?? SECTOR_VECTORS[0];
const minRadius = computeMinimumRingRadius(vector, args.footprint, args.centralExclusion);
const minRadius = resolveMinimumDirectionalRadius({
assignment: args.assignment,
footprint: args.footprint,
centralCollisionRects: args.centralCollisionRects,
runtimeCentralExclusion: args.runtimeCentralExclusion,
});
const directionalDepths = computeSlotDirectionalDepths(args.footprint, vector);
const ringState = resolveVirtualRingState(
args.assignment.sectorIndex,
@ -1211,7 +1308,48 @@ function buildSectorRingStateKey(sectorIndex: number, ringIndex: number): string
return `${sectorIndex}:${ringIndex}`;
}
function computeMinimumRingRadius(
function resolveMinimumDirectionalRadius(args: {
assignment: GraphOwnerSlotAssignment;
footprint: OwnerFootprint;
centralCollisionRects: readonly StableRect[];
runtimeCentralExclusion: StableRect;
}): number {
const legacyRadiusHint = computeLegacyMinimumRingRadius(
SECTOR_VECTORS[args.assignment.sectorIndex % SECTOR_VECTORS.length] ?? SECTOR_VECTORS[0],
args.footprint,
args.runtimeCentralExclusion
);
const overlapsCentralCollision = (radius: number): boolean => {
const frame = buildSlotFrameAtRadius(args.footprint, args.assignment, radius);
return rectOverlapsAnyCentralRect(frame.bounds, args.centralCollisionRects);
};
if (!overlapsCentralCollision(0)) {
return 0;
}
let low = 0;
let high = Math.max(legacyRadiusHint, SLOT_GEOMETRY.ringGap);
let expansionCount = 0;
while (overlapsCentralCollision(high) && expansionCount < 24) {
low = high;
high = Math.max(high * 2, high + SLOT_GEOMETRY.ringGap);
expansionCount += 1;
}
for (let iteration = 0; iteration < 24; iteration += 1) {
const mid = (low + high) / 2;
if (overlapsCentralCollision(mid)) {
low = mid;
} else {
high = mid;
}
}
return Math.ceil(high);
}
function computeLegacyMinimumRingRadius(
vector: { x: number; y: number },
footprint: OwnerFootprint,
centralExclusion: StableRect
@ -1253,15 +1391,20 @@ function rectsOverlap(a: StableRect, b: StableRect): boolean {
function rectContainsRect(outer: StableRect, inner: StableRect): boolean {
return (
inner.left >= outer.left &&
inner.right <= outer.right &&
inner.top >= outer.top &&
inner.bottom <= outer.bottom
inner.left >= outer.left - GEOMETRY_EPSILON &&
inner.right <= outer.right + GEOMETRY_EPSILON &&
inner.top >= outer.top - GEOMETRY_EPSILON &&
inner.bottom <= outer.bottom + GEOMETRY_EPSILON
);
}
function pointInRect(x: number, y: number, rect: StableRect): boolean {
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
return (
x >= rect.left - GEOMETRY_EPSILON &&
x <= rect.right + GEOMETRY_EPSILON &&
y >= rect.top - GEOMETRY_EPSILON &&
y <= rect.bottom + GEOMETRY_EPSILON
);
}
function isFiniteRect(rect: StableRect): boolean {
@ -1278,12 +1421,12 @@ function isFiniteRect(rect: StableRect): boolean {
function isSlotFramePlacementValid(
frame: SlotFrame,
existingFrames: readonly SlotFrame[],
runtimeCentralExclusion: StableRect
centralCollisionRects: readonly StableRect[]
): boolean {
if (!isFiniteRect(frame.bounds)) {
return false;
}
if (rectsOverlap(frame.bounds, runtimeCentralExclusion)) {
if (rectOverlapsAnyCentralRect(frame.bounds, centralCollisionRects)) {
return false;
}
return !existingFrames.some((existing) =>

View file

@ -6,12 +6,16 @@ import {
computeProcessBandWidth,
resolveNearestSlotAssignment,
snapshotToWorldBounds,
translateSlotFrame,
validateStableSlotLayout,
} from '../../../../packages/agent-graph/src/layout/stableSlots';
import { KanbanLayoutEngine } from '../../../../packages/agent-graph/src/layout/kanbanLayout';
import { TASK_PILL } from '../../../../packages/agent-graph/src/constants/canvas-constants';
import { ACTIVITY_LANE } from '../../../../packages/agent-graph/src/layout/activityLane';
import { STABLE_SLOT_GEOMETRY } from '../../../../packages/agent-graph/src/layout/stableSlotGeometry';
import {
STABLE_SLOT_GEOMETRY,
STABLE_SLOT_SECTOR_VECTORS,
} from '../../../../packages/agent-graph/src/layout/stableSlotGeometry';
import type { GraphLayoutPort, GraphNode } from '@claude-teams/agent-graph';
@ -65,6 +69,18 @@ function createProcess(teamName: string, processId: string, ownerId: string): Gr
};
}
function rectsOverlap(
left: { left: number; right: number; top: number; bottom: number },
right: { left: number; right: number; top: number; bottom: number }
): boolean {
return (
left.left < right.right &&
left.right > right.left &&
left.top < right.bottom &&
left.bottom > right.top
);
}
describe('stable slot layout planner', () => {
it('does not build a stable slot snapshot when the lead is missing', () => {
const snapshot = buildStableSlotLayoutSnapshot({
@ -164,6 +180,48 @@ describe('stable slot layout planner', () => {
);
});
it('keeps diagonal ring-zero sectors closer than the legacy coarse central box radius', () => {
const teamName = 'team-directional-radius';
const lead = createLead(teamName);
const alice = createMember(teamName, 'agent-alice', 'alice');
const layout: GraphLayoutPort = {
version: 'stable-slots-v1',
ownerOrder: [alice.id],
slotAssignments: {
[alice.id]: { ringIndex: 0, sectorIndex: 1 },
},
};
const snapshot = buildStableSlotLayoutSnapshot({
teamName,
nodes: [lead, alice],
layout,
});
const [footprint] = computeOwnerFootprints([lead, alice], layout);
const frame = snapshot?.memberSlotFrames[0];
const sectorVector = STABLE_SLOT_SECTOR_VECTORS[1];
expect(snapshot).not.toBeNull();
expect(frame).toBeDefined();
expect(footprint).toBeDefined();
const legacyHorizontalExtent = snapshot!.runtimeCentralExclusion.right;
const legacyVerticalExtent = Math.abs(snapshot!.runtimeCentralExclusion.top);
const legacyRequiredX =
(legacyHorizontalExtent + footprint!.slotWidth / 2 + STABLE_SLOT_GEOMETRY.ringPadding) /
Math.abs(sectorVector.x);
const legacyRequiredY =
(legacyVerticalExtent + footprint!.slotHeight / 2 + STABLE_SLOT_GEOMETRY.ringPadding) /
Math.abs(sectorVector.y);
const legacyMinRadius = Math.max(legacyRequiredX, legacyRequiredY, 0);
const actualRadius = Math.abs(frame!.ownerX / sectorVector.x);
expect(actualRadius).toBeLessThan(legacyMinRadius);
expect(
snapshot!.centralCollisionRects.some((rect) => rectsOverlap(frame!.bounds, rect))
).toBe(false);
});
it('grows process band width when an owner has multiple visible process nodes', () => {
const teamName = 'team-process-growth';
const lead = createLead(teamName);
@ -251,6 +309,51 @@ describe('stable slot layout planner', () => {
expect(validateStableSlotLayout(invalid).valid).toBe(false);
});
it('rejects member frames that overlap lead activity and launch central collision rects', () => {
const teamName = 'team-central-rects';
const lead = createLead(teamName);
const alice = createMember(teamName, 'agent-alice', 'alice');
const layout: GraphLayoutPort = {
version: 'stable-slots-v1',
ownerOrder: [alice.id],
slotAssignments: {
[alice.id]: { ringIndex: 0, sectorIndex: 1 },
},
};
const snapshot = buildStableSlotLayoutSnapshot({
teamName,
nodes: [lead, alice],
layout,
});
expect(snapshot).not.toBeNull();
const [frame] = snapshot!.memberSlotFrames;
const overlappingLeadActivity = translateSlotFrame(
frame,
snapshot!.leadActivityRect.left - frame.bounds.left + 1,
snapshot!.leadActivityRect.top - frame.bounds.top + 1
);
const overlappingLaunchHud = translateSlotFrame(
frame,
snapshot!.launchHudRect.left - frame.bounds.left + 1,
snapshot!.launchHudRect.top - frame.bounds.top + 1
);
expect(
validateStableSlotLayout({
...snapshot!,
memberSlotFrames: [overlappingLeadActivity],
}).valid
).toBe(false);
expect(
validateStableSlotLayout({
...snapshot!,
memberSlotFrames: [overlappingLaunchHud],
}).valid
).toBe(false);
});
it('prefers the occupied target slot when dragging near another owner anchor', () => {
const teamName = 'team-b';
const lead = createLead(teamName);
@ -290,6 +393,64 @@ describe('stable slot layout planner', () => {
expect(nearest?.displacedAssignment).toEqual({ ringIndex: 0, sectorIndex: 1 });
});
it('keeps nearest-slot drag resolution on the same central collision model as the planner', () => {
const teamName = 'team-drag-central-collision';
const lead = createLead(teamName);
const alice = createMember(teamName, 'agent-alice', 'alice');
const bob = createMember(teamName, 'agent-bob', 'bob');
const layout: GraphLayoutPort = {
version: 'stable-slots-v1',
ownerOrder: [alice.id, bob.id],
slotAssignments: {
[alice.id]: { ringIndex: 0, sectorIndex: 1 },
[bob.id]: { ringIndex: 0, sectorIndex: 2 },
},
};
const snapshot = buildStableSlotLayoutSnapshot({
teamName,
nodes: [lead, alice, bob],
layout,
});
expect(snapshot).not.toBeNull();
const nearest = resolveNearestSlotAssignment({
ownerId: alice.id,
ownerX: snapshot!.leadActivityRect.left + snapshot!.leadActivityRect.width / 2,
ownerY: snapshot!.leadActivityRect.top + snapshot!.leadActivityRect.height / 2,
nodes: [lead, alice, bob],
snapshot: snapshot!,
layout,
});
expect(nearest).not.toBeNull();
const replannedSnapshot = buildStableSlotLayoutSnapshot({
teamName,
nodes: [lead, alice, bob],
layout: {
...layout,
slotAssignments: {
...layout.slotAssignments,
[alice.id]: nearest!.assignment,
...(nearest?.displacedOwnerId && nearest.displacedAssignment
? { [nearest.displacedOwnerId]: nearest.displacedAssignment }
: {}),
},
},
});
const replannedFrame = replannedSnapshot?.memberSlotFrames.find(
(frame) => frame.ownerId === alice.id
);
expect(replannedSnapshot).not.toBeNull();
expect(replannedFrame).toBeDefined();
expect(
replannedSnapshot!.centralCollisionRects.some((rect) =>
rectsOverlap(replannedFrame!.bounds, rect)
)
).toBe(false);
});
it('treats tasks with missing owner nodes as unassigned topology actors', () => {
const teamName = 'team-orphan-task';
const lead = createLead(teamName);
@ -313,6 +474,45 @@ describe('stable slot layout planner', () => {
expect(snapshot?.unassignedTaskRect).not.toBeNull();
});
it('rejects member frames that overlap the unassigned central collision rect', () => {
const teamName = 'team-unassigned-central-rect';
const lead = createLead(teamName);
const alice = createMember(teamName, 'agent-alice', 'alice');
const orphanTask = createTask(
teamName,
'task-orphan',
'member:team-unassigned-central-rect:ghost'
);
const layout: GraphLayoutPort = {
version: 'stable-slots-v1',
ownerOrder: [alice.id],
slotAssignments: {
[alice.id]: { ringIndex: 0, sectorIndex: 1 },
},
};
const snapshot = buildStableSlotLayoutSnapshot({
teamName,
nodes: [lead, alice, orphanTask],
layout,
});
expect(snapshot?.unassignedTaskRect).not.toBeNull();
const [frame] = snapshot!.memberSlotFrames;
const overlappingUnassigned = translateSlotFrame(
frame,
snapshot!.unassignedTaskRect!.left - frame.bounds.left + 1,
snapshot!.unassignedTaskRect!.top - frame.bounds.top + 1
);
expect(
validateStableSlotLayout({
...snapshot!,
memberSlotFrames: [overlappingUnassigned],
}).valid
).toBe(false);
});
it('computes the next ring radius from previous ring depth, not member count', () => {
const teamName = 'team-ring-depth';
const lead = createLead(teamName);