diff --git a/apps/server/agent/mock-manager.ts b/apps/server/agent/mock-manager.ts index 0c59498..63eac8d 100644 --- a/apps/server/agent/mock-manager.ts +++ b/apps/server/agent/mock-manager.ts @@ -471,6 +471,42 @@ export class MockAgentManager implements AgentManager { record.info.updatedAt = now; } + /** + * Resume an idle agent to answer an inter-agent conversation. + * Mock implementation: marks agent as running and schedules immediate completion. + */ + async resumeForConversation( + agentId: string, + conversationId: string, + question: string, + fromAgentId: string, + ): Promise { + const record = this.agents.get(agentId); + if (!record || record.info.status !== 'idle' || !record.info.sessionId) { + return false; + } + + record.info.status = 'running'; + record.info.updatedAt = new Date(); + + if (this.eventBus) { + const event: AgentResumedEvent = { + type: 'agent:resumed', + timestamp: new Date(), + payload: { + agentId, + name: record.info.name, + taskId: record.info.taskId, + sessionId: record.info.sessionId, + }, + }; + this.eventBus.emit(event); + } + + this.scheduleCompletion(agentId, { status: 'done', delay: 0, result: 'Answered conversation' }); + return true; + } + /** * Clear all agents and pending timers. * Useful for test cleanup. diff --git a/apps/server/agent/types.ts b/apps/server/agent/types.ts index 3640072..1a7729c 100644 --- a/apps/server/agent/types.ts +++ b/apps/server/agent/types.ts @@ -237,4 +237,24 @@ export interface AgentManager { * @param agentId - Agent to dismiss */ dismiss(agentId: string): Promise; + + /** + * Resume an idle agent to answer an inter-agent conversation. + * + * When Agent A asks Agent B a question via `cw ask` and Agent B is idle, + * this resumes B's session with a prompt to answer via `cw answer` and + * drain any remaining pending conversations via `cw listen`. + * + * @param agentId - The idle agent to resume + * @param conversationId - The conversation that triggered the resume + * @param question - The question being asked + * @param fromAgentId - The agent asking the question + * @returns true if resume was initiated, false if not possible + */ + resumeForConversation( + agentId: string, + conversationId: string, + question: string, + fromAgentId: string, + ): Promise; } diff --git a/apps/server/dispatch/manager.test.ts b/apps/server/dispatch/manager.test.ts index b72783c..10a412e 100644 --- a/apps/server/dispatch/manager.test.ts +++ b/apps/server/dispatch/manager.test.ts @@ -80,6 +80,7 @@ function createMockAgentManager( resume: vi.fn().mockResolvedValue(undefined), getResult: vi.fn().mockResolvedValue(null), getPendingQuestions: vi.fn().mockResolvedValue(null), + resumeForConversation: vi.fn().mockResolvedValue(false), }; } diff --git a/apps/server/test/harness.ts b/apps/server/test/harness.ts index d635e67..7773c79 100644 --- a/apps/server/test/harness.ts +++ b/apps/server/test/harness.ts @@ -254,6 +254,11 @@ export interface TestHarness { */ getPendingQuestions(agentId: string): Promise; + /** + * Resume an idle agent to answer a conversation (mock: always returns false). + */ + resumeForConversation(agentId: string, conversationId: string, question: string, fromAgentId: string): Promise; + /** * Get events by type. */ @@ -505,6 +510,9 @@ export function createTestHarness(): TestHarness { getPendingQuestions: (agentId: string) => agentManager.getPendingQuestions(agentId), + resumeForConversation: (agentId: string, conversationId: string, question: string, fromAgentId: string) => + agentManager.resumeForConversation(agentId, conversationId, question, fromAgentId), + getEventsByType: (type: string) => eventBus.getEventsByType(type), getEmittedEvents: (type: string) => eventBus.getEventsByType(type), diff --git a/apps/server/trpc/routers/conversation.ts b/apps/server/trpc/routers/conversation.ts index 793006e..c5486fd 100644 --- a/apps/server/trpc/routers/conversation.ts +++ b/apps/server/trpc/routers/conversation.ts @@ -8,6 +8,9 @@ import { z } from 'zod'; import type { ProcedureBuilder } from '../trpc.js'; import { requireConversationRepository, requireAgentManager, requireTaskRepository } from './_helpers.js'; import type { ConversationCreatedEvent, ConversationAnsweredEvent } from '../../events/types.js'; +import { createModuleLogger } from '../../logger/index.js'; + +const log = createModuleLogger('conversation-router'); export function conversationProcedures(publicProcedure: ProcedureBuilder) { return { @@ -25,30 +28,34 @@ export function conversationProcedures(publicProcedure: ProcedureBuilder) { let toAgentId = input.toAgentId; - // Resolve target agent from taskId + // Resolve target agent from taskId — prefer running, fall back to idle if (!toAgentId && input.taskId) { const agents = await agentManager.list(); - const match = agents.find(a => a.taskId === input.taskId && a.status === 'running'); + const running = agents.find(a => a.taskId === input.taskId && a.status === 'running'); + const idle = agents.find(a => a.taskId === input.taskId && a.status === 'idle'); + const match = running ?? idle; if (!match) { throw new TRPCError({ code: 'NOT_FOUND', - message: `No running agent found for task '${input.taskId}'`, + message: `No running or idle agent found for task '${input.taskId}'`, }); } toAgentId = match.id; } - // Resolve target agent from phaseId + // Resolve target agent from phaseId — prefer running, fall back to idle if (!toAgentId && input.phaseId) { const taskRepo = requireTaskRepository(ctx); const tasks = await taskRepo.findByPhaseId(input.phaseId); const taskIds = new Set(tasks.map(t => t.id)); const agents = await agentManager.list(); - const match = agents.find(a => a.taskId && taskIds.has(a.taskId) && a.status === 'running'); + const running = agents.find(a => a.taskId && taskIds.has(a.taskId) && a.status === 'running'); + const idle = agents.find(a => a.taskId && taskIds.has(a.taskId) && a.status === 'idle'); + const match = running ?? idle; if (!match) { throw new TRPCError({ code: 'NOT_FOUND', - message: `No running agent found for phase '${input.phaseId}'`, + message: `No running or idle agent found for phase '${input.phaseId}'`, }); } toAgentId = match.id; @@ -80,6 +87,24 @@ export function conversationProcedures(publicProcedure: ProcedureBuilder) { }, }); + // Auto-resume idle target agent so it can answer the conversation + const targetAgent = await agentManager.get(toAgentId); + if (targetAgent && targetAgent.status === 'idle') { + try { + const resumed = await agentManager.resumeForConversation( + toAgentId, conversation.id, input.question, input.fromAgentId, + ); + if (resumed) { + log.info({ conversationId: conversation.id, toAgentId }, 'auto-resumed idle agent for conversation'); + } + } catch (err) { + log.warn( + { conversationId: conversation.id, toAgentId, err: err instanceof Error ? err.message : String(err) }, + 'failed to auto-resume agent for conversation', + ); + } + } + return conversation; }), diff --git a/docs/agent.md b/docs/agent.md index 00d46a2..6c78cb4 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -115,6 +115,25 @@ cw account add --token --email user@example.com Stored as `credentials: {"claudeAiOauth":{"accessToken":""}}` and `configJson: {"hasCompletedOnboarding":true}`. +## Auto-Resume for Conversations + +When Agent A asks Agent B a question via `cw ask` and Agent B is idle, the conversation router automatically resumes Agent B's session. This mirrors the `resumeForCommit()` pattern. + +### Flow + +1. `createConversation` tRPC procedure creates the conversation record +2. Target resolution prefers `running` agents, falls back to `idle` (previously only matched `running`) +3. After creation, checks if target agent is idle → calls `agentManager.resumeForConversation()` +4. Agent resumes with a prompt to: answer via `cw answer`, drain pending conversations via `cw listen`, then complete + +### Guards + +- Agent must be `idle` status with a valid `sessionId` +- Provider must support resume (`resumeStyle !== 'none'`) +- Worktree must still exist (`existsSync` check) +- In-memory `conversationResumeLocks` Set prevents double-resume race when multiple conversations arrive simultaneously +- Resume failure is caught and logged — conversation is always created even if resume fails + ## Auto-Cleanup & Commit Retries After an agent completes (status → `idle`), `tryAutoCleanup` checks if its project worktrees have uncommitted changes: diff --git a/docs/dispatch-events.md b/docs/dispatch-events.md index f314e3d..48b718e 100644 --- a/docs/dispatch-events.md +++ b/docs/dispatch-events.md @@ -25,7 +25,7 @@ | **Worktree** | `worktree:created`, `worktree:removed`, `worktree:merged`, `worktree:conflict` | 4 | | **Account** | `account:credentials_refreshed`, `account:credentials_expired`, `account:credentials_validated` | 3 | | **Preview** | `preview:building`, `preview:ready`, `preview:stopped`, `preview:failed` | 4 | -| **Conversation** | `conversation:created`, `conversation:answered` | 2 | +| **Conversation** | `conversation:created`, `conversation:answered` | 2 | `conversation:created` triggers auto-resume of idle target agents via `resumeForConversation()` | | **Log** | `log:entry` | 1 | ### Key Event Payloads