1212from agentex .lib .cli .debug import DebugConfig , start_acp_server_debug , start_temporal_worker_debug
1313from agentex .lib .utils .logging import make_logger
1414from agentex .config .agent_manifest import AgentManifest
15+ from agentex .lib .cli .utils .cli_utils import SUBPROCESS_STREAM_LIMIT
1516from agentex .lib .cli .utils .path_utils import (
1617 get_file_paths ,
1718 calculate_uvicorn_target_for_local ,
2324logger = make_logger (__name__ )
2425console = Console ()
2526
27+ # How many consecutive unreadable lines to skip before giving up on the stream.
28+ # Skipping is only known-safe for the limit-overrun case; this bounds the damage
29+ # if some other error repeats without consuming anything.
30+ MAX_CONSECUTIVE_READ_ERRORS = 100
31+
2632
2733class RunError (Exception ):
2834 """An error occurred during agent run"""
@@ -215,6 +221,7 @@ async def start_acp_server(
215221 env = env ,
216222 stdout = asyncio .subprocess .PIPE ,
217223 stderr = asyncio .subprocess .STDOUT ,
224+ limit = SUBPROCESS_STREAM_LIMIT ,
218225 )
219226
220227
@@ -234,23 +241,68 @@ async def start_temporal_worker(
234241 env = env ,
235242 stdout = asyncio .subprocess .PIPE ,
236243 stderr = asyncio .subprocess .STDOUT ,
244+ limit = SUBPROCESS_STREAM_LIMIT ,
237245 )
238246
239247
240248async def stream_process_output (process : asyncio .subprocess .Process , prefix : str ):
241- """Stream process output with prefix"""
249+ """Stream process output with prefix.
250+
251+ This loop is the only reader of the child's stdout pipe. If it ever stops
252+ reading, the pipe fills and the child blocks forever inside ``write()``,
253+ which presents as a silent freeze: 0% CPU, no further logs, no traceback.
254+ So a single unreadable line must never end the loop.
255+ """
242256 try :
243257 if process .stdout is None :
244258 return
259+ consecutive_read_errors = 0
245260 while True :
246- line = await process .stdout .readline ()
261+ try :
262+ line = await process .stdout .readline ()
263+ except ValueError as e :
264+ # readline() raises ValueError when a line exceeds the stream limit.
265+ # In *that* case it has already discarded the line and resumed the
266+ # transport, so skipping it makes guaranteed progress. Any other
267+ # ValueError carries no such guarantee, and retrying it forever would
268+ # spin without draining. We cannot tell the two apart (readline
269+ # flattens LimitOverrunError into a bare ValueError), so bound the
270+ # retries and let the outer handler report the hang risk.
271+ consecutive_read_errors += 1
272+ if consecutive_read_errors > MAX_CONSECUTIVE_READ_ERRORS :
273+ raise
274+ logger .warning (
275+ f"Skipping an unreadable line from { prefix } : { e !r} "
276+ f"(consecutive failure { consecutive_read_errors } /{ MAX_CONSECUTIVE_READ_ERRORS } ). "
277+ f"If this says the chunk exceeded the limit, raise limit= on this "
278+ f"process's create_subprocess_exec."
279+ )
280+ continue
281+
282+ consecutive_read_errors = 0
283+
247284 if not line :
248285 break
249- decoded_line = line .decode ("utf-8" ).rstrip ()
286+
287+ try :
288+ decoded_line = line .decode ("utf-8" ).rstrip ()
289+ except UnicodeDecodeError as e :
290+ logger .warning (f"Dropped an undecodable log line from { prefix } ({ e } )." )
291+ continue
292+
250293 if decoded_line : # Only print non-empty lines
251294 console .print (f"[dim]{ prefix } :[/dim] { decoded_line } " )
252295 except Exception as e :
253- logger .debug (f"Output streaming ended for { prefix } : { e } " )
296+ # The escalation path, including for the re-raise above. Anything reaching
297+ # here ends the loop, so the child is now at risk of blocking on a full pipe.
298+ # Warning rather than debug: this used to be a debug() that make_logger could
299+ # never emit, which is why three freezes produced no clue.
300+ # CancelledError derives from BaseException, so the auto-reload path that
301+ # cancels these tasks passes straight through and is unaffected.
302+ logger .warning (
303+ f"Output streaming for { prefix } stopped on { e !r} . "
304+ f"Nothing is draining its stdout now, so { prefix } will hang once the pipe fills."
305+ )
254306
255307
256308async def run_agent (manifest_path : str , debug_config : "DebugConfig | None" = None ):
0 commit comments