Skip to content

env_fault classifier residuals from #324: an unguarded straddler drop, an unpinned evidence clamp, anchor-drift vacuity, and stale docs #508

Description

@pbean

Residuals from the #324 review. None blocked the merge — #324 is a large net win (opencode had no env-fault classification at all before it) and all of these are edges in new functionality rather than regressions. Filing so they are not lost.

Each item was ablation-verified during review; the first two were found independently by two reviewers.

1. Fix A's "won't discard the only line" guard is defeated by a trailing newline

src/bmad_loop/adapters/env_fault.py:192

if truncated and len(lines) > 1:
    lines = lines[1:]

lines = text.split("\n") yields a trailing "". So a tail window that is one >64 KiB line terminated by a newline splits to [fragment, ""]len(lines) > 1 passes → the fragment is dropped → only "" is scanned → no classification. The byte-identical log without the trailing terminator classifies correctly (which is exactly what tests/test_generic_tmux.py:4422 pins — it deliberately writes no terminator, which is why it is green).

Verified against the real adapter:

input result
single >64K line, no trailing newline env_fault=True
single >64K line, trailing \n env_fault=False
single >64K line, trailing \r env_fault=False

Failure scenario: a CLI's last act before dying is a single >64 KiB payload (a large JSON error body or stack dump) followed by one terminator — the outage goes unclassified, so the run charges a dev attempt and defers the story instead of pausing. That is the outcome fix A's guard exists to prevent.

One-token remedy: if truncated and any(lines[1:]):

Related nit, same block (:192-205): the code cannot distinguish a straddling fragment from a complete line whose first byte happens to land on the seek boundary — it drops lines[0] unconditionally. Narrow, and last-match-wins makes the tail's first line the least likely winner, but the "the straddling fragment is discarded" framing overstates what the code can tell apart.

2. Fix C's "slide left" clamp has no test coverage

src/bmad_loop/adapters/env_fault.py:251

start = max(0, min(match_pos - ENV_FAULT_EVIDENCE_LEAD, usable - budget))

Replacing it with start = max(0, match_pos - ENV_FAULT_EVIDENCE_LEAD) (dropping only the left-slide clamp) leaves the whole affected suite green — 480 passed, 5 xfailed across test_generic_tmux.py, test_env_fault_patterns.py, test_opencode_http.py, test_profile.py, and 933 passed across the other files that mention env_fault. The harness is otherwise live: five sibling ablations each reddened exactly the right tests.

The clamp is what the code's own comment (:246-250) calls the common case, not a corner. Regression it permits: a match sitting within ENV_FAULT_EVIDENCE_LEAD chars of the line end yields a stub excerpt instead of a full 239-char window, silently discarding operator-facing context.

Note tests/test_opencode_http.py:2443 looks like it pins fix C but does not — it asserts only "AI_APICallError" in evidence, which the pre-PR head-truncated excerpt also satisfies. The breadcrumb (:2454) and e2e (:2501) tests are what actually bite.

Suggested pin: in test_classify_env_fault_marks_a_dropped_suffix, add a case with the match near the line end asserting len(ev) == ENV_FAULT_EVIDENCE_MAX.

3. The anchor-vacuity guard checks a hand-maintained literal, not the live pattern

tests/test_env_fault_patterns.py:150PROFILE_ANCHOR_LITERAL is a parallel copy of the profile's anchor, never cross-checked against get_profile(name).env_fault_patterns.

Simulating a realistic upstream drift (opencode renames its ai-sdk error class): change the anchor in opencode.toml to error[.]error="ProviderCallError: and update OPENCODE_REAL + INSEPARABLE_VERBATIM_CITATIONS as a maintainer would, leaving PROFILE_ANCHOR_LITERAL and ANCHOR_REACHING_BAIT on the old literal. Result: 130 passed, 5 xfailed — fully green, while zero ANCHOR_REACHING_BAIT lines reach the real anchor.

That is precisely the vacuity failure the test file's own header (:96-98) narrates having hit once and closed. The reachability guard is real against a corpus edit but not against a pattern edit.

Remedy: derive the anchor from the live pattern, or assert containment, e.g.
assert any(PROFILE_ANCHOR_LITERAL[name].replace(".", "[.]") in p for p in get_profile(name).env_fault_patterns)

4. Stale comments contradict the safety argument

Four sites still describe logs/<task_id>.log as the opencode server's own stdout. Since the readable-logs work, .log is the curated [bmad] transcript (model-writable) and the server sink is <task_id>.server.out:

  • src/bmad_loop/adapters/env_fault.py:10-13 and :86
  • src/bmad_loop/adapters/profile.py:108-110
  • tests/test_env_fault_patterns.py:135"logs/<task_id>.log is the opencode serve process's own stdout/stderr, which the model cannot write to"

The last one is load-bearing and self-contradictory: it is the written justification for the positive characterization test test_verbatim_citation_of_the_real_error_is_inseparable, and it contradicts that same file's header at :18-21, which correctly names .server.out. The code is right; only the prose is wrong — but a reader who trusts it concludes the transcript is model-free, which is the exact stale premise #324 exists to correct.

5. Docs are stale after #324

  • docs/adapter-authoring-guide.md:411 — "Seeded only for claude; empty = inert."
  • docs/FEATURES.md:70 — describes the scan as over a "pane log", "seeded only for claude"

opencode.toml now seeds two patterns, and the opencode adapter scans <task_id>.server.out, not a pane log.

Also: #324 landed with no ## [Unreleased] CHANGELOG entry despite user-visible behavior change (opencode gains env-fault classification; the evidence string format changed).

6. Smaller items

  • The isolation property rests on a single e2e test. Reverting ENV_FAULT_LOG_SUFFIX to .log fails only test_e2e_env_fault_classified_through_run; the seven other opencode env-fault unit tests still pass, because the unit helper resolves through _env_fault_log_path by design. Two class-attribute assertions would keep it pinned if that test is ever skipped or quarantined.
  • Coverage gap toward false negatives. opencode 1.18.2 ships sibling ai-sdk classes (AI_RetryError with maxRetriesExceeded/errorNotRetryable, AI_LoadAPIKeyError). A retry-exhausted outage surfacing as error.error="AI_RetryError: …" is missed by both shipped patterns. Speculative — the author verified only against a captured AI_APICallError outage — but widening the class token produced zero false positives across the review's negative corpus.
  • Eager → lazy pattern compilation (src/bmad_loop/adapters/env_fault.py:124) is a semantic change riding inside the extraction: a regex.error now surfaces during run() teardown rather than at adapter construction, and reassigning adapter.profile post-construction now changes classification. Unreachable via load_profile (patterns are validated at TOML parse time, profile.py:214-219) and nothing in src/ mutates .profile, so benign — noting it because it is not a pure move.
  • RecursionError escapes load_profiles() bare (pre-existing, not from Classify provider quota errors as environment faults on opencode-http #324): a pattern nested ≥ ~500 deep makes regex.compile raise RecursionError, which is neither regex.error nor in CONVERSION_FAULTS. Identical in main before Classify provider quota errors as environment faults on opencode-http #324. One-word fix if wanted: except (regex.error, RecursionError).
  • No total-scan deadline. env_fault.py:211 uses a per-search timeout=2.0, so an operator pattern taking ~1.9 s/line costs lines × patterns × 1.9 s. Documented as a known bound at :58-65; noting for completeness.

Follow-up to #324. See also #323 and the claude-anchor false-positive issue filed alongside this one.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions