feat: Auto-resume idle agents for inter-agent conversations

When an agent asks a question via `cw ask` targeting an idle agent,
the conversation router now auto-resumes the idle agent's session so
it can answer. Previously, questions to idle agents sat unanswered
forever because target resolution only matched running agents.

Changes:
- Add `resumeForConversation()` to AgentManager interface and implement
  on MultiProviderAgentManager (mirrors resumeForCommit pattern)
- Relax createConversation target resolution: prefer running, fall back
  to idle (was running-only)
- Trigger auto-resume after conversation creation for idle targets
- Add concurrency lock (conversationResumeLocks Set) to prevent
  double-resume race conditions
This commit is contained in:
Lukas May
2026-03-03 13:29:39 +01:00
parent 938700d45d
commit 9edc93a268
7 changed files with 116 additions and 7 deletions

View File

@@ -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;
}),