fix(agent-graph): route lead tasks and tighten lead spacing
This commit is contained in:
parent
ad8cddabcd
commit
2e062e4432
4 changed files with 161 additions and 10 deletions
|
|
@ -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' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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('.');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue