fix(tui): prompt.submit's truncate_before_user_ordinal reads fresh history - #69109
fix(tui): prompt.submit's truncate_before_user_ordinal reads fresh history#69109Crong-Gabia wants to merge 6 commits into
Conversation
PRATHAMESH75
left a comment
There was a problem hiding this comment.
Reviewed against #69107. The root cause is confirmed: prompt.submit's truncate_before_user_ordinal branch computes user_indices from session["history"] — the TUI process's in-memory copy refreshed only on resume — so a turn appended by another client (REST gateway) sharing the same session_key is invisible, and a valid ordinal is rejected with 4018. Re-reading from the DB right before the bounds check is the right direction, and because the same history then feeds replace_messages, this also closes the more serious failure mode: persisting the stale (shorter) truncation would have silently dropped the concurrent writer's rows — cross-client data loss, not just a stale display. The regression test faithfully models stale-in-memory vs fresh-DB and the fail-safe fallback is a good touch.
One edge case worth confirming before merge — the re-read hardcodes include_ancestors=True (and drops repair_alternation), which diverges from how session["history"] is actually built on the child/subagent resume path. tui_gateway/server.py:6356 resumes a delegated child with get_messages_as_conversation(target, repair_alternation=True) and deliberately without include_ancestors, with the comment that "include_ancestors would prepend the parent's transcript onto the subagent's branch"; its display projection (:6385) is likewise child-only. For a resumed child session that has a parent transcript, re-reading with include_ancestors=True would prepend the parent's user turns, shifting every entry in user_indices — so the same truncate_before_user_ordinal (which the client computed against the child-only display) would resolve to a different, earlier turn and truncate at the wrong point. That's the same truncation/data-loss class this PR is closing, just triggered on child sessions rather than by staleness.
The reported top-level shared-session scenario has no ancestors, so include_ancestors=True is harmless there and the fix works as tested. But since the branch can run on any session, consider re-reading with the same projection the session was resumed with (mirror the resume flags / the display projection the ordinal was computed against) rather than unconditionally adding ancestors, so the ordinal stays anchored to the rows the client actually saw. Dropping repair_alternation is a smaller sibling of the same point — the resume path heals a durable user;user once, and the re-read no longer does.
Otherwise this correctly fixes the confirmed bug for the scenario in the issue.
|
Thanks — this review was recorded against the initial head The latest commits address both points:
A real- |
aa25db6 to
5c22ad4
Compare
|
Rebased onto current main and revalidated the truncation path against the changes that landed after this PR opened. The resolution preserves the newer confirm_empty_truncate full-transcript guard, performs the authoritative DB refresh before ordinal validation, keeps display-lineage ordinal translation tip-local, retains repair_alternation=True, and fails closed before mutating in-memory state when persistence fails. I also updated the regression fixtures for the current persist_user_message call contract. Current result: the branch is GitHub-mergeable and tests/test_tui_gateway_server.py passes in full: 472 passed. |
5c22ad4 to
2366f4d
Compare
92e404c to
b9b664e
Compare
teknium1
left a comment
There was a problem hiding this comment.
Thanks for the focused stale-history investigation. The stale-snapshot premise is still present on current main at tui_gateway/server.py:11214-11226, but this branch needs a careful port rather than a direct salvage.
Problems
tui_gateway/methods_prompt.py:135-136refreshes the DB snapshot, then:190performs a separate destructivereplace_messages.SessionDB.replace_messagesstarts its delete/reinsert transaction athermes_state.py:5453-5480with no expected-version check. A cross-client append between those operations can still be deleted.- Current main no longer has
tui_gateway/methods_prompt.py; the active handler istui_gateway/server.py:11116. Its resume path usesget_resume_conversations()andsanitize_replay_history()attui_gateway/server.py:7750-7759, so the proposed refresh must preserve that current model-history projection.
Suggested changes
- Port the handler change to
tui_gateway/server.pyand derive the fresh tip model history using the current resume projection. - Add an atomic/conditional rewrite or conflict path, with a regression for an append occurring after the refresh but before replacement.
Automated hermes-sweeper review.
| segment_ordinal = ordinal - prefix_user_count | ||
| if db is not None and session.get("session_key"): | ||
| try: | ||
| history = db.get_messages_as_conversation( |
There was a problem hiding this comment.
This refresh and the later replace_messages at line 190 are separate DB operations. SessionDB.replace_messages deletes and reinserts in its own transaction without an expected-version predicate, so another client can append after this read and have that new row erased. Please make the rewrite conditional/atomic against this snapshot, or fail with a conflict, and cover that interleaving.
b9b664e to
c70eb92
Compare
|
Addressed the latest stale-snapshot review in c70eb92b3 and rebased the branch onto current main.
Validation: |
c70eb92 to
6bcdd65
Compare
|
Rebased onto current main ( |
SummaryThree PRs address or reference #69107: #69109 fixes the stale cross-client ordinal path and protects the destructive rewrite against concurrent appends, while #72876 and its merged salvage #76634 cover the narrower persistence-failure path. Related pull requests
Duplicates#72876 and #76634 implement substantially the same persistence-failure fix, with #76634 being the merged current-handler salvage; that subset is also incorporated into the broader #69109 diff. Suggested consolidationKeep #69109 open with a salvage path: retain its authoritative refresh, lineage-aware ordinal translation, and atomic conditional rewrite/conflict tests. This is consistent with the contributor keep_open review, and the visible diff addresses its race objection; #72876 is already closed as superseded by merged #76634, while #76634 should remain the merged reference for the narrower fail-closed subset. Complex graphflowchart LR
classDef open fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a
classDef merged fill:#dcfce7,stroke:#15803d,color:#14532d
classDef closed fill:#e5e7eb,stroke:#6b7280,color:#1f2937
classDef unverified fill:#f3f4f6,stroke:#9ca3af,color:#374151
classDef best stroke-width:3px,stroke:#b45309
classDef target stroke-width:3px,stroke:#4338ca
I69107(["issue #69107 (open)"])
P69109["PR #69109 (open)"]
P69109 -->|best fix| I69107
class I69107 open
class P69109 open
class P69109 best
class P69109 target
click I69107 "https://github.com/NousResearch/hermes-agent/issues/69107"
click P69109 "https://github.com/NousResearch/hermes-agent/pull/69109"
Graph: solid arrow = fixes / best fix, dashed arrow = partial or unverified (see edge label); boxed group = PRs duplicating each other; amber border = best fix; indigo border = target; gray node = closed (state tag in the node label). Cross-PR triage: Reviewed 3 pull requests and 1 issue in this complex. Each diff was read against this issue; Assessment working set: 42 kB of PR diffs, 13 kB of issue/PR text, 12 kB of discussion (14 comments), 4 verify verdicts. verdicts reflect diff content, not PR titles. Part of an automated triage batch. |
6bcdd65 to
e46e57a
Compare
|
Rebased the accepted salvage onto current main ( |
session["history"] is only refreshed on session.resume — it does not track writes made by another client sharing this same session_key (e.g. a REST-gateway-backed web client such as our internal webui, or hermes-webui in its relay/remote-gateway mode) in the meantime. An ordinal that is valid against the session's current DB state was being rejected with 4018 "target user message is no longer in session history" solely because the TUI process's in-memory copy was shorter. Re-read from the DB (db.get_messages_as_conversation, the same call session.history already uses) immediately before computing user_indices and the truncation bounds, falling back to the in-memory copy if the DB read fails. Adjacent to the existing negative-ordinal guard a few lines above, whose own comment already flags replace_messages as capable of "an unrecoverable overwrite of the session DB" for a related reason. Fixes NousResearch#69107
e46e57a to
d11abdf
Compare
|
Thanks for the thorough work here — this PR correctly identified the stale-ordinal mis-aim class and the destructive-write risks well before the incident that finally forced the issue (#87059). Closing as superseded: the problem was ultimately resolved by stronger means than fresher ordinal math.
Together those remove the ordinal-only path this PR was hardening, so most of its diff no longer has a target. Your fail-closed-on-DB-read-failure instinct and the write-before-memory ordering both landed on main through that lineage as well. One piece of your work still stands on its own: the compare-and-swap Appreciate the contribution and the tests. |
|
@teknium1 Following your suggestion, I split the standalone It contains only:
|
Summary
Fixes #69107 —
prompt.submit'struncate_before_user_ordinal(the Desktop/TUI edit-and-regenerate flow) computed the truncation point fromsession["history"], which can remain stale while another REST-gateway-backed client writes new turns to the same durable session.An ordinal valid against the current SessionDB state was therefore rejected with
4018 "target user message is no longer in session history". Because the same path performs a destructivereplace_messages(...), using the stale snapshot could also discard newer rows.Root cause
The gateway's in-memory history is refreshed on resume and after its own turns, but it is not authoritative for writes made by another client.
Compressed sessions add an identity wrinkle: Desktop ordinals are based on the full displayed lineage, while the model-fed working history contains only the current continuation segment. Loading ancestors into the replacement history would duplicate compressed rows in the tip session.
Fix
SessionDBwithrepair_alternation=True, matching the model-fed resume projection, immediately before validating and applying the truncation.display_history_prefix, without copying ancestor rows into the tip.session["history"], so a SQLite/FTS/ENOSPC failure cannot leave memory and durable history diverged.mainpredicate that excludesdisplay_kindtimeline rows from both current-segment and ancestor-prefix user ordinals.tui_gateway/methods_prompt.pywithout restoring the mechanically extracted handler inserver.py.4018behavior for negative, ancestor-only, or otherwise out-of-range targets, and preserve the newerconfirm_empty_truncateguard for intentional first-turn rewinds.Scope
This PR intentionally fixes the concrete edit/regenerate correctness and data-loss path only. It does not add a live transcript subscription or choose new cross-client UI behavior; that broader UX remains a separate follow-up in #69107.
Relationship to #72876
#72876 independently proposes write-before-memory failure handling for
replace_messages(). The current head of this PR already includes that behavior and its regression coverage, in addition to the authoritative history refresh, compressed-lineage ordinal translation, resume-projection preservation, and fail-closed DB-read path. The implementations therefore substantially overlap; this PR covers #72876's failure mode as part of the broader stale cross-client history fix.Testing
4018.SessionDBcompression-lineage test proving only the current segment is truncated while ancestor rows remain unchanged.main(5a23e3c52):python -m pytest tests/test_tui_gateway_server.py -q→ 498 passed.python -m py_compile tui_gateway/methods_prompt.py tests/test_tui_gateway_server.py— clean.git diff --check— clean.Review focus
The highest-risk invariant is that the displayed full-lineage ordinal must resolve to the current segment without ever copying ancestor rows into the destructive replacement. The real-SessionDB lineage regression test pins that behavior. Both authoritative-read and replacement failures are fail-closed before the turn starts.