Summary
Session persistence (#102) intentionally lets a user-supplied checkpointer win: "A user-supplied checkpointer still wins, untouched", and the CLI supplies its durable SQLite store only when the graph has none. That part is correct. The bug is that the persistence diagnostics lie for such an agent:
--show-config reports persist = True and prints a concrete sessions_store = .../<key>.sqlite path — but that .sqlite is never created or written, so there is zero cross-run state.
--list-sessions lists the run as a resumable session (id + first message + timestamp).
--continue / --resume happily "resume" that thread — but restore nothing: /history is empty and the graph never sees prior turns.
So a user is told, by the CLI's own primary config diagnostic, that persistence is on and even where it is stored, while in reality every run is amnesiac. This directly violates #102's own acceptance criterion #3 — "Surface it in --show-config//status … so it's discoverable and honored-as-advertised." /status does distinguish the two modes (on (graph checkpointer) vs on (durable)), but --show-config (the non-interactive diagnostic) and --list-sessions do not.
This is reachable straight from the README's own "Creating Your Own Agent → full deep agent" example, which compiles with checkpointer=MemorySaver().
Severity: major — silent loss of conversation continuity on the headline feature, with the diagnostics actively reassuring the user that persistence is on and naming a store file that does not exist.
Environment
langstage-cli 0.6.30 (clean pip install langstage-cli from PyPI)
langstage-core 1.0.34, langgraph 1.2.11, langgraph-checkpoint-sqlite 3.1.1, click 8.4.2
- Python 3.11.15, Linux, fresh venv
Minimal repro
mkdir demo && cd demo
export LANGSTAGE_CLI_SESSIONS_DIR="$PWD/sess" # isolate the store for the repro
cat > mem_agent.py <<'PY'
# Same shape as the README's "full deep agent" example: the graph owns a checkpointer.
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import MessagesState
from langgraph.checkpoint.memory import MemorySaver
from langchain_core.messages import AIMessage
def respond(state):
return {"messages": [AIMessage(content=f"turn sees {len(state['messages'])} msgs")]}
g = StateGraph(MessagesState); g.add_node("respond", respond)
g.add_edge(START, "respond"); g.add_edge("respond", END)
graph = g.compile(checkpointer=MemorySaver()) # <-- user-supplied checkpointer
PY
export LANGSTAGE_AGENT_SPEC="mem_agent.py:graph"
langstage-cli --show-config | sed -n '/Session persistence/,$p'
langstage-cli -q "first message" # run 1
langstage-cli -q -c "second message" # run 2 (continue)
langstage-cli --list-sessions
printf '/history\n/quit\n' | langstage-cli -c # resume + inspect history
ls sess/
Actual
Session persistence:
persist = True [default] (env: LANGSTAGE_PERSIST, toml: session.persist)
sessions_store = .../sess/b6b30a6936f792a7.sqlite [env:LANGSTAGE_CLI_SESSIONS_DIR]
# run 1: turn sees 1 msgs
# run 2: turn sees 1 msgs <-- no continuity; prior turn invisible
Sessions (.../demo)
9b0ba186 2026-08-18 07:41 first message <-- listed as resumable
# /history after -c: (completely empty — no You/Agent lines at all)
# store dir contents:
b6b30a6936f792a7.index.json <-- ONLY the index sidecar; NO .sqlite was ever written
Expected
For an agent that owns its own (non-durable) checkpointer, one of:
- Honest diagnostics (minimum fix).
--show-config should report the same distinction /status already makes — e.g. persist = on (graph checkpointer; cross-run history not stored by the CLI) and not print a sessions_store .sqlite path that is never created. --list-sessions should either not list index-only entries as resumable, or mark them "(no stored state)".
- A one-line warning when
--continue/--resume targets a thread that has only an index entry and no durable checkpoint, so the silent empty resume is at least visible.
The current output tells the user persistence is on and points at a .sqlite file, while every run is actually amnesiac — the exact "honored-as-advertised" gap #102 set out to avoid.
Control (no own checkpointer — works correctly)
Same agent compiled with g.compile() (no checkpointer): run 2 reports turn sees 3 msgs, --list-sessions shows the session, /history shows both turns, and the store dir contains a real <key>.sqlite (~28 KB) alongside the index. So the divergence is entirely driven by whether the graph carries its own checkpointer.
Root cause (pointer)
main() computes had_user_checkpointer = getattr(graph, "checkpointer", None) is not None and use_durable = persist and not had_user_checkpointer; when use_durable is false, persist_sqlite_path stays None so no .sqlite is ever opened — but sessions.record_session(...) still writes the index entry, and the --show-config / _persist_diagnostic path reports persist=True with the computed db_path(...) regardless of use_durable.
Filed by the nightly dogfood routine (dogfooding 0.6.30, clean PyPI install).
Summary
Session persistence (#102) intentionally lets a user-supplied checkpointer win: "A user-supplied checkpointer still wins, untouched", and the CLI supplies its durable SQLite store only when the graph has none. That part is correct. The bug is that the persistence diagnostics lie for such an agent:
--show-configreportspersist = Trueand prints a concretesessions_store = .../<key>.sqlitepath — but that.sqliteis never created or written, so there is zero cross-run state.--list-sessionslists the run as a resumable session (id + first message + timestamp).--continue/--resumehappily "resume" that thread — but restore nothing:/historyis empty and the graph never sees prior turns.So a user is told, by the CLI's own primary config diagnostic, that persistence is on and even where it is stored, while in reality every run is amnesiac. This directly violates #102's own acceptance criterion #3 — "Surface it in
--show-config//status… so it's discoverable and honored-as-advertised."/statusdoes distinguish the two modes (on (graph checkpointer)vson (durable)), but--show-config(the non-interactive diagnostic) and--list-sessionsdo not.This is reachable straight from the README's own "Creating Your Own Agent → full deep agent" example, which compiles with
checkpointer=MemorySaver().Severity: major — silent loss of conversation continuity on the headline feature, with the diagnostics actively reassuring the user that persistence is on and naming a store file that does not exist.
Environment
langstage-cli0.6.30 (cleanpip install langstage-clifrom PyPI)langstage-core1.0.34,langgraph1.2.11,langgraph-checkpoint-sqlite3.1.1,click8.4.2Minimal repro
Actual
Expected
For an agent that owns its own (non-durable) checkpointer, one of:
--show-configshould report the same distinction/statusalready makes — e.g.persist = on (graph checkpointer; cross-run history not stored by the CLI)and not print asessions_store.sqlitepath that is never created.--list-sessionsshould either not list index-only entries as resumable, or mark them "(no stored state)".--continue/--resumetargets a thread that has only an index entry and no durable checkpoint, so the silent empty resume is at least visible.The current output tells the user persistence is on and points at a
.sqlitefile, while every run is actually amnesiac — the exact "honored-as-advertised" gap #102 set out to avoid.Control (no own checkpointer — works correctly)
Same agent compiled with
g.compile()(no checkpointer): run 2 reportsturn sees 3 msgs,--list-sessionsshows the session,/historyshows both turns, and the store dir contains a real<key>.sqlite(~28 KB) alongside the index. So the divergence is entirely driven by whether the graph carries its own checkpointer.Root cause (pointer)
main()computeshad_user_checkpointer = getattr(graph, "checkpointer", None) is not Noneanduse_durable = persist and not had_user_checkpointer; whenuse_durableis false,persist_sqlite_pathstaysNoneso no.sqliteis ever opened — butsessions.record_session(...)still writes the index entry, and the--show-config/_persist_diagnosticpath reportspersist=Truewith the computeddb_path(...)regardless ofuse_durable.Filed by the nightly dogfood routine (dogfooding 0.6.30, clean PyPI install).