Skip to content

fix(gateway): make a history-dropping prompt.submit prove it meant to (#80763) - #80802

Merged
kshitijk4poor merged 2 commits into
NousResearch:mainfrom
HexLab98:fix/80763-gateway-confirm-truncate
Aug 7, 2026
Merged

fix(gateway): make a history-dropping prompt.submit prove it meant to (#80763)#80802
kshitijk4poor merged 2 commits into
NousResearch:mainfrom
HexLab98:fix/80763-gateway-confirm-truncate

Conversation

@HexLab98

@HexLab98 HexLab98 commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Fixes #80763.

Summary

prompt.submit honored truncate_before_user_ordinal on every request. A client that carried a leftover ordinal into an ordinary send issued something the gateway could not tell apart from a real rewind — same method, same field shape, an in-range target — and the cut was applied with replace_messages(), which DELETEs the durable rows and drops them from FTS. The reporter lost 244 messages (296 → 52) across two days of work, with no confirmation and nothing to restore from.

The existing guard only covered the ordinal-0 edge, where the cut empties the transcript. A mid-session ordinal produced a non-empty result and sailed straight past it — which is exactly the shape both logged incidents had (ordinal=2, 296 → 52 and 53 → 52).

Nothing server-side can distinguish a stale ordinal from a deliberate rewind, because the two requests are identical. Only the client knows whether the user asked for a rewind, an edit, or a regenerate — so it now has to say so:

  • Gateway (tui_gateway/methods_prompt.py): an ordinal without confirm_truncate is refused with code 4029. The refusal is logged with the session and the size of the history it protected, and neither session["history"] nor the DB is touched. The confirm_empty_truncate gate stays on top of it for the whole-transcript edge.
  • Desktop (use-prompt-actions/rewind.ts): truncateSubmitParams — the one place every rewind, edit, regenerate, and restore-checkpoint path builds these params — always sets the flag, so all four paths are covered by construction.

The guard fails closed on purpose. A desktop build older than this change loses rewind against a newer gateway and gets an actionable error, instead of silently deleting a conversation; for a destructive, unrecoverable write that is the right side to fail on.

Making the truncation itself reversible (the issue's suggestion 2 — soft-archive the dropped rows the way archive_and_compact does instead of DELETEing them) is worth doing and is deliberately not in this PR: it is a storage-layer change with its own retention and restore-UX questions, and it does not need to block closing the vector that caused the loss.

Test plan

  • scripts/run_tests.sh tests/test_tui_gateway_server.py -q — 519 passed.
    • New test_prompt_submit_refuses_unconfirmed_nonempty_truncation reproduces the report: a 6-message history, ordinal=2 on an ordinary submit, no confirm flag. Asserts 4029, that history / history_version / running are untouched, that replace_messages was never called, that falsey confirm values do not satisfy the opt-in, and that confirm_empty_truncate cannot stand in for rewind intent on a non-empty cut.
    • Existing truncation tests now send confirm_truncate and still pass, so edit / regenerate / display_kind-skipping / persist-failure behavior is unchanged for a confirmed rewind.
  • npx vitest run src/app/session in apps/desktop — 443 passed. rewind.test.ts asserts no ordinal is ever built without the flag; the three index.test.tsx restore-checkpoint assertions cover the full wire params.
  • npm run typecheck in apps/desktop — clean.

prompt.submit honored truncate_before_user_ordinal on every request. A
client that carried a leftover ordinal into an ordinary send therefore
issued something the gateway could not tell apart from a real rewind —
same method, same shape, an in-range target — and the cut was applied
with replace_messages(), which DELETEs the durable rows. One report lost
244 messages (296 -> 52) with no prompt and nothing to restore from.

The existing guard only covered ordinal 0, where the cut empties the
transcript; a mid-session ordinal sailed straight through. Only the
client knows whether a submit is a rewind, an edit, or a regenerate, so
require it to say so: an ordinal without confirm_truncate is refused on
4029 and neither memory nor the DB is touched. Desktop sends the flag
from the one place that builds these params, so every rewind path is
covered and a stale build fails closed with an actionable error instead
of quietly deleting a conversation.
External hosts speak this protocol directly, so the parameter that
rewrites a session's stored transcript should not be folklore. Document
what each truncation field means, that an ordinal without
confirm_truncate is refused, and that a client must never hold the
ordinal in state across ordinary submits.
@alt-glitch alt-glitch added type/bug Something isn't working P0 Critical — data loss, security, crash loop comp/tui Terminal UI (ui-tui/ + tui_gateway/) comp/desktop Electron desktop app (apps/desktop/*) area/sessions Session lifecycle, resume, persistence, history sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Aug 7, 2026
@poisdahl

poisdahl commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

I found one remaining archive-loss path in this PR.

On an in-place-compacted session, the gateway history contains only the active compacted view, while the retained pre-compaction rows remain inactive under the same session id. A confirmed rewind currently calls replace_messages without active_only, whose default is to delete every row for that session. A legitimate edit/regenerate can therefore erase the entire retained compaction archive even though this PR correctly gates stale or accidental truncation.

I prepared a minimal supplement directly on top of the current PR head 00b5fc4:

  • pass active_only=True for the confirmed rewind replacement;
  • add a real SessionDB regression that creates inactive rows through archive_and_compact, dispatches the real prompt.submit path, and verifies the full archived rows remain identical;
  • update the affected test doubles to accept and, on reached paths, require the flag.

Commit: poisdahl@fa19ae7

To take it as-is:

git fetch https://github.com/poisdahl/hermes-agent.git codex/pr-80802-truncate-archive-safety
git cherry-pick fa19ae7a39f3788aac4435d350a3e9acc6530ec2

Validation:

  • the new regression fails on the exact PR head and passes with the supplement;
  • related prompt-submit truncation tests: 8 passed;
  • full affected test module: 520 passed;
  • Ruff check and git diff --check pass.

An independent read-only Opus 5 xhigh review reverified the exact commit, parent, tree, and diff and returned PASS with no P0/P1 findings. One non-blocking behavior note is worth making explicit: active_only=True also stops this path from collaterally purging inactive /undo audit rows. All normal resume, live-history, recent-message, and search paths filter those rows, so they are not resurfaced; retaining them is also consistent with rewind_to_message documenting them as audit/forensic history.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/sessions Session lifecycle, resume, persistence, history comp/desktop Electron desktop app (apps/desktop/*) comp/tui Terminal UI (ui-tui/ + tui_gateway/) P0 Critical — data loss, security, crash loop sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades 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.

Desktop client silently truncates session history on ordinary prompt.submit (truncate_before_user_ordinal) — unrecoverable data loss

4 participants