From 015f62a73b36642df779444c30fa4c86df94dca0 Mon Sep 17 00:00:00 2001 From: Layne Penney Date: Sun, 25 Jan 2026 19:57:56 -0600 Subject: [PATCH] fix(orchestrate): graceful IPC disconnect to prevent race condition The IPC client was using socket.destroy() which immediately closes the socket without waiting for pending writes to complete. This caused a race condition where: 1. Child agent sends task_complete message 2. Child immediately calls disconnect() which destroys the socket 3. Server receives 'close' event before processing the completion message 4. Reader/worker marked as "disconnected unexpectedly" Fix: Use socket.end() with a drain wait and 1-second timeout to ensure pending writes complete before closing the connection. Also fixes non-interactive mode (-P flag) where readline was being created and immediately closing, triggering premature exit before the prompt could be processed. Co-Authored-By: Claude Opus 4.5 --- src/index.ts | 7 +++++-- src/orchestrate/ipc/client.ts | 26 +++++++++++++++++++++++--- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/index.ts b/src/index.ts index 290f3d9..4bf2ee7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3394,7 +3394,10 @@ Begin by analyzing the query and planning your research approach.`; console.log(chalk.dim('\nGoodbye!')); }; - if (!useInkUi) { + // Skip readline creation for non-interactive mode + const isNonInteractive = Boolean(options.prompt); + + if (!useInkUi && !isNonInteractive) { // Enable bracketed paste mode for better paste detection enableBracketedPaste(); @@ -3448,7 +3451,7 @@ Begin by analyzing the query and planning your research approach.`; rl.on('error', (err) => { logger.error(`Readline error: ${err.message}`, err); }); - } else { + } else if (useInkUi) { exitApp = () => { inkController?.requestExit(); }; diff --git a/src/orchestrate/ipc/client.ts b/src/orchestrate/ipc/client.ts index 53d8154..e3afe67 100644 --- a/src/orchestrate/ipc/client.ts +++ b/src/orchestrate/ipc/client.ts @@ -154,13 +154,33 @@ export class IPCClient extends EventEmitter { /** * Disconnect from the server. + * Waits for pending writes to complete before closing the socket. */ async disconnect(): Promise { - if (this.socket) { - this.socket.destroy(); - this.socket = null; + if (!this.socket) { + this.connected = false; + return; } + + const socket = this.socket; + this.socket = null; this.connected = false; + + // Wait for pending writes to drain, then close gracefully + await new Promise((resolve) => { + const timeout = setTimeout(() => { + socket.destroy(); + resolve(); + }, 1000); // Timeout after 1 second + + socket.once('close', () => { + clearTimeout(timeout); + resolve(); + }); + + // End gracefully - allows pending writes to complete + socket.end(); + }); } /**