fix: stop losing a failed mutation's entire token accounting - #73
Open
KE7 wants to merge 3 commits into
Open
Conversation
`str.splitlines()` breaks on eight boundaries a newline-delimited format
does not define: U+000B, U+000C, U+001C-U+001E, U+0085 (NEL), U+2028 and
U+2029. JSON leaves the last three unescaped inside a string literal --
RFC 8259 only requires escaping C0 controls -- and neither `serde_json`
(Codex) nor `JSON.stringify` (the Node CLIs) escapes them. So a single
NEL byte inside one agent message, or inside one blob of captured command
output, turns a valid JSONL record into two invalid fragments.
`_parse_jsonl_output` raises `MutationError` on the first fragment when
the backend exited 0 (`strict=True`), which loses the whole mutation.
Observed in a real run: one generation's stdout contained a few raw
U+0085 bytes inside the `aggregated_output` of a `command_execution`
record that had captured binary data from a `curl`. `splitlines()`
produced fragments that failed `json.loads`; on LF-only splitting every
record parsed. The run log showed the resulting failure:
HELIX ERROR - Failed to parse Codex CLI JSONL output line
Operation: mutate | Phase: JSON parsing | Exit code: 0
Mutation failed -- skipping.
The agent had run for most of an hour; its work and its accounting were
both discarded. (The accounting half is fixed separately in the next
commit.)
Adds `helix.lines.split_lf_lines` so the invariant has one name, one
docstring and one test, and applies it at the twelve sites where the
line boundary is defined by the format rather than by human authorship:
mutator.py `_parse_jsonl_output` (the site above), the five per-backend
transcript tool-event counters, and the `HELIX_RESULT=`
stripper that builds mutation prompts
executor.py, parsers/helix_result.py
the paired `HELIX_RESULT=` reverse scans -- a payload with
U+2028 in evaluator side info was truncated mid-JSON
sandbox.py the JSONL fallback in `_extract_session_id_from_json_output`
config.py, evolution.py
JSONL dataset readers; a fragmented line raised
`JSONDecodeError` in one and inflated the example-id count
in the other, desynchronising HELIX's ids from the
evaluator's own dataset indexing
Deliberately left on `splitlines()`, since breaking on any Unicode line
boundary is either correct or harmless there: `config.py`'s dotenv reader
(human-authored, wants CRLF tolerance), the two git-output readers in
`worktree.py` / `cli.py`, `cli.py`'s `.gitignore` membership check, and
`asi.py`'s ASI log reader -- whose writer uses `ensure_ascii=True`, so
that file is ASCII by construction. A comment now records that reason at
the `asi.py` site.
Tests: `tests/unit/test_lines.py`. All seven behavioural assertions fail
against `splitlines()` and pass against `split("\n")`, verified by
temporarily reverting the helper body.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLHEwgZV983irkXWSXz2Vd
… parse `budget.charge_llm_usage` was reachable only from the success paths of the mutate and merge operators. `invoke_claude_code` raises `MutationError` when the strict parse of backend output fails, `mutate`/`merge` catch it, remove the worktree and return `None`, and `MutationFailedProposal` had no field for usage to travel in -- so the apply phase had nothing to charge. Every token an attempt spent before failing was discarded along with the candidate. Token usage is a fact about work the backend already did. It cannot be conditional on whether that work produced something usable. Observed in a real run: one generation's entire usage -- the most expensive generation of that run -- was missing from `state.json`'s budget because its JSONL output failed to parse (root cause fixed in the previous commit), with nothing in the run output saying so. The loss was confirmed three ways, all agreeing: the orphaned `turn.completed` record preserved in `.helix/helix.log`, the Codex rollout for that thread in the agent auth volume, and the residual against `state.json`'s reported total. The fix, in four parts: * `_salvage_backend_usage` recovers usage from raw stdout with a parse that cannot raise -- JSONL backends reuse `_parse_jsonl_output` in its existing `strict=False` mode, Claude's object mode falls back to the same line scan. `invoke_claude_code` calls it immediately after the subprocess returns, before anything that can fail. * `HelixError.usage` carries that record out on every error raised from `invoke_claude_code`, and `format_full` / `print_helix_error` now print it. `None` (no invocation) stays distinct from a zero-token `UsageStats` (an invocation that reported nothing). * `mutate` and `merge` take a `record_usage` sink, called once with the usage whether or not a candidate comes back. Their return contracts are unchanged -- `mutate` still returns `None` on `MutationError`. * `MutationFailedProposal` gains `child_usage`; the worker fills it from a worker-local list, and the sequential apply phase charges it with `source="mutation_failed"`. Merge failures charge with `source="merge_failed"`. Charging stays in the apply phase, so the serialization invariant documented at the top of `budget.py` holds. Rate-limited invocations get the same handoff -- a run can burn tokens before the limit trips. `.helix_backend_result.json` also now records the recovered usage instead of zeros when the strict parse failed, so the on-disk artifact stays a faithful account of the invocation. Tests: 11 new. `tests/unit/test_budget.py` feeds a malformed JSONL line to each JSONL backend and asserts the budget still records the usage, pins the recovered usage against a strict parse of the same stream minus the junk line, and covers the artifact and the None-vs-zero distinction. `tests/unit/test_mutator.py` covers the sink on the success, failure, rate-limit and no-invocation paths plus `_salvage_backend_usage` directly. `tests/unit/test_evolution.py` asserts end to end through `run_evolution` that a failed mutation produces exactly one `mutation_failed` charge and that a failure with no usage produces none. All 11 fail against the pre-fix source. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NLHEwgZV983irkXWSXz2Vd
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NLHEwgZV983irkXWSXz2Vd
KE7
force-pushed
the
fix/charge-usage-before-parse
branch
from
September 10, 2026 00:36
c58e9bb to
d2e9e9c
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two fixes, one story: a Unicode line-splitting bug that fragments backend JSONL, and the accounting hole it exposed — HELIX silently loses a failed attempt's entire token usage.
How it surfaced
In a codex run, one mutation failed with:
That agent had run for most of an hour and was the most expensive step of its run. None of its tokens reached
state.json's budget, and nothing in the run output said so. The loss was confirmed three independent ways: the orphanedturn.completedrecord preserved in the failed invocation's stdout in.helix/helix.log, the Codex rollout file for that thread in the agent auth volume, and the residual between the whole-run total andstate.json's own reported total. All three agreed exactly.Mechanism
Root cause (commit 1).
_parse_jsonl_outputsplit stdout withstr.splitlines(). That breaks on eight boundaries a newline-delimited format does not define, including U+0085 (NEL), U+2028 and U+2029. JSON leaves those three unescaped inside a string literal — RFC 8259 only requires escaping C0 controls — and neitherserde_json(Codex) norJSON.stringify(the Node CLIs) escapes them.The failed invocation's stdout carried a few raw U+0085 bytes inside the
aggregated_outputof onecommand_executionrecord that had captured binary data from acurl. Replaying that exact stdout:splitlines()produced fragments that failedjson.loads;split("\n")parsed every record. With exit code 0 the parse is strict, so the first bad fragment raisedMutationErrorand the mutation was lost.The accounting hole (commit 2).
budget.charge_llm_usagewas reachable only from the success paths.invoke_claude_coderaises,mutatecatches, removes the worktree and returnsNone, andMutationFailedProposalhad no field for usage to travel in — so the apply phase had nothing to charge. Every token the attempt spent went out with the candidate. The under-report is proportional: several such failures in a run lose several generations of accounting.The parse bug is fixed, but it is not the only way a strict parse can fail, so the accounting is made independent of it too.
The fix
Commit 1 —
fix(mutator): split backend JSONL on LF onlyAdds
helix.lines.split_lf_linesso the invariant has one name, one docstring and one test, and applies it at the twelve sites where the line boundary is defined by the format:mutator.py:_parse_jsonl_output, the five per-backend transcript tool-event counters, and theHELIX_RESULT=stripper that builds mutation promptsexecutor.py+parsers/helix_result.py: the pairedHELIX_RESULT=reverse scans — a payload with U+2028 in evaluator side info was truncated mid-JSONsandbox.py: the JSONL fallback in_extract_session_id_from_json_outputconfig.py+evolution.py: the JSONL dataset readers — a fragmented line raisedJSONDecodeErrorin one and inflated the example-id count in the other, desynchronising HELIX's ids from the evaluator's own dataset indexingNot a blanket replace. Five
splitlines()calls are deliberately left alone, since breaking on any Unicode line boundary is either correct or harmless there: the dotenv reader (human-authored, wants CRLF tolerance), the two git-output readers, the.gitignoremembership check, andasi.py's ASI log reader — whose writer usesensure_ascii=True, so that file is ASCII by construction. A comment now records that reason at theasi.pysite.Commit 2 —
fix(budget): charge backend token usage even when the output fails to parseToken usage is a fact about work the backend already did. It must not be conditional on whether that work produced something usable.
_salvage_backend_usagerecovers usage from raw stdout with a parse that cannot raise — JSONL backends reuse_parse_jsonl_outputin its existingstrict=Falsemode, Claude's object mode falls back to the same line scan.invoke_claude_codecalls it immediately after the subprocess returns, before anything that can fail.HelixError.usagecarries that record out on every error raised frominvoke_claude_code;format_fullandprint_helix_errornow print it.None(no invocation) stays distinct from a zero-tokenUsageStats(an invocation that reported nothing).mutateandmergetake arecord_usagesink, called once with the usage whether or not a candidate comes back. Return contracts are unchanged —mutatestill returnsNoneonMutationError.MutationFailedProposalgainschild_usage; the worker fills it from a worker-local list and the sequential apply phase charges it withsource="mutation_failed". Merge failures charge withsource="merge_failed". Charging stays in the apply phase, so the serialization invariant documented at the top ofbudget.pyholds and workers stay state-free.Rate-limited invocations get the same handoff — a run can burn tokens before the limit trips.
.helix_backend_result.jsonalso now records the recovered usage instead of zeros when the strict parse failed.Tests
18 new tests. Every one of them fails against the pre-fix source (verified by reverting the source and re-running).
tests/unit/test_lines.py(7 behavioural): every non-LF boundary; strict JSONL parse over a Codex-shaped stream carrying U+0085 / U+2028 / U+2029; the tool-event counter reading one record rather than two fragments; aHELIX_RESULT=payload not truncated at U+2028; and the machine-protocol fragment no longer leaking into the mutation prompt.tests/unit/test_budget.py(6): the headline regression — feed a malformed JSONL line to each JSONL backend and assert the budget still records the usage. Plus: recovered usage pinned against a strict parse of the same stream minus the junk line; the artifact; theNone-vs-zero distinction.tests/unit/test_mutator.py(7): the sink on the success, failure, rate-limit and no-invocation paths, plus_salvage_backend_usagedirectly.tests/unit/test_evolution.py(2): end to end throughrun_evolution— a failed mutation produces exactly onemutation_failedcharge, and a failure with no usage produces none.Results
No pre-existing failures on
mainto report: the baseline was green on both.ruff format --checkis not clean on this repo and never has been — 20 files undersrc/would reformat both before and after this branch, and CI does not run it. This branch adds no new formatting drift.Scope note
The trigger was one failure in one codex run. The mechanism is verified directly in the source and by replaying the captured stdout, so the fix does not rest on that sample; the size of the under-report in any given run depends on how many mutations fail this way.
🤖 Generated with Claude Code
https://claude.ai/code/session_01NLHEwgZV983irkXWSXz2Vd