Every compression attempt already funnels its outcome through
_emit_compression_attempt_telemetry (committed, aborted, lock-contended,
pool-saturated). That choke point now also records hermes.compression.count
once per attempt: trigger (auto / overflow / manual), outcome
(success / skipped for lock contention / failed) and the context fill from
the pre-compaction token estimate against the model window.
The pending record is set on the agent in _begin_compression_attempt and
popped by the first emit, because abort paths restore the compressor
snapshot (telemetry seed included) before they emit. compress_context and
AIAgent._compress_context gain a trigger kwarg so provider-proven overflow
recovery (turn_overflow, long-context-tier recovery) is labelled overflow
instead of being inferred from bypass_cooldown, which the stall-fallback
retry also sets.
compress_now() hands only the head to _compress_context() and rejoins the
kept exchanges in memory afterwards. With compression.in_place (the
default), the commit's archive_and_compact() archives every active row at
or below the lease watermark, which includes the kept tail's rows, and
inserts only the compacted head. Its tail_count rewind then lands on the
newest rows under the watermark, which are the kept tail itself, so those
rows end up active=0, compacted=0 with no live copy. No surface writes them
back: the CLI re-flushes only after a rotation, the gateway skips in-place
on purpose, and the TUI only swaps its in-memory history. The exchanges the
user asked to keep verbatim are gone on resume, and on the gateway from the
very next message.
compress_now() now passes copies of the tail as verbatim_tail. The in-place
commit stores head + tail in the same archive_and_compact() transaction,
joined with the same seam rejoin_compressed_head_and_tail() builds in
memory, and adds the tail to tail_count. The rewind flags then land on the
kept tail's originals and on compress()'s own carried rows, which were
left compacted=1 and shown twice in the resumed display history. The
copies are stamped as persisted and compress_now() returns the stored list
instead of rejoining the tail a second time. Rotation, no-op and
rolled-back commits are unchanged: the copies stay unstamped and the
caller's tail is rejoined as before.
_compress_context published the active commit fence with a
save/restore cell: registration order was serialized by the fence
lock, but completion order is not. When attempt B registered over A
and A finished first, A's finally popped the slot, deleting B's live
fence mid-attempt (hard_interrupt lost the handle serializing cancel
admission against B's begin_commit). B's finally then republished A's
dead fence, which lingered until the next compression. The same
clobber existed in _publish_new_fence, which overwrote the slot
unconditionally when minting the stall-fallback retry fence.
Replace the cell with a stack of per-attempt registrations. The
finally removes only its own registration and republishes the newest
live entry (or clears the slot), so a dead fence can never be
restored over a live newer attempt. The stall-fallback retry swaps
its fence inside the owning registration and publishes only while
that attempt still holds the top registration. Registration moved
inside the try so an early exception cannot strand an entry.
(cherry picked from commit 574e9945cf186071c3da23c4bb517c0cbdaf73e6)
A turn-start preflight pass whose summary stalled had no deterministic exit: the wrapper handed the
transcript back unchanged, _fail_closed_after_preflight_timeout raised for ANY over-threshold request
(even one that fits the model window — #113646: 99K of a 120K window), the loop labelled it
compression_exhausted, the messaging gateway auto-reset the session (#114594), and the existing
deterministic escalation (DETERMINISTIC_SUMMARY_ROUTE, #112420) was gated on a PRIOR stall in the same
session — unreachable once the first stall had already wiped it. /compress rode the same wrapper, so the
suggested recovery reproduced the same loop.
- request_exceeds_model_window(agent, tokens): one predicate, two consumers.
- Fits the window: the request is sent uncompressed this turn (the cooldown-blocked path already does
exactly this every turn); the summary-failure cooldown stops the retry from repeating.
- Above the window: the stall retry ladder escalates to the deterministic fallback summary on the FIRST
stall (old tool results pruned, static handoff committed through the normal lease/fence pipeline).
compression_exhausted / auto-reset is the last resort, when even that cannot shrink the transcript.
- Per-attempt observable: one INFO line when the summary call is dispatched (model, prompt chars,
prompt build ms) so a stalled attempt is distinguishable from a slow prompt build.
- Deterministic-rung wording no longer claims "again after a stall backoff".
Live repro (real AIAgent + SessionDB + local OpenAI-compatible stand-in whose summariser never answers
within the idle budget): before — FITS(73K/200K) and OVER(73K/64K) both end failed=True,
compression_exhausted=True, main_calls=0; after — FITS completes with the request sent uncompressed
(main_calls=1), OVER commits the deterministic fallback (103->25 rows) and completes.
Fold the duplicated fallback worker into the existing snapshot worker (one
parameter, functools.partial) and replace the patched-compress_context test
with an invariant test that drives the real AIAgent facade, timeout wrap and
compress_context: the primary summary route stalls silently until the host
cancels its fence, the cancelled worker persists its stall_interrupted
backoff, and the pinned fallback_chain retry must still compress.
Why: the contributor test only asserted the kwarg handoff with compress_context
patched out, so it could not catch the real gate/cooldown race that #112387
reports (retry returned the caller's own list in 15 ms).
Co-authored-by: KoNit-K <124019182+KoNit-K@users.noreply.github.com>
For each issue anchor present in BASE 63279301bc non-test .py and absent on HEAD, the BASE comment/docstring block was re-attached at the HEAD location of the code it explained (matched by the distinctive code line / enclosing def). Sentences already covered by an existing HEAD comment were deduped; the issue number always survives. Insert-only: no code lines changed.