Skip to content

fix(gateway): stop Desktop edit-resend from truncating the wrong turn - #87150

Merged
teknium1 merged 3 commits into
NousResearch:mainfrom
fangliquanflq:fix/gateway-durable-truncation-guard
Aug 15, 2026
Merged

teknium1 merged 3 commits into
NousResearch:mainfrom
fangliquanflq:fix/gateway-durable-truncation-guard

Conversation

@fangliquanflq

Copy link
Copy Markdown
Contributor

What does this PR do?

Desktop edit-resend can target the wrong durable user turn after a backend restart and silently remove a large part of the model context. This change makes ordinal-only rewinds fail closed whenever durable history exists or cannot be read, and preserves durable row IDs across every reload path involved in the incident.

Symptom

After Desktop reconnects with session.resume and omit_messages: true, editing and resending an interrupted turn can truncate at a stale server-side ordinal instead of the edited durable row. In the reported incident, the gateway cut 78 messages rather than 1 and the next request's prompt cache fell to 1%.

Impact

Users can lose hours of active model context from the live conversation even though the durable rows remain recoverable in SQLite. Subsequent answers then build on a silently shortened transcript.

Bug Cause

Trigger: tui_gateway/methods_prompt.py:504 in the ordinal-only prompt.submit truncation branch.

Causal chain:

  1. Desktop resumes a persisted session after a backend restart with omit_messages: true, then sends an edit-resend request that has a user ordinal but no durable row ID.
  2. Resume and sibling reload paths historically reconstructed the live history without _row_id stamps, so the guard treated a durable transcript as ephemeral.
  3. Renderer and gateway ordinal spaces can differ, allowing the request to archive the wrong range of durable turns and sharply reduce the model context.

Why it is wrong: Durability is a property of the persisted transcript, not of optional annotations on the current in-memory copy. Missing row stamps therefore cannot prove that ordinal-only destructive addressing is safe.

Working sibling / contrast: Row-ID-addressed rewinds already resolve the stable durable target and cross-check it against live history before truncating. The fixed ordinal-only path now consults the same profile-aware durable state and refuses unsafe downgrade addressing.

Ruled out: The failure was not caused by missing persistence. REAL_ENV verification resumed persisted rows from a fresh process and showed that they existed with stable row IDs; the bypass came from the unstamped live reload.

Fix

  • Load the profile-aware durable transcript before accepting ordinal-only truncation and fail closed if it is non-empty or unreadable.
  • Preserve _row_id metadata when restoring history through omit-messages resume, /undo, and the contended turn-lease reload.
  • Keep row IDs as internal bookkeeping; provider-bound messages continue to strip underscore-prefixed metadata centrally.
  • Add regression tests for unstamped durable histories, DB-read failures, ephemeral histories, and each affected reload boundary.

Related Issue

Closes #87059

Type of Change

  • Bug fix (non-breaking change that fixes an issue)

Changes Made

  • tui_gateway/methods_prompt.py - derive truncation safety from durable state and reject unsafe ordinal-only rewinds.
  • tui_gateway/methods_session.py - retain row IDs during deferred and eager omit-messages resume.
  • tui_gateway/methods_tools.py - retain row IDs after /undo reloads.
  • run_agent.py - retain row IDs when a contended turn lease reloads the latest transcript.
  • tests/test_tui_gateway_server.py - cover durable fail-closed behavior, ephemeral compatibility, and resume metadata.
  • tests/tui_gateway/test_undo_command.py and tests/run_agent/test_cross_process_turn_lease.py - cover sibling reload boundaries.

How to Test

  1. Persist a Desktop session with multiple user turns, restart the backend, and resume it with omit_messages: true.
  2. Submit a confirmed edit-resend request with only truncate_before_user_ordinal; verify error 4004, zero persistence writes, and unchanged live and durable history.
  3. Repeat with truncate_before_row_id; verify that exactly the selected durable turn is targeted.
  4. Run the focused Python suite (588 tests passed):
scripts/run_tests.sh tests/test_tui_gateway_server.py tests/tui_gateway/test_undo_command.py tests/run_agent/test_cross_process_turn_lease.py
  1. Run the Desktop rewind and submit suite (134 tests passed):
cd apps/desktop
npx vitest run src/app/session/hooks/use-prompt-actions/rewind.test.ts src/app/session/hooks/use-prompt-actions/index.test.tsx
  1. Run lint on the changed Python files; the focused ruff check passed.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated net changes)
  • I've run the repository test entry on the relevant tests and all focused tests pass
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: Windows 11

Documentation & Housekeeping

  • Documentation update is N/A because this fixes existing gateway behavior without changing the public interface
  • cli-config.yaml.example update is N/A because no configuration keys changed
  • CONTRIBUTING.md and AGENTS.md updates are N/A because no architecture or workflow changed
  • I've considered cross-platform impact; the change uses platform-independent SQLite and Python paths
  • Tool description/schema updates are N/A because no model tool behavior changed

Screenshots / Logs

REAL_ENV verification on Windows 11 restarted the backend, resumed 7 persisted rows with row IDs, and confirmed that ordinal-only edit-resend returned 4004 with zero memory or database mutation. A row-ID-addressed edit cut exactly at the selected durable target.

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/gateway Gateway runner, session dispatch, delivery comp/tui Terminal UI (ui-tui/ + tui_gateway/) comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Aug 15, 2026
@Enough1122

Copy link
Copy Markdown
Contributor

AI code review — automated review for reference, author can ignore or act on any point.

fix(gateway): stop Desktop edit-resend from truncating the wrong turn

The fail-closed posture (unreadable durable state → refuse ordinal-only truncation) is correct, and the new client_ordinal bounds check closes the out-of-range path. Observations:

  1. tui_gateway/methods_prompt.py _load_durable_truncation_history — the refusal condition is has_stamped_user or durable_history is None or durable_history. That means an ordinal-only truncation is allowed only when durable_history == [] and no live user turn is stamped. An empty DB result is treated as "ephemeral session", but it could also mean the session_key doesn't match what was persisted (key drift between the live session and the durable store) — in which case a destructive truncate proceeds against a session that actually is durable. Consider cross-checking the empty result against the live history length (they should match for a genuinely ephemeral session) before allowing the ordinal-only path.
  2. Behavior change: a transient DB error now turns every ordinal-only truncation into a hard refusal (durable_history is None → 4004) until the DB recovers. Fail-closed is the right call for a destructive operation, but it's worth noting in the error message (4004 text already mentions truncate_before_row_id) that the refusal may be temporary — otherwise users may think the feature is broken.
  3. run_agent.py lease-wait path now passes include_row_ids=True — if SessionDB.get_messages_as_conversation only accepts **kwargs-forwarded extras this is fine; the test stubs accept **_kwargs. Confirm the real SessionDB implementation tolerates the new kwarg on all call paths (the TUI test doubles accept it, but the production method signature is the actual contract).
  4. Test coverage is strong (unstamped refusal, row-id truncation, empty truncation, confirm-empty, undo stamps). Minor: test_prompt_submit_refuses_ordinal_only_when_durable_history_is_unstamped pins error code 4004 — good contract, though a second assertion that the message mentions the durable-history reasoning (not just truncate_before_row_id) would lock the operator-facing diagnosis.
  5. Minor: _load_durable_truncation_history opens a DB connection per call (with _session_db(session) as db); on the hot truncate path that's one extra connection per request — negligible for a user-triggered edit, no concern.

@teknium1
teknium1 merged commit 3863de3 into NousResearch:main Aug 15, 2026
48 checks passed
teknium1 added a commit that referenced this pull request Aug 15, 2026
…w ids by content

Client half of #87059. The gateway now fails ordinal-only truncation
closed for durable sessions (#87150), which turned the mis-aimed cut into
a visible edit-resend error for any bubble without a bound rowId (edit
after an interrupted turn, unstamped resume). Make the Desktop always
produce a durable address or degrade safely:

- runRewindSubmit: when a truncation request lacks a durable address,
  resolve the target's row id by exact content against session.history
  (which ships row_id per persisted row). Resolution is
  exact-or-nothing: a unique text match wins; ambiguity is accepted only
  when the target is provably the newest persisted turn (the
  edit-after-interrupt shape). Anything else degrades to a PLAIN
  resubmit — never a guessed cut. The client ordinal is dropped either
  way (its space can diverge from the gateway's — the #87059 root).
- planReload/planRestore: degrade failed turns to a plain resubmit
  (extends the #86623 pattern to regenerate/restore) and carry the
  turn's persisted sourceText as the content key.
- rebindSurvivorRowIds: iterate the same failed-turn-aware ordinal
  space as the truncate math.
- session-tile-actions: reload goes through the shared runRewindSubmit
  primitive instead of a raw prompt.submit, so the tile surface gets the
  same discipline.
bobaba76 pushed a commit to bobaba76/hermes-agent that referenced this pull request Aug 27, 2026
…w ids by content

Client half of NousResearch#87059. The gateway now fails ordinal-only truncation
closed for durable sessions (NousResearch#87150), which turned the mis-aimed cut into
a visible edit-resend error for any bubble without a bound rowId (edit
after an interrupted turn, unstamped resume). Make the Desktop always
produce a durable address or degrade safely:

- runRewindSubmit: when a truncation request lacks a durable address,
  resolve the target's row id by exact content against session.history
  (which ships row_id per persisted row). Resolution is
  exact-or-nothing: a unique text match wins; ambiguity is accepted only
  when the target is provably the newest persisted turn (the
  edit-after-interrupt shape). Anything else degrades to a PLAIN
  resubmit — never a guessed cut. The client ordinal is dropped either
  way (its space can diverge from the gateway's — the NousResearch#87059 root).
- planReload/planRestore: degrade failed turns to a plain resubmit
  (extends the NousResearch#86623 pattern to regenerate/restore) and carry the
  turn's persisted sourceText as the content key.
- rebindSurvivorRowIds: iterate the same failed-turn-aware ordinal
  space as the truncate math.
- session-tile-actions: reload goes through the shared runRewindSubmit
  primitive instead of a raw prompt.submit, so the tile surface gets the
  same discipline.
melon-xf added a commit to melon-xf/hermes-agent that referenced this pull request Sep 3, 2026
…w ids by content

Client half of NousResearch#87059. The gateway now fails ordinal-only truncation
closed for durable sessions (NousResearch#87150), which turned the mis-aimed cut into
a visible edit-resend error for any bubble without a bound rowId (edit
after an interrupted turn, unstamped resume). Make the Desktop always
produce a durable address or degrade safely:

- runRewindSubmit: when a truncation request lacks a durable address,
  resolve the target's row id by exact content against session.history
  (which ships row_id per persisted row). Resolution is
  exact-or-nothing: a unique text match wins; ambiguity is accepted only
  when the target is provably the newest persisted turn (the
  edit-after-interrupt shape). Anything else degrades to a PLAIN
  resubmit — never a guessed cut. The client ordinal is dropped either
  way (its space can diverge from the gateway's — the NousResearch#87059 root).
- planReload/planRestore: degrade failed turns to a plain resubmit
  (extends the NousResearch#86623 pattern to regenerate/restore) and carry the
  turn's persisted sourceText as the content key.
- rebindSurvivorRowIds: iterate the same failed-turn-aware ordinal
  space as the truncate math.
- session-tile-actions: reload goes through the shared runRewindSubmit
  primitive instead of a raw prompt.submit, so the tile surface gets the
  same discipline.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/gateway Gateway runner, session dispatch, delivery comp/tui Terminal UI (ui-tui/ + tui_gateway/) P2 Medium — degraded but workaround exists sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Desktop edit-resend truncates wrong turn — #86649 ordinal-only guard defeated by unstamped omit_messages resume

4 participants