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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user