feat: Add errand tRPC router with all 9 procedures and comprehensive tests

Implements the errand workflow for small isolated changes that spawn a
dedicated agent in a git worktree:
- errand.create: branch + worktree + DB record + agent spawn
- errand.list / errand.get / errand.diff: read procedures
- errand.complete: transitions active→pending_review, stops agent
- errand.merge: merges branch, handles conflicts with conflictFiles
- errand.delete / errand.abandon: cleanup worktree, branch, agent
- errand.sendMessage: delivers user message directly to running agent

Supporting changes:
- Add 'errand' to AgentMode union and agents.mode enum
- Add sendUserMessage() to AgentManager interface and MockAgentManager
- MockAgentManager now accepts optional agentRepository to persist agents
  to the DB (required for FK constraint satisfaction in tests)
- Add ORDER BY createdAt DESC, id DESC to errand findAll
- Fix dispatch/manager.test.ts missing sendUserMessage mock

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas May
2026-03-06 16:21:01 +01:00
parent 3a328d2b1c
commit 377e8de5e9
9 changed files with 1226 additions and 7 deletions

View File

@@ -26,6 +26,7 @@ import type {
AgentDeletedEvent,
AgentWaitingEvent,
} from '../events/index.js';
import type { AgentRepository } from '../db/repositories/agent-repository.js';
/**
* Scenario configuration for mock agent behavior.
@@ -83,10 +84,12 @@ export class MockAgentManager implements AgentManager {
private scenarioOverrides: Map<string, MockAgentScenario> = new Map();
private defaultScenario: MockAgentScenario;
private eventBus?: EventBus;
private agentRepository?: AgentRepository;
constructor(options?: { eventBus?: EventBus; defaultScenario?: MockAgentScenario }) {
constructor(options?: { eventBus?: EventBus; defaultScenario?: MockAgentScenario; agentRepository?: AgentRepository }) {
this.eventBus = options?.eventBus;
this.defaultScenario = options?.defaultScenario ?? DEFAULT_SCENARIO;
this.agentRepository = options?.agentRepository;
}
/**
@@ -111,7 +114,7 @@ export class MockAgentManager implements AgentManager {
* Completion happens async via setTimeout (even if delay=0).
*/
async spawn(options: SpawnAgentOptions): Promise<AgentInfo> {
const { taskId, prompt } = options;
const { taskId } = options;
const name = options.name ?? `agent-${taskId?.slice(0, 6) ?? 'noTask'}`;
// Check name uniqueness
@@ -121,11 +124,29 @@ export class MockAgentManager implements AgentManager {
}
}
const agentId = randomUUID();
const sessionId = randomUUID();
const worktreeId = randomUUID();
const now = new Date();
// Persist to agentRepository when provided (required for FK constraints in tests)
let agentId: string;
if (this.agentRepository) {
const dbAgent = await this.agentRepository.create({
name,
worktreeId,
taskId: taskId ?? null,
initiativeId: options.initiativeId ?? null,
sessionId,
status: 'running',
mode: options.mode ?? 'execute',
provider: options.provider ?? 'claude',
accountId: null,
});
agentId = dbAgent.id;
} else {
agentId = randomUUID();
}
// Determine scenario (override takes precedence — use original name or generated)
const scenario = this.scenarioOverrides.get(name) ?? this.defaultScenario;
@@ -507,6 +528,18 @@ export class MockAgentManager implements AgentManager {
return true;
}
/**
* Deliver a user message to a running errand agent.
* Mock implementation: no-op (simulates message delivery without actual process interaction).
*/
async sendUserMessage(agentId: string, _message: string): Promise<void> {
const record = this.agents.get(agentId);
if (!record) {
throw new Error(`Agent '${agentId}' not found`);
}
// Mock: succeed silently — message delivery is a no-op in tests
}
/**
* Clear all agents and pending timers.
* Useful for test cleanup.

View File

@@ -15,7 +15,7 @@ export type AgentStatus = 'idle' | 'running' | 'waiting_for_input' | 'stopped' |
* - plan: Plan initiative into phases
* - detail: Detail phase into individual tasks
*/
export type AgentMode = 'execute' | 'discuss' | 'plan' | 'detail' | 'refine' | 'chat';
export type AgentMode = 'execute' | 'discuss' | 'plan' | 'detail' | 'refine' | 'chat' | 'errand';
/**
* Context data written as input files in agent workdir before spawn.
@@ -257,4 +257,14 @@ export interface AgentManager {
question: string,
fromAgentId: string,
): Promise<boolean>;
/**
* Deliver a user message to a running errand agent.
* Does not use the conversations table — the message is injected directly
* into the agent's Claude Code session as a resume prompt.
*
* @param agentId - The errand agent to message
* @param message - The user's message text
*/
sendUserMessage(agentId: string, message: string): Promise<void>;
}