fix: Resolve agent workdir probing for initiative project subdirectories

Conflict-resolution agents (and any initiative-based agent) can write
.cw/output/signal.json inside a project subdirectory (e.g.
agent-workdirs/<name>/codewalk-district/.cw/output/) rather than the
parent agent workdir. This caused two failures:

1. spawnInternal wrote spawn-diagnostic.json before registering the
   agent in activeAgents and starting pollForCompletion. If the .cw/
   directory didn't exist (no inputContext provided), the write threw
   ENOENT, orphaning the running process with no completion monitoring.

2. resolveAgentCwd in cleanup-manager and output-handler only probed
   for a workspace/ subdirectory (standalone agents) but not project
   subdirectories, so reconciliation and completion handling couldn't
   find signal.json and marked the agent as crashed.

Fixes:
- Move activeAgents registration and pollForCompletion setup before
  the diagnostic write; make the write non-fatal with mkdir -p
- Add project subdirectory probing to resolveAgentCwd in both
  cleanup-manager.ts and output-handler.ts
This commit is contained in:
Lukas May
2026-03-06 12:03:20 +01:00
parent 2814c2d3b2
commit b853b28751
3 changed files with 91 additions and 32 deletions

View File

@@ -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/<name>/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;
}

View File

@@ -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);
}

View File

@@ -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/<name>/) 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/<name>/) 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 = {