refactor: unify errand worktree paths to use agent-workdirs/<alias>/

Errands now create worktrees via ProcessManager.createWorktreesForProjects()
into agent-workdirs/<alias>/<project.name>/ instead of repos/<project>/.cw-worktrees/<errandId>.
This makes getAgentWorkdir + resolveAgentCwd work correctly for all agent types.

Key changes:
- Extract createWorktreesForProjects() from createProjectWorktrees() in ProcessManager
- Add resolveAgentCwd() to ProcessManager (probes for .cw/output in subdirs)
- Add projectId to SpawnAgentOptions for single-project agents (errands)
- Skip auto-cleanup for errand agents (worktrees persist for merge/abandon)
- Errand router uses agentManager.delete() for cleanup instead of SimpleGitWorktreeManager
- Remove cwd parameter from sendUserMessage (resolves via worktreeId)
- Add pruneProjectRepos() to CleanupManager for errand worktree refs
This commit is contained in:
Lukas May
2026-03-07 00:02:27 +01:00
parent b17c0a2b4f
commit c52fa86542
7 changed files with 133 additions and 101 deletions

View File

@@ -19,8 +19,6 @@ import {
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';
@@ -98,7 +96,7 @@ export function errandProcedures(publicProcedure: ProcedureBuilder) {
});
}
// 7.5. Create DB record early (agentId null) to get a stable ID for the worktree
// 7.5. Create DB record early (agentId null)
const repo = requireErrandRepository(ctx);
let errand;
try {
@@ -121,37 +119,22 @@ export function errandProcedures(publicProcedure: ProcedureBuilder) {
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
// 8. Build prompt
const prompt = buildErrandPrompt(input.description);
// 10. Spawn agent
// 9. Spawn agent — worktree created via projectId in agent-workdirs/<alias>/<project.name>/
const agentManager = requireAgentManager(ctx);
let agent;
try {
agent = await agentManager.spawn({
prompt,
mode: 'errand',
cwd: worktree.path,
provider: undefined,
projectId: input.projectId,
branchName,
baseBranch,
});
} catch (err) {
// Clean up worktree, DB record, and branch on spawn failure
try { await worktreeManager.remove(errandId); } catch { /* no-op */ }
// Clean up DB record and branch on spawn failure
try { await repo.delete(errandId); } catch { /* no-op */ }
try { await branchManager.deleteBranch(clonePath, branchName); } catch { /* no-op */ }
throw new TRPCError({
@@ -160,9 +143,10 @@ export function errandProcedures(publicProcedure: ProcedureBuilder) {
});
}
// 11. Write errand manifest files
// 10. Write errand manifest files
const agentWorkdir = join(ctx.workspaceRoot!, 'agent-workdirs', agent.name, project.name);
await writeErrandManifest({
agentWorkdir: worktree.path,
agentWorkdir,
errandId,
description: input.description,
branch: branchName,
@@ -171,10 +155,10 @@ export function errandProcedures(publicProcedure: ProcedureBuilder) {
agentName: agent.name,
});
// 12. Update DB record with agent ID
// 11. Update DB record with agent ID
await repo.update(errandId, { agentId: agent.id });
// 13. Return result
// 12. Return result
return { id: errandId, branch: branchName, agentId: agent.id };
}),
@@ -314,9 +298,11 @@ export function errandProcedures(publicProcedure: ProcedureBuilder) {
);
if (result.success) {
// Clean merge — remove worktree and mark merged
const worktreeManager = new SimpleGitWorktreeManager(clonePath);
try { await worktreeManager.remove(errand.id); } catch { /* no-op */ }
// Clean merge — delete agent (removes worktree) and mark merged
const agentManager = requireAgentManager(ctx);
if (errand.agentId) {
try { await agentManager.delete(errand.agentId); } catch { /* no-op */ }
}
await repo.update(input.id, { status: 'merged' });
return { status: 'merged' };
} else {
@@ -345,18 +331,17 @@ export function errandProcedures(publicProcedure: ProcedureBuilder) {
const agentManager = requireAgentManager(ctx);
// Stop agent if active
if (errand.status === 'active' && errand.agentId) {
// Stop and delete agent (removes worktree + logs)
if (errand.agentId) {
try { await agentManager.stop(errand.agentId); } catch { /* no-op */ }
try { await agentManager.delete(errand.agentId); } catch { /* no-op */ }
}
// Remove worktree and branch (best-effort)
// Delete branch (agent doesn't own the branch name)
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 */ }
}
}
@@ -397,23 +382,7 @@ export function errandProcedures(publicProcedure: ProcedureBuilder) {
});
}
// 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);
await agentManager.sendUserMessage(errand.agentId, input.message);
return { success: true };
}),
@@ -439,18 +408,17 @@ export function errandProcedures(publicProcedure: ProcedureBuilder) {
const agentManager = requireAgentManager(ctx);
const branchManager = requireBranchManager(ctx);
// Stop agent if active
if (errand.status === 'active' && errand.agentId) {
// Stop and delete agent (removes worktree + logs)
if (errand.agentId) {
try { await agentManager.stop(errand.agentId); } catch { /* no-op */ }
try { await agentManager.delete(errand.agentId); } catch { /* no-op */ }
}
// Remove worktree and branch (best-effort)
// Delete branch
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 */ }
}
}
@@ -489,41 +457,24 @@ export function errandProcedures(publicProcedure: ProcedureBuilder) {
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);
// Stop and delete old agent — committed work is preserved on the branch
if (errand.agentId) {
try { await agentManager.stop(errand.agentId); } catch { /* no-op */ }
try { await agentManager.delete(errand.agentId); } catch { /* no-op */ }
}
// Spawn fresh agent on the same branch (all committed work is preserved)
const prompt = buildErrandRevisionPrompt(errand.description, input.feedback);
let agent;
try {
agent = await agentManager.spawn({
prompt,
mode: 'errand',
cwd: worktree.path,
provider: undefined,
projectId: errand.projectId!,
branchName: errand.branch,
baseBranch: errand.baseBranch,
});
} catch (err) {
throw new TRPCError({
@@ -533,8 +484,9 @@ export function errandProcedures(publicProcedure: ProcedureBuilder) {
}
// Update manifest files
const agentWorkdir = join(ctx.workspaceRoot!, 'agent-workdirs', agent.name, project.name);
await writeErrandManifest({
agentWorkdir: worktree.path,
agentWorkdir,
errandId: errand.id,
description: errand.description,
branch: errand.branch,