Skip to content

fix(session): per-turn history check is O(1), not O(n) per turn - #136

Merged
alpibrupa merged 1 commit into
mainfrom
fix-turn-check-quadratic
Sep 5, 2026
Merged

fix(session): per-turn history check is O(1), not O(n) per turn#136
alpibrupa merged 1 commit into
mainfrom
fix-turn-check-quadratic

Conversation

@alpibrupa

Copy link
Copy Markdown
Contributor

Summary

  • 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²) 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 in the code: this no longer catches content that silently changed underneath without changing the row count (e.g. the non-ASCII-collapse scenario fix(session): surface a refused turn to the user instead of silence #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. fix(session): surface a refused turn to the user instead of silence #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 (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 check on both modified files, plus a full repo sweep (lex check over every tracked src/ file)
  • lex fmt --check src/
  • lex test (4/4 passing, including test_session_events.lex which still exercises session_history directly)
  • lex doc-sync --check
  • A standalone probe reproducing the exact crash: 46 turns of ordinary-length assistant text hit the step-limit panic with the old per-turn full derivation; 200 turns of the identical workload complete cleanly with event_count
  • End-to-end: re-ran the real multi-file package build that originally surfaced this (an agent implementing an RLP codec) against a fresh project with both this fix and lex-schema#36 in place

🤖 Generated with Claude Code

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>
@alpibrupa
alpibrupa merged commit 09b74e2 into main Sep 5, 2026
1 check passed
@alpibrupa
alpibrupa deleted the fix-turn-check-quadratic branch September 5, 2026 16:32
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>
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