diff --git a/packages/agent-graph/src/layout/stableSlots.ts b/packages/agent-graph/src/layout/stableSlots.ts index 448095e2..e606c3cd 100644 --- a/packages/agent-graph/src/layout/stableSlots.ts +++ b/packages/agent-graph/src/layout/stableSlots.ts @@ -60,6 +60,7 @@ export interface StableSlotLayoutSnapshot { launchAnchor: { x: number; y: number } | null; leadCentralReservedBlock: StableRect; runtimeCentralExclusion: StableRect; + centralCollisionRects: StableRect[]; memberSlotFrames: SlotFrame[]; memberSlotFrameByOwnerId: Map; 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; @@ -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; 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; 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; 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; 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; 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) => diff --git a/test/renderer/features/agent-graph/useGraphSimulation.test.ts b/test/renderer/features/agent-graph/useGraphSimulation.test.ts index 96e084ed..7622f3a6 100644 --- a/test/renderer/features/agent-graph/useGraphSimulation.test.ts +++ b/test/renderer/features/agent-graph/useGraphSimulation.test.ts @@ -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);