From 2eaeedaceb6934b831dce424f7fc27af476dab45 Mon Sep 17 00:00:00 2001 From: Justin Katz Date: Fri, 3 Apr 2026 13:13:31 -0400 Subject: [PATCH] fix: Hook stdin reader hangs on parallel agent completions (#1021) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs in PAI hook stdin reading code cause intermittent hangs when multiple agents complete simultaneously (e.g., council debates): 1. hook-io.ts: Dangling Bun stdin reader after Promise.race timeout - Added reader.cancel() after timeout, increased timeout 500ms→2000ms 2. PRDSync.hook.ts: Synchronous readFileSync(0) blocks indefinitely - Converted to async Bun.stdin.stream() with 2s timeout + cancel 3. IntegrityCheck.hook.ts: Same dangling reader as hook-io.ts - Added reader.cancel() + timeout increase Closes #1021 --- .../.claude/hooks/IntegrityCheck.hook.ts | 13 +++-- Releases/v4.0.3/.claude/hooks/PRDSync.hook.ts | 55 ++++++++++++++++--- Releases/v4.0.3/.claude/hooks/lib/hook-io.ts | 9 ++- 3 files changed, 62 insertions(+), 15 deletions(-) diff --git a/Releases/v4.0.3/.claude/hooks/IntegrityCheck.hook.ts b/Releases/v4.0.3/.claude/hooks/IntegrityCheck.hook.ts index b66851269..8980de8c9 100755 --- a/Releases/v4.0.3/.claude/hooks/IntegrityCheck.hook.ts +++ b/Releases/v4.0.3/.claude/hooks/IntegrityCheck.hook.ts @@ -19,21 +19,26 @@ interface HookInput { } async function readStdin(): Promise { + let reader: ReadableStreamDefaultReader | null = null; try { const decoder = new TextDecoder(); - const reader = Bun.stdin.stream().getReader(); + reader = Bun.stdin.stream().getReader(); let input = ''; - const timeout = new Promise(r => setTimeout(r, 500)); + const timeout = new Promise(r => setTimeout(r, 2000)); const read = (async () => { while (true) { - const { done, value } = await reader.read(); + const { done, value } = await reader!.read(); if (done) break; input += decoder.decode(value, { stream: true }); } })(); await Promise.race([read, timeout]); + reader.cancel().catch(() => {}); if (input.trim()) return JSON.parse(input) as HookInput; - } catch {} + } catch (err) { + console.error('[IntegrityCheck] readStdin:', err); + if (reader) reader.cancel().catch(() => {}); + } return null; } diff --git a/Releases/v4.0.3/.claude/hooks/PRDSync.hook.ts b/Releases/v4.0.3/.claude/hooks/PRDSync.hook.ts index 8de6c6ed7..9606a9edc 100755 --- a/Releases/v4.0.3/.claude/hooks/PRDSync.hook.ts +++ b/Releases/v4.0.3/.claude/hooks/PRDSync.hook.ts @@ -12,6 +12,7 @@ */ import { readFileSync, existsSync } from 'fs'; +import { join } from 'path'; import { parseFrontmatter, syncToWorkJson, @@ -21,15 +22,36 @@ import { setPhaseTab } from './lib/tab-setter'; import type { AlgorithmTabPhase } from './lib/tab-constants'; let input: any; -try { - input = JSON.parse(readFileSync(0, 'utf-8')); -} catch { - process.exit(0); -} +let responded = false; -const toolInput = input.tool_input || {}; +async function readStdin(): Promise { + let reader: ReadableStreamDefaultReader | null = null; + try { + const decoder = new TextDecoder(); + reader = Bun.stdin.stream().getReader(); + let raw = ''; + const timeout = new Promise(r => setTimeout(r, 2000)); + const read = (async () => { + while (true) { + const { done, value } = await reader!.read(); + if (done) break; + raw += decoder.decode(value, { stream: true }); + } + })(); + await Promise.race([read, timeout]); + reader.cancel().catch(() => {}); + if (raw.trim()) return JSON.parse(raw); + } catch (err) { + console.error('[PRDSync] readStdin:', err); + if (reader) reader.cancel().catch(() => {}); + } + return null; +} async function main() { + input = await readStdin(); + if (!input) process.exit(0); + const toolInput = input.tool_input || {}; // Only trigger for PRD.md files in MEMORY/WORK/ const filePath = toolInput.file_path || ''; if (!filePath.includes('MEMORY/WORK/') || !filePath.endsWith('PRD.md')) return; @@ -52,7 +74,24 @@ async function main() { const registry = readRegistry(); const existing = registry.sessions[fm.slug]; if (existing) oldPhase = (existing.phase || '').toUpperCase(); - } catch { /* silent */ } + } catch (err) { console.error('[PRDSync] readRegistry skipping (silent):', err); } + } + + // Gate: when phase transitions to COMPLETE, check for reflection JSONL (before sync overwrites oldPhase) + if (newPhase === 'COMPLETE' && oldPhase !== 'COMPLETE' && fm.slug) { + const paiDir = process.env.PAI_DIR || join(process.env.HOME!, '.claude'); + const reflPath = join(paiDir, 'MEMORY', 'LEARNING', 'REFLECTIONS', 'algorithm-reflections.jsonl'); + let hasReflection = false; + if (existsSync(reflPath)) { + hasReflection = readFileSync(reflPath, 'utf-8').includes(fm.slug); + } + if (!hasReflection) { + console.log(JSON.stringify({ + continue: true, + additionalContext: `\n⚠️ REFLECTION MISSING: PRD "${fm.slug}" set to phase: complete but NO reflection entry in algorithm-reflections.jsonl. Write the reflection JSONL now. Do not end this session without it.\n` + })); + responded = true; + } } // Sync frontmatter + criteria to work.json (pass session_id for session name lookup) @@ -71,6 +110,6 @@ async function main() { } main().catch(() => {}).finally(() => { - console.log(JSON.stringify({ continue: true })); + if (!responded) console.log(JSON.stringify({ continue: true })); process.exit(0); }); diff --git a/Releases/v4.0.3/.claude/hooks/lib/hook-io.ts b/Releases/v4.0.3/.claude/hooks/lib/hook-io.ts index 2025c61b9..d06ebe4aa 100755 --- a/Releases/v4.0.3/.claude/hooks/lib/hook-io.ts +++ b/Releases/v4.0.3/.claude/hooks/lib/hook-io.ts @@ -20,29 +20,32 @@ export interface HookInput { * Returns null if stdin is empty or malformed. */ export async function readHookInput(): Promise { + let reader: ReadableStreamDefaultReader | null = null; try { const decoder = new TextDecoder(); - const reader = Bun.stdin.stream().getReader(); + reader = Bun.stdin.stream().getReader(); let input = ''; const timeoutPromise = new Promise((resolve) => { - setTimeout(() => resolve(), 500); + setTimeout(() => resolve(), 2000); }); const readPromise = (async () => { while (true) { - const { done, value } = await reader.read(); + const { done, value } = await reader!.read(); if (done) break; input += decoder.decode(value, { stream: true }); } })(); await Promise.race([readPromise, timeoutPromise]); + reader.cancel().catch(() => {}); if (input.trim()) { return JSON.parse(input) as HookInput; } } catch (error) { + if (reader) reader.cancel().catch(() => {}); console.error('[hook-io] Error reading stdin:', error); } return null;