Skip to content

Guard state.db replay after compressed context anchors - #4695

Closed
franksong2702 wants to merge 1 commit into
nesquena:masterfrom
franksong2702:franksong2702/fix-4249-compression-anchor-boundary
Closed

franksong2702 wants to merge 1 commit into
nesquena:masterfrom
franksong2702:franksong2702/fix-4249-compression-anchor-boundary

Conversation

@franksong2702

@franksong2702 franksong2702 commented Jun 22, 2026 •

Copy link
Copy Markdown
Contributor

Thinking Path

  • investigate(compress): user reports /compress + auto-compress 'sends full history back' next turn (cache miss/cost) — message-level resend NOT reproduced, needs reporter data #4249's reopened evidence shows the model-facing context, not the visible transcript, can be repopulated with stale state.db rows immediately after compression.
  • The risky boundary is reconciled_state_db_messages_for_session(..., prefer_context=True): once context_messages is compacted, content-fingerprint alignment against the uncompressed state.db can fall back to returning old transcript rows.
  • Compression persists a visible anchor key. That anchor is the right boundary for state.db deltas, but only when it can be verified.
  • Maintainer review caught one remaining leak: compressed context_messages with no anchor key still fell through to the old state.db delta path. This update fails closed for compacted context when the anchor is missing or unverifiable.

What Changed

  • Added compression-anchor boundary handling in api/models.py.
  • Added timestamp coercion for numeric and ISO-8601 anchor timestamps, including Z timestamps from real sessions.
  • When compacted session.context_messages are used with prefer_context=True, state.db rows are sliced only after a verified role/text/attachment/timestamp anchor match.
  • If compacted context has no anchor key, no matching timestamped state row, or otherwise cannot be trusted, reconciliation returns the compacted context_messages only.
  • Kept the existing prefix-delta behavior for ordinary non-compacted context_messages, so mirrored Session Arc Summary contexts can still admit fresh follow-up rows.
  • Added regression coverage for verified anchors, missing anchor matches, ts: None, missing anchor keys, and ISO timestamp anchors.

Why It Matters

This prevents the post-compression next turn from silently rebuilding model context with the old uncompressed transcript. For long sessions, that is the failure mode behind the apparent "compression did nothing" token/cache behavior described in #4249.

Contract Routing

Task type: runtime/model-context bugfix
Touched areas: api/models.py state.db reconciliation, compression anchor handling, transcript compaction tests
Relevant public docs:

  • AGENTS.md
  • CONTRIBUTING.md
  • docs/CONTRACTS.md
  • docs/rfcs/webui-run-state-consistency-contract.md
    Scope boundaries: model-facing context reconstruction only; no UI rendering or visible transcript behavior changes.
    Evidence needed before claiming done: regression tests prove stale state.db rows are not replayed after compacted context when the anchor is missing or incomplete, and timestamped anchors still allow fresh post-anchor rows.

Verification

  • ./scripts/test.sh tests/test_issue1217_transcript_compaction.py -q -> 31 passed
  • git diff --check origin/master
  • GitHub Actions are being watched after the latest force-with-lease update.

Risks / Follow-ups

  • This intentionally prefers dropping an untrusted state.db delta over polluting compacted model context with stale history.
  • The fail-closed path is limited to explicit context-compaction markers; ordinary context reconciliation keeps its prior prefix-delta behavior.
  • No UI screenshots: this change is model-context reconstruction only.
  • No CHANGELOG.md entry in this contributor PR; the release process owns changelog entries.

Fixes #4249.

Model Used

AI-assisted implementation and review.

  • Coordinator: OpenAI GPT-5 Codex in Codex desktop.
  • Worker session: Hermes WebUI using @openai-codex:gpt-5.3-codex-spark (openai-codex) at http://127.0.0.1:8787/session/ad0e0f643364.

@greptile-apps

greptile-apps Bot commented Jun 22, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a fail-closed guard to reconciled_state_db_messages_for_session so that, after context compaction, state.db rows are only re-admitted past a verified compression-anchor timestamp boundary — preventing stale transcript rows from silently re-entering the model context.

  • Introduces _state_db_anchor_index, _context_messages_include_compression_marker, and two timestamp/text normalisation helpers to locate the anchor row in state.db and slice it as the post-compaction delta boundary.
  • When context_messages contain a [context compaction marker and the anchor cannot be resolved (missing key, unverifiable timestamp, no matching state.db row), reconciliation returns the compacted context only rather than falling back to the unpredictable prefix-delta path.
  • Adds five regression tests covering verified anchor, missing anchor match, absent anchor key, ISO-format Z timestamps, and ts: None.

Confidence Score: 5/5

Safe to merge; the fail-closed semantics are correct and the existing prefix-delta path for non-compacted context is unchanged.

The anchor-boundary logic is well-structured: every untrusted state gives an early return of compacted context only, and only a fully verified timestamp + role + text match admits post-anchor rows. The five regression tests exercise the key branches. The one inconsistency — using _message_content_text inside _context_messages_include_compression_marker rather than the same extractor as is_context_compression_marker — cannot be triggered by any content shape current sessions produce, so it does not affect correctness today.

No files require special attention; both changed files are straightforward and well-covered by the new tests.

Important Files Changed

Filename Overview
api/models.py Adds compression-anchor boundary detection and fail-closed state.db replay guard for compacted context. Logic is sound; a minor text-extractor inconsistency in _context_messages_include_compression_marker could theoretically suppress fail-closed behavior for non-standard content shapes.
tests/test_issue1217_transcript_compaction.py Adds five regression tests covering verified anchor, unverifiable anchor, missing anchor key, ISO-format anchor timestamp, and missing ts. Coverage is thorough for the documented failure modes.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[reconciled_state_db_messages_for_session] --> B{prefer_context\nand context_messages?}
    B -- No --> G[state_db_delta_after_context\nfull state_messages]
    B -- Yes --> C{context_messages include\ncompaction marker?}
    C -- No --> G
    C -- Yes --> D{compression_anchor_message_key\npresent?}
    D -- No --> E[Return context_messages only\nfail-closed]
    D -- Yes --> F{_state_db_anchor_index\nfinds match?}
    F -- No --> E
    F -- Yes --> H[Slice state_messages\nto post-anchor rows]
    H --> I[state_db_delta_after_context\npost-anchor state_messages]
    G --> J[merge_session_messages_append_only]
    I --> J
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[reconciled_state_db_messages_for_session] --> B{prefer_context\nand context_messages?}
    B -- No --> G[state_db_delta_after_context\nfull state_messages]
    B -- Yes --> C{context_messages include\ncompaction marker?}
    C -- No --> G
    C -- Yes --> D{compression_anchor_message_key\npresent?}
    D -- No --> E[Return context_messages only\nfail-closed]
    D -- Yes --> F{_state_db_anchor_index\nfinds match?}
    F -- No --> E
    F -- Yes --> H[Slice state_messages\nto post-anchor rows]
    H --> I[state_db_delta_after_context\npost-anchor state_messages]
    G --> J[merge_session_messages_append_only]
    I --> J
Loading

Reviews (5): Last reviewed commit: "fix(compression): guard state replay aft..." | Re-trigger Greptile

Comment thread CHANGELOG.md Outdated
Comment on lines +6 to +8
### Fixed

- **Compressed model context no longer replays stale state.db history when the compression anchor is incomplete.** When a session has compacted `context_messages`, WebUI now only resumes state.db rows after a timestamp-verified compression anchor; missing or mismatched anchors fail closed to the compacted context instead of appending the old uncompressed transcript back into the next model prompt. Fixes #4249.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 CHANGELOG.md edited directly in a contributor PR

This repo's policy is that CHANGELOG.md is maintained exclusively by the release process via release: vX.Y.Z commits authored by the release agent — individual contributor PRs should not touch it directly. Please revert these lines; the release agent will pick up the entry from the PR description / commit messages when cutting the next release.

Rule Used: Do not flag missing CHANGELOG.md updates on indivi... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment thread api/models.py
@franksong2702
franksong2702 force-pushed the franksong2702/fix-4249-compression-anchor-boundary branch 2 times, most recently from 147049f to 44440de Compare June 22, 2026 10:22

@nesquena-hermes nesquena-hermes left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @franksong2702 — this is the right boundary to fix (#4249's stale post-compression context replay), and the timestamp-verified-anchor approach is sound. The gate reproduced one CORE gap that has to close before it ships, plus a follow-up under-coverage:

CORE (blocker, reproduced): the no-anchor case does not fail closed

reconciled_state_db_messages_for_session guards the new anchor logic with if anchor_key: (models.py:5278). When a compressed context_messages session has no compression_anchor_message_key (absent / None), the guard is skipped entirely and execution still falls through to state_db_delta_after_context() — which replays the stale uncompressed transcript into model-facing context. That's the exact failure mode the PR targets, left open for the no-anchor path.

Reproduced (compressed context + anchor_key=None):

context_messages=[
    {"role":"user","content":"[context compaction] summary of earlier work"},
    {"role":"user","content":"new question"},
]
compression_anchor_message_key=None
state=[{"role":"user","content":"old question","ts":1.0},
       {"role":"assistant","content":"old answer","ts":2.0},
       {"role":"user","content":"new question","ts":3.0}]
# result → ['[context compaction] summary…', 'new question', 'old question', 'old answer']  ← STALE LEAK

Fix: when using_context_messages AND the context is a compressed context (detect via api.compression_anchor.is_context_compression_marker over local_messages), require a truthy, timestamp-verifiable anchor — if it's missing or unverifiable, return list(local_messages) before state_db_delta_after_context(). Keep the existing delta behavior only for ordinary (non-compressed) context_messages so you don't over-suppress fresh turns on normal sessions. Please add a regression test for compressed-context + compression_anchor_message_key=None asserting no stale state.db rows return.

Follow-up (not a blocker, but it defeats the fix for the target sessions)

The anchor timestamp match appears to handle only numeric epoch-second timestamps. The continuation/LCM sessions #4249 is actually about carry ISO-8601 timestamps, so the anchor delta path silently no-ops for exactly those sessions (falls through to old behavior — no stale leak, but no fix either). Worth ISO-aware timestamp parsing + a regression test + a debug log when an anchor is present but can't be verified, so this doesn't look fixed while quietly doing nothing on the real-world case.

Everything else checks out: the visible-transcript path is unaffected (double-gated on prefer_context + using_context_messages), no perf regression, and the timestamp-match false-positive risk is acceptably bounded (role + attachment-count + normalized-text). Happy to re-review as soon as the no-anchor compressed-context case fails closed.

@nesquena-hermes nesquena-hermes added the size:M Medium PR (≤10 files, ≤250 LOC) label Jun 22, 2026
@franksong2702
franksong2702 force-pushed the franksong2702/fix-4249-compression-anchor-boundary branch from 44440de to bdebd52 Compare June 22, 2026 12:03
@franksong2702

Copy link
Copy Markdown
Contributor Author

Updated the PR to close the requested gap:

  • compacted context_messages now fail closed when compression_anchor_message_key is missing or unverifiable, before the old state.db delta path can append stale rows;
  • anchor timestamps now accept numeric and ISO-8601 values, including Z timestamps;
  • ordinary non-compacted context reconciliation keeps the existing prefix-delta behavior.

Local verification: ./scripts/test.sh tests/test_issue1217_transcript_compaction.py -q -> 31 passed, plus git diff --check origin/master. Latest GitHub Actions on this head are green.

@franksong2702
franksong2702 force-pushed the franksong2702/fix-4249-compression-anchor-boundary branch from bdebd52 to 548a49f Compare June 22, 2026 12:10
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Re-reviewed at head 548a49ff0. The CORE blocker from my last pass is closed, the ISO follow-up is in, and CI is green across the matrix (test (3.11/3.12/3.13), lint, browser-smoke all SUCCESS). This is good to ship from my side. Walking through what I verified:

CORE gap (no-anchor compressed context) — closed ✅

The previous head guarded the anchor logic with if anchor_key: and let the no-anchor compressed path fall through to state_db_delta_after_context(). The new head gates on a using_context_messages flag and a dedicated compressed-context check, and both the missing-key and unverifiable-anchor paths now return list(local_messages) before the delta call (api/models.py:5300-5319):

if using_context_messages:
    compressed_context = _context_messages_include_compression_marker(local_messages)
    anchor_key = getattr(session, "compression_anchor_message_key", None)
    if compressed_context:
        if not anchor_key:
            ... return list(local_messages)            # no anchor → fail closed
        anchor_index = _state_db_anchor_index(state_messages, anchor_key)
        if anchor_index is None:
            ... return list(local_messages)            # unverifiable → fail closed
        state_messages = list(state_messages or [])[anchor_index + 1 :]
state_messages = state_db_delta_after_context(local_messages, state_messages)

The reproduced leak (compression_anchor_message_key=None + compacted context) now returns the compacted context only. test_prefer_context_reconcile_fails_closed_when_compressed_context_has_no_anchor_key asserts exactly that (reconciled == sidecar_context).

ISO-8601 anchor timestamps — closed ✅

_compression_anchor_timestamp_as_float (api/models.py:4906-4916) now tries float() first, then ISO parsing with Z normalization:

return datetime.datetime.fromisoformat(str(value).strip().replace("Z", "+00:00")).timestamp()

test_prefer_context_reconcile_accepts_iso_compression_anchor_timestamp confirms a 2026-06-22T09:03:00Z anchor matches and slices to the single post-anchor row. The ts: None fail-closed branch is also covered (_state_db_anchor_index returns None when anchor_ts is None, with the comment correctly noting text-only fallback would risk matching stale legacy rows — api/models.py:4945-4949).

One non-blocking nit (optional polish)

_context_messages_include_compression_marker (api/models.py:4919-4929) detects the marker via is_context_compression_marker(message) — which extracts text through compression_anchor._content_text over {"text","input_text","output_text"} parts — but then re-derives the narrowing prefix with _message_content_text (api/models.py:2255), which only pulls item.get('text') from list parts. For any compacted summary that lives in provider-style input_text/output_text parts, the marker would be detected but the startswith("[context compaction") re-check could read empty/different text and skip the fail-closed branch. Greptile flagged the same and concluded (and I agree) it can't be triggered by content shapes current sessions actually produce, so it's not a blocker — but reusing compression_anchor._content_text (or threading the already-extracted text out of is_context_compression_marker) would remove the latent divergence.

Nothing here blocks merge. The fail-closed semantics are correct, the visible-transcript path stays untouched (double-gated on prefer_context + using_context_messages), and the regression coverage matches the failure modes from #4249. Nice work closing both items.

@nesquena-hermes nesquena-hermes added size:L Large PR (>10 files or >250 LOC) and removed size:M Medium PR (≤10 files, ≤250 LOC) labels Jun 22, 2026
nesquena-hermes added a commit that referenced this pull request Jun 22, 2026
Release v0.51.589 — Release UV (guard state.db replay after compressed anchors, #4695)
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Shipped in v0.51.589 (Release UV) 🚀 — thank you @franksong2702.

This guards the state.db replay path behind a timestamp-verified compression anchor exactly as your fix did: compressed context_messages with a missing or unverifiable anchor now fail closed to the compacted context, while non-compressed context keeps the existing prefix-delta behavior untouched. Closes #4249.

Gate before ship (all three legs, run on your latest head 548a49ff):

  • Full pytest suite: 10097 passed, 0 failed
  • Codex advisor: SAFE TO SHIP (fail-closed on every untrusted-anchor path, normal path untouched)
  • Opus advisor: CLEAN, no MUST-FIX

One non-blocking, latent-not-live follow-up was noted (unifying the compression-marker prefix re-check with the card-content extractor so the guarantee never depends on card-content shape) — it doesn't fire today since the canonical card is built as a plain string. Filing that as a small separate hardening; your code shipped as reviewed. Thanks again for the clean, well-tested fix on a delicate path.

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

Labels

size:L Large PR (>10 files or >250 LOC)

Projects

None yet

2 participants