feat: errand review & request changes

Add errand.requestChanges procedure that re-spawns an agent in the
existing worktree with user feedback. Replace raw <pre> diff blocks
with syntax-highlighted ErrandDiffView using FileCard components.
Add Output/Changes tabs to the active errand view.
This commit is contained in:
Lukas May
2026-03-06 22:09:01 +01:00
parent dca4224d26
commit 6482960c6f
5 changed files with 278 additions and 17 deletions

View File

@@ -1,7 +1,7 @@
/**
* Errand Router
*
* All 9 errand procedures: create, list, get, diff, complete, merge, delete, sendMessage, abandon.
* 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.
*/
@@ -17,8 +17,9 @@ import {
requireBranchManager,
} from './_helpers.js';
import { writeErrandManifest } from '../../agent/file-io.js';
import { buildErrandPrompt } from '../../agent/prompts/index.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';
@@ -441,6 +442,96 @@ export function errandProcedures(publicProcedure: ProcedureBuilder) {
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 };
}),
}),
};
}