fix: Convert sync file I/O to async in agent spawn path to unblock event loop
writeInputFiles, spawnDetached, and diagnostic writes now use fs/promises (mkdir, writeFile) instead of mkdirSync/writeFileSync. File writes in writeInputFiles are batched with Promise.all. openSync/closeSync for child process stdio FDs remain sync as spawn() requires the FDs immediately.
This commit is contained in:
@@ -43,7 +43,7 @@ describe('generateId', () => {
|
||||
});
|
||||
|
||||
describe('writeInputFiles', () => {
|
||||
it('writes initiative.md with frontmatter', () => {
|
||||
it('writes initiative.md with frontmatter', async () => {
|
||||
const initiative: Initiative = {
|
||||
id: 'init-1',
|
||||
name: 'Test Initiative',
|
||||
@@ -55,13 +55,13 @@ describe('writeInputFiles', () => {
|
||||
updatedAt: new Date('2026-01-02'),
|
||||
};
|
||||
|
||||
writeInputFiles({ agentWorkdir: testDir, initiative });
|
||||
await writeInputFiles({ agentWorkdir: testDir, initiative });
|
||||
|
||||
const filePath = join(testDir, '.cw', 'input', 'initiative.md');
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
});
|
||||
|
||||
it('writes phase.md with frontmatter', () => {
|
||||
it('writes phase.md with frontmatter', async () => {
|
||||
const phase = {
|
||||
id: 'phase-1',
|
||||
initiativeId: 'init-1',
|
||||
@@ -73,13 +73,13 @@ describe('writeInputFiles', () => {
|
||||
updatedAt: new Date(),
|
||||
} as Phase;
|
||||
|
||||
writeInputFiles({ agentWorkdir: testDir, phase });
|
||||
await writeInputFiles({ agentWorkdir: testDir, phase });
|
||||
|
||||
const filePath = join(testDir, '.cw', 'input', 'phase.md');
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
});
|
||||
|
||||
it('writes task.md with frontmatter', () => {
|
||||
it('writes task.md with frontmatter', async () => {
|
||||
const task = {
|
||||
id: 'task-1',
|
||||
name: 'Test Task',
|
||||
@@ -93,14 +93,14 @@ describe('writeInputFiles', () => {
|
||||
updatedAt: new Date(),
|
||||
} as Task;
|
||||
|
||||
writeInputFiles({ agentWorkdir: testDir, task });
|
||||
await writeInputFiles({ agentWorkdir: testDir, task });
|
||||
|
||||
const filePath = join(testDir, '.cw', 'input', 'task.md');
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
});
|
||||
|
||||
it('writes pages to pages/ subdirectory', () => {
|
||||
writeInputFiles({
|
||||
it('writes pages to pages/ subdirectory', async () => {
|
||||
await writeInputFiles({
|
||||
agentWorkdir: testDir,
|
||||
pages: [
|
||||
{ id: 'page-1', parentPageId: null, title: 'Root', content: null, sortOrder: 0 },
|
||||
@@ -112,13 +112,13 @@ describe('writeInputFiles', () => {
|
||||
expect(existsSync(join(testDir, '.cw', 'input', 'pages', 'page-2.md'))).toBe(true);
|
||||
});
|
||||
|
||||
it('handles empty options without error', () => {
|
||||
writeInputFiles({ agentWorkdir: testDir });
|
||||
it('handles empty options without error', async () => {
|
||||
await writeInputFiles({ agentWorkdir: testDir });
|
||||
expect(existsSync(join(testDir, '.cw', 'input'))).toBe(true);
|
||||
});
|
||||
|
||||
it('writes context/index.json grouping tasks by phaseId', () => {
|
||||
writeInputFiles({
|
||||
it('writes context/index.json grouping tasks by phaseId', async () => {
|
||||
await writeInputFiles({
|
||||
agentWorkdir: testDir,
|
||||
tasks: [
|
||||
{ id: 't1', name: 'Task A', phaseId: 'ph1', status: 'pending', category: 'execute', type: 'auto', priority: 'medium' } as Task,
|
||||
@@ -140,8 +140,8 @@ describe('writeInputFiles', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('does not write context/index.json when no tasks', () => {
|
||||
writeInputFiles({ agentWorkdir: testDir });
|
||||
it('does not write context/index.json when no tasks', async () => {
|
||||
await writeInputFiles({ agentWorkdir: testDir });
|
||||
expect(existsSync(join(testDir, '.cw', 'input', 'context', 'index.json'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
* Output: .cw/output/ — written by agent during execution
|
||||
*/
|
||||
|
||||
import { mkdirSync, writeFileSync, readdirSync, existsSync } from 'node:fs';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { readdirSync, existsSync, readFileSync } from 'node:fs';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import matter from 'gray-matter';
|
||||
import { nanoid } from 'nanoid';
|
||||
@@ -109,12 +109,12 @@ function formatFrontmatter(data: Record<string, unknown>, body: string = ''): st
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
export function writeInputFiles(options: WriteInputFilesOptions): void {
|
||||
export async function writeInputFiles(options: WriteInputFilesOptions): Promise<void> {
|
||||
const inputDir = join(options.agentWorkdir, '.cw', 'input');
|
||||
mkdirSync(inputDir, { recursive: true });
|
||||
await mkdir(inputDir, { recursive: true });
|
||||
|
||||
// Write expected working directory marker for verification
|
||||
writeFileSync(
|
||||
await writeFile(
|
||||
join(inputDir, '../expected-pwd.txt'),
|
||||
options.agentWorkdir,
|
||||
'utf-8'
|
||||
@@ -122,6 +122,9 @@ export function writeInputFiles(options: WriteInputFilesOptions): void {
|
||||
|
||||
const manifestFiles: string[] = [];
|
||||
|
||||
// Collect all file writes, then flush in parallel
|
||||
const writes: Array<{ path: string; content: string }> = [];
|
||||
|
||||
if (options.initiative) {
|
||||
const ini = options.initiative;
|
||||
const content = formatFrontmatter(
|
||||
@@ -134,13 +137,12 @@ export function writeInputFiles(options: WriteInputFilesOptions): void {
|
||||
},
|
||||
'',
|
||||
);
|
||||
writeFileSync(join(inputDir, 'initiative.md'), content, 'utf-8');
|
||||
writes.push({ path: join(inputDir, 'initiative.md'), content });
|
||||
manifestFiles.push('initiative.md');
|
||||
}
|
||||
|
||||
if (options.pages && options.pages.length > 0) {
|
||||
const pagesDir = join(inputDir, 'pages');
|
||||
mkdirSync(pagesDir, { recursive: true });
|
||||
await mkdir(join(inputDir, 'pages'), { recursive: true });
|
||||
|
||||
for (const page of options.pages) {
|
||||
let bodyMarkdown = '';
|
||||
@@ -162,7 +164,7 @@ export function writeInputFiles(options: WriteInputFilesOptions): void {
|
||||
bodyMarkdown,
|
||||
);
|
||||
const filename = `pages/${page.id}.md`;
|
||||
writeFileSync(join(pagesDir, `${page.id}.md`), content, 'utf-8');
|
||||
writes.push({ path: join(inputDir, 'pages', `${page.id}.md`), content });
|
||||
manifestFiles.push(filename);
|
||||
}
|
||||
}
|
||||
@@ -185,7 +187,7 @@ export function writeInputFiles(options: WriteInputFilesOptions): void {
|
||||
},
|
||||
bodyMarkdown,
|
||||
);
|
||||
writeFileSync(join(inputDir, 'phase.md'), content, 'utf-8');
|
||||
writes.push({ path: join(inputDir, 'phase.md'), content });
|
||||
manifestFiles.push('phase.md');
|
||||
}
|
||||
|
||||
@@ -202,7 +204,7 @@ export function writeInputFiles(options: WriteInputFilesOptions): void {
|
||||
},
|
||||
t.description ?? '',
|
||||
);
|
||||
writeFileSync(join(inputDir, 'task.md'), content, 'utf-8');
|
||||
writes.push({ path: join(inputDir, 'task.md'), content });
|
||||
manifestFiles.push('task.md');
|
||||
}
|
||||
|
||||
@@ -210,8 +212,7 @@ export function writeInputFiles(options: WriteInputFilesOptions): void {
|
||||
const contextFiles: string[] = [];
|
||||
|
||||
if (options.phases && options.phases.length > 0) {
|
||||
const phasesDir = join(inputDir, 'context', 'phases');
|
||||
mkdirSync(phasesDir, { recursive: true });
|
||||
await mkdir(join(inputDir, 'context', 'phases'), { recursive: true });
|
||||
|
||||
for (const ph of options.phases) {
|
||||
let bodyMarkdown = '';
|
||||
@@ -232,14 +233,13 @@ export function writeInputFiles(options: WriteInputFilesOptions): void {
|
||||
bodyMarkdown,
|
||||
);
|
||||
const filename = `context/phases/${ph.id}.md`;
|
||||
writeFileSync(join(phasesDir, `${ph.id}.md`), content, 'utf-8');
|
||||
writes.push({ path: join(inputDir, 'context', 'phases', `${ph.id}.md`), content });
|
||||
contextFiles.push(filename);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.tasks && options.tasks.length > 0) {
|
||||
const tasksDir = join(inputDir, 'context', 'tasks');
|
||||
mkdirSync(tasksDir, { recursive: true });
|
||||
await mkdir(join(inputDir, 'context', 'tasks'), { recursive: true });
|
||||
|
||||
for (const t of options.tasks) {
|
||||
const content = formatFrontmatter(
|
||||
@@ -257,7 +257,7 @@ export function writeInputFiles(options: WriteInputFilesOptions): void {
|
||||
t.description ?? '',
|
||||
);
|
||||
const filename = `context/tasks/${t.id}.md`;
|
||||
writeFileSync(join(tasksDir, `${t.id}.md`), content, 'utf-8');
|
||||
writes.push({ path: join(inputDir, 'context', 'tasks', `${t.id}.md`), content });
|
||||
contextFiles.push(filename);
|
||||
}
|
||||
}
|
||||
@@ -276,17 +276,18 @@ export function writeInputFiles(options: WriteInputFilesOptions): void {
|
||||
status: t.status,
|
||||
});
|
||||
}
|
||||
const contextDir = join(inputDir, 'context');
|
||||
mkdirSync(contextDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(contextDir, 'index.json'),
|
||||
JSON.stringify({ tasksByPhase }, null, 2) + '\n',
|
||||
'utf-8',
|
||||
);
|
||||
await mkdir(join(inputDir, 'context'), { recursive: true });
|
||||
writes.push({
|
||||
path: join(inputDir, 'context', 'index.json'),
|
||||
content: JSON.stringify({ tasksByPhase }, null, 2) + '\n',
|
||||
});
|
||||
}
|
||||
|
||||
// Write manifest listing exactly which files were created
|
||||
writeFileSync(
|
||||
// Flush all file writes in parallel — yields the event loop between I/O ops
|
||||
await Promise.all(writes.map(w => writeFile(w.path, w.content, 'utf-8')));
|
||||
|
||||
// Write manifest last (after all files exist)
|
||||
await writeFile(
|
||||
join(inputDir, 'manifest.json'),
|
||||
JSON.stringify({
|
||||
files: manifestFiles,
|
||||
|
||||
@@ -63,6 +63,8 @@ vi.mock('node:fs/promises', async () => {
|
||||
readFile: vi.fn().mockResolvedValue(''),
|
||||
readdir: vi.fn().mockRejectedValue(new Error('ENOENT')),
|
||||
rm: vi.fn().mockResolvedValue(undefined),
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -225,9 +227,9 @@ describe('MultiProviderAgentManager', () => {
|
||||
const mockChild = createMockChildProcess();
|
||||
mockSpawn.mockReturnValue(mockChild);
|
||||
|
||||
// Mock fs.writeFileSync to capture diagnostic file writing
|
||||
const { writeFileSync } = await import('node:fs');
|
||||
const mockWriteFileSync = vi.mocked(writeFileSync);
|
||||
// Mock fs/promises writeFile to capture diagnostic file writing
|
||||
const { writeFile } = await import('node:fs/promises');
|
||||
const mockWriteFile = vi.mocked(writeFile);
|
||||
|
||||
// The existsSync is already mocked globally to return true
|
||||
|
||||
@@ -238,7 +240,7 @@ describe('MultiProviderAgentManager', () => {
|
||||
});
|
||||
|
||||
// Verify diagnostic file was written
|
||||
const diagnosticCalls = mockWriteFileSync.mock.calls.filter(call =>
|
||||
const diagnosticCalls = mockWriteFile.mock.calls.filter(call =>
|
||||
call[0].toString().includes('spawn-diagnostic.json')
|
||||
);
|
||||
expect(diagnosticCalls).toHaveLength(1);
|
||||
|
||||
@@ -41,8 +41,8 @@ import { buildWorkspaceLayout, buildInterAgentCommunication } from './prompts/in
|
||||
import { getProvider } from './providers/registry.js';
|
||||
import { createModuleLogger } from '../logger/index.js';
|
||||
import { join } from 'node:path';
|
||||
import { unlink, readFile } from 'node:fs/promises';
|
||||
import { existsSync, writeFileSync } from 'node:fs';
|
||||
import { unlink, readFile, writeFile as writeFileAsync } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import type { AccountCredentialManager } from './credentials/types.js';
|
||||
import { ProcessManager } from './process-manager.js';
|
||||
import { CredentialHandler } from './credential-handler.js';
|
||||
@@ -283,7 +283,7 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
|
||||
// 3b. Write input files (after agent creation so we can include agentId/agentName)
|
||||
if (options.inputContext) {
|
||||
writeInputFiles({ agentWorkdir: agentCwd, ...options.inputContext, agentId, agentName: alias });
|
||||
await writeInputFiles({ agentWorkdir: agentCwd, ...options.inputContext, agentId, agentName: alias });
|
||||
log.debug({ alias }, 'input files written');
|
||||
}
|
||||
|
||||
@@ -312,7 +312,7 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
}, 'process environment prepared');
|
||||
|
||||
// 6. Spawn detached subprocess
|
||||
const { pid, outputFilePath, tailer } = this.processManager.spawnDetached(
|
||||
const { pid, outputFilePath, tailer } = await this.processManager.spawnDetached(
|
||||
agentId, alias, command, args, cwd ?? agentCwd, processEnv, providerName, prompt,
|
||||
(event) => this.outputHandler.handleStreamEvent(agentId, event, this.activeAgents.get(agentId)),
|
||||
this.createLogChunkCallback(agentId, alias, 1),
|
||||
@@ -337,7 +337,7 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
accountId: accountId || null,
|
||||
};
|
||||
|
||||
writeFileSync(
|
||||
await writeFileAsync(
|
||||
join(finalCwd, '.cw', 'spawn-diagnostic.json'),
|
||||
JSON.stringify(diagnostic, null, 2),
|
||||
'utf-8'
|
||||
@@ -469,7 +469,7 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
commitSessionNumber = (await this.logChunkRepository.getSessionCount(agentId)) + 1;
|
||||
}
|
||||
|
||||
const { pid, outputFilePath, tailer } = this.processManager.spawnDetached(
|
||||
const { pid, outputFilePath, tailer } = await this.processManager.spawnDetached(
|
||||
agentId, agent.name, command, args, agentCwd, processEnv, provider.name, commitPrompt,
|
||||
(event) => this.outputHandler.handleStreamEvent(agentId, event, this.activeAgents.get(agentId)),
|
||||
this.createLogChunkCallback(agentId, agent.name, commitSessionNumber),
|
||||
@@ -567,7 +567,7 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
sessionNumber = (await this.logChunkRepository.getSessionCount(agentId)) + 1;
|
||||
}
|
||||
|
||||
const { pid, outputFilePath, tailer } = this.processManager.spawnDetached(
|
||||
const { pid, outputFilePath, tailer } = await this.processManager.spawnDetached(
|
||||
agentId, agent.name, command, args, agentCwd, processEnv, provider.name, conversationPrompt,
|
||||
(event) => this.outputHandler.handleStreamEvent(agentId, event, this.activeAgents.get(agentId)),
|
||||
this.createLogChunkCallback(agentId, agent.name, sessionNumber),
|
||||
@@ -753,7 +753,7 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
resumeSessionNumber = (await this.logChunkRepository.getSessionCount(agentId)) + 1;
|
||||
}
|
||||
|
||||
const { pid, outputFilePath, tailer } = this.processManager.spawnDetached(
|
||||
const { pid, outputFilePath, tailer } = await this.processManager.spawnDetached(
|
||||
agentId, agent.name, command, args, agentCwd, processEnv, provider.name, prompt,
|
||||
(event) => this.outputHandler.handleStreamEvent(agentId, event, this.activeAgents.get(agentId)),
|
||||
this.createLogChunkCallback(agentId, agent.name, resumeSessionNumber),
|
||||
|
||||
@@ -16,8 +16,6 @@ vi.mock('node:child_process', () => ({
|
||||
|
||||
// Mock fs operations
|
||||
vi.mock('node:fs', () => ({
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
openSync: vi.fn((path) => {
|
||||
// Return different fd numbers for stdout and stderr
|
||||
if (path.includes('output.jsonl')) return 99;
|
||||
@@ -28,6 +26,11 @@ vi.mock('node:fs', () => ({
|
||||
existsSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Mock FileTailer
|
||||
vi.mock('./file-tailer.js', () => ({
|
||||
FileTailer: class MockFileTailer {
|
||||
@@ -56,13 +59,14 @@ vi.mock('./providers/parsers/index.js', () => ({
|
||||
}));
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { existsSync, writeFileSync, mkdirSync, openSync, closeSync } from 'node:fs';
|
||||
import { existsSync, openSync, closeSync } from 'node:fs';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { ensureProjectClone } from '../git/project-clones.js';
|
||||
|
||||
const mockSpawn = vi.mocked(spawn);
|
||||
const mockExistsSync = vi.mocked(existsSync);
|
||||
const mockWriteFileSync = vi.mocked(writeFileSync);
|
||||
const mockMkdirSync = vi.mocked(mkdirSync);
|
||||
const mockMkdir = vi.mocked(mkdir);
|
||||
const mockWriteFile = vi.mocked(writeFile);
|
||||
const mockOpenSync = vi.mocked(openSync);
|
||||
const mockCloseSync = vi.mocked(closeSync);
|
||||
|
||||
@@ -198,7 +202,7 @@ describe('ProcessManager', () => {
|
||||
mockExistsSync.mockReturnValue(true); // CWD exists
|
||||
});
|
||||
|
||||
it('validates cwd exists before spawn', () => {
|
||||
it('validates cwd exists before spawn', async () => {
|
||||
const agentId = 'agent-123';
|
||||
const agentName = 'test-agent';
|
||||
const command = 'claude';
|
||||
@@ -207,7 +211,7 @@ describe('ProcessManager', () => {
|
||||
const env = { TEST_VAR: 'value' };
|
||||
const providerName = 'claude';
|
||||
|
||||
processManager.spawnDetached(agentId, agentName, command, args, cwd, env, providerName);
|
||||
await processManager.spawnDetached(agentId, agentName, command, args, cwd, env, providerName);
|
||||
|
||||
expect(mockExistsSync).toHaveBeenCalledWith(cwd);
|
||||
expect(mockSpawn).toHaveBeenCalledWith(command, args, {
|
||||
@@ -218,7 +222,7 @@ describe('ProcessManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('throws error when cwd does not exist', () => {
|
||||
it('throws error when cwd does not exist', async () => {
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
|
||||
const agentId = 'agent-123';
|
||||
@@ -229,12 +233,12 @@ describe('ProcessManager', () => {
|
||||
const env = {};
|
||||
const providerName = 'claude';
|
||||
|
||||
expect(() => {
|
||||
processManager.spawnDetached(agentId, agentName, command, args, cwd, env, providerName);
|
||||
}).toThrow('Agent working directory does not exist: /nonexistent/path');
|
||||
await expect(
|
||||
processManager.spawnDetached(agentId, agentName, command, args, cwd, env, providerName),
|
||||
).rejects.toThrow('Agent working directory does not exist: /nonexistent/path');
|
||||
});
|
||||
|
||||
it('passes correct cwd parameter to spawn', () => {
|
||||
it('passes correct cwd parameter to spawn', async () => {
|
||||
const agentId = 'agent-123';
|
||||
const agentName = 'test-agent';
|
||||
const command = 'claude';
|
||||
@@ -243,7 +247,7 @@ describe('ProcessManager', () => {
|
||||
const env = { CLAUDE_CONFIG_DIR: '/config' };
|
||||
const providerName = 'claude';
|
||||
|
||||
processManager.spawnDetached(agentId, agentName, command, args, cwd, env, providerName);
|
||||
await processManager.spawnDetached(agentId, agentName, command, args, cwd, env, providerName);
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledTimes(1);
|
||||
const spawnCall = mockSpawn.mock.calls[0];
|
||||
@@ -260,7 +264,7 @@ describe('ProcessManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('writes prompt file when provided', () => {
|
||||
it('writes prompt file when provided', async () => {
|
||||
const agentId = 'agent-123';
|
||||
const agentName = 'test-agent';
|
||||
const command = 'claude';
|
||||
@@ -270,9 +274,9 @@ describe('ProcessManager', () => {
|
||||
const providerName = 'claude';
|
||||
const prompt = 'Test prompt';
|
||||
|
||||
processManager.spawnDetached(agentId, agentName, command, args, cwd, env, providerName, prompt);
|
||||
await processManager.spawnDetached(agentId, agentName, command, args, cwd, env, providerName, prompt);
|
||||
|
||||
expect(mockWriteFileSync).toHaveBeenCalledWith(
|
||||
expect(mockWriteFile).toHaveBeenCalledWith(
|
||||
'/test/workspace/.cw/agent-logs/test-agent/PROMPT.md',
|
||||
'Test prompt',
|
||||
'utf-8'
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { writeFileSync, mkdirSync, openSync, closeSync, existsSync } from 'node:fs';
|
||||
import { openSync, closeSync, existsSync } from 'node:fs';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import type { ProjectRepository } from '../db/repositories/project-repository.js';
|
||||
import type { AgentProviderConfig } from './providers/types.js';
|
||||
@@ -233,7 +234,7 @@ export class ProcessManager {
|
||||
*
|
||||
* @param onEvent - Callback for stream events from the tailer
|
||||
*/
|
||||
spawnDetached(
|
||||
async spawnDetached(
|
||||
agentId: string,
|
||||
agentName: string,
|
||||
command: string,
|
||||
@@ -244,7 +245,7 @@ export class ProcessManager {
|
||||
prompt?: string,
|
||||
onEvent?: (event: StreamEvent) => void,
|
||||
onRawContent?: (content: string) => void,
|
||||
): { pid: number; outputFilePath: string; tailer: FileTailer } {
|
||||
): Promise<{ pid: number; outputFilePath: string; tailer: FileTailer }> {
|
||||
// Pre-spawn validation and logging
|
||||
const cwdExists = existsSync(cwd);
|
||||
const commandWithArgs = [command, ...args].join(' ');
|
||||
@@ -272,12 +273,12 @@ export class ProcessManager {
|
||||
}
|
||||
|
||||
const logDir = join(this.workspaceRoot, '.cw', 'agent-logs', agentName);
|
||||
mkdirSync(logDir, { recursive: true });
|
||||
await mkdir(logDir, { recursive: true });
|
||||
const outputFilePath = join(logDir, 'output.jsonl');
|
||||
const stderrFilePath = join(logDir, 'stderr.log');
|
||||
|
||||
if (prompt) {
|
||||
writeFileSync(join(logDir, 'PROMPT.md'), prompt, 'utf-8');
|
||||
await writeFile(join(logDir, 'PROMPT.md'), prompt, 'utf-8');
|
||||
}
|
||||
|
||||
const stdoutFd = openSync(outputFilePath, 'w');
|
||||
|
||||
@@ -57,7 +57,7 @@ export class CassetteProcessManager extends ProcessManager {
|
||||
this.replayWorkerPath = new URL('./replay-worker.mjs', import.meta.url).pathname;
|
||||
}
|
||||
|
||||
override spawnDetached(
|
||||
override async spawnDetached(
|
||||
agentId: string,
|
||||
agentName: string,
|
||||
command: string,
|
||||
@@ -68,7 +68,7 @@ export class CassetteProcessManager extends ProcessManager {
|
||||
prompt?: string,
|
||||
onEvent?: (event: StreamEvent) => void,
|
||||
onRawContent?: (content: string) => void,
|
||||
): { pid: number; outputFilePath: string; tailer: FileTailer } {
|
||||
): Promise<{ pid: number; outputFilePath: string; tailer: FileTailer }> {
|
||||
const key: CassetteKey = {
|
||||
normalizedPrompt: normalizePrompt(prompt ?? '', this._workspaceRoot),
|
||||
providerName,
|
||||
@@ -80,7 +80,7 @@ export class CassetteProcessManager extends ProcessManager {
|
||||
const existing = this.cassetteMode !== 'record' ? this.store.find(key) : null;
|
||||
|
||||
if (existing) {
|
||||
const result = this.replayFromCassette(agentId, agentName, cwd, env, providerName, existing, onEvent, onRawContent);
|
||||
const result = await this.replayFromCassette(agentId, agentName, cwd, env, providerName, existing, onEvent, onRawContent);
|
||||
this.pendingReplays.set(result.pid, { cassette: existing, agentCwd: cwd });
|
||||
return result;
|
||||
}
|
||||
@@ -94,7 +94,7 @@ export class CassetteProcessManager extends ProcessManager {
|
||||
|
||||
// auto or record: run the real agent and record the cassette on completion.
|
||||
console.log(`[cassette] recording new cassette for agent '${agentName}' (${providerName})`);
|
||||
const result = super.spawnDetached(agentId, agentName, command, args, cwd, env, providerName, prompt, onEvent, onRawContent);
|
||||
const result = await super.spawnDetached(agentId, agentName, command, args, cwd, env, providerName, prompt, onEvent, onRawContent);
|
||||
this.pendingRecordings.set(result.pid, { key, outputFilePath: result.outputFilePath, agentCwd: cwd });
|
||||
return result;
|
||||
}
|
||||
@@ -230,7 +230,7 @@ export class CassetteProcessManager extends ProcessManager {
|
||||
}
|
||||
}
|
||||
|
||||
private replayFromCassette(
|
||||
private async replayFromCassette(
|
||||
agentId: string,
|
||||
agentName: string,
|
||||
cwd: string,
|
||||
@@ -239,7 +239,7 @@ export class CassetteProcessManager extends ProcessManager {
|
||||
cassette: CassetteEntry,
|
||||
onEvent?: (event: StreamEvent) => void,
|
||||
onRawContent?: (content: string) => void,
|
||||
): { pid: number; outputFilePath: string; tailer: FileTailer } {
|
||||
): Promise<{ pid: number; outputFilePath: string; tailer: FileTailer }> {
|
||||
console.log(`[cassette] replaying cassette for agent '${agentName}' (${cassette.recording.jsonlLines.length} lines)`);
|
||||
|
||||
return super.spawnDetached(
|
||||
|
||||
Reference in New Issue
Block a user