feat: enrich listWaitingAgents with task/phase/initiative context via DB joins

Replaces the in-memory filter (agentManager.list() + filter) with a direct
repository query that LEFT JOINs tasks, phases, and initiatives to return
taskName, phaseName, initiativeName, and taskDescription alongside agent fields.

- Adds AgentWithContext interface and findWaitingWithContext() to AgentRepository port
- Implements findWaitingWithContext() in DrizzleAgentRepository using getTableColumns
- Wires agentRepository into TRPCContext, CreateContextOptions, and TrpcAdapterOptions
- Adds requireAgentRepository() helper following existing pattern
- Updates listWaitingAgents to use repository query instead of agentManager
- Adds 5 unit tests for findWaitingWithContext() covering all FK join edge cases
- Updates existing AgentRepository mocks to satisfy updated interface

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas May
2026-03-06 23:29:49 +01:00
parent 79a0bd0a74
commit 7e6921f01e
12 changed files with 151 additions and 10 deletions

View File

@@ -139,6 +139,7 @@ describe('MultiProviderAgentManager', () => {
findByStatus: vi.fn().mockResolvedValue([mockAgent]), findByStatus: vi.fn().mockResolvedValue([mockAgent]),
update: vi.fn().mockResolvedValue(mockAgent), update: vi.fn().mockResolvedValue(mockAgent),
delete: vi.fn().mockResolvedValue(undefined), delete: vi.fn().mockResolvedValue(undefined),
findWaitingWithContext: vi.fn().mockResolvedValue([]),
}; };
mockProjectRepository = { mockProjectRepository = {

View File

@@ -46,7 +46,8 @@ describe('OutputHandler completion mutex', () => {
async findByTaskId() { throw new Error('Not implemented'); }, async findByTaskId() { throw new Error('Not implemented'); },
async findByName() { throw new Error('Not implemented'); }, async findByName() { throw new Error('Not implemented'); },
async findBySessionId() { throw new Error('Not implemented'); }, async findBySessionId() { throw new Error('Not implemented'); },
async delete() { throw new Error('Not implemented'); } async delete() { throw new Error('Not implemented'); },
async findWaitingWithContext() { throw new Error('Not implemented'); }
}; };
beforeEach(() => { beforeEach(() => {

View File

@@ -8,6 +8,14 @@
import type { Agent } from '../schema.js'; import type { Agent } from '../schema.js';
import type { AgentMode } from '../../agent/types.js'; import type { AgentMode } from '../../agent/types.js';
/** Agent row enriched with joined task/phase/initiative context fields. */
export interface AgentWithContext extends Agent {
taskName: string | null;
phaseName: string | null;
initiativeName: string | null;
taskDescription: string | null;
}
/** /**
* Agent status values. * Agent status values.
*/ */
@@ -117,4 +125,10 @@ export interface AgentRepository {
* Throws if agent not found. * Throws if agent not found.
*/ */
delete(id: string): Promise<void>; delete(id: string): Promise<void>;
/**
* Find all agents with status 'waiting_for_input', enriched with
* task, phase, and initiative names via LEFT JOINs.
*/
findWaitingWithContext(): Promise<AgentWithContext[]>;
} }

View File

@@ -277,3 +277,91 @@ describe('DrizzleAgentRepository', () => {
}); });
}); });
}); });
describe('DrizzleAgentRepository.findWaitingWithContext()', () => {
let agentRepo: DrizzleAgentRepository;
let taskRepo: DrizzleTaskRepository;
let phaseRepo: DrizzlePhaseRepository;
let initiativeRepo: DrizzleInitiativeRepository;
beforeEach(() => {
const db = createTestDatabase();
agentRepo = new DrizzleAgentRepository(db);
taskRepo = new DrizzleTaskRepository(db);
phaseRepo = new DrizzlePhaseRepository(db);
initiativeRepo = new DrizzleInitiativeRepository(db);
});
it('returns empty array when no waiting agents exist', async () => {
const result = await agentRepo.findWaitingWithContext();
expect(result).toEqual([]);
});
it('only returns agents with status waiting_for_input', async () => {
await agentRepo.create({ name: 'running-agent', worktreeId: 'wt1', status: 'running' });
await agentRepo.create({ name: 'waiting-agent', worktreeId: 'wt2', status: 'waiting_for_input' });
const result = await agentRepo.findWaitingWithContext();
expect(result).toHaveLength(1);
expect(result[0].name).toBe('waiting-agent');
});
it('populates taskName, phaseName, initiativeName, taskDescription when FK associations exist', async () => {
const initiative = await initiativeRepo.create({ name: 'My Initiative' });
const phase = await phaseRepo.create({ initiativeId: initiative.id, name: 'Phase 1' });
const task = await taskRepo.create({
phaseId: phase.id,
name: 'Implement feature',
description: 'Write the feature code',
});
await agentRepo.create({
name: 'ctx-agent',
worktreeId: 'wt3',
status: 'waiting_for_input',
taskId: task.id,
initiativeId: initiative.id,
});
const result = await agentRepo.findWaitingWithContext();
expect(result).toHaveLength(1);
expect(result[0].taskName).toBe('Implement feature');
expect(result[0].phaseName).toBe('Phase 1');
expect(result[0].initiativeName).toBe('My Initiative');
expect(result[0].taskDescription).toBe('Write the feature code');
});
it('returns null for context fields when agent has no taskId or initiativeId', async () => {
await agentRepo.create({ name: 'bare-agent', worktreeId: 'wt4', status: 'waiting_for_input' });
const result = await agentRepo.findWaitingWithContext();
expect(result).toHaveLength(1);
expect(result[0].taskName).toBeNull();
expect(result[0].phaseName).toBeNull();
expect(result[0].initiativeName).toBeNull();
expect(result[0].taskDescription).toBeNull();
});
it('returns null phaseName when task has no phaseId', async () => {
const initiative = await initiativeRepo.create({ name: 'Orphan Init' });
const task = await taskRepo.create({
phaseId: null,
name: 'Orphan Task',
description: null,
});
await agentRepo.create({
name: 'orphan-agent',
worktreeId: 'wt5',
status: 'waiting_for_input',
taskId: task.id,
initiativeId: initiative.id,
});
const result = await agentRepo.findWaitingWithContext();
expect(result).toHaveLength(1);
expect(result[0].phaseName).toBeNull();
expect(result[0].taskName).toBe('Orphan Task');
expect(result[0].initiativeName).toBe('Orphan Init');
});
});

View File

@@ -4,13 +4,14 @@
* Implements AgentRepository interface using Drizzle ORM. * Implements AgentRepository interface using Drizzle ORM.
*/ */
import { eq } from 'drizzle-orm'; import { eq, getTableColumns } from 'drizzle-orm';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import type { DrizzleDatabase } from '../../index.js'; import type { DrizzleDatabase } from '../../index.js';
import { agents, type Agent } from '../../schema.js'; import { agents, tasks, phases, initiatives, type Agent } from '../../schema.js';
import type { import type {
AgentRepository, AgentRepository,
AgentStatus, AgentStatus,
AgentWithContext,
CreateAgentData, CreateAgentData,
UpdateAgentData, UpdateAgentData,
} from '../agent-repository.js'; } from '../agent-repository.js';
@@ -116,4 +117,20 @@ export class DrizzleAgentRepository implements AgentRepository {
throw new Error(`Agent not found: ${id}`); throw new Error(`Agent not found: ${id}`);
} }
} }
async findWaitingWithContext(): Promise<AgentWithContext[]> {
return this.db
.select({
...getTableColumns(agents),
taskName: tasks.name,
phaseName: phases.name,
initiativeName: initiatives.name,
taskDescription: tasks.description,
})
.from(agents)
.where(eq(agents.status, 'waiting_for_input'))
.leftJoin(tasks, eq(agents.taskId, tasks.id))
.leftJoin(phases, eq(tasks.phaseId, phases.id))
.leftJoin(initiatives, eq(agents.initiativeId, initiatives.id));
}
} }

View File

@@ -23,6 +23,7 @@ import type { ConversationRepository } from '../db/repositories/conversation-rep
import type { ChatSessionRepository } from '../db/repositories/chat-session-repository.js'; import type { ChatSessionRepository } from '../db/repositories/chat-session-repository.js';
import type { ReviewCommentRepository } from '../db/repositories/review-comment-repository.js'; import type { ReviewCommentRepository } from '../db/repositories/review-comment-repository.js';
import type { ErrandRepository } from '../db/repositories/errand-repository.js'; import type { ErrandRepository } from '../db/repositories/errand-repository.js';
import type { AgentRepository } from '../db/repositories/agent-repository.js';
import type { AccountCredentialManager } from '../agent/credentials/types.js'; import type { AccountCredentialManager } from '../agent/credentials/types.js';
import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js'; import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js';
import type { CoordinationManager } from '../coordination/types.js'; import type { CoordinationManager } from '../coordination/types.js';
@@ -85,6 +86,8 @@ export interface TrpcAdapterOptions {
projectSyncManager?: ProjectSyncManager; projectSyncManager?: ProjectSyncManager;
/** Errand repository for errand CRUD operations */ /** Errand repository for errand CRUD operations */
errandRepository?: ErrandRepository; errandRepository?: ErrandRepository;
/** Agent repository for enriched agent queries */
agentRepository?: AgentRepository;
/** Absolute path to the workspace root (.cwrc directory) */ /** Absolute path to the workspace root (.cwrc directory) */
workspaceRoot?: string; workspaceRoot?: string;
} }
@@ -170,6 +173,7 @@ export function createTrpcHandler(options: TrpcAdapterOptions) {
reviewCommentRepository: options.reviewCommentRepository, reviewCommentRepository: options.reviewCommentRepository,
projectSyncManager: options.projectSyncManager, projectSyncManager: options.projectSyncManager,
errandRepository: options.errandRepository, errandRepository: options.errandRepository,
agentRepository: options.agentRepository,
workspaceRoot: options.workspaceRoot, workspaceRoot: options.workspaceRoot,
}), }),
}); });

View File

@@ -96,7 +96,8 @@ describe('Crash marking race condition', () => {
async findByTaskId() { throw new Error('Not implemented'); }, async findByTaskId() { throw new Error('Not implemented'); },
async findByName() { throw new Error('Not implemented'); }, async findByName() { throw new Error('Not implemented'); },
async findBySessionId() { throw new Error('Not implemented'); }, async findBySessionId() { throw new Error('Not implemented'); },
async delete() { throw new Error('Not implemented'); } async delete() { throw new Error('Not implemented'); },
async findWaitingWithContext() { throw new Error('Not implemented'); }
}; };
outputHandler = new OutputHandler(mockRepo); outputHandler = new OutputHandler(mockRepo);

View File

@@ -20,6 +20,7 @@ import type { ConversationRepository } from '../db/repositories/conversation-rep
import type { ChatSessionRepository } from '../db/repositories/chat-session-repository.js'; import type { ChatSessionRepository } from '../db/repositories/chat-session-repository.js';
import type { ReviewCommentRepository } from '../db/repositories/review-comment-repository.js'; import type { ReviewCommentRepository } from '../db/repositories/review-comment-repository.js';
import type { ErrandRepository } from '../db/repositories/errand-repository.js'; import type { ErrandRepository } from '../db/repositories/errand-repository.js';
import type { AgentRepository } from '../db/repositories/agent-repository.js';
import type { AccountCredentialManager } from '../agent/credentials/types.js'; import type { AccountCredentialManager } from '../agent/credentials/types.js';
import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js'; import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js';
import type { CoordinationManager } from '../coordination/types.js'; import type { CoordinationManager } from '../coordination/types.js';
@@ -87,6 +88,8 @@ export interface TRPCContext {
projectSyncManager?: ProjectSyncManager; projectSyncManager?: ProjectSyncManager;
/** Absolute path to the workspace root (.cwrc directory) */ /** Absolute path to the workspace root (.cwrc directory) */
workspaceRoot?: string; workspaceRoot?: string;
/** Agent repository for enriched queries (e.g., findWaitingWithContext) */
agentRepository?: AgentRepository;
} }
/** /**
@@ -119,6 +122,7 @@ export interface CreateContextOptions {
errandRepository?: ErrandRepository; errandRepository?: ErrandRepository;
projectSyncManager?: ProjectSyncManager; projectSyncManager?: ProjectSyncManager;
workspaceRoot?: string; workspaceRoot?: string;
agentRepository?: AgentRepository;
} }
/** /**
@@ -155,5 +159,6 @@ export function createContext(options: CreateContextOptions): TRPCContext {
errandRepository: options.errandRepository, errandRepository: options.errandRepository,
projectSyncManager: options.projectSyncManager, projectSyncManager: options.projectSyncManager,
workspaceRoot: options.workspaceRoot, workspaceRoot: options.workspaceRoot,
agentRepository: options.agentRepository,
}; };
} }

View File

@@ -20,6 +20,7 @@ import type { ConversationRepository } from '../../db/repositories/conversation-
import type { ChatSessionRepository } from '../../db/repositories/chat-session-repository.js'; import type { ChatSessionRepository } from '../../db/repositories/chat-session-repository.js';
import type { ReviewCommentRepository } from '../../db/repositories/review-comment-repository.js'; import type { ReviewCommentRepository } from '../../db/repositories/review-comment-repository.js';
import type { ErrandRepository } from '../../db/repositories/errand-repository.js'; import type { ErrandRepository } from '../../db/repositories/errand-repository.js';
import type { AgentRepository } from '../../db/repositories/agent-repository.js';
import type { DispatchManager, PhaseDispatchManager } from '../../dispatch/types.js'; import type { DispatchManager, PhaseDispatchManager } from '../../dispatch/types.js';
import type { CoordinationManager } from '../../coordination/types.js'; import type { CoordinationManager } from '../../coordination/types.js';
import type { BranchManager } from '../../git/branch-manager.js'; import type { BranchManager } from '../../git/branch-manager.js';
@@ -236,3 +237,13 @@ export function requireErrandRepository(ctx: TRPCContext): ErrandRepository {
} }
return ctx.errandRepository; return ctx.errandRepository;
} }
export function requireAgentRepository(ctx: TRPCContext): AgentRepository {
if (!ctx.agentRepository) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Agent repository not available',
});
}
return ctx.agentRepository;
}

View File

@@ -11,7 +11,7 @@ import type { ProcedureBuilder } from '../trpc.js';
import type { TRPCContext } from '../context.js'; import type { TRPCContext } from '../context.js';
import type { AgentInfo, AgentResult, PendingQuestions } from '../../agent/types.js'; import type { AgentInfo, AgentResult, PendingQuestions } from '../../agent/types.js';
import type { AgentOutputEvent } from '../../events/types.js'; import type { AgentOutputEvent } from '../../events/types.js';
import { requireAgentManager, requireLogChunkRepository, requireTaskRepository, requireInitiativeRepository, requireConversationRepository } from './_helpers.js'; import { requireAgentManager, requireAgentRepository, requireLogChunkRepository, requireTaskRepository, requireInitiativeRepository, requireConversationRepository } from './_helpers.js';
export type AgentRadarRow = { export type AgentRadarRow = {
id: string; id: string;
@@ -191,9 +191,8 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) {
listWaitingAgents: publicProcedure listWaitingAgents: publicProcedure
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
const agentManager = requireAgentManager(ctx); const agentRepo = requireAgentRepository(ctx);
const allAgents = await agentManager.list(); return agentRepo.findWaitingWithContext();
return allAgents.filter(agent => agent.status === 'waiting_for_input');
}), }),
getActiveRefineAgent: publicProcedure getActiveRefineAgent: publicProcedure

View File

@@ -241,7 +241,7 @@ Index: `(phaseId)`.
| InitiativeRepository | create, findById, findAll, findByStatus, update, delete | | InitiativeRepository | create, findById, findAll, findByStatus, update, delete |
| PhaseRepository | + createDependency, getDependencies, getDependents, findByInitiativeId | | PhaseRepository | + createDependency, getDependencies, getDependents, findByInitiativeId |
| TaskRepository | + findByParentTaskId, findByPhaseId, createDependency | | TaskRepository | + findByParentTaskId, findByPhaseId, createDependency |
| AgentRepository | + findByName, findByTaskId, findBySessionId, findByStatus | | AgentRepository | + findByName, findByTaskId, findBySessionId, findByStatus, findWaitingWithContext (LEFT JOIN enriched) |
| MessageRepository | + findPendingForUser, findRequiringResponse, findReplies | | MessageRepository | + findPendingForUser, findRequiringResponse, findReplies |
| PageRepository | + findRootPage, getOrCreateRootPage, findByParentPageId | | PageRepository | + findRootPage, getOrCreateRootPage, findByParentPageId |
| ProjectRepository | + junction ops: setInitiativeProjects (diff-based), findProjectsByInitiativeId | | ProjectRepository | + junction ops: setInitiativeProjects (diff-based), findProjectsByInitiativeId |

View File

@@ -68,7 +68,7 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
| getAgentPrompt | query | Assembled prompt — reads from DB (`agents.prompt`) first; falls back to `.cw/agent-logs/<name>/PROMPT.md` for pre-persistence agents (1 MB cap) | | getAgentPrompt | query | Assembled prompt — reads from DB (`agents.prompt`) first; falls back to `.cw/agent-logs/<name>/PROMPT.md` for pre-persistence agents (1 MB cap) |
| getActiveRefineAgent | query | Active refine agent for initiative | | getActiveRefineAgent | query | Active refine agent for initiative |
| getActiveConflictAgent | query | Active conflict resolution agent for initiative (name starts with `conflict-`) | | getActiveConflictAgent | query | Active conflict resolution agent for initiative (name starts with `conflict-`) |
| listWaitingAgents | query | Agents waiting for input | | listWaitingAgents | query | Agents waiting for input — returns `AgentWithContext[]` enriched with `taskName`, `phaseName`, `initiativeName`, `taskDescription` via SQL LEFT JOINs |
| listForRadar | query | Radar page: per-agent metrics (questionsCount, messagesCount, subagentsCount, compactionsCount) with time/status/mode/initiative filters | | listForRadar | query | Radar page: per-agent metrics (questionsCount, messagesCount, subagentsCount, compactionsCount) with time/status/mode/initiative filters |
| getCompactionEvents | query | Compaction events for one agent: `{agentId}``{timestamp, sessionNumber}[]` (cap 200) | | getCompactionEvents | query | Compaction events for one agent: `{agentId}``{timestamp, sessionNumber}[]` (cap 200) |
| getSubagentSpawns | query | Subagent spawn events for one agent: `{agentId}``{timestamp, description, promptPreview, fullPrompt}[]` (cap 200) | | getSubagentSpawns | query | Subagent spawn events for one agent: `{agentId}``{timestamp, description, promptPreview, fullPrompt}[]` (cap 200) |