From 0bb65452f74fedfe7cbd94026a6ac4aa2c68a6a8 Mon Sep 17 00:00:00 2001 From: Alfonso Sastre Date: Sat, 5 Sep 2026 17:07:32 +0200 Subject: [PATCH] fix(session): per-turn history check is O(1), not O(n) per turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/server/session.lex | 67 +++++++++++++++++++++-------------- src/server/session_events.lex | 33 +++++++++++++++++ 2 files changed, 74 insertions(+), 26 deletions(-) diff --git a/src/server/session.lex b/src/server/session.lex index 287ca9a..bdb8d8d 100644 --- a/src/server/session.lex +++ b/src/server/session.lex @@ -320,27 +320,35 @@ fn run_turn(session :: Session, user_input :: Str) -> [env, net, llm, io, proc, # The turn contract (#54): the model context is DERIVED from the session's # trail log, not read from the in-memory cache. Sequence: append the user -# event, re-derive the history, check the cache agrees, and only then call -# the provider. Any log failure or divergence refuses the turn in-band (the -# same idiom as run_loop's "[max_steps reached]") instead of letting the -# model see a conversation the durable record cannot reproduce. +# event, check the trail agrees with what the cache expects, and only then +# call the provider. Any log failure or divergence refuses the turn in-band +# (the same idiom as run_loop's "[max_steps reached]") instead of letting +# the model see a conversation the durable record cannot reproduce. +# +# The check is `event_count`, not a full `session_history` re-derivation +# (see event_count's own comment for why: a per-turn full re-derive is +# O(total history) per turn, i.e. O(n^2) over a session, and a real +# multi-file build hit the interpreter's step limit from exactly this by +# its 46th turn). `expected` — the in-memory cache plus this turn's input +# — stands in for `derived` once the count agrees, since that is what a +# full derivation would reconstruct anyway absent a divergence. fn run_turn_with_provider(session :: Session, user_input :: Str, provider_tag :: Str) -> [env, net, llm, io, proc, sql, time, approval] TurnResult { let started := time.now_ms() match evs.record_user(session.log, user_input) { Err(e) => refused_turn(session, str.concat("session log append failed: ", e)), - Ok(_) => match evs.session_history(session.log) { - Err(e) => refused_turn(session, str.concat("session history underivable: ", e)), - Ok(derived) => { - let expected := list.concat(session.messages, [msg.user(user_input)]) - if evs.history_eq(derived, expected) { + Ok(_) => { + let expected := list.concat(session.messages, [msg.user(user_input)]) + match evs.event_count(session.log) { + Err(e) => refused_turn(session, str.concat("session history count unavailable: ", e)), + Ok(count) => if count == list.len(expected) { let agent := with_mcp(with_memory(pick_agent(session.mode, provider_tag), session.memory), mcp_tools_for(session.mode)) - let step_iter := ag.run_loop_traced(agent, derived, session.log, session.parent) + let step_iter := ag.run_loop_traced(agent, expected, session.log, session.parent) let steps := iter.to_list(step_iter) - finish_turn(session, derived, steps, started) + finish_turn(session, expected, steps, started) } else { refused_turn(session, "cached messages diverge from the trail-derived history") - } - }, + }, + } }, } } @@ -373,27 +381,34 @@ fn run_turn_with_provider(session :: Session, user_input :: Str, provider_tag :: # discard the returned steps to avoid printing the normal path twice (see # print_step's own comment), so a refusal that skipped `on_step` was # completely invisible: no error, no explanation, just silence and the -# process exiting. Found live dogfooding this on a task string containing an -# em-dash — lex-schema's json_value parser collapses non-ASCII bytes to `?` -# (a documented, deliberate tradeoff there), which desynced the trail-derived -# history from the in-memory cache and refused the turn with nothing printed. +# process exiting. Found live dogfooding a task string containing an em-dash +# — lex-schema's json_value parser collapses non-ASCII bytes to `?` (a +# documented, deliberate tradeoff there), which used to desync a full +# content-derived history from the in-memory cache and refuse the turn with +# nothing printed. The `event_count` check below no longer content-compares +# at all (see its own comment), so it no longer catches that particular +# scenario — a real regression for non-ASCII input specifically, accepted +# because the alternative it replaces could crash the whole process outright +# on a long session, which is worse than silently trusting an already-append +# -only, already-once-verified cache. This on_step-routing fix stays needed +# regardless, for the failure modes event_count still does catch. fn run_turn_streaming_with_provider(session :: Session, user_input :: Str, provider_tag :: Str, on_step :: (d.Step) -> [io] Unit) -> [env, net, llm, io, proc, sql, time, approval, stream] TurnResult { let started := time.now_ms() match evs.record_user(session.log, user_input) { Err(e) => refused_turn_streamed(session, str.concat("session log append failed: ", e), on_step), - Ok(_) => match evs.session_history(session.log) { - Err(e) => refused_turn_streamed(session, str.concat("session history underivable: ", e), on_step), - Ok(derived) => { - let expected := list.concat(session.messages, [msg.user(user_input)]) - if evs.history_eq(derived, expected) { + Ok(_) => { + let expected := list.concat(session.messages, [msg.user(user_input)]) + match evs.event_count(session.log) { + Err(e) => refused_turn_streamed(session, str.concat("session history count unavailable: ", e), on_step), + Ok(count) => if count == list.len(expected) { let agent := with_mcp(with_memory(pick_agent(session.mode, provider_tag), session.memory), mcp_tools_for(session.mode)) let budget := ag.unwrap_int(agent.options.max_steps, 20) - let steps := ag.run_steps_streamed(agent, derived, budget, session.log, session.parent, on_step) - finish_turn(session, derived, steps, started) + let steps := ag.run_steps_streamed(agent, expected, budget, session.log, session.parent, on_step) + finish_turn(session, expected, steps, started) } else { refused_turn_streamed(session, "cached messages diverge from the trail-derived history", on_step) - } - }, + }, + } }, } } diff --git a/src/server/session_events.lex b/src/server/session_events.lex index 909b979..f13e543 100644 --- a/src/server/session_events.lex +++ b/src/server/session_events.lex @@ -176,6 +176,39 @@ fn session_history(log :: trail_log.Log) -> [sql] Result[List[msg.Message], Str] } } +# A cheap alternative to session_history for the one thing session.lex's +# per-turn check actually needs to catch: finish_turn's own comment notes +# that a FAILED evs.record_assistant append is deliberately not patched +# over, so the in-memory cache and the trail silently disagree until the +# next turn's check refuses. That disagreement always shows up as a row +# count behind what the cache expects -- decoding every prior message's +# JSON to compare its *text* catches the same divergence but costs O(total +# history) per turn, which makes a long session O(n^2) overall. Reproduced +# live: a real multi-file package build hit the interpreter's step limit +# by turn ~46 purely from re-deriving an ever-growing history on every +# turn, independent of any one message being unusually large. +# +# This does not re-verify that already-recorded content hasn't silently +# changed underneath (the trail is append-only and nothing in this +# codebase ever updates or deletes an event row, so that risk is already +# assumed away elsewhere) -- only that nothing has gone missing. +# session_history itself is unchanged and still used where its full, +# content-verifying cost is paid once rather than per turn (session +# resumption, and its own tests). +fn event_count(log :: trail_log.Log) -> [sql] Result[Int, Str] { + let q := str.join(["SELECT COUNT(*) AS n FROM events WHERE kind IN ('", user_kind(), "', '", assistant_kind(), "')"], "") + match trail_log.xquery(log.db, q, []) { + Err(e) => Err(e.message), + Ok(rows) => match list.head(rows) { + None => Ok(0), + Some(r) => match sql.get_int(r, "n") { + None => Ok(0), + Some(n) => Ok(n), + }, + }, + } +} + # ── Equality ──────────────────────────────────────────────────────────────── # Two histories are equal iff their canonical encodings are equal — the same # encoder writes the events, so this is exact, not heuristic.