From 377e8de5e9106c0b0131c7d503528801a0abec7a Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 16:21:01 +0100 Subject: [PATCH] feat: Add errand tRPC router with all 9 procedures and comprehensive tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/server/agent/mock-manager.ts | 39 +- apps/server/agent/types.ts | 12 +- apps/server/db/repositories/drizzle/errand.ts | 5 +- apps/server/db/schema.ts | 2 +- apps/server/dispatch/manager.test.ts | 1 + apps/server/trpc/router.ts | 2 + apps/server/trpc/routers/errand.test.ts | 720 ++++++++++++++++++ apps/server/trpc/routers/errand.ts | 430 +++++++++++ docs/server-api.md | 22 + 9 files changed, 1226 insertions(+), 7 deletions(-) create mode 100644 apps/server/trpc/routers/errand.test.ts create mode 100644 apps/server/trpc/routers/errand.ts diff --git a/apps/server/agent/mock-manager.ts b/apps/server/agent/mock-manager.ts index 63eac8d..7ca2361 100644 --- a/apps/server/agent/mock-manager.ts +++ b/apps/server/agent/mock-manager.ts @@ -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 = 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 { - 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 { + 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. diff --git a/apps/server/agent/types.ts b/apps/server/agent/types.ts index 94737d9..e46bcea 100644 --- a/apps/server/agent/types.ts +++ b/apps/server/agent/types.ts @@ -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; + + /** + * 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; } diff --git a/apps/server/db/repositories/drizzle/errand.ts b/apps/server/db/repositories/drizzle/errand.ts index 1b62fb1..a5999e2 100644 --- a/apps/server/db/repositories/drizzle/errand.ts +++ b/apps/server/db/repositories/drizzle/errand.ts @@ -4,7 +4,7 @@ * Implements ErrandRepository interface using Drizzle ORM. */ -import { eq, and } from 'drizzle-orm'; +import { eq, and, desc } from 'drizzle-orm'; import { nanoid } from 'nanoid'; import type { DrizzleDatabase } from '../../index.js'; import { errands, agents, type Errand } from '../../schema.js'; @@ -81,7 +81,8 @@ export class DrizzleErrandRepository implements ErrandRepository { }) .from(errands) .leftJoin(agents, eq(errands.agentId, agents.id)) - .where(conditions.length > 0 ? and(...conditions) : undefined); + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy(desc(errands.createdAt), desc(errands.id)); return rows as ErrandWithAlias[]; } diff --git a/apps/server/db/schema.ts b/apps/server/db/schema.ts index ed2f7fd..cba5967 100644 --- a/apps/server/db/schema.ts +++ b/apps/server/db/schema.ts @@ -261,7 +261,7 @@ export const agents = sqliteTable('agents', { }) .notNull() .default('idle'), - mode: text('mode', { enum: ['execute', 'discuss', 'plan', 'detail', 'refine', 'chat'] }) + mode: text('mode', { enum: ['execute', 'discuss', 'plan', 'detail', 'refine', 'chat', 'errand'] }) .notNull() .default('execute'), pid: integer('pid'), diff --git a/apps/server/dispatch/manager.test.ts b/apps/server/dispatch/manager.test.ts index 10a412e..477c2ce 100644 --- a/apps/server/dispatch/manager.test.ts +++ b/apps/server/dispatch/manager.test.ts @@ -81,6 +81,7 @@ function createMockAgentManager( getResult: vi.fn().mockResolvedValue(null), getPendingQuestions: vi.fn().mockResolvedValue(null), resumeForConversation: vi.fn().mockResolvedValue(false), + sendUserMessage: vi.fn().mockResolvedValue(undefined), }; } diff --git a/apps/server/trpc/router.ts b/apps/server/trpc/router.ts index d1c43fc..085a808 100644 --- a/apps/server/trpc/router.ts +++ b/apps/server/trpc/router.ts @@ -24,6 +24,7 @@ import { subscriptionProcedures } from './routers/subscription.js'; import { previewProcedures } from './routers/preview.js'; import { conversationProcedures } from './routers/conversation.js'; import { chatSessionProcedures } from './routers/chat-session.js'; +import { errandProcedures } from './routers/errand.js'; // Re-export tRPC primitives (preserves existing import paths) export { router, publicProcedure, middleware, createCallerFactory } from './trpc.js'; @@ -63,6 +64,7 @@ export const appRouter = router({ ...previewProcedures(publicProcedure), ...conversationProcedures(publicProcedure), ...chatSessionProcedures(publicProcedure), + ...errandProcedures(publicProcedure), }); export type AppRouter = typeof appRouter; diff --git a/apps/server/trpc/routers/errand.test.ts b/apps/server/trpc/routers/errand.test.ts new file mode 100644 index 0000000..c21e0b8 --- /dev/null +++ b/apps/server/trpc/routers/errand.test.ts @@ -0,0 +1,720 @@ +/** + * Errand Router Tests + * + * Tests all 9 errand tRPC procedures using in-memory SQLite, MockAgentManager, + * and vi.mock for git operations. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { nanoid } from 'nanoid'; +import type { BranchManager } from '../../git/branch-manager.js'; +import type { MergeResult, MergeabilityResult } from '../../git/types.js'; +import { MockAgentManager } from '../../agent/mock-manager.js'; +import { EventEmitterBus } from '../../events/bus.js'; +import { createTestDatabase } from '../../db/repositories/drizzle/test-helpers.js'; +import { createRepositories } from '../../container.js'; +import { appRouter, createCallerFactory } from '../router.js'; +import { createContext } from '../context.js'; + +// --------------------------------------------------------------------------- +// vi.hoisted mock handles for git module mocks (hoisted before vi.mock calls) +// --------------------------------------------------------------------------- +const { mockCreate, mockRemove, mockEnsureProjectClone, mockWriteErrandManifest } = vi.hoisted(() => ({ + mockCreate: vi.fn(), + mockRemove: vi.fn(), + mockEnsureProjectClone: vi.fn(), + mockWriteErrandManifest: vi.fn(), +})); + +vi.mock('../../git/manager.js', () => ({ + SimpleGitWorktreeManager: class MockWorktreeManager { + create = mockCreate; + remove = mockRemove; + }, +})); + +vi.mock('../../git/project-clones.js', () => ({ + ensureProjectClone: mockEnsureProjectClone, +})); + +vi.mock('../../agent/file-io.js', async (importOriginal) => { + const original = await importOriginal() as Record; + return { + ...original, + writeErrandManifest: mockWriteErrandManifest, + }; +}); + +// --------------------------------------------------------------------------- +// MockBranchManager +// --------------------------------------------------------------------------- +class MockBranchManager implements BranchManager { + private ensureBranchError: Error | null = null; + private mergeResultOverride: MergeResult | null = null; + private diffResult = ''; + public deletedBranches: string[] = []; + public ensuredBranches: string[] = []; + + setEnsureBranchError(err: Error | null): void { this.ensureBranchError = err; } + setMergeResult(result: MergeResult): void { this.mergeResultOverride = result; } + setDiffResult(diff: string): void { this.diffResult = diff; } + + async ensureBranch(_repoPath: string, branch: string, _baseBranch: string): Promise { + if (this.ensureBranchError) throw this.ensureBranchError; + this.ensuredBranches.push(branch); + } + async mergeBranch(_repoPath: string, _src: string, _target: string): Promise { + return this.mergeResultOverride ?? { success: true, message: 'Merged successfully' }; + } + async diffBranches(_repoPath: string, _base: string, _head: string): Promise { + return this.diffResult; + } + async deleteBranch(_repoPath: string, branch: string): Promise { + this.deletedBranches.push(branch); + } + async branchExists(_repoPath: string, _branch: string): Promise { return false; } + async remoteBranchExists(_repoPath: string, _branch: string): Promise { return false; } + async listCommits(_repoPath: string, _base: string, _head: string) { return []; } + async diffCommit(_repoPath: string, _hash: string): Promise { return ''; } + async getMergeBase(_repoPath: string, _b1: string, _b2: string): Promise { return ''; } + async pushBranch(_repoPath: string, _branch: string, _remote?: string): Promise {} + async checkMergeability(_repoPath: string, _src: string, _target: string): Promise { + return { mergeable: true, conflicts: [] }; + } + async fetchRemote(_repoPath: string, _remote?: string): Promise {} + async fastForwardBranch(_repoPath: string, _branch: string, _remote?: string): Promise {} +} + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- +const createCaller = createCallerFactory(appRouter); + +function createTestHarness() { + const db = createTestDatabase(); + const eventBus = new EventEmitterBus(); + const repos = createRepositories(db); + const agentManager = new MockAgentManager({ eventBus, agentRepository: repos.agentRepository }); + const branchManager = new MockBranchManager(); + + const ctx = createContext({ + eventBus, + serverStartedAt: new Date(), + processCount: 0, + agentManager, + errandRepository: repos.errandRepository, + projectRepository: repos.projectRepository, + branchManager, + workspaceRoot: '/tmp/test-workspace', + }); + + const caller = createCaller(ctx); + + return { + db, + caller, + agentManager, + branchManager, + repos, + }; +} + +async function createProject(repos: ReturnType) { + const suffix = nanoid().slice(0, 6); + return repos.projectRepository.create({ + name: `test-project-${suffix}`, + url: `https://github.com/test/project-${suffix}`, + defaultBranch: 'main', + }); +} + +async function createErrandDirect( + repos: ReturnType, + agentManager: MockAgentManager, + overrides: Partial<{ + description: string; + branch: string; + baseBranch: string; + agentId: string | null; + projectId: string; + status: 'active' | 'pending_review' | 'conflict' | 'merged' | 'abandoned'; + conflictFiles: string | null; + }> = {}, +) { + const project = await createProject(repos); + // Spawn an agent to get a real agent ID (unique name to avoid name collision) + const agent = await agentManager.spawn({ + prompt: 'Test errand', + name: `errand-agent-${nanoid().slice(0, 6)}`, + mode: 'errand', + cwd: '/tmp/fake-worktree', + taskId: null, + }); + + const errand = await repos.errandRepository.create({ + description: overrides.description ?? 'Fix typo in README', + branch: overrides.branch ?? 'cw/errand/fix-typo-abc12345', + baseBranch: overrides.baseBranch ?? 'main', + agentId: overrides.agentId !== undefined ? overrides.agentId : agent.id, + projectId: overrides.projectId ?? project.id, + status: overrides.status ?? 'active', + conflictFiles: overrides.conflictFiles ?? null, + }); + + return { errand, project, agent }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +describe('errand procedures', () => { + let h: ReturnType; + + beforeEach(() => { + h = createTestHarness(); + // Reset mock call counts and set default passing behavior + mockCreate.mockClear(); + mockRemove.mockClear(); + mockEnsureProjectClone.mockClear(); + mockWriteErrandManifest.mockClear(); + mockEnsureProjectClone.mockResolvedValue('/tmp/fake-clone'); + mockCreate.mockResolvedValue({ id: 'errand-id', branch: 'cw/errand/test', path: '/tmp/worktree', isMainWorktree: false }); + mockRemove.mockResolvedValue(undefined); + mockWriteErrandManifest.mockResolvedValue(undefined); + h.branchManager.setEnsureBranchError(null); + h.branchManager.deletedBranches.splice(0); + h.branchManager.ensuredBranches.splice(0); + }); + + // ========================================================================= + // errand.create + // ========================================================================= + describe('errand.create', () => { + it('creates errand with valid input and returns id, branch, agentId', async () => { + const project = await createProject(h.repos); + const result = await h.caller.errand.create({ + description: 'Fix typo in README', + projectId: project.id, + }); + + expect(result).toMatchObject({ + id: expect.any(String), + branch: expect.stringMatching(/^cw\/errand\/fix-typo-in-readme-[a-zA-Z0-9_-]{8}$/), + agentId: expect.any(String), + }); + }); + + it('generates correct slug from description', async () => { + const project = await createProject(h.repos); + const result = await h.caller.errand.create({ + description: 'fix typo in README', + projectId: project.id, + }); + + expect(result.branch).toMatch(/^cw\/errand\/fix-typo-in-readme-[a-zA-Z0-9_-]{8}$/); + }); + + it('uses fallback slug "errand" when description has only special chars', async () => { + const project = await createProject(h.repos); + const result = await h.caller.errand.create({ + description: '!!!', + projectId: project.id, + }); + + expect(result.branch).toMatch(/^cw\/errand\/errand-[a-zA-Z0-9_-]{8}$/); + }); + + it('stores errand in database with correct fields', async () => { + const project = await createProject(h.repos); + const result = await h.caller.errand.create({ + description: 'Fix typo in README', + projectId: project.id, + baseBranch: 'develop', + }); + + const errand = await h.repos.errandRepository.findById(result.id); + expect(errand).not.toBeNull(); + expect(errand!.description).toBe('Fix typo in README'); + expect(errand!.baseBranch).toBe('develop'); + expect(errand!.projectId).toBe(project.id); + expect(errand!.status).toBe('active'); + expect(errand!.agentId).toBe(result.agentId); + }); + + it('throws BAD_REQUEST when description exceeds 200 chars', async () => { + const project = await createProject(h.repos); + const longDesc = 'a'.repeat(201); + + await expect(h.caller.errand.create({ + description: longDesc, + projectId: project.id, + })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: `description must be ≤200 characters (201 given)`, + }); + + // No DB record created + const errands = await h.repos.errandRepository.findAll(); + expect(errands).toHaveLength(0); + }); + + it('throws NOT_FOUND for non-existent projectId', async () => { + await expect(h.caller.errand.create({ + description: 'Fix something', + projectId: 'nonexistent-project', + })).rejects.toMatchObject({ + code: 'NOT_FOUND', + message: 'Project not found', + }); + + // No DB record created + const errands = await h.repos.errandRepository.findAll(); + expect(errands).toHaveLength(0); + }); + + it('throws INTERNAL_SERVER_ERROR when branch creation fails', async () => { + const project = await createProject(h.repos); + h.branchManager.setEnsureBranchError(new Error('Git error: branch locked')); + + await expect(h.caller.errand.create({ + description: 'Fix something', + projectId: project.id, + })).rejects.toMatchObject({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Git error: branch locked', + }); + + // No DB record, no worktree created + const errands = await h.repos.errandRepository.findAll(); + expect(errands).toHaveLength(0); + expect(mockCreate).not.toHaveBeenCalled(); + }); + + it('throws INTERNAL_SERVER_ERROR when worktree creation fails, cleans up branch and DB record', async () => { + const project = await createProject(h.repos); + mockCreate.mockRejectedValueOnce(new Error('Worktree creation failed')); + + await expect(h.caller.errand.create({ + description: 'Fix something', + projectId: project.id, + })).rejects.toMatchObject({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Worktree creation failed', + }); + + // No DB record (was created then deleted) + const errands = await h.repos.errandRepository.findAll(); + expect(errands).toHaveLength(0); + + // Branch was deleted + expect(h.branchManager.deletedBranches.length).toBe(1); + }); + + it('throws INTERNAL_SERVER_ERROR when agent spawn fails, cleans up worktree, DB record, and branch', async () => { + const project = await createProject(h.repos); + + // Make spawn fail by using a scenario that throws immediately + vi.spyOn(h.agentManager, 'spawn').mockRejectedValueOnce(new Error('Spawn failed')); + + await expect(h.caller.errand.create({ + description: 'Fix something', + projectId: project.id, + })).rejects.toMatchObject({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Spawn failed', + }); + + // No DB record (was created then deleted) + const errands = await h.repos.errandRepository.findAll(); + expect(errands).toHaveLength(0); + + // Worktree was removed, branch deleted + expect(mockRemove).toHaveBeenCalledOnce(); + expect(h.branchManager.deletedBranches.length).toBe(1); + }); + }); + + // ========================================================================= + // errand.list + // ========================================================================= + describe('errand.list', () => { + it('returns all errands ordered newest first', async () => { + const { errand: e1 } = await createErrandDirect(h.repos, h.agentManager, { description: 'First' }); + const project2 = await h.repos.projectRepository.create({ name: 'proj2', url: 'https://github.com/t/p2', defaultBranch: 'main' }); + const { errand: e2 } = await createErrandDirect(h.repos, h.agentManager, { description: 'Second', projectId: project2.id, branch: 'cw/errand/second-xyz12345' }); + + const result = await h.caller.errand.list({}); + expect(result.length).toBe(2); + // Both errands are present (repository orders by createdAt DESC) + const ids = result.map(r => r.id); + expect(ids).toContain(e1.id); + expect(ids).toContain(e2.id); + }); + + it('filters by projectId', async () => { + const { errand: e1, project } = await createErrandDirect(h.repos, h.agentManager); + const project2 = await h.repos.projectRepository.create({ name: 'proj2', url: 'https://github.com/t/p2', defaultBranch: 'main' }); + const agent2 = await h.agentManager.spawn({ prompt: 'x', mode: 'errand', cwd: '/tmp/x', taskId: null }); + await h.repos.errandRepository.create({ description: 'Other', branch: 'cw/errand/other-abc12345', baseBranch: 'main', agentId: agent2.id, projectId: project2.id, status: 'active', conflictFiles: null }); + + const result = await h.caller.errand.list({ projectId: project.id }); + expect(result.length).toBe(1); + expect(result[0].id).toBe(e1.id); + }); + + it('filters by status', async () => { + await createErrandDirect(h.repos, h.agentManager, { status: 'active' }); + const { errand: e2 } = await createErrandDirect(h.repos, h.agentManager, { status: 'merged', branch: 'cw/errand/merged-abc12345' }); + + const result = await h.caller.errand.list({ status: 'merged' }); + expect(result.length).toBe(1); + expect(result[0].id).toBe(e2.id); + }); + + it('returns empty array when no errands exist', async () => { + const result = await h.caller.errand.list({}); + expect(result).toEqual([]); + }); + + it('each record includes agentAlias', async () => { + await createErrandDirect(h.repos, h.agentManager); + const result = await h.caller.errand.list({}); + expect(result[0]).toHaveProperty('agentAlias'); + }); + }); + + // ========================================================================= + // errand.get + // ========================================================================= + describe('errand.get', () => { + it('returns errand with agentAlias and parsed conflictFiles', async () => { + const { errand } = await createErrandDirect(h.repos, h.agentManager); + const result = await h.caller.errand.get({ id: errand.id }); + + expect(result.id).toBe(errand.id); + expect(result).toHaveProperty('agentAlias'); + expect(result.conflictFiles).toBeNull(); + }); + + it('parses conflictFiles JSON when present', async () => { + const { errand } = await createErrandDirect(h.repos, h.agentManager, { + status: 'conflict', + conflictFiles: '["src/a.ts","src/b.ts"]', + }); + + const result = await h.caller.errand.get({ id: errand.id }); + expect(result.conflictFiles).toEqual(['src/a.ts', 'src/b.ts']); + }); + + it('throws NOT_FOUND for unknown id', async () => { + await expect(h.caller.errand.get({ id: 'nonexistent' })).rejects.toMatchObject({ + code: 'NOT_FOUND', + message: 'Errand not found', + }); + }); + }); + + // ========================================================================= + // errand.diff + // ========================================================================= + describe('errand.diff', () => { + it('returns diff string for an existing errand', async () => { + const { errand } = await createErrandDirect(h.repos, h.agentManager); + h.branchManager.setDiffResult('diff --git a/README.md b/README.md\n...'); + + const result = await h.caller.errand.diff({ id: errand.id }); + expect(result.diff).toBe('diff --git a/README.md b/README.md\n...'); + }); + + it('returns empty diff string when branch has no commits', async () => { + const { errand } = await createErrandDirect(h.repos, h.agentManager); + h.branchManager.setDiffResult(''); + + const result = await h.caller.errand.diff({ id: errand.id }); + expect(result.diff).toBe(''); + }); + + it('throws NOT_FOUND for unknown id', async () => { + await expect(h.caller.errand.diff({ id: 'nonexistent' })).rejects.toMatchObject({ + code: 'NOT_FOUND', + message: 'Errand not found', + }); + }); + }); + + // ========================================================================= + // errand.complete + // ========================================================================= + describe('errand.complete', () => { + it('transitions active errand to pending_review and stops agent', async () => { + const { errand, agent } = await createErrandDirect(h.repos, h.agentManager, { status: 'active' }); + const stopSpy = vi.spyOn(h.agentManager, 'stop'); + + const result = await h.caller.errand.complete({ id: errand.id }); + + expect(result!.status).toBe('pending_review'); + expect(stopSpy).toHaveBeenCalledWith(agent.id); + }); + + it('throws BAD_REQUEST when status is pending_review', async () => { + const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'pending_review' }); + + await expect(h.caller.errand.complete({ id: errand.id })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: "Cannot complete an errand with status 'pending_review'", + }); + }); + + it('throws BAD_REQUEST when status is merged', async () => { + const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'merged', agentId: null }); + + await expect(h.caller.errand.complete({ id: errand.id })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: "Cannot complete an errand with status 'merged'", + }); + }); + }); + + // ========================================================================= + // errand.merge + // ========================================================================= + describe('errand.merge', () => { + it('merges clean pending_review errand, removes worktree, sets status to merged', async () => { + const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'pending_review' }); + h.branchManager.setMergeResult({ success: true, message: 'Merged' }); + + const result = await h.caller.errand.merge({ id: errand.id }); + + expect(result).toEqual({ status: 'merged' }); + expect(mockRemove).toHaveBeenCalledOnce(); + + const updated = await h.repos.errandRepository.findById(errand.id); + expect(updated!.status).toBe('merged'); + }); + + it('merges clean conflict errand (re-merge after resolve)', async () => { + const { errand } = await createErrandDirect(h.repos, h.agentManager, { + status: 'conflict', + conflictFiles: '["src/a.ts"]', + }); + h.branchManager.setMergeResult({ success: true, message: 'Merged' }); + + const result = await h.caller.errand.merge({ id: errand.id }); + expect(result).toEqual({ status: 'merged' }); + }); + + it('merges into target branch override', async () => { + const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'pending_review' }); + const mergeSpy = vi.spyOn(h.branchManager, 'mergeBranch'); + + await h.caller.errand.merge({ id: errand.id, target: 'develop' }); + + expect(mergeSpy).toHaveBeenCalledWith( + expect.any(String), + errand.branch, + 'develop', + ); + }); + + it('throws BAD_REQUEST and stores conflictFiles on merge conflict', async () => { + const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'pending_review' }); + h.branchManager.setMergeResult({ + success: false, + conflicts: ['src/a.ts', 'src/b.ts'], + message: 'Conflict detected', + }); + + await expect(h.caller.errand.merge({ id: errand.id })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: 'Merge conflict in 2 file(s)', + }); + + const updated = await h.repos.errandRepository.findById(errand.id); + expect(updated!.status).toBe('conflict'); + expect(JSON.parse(updated!.conflictFiles!)).toEqual(['src/a.ts', 'src/b.ts']); + }); + + it('throws BAD_REQUEST when status is active', async () => { + const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'active' }); + + await expect(h.caller.errand.merge({ id: errand.id })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: "Cannot merge an errand with status 'active'", + }); + }); + + it('throws BAD_REQUEST when status is abandoned', async () => { + const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'abandoned', agentId: null }); + + await expect(h.caller.errand.merge({ id: errand.id })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: "Cannot merge an errand with status 'abandoned'", + }); + }); + }); + + // ========================================================================= + // errand.delete + // ========================================================================= + describe('errand.delete', () => { + it('deletes active errand: stops agent, removes worktree, deletes branch and DB record', async () => { + const { errand, agent } = await createErrandDirect(h.repos, h.agentManager, { status: 'active' }); + const stopSpy = vi.spyOn(h.agentManager, 'stop'); + + const result = await h.caller.errand.delete({ id: errand.id }); + + expect(result).toEqual({ success: true }); + expect(stopSpy).toHaveBeenCalledWith(agent.id); + expect(mockRemove).toHaveBeenCalledOnce(); + expect(h.branchManager.deletedBranches).toContain(errand.branch); + + const deleted = await h.repos.errandRepository.findById(errand.id); + expect(deleted).toBeNull(); + }); + + it('deletes non-active errand: skips agent stop', async () => { + const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'pending_review' }); + const stopSpy = vi.spyOn(h.agentManager, 'stop'); + + const result = await h.caller.errand.delete({ id: errand.id }); + + expect(result).toEqual({ success: true }); + expect(stopSpy).not.toHaveBeenCalled(); + + const deleted = await h.repos.errandRepository.findById(errand.id); + expect(deleted).toBeNull(); + }); + + it('succeeds when worktree already removed (no-op)', async () => { + const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'active' }); + mockRemove.mockRejectedValueOnce(new Error('Worktree not found')); + + // Should not throw + const result = await h.caller.errand.delete({ id: errand.id }); + expect(result).toEqual({ success: true }); + + const deleted = await h.repos.errandRepository.findById(errand.id); + expect(deleted).toBeNull(); + }); + + it('succeeds when branch already deleted (no-op)', async () => { + const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'active' }); + + // DeleteBranch doesn't throw (BranchManager interface says no-op if not found) + const result = await h.caller.errand.delete({ id: errand.id }); + expect(result).toEqual({ success: true }); + }); + + it('throws NOT_FOUND for unknown id', async () => { + await expect(h.caller.errand.delete({ id: 'nonexistent' })).rejects.toMatchObject({ + code: 'NOT_FOUND', + message: 'Errand not found', + }); + }); + }); + + // ========================================================================= + // errand.sendMessage + // ========================================================================= + describe('errand.sendMessage', () => { + it('sends message to active running errand agent', async () => { + const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'active' }); + const sendSpy = vi.spyOn(h.agentManager, 'sendUserMessage'); + + const result = await h.caller.errand.sendMessage({ id: errand.id, message: 'Hello agent' }); + + expect(result).toEqual({ success: true }); + expect(sendSpy).toHaveBeenCalledWith(errand.agentId, 'Hello agent'); + }); + + it('does NOT create a conversations record', async () => { + const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'active' }); + await h.caller.errand.sendMessage({ id: errand.id, message: 'Hello agent' }); + + // No pending conversation records should exist for the agent + const convs = await h.repos.conversationRepository.findPendingForAgent(errand.agentId!); + expect(convs).toHaveLength(0); + }); + + it('throws BAD_REQUEST when agent is stopped', async () => { + const { errand, agent } = await createErrandDirect(h.repos, h.agentManager, { status: 'active' }); + // Stop the agent to set its status to stopped + await h.agentManager.stop(agent.id); + + await expect(h.caller.errand.sendMessage({ id: errand.id, message: 'Hello' })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: 'Agent is not running (status: stopped)', + }); + }); + + it('throws BAD_REQUEST when errand is not active', async () => { + const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'pending_review' }); + + await expect(h.caller.errand.sendMessage({ id: errand.id, message: 'Hello' })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: 'Errand is not active', + }); + }); + }); + + // ========================================================================= + // errand.abandon + // ========================================================================= + describe('errand.abandon', () => { + it('abandons active errand: stops agent, removes worktree, deletes branch, sets status', async () => { + const { errand, agent } = await createErrandDirect(h.repos, h.agentManager, { status: 'active' }); + const stopSpy = vi.spyOn(h.agentManager, 'stop'); + + const result = await h.caller.errand.abandon({ id: errand.id }); + + expect(result!.status).toBe('abandoned'); + expect(result!.agentId).toBe(agent.id); // agentId preserved + expect(stopSpy).toHaveBeenCalledWith(agent.id); + expect(mockRemove).toHaveBeenCalledOnce(); + expect(h.branchManager.deletedBranches).toContain(errand.branch); + + // DB record preserved with abandoned status + const found = await h.repos.errandRepository.findById(errand.id); + expect(found!.status).toBe('abandoned'); + }); + + it('abandons pending_review errand: skips agent stop', async () => { + const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'pending_review' }); + const stopSpy = vi.spyOn(h.agentManager, 'stop'); + + const result = await h.caller.errand.abandon({ id: errand.id }); + + expect(result!.status).toBe('abandoned'); + expect(stopSpy).not.toHaveBeenCalled(); + }); + + it('abandons conflict errand: skips agent stop, removes worktree, deletes branch', async () => { + const { errand } = await createErrandDirect(h.repos, h.agentManager, { + status: 'conflict', + conflictFiles: '["src/a.ts"]', + agentId: null, + }); + + const result = await h.caller.errand.abandon({ id: errand.id }); + expect(result!.status).toBe('abandoned'); + }); + + it('throws BAD_REQUEST when status is merged', async () => { + const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'merged', agentId: null }); + + await expect(h.caller.errand.abandon({ id: errand.id })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: "Cannot abandon an errand with status 'merged'", + }); + }); + + it('throws BAD_REQUEST when status is abandoned', async () => { + const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'abandoned', agentId: null }); + + await expect(h.caller.errand.abandon({ id: errand.id })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: "Cannot abandon an errand with status 'abandoned'", + }); + }); + }); +}); diff --git a/apps/server/trpc/routers/errand.ts b/apps/server/trpc/routers/errand.ts new file mode 100644 index 0000000..4ef6a32 --- /dev/null +++ b/apps/server/trpc/routers/errand.ts @@ -0,0 +1,430 @@ +/** + * Errand Router + * + * All 9 errand procedures: create, list, get, diff, complete, merge, delete, sendMessage, abandon. + * Errands are small isolated changes that spawn a dedicated agent in a git worktree. + */ + +import { z } from 'zod'; +import { TRPCError } from '@trpc/server'; +import { nanoid } from 'nanoid'; +import { router } from '../trpc.js'; +import type { ProcedureBuilder } from '../trpc.js'; +import { + requireErrandRepository, + requireProjectRepository, + requireAgentManager, + requireBranchManager, +} from './_helpers.js'; +import { writeErrandManifest } from '../../agent/file-io.js'; +import { buildErrandPrompt } from '../../agent/prompts/index.js'; +import { SimpleGitWorktreeManager } from '../../git/manager.js'; +import { ensureProjectClone } from '../../git/project-clones.js'; +import type { TRPCContext } from '../context.js'; + +// ErrandStatus values for input validation +const ErrandStatusValues = ['active', 'pending_review', 'conflict', 'merged', 'abandoned'] as const; + +/** + * Resolve the project's local clone path. + * Throws INTERNAL_SERVER_ERROR if workspaceRoot is not available. + */ +async function resolveClonePath( + project: { id: string; name: string; url: string }, + ctx: TRPCContext, +): Promise { + if (!ctx.workspaceRoot) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Workspace root not configured', + }); + } + return ensureProjectClone(project, ctx.workspaceRoot); +} + +export function errandProcedures(publicProcedure: ProcedureBuilder) { + return { + errand: router({ + // ----------------------------------------------------------------------- + // errand.create + // ----------------------------------------------------------------------- + create: publicProcedure + .input(z.object({ + description: z.string(), + projectId: z.string().min(1), + baseBranch: z.string().optional(), + })) + .mutation(async ({ ctx, input }) => { + // 1. Validate description length + if (input.description.length > 200) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `description must be ≤200 characters (${input.description.length} given)`, + }); + } + + // 2. Look up project + const project = await requireProjectRepository(ctx).findById(input.projectId); + if (!project) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' }); + } + + // 3. Generate slug + let slug = input.description + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-]/g, '') + .slice(0, 50); + if (!slug) slug = 'errand'; + + // 4–5. Compute branch name with unique suffix + const branchName = `cw/errand/${slug}-${nanoid().slice(0, 8)}`; + + // 6. Resolve base branch + const baseBranch = input.baseBranch ?? 'main'; + + // 7. Get project clone path and create branch + const clonePath = await resolveClonePath(project, ctx); + const branchManager = requireBranchManager(ctx); + + try { + await branchManager.ensureBranch(clonePath, branchName, baseBranch); + } catch (err) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: err instanceof Error ? err.message : String(err), + }); + } + + // 7.5. Create DB record early (agentId null) to get a stable ID for the worktree + const repo = requireErrandRepository(ctx); + let errand; + try { + errand = await repo.create({ + description: input.description, + branch: branchName, + baseBranch, + agentId: null, + projectId: input.projectId, + status: 'active', + }); + } catch (err) { + try { await branchManager.deleteBranch(clonePath, branchName); } catch { /* no-op */ } + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: err instanceof Error ? err.message : String(err), + }); + } + + const errandId = errand.id; + + // 8. Create worktree using the DB-assigned errand ID + const worktreeManager = new SimpleGitWorktreeManager(clonePath); + let worktree; + try { + worktree = await worktreeManager.create(errandId, branchName, baseBranch); + } catch (err) { + // Clean up DB record and branch on worktree failure + try { await repo.delete(errandId); } catch { /* no-op */ } + try { await branchManager.deleteBranch(clonePath, branchName); } catch { /* no-op */ } + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: err instanceof Error ? err.message : String(err), + }); + } + + // 9. Build prompt + const prompt = buildErrandPrompt(input.description); + + // 10. Spawn agent + const agentManager = requireAgentManager(ctx); + let agent; + try { + agent = await agentManager.spawn({ + prompt, + mode: 'errand', + cwd: worktree.path, + provider: undefined, + }); + } catch (err) { + // Clean up worktree, DB record, and branch on spawn failure + try { await worktreeManager.remove(errandId); } catch { /* no-op */ } + try { await repo.delete(errandId); } catch { /* no-op */ } + try { await branchManager.deleteBranch(clonePath, branchName); } catch { /* no-op */ } + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: err instanceof Error ? err.message : String(err), + }); + } + + // 11. Write errand manifest files + await writeErrandManifest({ + agentWorkdir: worktree.path, + errandId, + description: input.description, + branch: branchName, + projectName: project.name, + agentId: agent.id, + agentName: agent.name, + }); + + // 12. Update DB record with agent ID + await repo.update(errandId, { agentId: agent.id }); + + // 13. Return result + return { id: errandId, branch: branchName, agentId: agent.id }; + }), + + // ----------------------------------------------------------------------- + // errand.list + // ----------------------------------------------------------------------- + list: publicProcedure + .input(z.object({ + projectId: z.string().optional(), + status: z.enum(ErrandStatusValues).optional(), + })) + .query(async ({ ctx, input }) => { + return requireErrandRepository(ctx).findAll({ + projectId: input.projectId, + status: input.status, + }); + }), + + // ----------------------------------------------------------------------- + // errand.get + // ----------------------------------------------------------------------- + get: publicProcedure + .input(z.object({ id: z.string().min(1) })) + .query(async ({ ctx, input }) => { + const errand = await requireErrandRepository(ctx).findById(input.id); + if (!errand) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Errand not found' }); + } + return { + ...errand, + conflictFiles: errand.conflictFiles ? (JSON.parse(errand.conflictFiles) as string[]) : null, + }; + }), + + // ----------------------------------------------------------------------- + // errand.diff + // ----------------------------------------------------------------------- + diff: publicProcedure + .input(z.object({ id: z.string().min(1) })) + .query(async ({ ctx, input }) => { + const errand = await requireErrandRepository(ctx).findById(input.id); + if (!errand) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Errand not found' }); + } + + const project = await requireProjectRepository(ctx).findById(errand.projectId); + if (!project) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' }); + } + + const clonePath = await resolveClonePath(project, ctx); + const diff = await requireBranchManager(ctx).diffBranches( + clonePath, + errand.baseBranch, + errand.branch, + ); + return { diff }; + }), + + // ----------------------------------------------------------------------- + // errand.complete + // ----------------------------------------------------------------------- + complete: publicProcedure + .input(z.object({ id: z.string().min(1) })) + .mutation(async ({ ctx, input }) => { + const repo = requireErrandRepository(ctx); + const errand = await repo.findById(input.id); + if (!errand) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Errand not found' }); + } + + if (errand.status !== 'active') { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Cannot complete an errand with status '${errand.status}'`, + }); + } + + // Stop agent if present + if (errand.agentId) { + try { + await requireAgentManager(ctx).stop(errand.agentId); + } catch { /* no-op if already stopped */ } + } + + const updated = await repo.update(input.id, { status: 'pending_review' }); + return updated; + }), + + // ----------------------------------------------------------------------- + // errand.merge + // ----------------------------------------------------------------------- + merge: publicProcedure + .input(z.object({ + id: z.string().min(1), + target: z.string().optional(), + })) + .mutation(async ({ ctx, input }) => { + const repo = requireErrandRepository(ctx); + const errand = await repo.findById(input.id); + if (!errand) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Errand not found' }); + } + + if (errand.status !== 'pending_review' && errand.status !== 'conflict') { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Cannot merge an errand with status '${errand.status}'`, + }); + } + + const targetBranch = input.target ?? errand.baseBranch; + + const project = await requireProjectRepository(ctx).findById(errand.projectId); + if (!project) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' }); + } + + const clonePath = await resolveClonePath(project, ctx); + const result = await requireBranchManager(ctx).mergeBranch( + clonePath, + errand.branch, + targetBranch, + ); + + if (result.success) { + // Clean merge — remove worktree and mark merged + const worktreeManager = new SimpleGitWorktreeManager(clonePath); + try { await worktreeManager.remove(errand.id); } catch { /* no-op */ } + await repo.update(input.id, { status: 'merged', conflictFiles: null }); + return { status: 'merged' }; + } else { + // Conflict — persist conflict files and throw + const conflictFilesList = result.conflicts ?? []; + await repo.update(input.id, { + status: 'conflict', + conflictFiles: JSON.stringify(conflictFilesList), + }); + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Merge conflict in ${conflictFilesList.length} file(s)`, + cause: { conflictFiles: conflictFilesList }, + }); + } + }), + + // ----------------------------------------------------------------------- + // errand.delete + // ----------------------------------------------------------------------- + delete: publicProcedure + .input(z.object({ id: z.string().min(1) })) + .mutation(async ({ ctx, input }) => { + const repo = requireErrandRepository(ctx); + const errand = await repo.findById(input.id); + if (!errand) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Errand not found' }); + } + + const agentManager = requireAgentManager(ctx); + + // Stop agent if active + if (errand.status === 'active' && errand.agentId) { + try { await agentManager.stop(errand.agentId); } catch { /* no-op */ } + } + + // Remove worktree and branch (best-effort) + const project = await requireProjectRepository(ctx).findById(errand.projectId); + if (project) { + const clonePath = await resolveClonePath(project, ctx); + const worktreeManager = new SimpleGitWorktreeManager(clonePath); + try { await worktreeManager.remove(errand.id); } catch { /* no-op if already gone */ } + try { await requireBranchManager(ctx).deleteBranch(clonePath, errand.branch); } catch { /* no-op */ } + } + + await repo.delete(errand.id); + return { success: true }; + }), + + // ----------------------------------------------------------------------- + // errand.sendMessage + // ----------------------------------------------------------------------- + sendMessage: publicProcedure + .input(z.object({ + id: z.string().min(1), + message: z.string().min(1), + })) + .mutation(async ({ ctx, input }) => { + const errand = await requireErrandRepository(ctx).findById(input.id); + if (!errand) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Errand not found' }); + } + + if (errand.status !== 'active') { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Errand is not active' }); + } + + if (!errand.agentId) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Errand has no associated agent' }); + } + + const agentManager = requireAgentManager(ctx); + const agent = await agentManager.get(errand.agentId); + if (!agent || agent.status === 'stopped' || agent.status === 'crashed') { + const status = agent?.status ?? 'unknown'; + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Agent is not running (status: ${status})`, + }); + } + + await agentManager.sendUserMessage(errand.agentId, input.message); + return { success: true }; + }), + + // ----------------------------------------------------------------------- + // errand.abandon + // ----------------------------------------------------------------------- + abandon: publicProcedure + .input(z.object({ id: z.string().min(1) })) + .mutation(async ({ ctx, input }) => { + const repo = requireErrandRepository(ctx); + const errand = await repo.findById(input.id); + if (!errand) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Errand not found' }); + } + + if (errand.status === 'merged' || errand.status === 'abandoned') { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Cannot abandon an errand with status '${errand.status}'`, + }); + } + + const agentManager = requireAgentManager(ctx); + const branchManager = requireBranchManager(ctx); + + // Stop agent if active + if (errand.status === 'active' && errand.agentId) { + try { await agentManager.stop(errand.agentId); } catch { /* no-op */ } + } + + // Remove worktree and branch (best-effort) + const project = await requireProjectRepository(ctx).findById(errand.projectId); + if (project) { + const clonePath = await resolveClonePath(project, ctx); + const worktreeManager = new SimpleGitWorktreeManager(clonePath); + try { await worktreeManager.remove(errand.id); } catch { /* no-op if already gone */ } + try { await branchManager.deleteBranch(clonePath, errand.branch); } catch { /* no-op */ } + } + + const updated = await repo.update(input.id, { status: 'abandoned' }); + return updated; + }), + }), + }; +} diff --git a/docs/server-api.md b/docs/server-api.md index ec11000..dc7def9 100644 --- a/docs/server-api.md +++ b/docs/server-api.md @@ -269,3 +269,25 @@ Persistent chat loop for iterative phase/task refinement via agent. `sendChatMessage` finds or creates an active session, stores the user message, then either resumes the existing agent (if `waiting_for_input`) or spawns a fresh one with full chat history + initiative context. Agent runs in `'chat'` mode and signals `"questions"` after applying changes, staying alive for the next message. Context dependency: `requireChatSessionRepository(ctx)`, `requireAgentManager(ctx)`, `requireInitiativeRepository(ctx)`, `requireTaskRepository(ctx)`. + +## Errand Procedures + +Small isolated changes that spawn a dedicated agent in a git worktree. Errands are scoped to a project and use a branch named `cw/errand/-<8-char-id>`. + +| Procedure | Type | Description | +|-----------|------|-------------| +| `errand.create` | mutation | Create errand: `{description, projectId, baseBranch?}` → `{id, branch, agentId}`. Creates branch, worktree, DB record, spawns agent. | +| `errand.list` | query | List errands: `{projectId?, status?}` → ErrandWithAlias[] (ordered newest-first) | +| `errand.get` | query | Get errand by ID: `{id}` → ErrandWithAlias with parsed `conflictFiles` | +| `errand.diff` | query | Get branch diff: `{id}` → `{diff: string}` | +| `errand.complete` | mutation | Mark active errand ready for review (stops agent): `{id}` → Errand | +| `errand.merge` | mutation | Merge errand branch: `{id, target?}` → `{status: 'merged'}` or throws conflict | +| `errand.delete` | mutation | Delete errand and clean up worktree/branch: `{id}` → `{success: true}` | +| `errand.sendMessage` | mutation | Send message to running errand agent: `{id, message}` → `{success: true}` | +| `errand.abandon` | mutation | Abandon errand (stop agent, clean up, set status): `{id}` → Errand | + +**Errand statuses**: `active` → `pending_review` (via complete) → `merged` (via merge) or `conflict` (merge failed) → retry merge. `abandoned` is terminal. Only `pending_review` and `conflict` errands can be merged. + +**Merge conflict flow**: On conflict, `errand.merge` updates status to `conflict` and stores `conflictFiles` (JSON string[]). After manual resolution, call `errand.merge` again. + +Context dependencies: `requireErrandRepository(ctx)`, `requireProjectRepository(ctx)`, `requireAgentManager(ctx)`, `requireBranchManager(ctx)`, `ctx.workspaceRoot` (for `ensureProjectClone`). `SimpleGitWorktreeManager` is created on-the-fly per project clone path.