From 0ba6f0a2074c17ad274090c42bb1f01d5797ecca Mon Sep 17 00:00:00 2001 From: Alfonso Sastre Date: Sat, 5 Sep 2026 20:34:30 +0200 Subject: [PATCH] fix(tui): persist one-shot session trails for post-mortem debugging 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/.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 --- src/tui/main.lex | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/src/tui/main.lex b/src/tui/main.lex index 53f9b6e..bea5828 100644 --- a/src/tui/main.lex +++ b/src/tui/main.lex @@ -4,6 +4,12 @@ import "std.str" as str import "std.list" as list +import "std.time" as time + +import "std.crypto" as crypto + +import "std.int" as int + import "lex-llm/delta" as d import "lex-llm/message" as msg @@ -57,12 +63,33 @@ fn repl(session :: sess.Session, provider_tag :: Str) -> [env, io, net, llm, pro } } +# A unique id per invocation, not the fixed "cli" new_session_with_provider +# used to key on. `new_session_from_log` (session.lex) always starts a +# session's in-memory cache at `messages: []`, regardless of what a log +# under that id already holds — the right behavior for a graph pipeline +# node, whose id is reused deliberately across runs of the SAME pipeline, +# but wrong for a one-shot CLI task: 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, and +# refuse before ever reaching the model. A fresh id per invocation gives +# each run its own file with no such collision. +fn cli_session_id() -> [time, crypto, random] Str { + str.join(["cli-", int.to_str(time.now_ms()), "-", crypto.random_str_hex(4)], "") +} + +# One-shot sessions persist their trail (session.new_session_persistent_with_provider, +# `.lex/sessions/.db`) rather than the ephemeral in-memory log the REPL +# uses. A REPL user watches every step live; a one-shot run that stops +# without producing anything — hits its step budget, say — otherwise +# leaves no record of what it actually did once the process exits, which +# is exactly the case that needs a post-mortem the most. fn run_once(task :: Str, mode :: sess.AgentMode, provider_tag :: Str) -> [env, io, net, llm, proc, sql, fs_read, fs_walk, fs_write, time, approval, stream, crypto, random] Nil { - match sess.new_session_with_provider("cli", mode, provider_tag) { + let session_id := cli_session_id() + match sess.new_session_persistent_with_provider(session_id, mode, provider_tag) { Err(e) => io.print(str.concat(str.concat("error: ", e), "\n")), Ok(session) => { let __printed := sess.run_turn_streaming_with_provider(session, task, provider_tag, print_step) - io.print(str.concat("", "\n")) + io.print(str.join(["\n(trail: .lex/sessions/", session_id, ".db)\n"], "")) }, } }