From bd0aec44996fa93bda2ffdf13385c84ec0b9a40d Mon Sep 17 00:00:00 2001 From: Lukas May Date: Wed, 4 Mar 2026 12:15:31 +0100 Subject: [PATCH] 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. --- apps/server/agent/file-io.test.ts | 28 +++++------ apps/server/agent/file-io.ts | 53 ++++++++++---------- apps/server/agent/manager.test.ts | 10 ++-- apps/server/agent/manager.ts | 16 +++--- apps/server/agent/process-manager.test.ts | 36 +++++++------ apps/server/agent/process-manager.ts | 11 ++-- apps/server/test/cassette/process-manager.ts | 12 ++--- 7 files changed, 87 insertions(+), 79 deletions(-) diff --git a/apps/server/agent/file-io.test.ts b/apps/server/agent/file-io.test.ts index 7eb4dc3..62d1b5e 100644 --- a/apps/server/agent/file-io.test.ts +++ b/apps/server/agent/file-io.test.ts @@ -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); }); }); diff --git a/apps/server/agent/file-io.ts b/apps/server/agent/file-io.ts index 468e1f8..6f07b13 100644 --- a/apps/server/agent/file-io.ts +++ b/apps/server/agent/file-io.ts @@ -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, body: string = ''): st return lines.join('\n') + '\n'; } -export function writeInputFiles(options: WriteInputFilesOptions): void { +export async function writeInputFiles(options: WriteInputFilesOptions): Promise { 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, diff --git a/apps/server/agent/manager.test.ts b/apps/server/agent/manager.test.ts index 91f6360..5781477 100644 --- a/apps/server/agent/manager.test.ts +++ b/apps/server/agent/manager.test.ts @@ -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); diff --git a/apps/server/agent/manager.ts b/apps/server/agent/manager.ts index a837848..85a52fc 100644 --- a/apps/server/agent/manager.ts +++ b/apps/server/agent/manager.ts @@ -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), diff --git a/apps/server/agent/process-manager.test.ts b/apps/server/agent/process-manager.test.ts index ef254bb..4bd39e4 100644 --- a/apps/server/agent/process-manager.test.ts +++ b/apps/server/agent/process-manager.test.ts @@ -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' diff --git a/apps/server/agent/process-manager.ts b/apps/server/agent/process-manager.ts index 5117a02..144b348 100644 --- a/apps/server/agent/process-manager.ts +++ b/apps/server/agent/process-manager.ts @@ -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'); diff --git a/apps/server/test/cassette/process-manager.ts b/apps/server/test/cassette/process-manager.ts index 81b747b..ac556ae 100644 --- a/apps/server/test/cassette/process-manager.ts +++ b/apps/server/test/cassette/process-manager.ts @@ -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(