3636session can run for hours without OOM. A single agent driven for
37374 hours is therefore the cleanest evidence.
3838
39- A note on what this bench measures and what it doesn't (per the
40- analysis of the 2026-05-30 Mac M4 run, ``bench_long_session_mac_
41- 1780130542.aborted.json``):
42-
43- * **KV memory** stays bounded across hours (the §2.3 claim). The
44- ``scheduler_kv_live_bytes`` gauge is what proves it.
45- * **Per-turn latency** does NOT stay bounded. The OpenAI
46- chat-completions protocol is stateless: every turn the client
47- sends the full history, the server tokenizes it from scratch
48- and the verifier prefills the entire prompt, so prefill cost
49- grows linearly with history length. Sink+window only bounds
50- *generation-phase* memory, not prefill cost. A 30-min run on
51- Mac M4 showed p50 turn latency growing from ~15 s to ~55 s as
52- history grew from ~50 to ~3700 tokens. This is a **protocol-
53- level limitation**, not a memory-stability failure. Cross-
54- request KV reuse (a v0.4 feature) is the eventual fix; until
55- then, agent applications should manage prompt length via
56- summarization or sliding windows.
57-
58- The bench reports both metrics independently — KV bounded check is
59- a hard claim, latency drift is a measurement, not a gate.
39+ What changed in PR 7-6 (ADR 0007 cross-request KV reuse)
40+ --------------------------------------------------------
41+
42+ Before ADR 0007 (the 2026-05-30 4h Mac M4 run produced 58 useful
43+ turns then 3.5h of timeout/recovery): the bench was driving the
44+ v0.3.0-rc1 server, which reset the verifier cache on every
45+ chat-completions request. Per-turn prefill cost grew O(history),
46+ exceeded the 120s client timeout around turn 58, and the run
47+ degenerated.
48+
49+ After ADR 0007 (PRs 7-1 to 7-5): the server now takes the
50+ continuation path on prompts that extend the cached state. Per-
51+ turn prefill cost is O(new_user_message) instead of O(history).
52+ The 4h re-run with the same workload should now produce
53+ hundreds of useful turns and never enter the timeout/recovery
54+ regime.
55+
56+ This bench scrapes the new ADR §2.10 metrics on every turn:
57+
58+ * ``path_selection_total{path=continuation}`` Counter
59+ * ``path_selection_total{path=new_session}`` Counter
60+ * ``continuation_tokens_skipped_total`` Counter
61+ * ``cache_invariant_violations_total{kind=...}`` Counter (must be 0)
62+
63+ The aggregate report now includes an ``adr_0007`` block with:
64+
65+ continuation_decisions int (count this run)
66+ new_session_decisions int
67+ total_decisions int
68+ continuation_rate float in [0, 1]
69+ tokens_skipped int (total prefill tokens reused)
70+ cache_invariant_inv1_total int (must be 0)
71+ cache_invariant_inv2_total int (must be 0)
72+
73+ GA gate criteria (per ADR 0007 §6, applied to the 4h Mac M4 re-run):
74+
75+ 1. ``agg.kv_bounded`` is True (memory bound holds across 4h)
76+ 2. ``agg.n_errors`` < 5 (no sustained timeout/recovery loop)
77+ 3. ``agg.n_turns`` >= 200 (vs 58 in v0.3.0-rc1)
78+ 4. ``agg.latency_drift_p50_s`` <= 5 seconds (vs +39.74s in v0.3.0-rc1)
79+ 5. ``adr_0007.continuation_rate`` >= 0.95
80+ 6. ``adr_0007.cache_invariant_inv1_total + inv2_total`` == 0
6081
6182Usage
6283-----
@@ -166,6 +187,20 @@ def _client_rss_bytes() -> Optional[int]:
166187 "scheduler_kv_live_bytes" ,
167188)
168189
190+ # ADR 0007 §2.10 path-selection metrics — labeled, parsed separately
191+ # from the unlabeled scheduler gauges. continuation_rate is the
192+ # headline KPI for the cross-request KV reuse fix: ≥95% on a healthy
193+ # long-session agent run.
194+ _PATH_SELECTION_METRIC = "path_selection_total"
195+ _CONTINUATION_TOKENS_SKIPPED_METRIC = "continuation_tokens_skipped_total"
196+ _CACHE_INVARIANT_VIOLATIONS_METRIC = "cache_invariant_violations_total"
197+
198+ # Match a Prometheus exposition line with labels: e.g.
199+ # `path_selection_total{path="continuation"} 5.0`
200+ _LABELED_METRIC_LINE = re .compile (
201+ r'^(?P<name>[a-zA-Z_:][a-zA-Z0-9_:]*)\{(?P<labels>[^}]*)\}\s+(?P<value>[-+0-9eE.\.NaNinf]+)\s*$'
202+ )
203+
169204
170205_METRIC_LINE = re .compile (
171206 r"^(?P<name>[a-zA-Z_:][a-zA-Z0-9_:]*)(\{[^}]*\})?\s+(?P<value>[-+0-9eE.\.NaNinf]+)\s*$"
@@ -175,28 +210,75 @@ def _client_rss_bytes() -> Optional[int]:
175210def _parse_prom_text (body : str ) -> dict [str , float ]:
176211 """Tiny Prometheus text parser.
177212
178- Only the four metrics we care about are extracted; everything
179- else is skipped. Values are coerced to float; ``NaN`` and ``inf``
180- are preserved as floats. We do not try to handle multi-label
181- series — these are gauges with no labels in our exporter.
213+ Returns a flat dict where unlabeled metrics keep their bare
214+ name and labeled ADR 0007 §2.10 metrics get a synthesized name
215+ of the form ``"{base}__{label_value}"``:
216+
217+ * ``path_selection_total{path="continuation"}`` →
218+ ``"path_selection_total__continuation"``
219+ * ``path_selection_total{path="new_session"}`` →
220+ ``"path_selection_total__new_session"``
221+ * ``cache_invariant_violations_total{kind="inv1"}`` →
222+ ``"cache_invariant_violations_total__inv1"``
223+ * ``continuation_tokens_skipped_total`` (no label) →
224+ ``"continuation_tokens_skipped_total"``
225+
226+ Values are coerced to float; ``NaN`` and ``inf`` are preserved.
182227 """
183228 out : dict [str , float ] = {}
184229 for line in body .splitlines ():
185230 if not line or line .startswith ("#" ):
186231 continue
232+ # Try labeled match first (more specific).
233+ labeled = _LABELED_METRIC_LINE .match (line )
234+ if labeled is not None :
235+ name = labeled .group ("name" )
236+ label_str = labeled .group ("labels" )
237+ try :
238+ value = float (labeled .group ("value" ))
239+ except ValueError : # pragma: no cover - malformed exporter
240+ continue
241+ if name == _PATH_SELECTION_METRIC :
242+ # Extract the path label (continuation|new_session)
243+ path = _extract_label (label_str , "path" )
244+ if path :
245+ out [f"{ name } __{ path } " ] = value
246+ elif name == _CACHE_INVARIANT_VIOLATIONS_METRIC :
247+ kind = _extract_label (label_str , "kind" )
248+ if kind :
249+ out [f"{ name } __{ kind } " ] = value
250+ continue
251+ # Unlabeled.
187252 m = _METRIC_LINE .match (line )
188253 if m is None :
189254 continue
190255 name = m .group ("name" )
191- if name not in _METRIC_NAMES :
192- continue
193- try :
194- out [name ] = float (m .group ("value" ))
195- except ValueError : # pragma: no cover - malformed exporter
196- continue
256+ if name in _METRIC_NAMES or name == _CONTINUATION_TOKENS_SKIPPED_METRIC :
257+ try :
258+ out [name ] = float (m .group ("value" ))
259+ except ValueError : # pragma: no cover - malformed exporter
260+ continue
197261 return out
198262
199263
264+ def _extract_label (label_str : str , key : str ) -> Optional [str ]:
265+ """Pull a single label value out of a Prometheus label segment.
266+
267+ Input is the part between the curly braces, e.g.
268+ ``path="continuation"`` or
269+ ``path="continuation",result="ok"``. Returns the value (without
270+ quotes) for the requested key, or ``None`` if absent.
271+ """
272+ for fragment in label_str .split ("," ):
273+ fragment = fragment .strip ()
274+ if "=" not in fragment :
275+ continue
276+ k , v = fragment .split ("=" , 1 )
277+ if k .strip () == key :
278+ return v .strip ().strip ('"' )
279+ return None
280+
281+
200282async def _scrape_metrics (
201283 client : httpx .AsyncClient ,
202284 * ,
@@ -669,6 +751,58 @@ def _aggregate(
669751 }
670752
671753
754+ def _adr_0007_summary (turns : list [dict [str , Any ]]) -> dict [str , Any ]:
755+ """ADR 0007 §2.10 path-selection summary from the per-turn idle
756+ scrapes (`metrics_idle`).
757+
758+ The path_selection_total counters are CUMULATIVE across the
759+ server's lifetime, so we take the LAST observed value (= final
760+ counter at end of run) and the FIRST observed value (= counter
761+ at start of run, may be > 0 if the server already handled
762+ requests before this bench started). Difference = decisions
763+ this run made.
764+
765+ Returns a dict with:
766+ continuation_decisions int
767+ new_session_decisions int
768+ total_decisions int
769+ continuation_rate float in [0, 1] (None if 0 decisions)
770+ tokens_skipped int (delta of the counter)
771+ cache_invariant_inv1_total int (last - first; should be 0)
772+ cache_invariant_inv2_total int (last - first; should be 0)
773+ """
774+ def _delta (name : str ) -> int :
775+ first : Optional [float ] = None
776+ last : Optional [float ] = None
777+ for t in turns :
778+ idle = t .get ("metrics_idle" ) or {}
779+ v = idle .get (name )
780+ if v is None :
781+ continue
782+ if first is None :
783+ first = v
784+ last = v
785+ if first is None or last is None :
786+ return 0
787+ return int (round (last - first ))
788+
789+ cont = _delta (f"{ _PATH_SELECTION_METRIC } __continuation" )
790+ news = _delta (f"{ _PATH_SELECTION_METRIC } __new_session" )
791+ skipped = _delta (_CONTINUATION_TOKENS_SKIPPED_METRIC )
792+ inv1 = _delta (f"{ _CACHE_INVARIANT_VIOLATIONS_METRIC } __inv1" )
793+ inv2 = _delta (f"{ _CACHE_INVARIANT_VIOLATIONS_METRIC } __inv2" )
794+ total = cont + news
795+ return {
796+ "continuation_decisions" : cont ,
797+ "new_session_decisions" : news ,
798+ "total_decisions" : total ,
799+ "continuation_rate" : (cont / total ) if total > 0 else None ,
800+ "tokens_skipped" : skipped ,
801+ "cache_invariant_inv1_total" : inv1 ,
802+ "cache_invariant_inv2_total" : inv2 ,
803+ }
804+
805+
672806def _build_payload (
673807 * ,
674808 turns : list [dict [str , Any ]],
@@ -687,6 +821,7 @@ def _build_payload(
687821 "turns" : turns ,
688822 "errors" : errors ,
689823 "agg" : _aggregate (turns , errors , duration_s ),
824+ "adr_0007" : _adr_0007_summary (turns ),
690825 }
691826
692827
@@ -745,6 +880,42 @@ def _fmt_bytes(b: Optional[float]) -> str:
745880 f"{ b ['p95_latency_s' ]:>7.3f} s "
746881 f"{ (mkb / (1024 * 1024 )) if mkb is not None else float ('nan' ):>7.1f} MiB"
747882 )
883+ # ADR 0007 §2.10 path-selection block (only emitted when the
884+ # payload actually carries the data — backward-compat with old
885+ # checkpoints that pre-date PR 7-6).
886+ adr_0007 = payload .get ("adr_0007" )
887+ if adr_0007 is not None :
888+ rate = adr_0007 .get ("continuation_rate" )
889+ rate_str = (
890+ f"{ rate * 100 :.2f} %" if rate is not None else "n/a (no decisions)"
891+ )
892+ lines .append ("" )
893+ lines .append (" ADR 0007 §2.10 — cross-request KV reuse" )
894+ lines .append (
895+ f" continuation decisions = "
896+ f"{ adr_0007 ['continuation_decisions' ]} "
897+ )
898+ lines .append (
899+ f" new-session decisions = "
900+ f"{ adr_0007 ['new_session_decisions' ]} "
901+ )
902+ lines .append (f" continuation rate = { rate_str } " )
903+ lines .append (
904+ f" total tokens skipped = "
905+ f"{ adr_0007 ['tokens_skipped' ]:,} "
906+ )
907+ inv1 = adr_0007 ["cache_invariant_inv1_total" ]
908+ inv2 = adr_0007 ["cache_invariant_inv2_total" ]
909+ inv_marker = "" if (inv1 == 0 and inv2 == 0 ) else " ← CRITICAL"
910+ lines .append (
911+ f" INV-1 violations = { inv1 } "
912+ + (inv_marker if inv1 > 0 else "" )
913+ )
914+ lines .append (
915+ f" INV-2 violations = { inv2 } "
916+ + (inv_marker if inv2 > 0 else "" )
917+ )
918+
748919 lines .append ("=" * 78 )
749920 return "\n " .join (lines )
750921
0 commit comments