diff --git a/apps/server/agent/manager.test.ts b/apps/server/agent/manager.test.ts index d9a751d..76db2b6 100644 --- a/apps/server/agent/manager.test.ts +++ b/apps/server/agent/manager.test.ts @@ -139,6 +139,7 @@ describe('MultiProviderAgentManager', () => { findByStatus: vi.fn().mockResolvedValue([mockAgent]), update: vi.fn().mockResolvedValue(mockAgent), delete: vi.fn().mockResolvedValue(undefined), + findWaitingWithContext: vi.fn().mockResolvedValue([]), }; mockProjectRepository = { diff --git a/apps/server/agent/mutex-completion.test.ts b/apps/server/agent/mutex-completion.test.ts index ec39cbe..b2a039a 100644 --- a/apps/server/agent/mutex-completion.test.ts +++ b/apps/server/agent/mutex-completion.test.ts @@ -46,7 +46,8 @@ describe('OutputHandler completion mutex', () => { async findByTaskId() { throw new Error('Not implemented'); }, async findByName() { 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(() => { diff --git a/apps/server/db/repositories/agent-repository.ts b/apps/server/db/repositories/agent-repository.ts index f4f4994..656d748 100644 --- a/apps/server/db/repositories/agent-repository.ts +++ b/apps/server/db/repositories/agent-repository.ts @@ -8,6 +8,14 @@ import type { Agent } from '../schema.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. */ @@ -117,4 +125,10 @@ export interface AgentRepository { * Throws if agent not found. */ delete(id: string): Promise; + + /** + * Find all agents with status 'waiting_for_input', enriched with + * task, phase, and initiative names via LEFT JOINs. + */ + findWaitingWithContext(): Promise; } diff --git a/apps/server/db/repositories/drizzle/agent.test.ts b/apps/server/db/repositories/drizzle/agent.test.ts index e40bfaf..e43cbf2 100644 --- a/apps/server/db/repositories/drizzle/agent.test.ts +++ b/apps/server/db/repositories/drizzle/agent.test.ts @@ -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'); + }); +}); diff --git a/apps/server/db/repositories/drizzle/agent.ts b/apps/server/db/repositories/drizzle/agent.ts index c8f82fd..c5f9038 100644 --- a/apps/server/db/repositories/drizzle/agent.ts +++ b/apps/server/db/repositories/drizzle/agent.ts @@ -4,13 +4,14 @@ * Implements AgentRepository interface using Drizzle ORM. */ -import { eq } from 'drizzle-orm'; +import { eq, getTableColumns } from 'drizzle-orm'; import { nanoid } from 'nanoid'; 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 { AgentRepository, AgentStatus, + AgentWithContext, CreateAgentData, UpdateAgentData, } from '../agent-repository.js'; @@ -116,4 +117,20 @@ export class DrizzleAgentRepository implements AgentRepository { throw new Error(`Agent not found: ${id}`); } } + + async findWaitingWithContext(): Promise { + 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)); + } } diff --git a/apps/server/server/trpc-adapter.ts b/apps/server/server/trpc-adapter.ts index cc80176..5f565af 100644 --- a/apps/server/server/trpc-adapter.ts +++ b/apps/server/server/trpc-adapter.ts @@ -23,6 +23,7 @@ import type { ConversationRepository } from '../db/repositories/conversation-rep import type { ChatSessionRepository } from '../db/repositories/chat-session-repository.js'; import type { ReviewCommentRepository } from '../db/repositories/review-comment-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 { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js'; import type { CoordinationManager } from '../coordination/types.js'; @@ -85,6 +86,8 @@ export interface TrpcAdapterOptions { projectSyncManager?: ProjectSyncManager; /** Errand repository for errand CRUD operations */ errandRepository?: ErrandRepository; + /** Agent repository for enriched agent queries */ + agentRepository?: AgentRepository; /** Absolute path to the workspace root (.cwrc directory) */ workspaceRoot?: string; } @@ -170,6 +173,7 @@ export function createTrpcHandler(options: TrpcAdapterOptions) { reviewCommentRepository: options.reviewCommentRepository, projectSyncManager: options.projectSyncManager, errandRepository: options.errandRepository, + agentRepository: options.agentRepository, workspaceRoot: options.workspaceRoot, }), }); diff --git a/apps/server/test/integration/crash-race-condition.test.ts b/apps/server/test/integration/crash-race-condition.test.ts index 4af02a1..e0790fc 100644 --- a/apps/server/test/integration/crash-race-condition.test.ts +++ b/apps/server/test/integration/crash-race-condition.test.ts @@ -96,7 +96,8 @@ describe('Crash marking race condition', () => { async findByTaskId() { throw new Error('Not implemented'); }, async findByName() { 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); diff --git a/apps/server/trpc/context.ts b/apps/server/trpc/context.ts index 3c259c3..9e27b57 100644 --- a/apps/server/trpc/context.ts +++ b/apps/server/trpc/context.ts @@ -20,6 +20,7 @@ import type { ConversationRepository } from '../db/repositories/conversation-rep import type { ChatSessionRepository } from '../db/repositories/chat-session-repository.js'; import type { ReviewCommentRepository } from '../db/repositories/review-comment-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 { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js'; import type { CoordinationManager } from '../coordination/types.js'; @@ -87,6 +88,8 @@ export interface TRPCContext { projectSyncManager?: ProjectSyncManager; /** Absolute path to the workspace root (.cwrc directory) */ workspaceRoot?: string; + /** Agent repository for enriched queries (e.g., findWaitingWithContext) */ + agentRepository?: AgentRepository; } /** @@ -119,6 +122,7 @@ export interface CreateContextOptions { errandRepository?: ErrandRepository; projectSyncManager?: ProjectSyncManager; workspaceRoot?: string; + agentRepository?: AgentRepository; } /** @@ -155,5 +159,6 @@ export function createContext(options: CreateContextOptions): TRPCContext { errandRepository: options.errandRepository, projectSyncManager: options.projectSyncManager, workspaceRoot: options.workspaceRoot, + agentRepository: options.agentRepository, }; } diff --git a/apps/server/trpc/routers/_helpers.ts b/apps/server/trpc/routers/_helpers.ts index 928aac4..e4e097f 100644 --- a/apps/server/trpc/routers/_helpers.ts +++ b/apps/server/trpc/routers/_helpers.ts @@ -20,6 +20,7 @@ import type { ConversationRepository } from '../../db/repositories/conversation- import type { ChatSessionRepository } from '../../db/repositories/chat-session-repository.js'; import type { ReviewCommentRepository } from '../../db/repositories/review-comment-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 { CoordinationManager } from '../../coordination/types.js'; import type { BranchManager } from '../../git/branch-manager.js'; @@ -236,3 +237,13 @@ export function requireErrandRepository(ctx: TRPCContext): 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; +} diff --git a/apps/server/trpc/routers/agent.ts b/apps/server/trpc/routers/agent.ts index 82d397d..268751a 100644 --- a/apps/server/trpc/routers/agent.ts +++ b/apps/server/trpc/routers/agent.ts @@ -11,7 +11,7 @@ import type { ProcedureBuilder } from '../trpc.js'; import type { TRPCContext } from '../context.js'; import type { AgentInfo, AgentResult, PendingQuestions } from '../../agent/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 = { id: string; @@ -191,9 +191,8 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) { listWaitingAgents: publicProcedure .query(async ({ ctx }) => { - const agentManager = requireAgentManager(ctx); - const allAgents = await agentManager.list(); - return allAgents.filter(agent => agent.status === 'waiting_for_input'); + const agentRepo = requireAgentRepository(ctx); + return agentRepo.findWaitingWithContext(); }), getActiveRefineAgent: publicProcedure diff --git a/docs/database.md b/docs/database.md index f877251..49c05ca 100644 --- a/docs/database.md +++ b/docs/database.md @@ -241,7 +241,7 @@ Index: `(phaseId)`. | InitiativeRepository | create, findById, findAll, findByStatus, update, delete | | PhaseRepository | + createDependency, getDependencies, getDependents, findByInitiativeId | | TaskRepository | + findByParentTaskId, findByPhaseId, createDependency | -| AgentRepository | + findByName, findByTaskId, findBySessionId, findByStatus | +| AgentRepository | + findByName, findByTaskId, findBySessionId, findByStatus, findWaitingWithContext (LEFT JOIN enriched) | | MessageRepository | + findPendingForUser, findRequiringResponse, findReplies | | PageRepository | + findRootPage, getOrCreateRootPage, findByParentPageId | | ProjectRepository | + junction ops: setInitiativeProjects (diff-based), findProjectsByInitiativeId | diff --git a/docs/server-api.md b/docs/server-api.md index 42e31b5..a32640c 100644 --- a/docs/server-api.md +++ b/docs/server-api.md @@ -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//PROMPT.md` for pre-persistence agents (1 MB cap) | | getActiveRefineAgent | query | Active refine agent for initiative | | 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 | | 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) |