fix(store): give rewind payloads their own shorter horizon, and count the reclamations a replay absorbs - #204
fix(store): give rewind payloads their own shorter horizon, and count the reclamations a replay absorbs#204amiddavid wants to merge 3 commits into
Conversation
… the reclamations a replay absorbs Closes #190. #188 bounded the rewind reserve — an entry cap, a byte budget, an evictable floor — but left a slot released ONLY by the TTL, and both namespaces shared one. The store is a single process-wide instance (cmd/context-guru-proxy builds it once), so a busy period could hold the reserve saturated for the whole of ttl_seconds — ~2.8h at the default sliding 10,000s — and while it is saturated every new removal is refused for every session at once. The failure is not broken markers, which #188 fixed; it is savings falling to zero long after the load that caused it is gone. WHY THE PAYLOAD HORIZON CAN BE SHORT, WHICH IS THE WHOLE ARGUMENT The issue listed five options and said picking among them without a re-run would be another guess at a default. It turns out the choice is decidable from the code, because the issue's stated objection to option 3 — that a shorter payload TTL "shortens the window in which a long-idle session can still expand" — is not what the tree does. A payload, unlike a frozen decision, is RE-DERIVABLE from the request in flight: - A frozen decision is the replacement bytes the provider ALREADY CACHED. Nothing else holds them; losing one flips an already-cached message and re-writes the suffix at ~11.5x the read price. It has to survive a whole long-horizon task including idle gaps, which is what DefaultTTL is sized for. - A payload is a copy of content the AGENT RE-SENDS every turn. Every offloader replays its frozen decision on every turn REGARDLESS of the cache-tail gate — it must, or the message reverts full→compacted→full and churns the KV cache (mask.go:79, "Reapply a previously-frozen mask on EVERY turn") — and that replay calls commitRefresh, which PutStashes the payload again from the message text it just read. So a live marker's payload has its deadline slid every turn, and one already reclaimed is RE-CREATED on the REQUEST path — before the request goes upstream, and therefore before any expand call in the response could ask for it. The horizon a payload needs is one INTER-TURN GAP, not one session. That makes this option 3 arrived at by mechanism rather than by guess, and it introduces no irreversibility (option 1), needs no arbitrary share number (option 2, which #188 declined for that reason), and needs no session-end signal that does not exist (option 4). WHAT CHANGED - store.Options.StashTTLSeconds (yaml stash_ttl_seconds), DefaultStashTTL = 1800s. Not a fresh guess: 1800s is the value DefaultTTL's own comment records as too short for a FROZEN DECISION (headroom's CCR store), reused in the one namespace whose horizon it does fit. - Capped at ttl_seconds. A payload outliving the decision that names its marker is memory nothing can ever read — no replay stamps that marker again — so an operator who shortens ttl_seconds gets the shorter of the two rather than a reserve held open by dead payloads, which would be this same saturation arrived at by config. - Memory.ttlFor picks the horizon per entry, keyed on the STASH FLAG rather than the key, because a payload's key is a bare content hash the store cannot recognise (see Stasher). Applied at every write and at Get's sliding refresh, including Put, so a plain Put cannot hand a payload the long horizon. - stash_revived / cg_stash_revived_total: payloads written again under a key the TTL had taken. stash_expired reported two outcomes at once — a payload nobody wanted, and a payload an outstanding marker still needs — and those call for opposite responses, which is the same ambiguous-counter shape #188 split stash_refused/stash_missing over and #200 states as a general rule. Bounded FIFO set of reclaimed keys, mirroring lostFrozen. AND ONE HELP TEXT THAT NAMED THE WRONG REMEDY cg_stash_missing_total, /stats and two docs pages all said the fix for a dangling marker is ttl_seconds. A replay re-stashes the payload it re-derived, so a marker only dangles when THAT WRITE WAS ALSO REFUSED — the remedy is the reserve first (max_entries / stash_max_bytes) and stash_ttl_seconds only if stash_expired is what is taking the payloads. That was already incomplete before this change; included here because this change alters what expiry means, so leaving it would have made it wrong rather than merely partial. THE EXPOSURE THIS LEAVES, STATED RATHER THAN WAVED AT A turn that runs NO pipeline performs no refresh — an x-context-guru-bypass request, or the agent-compaction bypass — so an unbroken run of bypassed turns longer than stash_ttl_seconds could outlive a payload whose marker is still live in the transcript. Both are single-request events in practice, and if it happens the outcome is the already-reported one (stash_missing), not a silent loss. Documented at docs/reference/config.md. VERIFICATION gofmt -l . clean, go vet ./... clean, go test ./... and go test -race over the touched packages all pass (Go 1.26.4, eval box). Five new tests, each REVERT-VERIFIED to fail without the change: store.TestPayloadsExpireSoonerThanTheDecisionsThatNameThem the split itself — the reserve releases a slot at the payload horizon while the pinned decisions written with it are still live. Reverted (one shared horizon) -> "the reserve released no slot at the payload horizon (2h46m40s)". store.TestAReclaimedPayloadRewrittenByAReplayIsCountedAsRevived the counter, including that a FIRST stash and a refresh of a LIVE payload are both not revivals. Reverted -> "the payload outlived stash_ttl_seconds". store.TestThePayloadHorizonNeverOutlivesTheDecisionHorizon the cap, and that the knob stays configurable and defaulted. Reverted -> "stash_ttl_seconds must stay configurable, got 2h46m40s". store.TestDefaultPayloadHorizonIsWellInsideTheDecisionHorizon the constants' RELATIONSHIP, so the next edit to either has to face it. offload.TestAReclaimedPayloadIsReDerivedBeforeItsMarkerGoesUpstream THE LOAD-BEARING ONE: the property the short horizon rests on, driven end to end through mask over two turns. If it fails, DefaultStashTTL must go back to DefaultTTL and not the other way round. offload.TestAReclaimedPayloadThatCannotBeReStashedIsReportedMissing the case re-derivation cannot rescue — a full reserve at replay time — and that it is reported rather than silent. The re-derive test was verified against a SECOND mutation, not only against the shared-horizon revert, because the shared-horizon revert only proves the reclamation happened: with commitRefresh changed to read the payload instead of re-stashing it, the test fails on its own subject — "the marker on the wire does NOT resolve after its payload was reclaimed". Without that second check the assertion could have been passing on a payload that never left. Two contract tests updated as they are designed to require: proxy.TestEverySnapshotFieldIsExportedOrExempt (StashRevived is exported from StashStats(), like its four siblings, so it is listed in notExportedWhy for that reason) and proxy.TestStatsShapeIsUnchanged (stash_revived added to the reviewed top-level contract, in alphabetical position). NOT IN THIS CHANGE, deliberately: option 5 from the issue's comment — gating extract_llm's exploring() on reserve health. It bounds a different cost (repeated model calls during saturation) in a different package and needs its own decision about the threshold, and this change reduces the duration of the episode it is about. Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com> Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
amiddavid
left a comment
There was a problem hiding this comment.
Reviewed the mechanism first, as you asked. The load-bearing claim survives for the component the test drives (mask via reapplyFrozen), and I could not break it — the re-derivation really does happen on the request path, commitRefresh really is unrefusable, and TestAReclaimedPayloadIsReDerivedBeforeItsMarkerGoesUpstream is non-vacuous (the expand.Resolve before turn 2 is what forces the reclamation, and the second mutation you describe is the right one).
What I think is wrong is the universal quantifier: "every offloader replays its frozen decision on every turn" is not true of every offloader, and the two ways it fails have different failure shapes — one of which is not the stash_missing your exposure paragraph promises. Both are in the comments/docs rather than the code, so neither is a revert; the second one I would want stated before this merges, because the docs table is what an operator will reason from.
Answers to your two specific questions are inline at ttlFor (a) and at the cap (b). Short version: (a) I audited all five e.expires = sites and believe the fix is complete — but the completeness rests on stashTTL <= ttl, so I would make it structural rather than audited. (b) the cap is right, but it is silent and /config reports the pre-cap value.
Nothing here blocks on a re-run, and the stash_revived/stash_expired split is the right shape.
| // holds them; losing one flips an already-cached message and re-writes the suffix at ~11.5x | ||
| // the read price. It needs to survive a whole long-horizon task, idle gaps included, which | ||
| // is what DefaultTTL is sized for. | ||
| // - A payload is a copy of content the AGENT RE-SENDS every turn. Every offloader replays its |
There was a problem hiding this comment.
The quantifier is too strong: a component's replay phase can be skipped by a gate that sits ahead of it.
reapplyFrozen-based offloaders (mask, cmdfilter, collapse, failed_run, skeleton, readlifecycle, agentdiet) do behave as this comment says. Two do not:
summarize.Offloadreturns atsummarize.go:157on!s.trigger.Fires(...) || end <= start, and again at:162/:165when no model client resolves — all beforetryReuse, which is where itscommitRefreshpair lives. A turn with no cheap-model credential refreshes none of summarize's span payloads.extract_llm.Offloadreturns atextract_llm.go:659(no_goal_keywords) before Phase 1 at:827, which is the "reapply frozen compactions on every turn" block.
At 10,000s these gaps were almost always absorbed. At 1,800s a 30-minute run of them is not exotic, and one of them is not a single-request event the way x-context-guru-bypass is: the agent's own compaction shrinks the incoming request, which can drop it back under Trigger.MinRequestTokens (3,000 by default) for several consecutive turns while markers are still live in the compacted transcript — the interaction #67 just landed for. That makes summarize's trigger skip recurring, which is exactly the shape the exposure paragraph at :315 assumes does not exist.
Two ways out, and I don't think it matters much which: hoist the replay/refresh phase above those gates (a refresh is cheap and unrefusable, so there is no reason it should be downstream of a model gate), or narrow this comment and the docs/reference/config.md exposure list to name per-component skips. I'd prefer the hoist for summarize's model == nil case specifically, since "the cheap model is down" is a state that persists for many turns by nature.
| // refresh (an x-context-guru-bypass request, or the agent-compaction bypass), so a long | ||
| // unbroken run of bypassed turns could outlive a payload while its marker is still live in the | ||
| // transcript. Both are single-request events in practice. If it happens the outcome is the | ||
| // already-reported one — stash_missing on the next replay — not a silent loss, and |
There was a problem hiding this comment.
"the outcome is the already-reported one — stash_missing" is component-dependent, and for four offloaders it is the other counter plus a silent flip.
dedup.go:70, extract.go:82, linecap.go:146 and smartcrush.go:88 call commitMark and have no reapplyFrozen/commitRefresh path at all (git grep -n 'commitMark(\|reapplyFrozen(' components/offload/*.go). Their per-turn re-stash therefore goes through commitMark, which is the refusable half of the pair — skipReduce is false on a re-sent original, so they redo the transformation every turn and land on PutStash via commitMark.
While the payload is live that write is PutStash's refresh branch, which is retained unconditionally. Once the payload has been reclaimed it is a new stash, and a new stash into a saturated reserve is refused → continue → the message goes upstream verbatim after earlier turns sent it compacted. That is a full-suffix cache write, and it is booked as stash_refused, whose operator-facing text in promexport.go and docs/reference/routes.md promises "the content was left verbatim and nothing became irreversible". True as far as reversibility goes, and misleading about the cost actually paid.
This is reachable at 10,000s too, so it is not a regression this PR introduces — but shortening the horizon shortens the distance to it by 5.5x, and the paragraph above tells a reader the worst case is a counter that in this path never moves. Worth a sentence here and a row in the docs/reference/config.md table.
| // ttlFor is the lifetime an entry gets when it is written or read. Rewind payloads take the | ||
| // shorter stashTTL, everything else the full ttl — keyed on the STASH FLAG rather than on the | ||
| // key, because a payload's key is a bare content hash the store cannot recognise (see Stasher). | ||
| func (m *Memory) ttlFor(e *entry) time.Duration { |
There was a problem hiding this comment.
(a) — I audited it and believe you got all of them; here is the audit so it is on the record, plus a way to stop relying on one.
Every site that assigns e.expires:
| site | direction | noted? |
|---|---|---|
PutStash refresh, :576 |
can move earlier (ttl → stashTTL) | yes, your fix |
PutStash new, :593 |
new entry | yes, :610 |
Put refresh, :691 |
later only | not needed |
Put new, :702 |
new entry | yes, :713 |
Get slide, :801 |
later only | not needed |
The two unnoted ones are safe because stashTTL <= ttl is enforced in NewMemory and now is monotonic: now₁ + stashTTL > now₀ + stashTTL for any refresh, and no path ever clears e.stash, so an entry's horizon never lengthens mid-life either. So no sibling — but note that the correctness of :691 and :801 is now a consequence of the answer to your question (b). If the cap ever goes, someone has to redo this table.
Concrete suggestion: fold it into one place, e.g. func (m *Memory) setExpiry(e *entry) { e.expires = m.now().Add(m.ttlFor(e)); m.noteExpiry(e.expires) }, and use it at all five sites. noteExpiry on a deadline that only moves later is a no-op by construction, so the unconditional version costs one comparison and makes a sixth write site unable to get this wrong — which is the same completeness argument #198 is open about for the gateExempt table.
| // once the decision is gone no replay stamps that marker again. So an operator who shortens | ||
| // ttl_seconds below the payload default gets the shorter of the two rather than a reserve | ||
| // held open by dead payloads. | ||
| if stashTTL > ttl { |
There was a problem hiding this comment.
(b) — keep the cap. Two smaller things about it.
The cap is the right default, and the reason given (a payload nothing can name is a slot held for nothing) is the one that matters. I would not add an escape hatch: an operator who wants payloads to outlive decisions is asking for the #190 saturation by configuration, and there is no workload argument for it that isn't better served by raising ttl_seconds.
Two notes:
-
The justification is slightly overstated. "A payload outliving the decision that names its marker is memory nothing can ever read" — the model can call
expandon a marker it read in an earlier turn's context even after the frozen decision has expired and that message has gone upstream verbatim, because the marker is in the conversation the model is reasoning over, not only in the request the proxy just built. That path is rare and short-lived, and it does not change the conclusion; I'd just not lean on "ever". -
The cap is applied silently, and
/configreports the pre-cap value.cmd/context-guru-proxy/main.go:921publishescfg.Store.StashTTLSeconds, sostash_ttl_seconds: 20000withttl_seconds: 10000shows 20000 on/configand the dashboard while the store uses 10000. That is the same silent-divergence shape fix(proxy): tell an unreadable usage block apart from a provider that reported none, and record the shape once #205 is about, in the config surface. Either report the effective value (the store would need to expose it) or log once at startup when the cap bites.stash_ttl_seconds: 0reporting0rather than1800is the same pre-existing pattern asttl_seconds, so I'd leave that alone unless you want to fix the family.
| // the transcript: every turn's replay re-writes it, so a reclaimed one is re-created on the | ||
| // request path before any expand could ask for it (see store.DefaultStashTTL, #190). | ||
| // | ||
| // StashRevived is that absorption, counted: a payload written again under a key the TTL had |
There was a problem hiding this comment.
A zero on this pair is not evidence, and "the measurement arrives on the first run" needs that caveat.
sweepExpired is called only from StashRoom (store.go:522), PutStash's pre-refusal path (:586), and evictOldest (:889) — i.e. only when the reserve, the shared exempt budget, or the entry cap is already binding. In a run that never saturates, expired payloads are simply never swept: PutStash's refresh branch does not check expiry, so an expired-but-unswept entry is resurrected in place (your own test comment at payload_rederive_test.go:66 says exactly this) and neither stash_expired nor stash_revived moves.
That is the correct behaviour — the slot is released at the moment the slot is wanted — but it means the PR's closing claim that the measurement "now arrives on the first run rather than needing an instrumented arm" holds only for a run that actually saturates the reserve. That is the same precondition iteration 024 failed to meet for stash_refused, which is how #190 ended up undecidable from data in the first place. Worth saying in "what the next run should read": stash_expired = stash_revived = 0 means the reserve never bound, not the horizon is working, and the thing that distinguishes those is stash_refused / StashLive against Capacity.
…e it, and report the capped horizon Review round 1 on #204. All five findings accepted; the load-bearing claim survived the attack, the UNIVERSAL QUANTIFIER around it did not. "EVERY OFFLOADER REPLAYS ON EVERY TURN" IS FALSE IN TWO WAYS, WITH DIFFERENT CONSEQUENCES. Both verified before writing: - summarize returns at summarize.go:157 on its trigger and at :162/:165 when no model client resolves, all ahead of tryReuse where its commitRefresh lives; extract_llm returns at extract_llm.go:659 on no_goal_keywords, ahead of Phase 1. A skipped turn refreshes none of their payloads. And summarize's trigger skip is RECURRING rather than the single-request event the exposure paragraph assumed — the agent's own compaction shrinks the incoming request and can drop it back under Trigger.MinRequestTokens for consecutive turns, and "the cheap model is down" persists for many turns by nature. The mitigating half, which the review did not have to give me and which I state because it bounds the severity: a skipped component splices NOTHING, so no marker of its goes upstream on those turns and none dangles. The payload's reclamation is harmless while the skip lasts; the exposure is only that its next firing may find the payload gone and the reserve full at the same moment. - dedup, extract, linecap and smartcrush have NO replay path at all — no reapplyFrozen, no commitRefresh (confirmed: one commitMark each, zero of either). They redo the transformation from the re-sent original every turn, so their per-turn write goes through the REFUSABLE commitMark. While the payload is live that is PutStash's refresh branch, retained unconditionally; once reclaimed it is a NEW stash, and a new stash into a saturated reserve is refused, the component declines, and the message goes upstream verbatim after earlier turns sent it compacted. So for those four the outcome is stash_refused PLUS a representation flip — not the stash_missing my paragraph promised. And stash_refused's operator-facing text promises "nothing became irreversible", which is true about reversibility and silent about the cache-write actually paid. Reachable at 10,000s too, so not introduced here; this horizon shortens the distance to it by 5.5x. Both are now a per-offloader table in the comment and in docs/reference/config.md, rather than a claim that holds for seven of thirteen. I did NOT hoist summarize's replay above its model gate, which the reviewer mildly preferred: it is a behaviour change in a component with its own test burden, and the narrowed claim is honest without it. Left as a follow-up. ONE HELPER FOR EVERY EXPIRY WRITE. The reviewer audited all five e.expires sites and found no sibling bug, but noted the two unnoted ones are safe only as a CONSEQUENCE of stashTTL <= ttl plus a monotonic clock — so the completeness rests on a cap elsewhere and has to be redone by hand if that ever changes. setExpiry now stamps the deadline and lowers the sweep bound in one place, used at all five sites. noteExpiry on a later-only deadline is a no-op by construction, so the unconditional version costs one comparison and makes a sixth site unable to get it wrong. Same completeness argument #198 is open about for gateExempt. THE CAP WAS SILENT AND /config PUBLISHED THE PRE-CAP VALUE. `stash_ttl_seconds: 20000` with `ttl_seconds: 10000` displayed 20000 on /config and the dashboard while the store used 10000 — an operator told one thing while another runs, which is #205's shape in the config surface. store.EffectiveStashTTLSeconds derives the value from the same code path NewMemory uses, so the two cannot drift, and main.go publishes that. The cap itself stays, with no escape hatch, for the reason the review gives: wanting payloads to outlive decisions is asking for #190 by configuration. Also narrowed the justification, which was overstated: a payload outliving its decision is a slot held for ALMOST nothing, not for nothing that can "ever" be read — the model can call expand on a marker it read in an earlier turn's context, since the marker lives in the conversation it reasons over and not only in the request the proxy just built. Rare and short-lived, and it does not change the conclusion. A ZERO ON THE NEW PAIR IS NOT EVIDENCE. sweepExpired runs only from StashRoom, PutStash's pre-refusal path and evictOldest — only once a budget is already binding — and PutStash's refresh branch does not check expiry, so on an unsaturated run an expired-but-unswept payload is resurrected in place and NEITHER stash_expired nor stash_revived moves. So the PR's claim that the measurement "arrives on the first run" holds only for a run that actually saturates the reserve, which is the same precondition iteration 024 failed for stash_refused — and failing it is how #190 became undecidable from data in the first place. Said at metrics.Snapshot and in the docs: both at zero means THE RESERVE NEVER BOUND, and what distinguishes that from "the horizon works" is stash_refused and stash_live against stash_capacity. VERIFICATION gofmt -l . clean, go vet ./... clean, go test ./... all packages pass, go test -race clean over store and components/offload (Go 1.26.4, eval box). One new test, revert-verified: TestTheConfigSurfaceReportsTheEffectivePayloadHorizon — with EffectiveStashTTLSeconds returning the raw field again it fails on "/config would advertise a horizon the store does not use". It also cross-checks the helper against a store built from the same Options for four option shapes, so the two cannot drift apart silently, which is the actual defect rather than the one wrong number. The other four findings are comment and documentation only, so no test changes: the existing suite passes unchanged, which is the correct outcome for a claim that was too broad rather than a behaviour that was wrong. Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com> Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
amiddavid
left a comment
There was a problem hiding this comment.
48e0f09 addresses all five. Re-read the whole store diff rather than just the fixup, and I have no objection left — LGTM from me (GitHub will not let this account file a formal approval on its own PR, so read this as one).
Specifically: setExpiry is now the only thing that stamps a deadline, including the two new-entry sites, which is what makes the invariant structural instead of an audit — and moving Put's construction to a bare &entry{} so there is no second place a deadline can come from is more than I asked for and the right call. EffectiveStashTTLSeconds deriving from NewMemory rather than re-implementing the rule is the correct shape. The narrowed re-derivation claim reads as the honest version now, and your bound on it — a skipped component splices nothing, so nothing dangles while the skip lasts, and the exposure is only that its next firing may find the payload gone and the reserve full at the same moment — is a better statement than the finding I raised. Declining to hoist summarize's replay above its model gate is right on this PR: it is a behaviour change in a component with its own test burden, and it is noted rather than buried.
The metrics.Snapshot paragraph on both-counters-at-zero is the change I care most about, because it is the difference between the next run producing an answer and producing another undecidable issue.
Two notes below, neither blocking and neither worth another round.
| } | ||
| // It must agree with the store built from the same Options, which is the whole point of it | ||
| // existing rather than the caller re-deriving the rule. | ||
| for _, o := range []Options{ |
There was a problem hiding this comment.
Non-blocking: this loop cannot fail. EffectiveStashTTLSeconds(o) is defined as int(NewMemory(o).stashTTL / time.Second), so the assertion is A == A for every shape in the table — it will keep passing through any change to the resolution rule, including a wrong one. The three literal assertions above it are the ones carrying this test.
Not an argument for deleting it: a future refactor that re-implements the rule inside the helper would start being caught, which is presumably the intent. But the drift this PR actually fixed is between effectiveConfig's published value and the store, and the loop does not span that — cmd/context-guru-proxy/main.go could go back to cfg.Store.StashTTLSeconds tomorrow and every assertion in this file would still pass. A test that fails on the regression has to read the map effectiveConfig returns (got["store"].(map[string]any)["stash_ttl_seconds"]) for Options where the cap bites, which is one assertion in the cmd package.
| // while the store used 10000 — a silent divergence between what an operator is told and what runs, | ||
| // which is the shape #200 is about, in the config surface instead of the metrics one. Derived from | ||
| // the same code path NewMemory uses rather than re-implemented, so the two cannot disagree. | ||
| func EffectiveStashTTLSeconds(o Options) int { |
There was a problem hiding this comment.
Trivial, for the record rather than for action: this builds a whole Memory — list plus three maps — to read one duration. Fine as it stands, because effectiveConfig is called once at startup (main.go:289) and the coupling to NewMemory is the property you wanted. Worth knowing only so nobody later moves the caller onto a request path, or gives NewMemory a side effect (a goroutine, a file handle) without noticing that this reads it. If either looks likely, lifting the two lines of resolution into a resolveStashTTL(o Options) time.Duration that both NewMemory and this call keeps the no-drift property without the allocation.
Round 2 on #204, non-blocking finding. No behaviour change. THE CROSS-CHECK LOOP I ADDED WAS `A == A` AND COULD NOT FAIL. EffectiveStashTTLSeconds IS `int(NewMemory(o).stashTTL / time.Second)`, so a store-side test comparing the two compares a function against its own body. It passes for any implementation, including a wrong one, and a reader scanning for coverage would count it as evidence. Removed, with a note saying why so it does not come back — the three literal assertions above it were carrying that test on their own. AND IT DID NOT SPAN THE DRIFT THE CHANGE ACTUALLY FIXES. The divergence was between the value `effectiveConfig` PUBLISHES and the value the store uses; nothing in the store package can observe that, so main.go could go back to publishing the raw field with every store test still green — which is the same "the test is in the wrong package to fail" shape as the vacuity it replaced. cmd/context-guru-proxy.TestConfigPublishesTheEffectivePayloadHorizon reads the map main.go builds and the store the same Options produce, and asserts they name the same number. Revert-verified: with main.go publishing cfg.Store.StashTTLSeconds again it fails with "/config publishes stash_ttl_seconds=20000, want 10000 — the dashboard would advertise a horizon the store does not use", and again on the defaults case (0 published where the store uses 1800). Also noted on the review, and left alone deliberately: EffectiveStashTTLSeconds building a whole Memory is fine at one startup call site. It is correct by construction rather than by duplicating the rule, which is the property worth paying an allocation for; if anyone ever wants it on a request path, that is the moment to split the rule out, not now. VERIFICATION gofmt -l . clean, go vet ./... clean, go test ./... all packages pass (Go 1.26.4, eval box). Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com> Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
amiddavid
left a comment
There was a problem hiding this comment.
e07c7af closes both notes, and the second one better than I asked. LGTM, nothing outstanding (this account cannot file a formal approval on its own PR).
The new cmd/context-guru-proxy.TestConfigPublishesTheEffectivePayloadHorizon does span the drift, including in the part I would have called decorative: got comes from the map effectiveConfig builds and store.EffectiveStashTTLSeconds(cfg.Store) from the helper, so if main.go ever goes back to publishing the raw field those two diverge and it fails — it is not the A == A the removed loop was. The literal tc.want rows cover the defaults case on top of that, which is the one a same-function comparison could not have caught. Revert-verifying it against main.go publishing the raw field is the right check for a test whose whole purpose is to fail on that regression.
Removing the store-side loop with the note rather than silently is what makes it not come back. Agreed on leaving EffectiveStashTTLSeconds building a Memory.
One thing worth keeping from this exchange, since you said you were writing it into your notes: a test in the wrong package to fail and a test that is true by construction are the same defect wearing different clothes — both read as coverage from the outside, and neither can distinguish a working implementation from a broken one. The tell is the same in both cases: ask what edit would make it fail, and if the answer is "an edit to the assertion itself", it is not a test.
… probe without renewing Round 1 review on #208. Five findings; the measurement survived the attack and two of these are behaviour. SUMMARIZE WOULD HAVE FALSIFIED THE POINTER, AND THE PRE-EXISTING HALF IS WORSE. summarize is the one offloader that consults neither skipReduce nor isKeptVerbatim (`git grep -n 'isKeptVerbatim\\|skipReduce\\|keptKey' components/offload/summarize.go` is empty), and it replaces msgs[start:end] wholesale rather than editing in place. So under `preset: summarize`: the repair sees the original, writes the pointer, summarize summarizes that span away, and the model gets "present in the transcript above" with a summary where the content was. Before this branch it got the content — a regression I would have shipped. The pre-existing half is the one that decided where to fix it: summarizing away content the agent just expanded IS the bounce loop cg:keep: exists to prevent, which is why the other ten offloaders consult it. So the fix belongs in summarize, not in contentPresent. trimSpanForKeptVerbatim lowers `end` so the span never contains kept-verbatim content, and THE RETREAT MIRRORS summarizeSpan's EXISTING ADVANCE, for the same documented reason: a tool exchange is atomic. Setting `end` to the kept-verbatim message's index is not enough, because that message is typically a tool output — the kept tail would begin with a tool_result whose tool_use is still in the span, which is one of the two provider rejections summarizeSpan was written after: 400 messages.N.content.M: unexpected `tool_use_id` found in `tool_result` blocks 400 messages.N: `tool_use` ids were found without `tool_result` blocks immediately after Advancing instead — what the tail-alignment rule does — is not available here: advancing past a tool message would swallow the very message being protected. If the retreat leaves end <= start, summarize declines, which it already supports and which is the safe direction. THE FLIP PROBE WAS RENEWING WHAT IT ASKED ABOUT, which the review measured. "One extra Get on rare content" accounted for the CPU and not the lifetime. Memory.Get slides the TTL and reorders the LRU, and store.FrozenPrefix is in DefaultPinPrefixes — so the probe renewed a PINNED entry the branch had just decided will never be replayed, and pinned entries count against exemptRoom(), which gates rewind-reserve admission. A diagnostic was applying back-pressure to the reserve: #188's budget reached from a new direction, by accident. store.Peeker / store.Peek is a non-sliding presence probe, following Stasher and FrozenLoser's shape so callers do not assert at the site. Memory.Peek deliberately does NOT remove an entry it finds expired, unlike Get: removal from a read-only probe would mutate stash accounting and the reserve's byte total from a caller that only asked a question. I did NOT take the offered second half — counting once per (component, content) to retire the "per turn per message" caveat. It changes what the counter means mid-review and needs bounded state; the caveat is documented and the lifetime bug is the actual defect. Worth its own decision. THREE SMALL ONES, all real: - agentdiet.go lost the `||` short-circuit when I split the call, so skipReduce — a store Get — ran for empty content, against the ordering its own comment establishes, with a spurious gate if "" were ever marked. Restored as an early continue. - the ExpandPrefixFlips block landed between CompactionResets' comment and its field, so that comment documented the wrong name. Moved above it. - routes.md opened a second `| Field | Meaning |` header that split the stash table from the `stash_refused` prose explaining it. The expand row is now its own subsection, which also keeps this branch off the stash rows #204 edits — noted because whichever of the two merges second needs a rebase, and #204 is what corrects `stash_expired`'s remedy in that table. VERIFICATION gofmt -l . clean, go vet ./... clean, go test ./... all packages pass, go test -race clean over components/offload, store, expand and proxy (Go 1.26.4, eval box). Four new tests. The span trim is revert-verified in both directions — never trimming gives "span still ends at 4, so the expanded tool output at index 3 is inside it" and "span is [1,2) and still contains the expanded message at index 1", and a third test pins that a span with nothing expanded in it is left EXACTLY as summarizeSpan computed it, because a protection that shrinks healthy spans costs real savings. The probe has two tests, and the second exists because of the lesson from #204's round 2: a store-side test cannot fail if the CALL SITE goes back to Get, so the assertion has to live where the probe is. store.TestPeekDoesNotRenewTheEntryItAsksAbout fails when Peek is implemented as Get; offload.TestTheFlipProbeDoesNotRenewTheDecisionItAsksAbout fails when reapplyFrozen probes with Get — verified, it reported the frozen decision alive 600s into a 100s TTL. Neither test covers the other. Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com> Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
…an fail Round 2 review on #208. The fix was right; its test was not where it could fail. I APPLIED THE LESSON ONE FILE AWAY AND NOT TO THE FIX IT WAS ABOUT. All three new summarize tests called trimSpanForKeptVerbatim directly with a stub predicate (`text == expanded`), so none of them touched Summarize.Offload. The reviewer ran the mutation: with the call replaced by `_ = trimSpanForKeptVerbatim`, those tests pass and all 28 packages pass. The regression I said I would have shipped was reachable again by deleting one statement, with the entire suite green. Reproduced both of their mutations here — the call site deleted, and a predicate that never consults the store — and both now fail on TestSummarizeOffloadKeepsExpandedContent: "summarize removed content the agent had expanded: the model is left with a pointer to nothing where it used to get the content". THE TELL WAS IN MY OWN FIXTURE, and it is the part worth keeping: line 38 built a store and called MarkKeptVerbatim, then never used it, because the stub had replaced it. Dead setup is what an assertion that stopped reaching its subject looks like. Same shape as the vacuous cross-check on #204, and its sibling in this very package — TestTheFlipProbeDoesNotRenewTheDecisionItAsksAbout — is the version that gets it right, which is what makes this an application failure rather than a knowledge one. The new test drives Offload through the existing harness (countingModel / newSummarizeKeepLast / ctxFor), with a PRECONDITION that the same fixture DOES summarize that message away when nothing is marked — without it, "the content survived" would pass on a fixture summarize never touched. The three boundary-arithmetic tests stay, with a note saying what they do and do not establish, and the dead store setup is gone. TWO SMALLER ONES. The trim now runs BELOW the trigger gate. Above it, a declined turn paid a contentKey hash plus a store Get per span message, and raised kept_verbatim_after_expand on turns summarize was never going to act on — a gate reporting a decision nothing made. The re-test of `end <= start` after the trim is what preserves the original ordering's guarantee; the scan already stops at the first hit, now said so at the function. And the counter's wording is narrowed, because the trim creates a second instance it cannot see. Lowering `end` can invalidate a checkpoint whose boundary reached past the new end, so summarize re-summarizes a shorter span and emits different summary text at a fixed prefix position — another suffix cache-write. Same class of event, correct trade (content loss for one cache-write), and expandFlips.Add(1) has exactly one call site in reapplyFrozen. metrics.Snapshot, routes.md and the counter's own doc now say it counts ONE event and that a zero does not mean expansion cost nothing. Counting the second instance is left to its own decision, on the reviewer's recommendation and for the reason I declined the per-(component, content) recount last round: a second increment site changes what the number means. VERIFICATION gofmt -l . clean, go vet ./... clean, go test ./... all packages pass, go test -race clean over components/offload, store and expand (Go 1.26.4, eval box). One new test, verified against both of the reviewer's mutations rather than one. Two earlier attempts at the "delete the call site" mutation failed to COMPILE — the first left a dangling condition, the second cut through the model resolution between the trigger gate and the trim — and a mutation that does not build is not evidence, so neither was counted until the third landed cleanly and failed on the assertion. Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com> Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
Closes #190.
The gap, and why the issue said it could not be decided yet
#188bounded the rewind reserve but left a slot released only by the TTL, with both namespaces sharing one. The store is a single process-wide instance, so a busy period could hold the reserve saturated for the whole ofttl_seconds— ~2.8 h at the default — and while it is saturated every new removal is refused for every session at once. Savings fall to zero long after the load that caused it is gone.The issue listed five options and said picking without a re-run of iteration 024 would be another guess at a default. The re-run has not happened and, per the validation session, will not arrive first — iteration 024 ran on a commit that predates
#188, sostash_refusedhas never been observed at all, and the issue's own gating plan is circular (the re-run needs #190 either chosen or deliberately left unfixed and instrumented).The choice turns out to be decidable from the code, because the issue's stated objection to option 3 is not what the tree does.
The mechanism the issue did not account for
A payload, unlike a frozen decision, is re-derivable from the request in flight:
DefaultTTLis sized for.mask.go:79, "Reapply a previously-frozen mask on EVERY turn") — and that replay callscommitRefresh, whichPutStashes the payload again from the message text it just read.So a live marker's payload has its deadline slid every turn, and one already reclaimed is re-created on the request path — before the request goes upstream, and therefore before any
expandcall in the response could ask for it.The horizon a payload needs is one inter-turn gap, not one session. That makes this option 3 arrived at by mechanism rather than by guess — and it introduces no irreversibility (option 1), needs no arbitrary share number (option 2, which
#188declined for exactly that reason), and needs no session-end signal that does not exist (option 4).What changed
store.Options.StashTTLSeconds(stash_ttl_seconds),DefaultStashTTL = 1800s. Not a fresh guess: 1800s is the valueDefaultTTL's own comment records as too short for a frozen decision (headroom's CCR store), reused in the one namespace whose horizon it does fit.ttl_seconds. A payload outliving the decision that names its marker is memory nothing can ever read, so shorteningttl_secondsgets the shorter of the two rather than a reserve held open by dead payloads — which would be this same saturation arrived at by config.Memory.ttlForpicks the horizon per entry, keyed on the stash flag rather than the key (a payload's key is a bare content hash the store cannot recognise — seeStasher). Applied at every write and atGet's sliding refresh,Putincluded.stash_revived/cg_stash_revived_total: payloads written again under a key the TTL had taken.stash_expiredreported two outcomes at once — a payload nobody wanted, and a payload an outstanding marker still needs — and those call for opposite responses. Same ambiguous-counter shape#188splitstash_refused/stash_missingover, and that proxy: an unrecognised usage dialect is indistinguishable from no usage, and degraded silently for 4,015 requests #200 states as a general rule.One help text that named the wrong remedy
cg_stash_missing_total,/statsand two docs pages all said the fix for a dangling marker isttl_seconds. A replay re-stashes the payload it re-derived, so a marker only dangles when that write was also refused — the remedy is the reserve first (max_entries/stash_max_bytes), andstash_ttl_secondsonly ifstash_expiredis what is taking the payloads. That was already incomplete before this change; corrected here because this change alters what expiry means, so leaving it would have made it wrong rather than merely partial.The exposure this leaves
A turn that runs no pipeline performs no refresh — an
x-context-guru-bypassrequest, or the agent-compaction bypass — so an unbroken run of bypassed turns longer thanstash_ttl_secondscould outlive a payload whose marker is still live. Both are single-request events in practice, and if it happens the outcome is the already-reported one (stash_missing), not a silent loss. Documented atdocs/reference/config.md.Verification
gofmt -l .clean,go vet ./...clean,go test ./...all packages pass,go test -raceclean overstore,components/offload,proxy,metrics,dash,cmd(Go 1.26.4, eval box).Six new tests, each revert-verified to fail without the change:
store.TestPayloadsExpireSoonerThanTheDecisionsThatNameThemstore.TestAReclaimedPayloadRewrittenByAReplayIsCountedAsRevivedstore.TestThePayloadHorizonNeverOutlivesTheDecisionHorizonstore.TestDefaultPayloadHorizonIsWellInsideTheDecisionHorizonstore.TestBecomingAPayloadLowersTheSweepBoundoffload.TestAReclaimedPayloadIsReDerivedBeforeItsMarkerGoesUpstreamoffload.TestAReclaimedPayloadThatCannotBeReStashedIsReportedMissingThe re-derive test was verified against a second mutation, not only the shared-horizon revert, because that revert only proves the reclamation happened. With
commitRefreshchanged to read the payload instead of re-stashing it, the test fails on its own subject — "the marker on the wire does NOT resolve after its payload was reclaimed". Without that second check the assertion could have been passing on a payload that never left. If this test ever fails,DefaultStashTTLgoes back toDefaultTTLand not the other way round.One bug this change introduced and its own new test caught: when an entry becomes a payload its deadline moves earlier (
ttl→stashTTL), andnextExpiry— the lower bound that letssweepExpiredskip a pass — was not lowered to match. A bound left above an entry's real expiry makes the sweep return early, so that reserve slot is never reclaimed by any TTL: the exact permanent saturation this PR is about, reintroduced by the fix for it.TestBecomingAPayloadLowersTheSweepBoundis sized so the become-a-payload write is the only thing that can lower the bound, andnoteExpiry's comment no longer claims a plain write's deadline is "the latest of any live entry", which two horizons make false.Two contract tests updated as they are designed to require:
TestEverySnapshotFieldIsExportedOrExempt(StashRevivedis sourced fromStashStats()like its four siblings, so it is listed innotExportedWhyfor that reason) andTestStatsShapeIsUnchanged(stash_revivedadded to the reviewed contract, in alphabetical position).Not in this change, deliberately
Option 5 from the issue's comment — gating
extract_llm'sexploring()on reserve health. It bounds a different cost (repeated model calls during saturation) in a different package and needs its own decision about the threshold; this change reduces the duration of the episode it is about. Left on the issue.What the next run should read
stash_revivedagainststash_expired, withstash_missing. Revived tracking expired means the shorter horizon is being absorbed by the per-turn re-stash exactly as argued above; expired climbing while revived stays flat is also fine (sessions that never came back — the reclamation this exists to do);stash_missingmoving is the one that says the reserve is genuinely undersized. That is the measurement #190 asked for, and it now arrives on the first run rather than needing an instrumented arm.🤖 Generated with Claude Code