fix(session): per-turn history check is O(1), not O(n) per turn - #136
Merged
Conversation
run_turn_(streaming_)with_provider re-derived the ENTIRE conversation history from the trail log and content-compared it against the in-memory cache on every single turn, to catch a failed evs.record_assistant append (finish_turn's own comment: "the cache keeps the message anyway"). That re-derivation is O(total history) per turn, so a session that runs long enough is O(n^2) overall. Reproduced live: a synthetic session doing nothing more than ordinary turns of realistic-length assistant text crashed with a step-limit- exceeded panic in lex-schema's json_value parser by turn 46, purely from re-parsing an ever-growing history on every turn — independent of any one message being unusually large. This is what was actually blocking a real multi-file package build via lex-code's agent loop. Replaces the full re-derivation with evs.event_count: a cheap SELECT COUNT(*), compared against the in-memory cache's length. A failed record_assistant append shows up as a count mismatch exactly as reliably as a full content comparison would, since the trail is append-only. `expected` (the cache plus this turn's input) stands in for the old `derived` once the count agrees, since that is what a full derivation would reconstruct anyway absent a divergence. Trade-off, stated plainly: this no longer catches content that silently changed underneath without changing the row count (e.g. the non-ASCII-collapse scenario #135 was about) — only that nothing has gone missing. Accepted because the check it replaces could crash the whole process outright on a long session, which is worse. #135's on_step-routing fix stays needed regardless, for the failure modes event_count still does catch. session_history itself is unchanged and still used for its full, content-verifying cost paid once at session resumption rather than per turn. Verified: a probe reproducing the crash at turn 46 with the old check now completes 200 turns cleanly with event_count. Depends on alpibrusl/lex-schema#36 (also fixed today), which addressed a compounding quadratic bug in the same code path that could still panic within a single turn on escape-dense content regardless of this fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
3 tasks
alpibrupa
added a commit
that referenced
this pull request
Sep 5, 2026
…137) The lex VM's default step limit (10,000,000) is a DoS guard for untrusted sandboxed snippets (lex run --help). lex-code's own process -- the TUI, one-shot mode, the eval harness -- is trusted, long-running code, not that. Reproduced live: a real multi-file package build crashed outright with a step-limit-exceeded panic partway through, independent of the two other quadratic bugs fixed today (alpibrusl/lex-schema#36, this repo's own #136) -- an ordinary verbose turn from a thinking-mode model was enough on its own. `lex run --max-steps 20000000000` already exists for exactly this; `src/index_build.lex`'s README section already needed it for its own long-running batch job. bin/lex-code now sets the same value by default (overridable via LEX_CODE_MAX_STEPS, matching the existing LEX_CODE_EFFECTS pattern), scripts/eval.sh's harness invocation gets the same flag, and the README's literal lex run examples (the TUI entry point, the MCP/A2A/web servers, the ACP server) are updated to match -- bin/lex-code's own comment is where the full story lives, and the Quickstart section now points there. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
4 tasks
alpibrupa
added a commit
that referenced
this pull request
Sep 5, 2026
…138) run_once used new_session_with_provider, which opens an in-memory-only trail log (persist.open_ephemeral) discarded the moment the process exits. A one-shot run that stops without producing anything -- hits its step budget, say -- left no record of what it actually did. Reproduced live today: a real build task burned its whole 50-step budget on bash exploration with nothing to inspect afterward, not even which commands it ran. Switches to new_session_persistent_with_provider (already used by graph pipelines), which writes to .lex/sessions/<id>.db instead. Not a straight swap, though: new_session_from_log always starts a session's in-memory cache at messages: [], regardless of what a log under that id already holds -- correct for a pipeline node's id, which is reused deliberately across runs of the SAME pipeline, but wrong for a fixed "cli" id reused across SEPARATE one-shot invocations. A second `bin/lex-code "task"` in the same project would find "cli"'s log already holding the first run's events, immediately fail the fresh session's event_count check (#136), and refuse before ever reaching the model. cli_session_id() generates a fresh id per invocation instead (timestamp + random suffix), so each run gets its own file with no collision. The one-shot output now names the trail file so a stuck or incomplete run can actually be inspected afterward (confirmed live: sqlite3 against the file shows the full conversation, including tool calls and the model's own reasoning text). repl is untouched -- a live REPL user already watches every step as it happens, which is the case the ephemeral log was originally fine for. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
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.
Summary
run_turn_(streaming_)with_providerre-derived the ENTIRE conversation history from the trail log and content-compared it against the in-memory cache on every single turn, to catch a failedevs.record_assistantappend (finish_turn's own comment: "the cache keeps the message anyway"). That re-derivation is O(total history) per turn, so a session that runs long enough is O(n²) overall.evs.event_count: a cheapSELECT COUNT(*), compared against the in-memory cache's length. A failedrecord_assistantappend shows up as a count mismatch exactly as reliably as a full content comparison would, since the trail is append-only.expected(the cache plus this turn's input) stands in for the oldderivedonce the count agrees, since that is what a full derivation would reconstruct anyway absent a divergence.event_countstill does catch.session_historyitself is unchanged and still used for its full, content-verifying cost paid once at session resumption (resume_session) rather than per turn, and its own tests are untouched.Depends on alpibrusl/lex-schema#36 (merged today), which fixed a compounding quadratic bug in the same code path (escape-dense string parsing) that could still panic within a single turn on verbose model output, independent of this fix.
Test plan
lex checkon both modified files, plus a full repo sweep (lex checkover every trackedsrc/file)lex fmt --check src/lex test(4/4 passing, includingtest_session_events.lexwhich still exercisessession_historydirectly)lex doc-sync --checkevent_count🤖 Generated with Claude Code