Errand agents are spawned in the errand's git worktree (managed by SimpleGitWorktreeManager), not in agent-workdirs/<alias>/. sendUserMessage was deriving the cwd from worktreeId which pointed to the non-existent agent-workdirs path. Now the errand.sendMessage procedure resolves the actual worktree path and passes it through.
554 lines
21 KiB
TypeScript
554 lines
21 KiB
TypeScript
/**
|
||
* Errand Router
|
||
*
|
||
* All 10 errand procedures: create, list, get, diff, complete, merge, delete, sendMessage, abandon, requestChanges.
|
||
* 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, buildErrandRevisionPrompt } from '../../agent/prompts/index.js';
|
||
import { join } from 'node:path';
|
||
import { existsSync, rmSync } from 'node:fs';
|
||
import { SimpleGitWorktreeManager } from '../../git/manager.js';
|
||
import { ensureProjectClone, getProjectCloneDir } 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<string> {
|
||
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 (respect project's default branch)
|
||
const baseBranch = input.baseBranch ?? project.defaultBranch;
|
||
|
||
// 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({
|
||
id: nanoid(),
|
||
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(),
|
||
}).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' });
|
||
}
|
||
|
||
// Compute project clone path for cw errand resolve
|
||
let projectPath: string | null = null;
|
||
if (errand.projectId && ctx.workspaceRoot) {
|
||
const project = await requireProjectRepository(ctx).findById(errand.projectId);
|
||
if (project) {
|
||
projectPath = join(ctx.workspaceRoot, getProjectCloneDir(project.name, project.id));
|
||
}
|
||
}
|
||
|
||
return { ...errand, projectPath };
|
||
}),
|
||
|
||
// -----------------------------------------------------------------------
|
||
// 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' });
|
||
}
|
||
|
||
if (!errand.projectId) {
|
||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Errand has no project' });
|
||
}
|
||
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;
|
||
|
||
if (!errand.projectId) {
|
||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Errand has no project' });
|
||
}
|
||
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' });
|
||
return { status: 'merged' };
|
||
} else {
|
||
// Conflict — update status and throw
|
||
const conflictFilesList = result.conflicts ?? [];
|
||
await repo.update(input.id, { status: 'conflict' });
|
||
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)
|
||
if (errand.projectId) {
|
||
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})`,
|
||
});
|
||
}
|
||
|
||
// Resolve errand worktree path — errand agents don't use agent-workdirs/
|
||
let worktreePath: string | undefined;
|
||
if (errand.projectId) {
|
||
const project = await requireProjectRepository(ctx).findById(errand.projectId);
|
||
if (project) {
|
||
try {
|
||
const clonePath = await resolveClonePath(project, ctx);
|
||
const wm = new SimpleGitWorktreeManager(clonePath);
|
||
const wt = await wm.get(errand.id);
|
||
if (wt) worktreePath = wt.path;
|
||
} catch {
|
||
// Fall through — sendUserMessage will use default path
|
||
}
|
||
}
|
||
}
|
||
|
||
await agentManager.sendUserMessage(errand.agentId, input.message, worktreePath);
|
||
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)
|
||
if (errand.projectId) {
|
||
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;
|
||
}),
|
||
|
||
// -----------------------------------------------------------------------
|
||
// errand.requestChanges
|
||
// -----------------------------------------------------------------------
|
||
requestChanges: publicProcedure
|
||
.input(z.object({
|
||
id: z.string().min(1),
|
||
feedback: 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 !== 'pending_review' && errand.status !== 'conflict') {
|
||
throw new TRPCError({
|
||
code: 'BAD_REQUEST',
|
||
message: `Cannot request changes on an errand with status '${errand.status}'`,
|
||
});
|
||
}
|
||
|
||
if (!errand.projectId) {
|
||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Errand has no project' });
|
||
}
|
||
const project = await requireProjectRepository(ctx).findById(errand.projectId);
|
||
if (!project) {
|
||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' });
|
||
}
|
||
|
||
// Resolve clone path and verify worktree still exists
|
||
const clonePath = await resolveClonePath(project, ctx);
|
||
const worktreeManager = new SimpleGitWorktreeManager(clonePath);
|
||
let worktree;
|
||
try {
|
||
worktree = await worktreeManager.get(errand.id);
|
||
} catch {
|
||
throw new TRPCError({
|
||
code: 'BAD_REQUEST',
|
||
message: 'Worktree no longer exists — cannot request changes. Delete and re-create the errand.',
|
||
});
|
||
}
|
||
if (!worktree) {
|
||
throw new TRPCError({
|
||
code: 'BAD_REQUEST',
|
||
message: 'Worktree no longer exists — cannot request changes. Delete and re-create the errand.',
|
||
});
|
||
}
|
||
|
||
// Clean up stale signal.json to prevent false completion detection
|
||
const signalPath = join(worktree.path, '.cw', 'output', 'signal.json');
|
||
if (existsSync(signalPath)) {
|
||
rmSync(signalPath);
|
||
}
|
||
|
||
// Build revision prompt and spawn new agent in existing worktree
|
||
const prompt = buildErrandRevisionPrompt(errand.description, input.feedback);
|
||
const agentManager = requireAgentManager(ctx);
|
||
let agent;
|
||
try {
|
||
agent = await agentManager.spawn({
|
||
prompt,
|
||
mode: 'errand',
|
||
cwd: worktree.path,
|
||
provider: undefined,
|
||
});
|
||
} catch (err) {
|
||
throw new TRPCError({
|
||
code: 'INTERNAL_SERVER_ERROR',
|
||
message: err instanceof Error ? err.message : String(err),
|
||
});
|
||
}
|
||
|
||
// Update manifest files
|
||
await writeErrandManifest({
|
||
agentWorkdir: worktree.path,
|
||
errandId: errand.id,
|
||
description: errand.description,
|
||
branch: errand.branch,
|
||
projectName: project.name,
|
||
agentId: agent.id,
|
||
agentName: agent.name,
|
||
});
|
||
|
||
// Transition back to active with new agent
|
||
await repo.update(errand.id, { status: 'active', agentId: agent.id });
|
||
|
||
return { id: errand.id, agentId: agent.id };
|
||
}),
|
||
}),
|
||
};
|
||
}
|