fix(agent-graph): route lead tasks and tighten lead spacing

This commit is contained in:
777genius 2026-04-18 17:28:27 +03:00
parent ad8cddabcd
commit 2e062e4432
4 changed files with 161 additions and 10 deletions

View file

@ -173,12 +173,16 @@ export function buildStableSlotLayoutSnapshot({
);
const leadActivityRect = leadSlotFrame.activityColumnRect;
const launchHudRect = createRect(leadCoreRect.right, leadCoreRect.top, 0, 0);
const leadCentralReservedBlock = leadSlotFrame.bounds;
const leadCentralReservedBlock = buildLeadCentralReservedBlock({
leadCoreRect,
leadSlotFrame,
});
const ownerFootprints = computeOwnerFootprints(nodes, layout);
const unassignedTaskRect = buildUnassignedTaskRect(nodes, leadCentralReservedBlock);
const centralCollisionRects = buildCentralCollisionRects({
leadCentralReservedBlock,
leadCoreRect,
leadSlotFrame,
unassignedTaskRect,
});
const runtimeCentralExclusion = padRect(
@ -222,16 +226,34 @@ export function buildStableSlotLayoutSnapshot({
}
function buildCentralCollisionRects(args: {
leadCentralReservedBlock: StableRect;
leadCoreRect: StableRect;
leadSlotFrame: SlotFrame;
unassignedTaskRect: StableRect | null;
}): StableRect[] {
const rects = [args.leadCentralReservedBlock];
const rects = [
args.leadCoreRect,
args.leadSlotFrame.processBandRect,
args.leadSlotFrame.activityColumnRect,
args.leadSlotFrame.kanbanBandRect,
];
if (args.unassignedTaskRect) {
rects.push(args.unassignedTaskRect);
}
return rects;
}
function buildLeadCentralReservedBlock(args: {
leadCoreRect: StableRect;
leadSlotFrame: SlotFrame;
}): StableRect {
return unionRects([
args.leadCoreRect,
args.leadSlotFrame.processBandRect,
args.leadSlotFrame.activityColumnRect,
args.leadSlotFrame.kanbanBandRect,
]);
}
function padCentralCollisionRects(
rects: readonly StableRect[],
padding: number
@ -648,6 +670,12 @@ function validateLeadSnapshotRects(
if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadActivityRect)) {
return { valid: false, reason: 'leadActivityRect must fit inside leadCentralReservedBlock' };
}
if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadSlotFrame.processBandRect)) {
return { valid: false, reason: 'lead processBandRect must fit inside leadCentralReservedBlock' };
}
if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadSlotFrame.kanbanBandRect)) {
return { valid: false, reason: 'lead kanbanBandRect must fit inside leadCentralReservedBlock' };
}
if (snapshot.leadActivityRect.left !== snapshot.leadSlotFrame.activityColumnRect.left) {
return {
valid: false,
@ -660,9 +688,6 @@ function validateLeadSnapshotRects(
reason: 'leadActivityRect must mirror leadSlotFrame.activityColumnRect',
};
}
if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadSlotFrame.bounds)) {
return { valid: false, reason: 'leadSlotFrame must fit inside leadCentralReservedBlock' };
}
if (!rectContainsRect(snapshot.runtimeCentralExclusion, snapshot.leadCentralReservedBlock)) {
return { valid: false, reason: 'runtimeCentralExclusion must contain leadCentralReservedBlock' };
}

View file

@ -175,7 +175,16 @@ export class TeamGraphAdapter {
isTeamProvisioning,
isLaunchSettling
);
this.#buildTaskNodes(nodes, edges, teamData, teamName, commentReadState, memberNodeIdByAlias);
this.#buildTaskNodes(
nodes,
edges,
teamData,
teamName,
commentReadState,
memberNodeIdByAlias,
leadId,
leadName
);
this.#buildProcessNodes(nodes, edges, teamData, teamName, memberNodeIdByAlias);
this.#attachActivityFeeds(nodes, teamData, teamName, leadId, leadName);
this.#buildMessageParticles(
@ -554,7 +563,9 @@ export class TeamGraphAdapter {
data: TeamData,
teamName: string,
commentReadState?: Record<string, unknown>,
memberNodeIdByAlias?: ReadonlyMap<string, string>
memberNodeIdByAlias?: ReadonlyMap<string, string>,
leadId?: string,
leadName?: string
): void {
const taskStateById = new Map<string, Pick<TeamData['tasks'][number], 'status'>>();
const taskDisplayIds = new Map<string, string>();
@ -575,7 +586,12 @@ export class TeamGraphAdapter {
for (const task of data.tasks) {
if (task.status === 'deleted') continue;
const taskId = `task:${teamName}:${task.id}`;
const ownerMemberId = task.owner ? (memberNodeIdByAlias?.get(task.owner) ?? null) : null;
const ownerMemberId =
leadId && memberNodeIdByAlias
? TeamGraphAdapter.#resolveTaskOwnerId(task.owner, leadId, leadName, memberNodeIdByAlias)
: task.owner
? (memberNodeIdByAlias?.get(task.owner) ?? null)
: null;
const kanbanTaskState = data.kanbanState.tasks[task.id];
const reviewerName = resolveTaskReviewer(task, kanbanTaskState);
const isReviewCycle = isTaskInReviewCycle(task);
@ -1228,6 +1244,25 @@ export class TeamGraphAdapter {
return memberNodeIdByAlias.get(name) ?? leadId;
}
static #resolveTaskOwnerId(
ownerName: string | null | undefined,
leadId: string,
leadName: string | undefined,
memberNodeIdByAlias: ReadonlyMap<string, string>
): string | null {
if (!ownerName?.trim()) {
return null;
}
const normalized = ownerName.trim().toLowerCase();
if (normalized === 'user' || normalized === 'team-lead') {
return leadId;
}
if (normalized === leadName?.trim().toLowerCase()) {
return leadId;
}
return memberNodeIdByAlias.get(ownerName) ?? null;
}
/** Extract external team name from cross-team "from" field like "team-b.alice" */
static #extractExternalTeamName(from: string): string | null {
const dotIdx = from.indexOf('.');

View file

@ -713,6 +713,63 @@ describe('TeamGraphAdapter particles', () => {
]);
});
it('maps lead-owned tasks onto the lead board without routing unknown owners to lead', () => {
const adapter = TeamGraphAdapter.create();
const graph = adapter.adapt(
createBaseTeamData({
config: {
name: 'My Team',
members: [{ name: 'olivia', agentType: 'lead' }, { name: 'alice' }],
projectPath: '/repo',
},
members: [
{
name: 'olivia',
status: 'active',
currentTaskId: null,
taskCount: 1,
lastActiveAt: null,
messageCount: 0,
agentType: 'lead',
},
{
name: 'alice',
status: 'active',
currentTaskId: null,
taskCount: 1,
lastActiveAt: null,
messageCount: 0,
},
],
tasks: [
{
id: 'lead-task',
displayId: '#11',
subject: 'Lead summary',
owner: 'olivia',
status: 'in_progress',
comments: [],
reviewState: 'none',
} as TeamTaskWithKanban,
{
id: 'unknown-task',
displayId: '#12',
subject: 'Unknown owner',
owner: 'ghost',
status: 'in_progress',
comments: [],
reviewState: 'none',
} as TeamTaskWithKanban,
],
}),
'my-team'
);
expect(findNode(graph, 'task:my-team:lead-task')?.ownerId).toBe('lead:my-team');
expect(findNode(graph, 'task:my-team:unknown-task')?.ownerId).toBeNull();
});
it('builds member activity feeds from inbox messages in newest-first order', () => {
const adapter = TeamGraphAdapter.create();

View file

@ -797,6 +797,40 @@ describe('stable slot layout planner', () => {
}
});
it('builds central collisions from occupied lead sub-rects instead of the full lead slot bounds', () => {
const teamName = 'team-lead-central-collision';
const lead = createLead(teamName);
const alice = createMember(teamName, 'agent-alice', 'alice');
const leadTasks = [
createTask(teamName, 'lead-a', lead.id, { taskStatus: 'completed' }),
createTask(teamName, 'lead-b', lead.id, { taskStatus: 'in_progress' }),
];
const layout: GraphLayoutPort = {
version: 'stable-slots-v1',
ownerOrder: [alice.id],
slotAssignments: {
[alice.id]: { ringIndex: 0, sectorIndex: 1 },
},
};
const nodes = [lead, alice, ...leadTasks];
const snapshot = buildStableSlotLayoutSnapshot({
teamName,
nodes,
layout,
});
expect(snapshot).not.toBeNull();
expect(snapshot!.centralCollisionRects).toContain(snapshot!.leadCoreRect);
expect(snapshot!.centralCollisionRects).toContain(snapshot!.leadSlotFrame.processBandRect);
expect(snapshot!.centralCollisionRects).toContain(snapshot!.leadSlotFrame.activityColumnRect);
expect(snapshot!.centralCollisionRects).toContain(snapshot!.leadSlotFrame.kanbanBandRect);
expect(snapshot!.leadCentralReservedBlock.width).toBeLessThan(snapshot!.leadSlotFrame.bounds.width);
expect(snapshot!.leadCentralReservedBlock.height).toBeLessThan(
snapshot!.leadSlotFrame.bounds.height
);
});
it('keeps the same sector and spills to the next outer ring when the saved slot is already occupied', () => {
const teamName = 'team-wide-spill';
const lead = createLead(teamName);