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 leadActivityRect = leadSlotFrame.activityColumnRect;
|
||||||
const launchHudRect = createRect(leadCoreRect.right, leadCoreRect.top, 0, 0);
|
const launchHudRect = createRect(leadCoreRect.right, leadCoreRect.top, 0, 0);
|
||||||
const leadCentralReservedBlock = leadSlotFrame.bounds;
|
const leadCentralReservedBlock = buildLeadCentralReservedBlock({
|
||||||
|
leadCoreRect,
|
||||||
|
leadSlotFrame,
|
||||||
|
});
|
||||||
|
|
||||||
const ownerFootprints = computeOwnerFootprints(nodes, layout);
|
const ownerFootprints = computeOwnerFootprints(nodes, layout);
|
||||||
const unassignedTaskRect = buildUnassignedTaskRect(nodes, leadCentralReservedBlock);
|
const unassignedTaskRect = buildUnassignedTaskRect(nodes, leadCentralReservedBlock);
|
||||||
const centralCollisionRects = buildCentralCollisionRects({
|
const centralCollisionRects = buildCentralCollisionRects({
|
||||||
leadCentralReservedBlock,
|
leadCoreRect,
|
||||||
|
leadSlotFrame,
|
||||||
unassignedTaskRect,
|
unassignedTaskRect,
|
||||||
});
|
});
|
||||||
const runtimeCentralExclusion = padRect(
|
const runtimeCentralExclusion = padRect(
|
||||||
|
|
@ -222,16 +226,34 @@ export function buildStableSlotLayoutSnapshot({
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCentralCollisionRects(args: {
|
function buildCentralCollisionRects(args: {
|
||||||
leadCentralReservedBlock: StableRect;
|
leadCoreRect: StableRect;
|
||||||
|
leadSlotFrame: SlotFrame;
|
||||||
unassignedTaskRect: StableRect | null;
|
unassignedTaskRect: StableRect | null;
|
||||||
}): StableRect[] {
|
}): StableRect[] {
|
||||||
const rects = [args.leadCentralReservedBlock];
|
const rects = [
|
||||||
|
args.leadCoreRect,
|
||||||
|
args.leadSlotFrame.processBandRect,
|
||||||
|
args.leadSlotFrame.activityColumnRect,
|
||||||
|
args.leadSlotFrame.kanbanBandRect,
|
||||||
|
];
|
||||||
if (args.unassignedTaskRect) {
|
if (args.unassignedTaskRect) {
|
||||||
rects.push(args.unassignedTaskRect);
|
rects.push(args.unassignedTaskRect);
|
||||||
}
|
}
|
||||||
return rects;
|
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(
|
function padCentralCollisionRects(
|
||||||
rects: readonly StableRect[],
|
rects: readonly StableRect[],
|
||||||
padding: number
|
padding: number
|
||||||
|
|
@ -648,6 +670,12 @@ function validateLeadSnapshotRects(
|
||||||
if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadActivityRect)) {
|
if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadActivityRect)) {
|
||||||
return { valid: false, reason: 'leadActivityRect must fit inside leadCentralReservedBlock' };
|
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) {
|
if (snapshot.leadActivityRect.left !== snapshot.leadSlotFrame.activityColumnRect.left) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
|
|
@ -660,9 +688,6 @@ function validateLeadSnapshotRects(
|
||||||
reason: 'leadActivityRect must mirror leadSlotFrame.activityColumnRect',
|
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)) {
|
if (!rectContainsRect(snapshot.runtimeCentralExclusion, snapshot.leadCentralReservedBlock)) {
|
||||||
return { valid: false, reason: 'runtimeCentralExclusion must contain leadCentralReservedBlock' };
|
return { valid: false, reason: 'runtimeCentralExclusion must contain leadCentralReservedBlock' };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -175,7 +175,16 @@ export class TeamGraphAdapter {
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
isLaunchSettling
|
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.#buildProcessNodes(nodes, edges, teamData, teamName, memberNodeIdByAlias);
|
||||||
this.#attachActivityFeeds(nodes, teamData, teamName, leadId, leadName);
|
this.#attachActivityFeeds(nodes, teamData, teamName, leadId, leadName);
|
||||||
this.#buildMessageParticles(
|
this.#buildMessageParticles(
|
||||||
|
|
@ -554,7 +563,9 @@ export class TeamGraphAdapter {
|
||||||
data: TeamData,
|
data: TeamData,
|
||||||
teamName: string,
|
teamName: string,
|
||||||
commentReadState?: Record<string, unknown>,
|
commentReadState?: Record<string, unknown>,
|
||||||
memberNodeIdByAlias?: ReadonlyMap<string, string>
|
memberNodeIdByAlias?: ReadonlyMap<string, string>,
|
||||||
|
leadId?: string,
|
||||||
|
leadName?: string
|
||||||
): void {
|
): void {
|
||||||
const taskStateById = new Map<string, Pick<TeamData['tasks'][number], 'status'>>();
|
const taskStateById = new Map<string, Pick<TeamData['tasks'][number], 'status'>>();
|
||||||
const taskDisplayIds = new Map<string, string>();
|
const taskDisplayIds = new Map<string, string>();
|
||||||
|
|
@ -575,7 +586,12 @@ export class TeamGraphAdapter {
|
||||||
for (const task of data.tasks) {
|
for (const task of data.tasks) {
|
||||||
if (task.status === 'deleted') continue;
|
if (task.status === 'deleted') continue;
|
||||||
const taskId = `task:${teamName}:${task.id}`;
|
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 kanbanTaskState = data.kanbanState.tasks[task.id];
|
||||||
const reviewerName = resolveTaskReviewer(task, kanbanTaskState);
|
const reviewerName = resolveTaskReviewer(task, kanbanTaskState);
|
||||||
const isReviewCycle = isTaskInReviewCycle(task);
|
const isReviewCycle = isTaskInReviewCycle(task);
|
||||||
|
|
@ -1228,6 +1244,25 @@ export class TeamGraphAdapter {
|
||||||
return memberNodeIdByAlias.get(name) ?? leadId;
|
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" */
|
/** Extract external team name from cross-team "from" field like "team-b.alice" */
|
||||||
static #extractExternalTeamName(from: string): string | null {
|
static #extractExternalTeamName(from: string): string | null {
|
||||||
const dotIdx = from.indexOf('.');
|
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', () => {
|
it('builds member activity feeds from inbox messages in newest-first order', () => {
|
||||||
const adapter = TeamGraphAdapter.create();
|
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', () => {
|
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 teamName = 'team-wide-spill';
|
||||||
const lead = createLead(teamName);
|
const lead = createLead(teamName);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue