feat(proxy): async mode — cache-safe deferred compaction (HELD: benefit unmeasured since #34) - #35
feat(proxy): async mode — cache-safe deferred compaction (HELD: benefit unmeasured since #34)#35OsherElhadad wants to merge 16 commits into
Conversation
… compaction), observe Compaction was unconditionally synchronous, so the only way to get savings was to accept ~450 ms/req of latency, and the only way to find out whether context-guru helps a workload was to enforce it in production. Add an explicit `mode:` with three settings; sync remains the default and is byte-identical to before. async defers the expensive part (the compaction LLM call) off the request path. The inline pass gets no model clients, so it only replays decisions an earlier turn's off-path job already froze; the deferred pass gets them and its result benefits subsequent turns. The hard part is the cache. A cache-write costs 11.5x a cache-read, so letting the un-compacted tail get provider-cached and THEN replacing it converts a read into a write and is strictly worse than sync — exactly what tripled headroom's cache-write on Terminal-Bench. So by default no breakpoint is placed at or beyond the tail a pending compaction will replace (cacheinject drops those positions and anchors at the highest safe index instead). async.cache_uncompacted_tail: true is the escape hatch for a backend confirmed not to cache. Correctness rests on a per-session compaction generation. A request records the generation it was built from; a deferred job writes into a store.Buffer and the buffer is committed ONLY if the session is still at that generation, under the same lock that advances it. A stale result is discarded, never applied, and counted. The pool is one bounded queue with a fixed worker count owned by the proxy — dedup by (session, generation) with the pending slot claimed before the job is observable, drop rather than block, clean cancellation, no goroutine leaks. observe forwards the original body and never touches it: the request path does not run the pipeline at all (and skips expand tool injection), while a copy runs off-path against a buffer that is never committed. Its numbers live in a physically separate metric namespace with their own vocabulary (potential_* / projected_*) that shares no key with an enforced metric — a mislabelled hypothetical would silently inflate the product's headline claim. Also folds prevLen into a locked Tracker call. It was read then written back in a defer, so two concurrent turns of one session raced on it (overlaps #25). /stats gains mode, sync_enforced, async_enforced, the full async queue tuple including dropped and stale_discarded, and the observe hypotheticals. Every pre-existing field keeps its name and shape — deploy/harbor/*.py parses it. Tests are -race throughout: sync byte-identical to the legacy entry point, observe byte-identity of the forwarded body, stale-generation discard end to end, concurrent turns of one session, atomic enqueue dedup, a full queue that drops and counts without blocking, no goroutine leak on cancellation, no breakpoint at or beyond the un-compacted tail, and observe metrics unreachable from every enforced aggregate. Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
…ading observe numbers New docs/how-to/operating-modes.md covers the three modes, the 11.5x cache-write arithmetic behind async's default tail protection, how to read the async queue counters (including what a rising stale_discarded or dropped actually means), and what observe mode CANNOT tell you — cache effects are projected not measured, no expand bounce is exercised, and off-path measurement still spends cheap-model tokens. design.md gains the mechanism: generations, the store.Buffer that makes "discard a stale result" possible at all, the job lifecycle, the cache policy and why the protection needed its own bool rather than a sentinel index, and fail-open per mode. Also documents mode as a metrics dimension and the namespace separation. config.md documents mode:/async: and the --mode/MODE override. README gains a modes table. Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
… and turn state Two bugs the first benchmark arm surfaced. A deferred run emitted reports stamped async, so the pipeline's savings landed in the enforced rollups even though nothing was forwarded — and then landed there AGAIN when a later turn replayed the frozen decision on the request path. Every deferred compaction was counted twice, and once against a request that never carried it. Report/RunReport now carry Deferred and the Aggregator drops those: off-path work is visible as async_deferred_runs, async_deferred_ms_total and the queue tuple, and its savings are credited only where they were actually realized. cacheinject now skips deferred runs entirely. Its per-message divergence digests are TURN state, and a deferred job commits some turns after the one it was built from, so committing its digests would replay turn N's over turn N+2's and make the next turn compute the wrong divergence point. Its breakpoints were pointless there anyway — a deferred run's body is discarded. Only an offloader's frozen decisions are meant to survive off-path. Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
The existing async tests proved the machinery (dedup, drops, stale discard) but not the claim: that a compaction computed off-path on turn N is replayed on turn N+k and saves tokens there. Without that, async is only "cheaper because it does less". Drives five real turns of one session through the handler and asserts both async_deferred_runs and async_realized_saved_tokens are non-zero — 2 committed compactions realizing 4,948 tokens on later turns as written. Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
… enforcing mode uses The first SWE-bench comparison disagreed badly: observe projected 9.5% savings on tasks where sync actually achieved 0.8%. The cause was not the arithmetic but the gating. The observe job ran without a Tracker, so its cached-prefix boundary was unknown, MaxCachedIdx stayed -1, the tail gate never fired, and every message in the transcript looked compactable — 50 extract_llm candidates passed the gate against sync's 5. A projection that ignores cache-awareness is not a projection of what this proxy would do; it is a projection of what a cache-blind proxy would do, and it overstates savings by the exact amount cache-awareness costs. Since agreement between observe's projection and sync's actuals is what validates the whole mode, that made the headline number wrong in the optimistic direction. Observe now shares the Tracker. Safe off-path despite jobs finishing out of order: prevLen only ever grows, so a late job for a shorter turn cannot move the boundary backwards, and observe never commits, so the generation stays put. Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
…at sync achieves Comparing observe's projection against sync's actuals on the same traffic — the check that validates the whole mode — found two independent errors, in opposite directions, neither visible by reading the code. Observe ran with no Tracker, so its cached-prefix boundary was unknown, the tail gate never fired, and every message looked compactable: 9.5% projected against 0.8% actually achieved on the same SWE-bench tasks, because 50 extract_llm candidates passed the gate instead of 5. A projection that ignores cache-awareness projects what a cache-BLIND proxy would do and overstates by exactly what cache-awareness costs. Observe now shares the Tracker; safe off-path because prevLen only grows and observe never commits. Then, with the boundary fixed, observe UNDER-projected by 3x. It ran against a discarded buffer, so the frozen decisions offloaders replay on every later turn — where most of the sustained saving lives — evaporated each turn, leaving it able to see only the current tail. Observe now gets a store of its own: as persistent as the live one, completely disjoint from it. The live store must stay pristine or a real request could replay a decision that was never enforced, which is a request modification arriving by the back door. With both fixed, projection and actual agree exactly on the same traffic: 10,020 tokens / 23.06% each. Two tests pin it — the agreement itself (which fails at ratio 0.33 without the shadow store) and zero writes to the live store. Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
…theticals Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
/compact hands the compacted body back in the response, so it is synchronous by contract regardless of how the proxy handles forwarded traffic. Worth pinning because observe mode turning /compact into a no-op would silently break offline replay and the llm-d-router integration, and nothing else would notice. Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
…ed honestly Records what the three modes actually did on live SWE-bench traffic and in real Claude Code sessions: async cuts added latency 1,599 ms -> 29 ms (55x) with every saved token attributed to a later turn replaying deferred work, and observe adds 0.062 ms to the enforced path while forwarding nothing modified. States plainly what is NOT established: cache-write parity between sync and async is proven structurally by unit test but not yet measured on a paired arm; no cost or solve-rate claim survives 2 tasks at n=1; and the drop / stale-discard paths have never been exercised by production load, only by tests. Also records the observe projection-vs-actual discrepancy without smoothing it over. The controlled same-traffic comparison agrees exactly (10,020 tokens / 23.06% both sides), but the benchmark arms read 6.40% projected against 0.82% enforced, and the reasons are given rather than explained away: different agent trajectories, and observe's projection being a structural upper bound because nothing it offloads can bounce back. Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
…eadline result The async arm re-ran cleanly on its own port (the first attempt's proxy lost a port bind, so its trial data was unusable). It answers the issue's sharpest question: async cut added latency 1,599.4 ms -> 25.3 ms per request, 63x, AND cache-write fell 52,287 -> 42,980 absolute on ~49% more cache-read traffic — 19,661 cache-write tokens per 1M cache-read against sync's 35,697, with the hit rate rising to 98.07%. That is the failure mode the issue warned about not occurring. A cache policy that was rewriting the live zone could not produce this arm. Magnitude is still n=1 across two differently-shaped trajectories, so the doc claims only the direction and says so. Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
Benchmark results — all three modesCommit Scale up front: 2 SWE-bench tasks per mode at n=1, plus one real Claude Code session SWE-bench Verified — 2 tasks, n=1
The four questions1. Does async reduce added latency without increasing cache-write? Latency: 1,599.4 ms → 25.3 ms, 63x. Visible per component — Cache-write: it went down. 52,287 → 42,980 absolute, on ~49% more cache-read That is the failure mode the issue warned about not occurring. A policy that was 2. Does async reach the same steady-state savings as sync, just later? It reached more here (4.17% vs 0.82%), but that is trajectory noise, not a finding. 3. Does observe add measurable latency to the enforced path? No — 0.062 ms/req against sync's 1,599.4 ms. Structural, not tuned: the request path Observe is not free in other respects — it moved 75.0 s of compaction off-path and spent 4. Do observe's projections match sync's actuals? Answering this honestly found two real bugs, and was the most valuable thing the
After both fixes, on identical traffic through the real handler, projection and On the benchmark arms the residual gap is 6.40% projected vs 0.82% enforced, and I
The controlled same-traffic test is the strong evidence; the arms are consistent with it Real Claude Code sessions (one per mode)
Observe's Namespace separation, verified in productionFrom the observe arm's live Terminal-Bench 2.0Ran Bugs the benchmark found that the tests did notAll four passed the unit suite while live:
Each now has a test that fails without its fix. Not established
Full write-up in |
Tracker.Forget and Pool.RecordError had no production caller. Forget was written for the issue's "cancel on session end", but this wire has no session-end signal — an agent simply stops sending — so the tracker's own session cap already IS the eviction policy, and the doc comment now says that instead of implying a hook that does not exist. RecordError duplicated the counter the panic path already bumps, so the test now provokes a real panic rather than poking the counter directly, which tests the path that actually runs in production. Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
Second benchmark, different traffic shape, same direction: normalised cache-write fell 39.2% under async (13,185 per 1M cache-read against sync's 21,689), against -45% on SWE-bench. Two independent replications is the strongest evidence here that the tail policy does what it was designed for. Also records the useful negative: extract_llm made ZERO model calls on these tasks, so async's added latency is identical to sync's (26.8 vs 26.9 ms). Async buys back the compaction model call, so on traffic that never triggers one it buys nothing — and, importantly, costs nothing either. No reward numbers quoted from this arm: two hard tasks, an environment-build exception in each configuration, and trajectories that diverged 30 vs 55.5 mean steps, which drives the cost column entirely at this scale. Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
Terminal-Bench 2.0 — the cache-write result replicatesSecond benchmark, different traffic shape, and it independently reproduces the finding
Normalised cache-write fell 39.2% under async here, against −45% on SWE-bench. Two And a useful negative result: No reward numbers from this arm. Two hard tasks, an environment-build exception in each The observe arm was still running at hand-off; |
…% where sync saved 1% Final arm of nine. Observe added 0.076 ms/req to the enforced path (against sync's 26.9 ms) with every enforced aggregate at zero, and projected 0% savings. That 0% is the point, and it is stronger evidence than the SWE-bench agreement was: a negative control. On traffic where sync achieves almost nothing, observe correctly reports almost nothing instead of inventing a headline. A projection that only ever agreed on high-savings traffic would be far weaker. It also correctly reported the overhead sync WOULD have added as 9.1 ms — small here because the pipeline made no model calls. Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
All nine trials complete — TB observe closes it out
Observe projected 0%, and that is the result I'd point a reviewer at first. It is a That gives three independent lines of evidence on projection accuracy:
Final tally
|
Orchestrator verification of the cache-write claim — it holds, with a caveat the PR should stateI re-derived the headline number from the row files rather than taking it on trust, and hit a discrepancy worth recording. What tripped me up
I checked whether the discarded arms were dropped for being unfavourable, and they were not. Both are genuinely invalid: A nulled trial and a pair of 2-step aborts. Paired per task, which is the honest framing
Both tasks move the same direction, and — importantly — the one whose step count nearly doubled (19 → 33) still shows lower normalised cache-write. That is the case where trajectory noise would most plausibly explain the result away, and it doesn't. Terminal-Bench replicates independently ( So the direction survives per-task pairing on both benchmarks, in both cases with async on the longer trajectory. That is stronger evidence than the aggregate table conveys. What the PR should addState plainly that sync and async came from separate runs ( On the remaining open concerns
Verdict from my side: the mechanism and the direction are sound and replicated. Fix the provenance disclosure before merge. |
OsherElhadad
left a comment
There was a problem hiding this comment.
Independent review — verdict: request changes
I did not write this code. I built a scratch worktree at feat/i31-modes (d550bbe), ran
go test -race ./... (green), -race -count=5 on modes/apply/store/metrics and
-count=3 on proxy (green, no flakes), go build, go vet, gofmt -l (all clean).
The concurrency primitives themselves are sound — I could not construct a data race, a
lost update in Tracker, a dedup window in Pool, or a goroutine leak on Stop.
The problems are not races. They are semantic: the generation does not mean what the
PR says it means, the cache protection has three holes that turn it into a cache-write
regression on the exact backend it was written for, and two of the headline numbers are
tautologies. Each finding below has a runnable reproduction I ran against this branch.
S1 — The tail cache protection is defeated by any agent that sets its own breakpoints (claude-code does)
components/reformat/cacheinject.go:155-168. The policy only deletes blocked positions
from the want set. Caller-set breakpoints are handled after, at line 172-178, and are
never removed:
c.MaxCachedIdx=21, NoCacheAtOrAfter=22, agent set its own breakpoint on msg 29
=> breakpoints = [19 21 29] // 29 is inside the doomed tail
The cacheinject doc comment (v2's whole design) says "keeps every breakpoint the caller
set". claude-code sets its own tail breakpoint on every turn. So on the primary target
workload the protection places its safe anchor at 21 in addition to the agent's unsafe
one at 29 — the doomed tail is still cache-written, and async is now strictly worse than
sync plus one wasted slot. This is the failure mode #31 explicitly warns about,
arriving through the one path the tests don't cover.
Fix: when TailCachePending, strip cache_control from blocked indices before counting
existing. That is the only version of this policy that is actually a policy.
S2 — Every async session's first turn writes no cache at all
apply/apply.go:206-212. First turn: prevLen=0 → maxCachedIdx=-1 →
noCacheAt = max(0, 0) = 0 → CacheBlocked(i) is true for all i, and the
"highest safe index" fallback is -1, so nothing is placed. Reproduced: 40 messages,
breakpoints=[] skipped=true. The existing TestWholeRequestPendingPlacesNothing
asserts this as correct, treating NoCacheAtOrAfter == 0 as "the whole request is
doomed tail" — but on turn 1 nothing is pending yet, and turn 1 is precisely the turn
that must write the prefix so turn 2 can read it at 0.1x. Suppressing it bills the whole
prefix at 1.0x on turn 2. On short sessions (the common case) that is the entire cache
benefit gone.
Fix: on the first turn there is no pending compaction for this session, so
tailPending must be false. Gate on "a job is actually in flight for this session",
not on mode == async.
S3 — cache_mode: off + async suppresses every breakpoint on every turn, forever
apply/apply.go:206-212 sets tailPending = true whenever mode is async and not
bypassed — including when cacheAware is false, where maxCachedIdx never leaves -1.
Result: NoCacheAtOrAfter is permanently 0 and cacheinject places nothing, permanently.
Reproduced. Ironic given cache_uncompacted_tail: true exists as the escape hatch for
exactly this deployment; the two knobs interact backwards.
Fix: if mode == ModeAsync && !o.Deferred && !bypass && cacheAware && !o.CacheUncompactedTail.
S4 — The protection is off by one turn: it blocks the wrong region
The blocked region is [maxCachedIdx+1, ∞) — this turn's new messages. But the
pending job was built from the previous turn's body, so the span it will replace is
the previous turn's tail, which by turn N+1 sits below the boundary and is
unprotected. Reproduced: turn N boundary 22 (job targets 22..29); turn N+1 boundary 30
places its top breakpoint at 29 — inside the span the still-pending job will rewrite.
So the protection blocks a region no pending job targets, and permits the region every
pending job does target. Combined with S1/S2/S3 I do not think the claimed mechanism
holds at all, which brings me to:
S5 — "cache-write went down 17.8%" is not evidence the policy works
docs/results/operating-modes.md:44-58 presents −17.8% cache-write as "the result the
policy was designed for". Given S2/S3, async's cacheinject frequently places fewer or
zero breakpoints — which lowers cache-write for the trivial reason that less is
written, while raising uncached input on later turns. The doc's own numbers are
consistent with that reading: cache-read went up 49% in the same arm. At n=1 over 2
tasks with different trajectories (15.5 vs 21.5 steps) this is not causal, and the
paragraph "this arm cannot be reconciled with a cache policy that was rewriting the live
zone" overclaims — it also cannot be distinguished from a policy that stopped writing
cache. Please either drop the causal claim or add the one measurement that separates
them: breakpoints placed per request, sync vs async.
S6 — The generation counts commits, not turns, so CommitIfCurrent never rejects a genuinely stale result
This is the invariant the PR calls "the one everything rests on". Tracker.gen advances
only in CommitIfCurrent. A turn does not advance it. So a job built from turn 1
still sees gen == 0 as current after turns 2…N have been forwarded, and commits.
Reproduced through the real handler (6 turns, mask, cache on):
deferred_runs(committed)=3, StaleDiscarded=2. The 2 discards are dedup collisions
(two jobs at the same gen), not "the session moved on". Every commit that did happen
wrote a result built from a snapshot the session had already moved several turns past —
the exact situation the guard was written to catch — and none was flagged.
The frozen decisions are content-hash-keyed so this is not corruption today, but the
guard does not provide the property claimed in the PR body, docs/design.md, and
docs/how-to/operating-modes.md:85-89. Either advance the generation on Turn (and
accept more discards), or restate the invariant honestly as "at most one commit per
generation" — which is what the code actually enforces.
S7 — async_realized_saved_tokens is a tautology
proxy/modes.go:59-62 calls RecordRealized(res.Run.Saved()) on every async request
whose inline pass saved anything, with no check that any deferred job ever committed.
Reproduced on the first turn with a purely deterministic pipeline (dedup, no model,
nothing to defer): saved=396, realized=396. So
async_realized_saved_tokens == saved_tokens by construction, not by measurement.
That means docs/results/operating-modes.md:70 — "async_realized_saved_tokens = 15,962
= the entire enforced saving … every token async saved was saved by a later turn
replaying an earlier turn's off-path job" — is circular. The equality is what the code
computes, not a finding. To make it real, credit only savings whose frozen decision was
written by a committed deferred run (e.g. tag replayed decisions, or diff against what
the inline pass would have saved with an empty store).
S8 — Observe mode does contaminate two pre-existing enforced /stats fields
TestObserveMetricsCannotBeSummedIntoEnforcedTotals checks requests, tokens_*,
saved_tokens, components, sync/async_enforced. It does not check the other
enforced-namespace fields, and two are non-zero in observe mode:
cg_added_ms_avg—proxy.go:438records added latency in every mode. Reproduced:
requests=0, cg_added_ms_avg=0.0087.deploy/harbor/swebench.py:305reads this
straight intos["cg_added_ms_avg"], so an observe arm reports a real-looking
overhead figure for requests it never touched.llm_calls/llm_input_tokens/llm_output_tokens—proxy.go:620reads the
globalcheapmodel.Usage(), which observe's off-path runs increment.swebench.py
turns these intocg_llm_cost. The results doc itself reports observe spending
$0.0779 of cheap-model tokens, so this is known — but it lands in the enforced
vocabulary a harness reader sums.
Neither is a hypothetical mislabelled as a saving, so the headline claim survives. But
"in observe mode every enforced aggregate is zero by construction" is not true as
written. Either move these under potential_* in observe mode or amend the claim in the
PR body, metrics.go:359-366, and the how-to.
S9 — An unproductive session re-runs a full off-path compaction on every turn, unbounded
The generation only advances on commit and runOne releases the pending slot
unconditionally, so a job that finds nothing to compact is re-enqueued at the same key
every turn. Reproduced: 10 turns of one session → 10 off-path jobs, gen still 0.
Each is a real cheap-model LLM call in production.
TestAsyncDoesNotSpinOnAnUnproductiveTurn only asserts the queue stays bounded and 12
requests were forwarded — it never counts jobs run, so it passes while the spin happens.
This is the answer to "what if turns arrive faster than compaction completes,
indefinitely": nothing bounds the spend, only the queue depth. Suggest a per-(session,
generation) attempt cap, or advance the generation on a completed-but-unproductive job.
S10 — Tracker eviction lets a stale gen-0 result commit, and fails open into mutating the cached prefix
modes/tracker.go:64-72 evicts an arbitrary session at the cap and recreates it at
gen=0, prevLen=0. Reproduced with NewTracker(2): session A's turn-1 job (100 msgs)
commits over a session now at 400 msgs, because both read gen == 0. The comment claims
a forgotten session "restarts at generation 0 — correct, just missing the pending job's
savings"; it is not correct, it is the one thing the guard exists to prevent.
Worse, prevLen resetting to 0 makes MaxCachedIdx = -1, and Ctx.TailOnly returns
true for every index — so offloaders are free to mutate the whole already-cached
prefix. That is #25's fail-open direction, reachable here through eviction alone. At 1000
sessions with random eviction this is rare but not negligible.
Fix: LRU rather than arbitrary eviction (the tracker already has the map; Turn is the
touch point), and make eviction leave a tombstone or start recreated sessions at a
generation no in-flight job can hold.
S11 — Pool.Stop() blocks for as long as the in-flight job takes
modes/pool.go:191-201 cancels then wg.Wait()s. A running job that does not poll ctx
— and the cheap-model HTTP client has a 5-minute timeout — holds Stop for its full
duration. Reproduced: Stop blocked 1.5 s on a time.Sleep job. main.go does
defer h.Close(), so process shutdown inherits it. TestStopLeaksNoGoroutines waits for
the job to finish before calling Stop, so it never exercises this. Suggest a bounded
wait in Stop (the jobs are pure savings; abandoning one is already the documented
trade) and a test that stops mid-flight.
Minor
store/buffer.go:60-70—Sticky()callsb.Base.Sticky(session)before taking
b.mu. Not a race (both are individually locked) but it means a concurrentCommit
can be observed half-applied through this one method. Worth a note; harmless today
because only one worker touches a given buffer.store/buffer.go— an uncommitted Buffer is dropped with its job closure, so no leak.
Verified. But a dropped enqueue never constructs one, and a job cancelled mid-run
leaves the partial writes garbage-collected. Fine — no action, recording that I checked.metrics.go:228-267observeCompis a near-verbatim copy of the second half of
Component. Two copies of the unique-savings attribution will drift. Extract the
shared body and pass the target map.Aggregator.Runreturns early onr.Deferredbefore the observe branch, so a
deferred observe run (can't happen today — observe never sets Deferred) would silently
vanish. Order these the same way as inComponentfor consistency.docs/how-to/operating-modes.md:85-89anddocs/design.mdstate the S6 invariant as
implemented. They need to match whatever S6 resolves to.- Docs: I could not run
mkdocs build --strict(mkdocs is not installed on this box), so
the green claim is taken on trust. Nav entries and the relative link from
reference/config.mdcheck out by inspection.
What is genuinely good
Trackeris correct as a mutual-exclusion primitive;CommitIfCurrentrunning the
commit under the generation lock is the right shape and I could not break it.Pool's claim-before-observable dedup is real — the slot is taken undermubefore
the channel send, and rolled back on a full queue. Atomic as advertised.- Panic containment verified:
runOne'srecoveris inside the deferred func that also
releases the slot, so a panicking job neither kills the worker nor leaks its key. - Structural observe byte-identity (never running the pipeline, skipping
expand.Inject)
is the right design, andTestObserveForwardsByteIdenticalBodygenuinely asserts it. syncbyte-identity: verified, andTestCompactEndpointIgnoresModecovers the
/compactregression risk.adapters/bifroststill goes throughBodyFull, whose
shim is a faithful translation. No security issues found — no credential, token, or
gateway URL in the new code, tests, docs, or PR body.
Bottom line
The async machinery is well built. What it is doing is not what the PR says, in three
places that matter: the cache policy has holes on its primary target workload (S1–S4),
the stale-result invariant is not the one implemented (S6), and two headline numbers are
tautological (S5, S7). S1, S2, S3, S6 and S7 should block; S8–S11 are fixable follow-ups
if scoped. Every finding above has a reproduction I ran on this branch and can hand over.
Correction to my own earlier comment — the cache-write result is not yet causalI verified the per-task pairing above and concluded the direction "survives per-task pairing on both benchmarks." That was premature, and the independent review has a better explanation than mine. The reviewer found (and I confirmed at Combined with two more findings — every async session's first turn places zero breakpoints ( So my "both tasks move the same direction, including the one whose step count nearly doubled" observation is still factually true, but it does not support the causal reading I gave it. Two arms can both show lower normalised cache-write for a reason that has nothing to do with the cache policy working. I should have checked whether the policy actually fired before treating the outcome as evidence that it did — the same discipline I applied to the degenerate-baseline and Revised position: keep the numbers, relabel them as not-yet-causal, and re-measure after S1–S4 are fixed. The latency result (1,599 ms → 25 ms) is unaffected and stands on its own — that one doesn't depend on the cache policy at all. Also retracting my characterisation of This is the fourth premise in this workstream to fall to "check whether the mechanism fired before believing the outcome" — after |
… its counters Review found the concurrency primitives sound but the semantics wrong in six places, including three where the code and the documentation disagreed — worse than a bug, because the docs were the specification. S1: the tail protection was INERT on the primary workload. It pruned only the positions cacheinject wanted, never breakpoints the caller set — and claude-code sets its own on the newest message, inside exactly the span a pending compaction replaces. The doomed tail was cache-written anyway, so async paid the 11.5x rewrite AND lost a slot: strictly worse than sync while reporting success. It now either strips those (async.strip_caller_breakpoints) or declines the turn via DeclineTailProtection, and the host then does not defer (async_tail_unprotected_turns). Declining is the default because overriding a directive an agent deliberately placed is a change to someone else's request. S6: the generation advanced only on commit, so a job from turn 1 read its own generation as current after any number of later turns and committed against a transcript long since replaced. The guard could only ever fire on a dedup collision, never on staleness — the documented invariant was not the implemented one. It now advances per TURN. The honest consequence is that async discards much of what it computes at agent turn rates; stale_discarded is how you see it, and the docs now say so instead of calling it a tuning nit. S7: async_realized_saved_tokens was a tautology, recorded on every async turn that saved anything with no check the saving came from deferred work. It re-reported the inline saving, so "realized == total saved" was true by construction. Now gated on the session having had a compaction land, with a test asserting a STRICT subset. S2/S3/S4: the protection also fired when there was nothing to protect. A session's first turn placed zero breakpoints (prevLen 0 blocked everything, on precisely the turn that must establish the cache — an existing test asserted this as correct and encoded the bug); cache_mode: off suppressed breakpoints forever; and the span was off by one turn, guarding this turn's new messages rather than the previous turn's tail that the pending job actually replaces. Turn accounting also moved out of the cache-aware branch, since a turn happens whether or not the backend caches. Follow-ups in the same pass: observe's real off-path model spend is labelled rather than hidden (S8); a session producing repeated unproductive jobs stops buying cheap-model calls (S9); eviction seeds a recreated session above every generation ever issued so a surviving in-flight job cannot commit over it (S10); Stop bounds its wait at 2s instead of inheriting the cheap model's 5-minute client timeout, which main.go's deferred Close would otherwise hang on (S11); and store.Buffer forwards the optional FrozenLost capability structurally, so #40's signal is not disabled by the wrapper. Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
Review response — all seven blocking findings fixed, plus the four follow-upsThanks — this was a genuinely good review. The three where the code and the docs I reproduced S1, S2, S6 and S7 before touching anything. S1 gave me The three code/doc disagreementsS1 — protection was inert on the primary workload. Fixed. The loop now also covers S6 — the generation now advances per turn. This was the deepest one. Fixing it I also had to make The honest consequence is now in the docs rather than buried: at agent turn rates a S7 — tautology gone. Gated on S2/S3/S4All three were the same root shape — the protection firing with nothing to protect — so You were right that a test encoded S2. S5 — claim withdrawnAgreed, and it's worse than unpaired arms: given S1 the mechanism wasn't running, so the Follow-ups
Gates (re-run after the fixes)
One thing I'm leavingAsync's value is now visibly conditional: inert on claude-code unless |
Closes #31.
Compaction was unconditionally synchronous, which forced two bad trades: to get
savings you accepted ~450 ms/req of latency, and to find out whether context-guru
helps your workload you had to enforce it in production and compare against
history. This adds an explicit
mode:with three settings.syncremains the default and is byte-identical to today.The three modes
sync(default)asyncobserveThe hard part: async's cache requirement
A cache-write costs 11.5x a cache-read (
($2.50 − $0.20)/$0.20). So a naiveasync implementation is strictly worse than sync: it lets the un-compacted tail
get provider-cached, then replaces it when the compaction lands, and the provider
re-writes a span it had committed to. That is exactly what tripled headroom's
cache-write on Terminal-Bench (12.37M vs 4.01M baseline) by rewriting the live zone.
By default no breakpoint is placed at or beyond the tail a pending compaction will
replace:
cacheinjectdrops those positions and anchors at the highest safe indexinstead, so the whole stable prefix is still written and nothing the provider
commits to is later rewritten.
async.cache_uncompacted_tail: trueis the escapehatch for a backend confirmed not to cache, where the protection buys nothing.
The protection needed its own bool rather than a sentinel index: index 0 is a
legitimate value ("no breakpoint anywhere"), so no integer is free to mean "off".
A false default also makes an unset field cost a missed optimisation rather than a
wrong request — the opposite of
MaxCachedIdx's-1(see #25).Generations: the invariant everything rests on
An async result lands at an unpredictable later moment, by which time the agent may
have taken another turn and another job may have committed. Applying a result built
from a superseded snapshot is how a compaction proxy corrupts a cached prefix.
modes.Trackerholds, per session under one lock, the compaction generation — whichadvances on every turn — and the cached-prefix boundary. A request records the
generation it was built from; a deferred job writes into a
store.Buffer(copy-on-writeover the real store) and that buffer is committed only if the session is still on that
turn — checked under the same lock that advances it. A stale result is discarded and
counted as
stale_discarded.The buffer is what makes "discard" possible at all: a deferred run writes frozen
decisions and stashes as it goes, so throwing the result away after the fact is not
an option. Running it against an overlay makes the whole result one atomic,
discardable unit —
Commit()flushes it, and not callingCommit()is the discard.The worker pool
One bounded queue plus a fixed worker count owned by the proxy, not a goroutine per
request. The shape is headroom's
BackgroundCompressor, ported and extended:(session, generation), pending slot claimed before the job isobservable in the queue, so dedup is atomic against a concurrent enqueue;
response is written);
droppedandstale_discardedincluded.headroom's dashboard shows only
queued, hiding precisely the "we silently gave upsavings" counters.
Observe: measure without enforcing
Byte-identity is structural, not a property of careful copying: the request path
never touches the pipeline, and also skips
expand.Inject— injecting a tooldeclaration would be a modification.
Observe results live in physically separate accumulators serialized under
potential_*/projected_*, sharing no key with any enforced metric. In observemode every enforced aggregate is zero by construction, which is the machine-readable
form of "context-guru did not modify requests". A mislabelled hypothetical silently
inflates the headline savings claim, so this is a correctness boundary and a test
asserts no enforced aggregate can reach an observe result.
headroom has no observe/shadow/dry-run mode at all — its
tokenandcachemodesare both enforcing — so this is a genuine differentiator rather than a port.
Two bugs the benchmark surfaced
Worth calling out, because both were found by running it rather than reading it:
async, sotheir savings entered the enforced rollups even though nothing was forwarded — and
entered again when a later turn replayed the decision on-path.
Report/RunReportnow carry
Deferredand the aggregator drops those; off-path work shows up asasync_deferred_runs/async_deferred_ms_total/ the queue tuple, and savings arecredited only where they were realized.
cacheinjecton a deferred run corrupted turn state. Its per-messagedivergence digests are turn state, and a deferred job commits some turns after the
one it was built from, so committing its digests replayed turn N's over turn N+2's
and made the next turn compute the wrong divergence point. It now skips deferred
runs entirely (its breakpoints were pointless there anyway — the body is discarded).
/statsAdditive only; every pre-existing field keeps its name and shape, because
deploy/harbor/*.pyparses this payload (a test pins the full pre-existing key set).New:
mode,sync_enforced,async_enforced,async_queue(the full tuple),async_deferred_runs,async_deferred_ms_total,async_realized_saved_tokens, andthe observe hypotheticals.
Overlap with #25
prevLenwas read then written back in adefer, so two concurrent turns of onesession raced on it.
Tracker.Turnnow reads the previous length and records the newone in a single locked call. That is the minimal form of the same fix #25 owns; if
#25 lands first, this reduces to using its state instead. Merge-order note: I did
not touch #25's files. The legacy store-backed path is left intact for library
callers and
/compact, which supply no tracker.Tests
go test -racethroughout (the whole suite, not just the new packages):syncbyte-identical to the legacy entry point, and the unset mode identical toexplicit
sync(golden);generation apply exactly once;
prevLennor the generation —through the tracker and through the real handler;
Pool.StoporHandler.Close, both idempotent;escape hatch restores normal placement;
counted as neither enforced nor hypothetical;
realizing 4,948 tokens across five turns of one session).
CGO_ENABLED=1 go build/test -tags cg_skeleton ./...green,gofmt -lclean,go vetclean,mkdocs build --strictgreen.Docs
New
docs/how-to/operating-modes.md(when to use each, the cache arithmetic, how toread the queue counters and the observe numbers — including what observe cannot
tell you: cache effects are projected not measured, no expand bounce is exercised,
and off-path measurement still spends cheap-model tokens).
docs/design.mdgainsgenerations, the job lifecycle, the cache policy and fail-open per mode.
docs/reference/config.md,mkdocs.yml, README.Review corrections (read before the benchmark comments)
An independent review found six semantic defects — the concurrency primitives held, the
meaning did not. Three were places where the code and these docs disagreed, which is
worse than a bug because the docs were the spec. All six are fixed here; the two benchmark
comments below predate the fixes, and two of their claims are retracted:
as measurements, but the tail protection was inert on that workload, and two further
defects (zero breakpoints on a session's first turn; total suppression under
cache_mode: off) lower cache-write for arithmetic rather than policy reasons. The armswere also unpaired — 49% more cache-read, 6 mean steps apart, async re-run separately
after a port collision. Mechanism is now verified by test; the effect needs a 50-task
paired re-run with
strip_caller_breakpoints: true.async_realized_saved_tokens= 15,962 "the entire enforced saving" is retracted. Thecounter fired on every async turn that saved anything, so it re-reported the inline
saving and equality with the total was guaranteed by construction. Now gated on a
compaction having actually landed, with a test asserting a strict subset. Corrected
measurement:
realized=4125ofsaved=4948, with one legitimate stale discard.Also fixed: the protected span was off by one turn (guarding this turn's new messages
rather than the previous turn's tail that the pending job actually replaces); observe's real
off-path model spend is labelled rather than implied hypothetical; unproductive sessions
stop buying cheap-model calls; eviction can no longer let a surviving in-flight job commit
over a recreated session;
Stop()is bounded at 2s instead of inheriting the cheap model's5-minute client timeout (which
main.go's deferredClose()would hang on); andstore.Bufferforwards #40's optionalFrozenLoststructurally so the wrapper does notdisable it.
Gates re-run after the fixes: build,
test -tags cg_skeleton,-race -count=5,gofmt -l,go vet,mkdocs build --strict— all clean.docs/results/operating-modes.mdcarries both retractions inline.