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

@@ -116,6 +116,8 @@ export class CleanupManager {
await rm(agentWorkdir, { recursive: true, force: true });
await this.pruneWorktrees(initiativeId);
// Also prune project clone repos (catches errand worktree refs)
await this.pruneProjectRepos();
}
/**
@@ -175,6 +177,22 @@ export class CleanupManager {
}
}
/**
* Run git worktree prune on all project clone repos.
* Catches errand worktree refs that aren't covered by initiative-based pruning.
*/
private async pruneProjectRepos(): Promise<void> {
const reposPath = join(this.workspaceRoot, 'repos');
try {
const repoDirs = await readdir(reposPath);
for (const repoDir of repoDirs) {
try {
await execFileAsync('git', ['worktree', 'prune'], { cwd: join(reposPath, repoDir) });
} catch { /* ignore */ }
}
} catch { /* no repos dir */ }
}
/**
* Clean up orphaned agent workdirs (directories with no matching DB agent).
*/

View File

@@ -233,10 +233,10 @@ export class MultiProviderAgentManager implements AgentManager {
log.debug('no accounts available, spawning without account');
}
// 2. Create isolated worktrees (skip when caller provides explicit cwd, e.g. errands)
// 2. Create isolated worktrees (skip when caller provides explicit cwd)
let agentCwd: string;
if (cwd) {
// Caller manages the worktree (errands). Use their cwd directly.
// Caller manages the worktree. Use their cwd directly.
agentCwd = cwd;
log.info({ alias, agentCwd }, 'using caller-provided cwd, skipping worktree creation');
} else if (initiativeId) {
@@ -262,6 +262,15 @@ export class MultiProviderAgentManager implements AgentManager {
projects: projects.map(p => ({ name: p.name, url: p.url })),
agentCwd
}, 'initiative-based agent workdir created');
} else if (options.projectId) {
// Single-project worktree (errands) — reuses the same agent-workdirs/<alias>/ structure
const project = await this.projectRepository.findById(options.projectId);
if (!project) throw new Error(`Project not found: ${options.projectId}`);
log.debug({ alias, projectId: options.projectId, baseBranch, branchName }, 'creating single-project worktree');
agentCwd = await this.processManager.createWorktreesForProjects(
alias, [project], baseBranch, branchName,
);
log.info({ alias, projectId: options.projectId, agentCwd }, 'single-project agent workdir created');
} else {
log.debug({ alias }, 'creating standalone worktree');
agentCwd = await this.processManager.createStandaloneWorktree(alias);
@@ -417,7 +426,7 @@ export class MultiProviderAgentManager implements AgentManager {
return async () => {
const agent = await this.repository.findById(agentId);
if (!agent?.worktreeId) return false;
const agentWorkdir = this.processManager.getAgentWorkdir(agent.worktreeId);
const agentWorkdir = this.processManager.resolveAgentCwd(agent.worktreeId);
const signal = await this.outputHandler.readSignalCompletion(agentWorkdir);
return signal !== null;
};
@@ -433,7 +442,7 @@ export class MultiProviderAgentManager implements AgentManager {
await this.outputHandler.handleCompletion(
agentId,
active,
(alias) => this.processManager.getAgentWorkdir(alias),
(alias) => this.processManager.resolveAgentCwd(alias),
);
// Sync credentials back to DB if the agent had an account
@@ -454,6 +463,13 @@ export class MultiProviderAgentManager implements AgentManager {
const agent = await this.repository.findById(agentId);
if (!agent || agent.status !== 'idle') return;
// Errand worktrees must persist for merge/abandon/requestChanges.
// They're cleaned up explicitly by the errand router.
if (agent.mode === 'errand') {
log.debug({ agentId }, 'skipping auto-cleanup for errand agent');
return;
}
const { clean, removed } = await this.cleanupManager.autoCleanupAfterCompletion(
agentId, agent.name, agent.initiativeId,
);
@@ -667,7 +683,7 @@ export class MultiProviderAgentManager implements AgentManager {
* Does not use the conversations table — the message is injected directly
* as the next resume prompt for the agent's Claude Code session.
*/
async sendUserMessage(agentId: string, message: string, cwd?: string): Promise<void> {
async sendUserMessage(agentId: string, message: string): Promise<void> {
const agent = await this.repository.findById(agentId);
if (!agent) throw new Error(`Agent not found: ${agentId}`);
@@ -682,7 +698,7 @@ export class MultiProviderAgentManager implements AgentManager {
const provider = getProvider(agent.provider);
if (!provider) throw new Error(`Unknown provider: ${agent.provider}`);
const agentCwd = cwd ?? this.processManager.getAgentWorkdir(agent.worktreeId);
const agentCwd = this.processManager.resolveAgentCwd(agent.worktreeId);
// Clear previous signal.json
const signalPath = join(agentCwd, '.cw/output/signal.json');

View File

@@ -534,7 +534,7 @@ export class MockAgentManager implements AgentManager {
* 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, _cwd?: string): Promise<void> {
async sendUserMessage(agentId: string, _message: string): Promise<void> {
const record = this.agents.get(agentId);
if (!record) {
throw new Error(`Agent '${agentId}' not found`);

View File

@@ -7,7 +7,7 @@
*/
import { spawn } from 'node:child_process';
import { openSync, closeSync, existsSync } from 'node:fs';
import { openSync, closeSync, existsSync, readdirSync } from 'node:fs';
import { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { ProjectRepository } from '../db/repositories/project-repository.js';
@@ -46,6 +46,36 @@ export class ProcessManager {
return join(this.workspaceRoot, 'agent-workdirs', alias);
}
/**
* Resolve the actual working directory for an agent by probing
* for the subdirectory that contains .cw/output/.
*/
resolveAgentCwd(alias: string): string {
const base = this.getAgentWorkdir(alias);
// Fast path: .cw/output exists at the base level
if (existsSync(join(base, '.cw', 'output'))) return base;
// Standalone agents use a workspace/ subdirectory
const workspaceSub = join(base, 'workspace');
if (existsSync(join(workspaceSub, '.cw'))) return workspaceSub;
// Initiative/errand agents may have written .cw/ inside a project
// subdirectory (e.g. agent-workdirs/<name>/<project-name>/.cw/).
try {
for (const entry of readdirSync(base, { withFileTypes: true })) {
if (entry.isDirectory() && entry.name !== '.cw') {
const sub = join(base, entry.name);
if (existsSync(join(sub, '.cw', 'output'))) return sub;
}
}
} catch {
// base dir may not exist
}
return base;
}
/**
* Create worktrees for all projects linked to an initiative.
* Returns the base agent workdir path.
@@ -57,13 +87,11 @@ export class ProcessManager {
branchName?: string,
): Promise<string> {
const projects = await this.projectRepository.findProjectsByInitiativeId(initiativeId);
const agentWorkdir = this.getAgentWorkdir(alias);
log.debug({
alias,
initiativeId,
projectCount: projects.length,
agentWorkdir,
baseBranch
}, 'creating project worktrees');
@@ -74,6 +102,22 @@ export class ProcessManager {
return this.createStandaloneWorktree(alias);
}
return this.createWorktreesForProjects(alias, projects, baseBranch, branchName);
}
/**
* Create worktrees for a given list of projects under agent-workdirs/<alias>/.
* Used by both initiative-based and single-project (errand) agents.
* Returns the base agent workdir path.
*/
async createWorktreesForProjects(
alias: string,
projects: Array<{ name: string; url: string; id: string; defaultBranch: string }>,
baseBranch?: string,
branchName?: string,
): Promise<string> {
const agentWorkdir = this.getAgentWorkdir(alias);
for (const project of projects) {
const clonePath = await ensureProjectClone(project, this.workspaceRoot);
const worktreeManager = new SimpleGitWorktreeManager(clonePath, undefined, agentWorkdir);

View File

@@ -63,6 +63,8 @@ export interface SpawnAgentOptions {
inputContext?: AgentInputContext;
/** Skip inter-agent communication and preview instructions (for focused agents like conflict resolution) */
skipPromptExtras?: boolean;
/** Project ID — for single-project agents (errands) */
projectId?: string;
}
/**
@@ -272,5 +274,5 @@ export interface AgentManager {
* @param agentId - The errand agent to message
* @param message - The user's message text
*/
sendUserMessage(agentId: string, message: string, cwd?: string): Promise<void>;
sendUserMessage(agentId: string, message: string): Promise<void>;
}

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,

View File

@@ -119,9 +119,9 @@ Stored as `credentials: {"claudeAiOauth":{"accessToken":"<token>"}}` and `config
### `sendUserMessage(agentId, message)`
Delivers a user message directly to a running or idle errand agent without going through the conversations table. Used by the `errand.sendMessage` tRPC procedure.
Delivers a user message directly to a running or idle errand agent without going through the conversations table. Used by the `errand.sendMessage` tRPC procedure. Resolves the agent's working directory via `processManager.resolveAgentCwd(worktreeId)`.
**Steps**: look up agent → validate status (`running`|`idle`) → validate `sessionId` → clear signal.json → update status to `running` → build resume command → stop active tailer/poll → spawn detached → start polling.
**Steps**: look up agent → validate status (`running`|`idle`) → validate `sessionId` resolve cwd via `resolveAgentCwd` clear signal.json → update status to `running` → build resume command → stop active tailer/poll → spawn detached → start polling.
**Key difference from `resumeForConversation`**: no `conversationResumeLocks`, no conversations table entry, raw message passed as resume prompt.