Skip to content

fix(harness): bound CLI output capture (16MB/stream, env-tunable) - #903

Merged
AbirAbbas merged 1 commit into
mainfrom
fix/bound-harness-stdout
Aug 10, 2026
Merged

fix(harness): bound CLI output capture (16MB/stream, env-tunable)#903
AbirAbbas merged 1 commit into
mainfrom
fix/bound-harness-stdout

Conversation

@AbirAbbas

Copy link
Copy Markdown
Contributor

Summary

run_cli buffered a CLI child's entire stdout/stderr with no bound. By the time a provider is done with one call, the stream exists ~three times over (raw chunk list, joined str, parsed JSONL event list on RawResult.messages) — and concurrent harness calls multiply that. This was one contributing layer in the pr-af node OOM (Agent-Field/pr-af#65, fixed on the pr-af side by Agent-Field/pr-af#68): ~16 concurrent opencode subprocesses, each buffered in full, on one container.

Change

  • Per-stream capture cap in run_cli, env-tunable via AGENTFIELD_HARNESS_MAX_OUTPUT_BYTES (default 16 MB, <= 0 disables). Real provider streams are completion-boundary JSONL events (hundreds of KB), so normal runs are byte-identical — the cap only bites pathological streams.
  • On overflow, a _BoundedChunks accumulator keeps the head (25% — session/model info, first error) and the tail (75% — the final result and cumulative-usage events), splicing a truncation marker line at the seam. The marker and any partial seam line are invalid JSON, which parse_jsonl already skips, so extract_final_text / token extraction keep working on truncated output. The newest chunk is always retained even if it alone exceeds the tail budget.
  • A warning is logged (stream name, cap, dropped bytes, command) whenever truncation occurs.

Applies to every CLI-based provider that goes through run_cli (opencode, codex, gemini, claude), not just opencode.

Validation contract → tests (tests/test_harness_cli.py)

  • Below the cap: joined output is byte-identical; no marker → test_bounded_chunks_is_byte_identical_below_cap, test_run_cli_no_truncation_below_cap
  • Above the cap: head start + tail end survive around a marker; total bounded → test_bounded_chunks_keeps_head_and_tail_above_cap
  • Final JSONL result event survives truncation and is still extracted → test_run_cli_truncation_preserves_final_jsonl_events
  • Oversized newest chunk is never evicted → test_bounded_chunks_retains_newest_chunk_even_when_oversized
  • <= 0 disables the cap → test_bounded_chunks_cap_disabled_when_nonpositive

Ran locally on Python 3.11: ruff check . (pinned 0.15.22) clean, full ./scripts/run_pytest.sh green.


🤖 Generated with Claude Code

run_cli buffered a child's entire stdout/stderr unbounded — chunk list,
joined str, and (in providers) the parsed JSONL event list all live at
once, so a runaway stream is held in memory several times over, and N
concurrent harness calls multiply that. This was one contributing layer in
pr-af's OOM crash (Agent-Field/pr-af#65): 16 concurrent opencode calls,
each buffering everything, on one container.

Capture is now bounded per stream via AGENTFIELD_HARNESS_MAX_OUTPUT_BYTES
(default 16MB, <=0 disables). Real provider streams are completion-boundary
events (hundreds of KB), so normal runs stay byte-identical. On overflow
the head (session/model info, first error) and the tail (final result +
cumulative usage events) are kept around a truncation marker line; the
marker and any partial seam line parse as invalid JSON, which parse_jsonl
already skips, so extract_final_text and token extraction keep working on a
truncated stream.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Performance

SDK Memory Δ Latency Δ Tests Status
Python 9.0 KB - 0.32 µs -9%

✓ No regressions detected

@github-actions

Copy link
Copy Markdown
Contributor

📊 Coverage gate

Thresholds from .coverage-gate.toml: per-surface ≥ 84%, aggregate ≥ 85%, max per-surface regression ≤ 1.0 pp, max aggregate regression ≤ 0.50 pp.

Surface Current Baseline Δ
control-plane 87.10% 87.40% ↓ -0.30 pp 🟡
sdk-go 92.70% 92.00% ↑ +0.70 pp 🟢
sdk-python 94.18% 93.73% ↑ +0.45 pp 🟢
sdk-typescript 91.19% 90.42% ↑ +0.77 pp 🟢
web-ui 84.75% 84.79% ↓ -0.04 pp 🟡
aggregate 85.62% 85.75% ↓ -0.13 pp 🟡

✅ Gate passed

No surface regressed past the allowed threshold and the aggregate stayed above the floor.

@github-actions

Copy link
Copy Markdown
Contributor

📐 Patch coverage gate

Threshold: 80% on lines this PR touches vs origin/main (from .coverage-gate.toml:thresholds.min_patch).

Surface Touched lines Patch coverage Status
control-plane 0 ➖ no changes
sdk-go 0 ➖ no changes
sdk-python 0 ➖ no changes
sdk-typescript 0 ➖ no changes
web-ui 0 ➖ no changes

✅ Patch gate passed

Every surface whose lines were touched by this PR has patch coverage at or above the threshold.

@AbirAbbas
AbirAbbas merged commit 33979de into main Aug 10, 2026
32 checks passed
@AbirAbbas
AbirAbbas deleted the fix/bound-harness-stdout branch August 10, 2026 17:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant