diff --git a/apps/server/agent/cleanup-manager.ts b/apps/server/agent/cleanup-manager.ts index 17586ac..779d8c3 100644 --- a/apps/server/agent/cleanup-manager.ts +++ b/apps/server/agent/cleanup-manager.ts @@ -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 { + 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). */ diff --git a/apps/server/agent/manager.ts b/apps/server/agent/manager.ts index 7a8251a..9ca7407 100644 --- a/apps/server/agent/manager.ts +++ b/apps/server/agent/manager.ts @@ -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// 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 { + async sendUserMessage(agentId: string, message: string): Promise { 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'); diff --git a/apps/server/agent/mock-manager.ts b/apps/server/agent/mock-manager.ts index f38e672..529b769 100644 --- a/apps/server/agent/mock-manager.ts +++ b/apps/server/agent/mock-manager.ts @@ -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 { + async sendUserMessage(agentId: string, _message: string): Promise { const record = this.agents.get(agentId); if (!record) { throw new Error(`Agent '${agentId}' not found`); diff --git a/apps/server/agent/process-manager.ts b/apps/server/agent/process-manager.ts index 105de84..dfb71d1 100644 --- a/apps/server/agent/process-manager.ts +++ b/apps/server/agent/process-manager.ts @@ -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///.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 { 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//. + * 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 { + const agentWorkdir = this.getAgentWorkdir(alias); + for (const project of projects) { const clonePath = await ensureProjectClone(project, this.workspaceRoot); const worktreeManager = new SimpleGitWorktreeManager(clonePath, undefined, agentWorkdir); diff --git a/apps/server/agent/types.ts b/apps/server/agent/types.ts index 9feec9a..2e495a2 100644 --- a/apps/server/agent/types.ts +++ b/apps/server/agent/types.ts @@ -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; + sendUserMessage(agentId: string, message: string): Promise; } diff --git a/apps/server/trpc/routers/errand.ts b/apps/server/trpc/routers/errand.ts index ae68ae7..862bda4 100644 --- a/apps/server/trpc/routers/errand.ts +++ b/apps/server/trpc/routers/errand.ts @@ -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/// 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, diff --git a/docs/agent.md b/docs/agent.md index cba1530..924a33e 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -119,9 +119,9 @@ Stored as `credentials: {"claudeAiOauth":{"accessToken":""}}` 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.