fix: keep agent output streaming alive on an unreadable line, and honor LOG_LEVEL - #509
fix: keep agent output streaming alive on an unreadable line, and honor LOG_LEVEL#509chakrris wants to merge 6 commits into
Conversation
stream_process_output is the only reader of a child's stdout pipe. When
readline() raised on an over-limit line, or the utf-8 decode failed, the loop
exited, nothing drained the pipe, and the child blocked forever inside write()
once 64 KiB accumulated. The agent presented as a silent freeze: 0% CPU, no
further logs, health check dead, no traceback.
Reproduced with an agent emitting an 81,988 character log line against
asyncio's 65,536 byte StreamReader default:
ValueError('Separator is found, but chunk is longer than limit')
Two changes:
- Handle readline() and decode failures per line rather than per loop. The
line is dropped with a warning and streaming continues. readline() already
removes the offending line, or clears the buffer, and resumes the transport
before it raises, so continuing is safe and always makes progress.
- Pass limit=8 MiB when spawning the ACP server and the Temporal worker, so
ordinary large lines stream through instead of being dropped.
The pre-existing handler logged this at debug level, but make_logger pins every
logger to INFO with no env override, so that message could never be emitted.
The one signal that would have explained the freeze was unreachable by
construction. It is now a warning naming the exception.
The debug spawn helpers in cli/debug/debug_handlers.py stream through this same
function, so they can no longer deadlock either. They still use asyncio's
default limit; raising it there needs the constant to live somewhere both
modules can import, which run_handlers cannot provide without a cycle.
Adds a regression test that fails against the previous loop: the child never
exits because the reader stops draining its pipe.
The within-limit test only checked that the child exited, which the dropped case satisfies too, so it would still have passed if the line were discarded or if the limit= arguments were removed from the spawn helpers. - Count marker characters in the captured console output rather than matching the line, since rich wraps long output at terminal width. - Assert the oversized case emits none of them and still streams what follows. - Add a test that both spawn helpers pass limit=SUBPROCESS_STREAM_LIMIT, so the production wiring cannot regress unnoticed.
9906696 to
df7f0ff
Compare
make_logger hardcoded logging.INFO and read no override, so the SDK's log level could not be changed by any configuration. That is not only a missing knob: it made diagnostics already written into the SDK unreachable. The handler that explains why agent output streaming stopped logged at debug level, so the one message that would have identified a frozen worker could never be emitted. Read LOG_LEVEL from the environment, defaulting to INFO so nothing changes for anyone who does not set it. Unprefixed to match the SDK's other variables (ENVIRONMENT, REDIS_URL, AGENT_NAME), and read directly rather than through EnvVarKeys, because environment_variables imports this module. getLevelName returns the string "Level FOO" for an unrecognized name, so an unusable value falls back to INFO rather than being handed to setLevel, where a typo would silently disable logging.
The previous commit caught ValueError from readline() and asserted it was a limit overrun. It only knew an exception had been raised. That mattered beyond the message. Skipping the line is safe only for the overrun case, where readline() has already discarded the line and resumed the transport before raising. readline() flattens LimitOverrunError into a bare ValueError, so the two are indistinguishable at the call site, and any other ValueError that consumes nothing would have spun the loop forever at 100% CPU while still not draining the pipe. That is worse than the freeze being fixed. Now: report the actual exception with repr() and no assumed cause, and bound consecutive failures at MAX_CONSECUTIVE_READ_ERRORS (100). Past that, re-raise so the outer handler reports that nothing is draining the child's stdout. The outer try/except is load-bearing, not vestigial: it is the escalation path for that re-raise, and it still covers console.print failures and non-ValueError transport errors. It does not swallow cancellation, since CancelledError derives from BaseException, so the auto-reload path that cancels these tasks is unaffected (verified). (cherry picked from commit 13fa1f5c28f1687f1f90469c22eba9bccf1bbc82)
…mment The bound and the cancellation behaviour were verified by hand but not pinned by the suite, so both could regress silently. - A reader whose readline() raises without consuming anything must stop after MAX_CONSECUTIVE_READ_ERRORS rather than spinning. This is the case the bound exists for, and it hangs the suite if the bound is removed. - Cancelling the streaming task must raise CancelledError out of it, since the auto-reload path cancels these tasks and swallowing it would hang restarts. Also corrects the outer handler's comment, which said make_logger pins loggers to INFO. That was true when it was written and is no longer, since LOG_LEVEL is now honored. It now records what the handler is for: the escalation path for the bounded re-raise, and why cancellation passes straight through it.
| consecutive_read_errors += 1 | ||
| if consecutive_read_errors > MAX_CONSECUTIVE_READ_ERRORS: | ||
| raise |
There was a problem hiding this comment.
Debug streaming can still freeze
In debug mode, both subprocess helpers retain asyncio's 64 KiB stream limit. A sufficiently large newline-free log entry can therefore cause repeated limit-overrun errors. Once this counter exceeds 100, the only stdout reader stops without terminating the subprocess, so its pipe can fill and freeze the worker. The debug subprocesses need the larger limit too, or progress-making overruns must not count toward the cutoff for non-consuming failures.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agentex/lib/cli/handlers/run_handlers.py
Line: 275-277
Comment:
**Debug streaming can still freeze**
In debug mode, both subprocess helpers retain asyncio's 64 KiB stream limit. A sufficiently large newline-free log entry can therefore cause repeated limit-overrun errors. Once this counter exceeds 100, the only stdout reader stops without terminating the subprocess, so its pipe can fill and freeze the worker. The debug subprocesses need the larger limit too, or progress-making overruns must not count toward the cutoff for non-consuming failures.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Fixed in d0a1c2e. Both debug helpers now pass limit=SUBPROCESS_STREAM_LIMIT, so all four spawn sites use 8 MiB rather than asyncio's default.
The constant moved to cli/utils/cli_utils.py, which imports only typer and rich. It could not live in either handler, since run_handlers imports cli.debug and the reverse would cycle. That import problem is why the debug path was left out of the first revision.
On the alternative you offered, not counting progress-making overruns toward the cutoff: I looked at it and did not take it. readline() reports a limit overrun and any other failure as the same bare ValueError, and the buffer state that would distinguish them is private, so the only signal available is the exception message. Raising the limit everywhere fixes the reachable case without that.
test_every_spawn_uses_the_larger_limit now asserts all four sites, and fails with a spawn is missing limit=: [8388608, 8388608, 8388608, None] if one is added on the default.
The retry bound counts every consecutive ValueError, including limit overruns, which do make progress. That is harmless where the limit is 8 MiB, but the debug spawns were left on asyncio's 64 KiB default, so an agent emitting enough consecutive large lines under --debug exhausts the bound, the reader re-raises, and the child deadlocks on a full pipe. The two changes were individually defensible and together reintroduced the bug being fixed. Move SUBPROCESS_STREAM_LIMIT to cli/utils/cli_utils.py, which imports only typer and rich, so both handlers can share it. It could not live in either handler: run_handlers imports cli.debug, so the reverse import would cycle. Extends the wiring test to all four spawn sites rather than two, so a new one cannot be added on the default limit without failing.
Summary
agentex agents runcould leave an agent's worker frozen with no error, no traceback and no CPU use. Two defects combined to make that possible and then to make it undiagnosable, so both are fixed here.The failure
An agent emitted an 81,988 character log line. asyncio's
StreamReaderdefaults to 65,536 bytes, soreadline()raised:stream_process_outputis the only reader of the child's stdout pipe. Theexcept Exceptioncaught that, the loop exited, and 64 KiB later the child was wedged in awrite()syscall. From the outside: 0% CPU, log output stops mid-run, health check stops answering, no traceback. A stack dump of the frozen process shows the main thread parked in_io_FileIO_writeinside a logging call.The handler did log the cause, with
logger.debug. Butmake_loggerpinned every logger toINFOand read no override, so that message could not be emitted on any configuration. The one signal that would have explained the freeze was unreachable by construction, which is why three separate freezes produced no clue.What changed
src/agentex/lib/cli/handlers/run_handlers.pyreadline()and decode failures are handled per line rather than per loop, so one bad line cannot end the stream.ValueErrorhandler does not assume the cause. Skipping is only known-safe for a limit overrun, wherereadline()has already discarded the line and resumed the transport.readline()flattensLimitOverrunErrorinto a bareValueError, so the two are indistinguishable at the call site, and any otherValueErrorconsumes nothing. Retrying that forever would spin at 100% CPU while still not draining the pipe, which is worse than the freeze being fixed. Consecutive failures are bounded atMAX_CONSECUTIVE_READ_ERRORS(100), then re-raised.try/exceptis load-bearing rather than vestigial: it is the escalation path for that re-raise, it still coversconsole.printfailures and non-ValueErrortransport errors, and it names the consequence. It does not swallow cancellation, sinceCancelledErrorderives fromBaseException, so the auto-reload path that cancels these tasks is unaffected.limit=SUBPROCESS_STREAM_LIMIT(8 MiB), so ordinary large lines stream through instead of being dropped. Agents legitimately emit them: serialized charts, payloads echoed back by validation errors.SUBPROCESS_STREAM_LIMITlives incli/utils/cli_utils.py, which imports only typer and rich, becauserun_handlersimportscli.debugand so neither handler can export it to the other.src/agentex/lib/utils/logging.pymake_loggerreadsLOG_LEVELfrom the environment, defaulting toINFOso nothing changes for anyone who does not set it.ENVIRONMENT,REDIS_URL,AGENT_NAME), and read directly rather than throughEnvVarKeys, becauseenvironment_variablesimports this module and the reverse would be a cycle.getLevelNamereturns the string"Level FOO"for an unrecognized name, so an unusable value falls back toINFOrather than being handed tosetLevel, where a typo would silently disable logging.Review guide
Start with
stream_process_output, and specifically theValueErrorbranch. The interesting question there is not whether skipping a line is safe, but that the handler cannot tell whether it is:readline()reports a limit overrun and any other failure as the same bareValueError. The bound is what makes the unsafe case survivable rather than an infinite spin.resolve_log_levelinlogging.pyis the other crux. Worth knowing thatmake_loggerhas 94 call sites, so this does change behaviour for anyone who already setsLOG_LEVELfor their own application: they will now see SDK logs at that level. KeepingINFOas the default means no change for anyone who does not set it.SUBPROCESS_STREAM_LIMITis 8 MiB, which is a judgement call: large enough for the payload-shaped lines that caused this, small enough to bound memory per line. Happy to change it.Tests are in
tests/lib/cli/test_run_handlers_streaming.pyandtests/lib/utils/test_logging_level.py, and each pins a behaviour that fails against the previous code: the child never exits because the reader stops draining its pipe; a reader that raises without consuming spins forever instead of giving up at the bound; cancelling the streaming task must still raise out of it; and the logger seesINFOwhereDEBUGwas configured.The limit and the retry bound have to stay in step, which is the one non-obvious coupling here. The bound counts every consecutive
ValueError, including overruns that do make progress. That is harmless at 8 MiB and dangerous at 64 KiB: a spawn left on the default overruns easily enough that a burst of large lines can exhaust the bound, stop the reader, and deadlock the child. An earlier revision of this PR had exactly that gap, because the debug spawns kept the default.test_every_spawn_uses_the_larger_limitcovers all four sites so a new spawn cannot be added on the default without failing.Greptile Summary
This revision completes the large-line streaming fix by applying the shared 8 MiB stream limit to both debug subprocess helpers and extending the regression test across all four spawn sites.
SUBPROCESS_STREAM_LIMITinto a dependency-safe shared CLI utility module.LOG_LEVEL, withINFOas the fallback for missing or invalid values.Confidence Score: 5/5
The PR appears safe to merge; the previously reported debug streaming freeze is fully addressed and no new actionable failures remain.
Both debug subprocess helpers now pass the enlarged stream limit, and the regression test verifies all four normal and debug spawn paths. The shared constant introduces no import cycle, while the logging and per-line streaming changes retain safe defaults and cancellation behavior.
Important Files Changed
LOG_LEVELwhile safely defaulting missing or invalid values toINFO.Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart TD A[Spawn agent subprocess] --> B[StreamReader with 8 MiB limit] B --> C{readline result} C -->|Valid bytes| D{UTF-8 decoding succeeds?} D -->|Yes| E[Print prefixed output] D -->|No| F[Warn and continue] C -->|ValueError| G[Increment consecutive error count] G --> H{More than 100?} H -->|No| B H -->|Yes| I[Warn that streaming stopped] E --> B F --> B C -->|EOF| J[Finish streaming]Reviews (4): Last reviewed commit: "Address greptile: give the debug spawns ..." | Re-trigger Greptile