diff --git a/apps/server/agent/cleanup-manager.ts b/apps/server/agent/cleanup-manager.ts index e35d406..17586ac 100644 --- a/apps/server/agent/cleanup-manager.ts +++ b/apps/server/agent/cleanup-manager.ts @@ -8,7 +8,7 @@ import { promisify } from 'node:util'; import { execFile } from 'node:child_process'; import { readFile, readdir, rm, cp, mkdir } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; +import { existsSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; import type { AgentRepository } from '../db/repositories/agent-repository.js'; import type { ProjectRepository } from '../db/repositories/project-repository.js'; @@ -49,10 +49,35 @@ export class CleanupManager { */ private resolveAgentCwd(worktreeId: string): string { const base = this.getAgentWorkdir(worktreeId); + + // 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(base, '.cw', 'output')) && existsSync(join(workspaceSub, '.cw'))) { + if (existsSync(join(workspaceSub, '.cw'))) { return workspaceSub; } + + // Initiative-based agents may have written .cw/ inside a project + // subdirectory (e.g. agent-workdirs//codewalk-district/.cw/). + // Probe immediate children for a .cw/output directory. + try { + const entries = readdirSync(base, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory() && entry.name !== '.cw') { + const projectSub = join(base, entry.name); + if (existsSync(join(projectSub, '.cw', 'output'))) { + return projectSub; + } + } + } + } catch { + // base dir may not exist + } + return base; } diff --git a/apps/server/agent/manager.ts b/apps/server/agent/manager.ts index 0572edb..d567fcc 100644 --- a/apps/server/agent/manager.ts +++ b/apps/server/agent/manager.ts @@ -43,7 +43,7 @@ import { getProvider } from './providers/registry.js'; import { createModuleLogger } from '../logger/index.js'; import { getProjectCloneDir } from '../git/project-clones.js'; import { join } from 'node:path'; -import { unlink, readFile, writeFile as writeFileAsync } from 'node:fs/promises'; +import { unlink, readFile, writeFile as writeFileAsync, mkdir } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import type { AccountCredentialManager } from './credentials/types.js'; import { ProcessManager } from './process-manager.js'; @@ -332,32 +332,10 @@ export class MultiProviderAgentManager implements AgentManager { await this.repository.update(agentId, { pid, outputFilePath }); - // Write spawn diagnostic file for post-execution verification - const diagnostic = { - timestamp: new Date().toISOString(), - agentId, - alias, - intendedCwd: finalCwd, - worktreeId: agent.worktreeId, - provider: providerName, - command, - args, - env: processEnv, - cwdExistsAtSpawn: existsSync(finalCwd), - initiativeId: initiativeId || null, - customCwdProvided: !!cwd, - accountId: accountId || null, - }; - - await writeFileAsync( - join(finalCwd, '.cw', 'spawn-diagnostic.json'), - JSON.stringify(diagnostic, null, 2), - 'utf-8' - ); - + // Register agent and start polling BEFORE non-critical I/O so that a + // diagnostic-write failure can never orphan a running process. const activeEntry: ActiveAgent = { agentId, pid, tailer, outputFilePath, agentCwd: finalCwd }; this.activeAgents.set(agentId, activeEntry); - log.info({ agentId, alias, pid, diagnosticWritten: true }, 'detached subprocess started with diagnostic'); // Emit spawned event if (this.eventBus) { @@ -377,6 +355,37 @@ export class MultiProviderAgentManager implements AgentManager { ); activeEntry.cancelPoll = cancel; + // Write spawn diagnostic file (non-fatal — .cw/ may not exist yet for + // agents spawned without inputContext, e.g. conflict-resolution agents) + try { + const diagnosticDir = join(finalCwd, '.cw'); + await mkdir(diagnosticDir, { recursive: true }); + const diagnostic = { + timestamp: new Date().toISOString(), + agentId, + alias, + intendedCwd: finalCwd, + worktreeId: agent.worktreeId, + provider: providerName, + command, + args, + env: processEnv, + cwdExistsAtSpawn: existsSync(finalCwd), + initiativeId: initiativeId || null, + customCwdProvided: !!cwd, + accountId: accountId || null, + }; + await writeFileAsync( + join(diagnosticDir, 'spawn-diagnostic.json'), + JSON.stringify(diagnostic, null, 2), + 'utf-8' + ); + } catch (err) { + log.warn({ agentId, alias, err: err instanceof Error ? err.message : String(err) }, 'failed to write spawn diagnostic'); + } + + log.info({ agentId, alias, pid }, 'detached subprocess started'); + return this.toAgentInfo(agent); } diff --git a/apps/server/agent/output-handler.ts b/apps/server/agent/output-handler.ts index a43f09e..28fdaf6 100644 --- a/apps/server/agent/output-handler.ts +++ b/apps/server/agent/output-handler.ts @@ -7,7 +7,7 @@ */ import { readFile } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; +import { existsSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; import type { AgentRepository } from '../db/repositories/agent-repository.js'; import type { ChangeSetRepository, CreateChangeSetEntryData } from '../db/repositories/change-set-repository.js'; @@ -233,10 +233,10 @@ export class OutputHandler { log.debug({ agentId }, 'detached agent completed'); - // Resolve actual agent working directory — standalone agents run in a - // "workspace/" subdirectory inside getAgentWorkdir, so prefer agentCwd - // recorded at spawn time when available. - const agentWorkdir = active?.agentCwd ?? getAgentWorkdir(agent.worktreeId); + // Resolve actual agent working directory. + // The recorded agentCwd may be the parent dir (agent-workdirs//) while + // the agent actually writes .cw/output/ inside a project subdirectory. + const agentWorkdir = this.resolveAgentWorkdir(active?.agentCwd ?? getAgentWorkdir(agent.worktreeId)); const outputDir = join(agentWorkdir, '.cw', 'output'); const expectedPwdFile = join(agentWorkdir, '.cw', 'expected-pwd.txt'); const diagnosticFile = join(agentWorkdir, '.cw', 'spawn-diagnostic.json'); @@ -1158,6 +1158,31 @@ export class OutputHandler { } } + /** + * Resolve the actual agent working directory. The recorded agentCwd may be + * the parent (agent-workdirs//) but .cw/output/ could be inside a + * project subdirectory (e.g. codewalk-district/.cw/output/). + */ + private resolveAgentWorkdir(base: string): string { + if (existsSync(join(base, '.cw', 'output'))) return base; + + // Standalone agents: workspace/ subdirectory + const workspaceSub = join(base, 'workspace'); + if (existsSync(join(workspaceSub, '.cw'))) return workspaceSub; + + // Initiative-based agents: probe project subdirectories + 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 may not exist */ } + + return base; + } + private emitCrashed(agent: { id: string; name: string; taskId: string | null }, error: string): void { if (this.eventBus) { const event: AgentCrashedEvent = {