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:
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user