Skip to content

release #5564: mint stable per-message ids for large-session fork alignment - #5595

Merged
nesquena-hermes merged 4 commits into
masterfrom
release/stage-5564
Jul 4, 2026
Merged

nesquena-hermes merged 4 commits into
masterfrom
release/stage-5564

Conversation

@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Release: #5564 — mint stable per-message ids so large-session forks align

Ships @b3nw's root-cause fix for fork/truncate context misalignment, rebuilt + reconciled on current master and taken through a 3-round convergent gate. Nathan approved shipping on the clean re-gate.

The bug

WebUI keeps two arrays per session — the visible transcript and the trimmed model context. Forking/truncating a large (compacted) session translates a display cut-index into the matching context cut-index via a per-message identity — an aligner that prefers a stable id (shipped in #5563). But the model-context rows carried no id (a 989-session scan found zero), so on large sessions full of look-alike rows the aligner went ambiguous and could cut mid-turn, leaving the fork's context ending on a dangling tool_use that the provider API rejects.

The fix

_assign_stable_message_ids() mints a monotonic, session-unique integer id on the per-turn result rows after context-restore and before both arrays are built, so the display and model-context copies of a logical row share the id. Wired into all commit sites (streaming main / in-band self-heal / except-path self-heal, runs/MoA sync, gateway). _restore_reasoning_metadata carries the id forward across turns. The id is stripped before the provider call (not in _API_SAFE_MSG_KEYS), same as timestamp.

Reconciliation with #5563 (shipped since this PR was authored)

#5563 already delivered the aligner half (id-preferring matcher + removed the compacted raw-slice short-circuit), so #5564's session_ops.py change was redundant and was dropped; this PR is now purely the id-minting. Tests updated to assert post-#5563 behavior.

Gate (3 rounds, converged)

  • Round 1 (Codex): found a real SILENT defect — eager save-mode left the display user row id-less while context had the id. Fixed (carry the minted id onto the eager checkpoint in the display merge).
  • Round 2 (Codex): found the same class in the stale-user repaired-boundary path (_dedupe_replayed_context_messages deep-copies the boundary row before the mint ran). Root-fixed by moving the mint to run before dedupe at all four commit sites — so the shared row is stamped before any deep-copy.
  • Round 3 (Codex): SAFE TO SHIP, no regression risk; confirmed id stays stripped from provider payloads. Verified independently that minting before dedupe can't perturb prefix/dedup logic (_message_identity keys on role+content+tool, never id).
  • Opus: SHIP — traced every persistence reader (replay/compression/recovery/import): the field is additive, backward/forward compatible with id-less legacy rows; divergent-id edges err toward under-keeping, never mis-cut; the monotonic counter is collision-free (re-seeded as max over the live arrays under the write-back lock).
  • Full suite: 12023 passed, 0 failed. Touched-file tests: 83/83.

Attribution: original author @b3nw (Co-authored-by trailer preserved).

Closes #5564

nesquena-hermes and others added 4 commits July 4, 2026 22:46
WebUI keeps two parallel arrays per session: messages (display transcript) and
context_messages (what's sent to the model). In large/compacted sessions they
diverge. Forking / in-place truncation copies a prefix of both and relies on
truncate_context_for_display_keep() to translate a display keep-index into the
matching context index — an aligner that prefers a stable per-row `id`. But the
model-context rows carried neither id nor timestamp (a 989-session live scan
found 0 with any id), so alignment fell back to fragile content-signature
matching that goes ambiguous on repeated tool calls / empty assistant turns.

This closes the DATA gap (the alignment logic itself already prefers `id` and
handles the compacted case via the matcher, shipped in #5563): mint a monotonic,
session-unique integer `id` on the per-turn result rows AFTER the context restore
and BEFORE both arrays are built, so the display and model-context copies of a
logical row share the same id.

- api/streaming.py: new _assign_stable_message_ids(); _restore_reasoning_metadata
  carries `id` forward across turns (as it already does timestamp); wired into all
  three streaming commit sites (main turn, retry, self-heal).
- api/routes.py: same mint on the runs/MoA _handle_chat_sync commit path.
- api/gateway_chat.py: mints ids on the two new rows of a gateway turn.
- The `id` is stripped before the provider API call (not in _API_SAFE_MSG_KEYS),
  same treatment as timestamp — nothing new reaches the provider.

session_ops.py alignment was already delivered by #5563 (id-preferring matcher +
compacted-case fall-through), so no change is needed there now; tests updated to
assert the post-#5563 behavior (id-bearing = exact cut; id-less = errs toward
under-keeping, never the old raw-index mis-cut).

Co-authored-by: b3nw <b3nw@users.noreply.github.com>
… merge

Codex/Opus gate finding: in eager session-save mode the current user turn is
checkpointed into s.messages before the agent runs (no id yet); after
_assign_stable_message_ids stamps the returned result row, the display merge
keeps the durable eager checkpoint and skips the stamped result — leaving the
display row id-less while its context twin carries the minted id, silently
defeating id-based fork/truncate alignment for eager-mode users. The merge now
copies the minted id onto the kept checkpoint when it lacks one (guarded so it
can't overwrite or duplicate). Opus verified cross-array id pairing holds across
the real 3-turn transform pipeline.
…boundary path)

Codex round-3 gate finding: _dedupe_replayed_context_messages deep-copies the
stale-user repaired boundary row (streaming.py:4501) into the context array. When
the mint ran AFTER dedupe, that deep-copied context row was no longer the shared
result dict, so it stayed id-less while the display copy got the minted id —
re-opening the cross-array id divergence for the stale-repair path. Fix: move
_assign_stable_message_ids() to run immediately after _restore_reasoning_metadata
and BEFORE _dedupe_replayed_context_messages at all four commit sites (streaming
main / in-band self-heal / except-path self-heal + routes runs/MoA), so the shared
result rows are stamped before any deep-copy — both arrays inherit the id.
@nesquena-hermes
nesquena-hermes merged commit 73643f2 into master Jul 4, 2026
18 checks passed
@nesquena-hermes
nesquena-hermes deleted the release/stage-5564 branch July 4, 2026 23:28
@greptile-apps

greptile-apps Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR mints stable, session-unique integer id fields on per-turn result rows so the fork/truncate aligner can match display and model-context rows by id rather than falling back to fragile content-signature matching that fails on large sessions full of structurally-identical tool-call/tool-result pairs.

  • _assign_stable_message_ids is added to streaming.py and wired at four commit sites (streaming main, two except-path self-heals, runs/MoA sync, gateway), always running after _restore_reasoning_metadata (so carried-forward ids survive) and before _dedupe_replayed_context_messages (so deep-copied boundary rows are stamped before any copy is taken).
  • _restore_reasoning_metadata now carries the id forward across turns alongside timestamp, preventing historical rows from being re-minted to a different id each turn.
  • _merge_display_messages_after_agent_result gains a fix for eager-session-save mode, where the display checkpoint is written before the mint runs — the minted id is now copied onto the kept checkpoint so display and context copies share it.

Confidence Score: 4/5

The core minting logic and all four production call sites look correct; the id field is additive and stripped before provider calls, so legacy sessions and the API contract are unaffected.

The gateway path silently drops minting failures at DEBUG log level, and the shared-id integration test runs dedupe before mint (opposite to production), so the stale-user boundary invariant that motivated the ordering is not actually exercised by the test suite.

tests/test_context_message_stable_ids.py (call order in test_both_arrays_share_id_for_same_logical_row) and api/gateway_chat.py (log level on minting failure)

Important Files Changed

Filename Overview
api/streaming.py Adds _assign_stable_message_ids and wires it at three commit sites (streaming main, two except-path self-heals); extends _restore_reasoning_metadata to carry ids forward; patches _merge_display_messages_after_agent_result for the eager-checkpoint id gap. All four call sites correctly order mint before dedupe.
api/gateway_chat.py Adds id minting for gateway-path turns; minting failure is silently swallowed with only a DEBUG-level log, which could hide regressions.
api/routes.py Wires _assign_stable_message_ids into _handle_chat_sync (runs/MoA sync path), correctly placed before _dedupe_replayed_context_messages.
tests/test_context_message_stable_ids.py New test file with good coverage of the minting helper, id carry-forward, and aligner resolution. The shared-id integration test inverts the production call order (dedupe before mint), passing only because prev_context is empty — it doesn't validate the stale-user boundary invariant that motivated moving the mint call.
tests/test_issue1217_transcript_compaction.py Updated existing compaction test to tolerate the new id field via _no_id projection; adds assertions that brand-new turn rows carry unique integer ids.
CHANGELOG.md Release changelog entry added by the release process, as expected for this repo.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Agent
    participant streaming as streaming.py
    participant ids as _assign_stable_message_ids
    participant dedupe as _dedupe_replayed_context_messages
    participant display as _merge_display_messages
    participant aligner as truncate_context_for_display_keep

    Agent->>streaming: result_messages (no id)
    streaming->>streaming: _restore_reasoning_metadata() carry existing id forward
    streaming->>ids: mint ids on result_messages
    note over ids: seed = max(existing ids), new rows get seed+1, seed+2...
    ids-->>streaming: result_messages now have ids
    streaming->>dedupe: _dedupe_replayed_context_messages() deep-copies happen AFTER mint
    dedupe-->>streaming: context array (shared id)
    streaming->>display: _merge_display_messages() eager checkpoint carry id to kept row
    display-->>streaming: display array (same id)
    note over streaming: s.context_messages and s.messages share identical id per logical row
    aligner->>aligner: fork/truncate match by id, no ambiguity on large sessions
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"}}}%%
sequenceDiagram
    participant Agent
    participant streaming as streaming.py
    participant ids as _assign_stable_message_ids
    participant dedupe as _dedupe_replayed_context_messages
    participant display as _merge_display_messages
    participant aligner as truncate_context_for_display_keep

    Agent->>streaming: result_messages (no id)
    streaming->>streaming: _restore_reasoning_metadata() carry existing id forward
    streaming->>ids: mint ids on result_messages
    note over ids: seed = max(existing ids), new rows get seed+1, seed+2...
    ids-->>streaming: result_messages now have ids
    streaming->>dedupe: _dedupe_replayed_context_messages() deep-copies happen AFTER mint
    dedupe-->>streaming: context array (shared id)
    streaming->>display: _merge_display_messages() eager checkpoint carry id to kept row
    display-->>streaming: display array (same id)
    note over streaming: s.context_messages and s.messages share identical id per logical row
    aligner->>aligner: fork/truncate match by id, no ambiguity on large sessions
Loading

Reviews (1): Last reviewed commit: "release #5564: mint stable per-message i..." | Re-trigger Greptile

Comment on lines +91 to +95
# --- context path (mirrors streaming.py commit site) ---
next_ctx = _restore_reasoning_metadata(prev_context, result_messages)
next_ctx = _dedupe_replayed_context_messages(prev_context, next_ctx, "hello")
_assign_stable_message_ids(result_messages, prev_display, prev_context)
context_out = _deduplicate_context_messages(next_ctx)

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 Test order inverts the production invariant it claims to mirror

Lines 93–94 call _dedupe_replayed_context_messages before _assign_stable_message_ids, but the real streaming commit site (and all three other call sites) do the opposite — mint first, then dedupe — precisely because _dedupe_replayed_context_messages can deep-copy a stale-user boundary row out of result_messages before the mint runs, leaving that copy id-less (Round 2 of the gate). The test comment says it "mirrors streaming.py commit site" but the order is reversed. The test still passes because prev_context = [], which makes dedupe a no-op, so the deep-copy hazard never fires. A follow-up test with a non-empty prev_context containing a stale-user boundary row would expose the gap, and this test as written gives no regression protection for that path.

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/gateway_chat.py
Comment on lines +849 to +858
try:
from api.streaming import _assign_stable_message_ids

_assign_stable_message_ids(
[user_msg, assistant_msg],
previous_context,
list(getattr(s, "messages", None) or []),
)
except Exception:
logger.debug("Failed to stamp stable ids on gateway turn rows", exc_info=True)

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 Silent id-minting failure only logged at DEBUG

The except Exception block swallows any error from _assign_stable_message_ids and logs only at DEBUG level. In production, DEBUG is typically disabled, so a minting failure here (e.g., a future refactor that moves or renames _assign_stable_message_ids) would leave gateway-path turns silently id-less — reverting them to the pre-fix content-signature matching with no visible signal. The fallback is intentional and correct, but bumping the log level to WARNING would make regressions detectable without adding any noise to the happy path.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant